diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..32d5e61 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/Scripts/python.exe", + "python.analysis.typeCheckingMode": "basic", + "python.analysis.extraPaths": [ + "${workspaceFolder}/src" + ], + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": [ + "tests" + ], + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 247f2ef..d13a823 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,8 +5,16 @@ ## 開発進捗 -現在の進捗: フェーズ 1〜6 完了.フェーズ 7(実装開始)に移行. -次のアクション: `/implement Step 1: 均一性評価モジュールの移植`([PLAN_02 参照](docs/03_PLAN/PLAN_02_開発ステップ.md)) +現在の進捗: フェーズ 7(実装)— Step 1〜3 完了. +次のアクション: `/implement Step 4: シーン構築 + 最小レンダー`([PLAN_02 参照](docs/03_PLAN/PLAN_02_開発ステップ.md)) + +実装済みモジュール: +- `src/sm_tias_lightsim/uniformity.py` — Rec.709 輝度変換 + CoV/std/max-min(MiniTias-Evaluation コピー) +- `src/sm_tias_lightsim/evaluation/zones.py` — 正規化楕円距離マップ + 3 ゾーン分類 + ゾーン別統計 +- `src/sm_tias_lightsim/evaluation/radial.py` — 20 ビン放射状輝度プロファイル +- `src/sm_tias_lightsim/scene/schema.py` — Pydantic ベース YAML スキーマ + ローダ +- `src/sm_tias_lightsim/geometry/loader.py` — STL 読込(trimesh)+ `LoadedMesh` データクラス +- `src/sm_tias_lightsim/geometry/transforms.py` — mm⇄m 単位スケール変換 ※ ステップ完了時にここを更新すること. ## 必須ルール(コード実装時) diff --git "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" index 8938b3c..aa44a9d 100644 --- "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" +++ "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" @@ -56,6 +56,8 @@ | シミュレータ | `mitsuba` | 光線追跡コア | | 数値計算 | `numpy` | 配列計算(評価指標と共用) | | ジオメトリ | `trimesh` | STL の読み込み・三角形メッシュ操作 | +| シーン定義 | `pydantic` | YAML スキーマのバリデーション | +| 設定ファイル | `pyyaml` | YAML パース | | 画像 I/O | `pillow` | PNG 出力(実機画像と同形式に揃える) | | 可視化 | `matplotlib` | 輝度マップ・ヒストグラム・放射状プロファイル等 | | 表データ | `pandas` | 均一性指標 CSV の生成・統計集計 | diff --git a/pyproject.toml b/pyproject.toml index 7e0940c..8dd8137 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,8 @@ "numpy>=2.4.4", "pandas>=3.0.2", "pillow>=12.2.0", + "pydantic>=2.13.3", + "pyyaml>=6.0.3", "trimesh>=4.12.1", ] diff --git a/src/sm_tias_lightsim/evaluation/__init__.py b/src/sm_tias_lightsim/evaluation/__init__.py new file mode 100644 index 0000000..e99c63a --- /dev/null +++ b/src/sm_tias_lightsim/evaluation/__init__.py @@ -0,0 +1 @@ +# evaluation パッケージ — 空間分析・放射状プロファイル等の評価モジュール群 diff --git a/src/sm_tias_lightsim/evaluation/radial.py b/src/sm_tias_lightsim/evaluation/radial.py new file mode 100644 index 0000000..9987734 --- /dev/null +++ b/src/sm_tias_lightsim/evaluation/radial.py @@ -0,0 +1,54 @@ +"""放射状輝度プロファイルモジュール. + +ROI の中心からの正規化楕円距離を等分割したビンごとに平均輝度を集計し, +中心-周辺方向の輝度変化を連続的に把握するためのプロファイルを生成する. + +References: + [1] MiniTias-Evaluation SPEC_02 「放射状輝度プロファイル」節 + https://github.com/rintoHasegawa/MiniTias-Evaluation +""" + +import numpy + +from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map + + +def calc_radial_profile( + luminance: numpy.ndarray, + n_bins: int = 20, +) -> numpy.ndarray: + """輝度画像から放射状輝度プロファイルを算出する. + + 正規化楕円距離 $d \\in [0, 1]$ を `n_bins` 等分のビンに分割し,各ビン内の + 平均輝度を集計する [1].最外ビンは $d = 1.0$ の画素を欠損させないよう上端を含める. + + Args: + luminance: 輝度画像(shape: (H, W), dtype: float64). + n_bins: ビン数.デフォルトは 20. + + Returns: + 放射状プロファイル配列(shape: (n_bins, 2), dtype: numpy.float64). + 列 0: ビン中心距離(ビン両端の中点). + 列 1: そのビンの平均輝度.空ビンは numpy.nan. + """ + height, width = luminance.shape + distance_map = compute_normalized_distance_map(height, width) + + # [0, 1] を n_bins 等分するビン境界を生成する + bin_edges = numpy.linspace(0.0, 1.0, n_bins + 1) + bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2.0 + + # numpy.digitize でビンインデックスを割り当てる(1 始まり, 右端開区間) + # 最外ビン(d == 1.0)が n_bins + 1 に割り振られないよう right=True を使用する + bin_indices = numpy.digitize(distance_map.ravel(), bin_edges, right=True) + flat_luminance = luminance.ravel() + + mean_luminance = numpy.full(n_bins, numpy.nan, dtype=numpy.float64) + for i in range(1, n_bins + 1): + mask = bin_indices == i + if numpy.any(mask): + mean_luminance[i - 1] = float(numpy.mean(flat_luminance[mask])) + # 空ビンは初期値 nan のまま維持する + + profile = numpy.column_stack([bin_centers, mean_luminance]) + return profile diff --git a/src/sm_tias_lightsim/evaluation/zones.py b/src/sm_tias_lightsim/evaluation/zones.py new file mode 100644 index 0000000..bb69855 --- /dev/null +++ b/src/sm_tias_lightsim/evaluation/zones.py @@ -0,0 +1,142 @@ +"""3 ゾーン空間分析モジュール. + +ROI を中心・中間・周辺の 3 同心楕円ゾーンに分割し,各ゾーンの輝度統計を算出する. + +References: + [1] MiniTias-Evaluation SPEC_02 「空間分析」節 + https://github.com/rintoHasegawa/MiniTias-Evaluation +""" + +import numpy + + +def compute_normalized_distance_map(height: int, width: int) -> numpy.ndarray: + """ROI の各ピクセルについて,中心からの正規化楕円距離マップを算出する. + + 正規化楕円距離 $d(x, y)$ を以下の式で定義する [1]: + + $$d(x, y) = \\sqrt{ + \\left(\\frac{x - c_x}{c_x}\\right)^2 + \\left(\\frac{y - c_y}{c_y}\\right)^2 + }$$ + + ここで $c_x = W/2$, $c_y = H/2$ は ROI の中心座標. + 楕円距離を用いることで,縦横比が異なる ROI でも等方的な評価が可能となる. + + 算出後,値域を $[0, 1]$ に再正規化する($d \\leftarrow d / d_{\\max}$). + + Args: + height: ROI の高さ(ピクセル数). + width: ROI の幅(ピクセル数). + + Returns: + 正規化距離マップ(shape: (height, width), dtype: numpy.float64). + 値域は [0, 1]. + """ + cx = width / 2.0 + cy = height / 2.0 + + # 各ピクセル座標をグリッドで生成する(ブロードキャストのため列ベクトル・行ベクトル) + ys = numpy.arange(height, dtype=numpy.float64).reshape(-1, 1) + xs = numpy.arange(width, dtype=numpy.float64).reshape(1, -1) + + distance_map = numpy.sqrt(((xs - cx) / cx) ** 2 + ((ys - cy) / cy) ** 2) + + # d_max で除算して [0, 1] に正規化する + d_max = float(numpy.max(distance_map)) + if d_max > 0.0: + distance_map = distance_map / d_max + + return distance_map + + +def classify_zones( + distance_map: numpy.ndarray, + boundaries: tuple[float, float] = (0.33, 0.66), +) -> numpy.ndarray: + """正規化距離マップを 3 ゾーンのラベルマップに変換する. + + SPEC_02 の 3 ゾーン分類に従い,以下の閾値でラベルを割り当てる [1]: + + | ゾーン | ラベル | 距離条件 | + |------------|--------|---------------------------------------| + | Center | 0 | $d < \\text{boundaries}[0]$ | + | Middle | 1 | $\\text{boundaries}[0] \\leq d < \\text{boundaries}[1]$ | + | Periphery | 2 | $d \\geq \\text{boundaries}[1]$ | + + Args: + distance_map: 正規化距離マップ(shape: (H, W), dtype: float). + boundaries: ゾーン境界の閾値ペア (lower, upper).デフォルトは (0.33, 0.66). + + Returns: + ゾーンラベルマップ(shape: (H, W), dtype: numpy.int8). + 値は 0(center), 1(middle), 2(periphery)のいずれか. + """ + lower, upper = boundaries + zone_map = numpy.full(distance_map.shape, 2, dtype=numpy.int8) + zone_map[distance_map < lower] = 0 + zone_map[(distance_map >= lower) & (distance_map < upper)] = 1 + return zone_map + + +def calc_zone_stats( + luminance: numpy.ndarray, + zone_map: numpy.ndarray, +) -> dict: + """各ゾーンの輝度統計と空間分析指標を算出する. + + ゾーン別に平均輝度・標準偏差を算出し,中心/周辺比と勾配量 (%) を導出する [1]: + + $$\\text{C/P Ratio} = \\frac{\\mu_{center}}{\\mu_{periphery}}$$ + + $$\\text{Gradient} = \\frac{\\mu_{center} - \\mu_{periphery}}{\\mu_{center}} \\times 100$$ + + Args: + luminance: 輝度画像(shape: (H, W), dtype: float64). + zone_map: ゾーンラベルマップ(shape: (H, W), dtype: int8). + 0=center, 1=middle, 2=periphery. + + Returns: + 空間分析指標を格納した辞書.以下の 8 キーを含む: + - center_mean: 中心ゾーンの平均輝度 + - center_std: 中心ゾーンの標準偏差 + - middle_mean: 中間ゾーンの平均輝度 + - middle_std: 中間ゾーンの標準偏差 + - periphery_mean: 周辺ゾーンの平均輝度 + - periphery_std: 周辺ゾーンの標準偏差 + - center_periphery_ratio: 中心/周辺比 (μ_center / μ_periphery) + - gradient_magnitude: 中心-周辺勾配量 (%) + + Raises: + ValueError: 周辺ゾーンの平均輝度が 0 の場合(ROI 設定の確認を促す). + """ + center_pixels = luminance[zone_map == 0] + middle_pixels = luminance[zone_map == 1] + periphery_pixels = luminance[zone_map == 2] + + center_mean = float(numpy.mean(center_pixels)) + center_std = float(numpy.std(center_pixels)) + middle_mean = float(numpy.mean(middle_pixels)) + middle_std = float(numpy.std(middle_pixels)) + periphery_mean = float(numpy.mean(periphery_pixels)) + periphery_std = float(numpy.std(periphery_pixels)) + + # 周辺輝度が 0 の場合は中心/周辺比が計算不能(ROI 設定の誤りを示す) + if periphery_mean == 0.0: + raise ValueError( + "周辺ゾーンの平均輝度が 0 のため中心/周辺比を計算できません." + "ROI の設定を確認してください." + ) + + center_periphery_ratio = center_mean / periphery_mean + gradient_magnitude = (center_mean - periphery_mean) / center_mean * 100.0 + + return { + "center_mean": center_mean, + "center_std": center_std, + "middle_mean": middle_mean, + "middle_std": middle_std, + "periphery_mean": periphery_mean, + "periphery_std": periphery_std, + "center_periphery_ratio": center_periphery_ratio, + "gradient_magnitude": gradient_magnitude, + } diff --git a/src/sm_tias_lightsim/geometry/__init__.py b/src/sm_tias_lightsim/geometry/__init__.py new file mode 100644 index 0000000..81752ba --- /dev/null +++ b/src/sm_tias_lightsim/geometry/__init__.py @@ -0,0 +1,6 @@ +"""geometry パッケージ — STL 読み込みと単位変換.""" + +from sm_tias_lightsim.geometry.loader import LoadedMesh, load_stl +from sm_tias_lightsim.geometry.transforms import mm_to_m, scale_vertices + +__all__ = ["LoadedMesh", "load_stl", "mm_to_m", "scale_vertices"] diff --git a/src/sm_tias_lightsim/geometry/loader.py b/src/sm_tias_lightsim/geometry/loader.py new file mode 100644 index 0000000..4283c92 --- /dev/null +++ b/src/sm_tias_lightsim/geometry/loader.py @@ -0,0 +1,74 @@ +"""STL ファイル読み込みモジュール. + +trimesh を使って STL を読み込み,頂点・面・単位情報を LoadedMesh として返す. +""" + +from __future__ import annotations + +import pathlib +from dataclasses import dataclass +from typing import Literal + +import numpy +import trimesh + + +@dataclass(frozen=True) +class LoadedMesh: + """STL から読み込んだメッシュ情報. + + Attributes: + vertices: 頂点配列 (N_v, 3), float64.座標は読み込み時の単位そのまま. + faces: 三角形面配列 (N_f, 3), int64.各値は vertices の行インデックス. + unit: 元 STL の単位("mm" または "m").scale_vertices で変換するときの参照. + source_path: 元ファイルのパス(デバッグ用). + """ + + vertices: numpy.ndarray + faces: numpy.ndarray + unit: Literal["mm", "m"] + source_path: pathlib.Path + + +def load_stl(path: str | pathlib.Path, unit: Literal["mm", "m"]) -> LoadedMesh: + """STL ファイルを trimesh で読み込み LoadedMesh として返す. + + trimesh は STL の単位情報を持たないため,呼び出し側が `unit` を明示する必要がある. + バイナリ・ASCII の両形式は trimesh が自動判別するため,特別な処理は不要. + + Args: + path: STL ファイルパス. + unit: STL の単位指定.SPEC_02 の `geometry.meshes[].unit` と一致する. + + Returns: + 頂点・面・単位を保持する LoadedMesh. + + Raises: + FileNotFoundError: ファイルが存在しない場合. + ValueError: STL のパースに失敗した場合,または三角形メッシュでない場合. + """ + resolved = pathlib.Path(path).resolve() + + if not resolved.exists(): + msg = f"STL file not found: {resolved}" + raise FileNotFoundError(msg) + + loaded = trimesh.load_mesh(str(resolved), file_type="stl") + + # trimesh.load_mesh は複数メッシュの場合 Scene を返す場合がある + if not isinstance(loaded, trimesh.Trimesh): + msg = ( + f"Expected a single Trimesh, got {type(loaded).__name__}. " + "Multi-mesh STL files are not supported." + ) + raise ValueError(msg) + + vertices = loaded.vertices.astype(numpy.float64) + faces = loaded.faces.astype(numpy.int64) + + return LoadedMesh( + vertices=vertices, + faces=faces, + unit=unit, + source_path=resolved, + ) diff --git a/src/sm_tias_lightsim/geometry/transforms.py b/src/sm_tias_lightsim/geometry/transforms.py new file mode 100644 index 0000000..cf2846f --- /dev/null +++ b/src/sm_tias_lightsim/geometry/transforms.py @@ -0,0 +1,59 @@ +"""座標変換ユーティリティモジュール. + +単位変換(mm ↔ m)および LoadedMesh への適用を提供する. +座標系の回転・並進は Step 4(シーン構築)で実装する. +""" + +from __future__ import annotations + +import dataclasses +from typing import Literal + +import numpy + +from sm_tias_lightsim.geometry.loader import LoadedMesh + +_MM_PER_M = 1000.0 +_M_PER_MM = 0.001 + + +def mm_to_m(value: numpy.ndarray | float) -> numpy.ndarray | float: + """ミリメートル単位の数値配列をメートル単位に変換する. + + Args: + value: スカラまたは ndarray(任意 shape). + + Returns: + 入力と同じ shape の値.スカラなら float 1 つ,ndarray なら新しい配列. + """ + return value * _M_PER_MM + + +def scale_vertices(mesh: LoadedMesh, target_unit: Literal["mm", "m"]) -> LoadedMesh: + """LoadedMesh の頂点座標を指定単位にスケール変換した新インスタンスを返す. + + frozen dataclass の不変性を維持するため,常に新規インスタンスを返す. + 元と同じ単位が指定された場合も頂点配列はコピーされる(関数の純粋性のため). + + Args: + mesh: 元のメッシュ. + target_unit: 変換先の単位. + + Returns: + 頂点が target_unit にスケールされた新 LoadedMesh. + unit フィールドは target_unit に更新される. + + Raises: + ValueError: 未対応の unit の組み合わせが指定された場合. + """ + if mesh.unit == target_unit: + new_vertices = mesh.vertices.copy() + elif mesh.unit == "mm" and target_unit == "m": + new_vertices = mesh.vertices * _M_PER_MM + elif mesh.unit == "m" and target_unit == "mm": + new_vertices = mesh.vertices * _MM_PER_M + else: + msg = f"Unsupported unit conversion: '{mesh.unit}' -> '{target_unit}'" + raise ValueError(msg) + + return dataclasses.replace(mesh, vertices=new_vertices, unit=target_unit) diff --git a/src/sm_tias_lightsim/scene/__init__.py b/src/sm_tias_lightsim/scene/__init__.py new file mode 100644 index 0000000..06e1e16 --- /dev/null +++ b/src/sm_tias_lightsim/scene/__init__.py @@ -0,0 +1,8 @@ +"""scene パッケージ — YAML スキーマ定義・シーン構築モジュール群.""" + +from sm_tias_lightsim.scene.schema import ( + SceneConfig, + load_scene_yaml, +) + +__all__ = ["SceneConfig", "load_scene_yaml"] diff --git a/src/sm_tias_lightsim/scene/schema.py b/src/sm_tias_lightsim/scene/schema.py new file mode 100644 index 0000000..47bf488 --- /dev/null +++ b/src/sm_tias_lightsim/scene/schema.py @@ -0,0 +1,325 @@ +"""YAML シーン定義スキーマ(Pydantic モデル). + +SPEC_02_シーン定義仕様 に準拠したモデル群と,YAML ファイルローダを提供する. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Annotated, Literal + +import yaml +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +# --------------------------------------------------------------------------- +# 共通基底・型エイリアス +# --------------------------------------------------------------------------- + + +# 全モデルで frozen=True を共有するため基底クラスに集約する +class _FrozenBase(BaseModel): + model_config = ConfigDict(frozen=True) + + +# 3D ベクトル(mm 座標・方向ベクトル・RGB 輝度)の共通型 +Vec3 = tuple[float, float, float] + + +# --------------------------------------------------------------------------- +# Film / Sensor +# --------------------------------------------------------------------------- + + +class FilmConfig(_FrozenBase): + """レンダリングフィルム(出力解像度・ピクセルフィルタ)設定.""" + + width: int = Field(..., ge=1, description="フィルム幅 [px]") + height: int = Field(..., ge=1, description="フィルム高さ [px]") + rfilter: Literal["gaussian", "box", "tent"] = Field( + default="gaussian", description="ピクセル再構成フィルタ" + ) + + +class SensorConfig(_FrozenBase): + """カメラセンサ設定.現在は perspective のみ対応.""" + + type: Literal["perspective"] + position: Vec3 = Field(..., description="カメラ位置 [mm]") + target: Vec3 = Field(..., description="注視点 [mm]") + up: Vec3 = Field(..., description="上方向ベクトル") + fov_deg: float = Field(..., description="水平方向 FOV [deg]") + film: FilmConfig + + @field_validator("fov_deg") + @classmethod + def _validate_fov(cls, v: float) -> float: + """FOV が 0 < fov_deg < 180 の範囲内であることを検証する.""" + if not (0.0 < v < 180.0): + msg = f"fov_deg must be in range (0, 180), got {v}" + raise ValueError(msg) + return v + + +# --------------------------------------------------------------------------- +# Render +# --------------------------------------------------------------------------- + + +class RenderConfig(_FrozenBase): + """レンダリングパラメータ設定.""" + + variant: str = Field(default="llvm_ad_rgb", description="Mitsuba variant 文字列") + integrator: Literal["path", "direct", "volpath"] = Field( + default="path", description="積分器の種類" + ) + max_depth: int = Field(default=8, ge=1, description="最大光線追跡深度") + spp: int = Field(default=256, ge=1, description="ピクセルあたりのサンプル数") + + +# --------------------------------------------------------------------------- +# Materials(discriminated union) +# --------------------------------------------------------------------------- + + +class DiffuseMaterial(_FrozenBase): + """Lambertian 拡散反射材料.""" + + type: Literal["diffuse"] + reflectance: float | Vec3 = Field(..., description="反射率(スカラまたは RGB タプル)") + + +class _IorBase(_FrozenBase): + """屈折率フィールドを持つ誘電体材料の共通基底.""" + + int_ior: float = Field(..., description="内側の屈折率") + ext_ior: float = Field(..., description="外側の屈折率") + + +class DielectricMaterial(_IorBase): + """誘電体材料(厚みあり).""" + + type: Literal["dielectric"] + + +class ThinDielectricMaterial(_IorBase): + """薄膜誘電体材料(ディフューザー薄板向け).""" + + type: Literal["thindielectric"] + + +# type フィールドで識別される discriminated union +MaterialConfig = Annotated[ + DiffuseMaterial | DielectricMaterial | ThinDielectricMaterial, + Field(discriminator="type"), +] + + +# --------------------------------------------------------------------------- +# Lights(discriminated union) +# --------------------------------------------------------------------------- + + +class PointLight(_FrozenBase): + """点光源(LED 単体モデル等).""" + + id: str = Field(..., description="シーン内ユニーク ID") + type: Literal["point"] + position: Vec3 = Field(..., description="位置 [mm]") + intensity: float | Vec3 = Field(..., description="強度(スカラまたは RGB タプル)") + profile: Literal["lambertian", "isotropic"] = Field(..., description="配光プロファイル") + + +class AreaLight(_FrozenBase): + """面光源(矩形).""" + + id: str = Field(..., description="シーン内ユニーク ID") + type: Literal["area"] + shape: Literal["rectangle"] = Field(..., description="面形状") + center: Vec3 = Field(..., description="中心位置 [mm]") + size: tuple[float, float] = Field(..., description="サイズ [mm, mm]") + direction: Vec3 = Field(..., description="放射方向ベクトル") + radiance: float | Vec3 = Field(..., description="放射輝度(スカラまたは RGB タプル)") + + +class DirectionalLight(_FrozenBase): + """平行光源(無限遠光源).""" + + id: str = Field(..., description="シーン内ユニーク ID") + type: Literal["directional"] + direction: Vec3 = Field(..., description="光の進行方向ベクトル") + irradiance: float | Vec3 = Field(..., description="照度(スカラまたは RGB タプル)") + + +# type フィールドで識別される discriminated union +LightConfig = Annotated[ + PointLight | AreaLight | DirectionalLight, + Field(discriminator="type"), +] + + +# --------------------------------------------------------------------------- +# Geometry +# --------------------------------------------------------------------------- + + +class MeshConfig(_FrozenBase): + """メッシュ(STL)定義.""" + + id: str = Field(..., description="シーン内ユニーク ID") + path: str = Field(..., description="プロジェクトルートからの相対パス(存在チェックは行わない)") + unit: Literal["mm", "m"] = Field(..., description="モデルの単位系") + material: str = Field(..., description="materials の dict キー参照") + + +class GeometryConfig(_FrozenBase): + """ジオメトリ設定(メッシュ一覧).""" + + meshes: list[MeshConfig] = Field(..., min_length=1, description="メッシュ定義リスト") + + +# --------------------------------------------------------------------------- +# Evaluation +# --------------------------------------------------------------------------- + + +class RoiConfig(_FrozenBase): + """評価 ROI 設定(image_pixel 型のみ対応). + + scene_face 型は将来実装予定. + """ + + type: Literal["image_pixel"] + x: int = Field(..., ge=0, description="ROI 左上 X 座標 [px]") + y: int = Field(..., ge=0, description="ROI 左上 Y 座標 [px]") + width: int = Field(..., ge=1, description="ROI 幅 [px]") + height: int = Field(..., ge=1, description="ROI 高さ [px]") + + +class ZonesConfig(_FrozenBase): + """3 ゾーン分析設定(中心 / 中間 / 周辺).""" + + enabled: bool = Field(default=True, description="ゾーン分析の有効/無効") + boundaries: tuple[float, float] = Field( + default=(0.33, 0.66), + description="境界値 (b1, b2).0 < b1 < b2 < 1 を満たす正規化距離", + ) + + @field_validator("boundaries") + @classmethod + def _validate_boundaries(cls, v: tuple[float, float]) -> tuple[float, float]: + """境界値が 0 < b1 < b2 < 1 を満たすことを検証する.""" + b1, b2 = v + if not (0.0 < b1 < b2 < 1.0): + msg = f"boundaries must satisfy 0 < b1 < b2 < 1, got ({b1}, {b2})" + raise ValueError(msg) + return v + + +class MetricsConfig(_FrozenBase): + """算出する均一性指標フラグ設定.""" + + cov: bool = Field(default=True, description="変動係数 (CoV)") + std: bool = Field(default=True, description="標準偏差") + max_min: bool = Field(default=True, description="最大値/最小値比") + radial_profile: bool = Field(default=True, description="放射状プロファイル") + + +class EvaluationConfig(_FrozenBase): + """評価設定(ROI・ゾーン・指標).""" + + roi: RoiConfig + zones: ZonesConfig | None = None + metrics: MetricsConfig | None = None + + +# --------------------------------------------------------------------------- +# Top-level SceneConfig +# --------------------------------------------------------------------------- + + +class SceneConfig(_FrozenBase): + """シーン定義全体を表すトップレベルモデル. + + SPEC_02_シーン定義仕様 に準拠した構造を持つ. + model_validator でメッシュ・光源 ID の一意性,および材料参照整合性を検証する. + """ + + name: str = Field(..., description="設計識別名(出力ディレクトリ名に使用)") + description: str | None = Field(default=None, description="自由記述") + geometry: GeometryConfig + materials: dict[str, MaterialConfig] + lights: list[LightConfig] = Field(..., min_length=1, description="光源リスト(1 個以上)") + sensor: SensorConfig + render: RenderConfig = Field(default_factory=RenderConfig) + evaluation: EvaluationConfig | None = None + + @model_validator(mode="after") + def _validate_cross_references(self) -> SceneConfig: + """メッシュ/光源 ID の一意性と材料参照整合性を検証する. + + 検証内容: + 1. geometry.meshes の各 id が重複していないこと. + 2. lights の各 id が重複していないこと. + 3. 各 mesh.material が materials の dict キーに存在すること. + + Returns: + 検証済みの SceneConfig 自身. + + Raises: + ValueError: 重複 ID または未定義材料参照が見つかった場合. + """ + # 1. mesh.id ユニーク性チェック + seen_mesh_ids: set[str] = set() + for mesh in self.geometry.meshes: + if mesh.id in seen_mesh_ids: + msg = f"Duplicate mesh id: '{mesh.id}'" + raise ValueError(msg) + seen_mesh_ids.add(mesh.id) + + # 2. light.id ユニーク性チェック + # LightConfig は discriminated union なので各エントリは必ず id を持つ + seen_light_ids: set[str] = set() + for light in self.lights: + if light.id in seen_light_ids: + msg = f"Duplicate light id: '{light.id}'" + raise ValueError(msg) + seen_light_ids.add(light.id) + + # 3. mesh.material 参照整合性チェック + material_keys = set(self.materials.keys()) + for mesh in self.geometry.meshes: + if mesh.material not in material_keys: + msg = f"mesh '{mesh.id}' references unknown material '{mesh.material}'" + raise ValueError(msg) + + return self + + +# --------------------------------------------------------------------------- +# YAML Loader +# --------------------------------------------------------------------------- + + +def load_scene_yaml(path: str | Path) -> SceneConfig: + """YAML ファイルを読み込み,検証済み SceneConfig を返す. + + Args: + path: YAML ファイルのパス. + + Returns: + 検証済みの SceneConfig インスタンス. + + Raises: + FileNotFoundError: ファイルが存在しない場合. + pydantic.ValidationError: スキーマ違反. + yaml.YAMLError: YAML 構文エラー. + """ + resolved = Path(path) + if not resolved.exists(): + msg = f"Scene YAML file not found: {resolved}" + raise FileNotFoundError(msg) + + with resolved.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + + return SceneConfig.model_validate(data) diff --git a/src/sm_tias_lightsim/uniformity.py b/src/sm_tias_lightsim/uniformity.py new file mode 100644 index 0000000..9779ffa --- /dev/null +++ b/src/sm_tias_lightsim/uniformity.py @@ -0,0 +1,79 @@ +"""輝度均一性評価モジュール. + +出典: https://github.com/rintoHasegawa/MiniTias-Evaluation +方針: PLAN_02 Step 1 の方針 (a) — コピーして独立運用 +ローカル参照ファイル: MiniTias-Evaluation/src/uniformity.py +""" + +import numpy + +# Rec.709 グレースケール変換係数 +# 参照: ITU-R BT.709 (https://www.itu.int/rec/R-REC-BT.709/en) +REC709_COEFF_R = 0.2126 +REC709_COEFF_G = 0.7152 +REC709_COEFF_B = 0.0722 + + +def to_grayscale(image: numpy.ndarray) -> numpy.ndarray: + """RGB 画像を Rec.709 輝度係数でグレースケール(輝度画像)に変換する. + + OpenCV の cvtColor ではなく Rec.709 の係数を明示的に使用する. + 参照: ITU-R BT.709 + + Args: + image: 入力 RGB 画像の NumPy 配列(H x W x 3, uint8). + + Returns: + 輝度画像の NumPy 配列(H x W, float64).値域は 0.0〜255.0. + """ + # float64 に変換してから係数を適用する(整数演算による丸め誤差を防ぐ) + img_float = image.astype(numpy.float64) + luminance = ( + REC709_COEFF_R * img_float[:, :, 0] + + REC709_COEFF_G * img_float[:, :, 1] + + REC709_COEFF_B * img_float[:, :, 2] + ) + return luminance + + +def calc_uniformity(luminance: numpy.ndarray) -> dict: + """輝度画像から均一性指標を算出する. + + 3 つの指標を算出する: + - CoV(変動係数): σ / μ — 小さいほど均一(無次元) + - std(標準偏差): 輝度のばらつきの絶対値 + - max_min_ratio(最大/最小比): max / min — 極端なムラの検出 + + Args: + luminance: 輝度画像の NumPy 配列(H x W, float64). + + Returns: + 均一性指標を格納した辞書.キーは "cov", "std", "max_min_ratio", + "mean", "max", "min". + + Raises: + ValueError: 輝度画像の最小値が 0 の場合(最大/最小比が計算不能). + """ + mean = float(numpy.mean(luminance)) + std = float(numpy.std(luminance)) + lmax = float(numpy.max(luminance)) + lmin = float(numpy.min(luminance)) + + # CoV: 相対的なばらつきの指標 + cov = std / mean if mean != 0.0 else float("inf") + + # 最大/最小比: 極端なムラを検出する指標 + if lmin == 0.0: + raise ValueError( + "輝度の最小値が 0 のため最大/最小比を計算できません.ROI の設定を確認してください." + ) + max_min_ratio = lmax / lmin + + return { + "cov": cov, + "std": std, + "max_min_ratio": max_min_ratio, + "mean": mean, + "max": lmax, + "min": lmin, + } diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..db21735 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,44 @@ +"""共通フィクスチャ. + +全テストモジュールから利用可能な pytest フィクスチャを定義する. +""" + +import numpy +import pytest + + +@pytest.fixture +def uniform_luminance_100x100() -> numpy.ndarray: + """全画素が 100.0 の (100, 100) 輝度配列.""" + return numpy.full((100, 100), 100.0, dtype=numpy.float64) + + +@pytest.fixture +def radial_gradient_luminance_100x100() -> numpy.ndarray: + """中心が明るく周辺が暗い放射グラジェント輝度配列 (100, 100). + + 正規化楕円距離 d を用いて 200 - 100 * d で合成する. + 中心 (d=0) で 200,周辺 (d=1) で 100 程度となる. + """ + from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map + + d = compute_normalized_distance_map(100, 100) + return (200.0 - 100.0 * d).astype(numpy.float64) + + +@pytest.fixture +def red_pixel() -> numpy.ndarray: + """純赤 (255, 0, 0) の 1x1 uint8 RGB 画像.""" + return numpy.array([[[255, 0, 0]]], dtype=numpy.uint8) + + +@pytest.fixture +def green_pixel() -> numpy.ndarray: + """純緑 (0, 255, 0) の 1x1 uint8 RGB 画像.""" + return numpy.array([[[0, 255, 0]]], dtype=numpy.uint8) + + +@pytest.fixture +def blue_pixel() -> numpy.ndarray: + """純青 (0, 0, 255) の 1x1 uint8 RGB 画像.""" + return numpy.array([[[0, 0, 255]]], dtype=numpy.uint8) diff --git a/tests/evaluation/__init__.py b/tests/evaluation/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/evaluation/__init__.py diff --git a/tests/evaluation/test_radial.py b/tests/evaluation/test_radial.py new file mode 100644 index 0000000..88ac990 --- /dev/null +++ b/tests/evaluation/test_radial.py @@ -0,0 +1,137 @@ +"""evaluation/radial.py の仕様ベーステスト. + +仕様出典: MiniTias-Evaluation/SPEC_02_照明均一性評価アルゴリズム.md「放射状輝度プロファイル」節 +""" + +import numpy +import pytest + +from sm_tias_lightsim.evaluation.radial import calc_radial_profile + +# --------------------------------------------------------------------------- +# 出力の形状・型テスト +# --------------------------------------------------------------------------- + + +def test_radial_profile_shape_and_dtype(uniform_luminance_100x100): + """デフォルト n_bins=20 で出力 shape が (20, 2), dtype が float64 になること. + + 仕様: 出力 20×2 の配列 (列 0=ビン中心距離, 列 1=平均輝度), dtype=numpy.float64 + (SPEC_02 放射状輝度プロファイル節) + """ + profile = calc_radial_profile(uniform_luminance_100x100) + assert profile.shape == (20, 2) + assert profile.dtype == numpy.float64 + + +def test_radial_profile_default_bins(uniform_luminance_100x100): + """デフォルト引数で 20 ビンのプロファイルが返ること.""" + profile = calc_radial_profile(uniform_luminance_100x100) + assert profile.shape[0] == 20 + + +def test_radial_profile_custom_bins(uniform_luminance_100x100): + """n_bins=10 で shape が (10, 2) になること. + + 仕様: n_bins でビン数を指定可能 (radial.py docstring) + """ + profile = calc_radial_profile(uniform_luminance_100x100, n_bins=10) + assert profile.shape == (10, 2) + + +# --------------------------------------------------------------------------- +# ビン中心距離のテスト +# --------------------------------------------------------------------------- + + +def test_radial_profile_distances_increasing(uniform_luminance_100x100): + """列 0 (ビン中心距離) が単調増加であること. + + 仕様: 中心から周辺にかけての輝度変化を連続的に把握 (SPEC_02) + ビン中心は linspace(0, 1, n_bins+1) の隣接平均なので常に単調増加する. + """ + profile = calc_radial_profile(uniform_luminance_100x100) + distances = profile[:, 0] + diffs = numpy.diff(distances) + assert numpy.all(diffs > 0) + + +def test_radial_profile_distances_in_unit_range(uniform_luminance_100x100): + """列 0 の値が [0, 1] 範囲内であること. + + 仕様: 正規化楕円距離 d ∈ [0, 1] を n_bins 等分 (SPEC_02) + """ + profile = calc_radial_profile(uniform_luminance_100x100) + distances = profile[:, 0] + assert float(numpy.min(distances)) >= 0.0 + assert float(numpy.max(distances)) <= 1.0 + + +def test_radial_profile_bin_centers_correct(): + """ビン中心距離が linspace(0, 1, n_bins+1) の隣接平均と一致すること.""" + n_bins = 5 + luminance = numpy.full((100, 100), 50.0, dtype=numpy.float64) + profile = calc_radial_profile(luminance, n_bins=n_bins) + + bin_edges = numpy.linspace(0.0, 1.0, n_bins + 1) + expected_centers = (bin_edges[:-1] + bin_edges[1:]) / 2.0 + assert numpy.allclose(profile[:, 0], expected_centers) + + +# --------------------------------------------------------------------------- +# 輝度値のテスト +# --------------------------------------------------------------------------- + + +def test_radial_profile_uniform_luminance(uniform_luminance_100x100): + """一様輝度では全ビンの平均輝度が同じ値 (NaN ビンを除く) になること. + + 仕様: 一様照明では放射プロファイルが平坦 (SPEC_02) + """ + profile = calc_radial_profile(uniform_luminance_100x100) + mean_values = profile[:, 1] + valid_mask = ~numpy.isnan(mean_values) + assert numpy.any(valid_mask), "有効なビンが 1 つ以上存在すること" + valid_values = mean_values[valid_mask] + assert numpy.allclose(valid_values, valid_values[0]) + + +def test_radial_profile_includes_d_one(): + """d == 1.0 の画素が最後のビンに含まれること(最外ビンが NaN にならない). + + 仕様: 最外ビンは上端を含める (d=1.0 の画素を欠損させない) (SPEC_02) + 100×100 の画像では隅のピクセルが d=1.0 付近に存在する. + """ + luminance = numpy.full((100, 100), 128.0, dtype=numpy.float64) + profile = calc_radial_profile(luminance, n_bins=20) + last_bin_value = profile[-1, 1] + assert not numpy.isnan(last_bin_value), "最外ビンが NaN であってはならない" + + +def test_radial_profile_radial_gradient(radial_gradient_luminance_100x100): + """中心が明るいグラジェント輝度では, 先頭ビンの輝度が末尾ビンより大きいこと. + + 仕様: 中心-周辺方向の輝度変化を連続的に把握 (SPEC_02) + 合成輝度: 200 - 100 * d なので中心 (d≈0) が周辺 (d≈1) より明るい. + """ + profile = calc_radial_profile(radial_gradient_luminance_100x100) + mean_values = profile[:, 1] + valid_mask = ~numpy.isnan(mean_values) + valid_values = mean_values[valid_mask] + assert valid_values[0] > valid_values[-1] + + +def test_radial_profile_empty_bins_are_nan(): + """画素が存在しない空ビンは numpy.nan になること. + + 仕様: 空ビンは numpy.nan (radial.py docstring) + 非常に小さい画像 (3×3) で n_bins=20 のとき,距離が到達しないビンが発生しうる. + """ + luminance = numpy.full((3, 3), 50.0, dtype=numpy.float64) + profile = calc_radial_profile(luminance, n_bins=20) + # 3×3 画像で 20 ビンのうち少なくとも 1 ビンが空である可能性を確認 + # (実際にどのビンが空になるかは距離マップに依存するが,nan があることを検証) + # ここでは nan が存在する可能性を確認するだけとし,存在する場合は値が nan であること + mean_values = profile[:, 1] + for v in mean_values: + assert v == pytest.approx(float(v)) or numpy.isnan(v) diff --git a/tests/evaluation/test_zones.py b/tests/evaluation/test_zones.py new file mode 100644 index 0000000..a2f7408 --- /dev/null +++ b/tests/evaluation/test_zones.py @@ -0,0 +1,276 @@ +"""evaluation/zones.py の仕様ベーステスト. + +仕様出典: MiniTias-Evaluation/SPEC_02_照明均一性評価アルゴリズム.md「空間分析」節 +""" + +import numpy +import pytest + +from sm_tias_lightsim.evaluation.zones import ( + calc_zone_stats, + classify_zones, + compute_normalized_distance_map, +) + +# --------------------------------------------------------------------------- +# compute_normalized_distance_map のテスト +# --------------------------------------------------------------------------- + + +def test_distance_map_shape_and_dtype(): + """出力 shape が (height, width), dtype が float64 であること. + + 仕様: 正規化距離マップ (shape: (height, width), dtype: numpy.float64) + """ + dm = compute_normalized_distance_map(50, 80) + assert dm.shape == (50, 80) + assert dm.dtype == numpy.float64 + + +def test_distance_map_normalized_to_unit_range(): + """値域が [0, 1] であり最大値が 1.0 になること. + + 仕様: 値域を [0, 1] に再正規化 (d <- d / d_max) (SPEC_02) + """ + dm = compute_normalized_distance_map(60, 80) + assert float(numpy.min(dm)) >= 0.0 + assert float(numpy.max(dm)) == pytest.approx(1.0) + + +def test_distance_map_center_is_zero(): + """偶数サイズにおける中心付近のピクセルの値がほぼ 0 になること. + + 仕様: d(cx, cy) = 0 (中心から距離 0) (SPEC_02) + 偶数サイズの場合 cx=W/2, cy=H/2 は 0-indexed グリッドの内側に落ちないため, + 中心寄りのピクセル (cy-1, cx-1) が最小値 (ほぼ 0) であることを確認する. + """ + h, w = 100, 100 + dm = compute_normalized_distance_map(h, w) + center_r, center_c = h // 2 - 1, w // 2 - 1 + assert dm[center_r, center_c] == pytest.approx(0.0, abs=0.05) + + +def test_distance_map_minimum_is_zero_even_size(): + """偶数サイズの ROI で最小値が 0 になること. + + 仕様: cx = W/2, cy = H/2 で計算するため,偶数サイズでは arange(W) の + index W/2 が cx に一致し,両軸の距離が 0 になるピクセルが存在する. + 例: W=100, cx=50.0 -> x=50 のとき (50-50)/50 = 0. + """ + dm = compute_normalized_distance_map(100, 100) + assert float(numpy.min(dm)) == pytest.approx(0.0, abs=1e-10) + + +def test_distance_map_row_symmetry_around_center(): + """同一行内で cx を挟む対称ピクセルが同じ距離をもつこと. + + 仕様: cx = W/2 なので x と (W - x) は cx について等距離(dx は逆符号). + ただし偶数 W での index 0 は鏡像 W が存在しないため, + index 1..W-1 の範囲で成立する対称ペアを確認する. + 例: W=100, cx=50 -> x=1 と x=99 (100-1) は同じ |x-cx| をもつ. + """ + h, w = 100, 100 + dm = compute_normalized_distance_map(h, w) + cy_row = h // 2 # 中心行(y距離が最小の行) + # x=1 と x=99 のペア(|1-50|=49, |99-50|=49) + assert dm[cy_row, 1] == pytest.approx(dm[cy_row, w - 1], rel=1e-6) + # x=10 と x=90 のペア + assert dm[cy_row, 10] == pytest.approx(dm[cy_row, w - 10], rel=1e-6) + + +def test_distance_map_increases_from_center(): + """距離マップが中心から外側へ向かって単調増加すること(行方向の確認). + + 仕様: d(x,y) は ROI 中心からの距離を表す (SPEC_02) + 中心列(x=cx 付近)で上から下へ増加する. + """ + h, w = 100, 100 + dm = compute_normalized_distance_map(h, w) + # 中心列(x=50)で上半分の各行が下の行より大きいこと(中心に近いほど小さい) + cx = w // 2 + center_col = dm[:, cx] + # 中心(行 50)から外側(行 0 または行 99)に向かって増加する + mid = h // 2 + assert center_col[mid] <= center_col[mid - 1] + assert center_col[mid] <= center_col[mid + 1] + + +# --------------------------------------------------------------------------- +# classify_zones のテスト +# --------------------------------------------------------------------------- + + +def test_classify_zones_thresholds(): + """boundaries=(0.33, 0.66) で 0/1/2 に正しく分類されること. + + 仕様: + - d < 0.33 → Center (0) + - 0.33 <= d < 0.66 → Middle (1) + - 0.66 <= d → Periphery (2) + (SPEC_02 3 ゾーン分類節) + """ + # 境界値を含む代表的な距離値で検証する + dm = numpy.array([[0.0, 0.32, 0.33, 0.65, 0.66, 1.0]], dtype=numpy.float64) + zones = classify_zones(dm) + + assert zones[0, 0] == 0 # d=0.0 -> center + assert zones[0, 1] == 0 # d=0.32 -> center (< 0.33) + assert zones[0, 2] == 1 # d=0.33 -> middle (>= 0.33) + assert zones[0, 3] == 1 # d=0.65 -> middle (< 0.66) + assert zones[0, 4] == 2 # d=0.66 -> periphery (>= 0.66) + assert zones[0, 5] == 2 # d=1.0 -> periphery + + +def test_classify_zones_dtype_int8(): + """classify_zones の出力 dtype が numpy.int8 であること. + + 仕様: ゾーンラベルマップ (dtype: numpy.int8) (zones.py docstring) + """ + dm = numpy.array([[0.0, 0.5, 1.0]], dtype=numpy.float64) + zones = classify_zones(dm) + assert zones.dtype == numpy.int8 + + +def test_classify_zones_custom_boundaries(): + """任意の境界 (0.5, 0.8) でも正しく分類されること.""" + dm = numpy.array([[0.0, 0.49, 0.5, 0.79, 0.8, 1.0]], dtype=numpy.float64) + zones = classify_zones(dm, boundaries=(0.5, 0.8)) + + assert zones[0, 0] == 0 # d=0.0 -> center + assert zones[0, 1] == 0 # d=0.49 -> center (< 0.5) + assert zones[0, 2] == 1 # d=0.5 -> middle (>= 0.5) + assert zones[0, 3] == 1 # d=0.79 -> middle (< 0.8) + assert zones[0, 4] == 2 # d=0.8 -> periphery (>= 0.8) + assert zones[0, 5] == 2 # d=1.0 -> periphery + + +def test_classify_zones_shape_preserved(): + """入力と同じ shape を持つゾーンマップが返ること.""" + dm = numpy.zeros((30, 40), dtype=numpy.float64) + zones = classify_zones(dm) + assert zones.shape == (30, 40) + + +# --------------------------------------------------------------------------- +# calc_zone_stats のテスト +# --------------------------------------------------------------------------- + + +def test_calc_zone_stats_keys_present(uniform_luminance_100x100): + """戻り値 dict に 8 キーが全て揃っていること. + + 仕様: 8 キー = center/middle/periphery × mean/std + center_periphery_ratio + + gradient_magnitude (SPEC_02 空間分析指標節) + """ + from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map + + dm = compute_normalized_distance_map(100, 100) + zone_map = classify_zones(dm) + result = calc_zone_stats(uniform_luminance_100x100, zone_map) + + required_keys = { + "center_mean", + "center_std", + "middle_mean", + "middle_std", + "periphery_mean", + "periphery_std", + "center_periphery_ratio", + "gradient_magnitude", + } + assert set(result.keys()) == required_keys + + +def test_calc_zone_stats_uniform_image(uniform_luminance_100x100): + """一様輝度では全ゾーン平均が同値, gradient==0, center_periphery_ratio==1 になること. + + 仕様: + - C/P Ratio = μ_center / μ_periphery = 1.0 (均一時) + - Gradient = (μ_center - μ_periphery) / μ_center * 100 = 0% (均一時) + (SPEC_02 空間分析指標節) + """ + from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map + + dm = compute_normalized_distance_map(100, 100) + zone_map = classify_zones(dm) + result = calc_zone_stats(uniform_luminance_100x100, zone_map) + + assert result["center_mean"] == pytest.approx(100.0) + assert result["middle_mean"] == pytest.approx(100.0) + assert result["periphery_mean"] == pytest.approx(100.0) + assert result["center_std"] == pytest.approx(0.0) + assert result["middle_std"] == pytest.approx(0.0) + assert result["periphery_std"] == pytest.approx(0.0) + assert result["gradient_magnitude"] == pytest.approx(0.0) + assert result["center_periphery_ratio"] == pytest.approx(1.0) + + +def test_calc_zone_stats_radial_gradient(radial_gradient_luminance_100x100): + """中心が明るい合成輝度で gradient > 0, center_periphery_ratio > 1 になること. + + 仕様: + - gradient > 0 → 中心から周辺に向かって輝度が減衰 (SPEC_02) + - C/P Ratio > 1.0 → 中心が明るい (SPEC_02) + """ + from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map + + dm = compute_normalized_distance_map(100, 100) + zone_map = classify_zones(dm) + result = calc_zone_stats(radial_gradient_luminance_100x100, zone_map) + + assert result["gradient_magnitude"] > 0.0 + assert result["center_periphery_ratio"] > 1.0 + + +def test_calc_zone_stats_gradient_formula(): + """gradient_magnitude が (μ_center - μ_periphery) / μ_center * 100 の公式に従うこと. + + 仕様: Gradient = (μ_center - μ_periphery) / μ_center * 100 (SPEC_02) + """ + from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map + + dm = compute_normalized_distance_map(100, 100) + zone_map = classify_zones(dm) + luminance = numpy.full((100, 100), 100.0, dtype=numpy.float64) + # 中心ゾーン (zone==0) だけ値を変える + luminance[zone_map == 0] = 150.0 + + result = calc_zone_stats(luminance, zone_map) + expected_gradient = ( + (result["center_mean"] - result["periphery_mean"]) / result["center_mean"] * 100.0 + ) + assert result["gradient_magnitude"] == pytest.approx(expected_gradient) + + +def test_calc_zone_stats_cp_ratio_formula(): + """center_periphery_ratio が μ_center / μ_periphery の公式に従うこと. + + 仕様: C/P Ratio = μ_center / μ_periphery (SPEC_02) + """ + from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map + + dm = compute_normalized_distance_map(100, 100) + zone_map = classify_zones(dm) + luminance = numpy.full((100, 100), 100.0, dtype=numpy.float64) + luminance[zone_map == 0] = 150.0 + + result = calc_zone_stats(luminance, zone_map) + expected_ratio = result["center_mean"] / result["periphery_mean"] + assert result["center_periphery_ratio"] == pytest.approx(expected_ratio) + + +def test_calc_zone_stats_raises_on_zero_periphery(): + """周辺ゾーンの平均輝度が 0 で ValueError が発生すること. + + 仕様: Y_min = 0 の場合は計算不能 (SPEC_02) + """ + from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map + + dm = compute_normalized_distance_map(100, 100) + zone_map = classify_zones(dm) + # 周辺 (zone==2) を 0 にする + luminance = numpy.full((100, 100), 100.0, dtype=numpy.float64) + luminance[zone_map == 2] = 0.0 + + with pytest.raises(ValueError): + calc_zone_stats(luminance, zone_map) diff --git a/tests/geometry/__init__.py b/tests/geometry/__init__.py new file mode 100644 index 0000000..d6bba1c --- /dev/null +++ b/tests/geometry/__init__.py @@ -0,0 +1 @@ +# geometry テストパッケージマーカ diff --git a/tests/geometry/conftest.py b/tests/geometry/conftest.py new file mode 100644 index 0000000..260fbc8 --- /dev/null +++ b/tests/geometry/conftest.py @@ -0,0 +1,53 @@ +"""geometry テスト共通フィクスチャ. + +テスト用 STL ファイルを tmp_path に生成して返す. +モックは使わず実ファイルで検証する(GUIDE_08 モック方針). +""" + +import pathlib + +import numpy +import pytest +import trimesh + + +@pytest.fixture +def tetrahedron_stl(tmp_path: pathlib.Path) -> pathlib.Path: + """四面体 STL を tmp_path に書き出してそのパスを返す. + + 頂点数 4, 面数 4 の最小三角形メッシュ. + """ + vertices = numpy.array( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.5, 1.0, 0.0], + [0.5, 0.5, 1.0], + ], + dtype=numpy.float64, + ) + faces = numpy.array( + [ + [0, 1, 2], + [0, 1, 3], + [0, 2, 3], + [1, 2, 3], + ], + dtype=numpy.int64, + ) + mesh = trimesh.Trimesh(vertices=vertices, faces=faces) + path = tmp_path / "shape.stl" + mesh.export(str(path)) + return path + + +@pytest.fixture +def cube_stl(tmp_path: pathlib.Path) -> pathlib.Path: + """立方体 STL を tmp_path に書き出してそのパスを返す. + + trimesh.creation.box() で生成: 頂点数 8, 面数 12. + """ + mesh = trimesh.creation.box() + path = tmp_path / "cube.stl" + mesh.export(str(path)) + return path diff --git a/tests/geometry/test_loader.py b/tests/geometry/test_loader.py new file mode 100644 index 0000000..fa46744 --- /dev/null +++ b/tests/geometry/test_loader.py @@ -0,0 +1,181 @@ +"""geometry/loader.py の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(geometry 層の責務) +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(unit は mm/m のみ) +- docs/03_PLAN/PLAN_02_開発ステップ.md(Step 3: 簡単な四面体 STL を読み頂点数が一致する) +""" + +import dataclasses +import pathlib + +import numpy +import pytest + +from sm_tias_lightsim.geometry import LoadedMesh, load_stl + +# --------------------------------------------------------------------------- +# 正常系: 返り値の型 +# --------------------------------------------------------------------------- + + +def test_load_stl_returns_loaded_mesh(tetrahedron_stl): + """load_stl が LoadedMesh インスタンスを返すこと. + + 仕様: 出力: 頂点・面配列 + 単位情報 (SPEC_01 geometry 層の責務) + """ + result = load_stl(tetrahedron_stl, "mm") + assert isinstance(result, LoadedMesh) + + +# --------------------------------------------------------------------------- +# 正常系: dtype +# --------------------------------------------------------------------------- + + +def test_load_stl_vertices_dtype_float64(tetrahedron_stl): + """vertices の dtype が float64 であること. + + 仕様: vertices: numpy.ndarray (N_v, 3) float64 (loader.py LoadedMesh docstring) + """ + result = load_stl(tetrahedron_stl, "mm") + assert result.vertices.dtype == numpy.float64 + + +def test_load_stl_faces_dtype_int64(tetrahedron_stl): + """faces の dtype が int64 であること. + + 仕様: faces: numpy.ndarray (N_f, 3) int64 (loader.py LoadedMesh docstring) + """ + result = load_stl(tetrahedron_stl, "mm") + assert result.faces.dtype == numpy.int64 + + +# --------------------------------------------------------------------------- +# 正常系: shape(PLAN_02 完了条件「頂点数が一致する」) +# --------------------------------------------------------------------------- + + +def test_load_stl_vertices_shape(tetrahedron_stl): + """四面体 STL の頂点配列 shape が (4, 3) であること. + + 仕様: 簡単な四面体 STL を読み頂点数が一致する (PLAN_02 Step 3 完了条件) + """ + result = load_stl(tetrahedron_stl, "mm") + assert result.vertices.shape == (4, 3) + + +def test_load_stl_faces_shape(tetrahedron_stl): + """四面体 STL の面配列 shape が (4, 3) であること. + + 仕様: 面配列 (N_f, 3) (loader.py LoadedMesh docstring) + """ + result = load_stl(tetrahedron_stl, "mm") + assert result.faces.shape == (4, 3) + + +def test_load_stl_cube_vertices_shape(cube_stl): + """立方体 STL の頂点配列 shape が (8, 3) であること. + + trimesh.creation.box() の既知頂点数(8)と面数(12)が一致する. + """ + result = load_stl(cube_stl, "mm") + assert result.vertices.shape == (8, 3) + + +def test_load_stl_cube_faces_shape(cube_stl): + """立方体 STL の面配列 shape が (12, 3) であること.""" + result = load_stl(cube_stl, "mm") + assert result.faces.shape == (12, 3) + + +# --------------------------------------------------------------------------- +# 正常系: unit フィールドの保持 +# --------------------------------------------------------------------------- + + +def test_load_stl_preserves_unit_mm(tetrahedron_stl): + """unit="mm" で読み込むと loaded.unit == "mm" であること. + + 仕様: unit は mm/m のみ (SPEC_02 geometry セクション) + """ + result = load_stl(tetrahedron_stl, "mm") + assert result.unit == "mm" + + +def test_load_stl_preserves_unit_m(tetrahedron_stl): + """unit="m" で読み込むと loaded.unit == "m" であること. + + 仕様: unit は mm/m のみ (SPEC_02 geometry セクション) + """ + result = load_stl(tetrahedron_stl, "m") + assert result.unit == "m" + + +# --------------------------------------------------------------------------- +# 正常系: source_path +# --------------------------------------------------------------------------- + + +def test_load_stl_source_path_is_resolved(tetrahedron_stl): + """source_path が Path 型で絶対パスであること. + + 仕様: source_path: pathlib.Path(デバッグ用)(loader.py LoadedMesh docstring) + """ + result = load_stl(tetrahedron_stl, "mm") + assert isinstance(result.source_path, pathlib.Path) + assert result.source_path.is_absolute() + + +# --------------------------------------------------------------------------- +# 正常系: パス型の受け入れ +# --------------------------------------------------------------------------- + + +def test_load_stl_accepts_str_path(tetrahedron_stl): + """str でパスを渡してもロードできること. + + 仕様: path: str | Path (loader.py load_stl シグネチャ) + """ + result = load_stl(str(tetrahedron_stl), "mm") + assert isinstance(result, LoadedMesh) + assert result.vertices.shape == (4, 3) + + +def test_load_stl_accepts_pathlib_path(tetrahedron_stl): + """pathlib.Path をそのまま渡してもロードできること. + + 仕様: path: str | Path (loader.py load_stl シグネチャ) + """ + result = load_stl(tetrahedron_stl, "mm") + assert isinstance(result, LoadedMesh) + + +# --------------------------------------------------------------------------- +# 異常系: ファイル不存在 +# --------------------------------------------------------------------------- + + +def test_load_stl_raises_file_not_found(tmp_path): + """存在しないパスを渡すと FileNotFoundError が発生すること. + + 仕様: FileNotFoundError: ファイルが存在しない場合 (loader.py load_stl docstring) + """ + missing = tmp_path / "no_such_file.stl" + with pytest.raises(FileNotFoundError): + load_stl(missing, "mm") + + +# --------------------------------------------------------------------------- +# 異常系: イミュータビリティ +# --------------------------------------------------------------------------- + + +def test_load_stl_loaded_mesh_is_frozen(tetrahedron_stl): + """パース後の LoadedMesh へのフィールド代入で FrozenInstanceError が発生すること. + + 仕様: @dataclass(frozen=True) (loader.py LoadedMesh クラス定義) + """ + result = load_stl(tetrahedron_stl, "mm") + with pytest.raises(dataclasses.FrozenInstanceError): + result.unit = "m" # type: ignore[misc] diff --git a/tests/geometry/test_transforms.py b/tests/geometry/test_transforms.py new file mode 100644 index 0000000..47c7b50 --- /dev/null +++ b/tests/geometry/test_transforms.py @@ -0,0 +1,261 @@ +"""geometry/transforms.py の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(geometry 層の責務) +- docs/03_PLAN/PLAN_02_開発ステップ.md(Step 3 完了条件: 単位変換が可逆) +- docs/01_GUIDE/GUIDE_08_テスト方針.md(Mandatory: データ変換「変換が可逆であること」) +""" + +import pathlib + +import numpy +import pytest + +from sm_tias_lightsim.geometry import LoadedMesh, mm_to_m, scale_vertices + +# --------------------------------------------------------------------------- +# テストヘルパー +# --------------------------------------------------------------------------- + + +def _make_mesh( + vertices: numpy.ndarray, + unit: str = "mm", +) -> LoadedMesh: + """テスト用 LoadedMesh を生成するヘルパー.faces はダミーの 1 面.""" + faces = numpy.array([[0, 1, 2]], dtype=numpy.int64) + return LoadedMesh( + vertices=vertices, + faces=faces, + unit=unit, # type: ignore[arg-type] + source_path=pathlib.Path("/dummy/mesh.stl"), + ) + + +# --------------------------------------------------------------------------- +# mm_to_m: スカラ +# --------------------------------------------------------------------------- + + +def test_mm_to_m_scalar_1000(): + """mm_to_m(1000.0) == 1.0 であること. + + 仕様: mm → m 変換(× 0.001)(transforms.py mm_to_m docstring) + """ + assert mm_to_m(1000.0) == pytest.approx(1.0) + + +def test_mm_to_m_scalar_2500_5(): + """mm_to_m(2500.5) == 2.5005 であること(小数点以下 4 桁).""" + assert mm_to_m(2500.5) == pytest.approx(2.5005) + + +def test_mm_to_m_zero(): + """mm_to_m(0) == 0 であること. + + 仕様: 0 ミリメートルは 0 メートル(恒等変換の特殊ケース) + """ + assert mm_to_m(0) == pytest.approx(0.0) + + +# --------------------------------------------------------------------------- +# mm_to_m: ndarray +# --------------------------------------------------------------------------- + + +def test_mm_to_m_ndarray(): + """mm_to_m(ndarray) が要素ごとに × 0.001 した配列を返すこと. + + 仕様: スカラ・ndarray 両対応 (transforms.py mm_to_m シグネチャ) + """ + input_arr = numpy.array([1000.0, 2000.0], dtype=numpy.float64) + result = mm_to_m(input_arr) + expected = numpy.array([1.0, 2.0], dtype=numpy.float64) + numpy.testing.assert_array_almost_equal(result, expected) + + +def test_mm_to_m_ndarray_shape_preserved(): + """mm_to_m が入力と同じ shape の配列を返すこと.""" + arr = numpy.ones((3, 3), dtype=numpy.float64) * 500.0 + result = mm_to_m(arr) + assert result.shape == (3, 3) + + +# --------------------------------------------------------------------------- +# scale_vertices: mm → m 変換 +# --------------------------------------------------------------------------- + + +def test_scale_vertices_mm_to_m(): + """mm → m 変換で頂点値が 1/1000 になること. + + 仕様: mm → m 変換(× 0.001)(transforms.py scale_vertices) + """ + vertices_mm = numpy.array( + [[1000.0, 2000.0, 500.0], [0.0, 3000.0, 1500.0], [500.0, 500.0, 0.0]], + dtype=numpy.float64, + ) + mesh = _make_mesh(vertices_mm, unit="mm") + scaled = scale_vertices(mesh, "m") + expected = vertices_mm * 0.001 + numpy.testing.assert_array_almost_equal(scaled.vertices, expected) + + +# --------------------------------------------------------------------------- +# scale_vertices: m → mm 変換 +# --------------------------------------------------------------------------- + + +def test_scale_vertices_m_to_mm(): + """m → mm 変換で頂点値が 1000 倍になること. + + 仕様: m → mm 変換(× 1000)(transforms.py scale_vertices) + """ + vertices_m = numpy.array( + [[1.0, 2.0, 0.5], [0.0, 3.0, 1.5], [0.5, 0.5, 0.0]], + dtype=numpy.float64, + ) + mesh = _make_mesh(vertices_m, unit="m") + scaled = scale_vertices(mesh, "mm") + expected = vertices_m * 1000.0 + numpy.testing.assert_array_almost_equal(scaled.vertices, expected) + + +# --------------------------------------------------------------------------- +# scale_vertices: 同一単位 +# --------------------------------------------------------------------------- + + +def test_scale_vertices_same_unit_values_unchanged(): + """mm → mm では頂点値が変わらないこと.""" + vertices = numpy.array([[100.0, 200.0, 300.0]], dtype=numpy.float64) + mesh = _make_mesh(vertices, unit="mm") + scaled = scale_vertices(mesh, "mm") + numpy.testing.assert_array_equal(scaled.vertices, vertices) + + +def test_scale_vertices_same_unit_returns_copy(): + """mm → mm では値は同じだが配列オブジェクトは別であること. + + 仕様: 元と同じ単位が指定された場合も頂点配列はコピーされる(関数の純粋性) + (transforms.py scale_vertices docstring) + """ + vertices = numpy.array([[100.0, 200.0, 300.0]], dtype=numpy.float64) + mesh = _make_mesh(vertices, unit="mm") + scaled = scale_vertices(mesh, "mm") + assert scaled.vertices is not mesh.vertices + + +# --------------------------------------------------------------------------- +# scale_vertices: unit フィールドの更新 +# --------------------------------------------------------------------------- + + +def test_scale_vertices_unit_field_updated_mm_to_m(): + """mm → m 変換後の unit が "m" になること. + + 仕様: unit フィールドは target_unit に更新される (transforms.py scale_vertices docstring) + """ + vertices = numpy.array([[1000.0, 0.0, 0.0]], dtype=numpy.float64) + mesh = _make_mesh(vertices, unit="mm") + scaled = scale_vertices(mesh, "m") + assert scaled.unit == "m" + + +def test_scale_vertices_unit_field_updated_m_to_mm(): + """m → mm 変換後の unit が "mm" になること.""" + vertices = numpy.array([[1.0, 0.0, 0.0]], dtype=numpy.float64) + mesh = _make_mesh(vertices, unit="m") + scaled = scale_vertices(mesh, "mm") + assert scaled.unit == "mm" + + +# --------------------------------------------------------------------------- +# scale_vertices: 可逆性(PLAN_02 完了条件「単位変換が可逆」) +# --------------------------------------------------------------------------- + + +def test_scale_vertices_roundtrip_is_identity(): + """mm → m → mm で元の頂点とほぼ一致すること(単位変換の可逆性). + + 仕様: 単位変換が可逆 (PLAN_02_開発ステップ.md Step 3 完了条件) + GUIDE_08 Mandatory: 「データ変換: 変換が可逆であること」 + """ + vertices_mm = numpy.array( + [ + [0.0, 0.0, 0.0], + [1000.0, 0.0, 0.0], + [500.0, 1000.0, 0.0], + [500.0, 500.0, 1000.0], + ], + dtype=numpy.float64, + ) + original = _make_mesh(vertices_mm, unit="mm") + converted = scale_vertices(original, "m") + round_tripped = scale_vertices(converted, "mm") + assert numpy.allclose(round_tripped.vertices, original.vertices) + + +# --------------------------------------------------------------------------- +# scale_vertices: インスタンスの独立性 +# --------------------------------------------------------------------------- + + +def test_scale_vertices_returns_new_instance(): + """scale_vertices の戻り値が元と異なるインスタンスであること. + + 仕様: frozen dataclass の不変性を維持するため,常に新規インスタンスを返す + (transforms.py scale_vertices docstring) + """ + vertices = numpy.array([[1000.0, 2000.0, 0.0]], dtype=numpy.float64) + mesh = _make_mesh(vertices, unit="mm") + scaled = scale_vertices(mesh, "m") + assert scaled is not mesh + + +# --------------------------------------------------------------------------- +# scale_vertices: faces の不変性 +# --------------------------------------------------------------------------- + + +def test_scale_vertices_faces_preserved(): + """単位変換で faces 配列の値が変わらないこと. + + 仕様: 頂点座標のみスケール変換する(面インデックスは変換対象外) + (SPEC_01 geometry 層の責務) + """ + faces = numpy.array([[0, 1, 2], [1, 2, 3]], dtype=numpy.int64) + vertices = numpy.array( + [ + [1000.0, 0.0, 0.0], + [0.0, 1000.0, 0.0], + [0.0, 0.0, 1000.0], + [1000.0, 1000.0, 0.0], + ], + dtype=numpy.float64, + ) + mesh = LoadedMesh( + vertices=vertices, + faces=faces, + unit="mm", + source_path=pathlib.Path("/dummy/mesh.stl"), + ) + scaled = scale_vertices(mesh, "m") + numpy.testing.assert_array_equal(scaled.faces, faces) + + +# --------------------------------------------------------------------------- +# scale_vertices: 元メッシュの faces 参照が同一かコピーかの確認 +# --------------------------------------------------------------------------- + + +def test_scale_vertices_original_vertices_unchanged(): + """scale_vertices 後に元メッシュの頂点が変わっていないこと(純粋関数性). + + 仕様: 元の LoadedMesh は変更しない(frozen=True かつ純粋関数) + """ + vertices = numpy.array([[1000.0, 2000.0, 3000.0]], dtype=numpy.float64) + original_copy = vertices.copy() + mesh = _make_mesh(vertices, unit="mm") + scale_vertices(mesh, "m") + numpy.testing.assert_array_equal(mesh.vertices, original_copy) diff --git a/tests/scene/__init__.py b/tests/scene/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/scene/__init__.py diff --git a/tests/scene/conftest.py b/tests/scene/conftest.py new file mode 100644 index 0000000..ef94eaf --- /dev/null +++ b/tests/scene/conftest.py @@ -0,0 +1,92 @@ +"""scene テスト共通フィクスチャ. + +有効な最小構成 YAML 文字列を提供する. +異常系テストはこの文字列を replace するか,dict を直接組み立てて検証する. +""" + +import pytest + + +@pytest.fixture +def valid_minimal_yaml() -> str: + """必須 6 セクション(name, geometry, materials, lights, sensor, render)のみの最小 YAML. + + 各テストで replace/加工のベースとして使う. + """ + return """\ +name: test_scene +geometry: + meshes: + - id: housing + path: configs/meshes/housing.stl + unit: mm + material: mat_diffuse +materials: + mat_diffuse: + type: diffuse + reflectance: 0.5 +lights: + - id: led_top + type: point + position: [0.0, 0.0, 50.0] + intensity: [1.0, 1.0, 1.0] + profile: lambertian +sensor: + type: perspective + position: [0.0, 0.0, 100.0] + target: [0.0, 0.0, 0.0] + up: [0.0, 1.0, 0.0] + fov_deg: 60.0 + film: + width: 1920 + height: 1080 +render: + variant: llvm_ad_rgb + integrator: path + max_depth: 8 + spp: 256 +""" + + +@pytest.fixture +def valid_minimal_dict() -> dict: + """valid_minimal_yaml と同内容の Python dict.モデル直接検証に使う.""" + return { + "name": "test_scene", + "geometry": { + "meshes": [ + { + "id": "housing", + "path": "configs/meshes/housing.stl", + "unit": "mm", + "material": "mat_diffuse", + } + ] + }, + "materials": { + "mat_diffuse": {"type": "diffuse", "reflectance": 0.5}, + }, + "lights": [ + { + "id": "led_top", + "type": "point", + "position": [0.0, 0.0, 50.0], + "intensity": [1.0, 1.0, 1.0], + "profile": "lambertian", + } + ], + "sensor": { + "type": "perspective", + "position": [0.0, 0.0, 100.0], + "target": [0.0, 0.0, 0.0], + "up": [0.0, 1.0, 0.0], + "fov_deg": 60.0, + "film": {"width": 1920, "height": 1080}, + }, + "render": { + "variant": "llvm_ad_rgb", + "integrator": "path", + "max_depth": 8, + "spp": 256, + }, + } diff --git a/tests/scene/test_loader.py b/tests/scene/test_loader.py new file mode 100644 index 0000000..fb4da9a --- /dev/null +++ b/tests/scene/test_loader.py @@ -0,0 +1,157 @@ +"""scene/schema.py の load_scene_yaml 関数の仕様ベーステスト. + +仕様出典: docs/04_SPEC/SPEC_02_シーン定義仕様.md +モック方針: ファイル I/O はモックせず tmp_path フィクスチャで実ファイルを使う (GUIDE_08) +""" + +from pathlib import Path + +import pytest +import yaml +from pydantic import ValidationError + +from sm_tias_lightsim.scene import SceneConfig, load_scene_yaml + + +@pytest.fixture +def valid_yaml_content() -> str: + """load_scene_yaml テスト用の有効な YAML 文字列.""" + return """\ +name: loader_test_scene +geometry: + meshes: + - id: housing + path: configs/meshes/housing.stl + unit: mm + material: mat_diffuse +materials: + mat_diffuse: + type: diffuse + reflectance: 0.5 +lights: + - id: led_top + type: point + position: [0.0, 0.0, 50.0] + intensity: [1.0, 1.0, 1.0] + profile: lambertian +sensor: + type: perspective + position: [0.0, 0.0, 100.0] + target: [0.0, 0.0, 0.0] + up: [0.0, 1.0, 0.0] + fov_deg: 60.0 + film: + width: 1920 + height: 1080 +render: + variant: llvm_ad_rgb + integrator: path + max_depth: 8 + spp: 256 +""" + + +def test_load_scene_yaml_returns_scene_config(tmp_path, valid_yaml_content): + """正常な YAML ファイルから SceneConfig インスタンスが返ること. + + 仕様: load_scene_yaml(path) -> SceneConfig (SPEC_02 バリデーション節) + """ + yaml_file = tmp_path / "scene.yaml" + yaml_file.write_text(valid_yaml_content, encoding="utf-8") + + config = load_scene_yaml(yaml_file) + + assert isinstance(config, SceneConfig) + assert config.name == "loader_test_scene" + + +def test_load_scene_yaml_raises_file_not_found(tmp_path): + """存在しないパスを渡したとき FileNotFoundError が発生すること. + + 仕様: FileNotFoundError: ファイルが存在しない場合 (schema.py docstring) + """ + nonexistent = tmp_path / "does_not_exist.yaml" + + with pytest.raises(FileNotFoundError): + load_scene_yaml(nonexistent) + + +def test_load_scene_yaml_raises_yaml_error(tmp_path): + """不正な YAML 構文のファイルを渡したとき yaml.YAMLError 系の例外が発生すること. + + 仕様: yaml.YAMLError: YAML 構文エラー (schema.py docstring) + """ + invalid_yaml = tmp_path / "invalid.yaml" + # タブ文字を含む不正な YAML + invalid_yaml.write_text("name: test\n\t invalid_indent: [unclosed", encoding="utf-8") + + with pytest.raises(yaml.YAMLError): + load_scene_yaml(invalid_yaml) + + +def test_load_scene_yaml_raises_validation_error(tmp_path): + """構文は正しいが必須キーが欠落した YAML で ValidationError が発生すること. + + 仕様: pydantic.ValidationError: スキーマ違反 (schema.py docstring) + """ + # name が欠落した構文的に正しい YAML + missing_name_yaml = """\ +geometry: + meshes: + - id: housing + path: configs/meshes/housing.stl + unit: mm + material: mat_diffuse +materials: + mat_diffuse: + type: diffuse + reflectance: 0.5 +lights: + - id: led_top + type: point + position: [0.0, 0.0, 50.0] + intensity: 1.0 + profile: lambertian +sensor: + type: perspective + position: [0.0, 0.0, 100.0] + target: [0.0, 0.0, 0.0] + up: [0.0, 1.0, 0.0] + fov_deg: 60.0 + film: + width: 1920 + height: 1080 +""" + yaml_file = tmp_path / "missing_name.yaml" + yaml_file.write_text(missing_name_yaml, encoding="utf-8") + + with pytest.raises(ValidationError): + load_scene_yaml(yaml_file) + + +def test_load_scene_yaml_accepts_pathlib_path(tmp_path, valid_yaml_content): + """Path オブジェクトを引数として受け付けること. + + 仕様: load_scene_yaml(path: str | Path) (schema.py シグネチャ) + """ + yaml_file = tmp_path / "scene.yaml" + yaml_file.write_text(valid_yaml_content, encoding="utf-8") + + # Path オブジェクトを渡す + config = load_scene_yaml(Path(yaml_file)) + + assert isinstance(config, SceneConfig) + + +def test_load_scene_yaml_accepts_str_path(tmp_path, valid_yaml_content): + """文字列パスを引数として受け付けること. + + 仕様: load_scene_yaml(path: str | Path) (schema.py シグネチャ) + """ + yaml_file = tmp_path / "scene.yaml" + yaml_file.write_text(valid_yaml_content, encoding="utf-8") + + # 文字列パスを渡す + config = load_scene_yaml(str(yaml_file)) + + assert isinstance(config, SceneConfig) diff --git a/tests/scene/test_schema.py b/tests/scene/test_schema.py new file mode 100644 index 0000000..7a4480a --- /dev/null +++ b/tests/scene/test_schema.py @@ -0,0 +1,517 @@ +"""scene/schema.py の仕様ベーステスト. + +仕様出典: docs/04_SPEC/SPEC_02_シーン定義仕様.md +""" + +import copy + +import pytest +import yaml +from pydantic import ValidationError + +from sm_tias_lightsim.scene.schema import ( + AreaLight, + DielectricMaterial, + DiffuseMaterial, + DirectionalLight, + PointLight, + SceneConfig, + ThinDielectricMaterial, +) + +# --------------------------------------------------------------------------- +# 正常系: 最小構成 +# --------------------------------------------------------------------------- + + +def test_minimal_valid_yaml_parses(valid_minimal_yaml): + """必須 6 セクションのみで SceneConfig が生成されること. + + 仕様: name, geometry, materials, lights, sensor, render が必須 (SPEC_02 トップレベル構造) + """ + data = yaml.safe_load(valid_minimal_yaml) + config = SceneConfig.model_validate(data) + assert config.name == "test_scene" + assert len(config.geometry.meshes) == 1 + assert len(config.lights) == 1 + + +def test_optional_evaluation_section(valid_minimal_dict): + """evaluation を省略しても SceneConfig が生成されること. + + 仕様: evaluation は任意(SPEC_02 evaluation セクション) + """ + d = copy.deepcopy(valid_minimal_dict) + d.pop("evaluation", None) + config = SceneConfig.model_validate(d) + assert config.evaluation is None + + +def test_optional_description(valid_minimal_dict): + """description を省略しても SceneConfig が生成されること. + + 仕様: description は任意(SPEC_02 トップレベル構造) + """ + d = copy.deepcopy(valid_minimal_dict) + d.pop("description", None) + config = SceneConfig.model_validate(d) + assert config.description is None + + +def test_render_defaults(valid_minimal_dict): + """render セクションを省略しても既定値が設定されること. + + 仕様: render.variant デフォルト llvm_ad_rgb, spp デフォルト 256 (SPEC_02 render セクション) + """ + d = copy.deepcopy(valid_minimal_dict) + d.pop("render", None) + config = SceneConfig.model_validate(d) + assert config.render.variant == "llvm_ad_rgb" + assert config.render.spp == 256 + assert config.render.integrator == "path" + assert config.render.max_depth == 8 + + +# --------------------------------------------------------------------------- +# 正常系: マテリアル +# --------------------------------------------------------------------------- + + +def test_diffuse_material_with_scalar_reflectance(valid_minimal_dict): + """reflectance をスカラ値で指定できること. + + 仕様: reflectance スカラ可 (SPEC_02 materials セクション) + """ + d = copy.deepcopy(valid_minimal_dict) + d["materials"]["mat_diffuse"] = {"type": "diffuse", "reflectance": 0.95} + config = SceneConfig.model_validate(d) + mat = config.materials["mat_diffuse"] + assert isinstance(mat, DiffuseMaterial) + assert mat.reflectance == pytest.approx(0.95) + + +def test_diffuse_material_with_rgb_reflectance(valid_minimal_dict): + """reflectance を RGB リストで指定できること. + + 仕様: reflectance: [0.05, 0.05, 0.05] の RGB 表記 (SPEC_02 materials セクション) + """ + d = copy.deepcopy(valid_minimal_dict) + d["materials"]["mat_diffuse"] = {"type": "diffuse", "reflectance": [0.05, 0.05, 0.05]} + config = SceneConfig.model_validate(d) + mat = config.materials["mat_diffuse"] + assert isinstance(mat, DiffuseMaterial) + assert mat.reflectance == pytest.approx((0.05, 0.05, 0.05)) + + +def test_dielectric_and_thindielectric_materials(valid_minimal_dict): + """dielectric / thindielectric の type が正しく識別されること. + + 仕様: type: dielectric / thindielectric の discriminated union (SPEC_02 materials セクション) + """ + d = copy.deepcopy(valid_minimal_dict) + d["materials"]["mat_dielectric"] = { + "type": "dielectric", + "int_ior": 1.5, + "ext_ior": 1.0, + } + d["materials"]["mat_thin"] = { + "type": "thindielectric", + "int_ior": 1.49, + "ext_ior": 1.0, + } + d["geometry"]["meshes"].append( + {"id": "mesh2", "path": "m.stl", "unit": "mm", "material": "mat_dielectric"} + ) + d["geometry"]["meshes"].append( + {"id": "mesh3", "path": "m.stl", "unit": "mm", "material": "mat_thin"} + ) + config = SceneConfig.model_validate(d) + assert isinstance(config.materials["mat_dielectric"], DielectricMaterial) + assert isinstance(config.materials["mat_thin"], ThinDielectricMaterial) + + +# --------------------------------------------------------------------------- +# 正常系: ライト +# --------------------------------------------------------------------------- + + +def test_point_light_parses(valid_minimal_dict): + """point 光源の必須フィールドが正しくパースされること. + + 仕様: type: point, position, intensity, profile (SPEC_02 lights セクション) + """ + d = copy.deepcopy(valid_minimal_dict) + config = SceneConfig.model_validate(d) + light = config.lights[0] + assert isinstance(light, PointLight) + assert light.id == "led_top" + assert light.type == "point" + assert light.position == pytest.approx((0.0, 0.0, 50.0)) + assert light.profile == "lambertian" + + +def test_area_light_parses(valid_minimal_dict): + """area 光源の必須フィールドが正しくパースされること. + + 仕様: type: area, shape, center, size, direction, radiance (SPEC_02 lights セクション) + """ + d = copy.deepcopy(valid_minimal_dict) + d["lights"] = [ + { + "id": "led_area", + "type": "area", + "shape": "rectangle", + "center": [0.0, 0.0, 20.0], + "size": [10.0, 10.0], + "direction": [0.0, 0.0, -1.0], + "radiance": [50.0, 50.0, 50.0], + } + ] + config = SceneConfig.model_validate(d) + light = config.lights[0] + assert isinstance(light, AreaLight) + assert light.id == "led_area" + assert light.shape == "rectangle" + assert light.size == pytest.approx((10.0, 10.0)) + assert light.radiance == pytest.approx((50.0, 50.0, 50.0)) + + +def test_directional_light_parses(valid_minimal_dict): + """directional 光源が正しくパースされること. + + 仕様: type: directional, direction, irradiance (SPEC_02 lights セクション) + """ + d = copy.deepcopy(valid_minimal_dict) + d["lights"] = [ + { + "id": "sun", + "type": "directional", + "direction": [0.0, 0.0, -1.0], + "irradiance": 1.0, + } + ] + config = SceneConfig.model_validate(d) + light = config.lights[0] + assert isinstance(light, DirectionalLight) + assert light.id == "sun" + assert light.direction == pytest.approx((0.0, 0.0, -1.0)) + + +# --------------------------------------------------------------------------- +# 正常系: evaluation / zones +# --------------------------------------------------------------------------- + + +def test_zones_default_boundaries(valid_minimal_dict): + """boundaries を省略したとき既定値 (0.33, 0.66) が設定されること. + + 仕様: boundaries デフォルト [0.33, 0.66] (SPEC_02 evaluation セクション) + """ + d = copy.deepcopy(valid_minimal_dict) + d["evaluation"] = { + "roi": {"type": "image_pixel", "x": 0, "y": 0, "width": 100, "height": 100}, + "zones": {"enabled": True}, + } + config = SceneConfig.model_validate(d) + assert config.evaluation is not None + assert config.evaluation.zones is not None + assert config.evaluation.zones.boundaries == pytest.approx((0.33, 0.66)) + + +# --------------------------------------------------------------------------- +# 異常系: 必須キー欠落 +# --------------------------------------------------------------------------- + + +def test_missing_name_raises(valid_minimal_dict): + """name が欠落したとき ValidationError が発生すること. + + 仕様: name 必須 (SPEC_02 トップレベル構造) + """ + d = copy.deepcopy(valid_minimal_dict) + del d["name"] + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +def test_missing_geometry_raises(valid_minimal_dict): + """geometry が欠落したとき ValidationError が発生すること. + + 仕様: geometry 必須 (SPEC_02 トップレベル構造) + """ + d = copy.deepcopy(valid_minimal_dict) + del d["geometry"] + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +def test_missing_materials_raises(valid_minimal_dict): + """materials が欠落したとき ValidationError が発生すること. + + 仕様: materials 必須 (SPEC_02 トップレベル構造) + """ + d = copy.deepcopy(valid_minimal_dict) + del d["materials"] + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +def test_missing_lights_raises(valid_minimal_dict): + """lights が欠落したとき ValidationError が発生すること. + + 仕様: lights 必須 (SPEC_02 トップレベル構造) + """ + d = copy.deepcopy(valid_minimal_dict) + del d["lights"] + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +def test_missing_sensor_raises(valid_minimal_dict): + """sensor が欠落したとき ValidationError が発生すること. + + 仕様: sensor 必須 (SPEC_02 トップレベル構造) + """ + d = copy.deepcopy(valid_minimal_dict) + del d["sensor"] + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +def test_empty_lights_list_raises(valid_minimal_dict): + """lights が空リストのとき ValidationError が発生すること. + + 仕様: lights は 1 個以上必須 (SPEC_02 lights セクション) + """ + d = copy.deepcopy(valid_minimal_dict) + d["lights"] = [] + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +def test_empty_meshes_list_raises(valid_minimal_dict): + """geometry.meshes が空リストのとき ValidationError が発生すること. + + 仕様: meshes は 1 個以上必須(min_length=1) + """ + d = copy.deepcopy(valid_minimal_dict) + d["geometry"]["meshes"] = [] + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +# --------------------------------------------------------------------------- +# 異常系: 参照整合性・ID 重複 +# --------------------------------------------------------------------------- + + +def test_dangling_material_reference_raises(valid_minimal_dict): + """存在しない material を mesh が参照したとき ValidationError が発生し, + エラーメッセージに mesh id と material name が含まれること. + + 仕様: 参照整合性チェック (SPEC_02 バリデーション節) + """ + d = copy.deepcopy(valid_minimal_dict) + d["geometry"]["meshes"][0]["material"] = "nonexistent_material" + with pytest.raises(ValidationError) as exc_info: + SceneConfig.model_validate(d) + error_str = str(exc_info.value) + assert "housing" in error_str + assert "nonexistent_material" in error_str + + +def test_duplicate_mesh_id_raises(valid_minimal_dict): + """同じ mesh.id が 2 つあるとき ValidationError が発生すること. + + 仕様: ID の重複チェック (SPEC_02 バリデーション節) + """ + d = copy.deepcopy(valid_minimal_dict) + d["geometry"]["meshes"].append( + { + "id": "housing", # 重複 + "path": "other.stl", + "unit": "mm", + "material": "mat_diffuse", + } + ) + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +def test_duplicate_light_id_raises(valid_minimal_dict): + """同じ light.id が 2 つあるとき ValidationError が発生すること. + + 仕様: ID の重複チェック (SPEC_02 バリデーション節) + """ + d = copy.deepcopy(valid_minimal_dict) + d["lights"].append( + { + "id": "led_top", # 重複 + "type": "point", + "position": [1.0, 0.0, 50.0], + "intensity": 1.0, + "profile": "isotropic", + } + ) + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +# --------------------------------------------------------------------------- +# 異常系: 型・値範囲 +# --------------------------------------------------------------------------- + + +def test_invalid_unit_cm_raises(valid_minimal_dict): + """unit: cm を指定したとき ValidationError が発生すること. + + 仕様: unit は mm | m のみ (SPEC_02 geometry セクション) + """ + d = copy.deepcopy(valid_minimal_dict) + d["geometry"]["meshes"][0]["unit"] = "cm" + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +def test_invalid_material_type_raises(valid_minimal_dict): + """未対応の material type(mask 等)で ValidationError が発生すること. + + 仕様: type は diffuse / dielectric / thindielectric の discriminated union (SPEC_02) + 注: SPEC_02 では mask が将来拡張として記載されているが,現在はサポート外 + """ + d = copy.deepcopy(valid_minimal_dict) + d["materials"]["mat_diffuse"] = {"type": "mask", "opacity": 0.5} + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +def test_invalid_light_type_raises(valid_minimal_dict): + """未対応の light type(laser 等)で ValidationError が発生すること. + + 仕様: type は point / area / directional の discriminated union (SPEC_02 lights セクション) + """ + d = copy.deepcopy(valid_minimal_dict) + d["lights"] = [ + { + "id": "laser_1", + "type": "laser", + "position": [0.0, 0.0, 50.0], + "intensity": 1.0, + } + ] + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +def test_invalid_sensor_type_raises(valid_minimal_dict): + """type: orthographic を指定したとき ValidationError が発生すること. + + 仕様: sensor.type は perspective のみ (SPEC_02 sensor セクション) + """ + d = copy.deepcopy(valid_minimal_dict) + d["sensor"]["type"] = "orthographic" + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +def test_fov_zero_raises(valid_minimal_dict): + """fov_deg: 0 のとき ValidationError が発生すること. + + 仕様: 0 < fov_deg < 180 (SPEC_02 バリデーション節) + """ + d = copy.deepcopy(valid_minimal_dict) + d["sensor"]["fov_deg"] = 0.0 + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +def test_fov_180_raises(valid_minimal_dict): + """fov_deg: 180 のとき ValidationError が発生すること. + + 仕様: 0 < fov_deg < 180(端点は含まない)(SPEC_02 バリデーション節) + """ + d = copy.deepcopy(valid_minimal_dict) + d["sensor"]["fov_deg"] = 180.0 + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +def test_zones_invalid_boundaries_raises(valid_minimal_dict): + """boundaries: [0.66, 0.33](逆順)のとき ValidationError が発生すること. + + 仕様: 0 < b1 < b2 < 1 (SPEC_02 evaluation/zones セクション) + """ + d = copy.deepcopy(valid_minimal_dict) + d["evaluation"] = { + "roi": {"type": "image_pixel", "x": 0, "y": 0, "width": 100, "height": 100}, + "zones": {"enabled": True, "boundaries": [0.66, 0.33]}, + } + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +def test_zones_boundaries_out_of_range_raises(valid_minimal_dict): + """boundaries: [0.0, 1.0](範囲外)のとき ValidationError が発生すること. + + 仕様: 0 < b1 < b2 < 1(端点 0 と 1 は含まない)(SPEC_02 evaluation/zones セクション) + """ + d = copy.deepcopy(valid_minimal_dict) + d["evaluation"] = { + "roi": {"type": "image_pixel", "x": 0, "y": 0, "width": 100, "height": 100}, + "zones": {"enabled": True, "boundaries": [0.0, 1.0]}, + } + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +# --------------------------------------------------------------------------- +# 異常系: フィールドの細部 +# --------------------------------------------------------------------------- + + +def test_film_dimensions_must_be_positive(valid_minimal_dict): + """film.width: 0 のとき ValidationError が発生すること. + + 仕様: width >= 1 (ge=1) + """ + d = copy.deepcopy(valid_minimal_dict) + d["sensor"]["film"]["width"] = 0 + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +def test_max_depth_must_be_positive(valid_minimal_dict): + """render.max_depth: 0 のとき ValidationError が発生すること. + + 仕様: max_depth >= 1 (ge=1) + """ + d = copy.deepcopy(valid_minimal_dict) + d["render"]["max_depth"] = 0 + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +def test_spp_must_be_positive(valid_minimal_dict): + """render.spp: 0 のとき ValidationError が発生すること. + + 仕様: spp >= 1 (ge=1) + """ + d = copy.deepcopy(valid_minimal_dict) + d["render"]["spp"] = 0 + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +# --------------------------------------------------------------------------- +# イミュータビリティ +# --------------------------------------------------------------------------- + + +def test_scene_config_is_frozen(valid_minimal_dict): + """パース後の SceneConfig インスタンスへの代入で ValidationError が発生すること. + + 仕様: frozen=True (schema.py ConfigDict) + """ + config = SceneConfig.model_validate(valid_minimal_dict) + with pytest.raises(ValidationError): + config.name = "modified" # type: ignore[misc] diff --git a/tests/test_uniformity.py b/tests/test_uniformity.py new file mode 100644 index 0000000..469140e --- /dev/null +++ b/tests/test_uniformity.py @@ -0,0 +1,180 @@ +"""uniformity.py の仕様ベーステスト. + +仕様出典: MiniTias-Evaluation/SPEC_02_照明均一性評価アルゴリズム.md +""" + +import numpy +import pytest + +from sm_tias_lightsim.uniformity import ( + REC709_COEFF_B, + REC709_COEFF_G, + REC709_COEFF_R, + calc_uniformity, + to_grayscale, +) + +# --------------------------------------------------------------------------- +# REC709 定数のテスト +# --------------------------------------------------------------------------- + + +def test_rec709_coefficients_sum_to_one(): + """REC709 3 係数の和が 1.0 であること. + + 仕様: Y = 0.2126 R + 0.7152 G + 0.0722 B (SPEC_02 輝度変換節) + """ + total = REC709_COEFF_R + REC709_COEFF_G + REC709_COEFF_B + assert abs(total - 1.0) < 1e-10 + + +def test_rec709_coefficient_r_value(): + """REC709 R 係数が 0.2126 であること.""" + assert REC709_COEFF_R == pytest.approx(0.2126) + + +def test_rec709_coefficient_g_value(): + """REC709 G 係数が 0.7152 であること.""" + assert REC709_COEFF_G == pytest.approx(0.7152) + + +def test_rec709_coefficient_b_value(): + """REC709 B 係数が 0.0722 であること.""" + assert REC709_COEFF_B == pytest.approx(0.0722) + + +# --------------------------------------------------------------------------- +# to_grayscale のテスト +# --------------------------------------------------------------------------- + + +def test_grayscale_uses_rec709_coefficients_red(red_pixel): + """純赤入力で期待輝度が REC709_COEFF_R * 255 になること. + + 仕様: Y = 0.2126 R + 0.7152 G + 0.0722 B (SPEC_02) + """ + result = to_grayscale(red_pixel) + expected = REC709_COEFF_R * 255.0 + assert result[0, 0] == pytest.approx(expected) + + +def test_grayscale_uses_rec709_coefficients_green(green_pixel): + """純緑入力で期待輝度が REC709_COEFF_G * 255 になること.""" + result = to_grayscale(green_pixel) + expected = REC709_COEFF_G * 255.0 + assert result[0, 0] == pytest.approx(expected) + + +def test_grayscale_uses_rec709_coefficients_blue(blue_pixel): + """純青入力で期待輝度が REC709_COEFF_B * 255 になること.""" + result = to_grayscale(blue_pixel) + expected = REC709_COEFF_B * 255.0 + assert result[0, 0] == pytest.approx(expected) + + +def test_grayscale_returns_float64(red_pixel): + """to_grayscale の出力 dtype が numpy.float64 であること. + + 仕様: 出力値域 0.0〜255.0, float64 (SPEC_02 輝度変換節) + """ + result = to_grayscale(red_pixel) + assert result.dtype == numpy.float64 + + +def test_grayscale_preserves_shape_hxw(): + """入力 (H, W, 3) に対して出力が (H, W) になること. + + 仕様: 出力は H x W の輝度画像 (SPEC_02) + """ + image = numpy.zeros((7, 13, 3), dtype=numpy.uint8) + result = to_grayscale(image) + assert result.shape == (7, 13) + + +def test_grayscale_white_pixel_equals_255(): + """白 (255, 255, 255) の輝度が 255 になること. + + Rec.709 係数の和が 1.0 なので白は 255 のまま. + """ + white = numpy.array([[[255, 255, 255]]], dtype=numpy.uint8) + result = to_grayscale(white) + assert result[0, 0] == pytest.approx(255.0) + + +def test_grayscale_black_pixel_equals_zero(): + """黒 (0, 0, 0) の輝度が 0 になること.""" + black = numpy.array([[[0, 0, 0]]], dtype=numpy.uint8) + result = to_grayscale(black) + assert result[0, 0] == pytest.approx(0.0) + + +# --------------------------------------------------------------------------- +# calc_uniformity のテスト +# --------------------------------------------------------------------------- + + +def test_calc_uniformity_uniform_image(uniform_luminance_100x100): + """全画素が同値の輝度マップでは cov==0, std==0, max_min_ratio==1.0 になること. + + 仕様: + - CoV = σ / μ (SPEC_02 均一性指標節) + - Max/Min Ratio = max / min; 理想的な均一照明では 1.0 (SPEC_02) + """ + result = calc_uniformity(uniform_luminance_100x100) + assert result["cov"] == pytest.approx(0.0) + assert result["std"] == pytest.approx(0.0) + assert result["max_min_ratio"] == pytest.approx(1.0) + assert result["mean"] == pytest.approx(100.0) + assert result["max"] == pytest.approx(100.0) + assert result["min"] == pytest.approx(100.0) + + +def test_calc_uniformity_known_values(): + """既知の配列 [50, 100, 150] で mean/std/cov が正しい値になること. + + 手計算: + - mean = 100.0 + - std = sqrt(((50-100)^2 + (100-100)^2 + (150-100)^2) / 3) + = sqrt((2500 + 0 + 2500) / 3) = sqrt(5000/3) ≈ 40.8248... + - cov = std / mean ≈ 0.40825... + - max_min_ratio = 150 / 50 = 3.0 + """ + luminance = numpy.array([[50.0, 100.0, 150.0]], dtype=numpy.float64) + result = calc_uniformity(luminance) + + expected_mean = 100.0 + expected_std = numpy.std(numpy.array([50.0, 100.0, 150.0])) + expected_cov = expected_std / expected_mean + expected_ratio = 150.0 / 50.0 + + assert result["mean"] == pytest.approx(expected_mean) + assert result["std"] == pytest.approx(float(expected_std)) + assert result["cov"] == pytest.approx(expected_cov) + assert result["max_min_ratio"] == pytest.approx(expected_ratio) + assert result["max"] == pytest.approx(150.0) + assert result["min"] == pytest.approx(50.0) + + +def test_calc_uniformity_result_contains_required_keys(): + """戻り値 dict が必須キーを全て含むこと.""" + luminance = numpy.array([[10.0, 20.0]], dtype=numpy.float64) + result = calc_uniformity(luminance) + assert set(result.keys()) == {"cov", "std", "max_min_ratio", "mean", "max", "min"} + + +def test_calc_uniformity_raises_on_zero_min(): + """最小値が 0 の輝度マップで ValueError が発生すること. + + 仕様: Y_min = 0 の場合は計算不能 (SPEC_02 均一性指標節) + """ + luminance = numpy.array([[0.0, 100.0, 200.0]], dtype=numpy.float64) + with pytest.raises(ValueError): + calc_uniformity(luminance) + + +def test_calc_uniformity_cov_formula(): + """CoV が σ / μ の公式に従うこと (SPEC_02).""" + luminance = numpy.array([[80.0, 100.0, 120.0]], dtype=numpy.float64) + result = calc_uniformity(luminance) + expected_cov = result["std"] / result["mean"] + assert result["cov"] == pytest.approx(expected_cov) diff --git a/uv.lock b/uv.lock index 6c52c49..78a87fb 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,15 @@ ] [[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } @@ -534,6 +543,96 @@ ] [[package]] +name = "pydantic" +version = "2.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, + { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, + { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, + { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, + { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, + { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, + { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, + { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, + { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, + { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, + { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, + { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, + { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, +] + +[[package]] name = "pygments" version = "2.20.0" source = { registry = "https://pypi.org/simple" } @@ -580,6 +679,52 @@ ] [[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] name = "ruff" version = "0.15.12" source = { registry = "https://pypi.org/simple" } @@ -623,6 +768,8 @@ { name = "numpy" }, { name = "pandas" }, { name = "pillow" }, + { name = "pydantic" }, + { name = "pyyaml" }, { name = "trimesh" }, ] @@ -639,6 +786,8 @@ { name = "numpy", specifier = ">=2.4.4" }, { name = "pandas", specifier = ">=3.0.2" }, { name = "pillow", specifier = ">=12.2.0" }, + { name = "pydantic", specifier = ">=2.13.3" }, + { name = "pyyaml", specifier = ">=6.0.3" }, { name = "trimesh", specifier = ">=4.12.1" }, ] @@ -661,6 +810,27 @@ ] [[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] name = "tzdata" version = "2026.2" source = { registry = "https://pypi.org/simple" }