diff --git a/.gitignore b/.gitignore index c2f7600..a1ec927 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ # 個人ローカルの参考画像(実機写真・データシート等) REFERENCE/ +# MiniTias-Evaluation 側から受け取った実測リファレンスデータ(個人ローカル) +reference_measurement/ + # Python / uv .venv/ __pycache__/ diff --git a/CLAUDE.md b/CLAUDE.md index fc39f68..d883b3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,11 +13,13 @@ - `src/sm_tias_lightsim/evaluation/zones.py` — 正規化楕円距離マップ + 3 ゾーン分類 + ゾーン別統計 - `src/sm_tias_lightsim/evaluation/radial.py` — 20 ビン放射状輝度プロファイル - `src/sm_tias_lightsim/evaluation/roi.py` — `image_pixel` 型矩形 ROI 抽出(2D/3D 配列対応) -- `src/sm_tias_lightsim/evaluation/visualize.py` — matplotlib 可視化(輝度マップ / ヒストグラム / 放射プロファイル / ゾーンマップ) -- `src/sm_tias_lightsim/scene/schema.py` — Pydantic ベース YAML スキーマ + ローダ +- `src/sm_tias_lightsim/evaluation/visualize.py` — matplotlib 可視化(輝度マップ / ヒストグラム / 放射プロファイル / ゾーンマップ).MiniTias-Evaluation 互換スタイル(hot cmap,zone_map は luminance 背景 + 境界線) +- `src/sm_tias_lightsim/scene/schema.py` — Pydantic ベース YAML スキーマ + ローダ.SensorConfig は perspective / orthographic 両対応 - `src/sm_tias_lightsim/geometry/loader.py` — STL 読込(trimesh)+ `LoadedMesh` データクラス - `src/sm_tias_lightsim/geometry/transforms.py` — mm⇄m 単位スケール変換 -- `src/sm_tias_lightsim/scene/{lights,materials,sensors}.py` — Mitsuba emitter / BSDF / sensor dict 変換 +- `src/sm_tias_lightsim/scene/{lights,materials,sensors}.py` — Mitsuba emitter / BSDF / sensor dict 変換(sensors は orthographic 対応) +- `scripts/select_white_board.py` — 実測 PNG で白色板境界を OpenCV GUI で選択(Step 8 キャリブ用) +- `scripts/visualize_roi_3d.py` — 評価板 STL に ROI を 3D 投影(PyVista,Step 8 キャリブ用) - `src/sm_tias_lightsim/scene/builder.py` — SceneConfig → Mitsuba scene dict(PLY 一時ファイル経由) - `src/sm_tias_lightsim/render/runner.py` — variant 管理 + `mi.render` 実行 - `src/sm_tias_lightsim/render/output.py` — PNG / luminance.npy 出力 @@ -82,6 +84,7 @@ - アーキテクチャ設計: docs/04_SPEC/SPEC_01_アーキテクチャ設計.md - シーン定義仕様: docs/04_SPEC/SPEC_02_シーン定義仕様.md +- 基準設計の物理構成: docs/04_SPEC/SPEC_03_基準設計の物理構成.md ### 05_TECH(技術設計) diff --git a/configs/eval_baseline.yaml b/configs/eval_baseline.yaml index 4e904b9..5f2b4b1 100644 --- a/configs/eval_baseline.yaml +++ b/configs/eval_baseline.yaml @@ -1,12 +1,14 @@ roi: type: image_pixel - x: 130 - y: 70 - width: 250 - height: 360 + x: 24 + y: 37 + width: 464 + height: 450 zones: enabled: true - boundaries: [0.33, 0.66] + boundaries: + - 0.33 + - 0.66 metrics: cov: true std: true diff --git a/configs/eval_real.yaml b/configs/eval_real.yaml new file mode 100644 index 0000000..53d1d39 --- /dev/null +++ b/configs/eval_real.yaml @@ -0,0 +1,14 @@ +roi: + type: image_pixel + x: 539 + y: 1015 + width: 1400 + height: 1409 +zones: + enabled: true + boundaries: [0.33, 0.66] +metrics: + cov: true + std: true + max_min: true + radial_profile: true diff --git a/configs/scene_baseline.yaml b/configs/scene_baseline.yaml index 60c97c3..8b78a00 100644 --- a/configs/scene_baseline.yaml +++ b/configs/scene_baseline.yaml @@ -1,19 +1,16 @@ name: baseline_minitias -description: 'MiniTIAS 基準設計の仮置きシーン(Step 8 - 基準設計のキャリブレーション). - - LED 配置・材料パラメータは現時点では仮置きで、実測値と突合しながら調整する. - - ' +description: MiniTIAS 基準設計シーン(Step 8).orthographic センサで評価板の裏面(-Z 側 = スマホ側)の照度分布マップを + 2D 画像として直接取得する. geometry: meshes: - id: housing_front path: meshes/前側.stl unit: mm - material: housing_inner + material: printer_white - id: housing_back path: meshes/後側.stl unit: mm - material: housing_inner + material: printer_white - id: phone_holder path: meshes/スマホホルダ.stl unit: mm @@ -21,7 +18,7 @@ - id: led_cover path: meshes/LED基板カバー.stl unit: mm - material: led_cover_white + material: printer_white - id: phone_body path: meshes/SH-02M.stl unit: mm @@ -29,9 +26,9 @@ - id: evaluation_board path: meshes/照明ムラ補正ボード.stl unit: mm - material: white_lambert + material: printer_white materials: - housing_inner: + printer_white: type: diffuse reflectance: - 0.85 @@ -43,22 +40,12 @@ - 0.5 - 0.5 - 0.5 - led_cover_white: - # 薄膜誘電体(PMMA 屈折率 1.49).LED 直接光を Fresnel 透過 + 一部反射でディフューザーとして扱う. - # ※ Mitsuba の thindielectric は鏡面反射 + 透過のみで散乱はしないため,乳白色のディフューザー - # 本来の挙動とは異なる.より正確には blendbsdf 等で散乱を足す必要があるが Phase 8 では未対応. - type: thindielectric - int_ior: 1.49 - ext_ior: 1.0 phone_body_dark: type: diffuse reflectance: - 0.05 - 0.05 - 0.05 - white_lambert: - type: diffuse - reflectance: 0.95 lights: - id: led_bottom_0 type: area @@ -72,12 +59,12 @@ - 3.5 direction: - 0.0 - - 1.0 - 0.0 + - -1.0 radiance: - - 1.0 - - 1.0 - - 1.0 + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 - id: led_bottom_1 type: area shape: rectangle @@ -90,12 +77,12 @@ - 3.5 direction: - 0.0 - - 1.0 - 0.0 + - -1.0 radiance: - - 1.0 - - 1.0 - - 1.0 + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 - id: led_bottom_2 type: area shape: rectangle @@ -108,12 +95,12 @@ - 3.5 direction: - 0.0 - - 1.0 - 0.0 + - -1.0 radiance: - - 1.0 - - 1.0 - - 1.0 + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 - id: led_bottom_3 type: area shape: rectangle @@ -126,12 +113,12 @@ - 3.5 direction: - 0.0 - - 1.0 - 0.0 + - -1.0 radiance: - - 1.0 - - 1.0 - - 1.0 + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 - id: led_bottom_4 type: area shape: rectangle @@ -144,12 +131,12 @@ - 3.5 direction: - 0.0 - - 1.0 - 0.0 + - -1.0 radiance: - - 1.0 - - 1.0 - - 1.0 + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 - id: led_bottom_5 type: area shape: rectangle @@ -162,12 +149,12 @@ - 3.5 direction: - 0.0 - - 1.0 - 0.0 + - -1.0 radiance: - - 1.0 - - 1.0 - - 1.0 + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 - id: led_right_0 type: area shape: rectangle @@ -179,13 +166,13 @@ - 2.8 - 3.5 direction: + - 0.0 + - 0.0 - -1.0 - - 0.0 - - 0.0 radiance: - - 1.0 - - 1.0 - - 1.0 + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 - id: led_right_1 type: area shape: rectangle @@ -197,13 +184,13 @@ - 2.8 - 3.5 direction: + - 0.0 + - 0.0 - -1.0 - - 0.0 - - 0.0 radiance: - - 1.0 - - 1.0 - - 1.0 + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 - id: led_right_2 type: area shape: rectangle @@ -215,13 +202,13 @@ - 2.8 - 3.5 direction: + - 0.0 + - 0.0 - -1.0 - - 0.0 - - 0.0 radiance: - - 1.0 - - 1.0 - - 1.0 + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 - id: led_right_3 type: area shape: rectangle @@ -233,13 +220,13 @@ - 2.8 - 3.5 direction: + - 0.0 + - 0.0 - -1.0 - - 0.0 - - 0.0 radiance: - - 1.0 - - 1.0 - - 1.0 + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 - id: led_right_4 type: area shape: rectangle @@ -251,13 +238,13 @@ - 2.8 - 3.5 direction: + - 0.0 + - 0.0 - -1.0 - - 0.0 - - 0.0 radiance: - - 1.0 - - 1.0 - - 1.0 + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 - id: led_right_5 type: area shape: rectangle @@ -269,13 +256,13 @@ - 2.8 - 3.5 direction: + - 0.0 + - 0.0 - -1.0 - - 0.0 - - 0.0 radiance: - - 1.0 - - 1.0 - - 1.0 + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 - id: led_left_0 type: area shape: rectangle @@ -287,13 +274,13 @@ - 2.8 - 3.5 direction: - - 1.0 - 0.0 - 0.0 + - -1.0 radiance: - - 1.0 - - 1.0 - - 1.0 + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 - id: led_left_1 type: area shape: rectangle @@ -305,13 +292,13 @@ - 2.8 - 3.5 direction: - - 1.0 - 0.0 - 0.0 + - -1.0 radiance: - - 1.0 - - 1.0 - - 1.0 + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 - id: led_left_2 type: area shape: rectangle @@ -323,13 +310,13 @@ - 2.8 - 3.5 direction: - - 1.0 - 0.0 - 0.0 + - -1.0 radiance: - - 1.0 - - 1.0 - - 1.0 + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 - id: led_left_3 type: area shape: rectangle @@ -341,13 +328,13 @@ - 2.8 - 3.5 direction: - - 1.0 - 0.0 - 0.0 + - -1.0 radiance: - - 1.0 - - 1.0 - - 1.0 + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 - id: led_left_4 type: area shape: rectangle @@ -359,13 +346,13 @@ - 2.8 - 3.5 direction: - - 1.0 - 0.0 - 0.0 + - -1.0 radiance: - - 1.0 - - 1.0 - - 1.0 + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 - id: led_left_5 type: area shape: rectangle @@ -377,28 +364,30 @@ - 2.8 - 3.5 direction: - - 1.0 - 0.0 - 0.0 + - -1.0 radiance: - - 1.0 - - 1.0 - - 1.0 + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 sensor: - type: perspective + type: orthographic position: - 0.0 - 0.0 - - 100.0 + - -50.0 target: - 0.0 - 0.0 - - 4.0 + - 0.0 up: - 0.0 - 1.0 - 0.0 - fov_deg: 60.0 + ortho_scale: + - 50.0 + - 52.0 film: width: 512 height: 512 diff --git a/configs/white_board_bbox.json b/configs/white_board_bbox.json new file mode 100644 index 0000000..de3fcf9 --- /dev/null +++ b/configs/white_board_bbox.json @@ -0,0 +1,7 @@ +{ + "image": "reference_measurement\\MiniTIAS_20260408_144434\\MiniTIAS_20260408_144434.png", + "x": 473, + "y": 916, + "width": 1530, + "height": 1570 +} \ No newline at end of file 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 c0fbf04..82f3d7e 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" @@ -61,6 +61,7 @@ | 画像 I/O | `pillow` | PNG 出力(実機画像と同形式に揃える) | | 可視化(2D) | `matplotlib` | 輝度マップ・ヒストグラム・放射状プロファイル等 | | 可視化(3D) | `pyvista` | 照度ベイク結果のインタラクティブ 3D ビューア(`sm-visualize`) | +| 画像処理(GUI) | `opencv-python` | 実測 PNG での白色板境界選択 GUI(`scripts/select_white_board.py`) | | 表データ | `pandas` | 均一性指標 CSV の生成・統計集計 | | テスト | `pytest` | 単体テスト | | Lint / Format | `ruff` | コードチェック・整形(uv エコシステムと整合) | diff --git "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" index b9342a3..423b0e5 100644 --- "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" +++ "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" @@ -88,7 +88,7 @@ | `scene/builder` | YAML 設定全体を Mitsuba scene dict に変換するエントリ.他のサブ層を呼ぶ | 入力: YAML dict.出力: Mitsuba scene | | `scene/lights` | YAML 光源定義 → Mitsuba の `point` / `area` / etc. emitter | 1 LED 定義 → 1 emitter | | `scene/materials` | YAML 材料定義 → Mitsuba BSDF(`diffuse` / `dielectric` / `thindielectric`) | 1 材料定義 → 1 BSDF | -| `scene/sensors` | YAML センサ定義 → Mitsuba `perspective` センサ | 1 センサ定義 → 1 sensor | +| `scene/sensors` | YAML センサ定義 → Mitsuba `perspective` / `orthographic` センサ | 1 センサ定義 → 1 sensor.orthographic は照度マップ取得用 | | `render/runner` | Mitsuba scene + パラメータ → 輝度配列 | spp / max_depth / variant 切替 | | `render/output` | 輝度配列 → PNG / luminance.npy ファイル | tone mapping 等の選択肢を持つ | | `evaluation/roi` | 画像 + ROI 定義 → ROI 内の輝度配列 | image_pixel / scene_face 両対応 | diff --git "a/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" index f8927dc..b7ca23e 100644 --- "a/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" +++ "b/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" @@ -93,19 +93,38 @@ ### sensor +`type` は `perspective`(通常カメラ)または `orthographic`(平行投影)から選ぶ. + ```yaml +# パターン 1: perspective(通常カメラ)— fov_deg 必須 sensor: - type: perspective # perspective のみ対応 + type: perspective position: [0, 0, 50] # mm target: [0, 0, 0] up: [0, 1, 0] - fov_deg: 60 # x 方向 FOV + fov_deg: 60 # x 方向 FOV [deg] film: width: 1920 height: 1920 rfilter: gaussian # ピクセルフィルタ + +# パターン 2: orthographic(平行投影)— ortho_scale 必須 +sensor: + type: orthographic + position: [0, 0, -50] # mm(撮影面に対する距離) + target: [0, 0, 0] # 撮影面の中心 + up: [0, 1, 0] + ortho_scale: [50, 52] # 撮影範囲の半幅 (sx, sy) [mm].画像 X 軸 ±sx, Y 軸 ±sy にマップ + film: + width: 512 + height: 512 + rfilter: gaussian ``` +`orthographic` は **評価板の表面上の照度マップを 2D 画像として直接取得** する用途で使う. +pixel と評価板の物理 mm 座標が 1 対 1 で対応し、パースペクティブ歪みがない. +詳細は [SPEC_03_基準設計の物理構成](SPEC_03_基準設計の物理構成.md) のカメラ(センサ)セクションを参照. + ### render ```yaml diff --git "a/docs/04_SPEC/SPEC_03_\345\237\272\346\272\226\350\250\255\350\250\210\343\201\256\347\211\251\347\220\206\346\247\213\346\210\220.md" "b/docs/04_SPEC/SPEC_03_\345\237\272\346\272\226\350\250\255\350\250\210\343\201\256\347\211\251\347\220\206\346\247\213\346\210\220.md" new file mode 100644 index 0000000..2b2ff56 --- /dev/null +++ "b/docs/04_SPEC/SPEC_03_\345\237\272\346\272\226\350\250\255\350\250\210\343\201\256\347\211\251\347\220\206\346\247\213\346\210\220.md" @@ -0,0 +1,166 @@ +# 基準設計の物理構成 (Baseline Physical Configuration) + +## 概要 (Overview) + +Step 8(基準設計のキャリブレーション)で対象とする MiniTIAS 実機の物理構成を定義する. +本ドキュメントは実機写真([REFERENCE/](../../REFERENCE/))と Fusion 360 設計データから読み取った構成を、シミュレーション側でどう表現するかの **認識合わせ** を残すためのものである. + +仕様確定前の暫定値(材料パラメータ・LED 強度等)はキャリブレーション反復で更新する. + +実体は [configs/scene_baseline.yaml](../../configs/scene_baseline.yaml). + +## 座標系 (Coordinate System) + +- 単位: mm(YAML 内).シミュレータには m に変換して渡す. +- 原点: STL モデル(Fusion 360 エクスポート)の座標系を踏襲. +- 主要部品の Z 座標(中心): + - 評価板(照明ムラ補正ボード): Z=+4(厚み Z=-4.5〜+12.5) + - LED 基板カバー: Z=-65.3 + - スマホ(SH-02M): Z=-154.4 + +## 評価板(照明ムラ補正ボード) + +### 構造 + +| 面 | 法線 | Z 座標 | 構造 | 役割 | +| --- | --- | --- | --- | --- | +| 表面(外側) | +Z 法線 | Z=-2.5〜+12.5(凹凸) | 楕円窓 + AR マーカー + カラーチャート | 撮影された画像で見える側.スマホで写真を撮るときに映る面 | +| **裏面(内側)** | **-Z 法線** | **Z=-4.5(平面)** | **平坦な取付面** | **照度均一性の評価対象面.LED 光がこの面に均一に届くことが MiniTIAS の設計目的** | + +### 評価対象面 + +**評価対象は内側(平坦面、-Z 法線面)の照度分布**.照明ムラ評価とは「この面に届く光が空間的に均一かどうか」を測ること. + +## LED(光源) + +### 物理仕様 + +| 項目 | 値 | 根拠 | +| --- | --- | --- | +| パッケージ | SMD 2835 | [REFERENCE/smd2835_datasheets.png](../../REFERENCE/smd2835_datasheets.png) | +| サイズ | 2.8 × 3.5 mm | データシート(Samsung / Bridgelux / Lumileds 共通) | +| 配光角(ビーム角) | 120°(≒ Lambertian) | データシート | + +### モデル化(光の広がり方) + +- Mitsuba の **AreaLight (rectangle)** として表現 +- 発光面の **片面のみ** から放射する(`direction` ベクトルが示す向き側.裏面は完全に発光しない) +- 放射分布は **Lambertian**(法線方向で最大,cos(θ) で減衰) + - データシートのビーム角 120° は半値全幅 ±60° に相当し,Lambertian の cos(60°)=0.5 と一致するため矛盾なし +- 各 LED の `direction` は **z 軸マイナス方向 `(0, 0, -1)`** で全 18 個共通.光は -Z 半球側に Lambertian で広がる +- `radiance` は暫定 `1.0 W/sr/m²`(キャリブレーション反復で実機 W 値に校正予定) + - 全光束への換算: `Φ [W] = radiance × area × π`(Lambertian 半球積分) + - 1 LED あたりの面積: `2.8 × 3.5 = 9.8 mm² = 9.8e-6 m²` + - `radiance=1.0` のとき `Φ = 1.0 × 9.8e-6 × π ≈ 3.08e-5 W`(極めて小さい.実機 LED は ~0.2 W なので約 6500 倍の校正が必要) + +### 配置 + +実機の LED 基板は **前側のでっぱりの直角部分** に取り付けられている.3 基板を Z 軸まわりに ±90° 回転で配置: + +| 基板 | 基板中心 [mm] | LED 並び方向 | 個数 | +| --- | --- | --- | --- | +| 下(Y-) | (0, -70.2, -65.3) | X 軸 | 6 | +| 右(X+) | (70.2, 0, -65.3) | Y 軸 | 6 | +| 左(X-) | (-70.2, 0, -65.3) | Y 軸 | 6 | + +各基板上の LED 6 個は基板の長手方向に約 8.7 mm 間隔で等間隔配置.総数 **18 個**. + +※ 左右基板の中心座標は STL の下基板(`LED基板カバー.stl`、bbox 中心 (0, -70.2, -65.3))を基準に Z 軸まわり ±90° 回転で算出した推定値.Fusion 360 設計の実位置と微小なズレがある可能性あり. + +## カメラ(センサ) + +評価板の **内側(平坦面)の照度マップを 2D 画像として直接取得** するため、orthographic(平行投影)センサを使う. + +| 項目 | 値 | 意図 | +| --- | --- | --- | +| `type` | `orthographic` | パースペクティブ歪みを排除し、評価板の各位置の輝度を pixel と 1 対 1 対応させる | +| `position` | (0, 0, -50) mm | 評価板の裏面(Z=-4.5)より少し離れ、かつ LED 基板(Z=-65.3)より評価板側.ray の経路に LED が入らない位置 | +| `target` | (0, 0, 0) mm | 評価板の方向(+Z)を見る | +| `ortho_scale` | (50, 52) mm | 評価板の bbox(X=99, Y=102 mm)に余裕を持たせた半幅 | + +カメラ位置が評価板に近すぎる(Z=-10 等)と Mitsuba の orthographic の near_clip 内に入って何も映らないので注意. + +## レンダリング設定(多重反射の扱い) + +Mitsuba 3 の **path tracer**(モンテカルロ光線追跡)を使用し、多重反射(インターリフレクション)まで含めて計算する. + +| 項目 | 値 | 意味 | +| --- | --- | --- | +| `integrator` | `path` | **多重反射を考慮した** path tracing.LED → 内壁 → 評価板 のような間接照明経路もすべて積分する | +| `max_depth` | 8 | ray の最大反射回数.8 回までの反射を追跡(実用上 MiniTIAS の白筐体内多重反射には十分) | +| `spp` | 128 | pixel あたりサンプル数.大きいほどノイズ減・計算重.キャリブ反復中の暫定値(512〜1024 に上げる予定) | +| `variant` | `llvm_ad_rgb` | LLVM JIT による RGB レンダリング.LLVM-C.dll が PATH(または DRJIT_LIBLLVM_PATH)で参照可能なこと | + +### 多重反射の意味 + +LED 光が評価板に届く経路は以下のすべてが計算される: + +1. **直接光**: LED → 評価板(評価板の表面に直接当たる光) +2. **1 回反射**: LED → 内壁 → 評価板 +3. **2 回反射**: LED → 内壁 → 内壁 → 評価板 +4. ... +5. **max_depth (8) 回反射まで** + +MiniTIAS は **白い筐体内壁の多重反射で照明を均一化** する設計のため、多重反射の正確な計算が CoV 値の再現に必須. + +### 制限 + +- 現状 Phase A のベイク([visualize/baker.py](../../src/sm_tias_lightsim/visualize/baker.py))は直接照度のみで多重反射を考慮しない.多重反射込みの照度マップを得るには本ドキュメント記載の path tracer(`sm-render`)を使う. +- `max_depth` を超える反射は無視される.白筐体(reflectance 0.85)で 8 回反射した光のエネルギーは初期の `0.85^8 ≈ 27%` まで減衰しているため、無視しても影響は小さい. + +## 筐体(材料) + +実機の筐体構造物(前側 / 後側 / LED 基板カバー / 評価板)は **すべて同じ 3D プリンタ白素材**(PLA / PETG / ABS の白フィラメント)で造形されている.スマホホルダのみ別素材.多重反射で照明を均一化する設計. + +| 部品 | 材料定義名 | 材料モデル | reflectance | 備考 | +| --- | --- | --- | --- | --- | +| 前側 / 後側(筐体) | `printer_white` | `diffuse` | 0.85 | 3D プリンタ白フィラメント(暫定値.実物の素材データシートで再校正) | +| LED 基板カバー | `printer_white` | `diffuse` | 0.85 | **同じ 3D プリンタ白素材.不透明な白プラの枠**で LED の周囲を囲む構造であり、LED 光が透過するディフューザーではない | +| 評価板 | `printer_white` | `diffuse` | 0.85 | 3D プリンタ白.照度マップ上の輝度から照度への換算は `E ≈ pixel × π / 0.85` | +| スマホホルダ | `holder_dark` | `diffuse` | 0.50 | 別素材(推定.要確認) | +| スマホ本体(SH-02M) | `phone_body_dark` | `diffuse` | 0.05 | 樹脂・金属の黒系 | + +### LED 基板カバーの取り扱いについて + +当初 `thindielectric`(半透明 PMMA)でディフューザーとしてモデル化していたが、実機は 3D プリンタ白素材(不透明)であることが判明したため `diffuse 0.85` に変更した.これにより: + +- LED 光は基板カバーを **透過しない**.Lambertian の発光面(AreaLight)から直接シーン空間に放出される +- 基板カバーの表面は **白プラとして拡散反射** に寄与し、多重反射の経路の一部になる +- 「半透明ディフューザーで散乱しない」問題は解消(そもそも実機にディフューザーは存在しなかった) + +## キャリブレーション目標(実測値) + +[PLAN_02](../03_PLAN/PLAN_02_開発ステップ.md) より: + +| 指標 | 実測値 | 仮置き許容範囲 | +| --- | --- | --- | +| CoV | 0.0426 | ±0.005 | +| 中心/周辺比 | 1.064 | ±0.02 | +| 勾配 (%) | 6.23 | ±1.5 | + +## 想定どおりの挙動(追加対応不要) + +以下は実機と一致する挙動として **意図的にそのままにしている** 項目: + +1. **評価板の楕円窓からの光漏れ**: 楕円窓は撮影用開口で、均一性測定時にも何も塞がない.シミュ上で楕円窓から外部に光が逃げる挙動は実機と整合. +2. **筐体開口部(スマホ取付穴等)からの光漏れ**: 同上.実機でも漏れる構造で、シミュも同じ. +3. **被写体(白板)は別途配置しない**: 「補正ボード」= 評価板(照明ムラ補正ボード)そのものを指す.楕円窓の奥に追加の白板は置かない. + +## 既知の差分(実機との不一致) + +Step 8 進行中に把握した、実機との未解消の差分: + +1. **3D プリンタ白素材の反射率の正確な値**: 暫定 `0.85` だが、実物のフィラメント(PLA / PETG / ABS のどれか)と分光反射率データで再校正が必要. +2. **左右 LED 基板の正確な位置・向き**: STL から推定した座標で、Fusion 360 設計とのズレがある可能性. +3. **LED 1 個あたりの実 W 値**: 暫定 `radiance=1.0 W/sr/m²`(≒ 3.08e-5 W).実機 SMD 2835 の規格値(~0.2 W)との校正係数を確定する必要. +4. **スマホホルダの実素材**: 推定 `holder_dark` (0.50).実物確認が必要. +5. **評価 ROI の不一致**: シミュ側は「評価板内側面の中央 256×256 pixel 矩形」で CoV を算出.MiniTias-Evaluation で実測値 CoV=0.0426 を出した際の ROI 設定(位置・形状)と一致しているか未確認. + +## 参照(References) + +- 実機写真: [REFERENCE/housing_interior_real.jpg](../../REFERENCE/housing_interior_real.jpg) +- LED 基板拡大: [REFERENCE/led_board_closeup.jpg](../../REFERENCE/led_board_closeup.jpg) +- Fusion 360 内部視点: [REFERENCE/fusion360_housing_interior.png](../../REFERENCE/fusion360_housing_interior.png) +- LED データシート: [REFERENCE/smd2835_datasheets.png](../../REFERENCE/smd2835_datasheets.png) +- YAML 仕様(一般形): [SPEC_02_シーン定義仕様](SPEC_02_シーン定義仕様.md) +- 基準設定の YAML 実体: [configs/scene_baseline.yaml](../../configs/scene_baseline.yaml) diff --git a/pyproject.toml b/pyproject.toml index 1cad8e0..c69bf61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ "matplotlib>=3.10.9", "mitsuba>=3.8.0", "numpy>=2.4.4", + "opencv-python>=4.13.0.92", "pandas>=3.0.2", "pillow>=12.2.0", "pydantic>=2.13.3", diff --git a/scripts/select_white_board.py b/scripts/select_white_board.py new file mode 100644 index 0000000..8e91fa7 --- /dev/null +++ b/scripts/select_white_board.py @@ -0,0 +1,88 @@ +"""実測 PNG で白色板の境界をマウスドラッグで選択する GUI スクリプト. + +使い方: + uv run python scripts/select_white_board.py \ + [--image reference_measurement/.../*.png] [--output configs/white_board_bbox.json] + +操作: + 1. 表示された画像で白色板の **境界を囲むように** マウスでドラッグして矩形を引く + 2. Enter または Space で確定 + 3. 結果が JSON に保存される + +備考: + 実画像は 2448x3264 と大きいので 30% 縮小表示する.座標は実寸に変換して保存. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import cv2 + + +def main() -> None: + parser = argparse.ArgumentParser(description="Select white board boundary in real PNG") + parser.add_argument( + "--image", + type=Path, + default=Path("reference_measurement/MiniTIAS_20260408_144434/MiniTIAS_20260408_144434.png"), + help="実測 PNG パス", + ) + parser.add_argument( + "--output", + type=Path, + default=Path("configs/white_board_bbox.json"), + help="出力 JSON パス", + ) + parser.add_argument("--scale", type=float, default=0.3, help="表示時の縮小率") + args = parser.parse_args() + + if not args.image.exists(): + msg = f"画像が見つかりません: {args.image}" + raise FileNotFoundError(msg) + + img = cv2.imread(str(args.image)) + if img is None: + msg = f"画像を読み込めません: {args.image}" + raise RuntimeError(msg) + h, w = img.shape[:2] + print(f"画像サイズ: {w} x {h}") + + small = cv2.resize(img, None, fx=args.scale, fy=args.scale) + print("白色板の境界をマウスでドラッグして選択してください(Enter/Space で確定)") + bbox_small = cv2.selectROI( + "Select white board (drag, then Enter/Space)", + small, + fromCenter=False, + showCrosshair=True, + ) + cv2.destroyAllWindows() + + if bbox_small == (0, 0, 0, 0): + print("選択がキャンセルされました") + return + + # 実寸に戻す + x = int(bbox_small[0] / args.scale) + y = int(bbox_small[1] / args.scale) + bw = int(bbox_small[2] / args.scale) + bh = int(bbox_small[3] / args.scale) + result = { + "image": str(args.image), + "x": x, + "y": y, + "width": bw, + "height": bh, + } + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"\n保存: {args.output}") + print(f"白色板 bbox: x={x}, y={y}, width={bw}, height={bh}") + print(f"画像中心からのオフセット: ({x + bw//2 - w//2}, {y + bh//2 - h//2})") + + +if __name__ == "__main__": + main() diff --git a/scripts/visualize_roi_3d.py b/scripts/visualize_roi_3d.py new file mode 100644 index 0000000..daa433f --- /dev/null +++ b/scripts/visualize_roi_3d.py @@ -0,0 +1,205 @@ +"""シミュ評価板 STL の上に ROI 矩形を 3D で重ねて表示するスクリプト. + +使い方: + uv run python scripts/visualize_roi_3d.py [--white-board configs/white_board_bbox.json] \ + [--real-roi-x 539 --real-roi-y 1015 --real-roi-w 1400 --real-roi-h 1409] \ + [--screenshot output/roi_3d.png] + +意図: + 実測 ROI が白色板内で占める「相対位置」を、シミュの評価板(同じ形状の白色板を想定)の + 物理サイズに変換し、評価板 STL の表面に 3D 矩形として重ねる. + 人間が見て「シミュの ROI が評価板のここに対応する」と確認するため. + +備考: + 白色板の物理サイズは「評価板の bbox(X=99, Y=102 mm)」を使う + (白色板 = 評価板の有効測定面と仮定). +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import numpy as np +import pyvista as pv +import trimesh + + +def main() -> None: + parser = argparse.ArgumentParser(description="Visualize ROI on eval board (3D)") + parser.add_argument("--white-board", type=Path, default=Path("configs/white_board_bbox.json")) + parser.add_argument("--real-roi-x", type=int, default=539) + parser.add_argument("--real-roi-y", type=int, default=1015) + parser.add_argument("--real-roi-w", type=int, default=1400) + parser.add_argument("--real-roi-h", type=int, default=1409) + parser.add_argument( + "--screenshot", type=Path, default=None, help="出力 PNG.未指定なら GUI を開く" + ) + parser.add_argument("--off-screen", action="store_true") + parser.add_argument( + "--flip-y", + action="store_true", + default=True, + # 実機の物理上下と画像 y の向きが逆の場合に反転する(デフォルト ON) + help="実測 PNG の y 軸を反転して物理 Y にマップ.デフォルト ON", + ) + parser.add_argument( + "--no-flip-y", + dest="flip_y", + action="store_false", + help="y 軸を反転しない", + ) + parser.add_argument( + "--flip-x", + action="store_true", + default=False, + help="実測 PNG の x 軸を反転して物理 X にマップ(実機の物理左右と画像 x の向きが逆の場合)", + ) + args = parser.parse_args() + + if not args.white_board.exists(): + msg = ( + f"白色板 bbox JSON が見つかりません: {args.white_board}\n" + "先に `uv run python scripts/select_white_board.py` を実行してください." + ) + raise FileNotFoundError(msg) + + wb = json.loads(args.white_board.read_text(encoding="utf-8")) + wb_x, wb_y, wb_w, wb_h = wb["x"], wb["y"], wb["width"], wb["height"] + print(f"白色板 bbox (real PNG): x={wb_x}, y={wb_y}, w={wb_w}, h={wb_h}") + + rx, ry, rw, rh = args.real_roi_x, args.real_roi_y, args.real_roi_w, args.real_roi_h + print(f"実測 ROI: x={rx}, y={ry}, w={rw}, h={rh}") + + # ROI の相対位置(白色板内 0〜1) + rel_x = (rx - wb_x) / wb_w + rel_y = (ry - wb_y) / wb_h + rel_w = rw / wb_w + rel_h = rh / wb_h + print(f" 相対位置: x={rel_x:.3f}, y={rel_y:.3f}, w={rel_w:.3f}, h={rel_h:.3f}") + + # 評価板 STL を読み込み + eval_stl = Path("configs/meshes/照明ムラ補正ボード.stl") + m = trimesh.load(eval_stl, force="mesh") + print(f"\n評価板 STL bbox (mm): min={m.bounds[0]}, max={m.bounds[1]}") + board_xmin, board_xmax = m.bounds[0, 0], m.bounds[1, 0] + board_ymin, board_ymax = m.bounds[0, 1], m.bounds[1, 1] + board_w = board_xmax - board_xmin + board_h = board_ymax - board_ymin + + # ROI の評価板内 mm 範囲 + if args.flip_x: + flipped_x_start = 1.0 - (rel_x + rel_w) + roi_xmin_mm = board_xmin + flipped_x_start * board_w + roi_xmax_mm = roi_xmin_mm + rel_w * board_w + print(f" X 反転適用: rel_x_orig={rel_x:.3f} → flipped={flipped_x_start:.3f}") + else: + roi_xmin_mm = board_xmin + rel_x * board_w + roi_xmax_mm = roi_xmin_mm + rel_w * board_w + if args.flip_y: + # 実測 PNG の y 増加(画像下方向)= 物理 Y 減少と仮定して反転 + # 白色板内 y 相対位置 [rel_y, rel_y+rel_h] を 1.0 から引いて反転 + flipped_y_start = 1.0 - (rel_y + rel_h) + roi_ymin_mm = board_ymin + flipped_y_start * board_h + roi_ymax_mm = roi_ymin_mm + rel_h * board_h + print(f" Y 反転適用: rel_y_orig={rel_y:.3f} → flipped={flipped_y_start:.3f}") + else: + roi_ymin_mm = board_ymin + rel_y * board_h + roi_ymax_mm = roi_ymin_mm + rel_h * board_h + + # 評価板の裏面(平坦面、-Z 法線、Z=最小)に ROI を重ねる + # 表面は凹凸が多くて視認しにくいため平坦な裏面を採用 + z_bottom = m.bounds[0, 2] + z_offset = -1.0 # 裏面のさらに -Z 方向に矩形を浮かせて視認性確保 + + print("\nROI の評価板上 mm 範囲:") + print(f" X: [{roi_xmin_mm:.2f}, {roi_xmax_mm:.2f}]") + print(f" Y: [{roi_ymin_mm:.2f}, {roi_ymax_mm:.2f}]") + print(f" Z: {z_bottom + z_offset:.2f}(裏面 Z={z_bottom:.2f} のさらに {z_offset}mm 下)") + + # PyVista で表示 + plotter = pv.Plotter( + off_screen=args.off_screen or args.screenshot is not None, + window_size=(1400, 1000), + ) + + # ----- シーン全体のメッシュを追加 ----- + scene_meshes = [ + ("照明ムラ補正ボード.stl", "lightgray", 0.85, "評価板"), + ("前側.stl", "white", 0.20, "前側筐体"), + ("後側.stl", "white", 0.20, "後側筐体"), + ("スマホホルダ.stl", "tan", 0.30, "スマホホルダ"), + ("LED基板カバー.stl", "lightblue", 0.40, "LED 基板カバー"), + ("SH-02M.stl", "dimgray", 0.30, "スマホ"), + ] + for stl_name, color, opacity, label in scene_meshes: + stl_path = Path(f"configs/meshes/{stl_name}") + if not stl_path.exists(): + continue + mesh = trimesh.load(stl_path, force="mesh") + v = mesh.vertices * 0.001 + f = np.hstack([np.full((len(mesh.faces), 1), 3), mesh.faces]).flatten() + plotter.add_mesh(pv.PolyData(v, f), color=color, opacity=opacity, label=label) + + # LED 18 個を黄色い小球で表示 + import yaml + cfg = yaml.safe_load(Path("configs/scene_baseline.yaml").read_text(encoding="utf-8")) + for light in cfg.get("lights", []): + if light.get("type") == "area": + cx, cy, cz = light["center"] + pos = np.array([cx, cy, cz]) * 0.001 + plotter.add_mesh(pv.Sphere(radius=0.003, center=pos), color="yellow") + + # ROI 矩形(評価板の裏面 -Z 側に浮かせて表示) + roi_corners_mm = np.array([ + [roi_xmin_mm, roi_ymin_mm, z_bottom + z_offset], + [roi_xmax_mm, roi_ymin_mm, z_bottom + z_offset], + [roi_xmax_mm, roi_ymax_mm, z_bottom + z_offset], + [roi_xmin_mm, roi_ymax_mm, z_bottom + z_offset], + ]) + roi_corners_m = roi_corners_mm * 0.001 + # 矩形を 2 三角形で + faces = np.array([[3, 0, 1, 2], [3, 0, 2, 3]]).flatten() + roi_poly = pv.PolyData(roi_corners_m, faces) + plotter.add_mesh(roi_poly, color="red", opacity=0.5, label="ROI(実測対応)") + # 矩形の枠線も + for i in range(4): + a = roi_corners_m[i] + b = roi_corners_m[(i + 1) % 4] + line = pv.Line(a, b) + plotter.add_mesh(line, color="red", line_width=5) + + plotter.add_axes() + annotation = ( + f"Red = ROI on eval board BACK surface (flat, -Z side)\n" + f"Range (mm): X=[{roi_xmin_mm:.1f},{roi_xmax_mm:.1f}]" + f" Y=[{roi_ymin_mm:.1f},{roi_ymax_mm:.1f}]" + ) + plotter.add_text(annotation, position="upper_left", font_size=10, color="black") + + # 座標軸(X+/Y+/Z+ の向きが見える) + plotter.add_axes(interactive=True) + plotter.show_bounds( + grid="back", + location="outer", + xtitle="X [m]", + ytitle="Y [m]", + ztitle="Z [m]", + all_edges=True, + ) + + # デフォルトの視点(screenshot 時のみ.GUI 時はマウスでぐりぐり回せる) + if args.screenshot is not None: + plotter.camera_position = [(0.15, -0.20, 0.05), (0, 0, -0.05), (0, 0, 1)] + + if args.screenshot is not None: + args.screenshot.parent.mkdir(parents=True, exist_ok=True) + plotter.show(screenshot=str(args.screenshot)) + print(f"\n保存: {args.screenshot}") + else: + plotter.show() + + +if __name__ == "__main__": + main() diff --git a/src/sm_tias_lightsim/cli/evaluate.py b/src/sm_tias_lightsim/cli/evaluate.py index 565964a..a0f90d3 100644 --- a/src/sm_tias_lightsim/cli/evaluate.py +++ b/src/sm_tias_lightsim/cli/evaluate.py @@ -237,7 +237,7 @@ save_radial_profile(radial_profile, figures_dir / _RADIAL_PROFILE_FILENAME) if zone_map is not None: - save_zone_map(zone_map, figures_dir / _ZONE_MAP_FILENAME) + save_zone_map(zone_map, figures_dir / _ZONE_MAP_FILENAME, luminance=roi_luminance) def _print_summary(metrics: dict, csv_path: Path, figures_dir: Path) -> None: diff --git a/src/sm_tias_lightsim/evaluation/visualize.py b/src/sm_tias_lightsim/evaluation/visualize.py index 36d3265..aa43002 100644 --- a/src/sm_tias_lightsim/evaluation/visualize.py +++ b/src/sm_tias_lightsim/evaluation/visualize.py @@ -5,6 +5,9 @@ 各関数は figure を生成 → savefig → plt.close(fig) の形式で実装し, メモリリークを防ぐ. + +カラーマップ・軸ラベル・全体のスタイルは MiniTias-Evaluation の出力と揃えている +(実測 PNG とシミュ PNG を並べて比較できるように). """ from __future__ import annotations @@ -19,17 +22,21 @@ matplotlib.use("Agg") import matplotlib.figure # noqa: E402 -import matplotlib.patches # noqa: E402 import matplotlib.pyplot as plt # noqa: E402 import numpy # noqa: E402 -# ゾーンラベルと RGB 値(classify_zones の 0=center/1=middle/2=periphery に対応) -_ZONE_LABELS = ["center", "middle", "periphery"] -_ZONE_COLORS_HEX = ["#e74c3c", "#f39c12", "#2ecc71"] -# hex カラーを [0, 1] RGB タプルに変換しておく(save_zone_map 呼び出しごとの変換を避けるため) -_ZONE_COLORS_RGB: list[tuple[float, float, float]] = [ +# MiniTias-Evaluation の出力に合わせて hot カラーマップを統一使用する. +_LUMINANCE_CMAP = "hot" + +# ゾーン境界の表示色(中心-中間,中間-周辺の 2 本) +_ZONE_BOUNDARY_COLORS = ["cyan", "lime"] + +# フォールバック描画(luminance=None 時)のゾーン塗り分け色(center/middle/periphery) +_ZONE_FALLBACK_COLORS_HEX = ["#e74c3c", "#f39c12", "#2ecc71"] +# hex カラーを [0, 1] RGB タプルに変換済み(save_zone_map 呼び出しごとの変換を避けるため) +_ZONE_FALLBACK_COLORS_RGB: list[tuple[float, float, float]] = [ (int(h[1:3], 16) / 255.0, int(h[3:5], 16) / 255.0, int(h[5:7], 16) / 255.0) - for h in _ZONE_COLORS_HEX + for h in _ZONE_FALLBACK_COLORS_HEX ] @@ -56,27 +63,30 @@ output_path: 保存先 PNG パス. """ fig, ax = plt.subplots(figsize=(6, 5)) - im = ax.imshow(luminance, cmap="viridis", origin="upper") + im = ax.imshow(luminance, cmap=_LUMINANCE_CMAP, origin="upper") fig.colorbar(im, ax=ax, label="Luminance") ax.set_title("Luminance Map") - ax.set_xlabel("X [px]") - ax.set_ylabel("Y [px]") + ax.set_xlabel("X (px)") + ax.set_ylabel("Y (px)") _save_figure(fig, output_path) def save_histogram(luminance: numpy.ndarray, output_path: Path, bins: int = 50) -> None: """輝度ヒストグラムを PNG として保存する. + 横軸は uint8 全範囲(0〜255)に固定して MiniTias-Evaluation と比較しやすくする. + Args: luminance: 輝度画像(H x W, float). output_path: 保存先 PNG パス. bins: ヒストグラムのビン数.デフォルトは 50. """ - fig, ax = plt.subplots(figsize=(6, 4)) - ax.hist(luminance.ravel(), bins=bins, color="#3498db", edgecolor="none", alpha=0.8) + fig, ax = plt.subplots(figsize=(8, 5)) + ax.hist(luminance.ravel(), bins=bins, color="#1f77b4", edgecolor="none") ax.set_title("Luminance Histogram") ax.set_xlabel("Luminance") ax.set_ylabel("Pixel Count") + ax.set_xlim(0, 255) _save_figure(fig, output_path) @@ -93,8 +103,8 @@ distances = profile[:, 0] mean_luminance = profile[:, 1] - fig, ax = plt.subplots(figsize=(6, 4)) - ax.plot(distances, mean_luminance, marker="o", markersize=3, linewidth=1.5, color="#2c3e50") + fig, ax = plt.subplots(figsize=(8, 5)) + ax.plot(distances, mean_luminance, marker="o", markersize=6, linewidth=2, color="#1f77b4") ax.set_title("Radial Luminance Profile") ax.set_xlabel("Normalized Distance from Center") ax.set_ylabel("Mean Luminance") @@ -103,35 +113,48 @@ _save_figure(fig, output_path) -def save_zone_map(zone_map: numpy.ndarray, output_path: Path) -> None: - """ゾーンマップを 3 色で PNG として保存する. +def save_zone_map( + zone_map: numpy.ndarray, + output_path: Path, + luminance: numpy.ndarray | None = None, +) -> None: + """ゾーンマップを PNG として保存する. - `evaluation/zones.classify_zones` が返す int8 配列(0=center, 1=middle, 2=periphery) - を 3 色に着色して凡例付きで保存する. + MiniTias-Evaluation 互換:背景に輝度マップ(hot)を表示し、 + その上にゾーン境界(center-middle,middle-periphery)を線で重ねる. + + `luminance` が None の場合は zone_map のみを 3 色で塗り分けるフォールバック動作. Args: zone_map: ゾーンラベルマップ(shape: (H, W), dtype: int8). 値は 0(center), 1(middle), 2(periphery). output_path: 保存先 PNG パス. + luminance: 背景に重ねる輝度マップ(H x W, float).None ならゾーンの色塗りのみ. """ - height, width = zone_map.shape - rgb = numpy.zeros((height, width, 3), dtype=numpy.float32) - - for label, color_rgb in enumerate(_ZONE_COLORS_RGB): - mask = zone_map == label - rgb[mask] = color_rgb - fig, ax = plt.subplots(figsize=(6, 5)) - ax.imshow(rgb, origin="upper") - ax.set_title("Zone Map") - ax.set_xlabel("X [px]") - ax.set_ylabel("Y [px]") - # imshow は自動凡例を持たないため,凡例パッチを手動で生成する - legend_patches = [ - matplotlib.patches.Patch(color=_ZONE_COLORS_HEX[i], label=_ZONE_LABELS[i]) - for i in range(len(_ZONE_LABELS)) - ] - ax.legend(handles=legend_patches, loc="upper right") + if luminance is not None: + im = ax.imshow(luminance, cmap=_LUMINANCE_CMAP, origin="upper") + fig.colorbar(im, ax=ax, label="Luminance") + # ゾーン境界線(zone_map のラベル変化を contour で抽出) + # zone_map: 0/1/2 → 境界は level=0.5(0-1)と level=1.5(1-2) + ax.contour( + zone_map.astype(float), + levels=[0.5, 1.5], + colors=_ZONE_BOUNDARY_COLORS, + linewidths=2, + ) + else: + # フォールバック: 3 色塗り分け + height, width = zone_map.shape + rgb = numpy.zeros((height, width, 3), dtype=numpy.float32) + for label, color_rgb in enumerate(_ZONE_FALLBACK_COLORS_RGB): + mask = zone_map == label + rgb[mask] = color_rgb + ax.imshow(rgb, origin="upper") + + ax.set_title("Zone Map (center / middle / periphery)") + ax.set_xlabel("X (px)") + ax.set_ylabel("Y (px)") _save_figure(fig, output_path) diff --git a/src/sm_tias_lightsim/scene/schema.py b/src/sm_tias_lightsim/scene/schema.py index 47bf488..b49cff3 100644 --- a/src/sm_tias_lightsim/scene/schema.py +++ b/src/sm_tias_lightsim/scene/schema.py @@ -41,24 +41,48 @@ class SensorConfig(_FrozenBase): - """カメラセンサ設定.現在は perspective のみ対応.""" + """カメラセンサ設定.perspective または orthographic に対応. - type: Literal["perspective"] + - perspective: 通常のカメラ.fov_deg を必須. + - orthographic: 平行投影.ortho_scale (mm) を必須(撮影範囲の半幅 (sx, sy)). + 画像座標は X 軸が ±sx,Y 軸が ±sy にマップされる. + """ + + type: Literal["perspective", "orthographic"] position: Vec3 = Field(..., description="カメラ位置 [mm]") target: Vec3 = Field(..., description="注視点 [mm]") up: Vec3 = Field(..., description="上方向ベクトル") - fov_deg: float = Field(..., description="水平方向 FOV [deg]") + fov_deg: float | None = Field( + default=None, description="水平方向 FOV [deg].perspective のみ必須" + ) + ortho_scale: tuple[float, float] | None = Field( + default=None, + description="撮影範囲の半幅 (sx, sy) [mm].orthographic のみ必須", + ) film: FilmConfig @field_validator("fov_deg") @classmethod - def _validate_fov(cls, v: float) -> float: + def _validate_fov(cls, v: float | None) -> float | None: """FOV が 0 < fov_deg < 180 の範囲内であることを検証する.""" + if v is None: + return v if not (0.0 < v < 180.0): msg = f"fov_deg must be in range (0, 180), got {v}" raise ValueError(msg) return v + @model_validator(mode="after") + def _validate_type_specific_fields(self) -> SensorConfig: + """type に応じた必須フィールドの有無を検証する.""" + if self.type == "perspective" and self.fov_deg is None: + msg = "perspective sensor requires fov_deg" + raise ValueError(msg) + if self.type == "orthographic" and self.ortho_scale is None: + msg = "orthographic sensor requires ortho_scale (sx, sy) in mm" + raise ValueError(msg) + return self + # --------------------------------------------------------------------------- # Render diff --git a/src/sm_tias_lightsim/scene/sensors.py b/src/sm_tias_lightsim/scene/sensors.py index 3813b7f..24de308 100644 --- a/src/sm_tias_lightsim/scene/sensors.py +++ b/src/sm_tias_lightsim/scene/sensors.py @@ -18,25 +18,45 @@ position_scale: 座標スケール係数(mm→m なら 0.001).position/target にも適用. Returns: - Mitsuba sensor dict(perspective). + Mitsuba sensor dict(perspective または orthographic). """ pos = [c * position_scale for c in sensor.position] target = [c * position_scale for c in sensor.target] - # 方向ベクトルは無次元なのでスケール不要 up = list(sensor.up) - to_world = mi.ScalarTransform4f().look_at(origin=pos, target=target, up=up) + film = { + "type": "hdrfilm", + "width": sensor.film.width, + "height": sensor.film.height, + "rfilter": {"type": sensor.film.rfilter}, + } + sampler = {"type": "independent", "sample_count": 1} + if sensor.type == "perspective": + to_world = mi.ScalarTransform4f().look_at(origin=pos, target=target, up=up) + return { + "type": "perspective", + "fov": sensor.fov_deg, + "to_world": to_world, + "film": film, + "sampler": sampler, + } + + # orthographic: ortho_scale (mm) を画像幅・高さの半幅として使う. + # Mitsuba の orthographic は clip-space X,Y が ±1 → look_at × scale で実空間範囲を決める. + if sensor.ortho_scale is None: + msg = "orthographic sensor requires ortho_scale (validated in schema)" + raise RuntimeError(msg) + sx_m = sensor.ortho_scale[0] * position_scale + sy_m = sensor.ortho_scale[1] * position_scale + to_world = ( + mi.ScalarTransform4f().look_at(origin=pos, target=target, up=up) + # Z 方向はスケールしない(奥行き軸はそのまま look_at の視錐台に従う) + @ mi.ScalarTransform4f().scale([sx_m, sy_m, 1.0]) + ) return { - "type": "perspective", - "fov": sensor.fov_deg, + "type": "orthographic", "to_world": to_world, - "film": { - "type": "hdrfilm", - "width": sensor.film.width, - "height": sensor.film.height, - "rfilter": {"type": sensor.film.rfilter}, - }, - # spp は render 時に渡すので置きの値 - "sampler": {"type": "independent", "sample_count": 1}, + "film": film, + "sampler": sampler, } diff --git a/tests/cli/test_evaluate.py b/tests/cli/test_evaluate.py index 7258c7b..e267630 100644 --- a/tests/cli/test_evaluate.py +++ b/tests/cli/test_evaluate.py @@ -580,6 +580,46 @@ # --------------------------------------------------------------------------- +# Step 8 追加: zone_map に luminance が渡されること(CLI 統合テスト) +# --------------------------------------------------------------------------- + + +def test_zone_map_png_is_created_with_luminance_background(tmp_path: Path): + """zones が有効な場合,zone_map.png が生成されること(CLI レベルの確認). + + 仕様: zones.enabled のとき zone_map.png が figures/ に出力される + Step 8 変更: save_zone_map に luminance=roi_luminance を渡す + (cli/evaluate.py _save_figures: save_zone_map(zone_map, ..., luminance=roi_luminance)) + CLI レベルでは「PNG 生成成功」のみ確認する(API レベルのテストは test_visualize.py で担保) + """ + luminance = _make_luminance_array(seed=100) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + config_path = _make_eval_yaml(tmp_path, zones_enabled=True) + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + zone_map_path = output_dir / "figures" / "zone_map.png" + assert zone_map_path.exists() + assert zone_map_path.stat().st_size > 0 + + +def test_zone_map_png_is_not_created_when_zones_disabled(tmp_path: Path): + """zones.enabled=false の場合,zone_map.png が生成されないこと(後方互換). + + 仕様: zone_map が None のとき save_zone_map は呼ばれない(cli/evaluate.py _save_figures) + """ + luminance = _make_luminance_array(seed=101) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + config_path = _make_eval_yaml(tmp_path, zones_enabled=False) + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + assert not (output_dir / "figures" / "zone_map.png").exists() + + +# --------------------------------------------------------------------------- # 14. 短縮引数 -i / -c / -o # --------------------------------------------------------------------------- diff --git a/tests/evaluation/test_visualize.py b/tests/evaluation/test_visualize.py index 382ad06..cdb3718 100644 --- a/tests/evaluation/test_visualize.py +++ b/tests/evaluation/test_visualize.py @@ -199,3 +199,67 @@ save_zone_map(zone_map, output_path) assert output_path.exists() assert output_path.stat().st_size > 0 + + +# --------------------------------------------------------------------------- +# Step 8 追加: save_zone_map のシグネチャ拡張テスト +# --------------------------------------------------------------------------- + + +def test_save_zone_map_luminance_none_backward_compat_creates_file(tmp_path: Path): + """luminance=None で呼び出しても例外なく PNG が生成されること(後方互換性). + + 仕様: luminance が None の場合は zone_map のみを 3 色で塗り分けるフォールバック動作 + (visualize.py save_zone_map) + """ + from sm_tias_lightsim.evaluation.zones import classify_zones, compute_normalized_distance_map + + distance_map = compute_normalized_distance_map(32, 32) + zone_map = classify_zones(distance_map) + output_path = tmp_path / "zone_map_no_luminance.png" + save_zone_map(zone_map, output_path, luminance=None) + assert output_path.exists() + assert output_path.stat().st_size > 0 + + +def test_save_zone_map_with_luminance_creates_file(tmp_path: Path): + """luminance を渡すと例外なく PNG が生成されること(新機能). + + 仕様: luminance 指定時は背景に hot luminance + ゾーン境界線を重ねる + (visualize.py save_zone_map) + """ + from sm_tias_lightsim.evaluation.zones import classify_zones, compute_normalized_distance_map + + luminance = _make_luminance(size=32) + distance_map = compute_normalized_distance_map(32, 32) + zone_map = classify_zones(distance_map) + output_path = tmp_path / "zone_map_with_luminance.png" + save_zone_map(zone_map, output_path, luminance=luminance) + assert output_path.exists() + assert output_path.stat().st_size > 0 + + +def test_save_zone_map_with_luminance_larger_than_without(tmp_path: Path): + """luminance を渡したときのファイルサイズが luminance=None より大きいこと. + + 仕様: luminance 指定時は背景に hot luminance + 境界線が加わるため情報量が増える + (visualize.py save_zone_map) + 注: 厳密な画像 diff は行わず「ファイルサイズの差分」でのみ確認する. + """ + from sm_tias_lightsim.evaluation.zones import classify_zones, compute_normalized_distance_map + + luminance = _make_luminance(size=64) + distance_map = compute_normalized_distance_map(64, 64) + zone_map = classify_zones(distance_map) + + path_no_lum = tmp_path / "zone_map_no_lum.png" + path_with_lum = tmp_path / "zone_map_with_lum.png" + + save_zone_map(zone_map, path_no_lum, luminance=None) + save_zone_map(zone_map, path_with_lum, luminance=luminance) + + size_no_lum = path_no_lum.stat().st_size + size_with_lum = path_with_lum.stat().st_size + + # 背景輝度+カラーバー+境界線で情報量が増えるため,ファイルサイズも大きくなる + assert size_with_lum > size_no_lum diff --git a/tests/scene/test_schema.py b/tests/scene/test_schema.py index 7a4480a..d994330 100644 --- a/tests/scene/test_schema.py +++ b/tests/scene/test_schema.py @@ -404,12 +404,12 @@ def test_invalid_sensor_type_raises(valid_minimal_dict): - """type: orthographic を指定したとき ValidationError が発生すること. + """type: fisheye 等のサポート外センサ型で ValidationError が発生すること. - 仕様: sensor.type は perspective のみ (SPEC_02 sensor セクション) + 仕様: sensor.type は perspective | orthographic の Literal (schema.py SensorConfig) """ d = copy.deepcopy(valid_minimal_dict) - d["sensor"]["type"] = "orthographic" + d["sensor"]["type"] = "fisheye" with pytest.raises(ValidationError): SceneConfig.model_validate(d) @@ -503,6 +503,80 @@ # --------------------------------------------------------------------------- +# 正常系: SensorConfig orthographic 対応(Step 8 追加) +# --------------------------------------------------------------------------- + + +def test_sensor_perspective_with_fov_is_valid(valid_minimal_dict): + """type=perspective + fov_deg=60 のとき検証が通過し,ortho_scale は None で OK なこと. + + 仕様: type=perspective のとき fov_deg 必須,ortho_scale は任意(schema.py SensorConfig) + """ + d = copy.deepcopy(valid_minimal_dict) + d["sensor"]["type"] = "perspective" + d["sensor"]["fov_deg"] = 60.0 + d["sensor"].pop("ortho_scale", None) + config = SceneConfig.model_validate(d) + assert config.sensor.type == "perspective" + assert config.sensor.fov_deg == pytest.approx(60.0) + assert config.sensor.ortho_scale is None + + +def test_sensor_orthographic_with_ortho_scale_is_valid(valid_minimal_dict): + """type=orthographic + ortho_scale=(50, 52) のとき検証が通過し,fov_deg は None で OK なこと. + + 仕様: type=orthographic のとき ortho_scale 必須,fov_deg は任意(schema.py SensorConfig) + """ + d = copy.deepcopy(valid_minimal_dict) + d["sensor"]["type"] = "orthographic" + d["sensor"].pop("fov_deg", None) + d["sensor"]["ortho_scale"] = [50.0, 52.0] + config = SceneConfig.model_validate(d) + assert config.sensor.type == "orthographic" + assert config.sensor.fov_deg is None + assert config.sensor.ortho_scale == pytest.approx((50.0, 52.0)) + + +def test_sensor_perspective_without_fov_deg_raises(valid_minimal_dict): + """type=perspective かつ fov_deg=None のとき ValidationError が発生すること. + + 仕様: perspective sensor requires fov_deg(schema.py _validate_type_specific_fields) + """ + d = copy.deepcopy(valid_minimal_dict) + d["sensor"]["type"] = "perspective" + d["sensor"]["fov_deg"] = None + with pytest.raises(ValidationError) as exc_info: + SceneConfig.model_validate(d) + assert "perspective sensor requires fov_deg" in str(exc_info.value) + + +def test_sensor_orthographic_without_ortho_scale_raises(valid_minimal_dict): + """type=orthographic かつ ortho_scale=None のとき ValidationError が発生すること. + + 仕様: orthographic sensor requires ortho_scale(schema.py _validate_type_specific_fields) + """ + d = copy.deepcopy(valid_minimal_dict) + d["sensor"]["type"] = "orthographic" + d["sensor"].pop("fov_deg", None) + d["sensor"]["ortho_scale"] = None + with pytest.raises(ValidationError) as exc_info: + SceneConfig.model_validate(d) + assert "orthographic sensor requires ortho_scale" in str(exc_info.value) + + +def test_sensor_perspective_fov_deg_200_raises(valid_minimal_dict): + """type=perspective かつ fov_deg=200 のとき既存の FOV 範囲チェックで ValidationError が発生すること. + + 仕様: 0 < fov_deg < 180(後方互換)(schema.py _validate_fov) + """ + d = copy.deepcopy(valid_minimal_dict) + d["sensor"]["type"] = "perspective" + d["sensor"]["fov_deg"] = 200.0 + with pytest.raises(ValidationError): + SceneConfig.model_validate(d) + + +# --------------------------------------------------------------------------- # イミュータビリティ # --------------------------------------------------------------------------- diff --git a/tests/scene/test_sensors.py b/tests/scene/test_sensors.py index d605fa3..d960e9d 100644 --- a/tests/scene/test_sensors.py +++ b/tests/scene/test_sensors.py @@ -26,7 +26,7 @@ height=480, rfilter="gaussian", ) -> SensorConfig: - """テスト用 SensorConfig を組み立てるヘルパ.""" + """テスト用 perspective SensorConfig を組み立てるヘルパ.""" return SensorConfig( type="perspective", position=position, @@ -37,6 +37,28 @@ ) +def _make_ortho_sensor( + *, + position=(0.0, 0.0, 100.0), + target=(0.0, 0.0, 0.0), + up=(0.0, 1.0, 0.0), + ortho_scale=(50.0, 52.0), + width=640, + height=480, + rfilter="gaussian", +) -> SensorConfig: + """テスト用 orthographic SensorConfig を組み立てるヘルパ.""" + return SensorConfig( + type="orthographic", + position=position, + target=target, + up=up, + fov_deg=None, + ortho_scale=ortho_scale, + film=FilmConfig(width=width, height=height, rfilter=rfilter), + ) + + # --------------------------------------------------------------------------- # type # --------------------------------------------------------------------------- @@ -168,3 +190,84 @@ result_mm = build_sensor_dict(sensor_mm, position_scale=0.001) result_m = build_sensor_dict(sensor_mm, position_scale=1.0) assert str(result_mm["to_world"]) != str(result_m["to_world"]) + + +# --------------------------------------------------------------------------- +# orthographic センサ対応(Step 8 追加) +# --------------------------------------------------------------------------- + + +def test_ortho_sensor_type_is_orthographic(): + """orthographic SensorConfig の build_sensor_dict 戻り値 type が "orthographic" であること. + + 仕様: sensor.type = orthographic → Mitsuba sensor dict の type = "orthographic" + (sensors.py build_sensor_dict 分岐) + """ + sensor = _make_ortho_sensor() + result = build_sensor_dict(sensor, position_scale=0.001) + assert result["type"] == "orthographic" + + +def test_ortho_sensor_has_no_fov_key(): + """orthographic センサの dict に "fov" キーが存在しないこと. + + 仕様: orthographic では fov は不要(Mitsuba orthographic は scale で撮影範囲を決める) + (sensors.py build_sensor_dict 分岐) + """ + sensor = _make_ortho_sensor() + result = build_sensor_dict(sensor, position_scale=0.001) + assert "fov" not in result + + +def test_ortho_sensor_has_to_world_key(): + """orthographic センサの dict に "to_world" キーが存在すること. + + 仕様: orthographic sensor は look_at × scale で to_world を生成する + (sensors.py build_sensor_dict 分岐) + """ + sensor = _make_ortho_sensor() + result = build_sensor_dict(sensor, position_scale=0.001) + assert "to_world" in result + + +def test_ortho_sensor_has_film_and_sampler(): + """orthographic センサの dict に "film" と "sampler" が含まれること. + + 仕様: perspective / orthographic 共通で film: hdrfilm, sampler: independent を持つ + (sensors.py build_sensor_dict) + """ + sensor = _make_ortho_sensor() + result = build_sensor_dict(sensor, position_scale=0.001) + assert result["film"]["type"] == "hdrfilm" + assert result["sampler"]["type"] == "independent" + + +def test_ortho_sensor_to_world_scale_applied(): + """ortho_scale=(50, 52) + position_scale=0.001 のとき,perspective センサと to_world が異なること. + + 仕様: orthographic の to_world は look_at × scale(sx*ps, sy*ps, 1.0) で構築される + perspective の to_world は look_at のみのため,両者は必ず異なる + (sensors.py build_sensor_dict 分岐) + """ + position = (0.0, 0.0, 100.0) + target = (0.0, 0.0, 0.0) + up = (0.0, 1.0, 0.0) + ortho_sensor = _make_ortho_sensor(position=position, target=target, up=up, ortho_scale=(50.0, 52.0)) + persp_sensor = _make_sensor(position=position, target=target, up=up, fov_deg=60.0) + ortho_result = build_sensor_dict(ortho_sensor, position_scale=0.001) + persp_result = build_sensor_dict(persp_sensor, position_scale=0.001) + # scale が追加された分,to_world 文字列表現が異なるはず + assert str(ortho_result["to_world"]) != str(persp_result["to_world"]) + + +def test_ortho_sensor_different_scales_produce_different_to_world(): + """ortho_scale が異なると to_world が変わること. + + 仕様: ortho_scale の値が to_world の scale 変換に反映される + (sensors.py build_sensor_dict: sx_m = ortho_scale[0] * position_scale) + """ + sensor_small = _make_ortho_sensor(ortho_scale=(25.0, 26.0)) + sensor_large = _make_ortho_sensor(ortho_scale=(50.0, 52.0)) + result_small = build_sensor_dict(sensor_small, position_scale=0.001) + result_large = build_sensor_dict(sensor_large, position_scale=0.001) + assert str(result_small["to_world"]) != str(result_large["to_world"]) diff --git a/uv.lock b/uv.lock index f2df0f6..36fe48f 100644 --- a/uv.lock +++ b/uv.lock @@ -558,6 +558,24 @@ ] [[package]] +name = "opencv-python" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" }, + { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, +] + +[[package]] name = "packaging" version = "26.2" source = { registry = "https://pypi.org/simple" } @@ -1012,6 +1030,7 @@ { name = "matplotlib" }, { name = "mitsuba" }, { name = "numpy" }, + { name = "opencv-python" }, { name = "pandas" }, { name = "pillow" }, { name = "pydantic" }, @@ -1031,6 +1050,7 @@ { name = "matplotlib", specifier = ">=3.10.9" }, { name = "mitsuba", specifier = ">=3.8.0" }, { name = "numpy", specifier = ">=2.4.4" }, + { name = "opencv-python", specifier = ">=4.13.0.92" }, { name = "pandas", specifier = ">=3.0.2" }, { name = "pillow", specifier = ">=12.2.0" }, { name = "pydantic", specifier = ">=2.13.3" },