diff --git a/CLAUDE.md b/CLAUDE.md index bc6f8e2..e7867c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,12 @@ 補足: **作業フェーズを A/B に分割(2026-06-01)**.「連射再現性が確保できてもシミュとの対応が取れなければ意味が薄い」という研究上の判断から,**フェーズ A: 1 ショット撮影でシミュ vs 実機対応を先に検証** → **フェーズ B: 対応が取れたら連射 V1 再現性検証**の二段階方式に変更.フェーズ A で MiniTIAS 側に依頼するのは **画素統計・Hot pixel map(高優先 2 件)と白板 1 ショット試し撮りのみ**.**連射 UI は高優先→フェーズ B に降格**.SmTIAS-LightSim 側は DNG/meta.json/LSC マップ読み込み I/F・新規 baseline config を実装し,1 ショット突合で既存通常モード baseline 残差 2.1× が定量モードで変化するかを観察.結果次第でフェーズ B 移行 or SHADING_MODE フォールバック条件 C1 検討 or 物理モデル見直しを判断.[TEST_01](docs/06_TEST/TEST_01_キャリブレーション検証履歴.md) に **Step 8-13: 定量モード 1 ショット対応検証** として記録予定. 補足: **DNG 対応を MiniTias-Evaluation 側にも依頼(2026-06-01)**.PNG から DNG への撮影フォーマット変更に伴い,MiniTias-Evaluation も実機データの一次窓口として DNG 読み込み I/F の追加が必要.既存パターン(`uniformity.py` を MiniTias-Evaluation から SmTIAS-LightSim にコピー)を踏襲する役割分担を採用:**MiniTias-Evaluation 側で正規実装 → SmTIAS-LightSim 側にコピー**.[TECH_05 MiniTias-Evaluation DNG 対応要求仕様](docs/05_TECH/TECH_05_MiniTiasEvaluation_DNG対応要求仕様.md) に要求書を作成.主要 I/F: `load_image_dng(path, demosaic, black_level, output_format)` / `load_metadata(path)` / `load_lsc_map(path)` / `load_hot_pixel_list(path)` / `mask_hot_pixels` / `apply_lsc_inverse`(将来検証用).既存 PNG 経路と並列対応(後方互換).依存追加: `rawpy>=0.21.0`.想定実装工数 ~3.5 日(6 Phase).完了後 SmTIAS-LightSim 側 Claude セッションで `src/sm_tias_lightsim/evaluation/dng_loader.py` 等にコピー.次のアクション: MiniTias-Evaluation 側 Claude セッションで TECH_05 を受領 → Phase 1 から `/implement`.SmTIAS-LightSim 側のフェーズ A 受け入れ実装は MiniTias-Evaluation 完了後に着手. 補足: **MiniTias-Evaluation 側 Phase 1 完了とハンドオフ受領(2026-06-01)**.MiniTias-Evaluation 側で TECH_05 Phase 1 (DNG 読み込み I/F) と LSC 順適用プロトタイプ・新指標 (動径 min/max 比) が完成し,ハンドオフパッケージとして SmTIAS-LightSim 側に渡された.受領記録は [TECH_06 MiniTias-Evaluation 引き継ぎ受領](docs/05_TECH/TECH_06_MiniTias-Evaluation引き継ぎ受領.md),関連分析は [TEST_05 PNG/DNG 評価比較](docs/06_TEST/TEST_05_PNG_DNG評価比較.md) と [TEST_06 外乱光影響評価](docs/06_TEST/TEST_06_外乱光影響評価.md).**5 つの重要発見**: (1) **TECH_05 の `apply_lsc_inverse` は誤り**(DNG は LSC 未補正で逆ではなく順方向適用が正しい.TECH_05 §4 を訂正済).(2) **過去 Step 8 キャリブの残差 2.1× はドメイン不一致**(実機 PNG sRGB 0.0423 vs シミュ sRGB/linear 比較):正しい linear 比較では DNG linear+LSC 0.0689 vs シミュ linear 0.183 = **真の残差 2.66×**.(3) **新実機ターゲット = `MiniTIAS_QM_20260601_124500`(暗闇撮影)**: CoV=0.0695, 勾配=8.35%, 中心/周辺=1.0911, 動径 min/max=0.860.Step 8-13 以降の正規 baseline.(4) **外乱光ロバスト確認**: 室内灯 ON/OFF で指標差 < 1%.SmTIAS 自前照明が支配的で外乱光モデル化不要.(5) **新指標 `calc_radial_min_max_ratio`** (20 点輪帯平均 min/max 比) を追加.次フェーズ: S2 (dng_loader.py コピー + 新指標マージ + rawpy 依存追加) と S3 (sm-evaluate DNG 対応 + Step 8-13 突合) を別 `/implement` パイプラインで実施. +補足: **S2 完了 (2026-06-01)**.`src/sm_tias_lightsim/evaluation/dng_loader.py` を `handoff_smtias_lightsim/src/dng_loader.py` の完全コピーで新規作成 (docstring のみ差し替え).`src/sm_tias_lightsim/evaluation/radial.py` に `calc_radial_min_max_ratio` をマージ追加 (nan ビン除外を移植時に補強).`pyproject.toml` に `rawpy>=0.21.0` 追加 (実 rawpy==0.27.0).新規テスト 32 件追加 (`tests/evaluation/test_dng_loader.py` 341 行).既存 537 + 新規 32 = **569 件全通過** (slow 除外).**検証 V4 が完全一致**: 実 DNG (`MiniTIAS_QM_20260601_124500.dng`) を読み込んで LSC 順方向適用 + 白板 ROI で評価指標 6 件 (CoV/std/max-min/mean/max/min) が **相対誤差 < 1e-6 で期待値完全一致**,空間指標 3 件 (中心/周辺/勾配/動径 min/max) も絶対誤差 < 0.003.Refactorer 判断: `dng_loader.py` 内部の `_apply_srgb_oetf` は既存 `evaluation/oetf.py:apply_srgb_oetf` と実装重複だが「コピー方針優先」「用途が異なる」「違反基準 (3 箇所以上) 未満」の理由でそのまま放置.次フェーズ: S3 (sm-evaluate に DNG 入力経路追加 + 新規 baseline config + シミュ vs 実機 DNG 突合 → TEST_01 Step 8-13 記録) を別 /implement パイプラインで実施. +補足: **S3 方針見直しと Step D 完了 (2026-06-01)**.ユーザー指摘により「sm-evaluate に DNG 入力経路を追加する」当初案は **不採用**.理由: 実機 DNG 評価は MiniTias-Evaluation の本業で完結.SmTIAS-LightSim 側で重複実装する必要なし.S3 の作業範囲を **3 項目に絞り込み**: Step D (動径 min/max 比を sm-evaluate に統合)・Step C' (新規シミュ用 baseline config 作成)・Step G (シミュ再評価 → Step 8-13 として TEST_01 に記録).**Step D 完了**: `MetricsConfig.radial_min_max: bool = False` をスキーマ追加 (`schema.py`),`cli/evaluate.py` で `_run_radial_min_max` 関数追加して有効時に metrics.csv に 4 列 (radial_min_max_ratio / radial_min_distance / radial_min / radial_max) を末尾追加,コンソールサマリに 2 行追加.`radial_profile` を再利用 (重複算出回避),未算出時は内部算出.後方互換維持 (既存 3 configs は無修正で動作).新規テスト 39 件追加 (`tests/scene/test_metrics_radial_min_max_schema.py` 250 行 + `tests/cli/test_evaluate_radial_min_max.py` 740 行) で仕様 A1-D2 全カバー.既存 569 + 新規 39 = **608 件全通過**.SPEC_04 §動径 min/max 比 セクションを追記.次: Step C' (configs/eval_baseline_dng_target.yaml 作成: 既存 eval_baseline_linear.yaml ベース,target_mean=207.82,radial_min_max=true) → Step G (Step 8-13 計測). +補足: **Step C' 完了と Step G 計測 (2026-06-02)**.`configs/eval_baseline_dng_target.yaml` を新規作成(eval_baseline_linear.yaml ベース,target_mean=207.82,metrics.radial_min_max=true,OETF なし).動作確認で既存シミュ baseline (output/baseline/luminance.npy) を新 config で評価した結果,Step 8-13 数値が判明: シミュ CoV=0.1826, 勾配=24.65%, 中心/周辺=1.3271, 動径 min/max=0.6151.実機 DNG+LSC 補正 (CoV=0.0695, 勾配=8.35%, C/P=1.0911, 動径 min/max=0.860) と比較して **残差 2.63×**.これは TECH_06 で予想した「真の残差 2.66×」とほぼ一致.過去 Step 8-3 OETF 順方向適用時の「2.1×」は PNG sRGB ガンマ圧縮による楽観値であり,真の linear 比較では大きい.**LSC 取り扱いの結論を [TECH_07 LSC 取り扱い方針](docs/05_TECH/TECH_07_LSC取り扱い方針.md) として一元化**: 3 プロジェクト役割分担 (MiniTias=SHADING_MODE=HIGH_QUALITY 撮影 / MiniTias-Evaluation=lscMap 順方向適用 / SmTIAS-LightSim=一切扱わない),シミュ側で LSC を扱わない物理的根拠 (Mitsuba orthographic = LSC 完璧補正後相当),検討した代替案 3 つ (A: シミュに LSC モデル化追加・B: perspective+thinlens で物理 cos⁴・C: ISP 補正残差を物理導出) すべて却下,限界と注意点,フォールバック条件 C1-C3 (SHADING_OFF 撮影),Step 8-13 残差 2.63× の解釈 (LSC 由来ではない別要因,物理モデル誤差の可能性) を明文化.残差 2.63× の解消は今後の課題 (物理モデル精緻化 or SHADING_OFF 撮影で切り分け or 形状相対比較に絞る) .S3 残作業: TEST_01 への Step 8-13 正式記録・handoff_smtias_lightsim/ 削除. +補足: **実機変更とシミュ側対応の整合マッピングを明文化 (2026-06-02)**.[TECH_08 実機変更とシミュ側対応](docs/05_TECH/TECH_08_実機変更とシミュ側対応.md) として作成.実機側 8 項目変更 (PNG→DNG / AWB OFF / AE OFF / ガンマなし / NR OFF / EDGE OFF / LSC 維持 / LSC 順方向適用) に対するシミュ側 3 層 (Mitsuba シーン定義 α / sm-render β / sm-evaluate γ) の対応をマッピング.**結論: シミュ側で実際に対応が必要だったのは sm-evaluate の config レベルでの OETF 無効化 (#4) と mean アンカー調整 (#3) の 2 項目のみで,他はすべて対応不要 (Mitsuba は per-channel 独立計算で AWB/AE/NR/EDGE 概念なし,orthographic で LSC 自然対応)**.計 15 項目以上で網羅チェック,対応漏れなしを確認.特にガンマ補正は `configs/eval_baseline_dng_target.yaml` で OETF セクションを意図的に書かない形で Step C' にて対応済.**残差 2.63× は ISP 関連の対応漏れではなく,シミュ物理モデル誤差 (反射率精度・LED 配光モデル・メッシュ幾何精度等) が主因**と推定.詳細な切り分けは Step 8-14 以降の物理モデル精緻化として残す. +補足: **「対応不要」を 2 タイプに細分化 (TECH_08 §許容している構造的差異 追加)**.「対応不要」の理由を **タイプ I (本質構造同一・対応概念なし)** = AWB/NR/EDGE/LSC 等と,**タイプ II (構造的差異あり・白板で許容)** = AF (焦点モデル)/絞り口径食/ピクセル積分/センサノイズ/QE 波長依存/シャッター方式/温度依存 に分類.特に AF は実機 perspective+固定焦点 vs シミュ orthographic 無限遠相当という **シミュと実機の構造的な違い**を明示.白板撮影では影響軽微で許容しているが「ゼロ影響」ではない.舌模型評価時や残差精緻化フェーズで再評価対象.Step 8-13 残差 2.63× の主因はタイプ II ではなくシミュ物理モデル誤差と推定する従来結論は維持. +補足: **S3 全体完結 — TEST_01 に Step 8-13 正式記録 + handoff 整理 (2026-06-02)**.[TEST_01 §Step 8-13](docs/06_TEST/TEST_01_キャリブレーション検証履歴.md) に新方針 (実機 DNG+LSC linear) でのシミュ突合を Step 8-1〜8-12 と同フォーマットで正式記録.俯瞰サマリ表に新ターゲット (CoV=0.0695) と Step 8-13 結果 (CoV=0.1826, 残差 2.63×) を追加,過去 2.1× が PNG sRGB ガンマ圧縮による楽観値だった旨も明記.§Step 8-N 候補 (LSC 順方向モデル) を「不採用 — Step 8-13 で方針確定」のヘッダに更新し冒頭注記で経緯を残す (歴史的記録として保持).`handoff_smtias_lightsim/` ディレクトリは S3 完結のため整理: 参考スクリプト `run_uniformity_dng.py` のみ `reference_measurement/quantitative/reference_scripts/` に移動 (MiniTias-Evaluation 側の正規評価実装 = LSC 順方向適用 + portrait 向き合わせ + ROI 抽出の参考.将来 SHADING_OFF フォールバック発動時の独立検証用),他は全て SmTIAS-LightSim 側に取り込み済みのため削除.S3 全体 (S2 DNG ローダコピー + Step D radial_min_max 統合 + Step C' 新規 baseline config + TECH_07 LSC 方針 + TECH_08 対応マッピング + Step 8-13 計測 + handoff 整理) 完結.次のキャリブ作業は Step 8-14 以降の物理モデル精緻化 (反射率精度・LED 配光・メッシュ幾何) と TECH_04 フォールバック条件 C1 (SHADING_OFF 撮影) の並行検討. 実装済みモジュール: - `src/sm_tias_lightsim/uniformity.py` — Rec.709 輝度変換 + CoV/std/max-min(MiniTias-Evaluation コピー) @@ -53,6 +59,8 @@ - `src/sm_tias_lightsim/visualize/viewer.py` — PyVista 3D ビューア.既定=全メッシュ統一ベイク着色(**lit のみ共有 clim**・log/linear・単一カラーバー・`bake_opacity` 半透明・`hide_below` 非表示・`bake_smooth` 平滑化・depth peeling・viridis)+**`cull_outward`(既定 ON)で外向き ≈0 面を非描画**=暗殻の濁り/見かけ段差を解消・clim も可視内向き面に密着;`hide_sensor_marker`/`hide_led_markers` でマーカー個別非表示;`luminance_map` 指定時=評価板 512² テクスチャ投影(E=π·L/ρ で壁と同一 W/m² スケール、内側カメラ向き face のみ、外側ニュートラル、マスク無効時 per-vertex フォールバック) - `src/sm_tias_lightsim/cli/visualize.py` — `sm-visualize` CLI(YAML → 3D 表示 / screenshot).`--bake-mode {direct,path}` / `--bake-samples` / `--bake-max-depth`(path ベイクの反射回数、既定 16、render 設定から独立)/ `--bake-cache `(1回計算→以後即時)/ `--cull-outward|--no-cull-outward`(既定 ON)/ `--opacity` / `--hide-below` / `--bake-smooth` / `--bake-scale {linear,log}` / `--clip-percentile` / `--no-sensor-marker`・`--no-led-markers`・`--no-markers` / `--render `(任意・テクスチャ投影).`--render` 無し=全面統一ベイク(既定) - `src/sm_tias_lightsim/runtime/llvm_init.py` — LLVM JIT 初期化ヘルパ.`ensure_drjit_libllvm_path()` で `DRJIT_LIBLLVM_PATH` 未設定時に Windows/POSIX 標準パスを自動探索して `os.environ` 設定.`log_variant_status()` で variant 経路(llvm_/cuda_/scalar_)を INFO 明示,要求 llvm_* で実 scalar_* なら WARNING 追加.`runner.py` / `baker.py` から呼ばれ環境変数未設定時のサイレントクラッシュを防ぐ +- `src/sm_tias_lightsim/evaluation/dng_loader.py` — DNG 読み込みモジュール.MiniTias-Evaluation `src/io/dng_loader.py` を SmTIAS-LightSim にコピーしたもの (オリジナルは MiniTias-Evaluation 側で管理.バグ修正・機能追加はそちらで反映).主要 API: `load_image_dng(path, demosaic, black_level, output_format)`.`output_format` は `rgb_float32` / `luma_float32` / `rgb_uint8`.linear 系出力 (`rgb_float32` / `luma_float32`) は `output_color=raw` でカラーマトリクス非適用・per-channel 比例性保持.`gamma=(1,1) + no_auto_bright=True + use_camera_wb=False + user_wb=[1,1,1,1]` で linear raw を保つ.依存: `rawpy>=0.21.0` +- `src/sm_tias_lightsim/evaluation/radial.py` — 20 ビン放射状輝度プロファイル.既存 `calc_radial_profile` に加えて MiniTias-Evaluation `src/analysis/spatial.py:calc_radial_min_max_ratio` をマージ.動径 min/max 比 (20 点輪帯平均) は画素単位 max/min よりノイズに対しロバスト.nan ビン除外を移植時に補強 (既存 `calc_radial_profile` は空ビンを nan で返す) ※ ステップ完了時にここを更新すること. ## 必須ルール(コード実装時) diff --git a/configs/eval_baseline_dng_target.yaml b/configs/eval_baseline_dng_target.yaml new file mode 100644 index 0000000..a4a4af9 --- /dev/null +++ b/configs/eval_baseline_dng_target.yaml @@ -0,0 +1,45 @@ +# Step 8-13 用:定量モード DNG + LSC 順方向適用後の実機ターゲットと直接比較する +# シミュ評価設定(過去 Step 8-1〜8-12 の評価 config 群と並列に残し,Step 8-13 評価条件を固定化する). +# +# 実機側 (MiniTias-Evaluation): DNG → LSC 順方向適用 → linear → 白板 ROI で評価 +# mean=207.82, CoV=0.0695, 中心/周辺=1.0911, 勾配=8.35%, 動径 min/max=0.860 +# (`reference_measurement/quantitative/MiniTIAS_QM_20260601_124500.dng` 暗闇撮影, +# 詳細は docs/06_TEST/TEST_05_PNG_DNG評価比較.md と TEST_06_外乱光影響評価.md) +# +# シミュ側 (本設定): +# - linear のまま評価(OETF 設定なし.実機 DNG+LSC が linear のためドメイン一致) +# - mean アンカー正規化で実機 mean=207.82 に揃える(線形ゲインなので形状指標は不変) +# - 動径 min/max 比 (Step D で sm-evaluate に統合した新指標) を有効化 +# +# 既存 configs/eval_baseline_linear.yaml からの差分は 2 点だけ: +# 1. normalization.target_mean: 146.7146 → 207.82 +# 2. metrics.radial_min_max: true を追加 +# +# 過去設定との関係: +# - configs/eval_baseline.yaml : Step 8-1〜8-7 (OETF on,sRGB 比較,mean=146.71) +# - configs/eval_baseline_linear.yaml : Step 8-8〜8-12 (OETF off,linear 比較,mean=146.71) +# - configs/eval_baseline_dng_target.yaml ← 本設定: Step 8-13〜 (OETF off, linear, mean=207.82) +# - configs/eval_real.yaml : 実機 PNG 評価 (過去 sRGB ターゲット算出用,現状参考扱い) +# +# 詳細: docs/05_TECH/TECH_06_MiniTias-Evaluation引き継ぎ受領.md +roi: + type: image_pixel + x: 24 + y: 37 + width: 464 + height: 450 +zones: + enabled: true + boundaries: + - 0.33 + - 0.66 +metrics: + cov: true + std: true + max_min: true + radial_profile: true + radial_min_max: true # Step D で sm-evaluate に統合した新指標.動径 min/max 比 (20 ビン平均の min/max). +normalization: + enabled: true + type: mean_anchor + target_mean: 207.82 # 実機 DNG+LSC の白板 ROI mean (reference_measurement/quantitative/expected_results/MiniTIAS_QM_20260601_124500_uniformity.csv より) diff --git "a/docs/04_SPEC/SPEC_04_\350\251\225\344\276\241\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_04_\350\251\225\344\276\241\344\273\225\346\247\230.md" index 197058b..cd15419 100644 --- "a/docs/04_SPEC/SPEC_04_\350\251\225\344\276\241\344\273\225\346\247\230.md" +++ "b/docs/04_SPEC/SPEC_04_\350\251\225\344\276\241\344\273\225\346\247\230.md" @@ -235,6 +235,17 @@ 正規化距離 `d` を **20 ビン**(既定)に分け,各ビンの平均輝度を取る.`figures/radial_profile.png` でグラフ化. +### 動径 min/max 比(オプション) + +`metrics.radial_min_max: true` で有効化.動径プロファイル(20 ビン平均)の **最小ビン値 ÷ 最大ビン値**.画素単位 `max_min` 比よりノイズ・外れ値に対しロバスト. + +| 指標 | 定義 | 値域 | +| --- | --- | --- | +| **動径 min/max 比** | `min(bins) / max(bins)`(空ビンは nan で除外) | 0〜1(1.0 で均一.小さいほどムラ大) | +| **動径 min ビン距離** | 最小値ビンの正規化距離 | 0〜1(1.0 が最周辺ビン) | + +`metrics.csv` に `radial_min_max_ratio` / `radial_min_distance` / `radial_min` / `radial_max` の 4 列を末尾に追加.既定 False(既存挙動互換).`metrics.radial_profile` の有無に関わらず内部で動径プロファイルを算出するため独立して有効化可能.実装: `evaluation/radial.py:calc_radial_min_max_ratio`(MiniTias-Evaluation の `src/analysis/spatial.py` からマージ). + ## 可視化規約 (Figure Conventions) `sm-evaluate` は `figures/` に以下 4 種を生成.MiniTias-Evaluation 互換スタイル. diff --git "a/docs/05_TECH/TECH_07_LSC\345\217\226\343\202\212\346\211\261\343\201\204\346\226\271\351\207\235.md" "b/docs/05_TECH/TECH_07_LSC\345\217\226\343\202\212\346\211\261\343\201\204\346\226\271\351\207\235.md" new file mode 100644 index 0000000..5756624 --- /dev/null +++ "b/docs/05_TECH/TECH_07_LSC\345\217\226\343\202\212\346\211\261\343\201\204\346\226\271\351\207\235.md" @@ -0,0 +1,197 @@ +# LSC 取り扱い方針 (Lens Shading Correction Handling Policy) + +本ドキュメントは 3 プロジェクト(MiniTias / MiniTias-Evaluation / SmTIAS-LightSim)における **LSC (Lens Shading Correction) の取り扱い** に関する設計判断と物理的根拠を明文化したものである.過去 [TECH_03](TECH_03_定量モード実装報告とSHADING_MODE相談.md)〜[TECH_06](TECH_06_MiniTias-Evaluation引き継ぎ受領.md) / [TEST_05](../06_TEST/TEST_05_PNG_DNG評価比較.md) / [TEST_06](../06_TEST/TEST_06_外乱光影響評価.md) で散在していた議論の **結論をここに集約**し,将来の参照を容易にする. + +## 結論(先に) (Conclusion First) + +### 役割分担 + +| プロジェクト | LSC の扱い | 採用理由 | +| --- | --- | --- | +| **MiniTias**(撮影アプリ) | `SHADING_MODE=HIGH_QUALITY` で撮影.`STATISTICS_LENS_SHADING_MAP_MODE=ON` で `lscMap` を meta.json に保存.DNG 自体は LSC 未補正 (RAW_SENSOR) | TECH_04 で本流維持と決定.SHADING_OFF はフォールバック候補として温存 | +| **MiniTias-Evaluation**(実機評価) | DNG 読み込み後に **lscMap を順方向適用** (`image × interp(lscMap)`) してレンズビネット除去.残った成分を照明ムラとして評価 | TEST_05 で「LSC 補正なし CoV=0.148 → あり CoV=0.069」と実証 | +| **SmTIAS-LightSim**(シミュ) | **一切扱わない**.Mitsuba orthographic sensor が「LSC を完璧に補正できた場合の理想値」と本質的に等価のため | TECH_04 §SHADING_MODE 判断 / 本ドキュメント §物理的根拠 | + +### 一言で言うと + +> **「実機側で LSC 順方向適用で物理 cos⁴ を除去」+「シミュ側で orthographic で物理 cos⁴ を最初から持たない」**で,両者を **同じドメイン (LSC 補正後相当)** で比較する設計.シミュ側に LSC モデル化を追加する必要も価値もない. + +## 背景・経緯 (Background) + +LSC 取り扱いの議論は,過去 Step 8 キャリブレーション([TEST_01](../06_TEST/TEST_01_キャリブレーション検証履歴.md))で「シミュ vs 実機の形が違う」現象の原因解明から始まった.時系列: + +| 時期 | 議論内容 | 結論 | +| --- | --- | --- | +| Step 8-3 (2026-05) | sRGB OETF を sm-evaluate に追加 → 残差 4.3× → 2.1× | OETF が「見かけ差の 2/3」を占めていたと判明 | +| Step 8-12 (2026-05) | Helmholtz 相反原理で「方向独立性」検証 | 形は方向独立.残差は方向では解消しない | +| TECH_03 (2026-06) | MiniTias 側で定量モード DNG 実装.SHADING_MODE 相談 | SHADING_MODE = HIGH_QUALITY を本流維持 (TECH_04) | +| TECH_04 (2026-06) | Mitsuba orthographic の物理的意味を整理 | orthographic ≒「LSC 完璧補正後相当」を確定 | +| TECH_05 (2026-06) | MiniTias-Evaluation 側に DNG 対応依頼 | `apply_lsc_inverse` (誤り) を仕様化 | +| TECH_06 (2026-06) | MiniTias-Evaluation 側で実装完了 | **DNG は LSC 未補正**と判明.TECH_05 を `apply_lsc_forward` に訂正 | +| TEST_05 (2026-06) | PNG/DNG ドメイン比較 | LSC 補正なし CoV=0.148 → あり CoV=0.069.LSC の効果が数値で実証 | +| **本ドキュメント (2026-06-02)** | **LSC 取り扱いの結論を一元化** | — | + +## シミュ側で LSC を扱わない物理的根拠 (Physical Rationale) + +### 各経路で「何を測っているか」 + +```text +[実機・新パイプライン] +LED 発光 + ↓ 多重反射(筐体内壁) +評価板表面の放射輝度 L (W/m²/sr) ← この値を測りたい本質 + ↓ レンズ通過時に物理 cos⁴ falloff +センサ raw (cos⁴ 残ったまま) + ↓ DNG (RAW_SENSOR) として保存 + ↓ ★ MiniTias-Evaluation 側で lscMap を順方向適用 + = 理論上「cos⁴ を打ち消した = 評価板表面の放射輝度 L 相当」 + ↓ Rec.709 luma 抽出 + 白板 ROI +評価指標 (CoV / 勾配 / 中心/周辺 / 動径 min/max) + +[シミュ側] +LED 発光(SceneConfig) + ↓ Mitsuba path tracer + 多重反射(max_depth=8) +評価板表面の放射輝度 L (W/m²/sr) ← 全画素 orthographic で取得 + ↓ ★ cos⁴ falloff は最初から含まない(全画素平行光線) +luminance.npy = 放射輝度 L そのもの + ↓ ROI 抽出 +評価指標 +``` + +### 等価性の本質 + +両方の「★」で同じドメイン(**放射輝度 L 相当**)に到達する: + +- 実機: `生raw × lscMap = 放射輝度 L 相当`(実機 ISP の lscMap が完璧なら) +- シミュ: 最初から放射輝度 L(cos⁴ を持たないので除去すべきものがない) + +→ 両者は **構造的に同じものを測っている**.シミュ側に追加の操作は不要. + +## 検討した代替案と却下理由 (Alternatives Considered & Rejected) + +過去議論で「シミュ側で LSC を考慮するか」という観点で 3 つの選択肢を検討した.結果はすべて却下: + +### 選択肢 A: シミュに LSC 順方向モデルを追加([Step 8-N 候補だったもの]) + +**当初の発想**: 過去 TEST_03 で「形を変える唯一の道は LSC モデル化」と結論していた.シミュ側で cos⁴ または多項式モデルを sm-evaluate に追加する案. + +**却下理由**: + +- 実機側で LSC 順方向適用するアプローチが MiniTias-Evaluation で確立した(TEST_05)→ シミュ側にモデルを追加する必要が消失 +- シミュ側に自由度パラメータ(cos^n の n や多項式係数)を追加すると **overfitting リスク**が増す +- 過去 [TEST_03 finding 4](../06_TEST/TEST_03_輝度マップ形状追跡.md) の「形を変える唯一の道は LSC モデル化」は **当時の前提(実機側を改善しない)下での結論**であり,実機側改善で同じ目的を達成した今は無効 + +### 選択肢 B: Mitsuba を perspective + thinlens に変えて物理 cos⁴ をシミュレート + +**発想**: Mitsuba 3 の `thinlens` カメラを使えば物理 cos⁴ falloff が自動で出る. + +**却下理由**: + +- thinlens で cos⁴ falloff を入れても,**実機側との比較には lscMap で割り戻す必要**が出る +- → 結果として `(シミュ × cos⁴) / lscMap` と `シミュ(orthographic)` が **数学的に等価**(lscMap ≒ cos⁴ の逆数のため) +- 余分な計算ステップが増えるだけで得るものがない +- 加えて thinlens は aperture / focal_length の物理値が必要で,自由度パラメータが増える + +### 選択肢 C: 実機 ISP の LSC 補正残差を物理から導出してシミュに反映 + +**発想**: 「実機 LSC マップが完璧に物理 cos⁴ を補正できているか」を独立に検証してから,残差をシミュに反映する. + +**却下理由**: + +- **実機 ISP の LSC アルゴリズム・補正残差は メーカー非公開**で,物理から導出できない +- 実機計測から残差を逆推定すれば数値は出るが,それは「物理モデル」ではなく「fit」であり overfitting リスク +- 残差を物理的に切り分けたい場合は **SHADING_OFF 撮影**(フォールバック条件 C1〜C3)が必要だが,それは別途撮影が必要な大掛かりな話 + +### つまり「数値的に正しく考慮できない」の正確な意味 + +ユーザー疑問(2026-06-02)への正式回答として: + +| 何を | 物理的にできるか | 価値 | +|---|---|---| +| 物理 cos⁴ falloff のモデル化(選択肢 B) | ✓ できる | × 余分なだけで価値ゼロ | +| 実機 LSC 補正残差の物理導出(選択肢 C) | ✗ できない | — | +| シミュ側に LSC モデル化追加(選択肢 A) | △ fit で形式上は可能 | × overfitting リスク.得るものなし | + +→ **「シミュ側で LSC を考慮しない」は構造的に最適**.他の選択肢は「できないか」「できても価値が増えない」. + +## 限界と注意点 (Limitations & Caveats) + +### 1. 実機側にも残る誤差 + +実機 ISP の lscMap は「**メーカー出荷時にキャリブされた標準補正値**」であり,個体差・経時変化・撮影条件(色温度等)による補正残差が残る.これは: + +- SHADING_OFF 撮影で生 raw を取得 → 物理 cos⁴ パターンと比較すれば定量化可能(**フォールバック条件 C1〜C3 で実施**,[TECH_04](TECH_04_MiniTIASへの回答.md)) +- 現状(フォールバック未発動)では「補正残差は実機側に残るが小さい」と仮定して進めている + +### 2. SmTIAS-LightSim 側の「実機との一致度」は別問題 + +LSC 取り扱いの整理が完了しても,シミュ vs 実機の **残差そのものは消えない**: + +| 指標 | 実機 (DNG+LSC) | シミュ (Step 8-13) | 残差 | +| --- | --- | --- | --- | +| CoV | 0.0695 | 0.183 | 2.63× | +| 勾配 % | 8.35 | 24.65 | 2.95× | +| 動径 min/max | 0.860 | 0.615 | 絶対差 0.245 | + +→ この残差は **LSC 由来ではない別要因**(シミュ側の物理モデル誤差 / 反射率 / 配光 / 幾何精度等).LSC モデル化で解消できる問題ではない. + +### 3. 過去 Step 8 残差解釈の見直し + +過去 Step 8-1〜8-12 で「シミュ vs 実機の残差 2.1×」と評価していたが,これは **PNG sRGB(ガンマ圧縮)と シミュ sRGB の比較**で,PNG 側がガンマでムラを過小評価していた.Step 8-13 で **真の linear ドメイン比較**を行った結果 **2.63×** と判明([TEST_05](../06_TEST/TEST_05_PNG_DNG評価比較.md) §結果比較 参照). + +→ 過去残差 2.1× は楽観的.真の残差 2.63× は LSC モデル化以外の方法で減らす必要がある. + +## フォールバック条件(再掲) (Fallback Conditions) + +[TECH_04 §将来 SHADING_OFF 切替のフォールバック条件](TECH_04_MiniTIASへの回答.md) で定義した条件: + +| ID | 発動条件 | 対応 | +| --- | --- | --- | +| **C1** | シミュ vs HIGH_QUALITY 残差が **再現的に大きい**(CoV/勾配 1.5× 以上 + 形が物理説明不能) | SHADING_OFF 撮影で物理 cos⁴ と ISP 補正残差を切り分け | +| **C2** | lscMap が **撮影ごとに大きく変動**(中央値 variation > 5%) | ISP 動的計算の証拠.補正後画像の解析時に動的性を考慮 | +| **C3** | シミュモデル精度向上後も残差が **物理説明不能** | 別要因の探索.SHADING_OFF 比較で物理 cos⁴ 寄与を独立評価 | + +**現状(2026-06-02)の状態**: Step 8-13 で残差 2.63× を計測.これは **C1 候補**(残差は大きく、形は説明可能性が要検討).SHADING_OFF 撮影の正式検討に進むか,物理モデル見直しに進むかは別途判断. + +## 次のアクション (Next Actions) + +LSC 取り扱いは本ドキュメントで決着.残る課題: + +### 短期 + +- Step 8-13 の正式記録([TEST_01](../06_TEST/TEST_01_キャリブレーション検証履歴.md) への Step 8-13 セクション追記) +- `handoff_smtias_lightsim/` 一時ディレクトリの削除(S3 完了処理) + +### 中期(残差 2.63× の解消が必要なら) + +- **物理モデル精緻化**: 反射率 / LED 配光 / メッシュ幾何の各要素を 1 つずつ詰める +- **SHADING_OFF 撮影**: フォールバック条件 C1 発動として実施.MiniTias 側で 1 ショット撮影 → 物理 cos⁴ パターンと lscMap の合致度を独立検証 + +### 中長期 + +- 「実機との完全一致」を諦めて **形状指標の相対比較**に絞る選択肢(過去 [TEST_03](../06_TEST/TEST_03_輝度マップ形状追跡.md) の「形」観点) +- LSC モデル化を「fit」として割り切って追加(overfitting リスク受容.推奨しない) + +## 関連ドキュメント (Related Documents) + +### TECH 系(設計判断) + +- [TECH_01 定量撮影モード要求仕様](TECH_01_定量撮影モード要求仕様.md) — MiniTias への撮影モード要求 +- [TECH_02 MiniTIAS 引き継ぎ手順](TECH_02_MiniTIAS引き継ぎ手順.md) — 引き継ぎパッケージの構造 +- [TECH_03 定量モード実装報告と SHADING_MODE 相談](TECH_03_定量モード実装報告とSHADING_MODE相談.md) — MiniTias 側からの SHADING_MODE 相談 +- [TECH_04 MiniTIAS への回答](TECH_04_MiniTIASへの回答.md) — SHADING_MODE = HIGH_QUALITY 維持判断とフェーズ A/B 分割 +- [TECH_05 MiniTias-Evaluation DNG 対応要求仕様](TECH_05_MiniTiasEvaluation_DNG対応要求仕様.md) — Evaluation 側への DNG 対応要求(§4 訂正済) +- [TECH_06 MiniTias-Evaluation 引き継ぎ受領](TECH_06_MiniTias-Evaluation引き継ぎ受領.md) — DNG ローダ受領.LSC 順方向適用が正しいと判明した経緯 + +### TEST 系(試行検証) + +- [TEST_01 キャリブレーション検証履歴](../06_TEST/TEST_01_キャリブレーション検証履歴.md) — Step 8 全イテレーション.Step 8-13 で本方針の数値結果を記録予定 +- [TEST_03 輝度マップ形状追跡](../06_TEST/TEST_03_輝度マップ形状追跡.md) — 「形のずれ」議論.finding 4 の前提変更を本ドキュメントで明示 +- [TEST_05 PNG/DNG 評価比較](../06_TEST/TEST_05_PNG_DNG評価比較.md) — LSC 補正なし/あり の数値実証 +- [TEST_06 外乱光影響評価](../06_TEST/TEST_06_外乱光影響評価.md) — 外乱光がロバスト < 1%(補正残差解析の前提) + +### 外部参考 + +- [Android Camera2 API LensShadingMap](https://developer.android.com/reference/android/hardware/camera2/params/LensShadingMap) +- [Android Camera2 API CaptureRequest.SHADING_MODE](https://developer.android.com/reference/android/hardware/camera2/CaptureRequest#SHADING_MODE) +- [DNG Spec 1.4 OpcodeList2 GainMap](https://www.adobe.com/content/dam/acom/en/products/photoshop/pdfs/dng_spec_1.4.0.0.pdf) diff --git "a/docs/05_TECH/TECH_08_\345\256\237\346\251\237\345\244\211\346\233\264\343\201\250\343\202\267\343\203\237\343\203\245\345\201\264\345\257\276\345\277\234.md" "b/docs/05_TECH/TECH_08_\345\256\237\346\251\237\345\244\211\346\233\264\343\201\250\343\202\267\343\203\237\343\203\245\345\201\264\345\257\276\345\277\234.md" new file mode 100644 index 0000000..c0d718c --- /dev/null +++ "b/docs/05_TECH/TECH_08_\345\256\237\346\251\237\345\244\211\346\233\264\343\201\250\343\202\267\343\203\237\343\203\245\345\201\264\345\257\276\345\277\234.md" @@ -0,0 +1,346 @@ +# 実機変更とシミュ側対応の整合マッピング (Device Setup Changes & Simulation-side Correspondence) + +本ドキュメントは MiniTias 撮影アプリの定量モード導入に伴う **実機側撮影条件の変更**に対して,SmTIAS-LightSim 側で **どこを変える必要があったか/不要だったか** を網羅的にマッピングしたものである.Step 8-13 で残差 2.63× が判明した際に「シミュ側の対応漏れではないか」を切り分けるために整理した結果である. + +## 結論(先に) (Conclusion First) + +**実機側で 8 項目の設定変更を行ったが,シミュ側で実際に対応が必要だったのは「sm-evaluate の評価 config レベルでの OETF 無効化と mean アンカー調整」のみ**.他の 7 項目はシミュ側に対応物が存在しない(Mitsuba は物理量を直接計算するため)か,既に等価ドメインに到達している. + +| 観点 | 結論 | +| --- | --- | +| **シミュ側対応漏れ** | **なし**(15 項目以上で網羅チェック済み) | +| **対応が必要だった項目** | **2 項目**: ガンマ補正(OETF)→ Step C' で対応済 / AE OFF(mean アンカー) | +| **対応不要だった項目** | 残り全項目.ただし 2 タイプに分かれる(後述) | +| **タイプ I(本質構造同一)** | AWB / NR / EDGE / LSC / 量子化 等 — シミュと実機は本質的に同じ | +| **タイプ II(許容している構造的差異)** | **AF(焦点)** / 絞り・口径食 / ピクセル積分 / センサノイズ 等 — 構造的差異はあるが白板撮影では影響軽微で許容 | +| **Step 8-13 残差 2.63× の解釈** | ISP 関連の対応漏れではなく,**シミュ物理モデル誤差**が主因.タイプ II の寄与は小さいが排除不能 | + +## 背景・経緯 (Background) + +実機側変更の経緯は別ドキュメントに記録済み: + +| 段階 | 内容 | ドキュメント | +| --- | --- | --- | +| 1 | MiniTias アプリに定量撮影モード追加(PNG → DNG,ISP オート → マニュアル制御) | [TECH_01](TECH_01_定量撮影モード要求仕様.md) | +| 2 | MiniTias 側で実装完了.AQUOS sense3 は LEVEL_3 で全機能サポート | [TECH_03](TECH_03_定量モード実装報告とSHADING_MODE相談.md) | +| 3 | SHADING_MODE = HIGH_QUALITY 維持を決定 | [TECH_04](TECH_04_MiniTIASへの回答.md) | +| 4 | MiniTias-Evaluation 側で DNG 読み込み + LSC 順方向適用を実装 | [TECH_05](TECH_05_MiniTiasEvaluation_DNG対応要求仕様.md) / [TECH_06](TECH_06_MiniTias-Evaluation引き継ぎ受領.md) | +| 5 | LSC 取り扱いの設計判断を明文化 | [TECH_07](TECH_07_LSC取り扱い方針.md) | +| **6** | **実機側変更全般に対するシミュ側対応の整合確認(本ドキュメント)** | TECH_08(本ファイル) | + +## 実機側 8 項目の変更一覧 (Device-Side Changes) + +MiniTias 定量撮影モードで実機側に施した設定変更: + +| # | 設定 | 過去(通常モード) | 新規(定量モード) | 目的 | +| --- | --- | --- | --- | --- | +| 1 | **画像フォーマット** | PNG (8-bit sRGB) | DNG (10-bit RAW_SENSOR linear) | 量子化誤差・ガンマ非線形性の排除 | +| 2 | **AWB**(自動 WB) | ON | OFF(gains=[1,1,1,1], identity matrix) | 色温度ドリフトの排除 | +| 3 | **AE**(自動露光) | ON | OFF(露光 1/60s 固定,ISO 40 固定) | 撮影ごとの露光変動の排除 | +| 4 | **ガンマ補正** | sRGB OETF 適用 | CONTRAST_CURVE 線形(OETF なし) | 入射光量との比例性確保 | +| 5 | **NR**(ノイズ削減) | デフォルト(多分 FAST) | OFF | アーティファクト排除 | +| 6 | **EDGE**(エッジ強調) | デフォルト(多分 FAST) | OFF | 偽エッジ排除 | +| 7 | **LSC**(レンズシェーディング補正) | デフォルト | HIGH_QUALITY 維持 + lscMap 取得 | 後段で順方向適用するため取得 | +| 8 | **LSC 順方向適用** | (ISP 内で完結) | MiniTias-Evaluation 側で適用 | レンズビネット除去 → 照明ムラのみ評価 | + +→ 撮影段階 (#1〜#7) + 評価段階 (#8) の合計 8 項目.詳細は各 TECH ドキュメント参照. + +## シミュ側の「3 層」構造 (Simulation 3-Layer Structure) + +シミュ側を 3 層に分けると,各層がどの実機側変更に対応するかが明確になる: + +| 層 | 場所 | 役割 | 物理的意味 | +| --- | --- | --- | --- | +| **(α) Mitsuba シーン定義** | `configs/scene_*.yaml`, `src/sm_tias_lightsim/scene/` | シーン構築・光線追跡 | 物理量 (放射輝度 L, W/m²/sr) を計算 | +| **(β) sm-render CLI** | `src/sm_tias_lightsim/cli/render.py` | Mitsuba 実行 + luminance.npy 出力 | 物理量を float32 配列として保存 | +| **(γ) sm-evaluate CLI** | `src/sm_tias_lightsim/cli/evaluate.py` + `configs/eval_*.yaml` | 評価指標算出 + ドメイン整合 | 実機との比較ドメインを合わせる | + +## 対応マッピング表 (Correspondence Table) + +実機側 8 項目に対して,シミュ側 3 層 (α/β/γ) で何をしたかを網羅: + +| # | 実機側変更 | (α) シーン定義 | (β) sm-render | (γ) sm-evaluate | 状態 | +| --- | --- | --- | --- | --- | --- | +| 1 | PNG → DNG(量子化) | 概念なし | float32 出力(量子化なし) | DNG 直接読みは MiniTias-Evaluation の責務 | **対応不要** | +| 2 | AWB OFF | per-channel 独立計算(AWB 概念なし) | — | — | **対応不要** | +| 3 | AE OFF | 露光時間概念なし | — | **mean アンカーで実機 mean=207.82 にスケール調整**(既存 normalization 機能で対応) | **対応済 (γ)** | +| 4 | **ガンマ補正なし** | linear radiance を計算 | linear のまま保存 | **OETF セクションを config に書かない**([Step C'](#step-c-での対応) で対応) | **対応済 (γ)** | +| 5 | NR OFF | NR をかけない | — | — | **対応不要** | +| 6 | EDGE OFF | エッジ強調しない | — | — | **対応不要** | +| 7 | LSC HIGH_QUALITY 維持 | orthographic で cos⁴ falloff を持たない([TECH_07](TECH_07_LSC取り扱い方針.md) §物理的根拠 参照) | — | — | **対応不要**(構造的に等価) | +| 8 | LSC 順方向適用(実機側で実施) | — | — | — | **対応不要**(実機側で完結) | + +→ シミュ側で実際に対応した項目は **#3 と #4 のみ**.いずれも **sm-evaluate の config レベル**で完結.Mitsuba シーン定義 (α) も sm-render (β) も **無変更**. + +## 各項目の詳細解説 (Per-Item Details) + +### #1 PNG → DNG (量子化・ビット深度) + +**実機**: PNG (8-bit sRGB, 0-255) → DNG (10-bit linear, 0-1023, 黒レベル 64 で実効 0-959) +**シミュ**: Mitsuba は float32 で放射輝度 L (W/m²/sr) を出力.量子化なし. +**理由**: Mitsuba の物理計算は連続値.量子化はそもそも持ち込まないので「対応不要」. + +> なお,DNG の直接読み込みは MiniTias-Evaluation の責務([TECH_07](TECH_07_LSC取り扱い方針.md) §役割分担 参照).sm-evaluate にも `dng_loader.py` をコピーで持っているが(S2 で実装),これは将来の検証用ツールであり,本流評価では使わない. + +### #2 AWB OFF + +**実機**: gains=[1,1,1,1] + identity color matrix.ISP の AWB 補正を無効化. +**シミュ**: Mitsuba の `path` integrator は per-channel 独立で物理量を計算.R/G/B チャネル間の混合はそもそも行わない.AWB の概念がない. +**理由**: シミュは最初から「AWB OFF 相当」.「対応不要」. + +### #3 AE OFF(露光時間 / ISO 固定) + +**実機**: 露光時間 1/60s + ISO 40 固定.mean = 207.82(白板撮影). +**シミュ**: Mitsuba は露光時間の概念を持たない.物理量 (W/m²/sr) を直接出力.絶対値スケールは実機と直接比較できない. +**対応**: `evaluation/normalize.py` の `apply_mean_anchor()` で mean アンカー正規化.`configs/eval_baseline_dng_target.yaml` で `normalization.target_mean: 207.82` を指定.**線形ゲインなので形状指標 (CoV/勾配/中心-周辺/動径 min/max) は厳密に不変**.→ **(γ) で対応済**. + +> 既存 `evaluation/normalize.py` は Step 8-8 で実装済み.新規 config で値を 207.82 に設定するだけで対応完了. + +### #4 ガンマ補正なし(最重要対応項目) {#step-c-での対応} + +**実機**: TONEMAP_MODE = CONTRAST_CURVE 線形.sRGB OETF (γ ≈ 2.2) を適用しない.画像値は入射光量に比例 (linear). +**シミュ**: Mitsuba は元から linear radiance を出力. + +**過去の問題(Step 8-3 当時)**: 実機 PNG が sRGB ドメイン,シミュが linear ドメインで **ドメイン不一致**.これを揃えるため sm-evaluate に **OETF 順方向適用ステップ**を追加した(`evaluation/oetf.py`). + +**現在の対応**: 実機 DNG が linear になった今,シミュ側でも OETF を適用すると逆にドメイン不一致になる.したがって **OETF を sm-evaluate で適用しない**よう新規 config を作成.具体的には `configs/eval_baseline_dng_target.yaml` で `oetf:` セクションを **意図的に書かない**(既定で無効). + +| Config | OETF | 用途 | +| --- | --- | --- | +| `configs/eval_baseline.yaml` | **有効** (`oetf.enabled: true`) | 過去 Step 8-1〜8-7 の sRGB 比較用(歴史的設定として保持) | +| `configs/eval_baseline_linear.yaml` | 無効 | Step 8-8〜8-12 の linear 比較用(target_mean=146.71) | +| **`configs/eval_baseline_dng_target.yaml`** | **無効** | **Step 8-13 以降の新方針(target_mean=207.82)** | +| `configs/eval_real.yaml` | 無効 | 実機 PNG 評価用(過去再現) | + +→ **(γ) で対応済**.既存 OETF 機能は無効にして使うだけ.コード変更なし. + +### #5 NR OFF + +**実機**: Camera2 API の NOISE_REDUCTION_MODE = OFF.センサ raw に近い値を保持. +**シミュ**: Mitsuba の path tracer は物理計算のみで NR をかけない. +**理由**: シミュは最初から「NR OFF 相当」.「対応不要」. + +### #6 EDGE OFF + +**実機**: Camera2 API の EDGE_MODE = OFF.エッジ強調による偽の高周波成分を排除. +**シミュ**: Mitsuba はエッジ強調しない. +**理由**: 「対応不要」. + +### #7 LSC HIGH_QUALITY 維持 + +**実機**: SHADING_MODE = HIGH_QUALITY(ISP の LSC 補正が有効).ただし DNG (RAW_SENSOR) は LSC 未補正で出力され,lscMap が meta.json に保存される. +**シミュ**: Mitsuba orthographic sensor は全画素平行光線で,物理 cos⁴ falloff を **そもそも持たない**.「ISP が完璧に LSC 補正できた場合の理想値」と構造的に等価. + +詳細は [TECH_07](TECH_07_LSC取り扱い方針.md) §物理的根拠 参照.**対応不要**(構造的に等価). + +### #8 LSC 順方向適用(実機側で実施) + +**実機側 (MiniTias-Evaluation)**: DNG 読み込み後に lscMap を画像解像度にバイリニア補間して順方向乗算.レンズビネット除去. +**シミュ**: 上記 #7 の通り,シミュは cos⁴ falloff を持たないので適用不要. +**理由**: 実機側で完結.「対応不要」. + +## 追加チェック項目 (Additional Items) + +念のため,議論に出ていない実機側設定でもシミュ側影響を確認した: + +| 実機側設定 | シミュ側影響 | 対応要否 | +| --- | --- | --- | +| CONTROL_MODE: OFF(マニュアル全制御) | Mitsuba は ISP 概念なし | **不要** | +| SENSOR_FRAME_DURATION: 30fps 相当 | Mitsuba はフレームレート概念なし | **不要** | +| BLACK_LEVEL_LOCK: true | DNG 読み込み時に黒レベル除去(rawpy が自動) | **不要**(読み込み側で対応) | +| COLOR_CORRECTION_GAINS: [1,1,1,1] | per-channel 独立計算 | **不要** | +| COLOR_CORRECTION_TRANSFORM: identity | 同上 | **不要** | +| CONTROL_AF_MODE: OFF(固定焦点) | Mitsuba orthographic は焦点概念なし(無限遠相当) | **対応不要・許容**([§許容している構造的差異](#許容している構造的差異-accepted-structural-differences) 参照) | +| CONTROL_VIDEO_STABILIZATION_MODE: OFF | シミュに振動概念なし | **不要** | + +→ **追加で 7 項目チェックしたが,すべて対応不要 or 許容範囲**.計 15 項目以上で網羅チェック済み. + +> ただし **AF モード**は厳密にはシミュと実機の **構造的な違い**(実機 perspective + 固定焦点 vs シミュ orthographic 無限遠相当)であり,「対応物が存在しない」AWB/NR/EDGE と同列ではない.次節で「許容している差異」として明示的に分類する. + +## 許容している構造的差異 (Accepted Structural Differences) + +上記までの「対応不要」項目は,理由を 2 種類に分類できる: + +| タイプ | 説明 | 例 | +| --- | --- | --- | +| **タイプ I** | シミュと実機の **本質的構造が同じ**.Mitsuba の物理計算に対応概念がそもそも存在しない | AWB / NR / EDGE / LSC (orthographic で自然対応) | +| **タイプ II** | シミュと実機に **構造的な違いが存在する**.ただし白板撮影では影響が小さく許容している | **AF / 絞り / ピクセル積分** など | + +タイプ II は「対応不要」ではあるが,**Step 8-13 残差 2.63× の要因候補**として残っている可能性がある.「許容している」は「ゼロ影響」ではなく「影響は小さいと仮定している」という意味であり,将来の物理モデル精緻化で再評価すべき項目である. + +### II-1. 焦点モデル (AF / Focus) + +**実機**: AQUOS sense3 のフロントカメラは **固定焦点**(CONTROL_AF_MODE = OFF のみサポート,無限遠ハイパーフォーカル相当 0.8189).perspective レンズで物体距離に依存した焦点が存在し,被写界深度(DoF)が定義される. + +**シミュ**: Mitsuba `orthographic` sensor は全画素が平行光線で,**焦点・被写界深度の概念がない**.すべての画素位置が常に「完全に合焦」した状態でサンプリングされる. + +**構造的な違い**: + +- 実機は被写体距離が DoF を外れると **焦点ボケ**で輝度の空間滲みが発生 +- シミュは焦点ボケがない(完璧合焦相当) + +**白板撮影では許容できる理由**: + +- 評価板(白板)はアタッチメント内で **固定位置**にあり,常に DoF 内 +- 白板自体が「ボケても変わらない一様パターン」なので焦点ボケの影響は実質ゼロ +- 仮に若干のボケがあっても,照明ムラ(CoV / 勾配等)の指標値には大きく効かない + +**将来の物理モデル精緻化で再評価する条件**: + +- 評価対象を白板から **舌模型・舌**に変えた時(DoF が問題になる可能性) +- 残差 2.63× の主因切り分けで他要因を潰した後(最後の候補として) + +### II-2. 絞り・口径食 (Aperture / Vignetting) + +**実機**: 固定 F 値の絞りを持つ.レンズ通過時に物理 cos⁴ falloff(口径食)が発生. + +**シミュ**: Mitsuba orthographic は絞りを持たず,cos⁴ falloff も発生しない. + +**構造的な違い**: + +- 実機は周辺ほど暗い物理 cos⁴ falloff +- シミュは完全に均一に光線取得 + +**白板撮影では許容できる理由**: + +- 実機側で **lscMap 順方向適用**により ISP の補正(≒ 物理 cos⁴ の逆)が掛かる([TECH_07](TECH_07_LSC取り扱い方針.md) 参照) +- 結果,両者は「LSC 完璧補正後の理想値」相当のドメインに揃う +- ISP 補正残差は若干残るが,現状の評価精度では許容範囲 + +**将来の精緻化条件**: + +- TECH_04 フォールバック条件 C1〜C3 のいずれか発動時 + +### II-3. センサ ピクセル積分 (Sensor Pixel Integration) + +**実機**: 各画素は有限面積でセンサ表面に到達した光を積分する(**ピクセル積分**). + +**シミュ**: Mitsuba は画素中心で点サンプリング + 多重サンプリング(spp)で評価. + +**構造的な違い**: + +- 実機は画素サイズで空間積分(ローパスフィルタ的効果) +- シミュは点サンプリングのため,原理的にエイリアシング可能性あり(spp で緩和) + +**白板撮影では許容できる理由**: + +- spp=1024 で十分な多重サンプリング +- 白板は低空間周波数のシーン(一様パターン)でエイリアシング感度低 +- ROI 内で平均化される指標(mean / CoV / 勾配等)には影響軽微 + +### II-4. 暗電流・読み出しノイズ (Dark Current / Read Noise) + +**実機**: センサ固有の暗電流・読み出しノイズ・熱雑音.センサ温度や ISO 設定で変動. +**シミュ**: Mitsuba にはセンサノイズ概念なし(MC 計算ノイズのみ). + +**構造的な違い**: 実機にはセンサ起因のノイズフロアが存在. + +**白板撮影では許容できる理由**: + +- 白板は **高輝度シーン**でセンサノイズの相対影響は小(ショットノイズが支配的) +- ISO 40 で読み出しノイズも最小化されている +- 暗部の評価では効くが,白板評価では影響軽微 + +### II-5. その他の許容差異 + +| 項目 | 構造的差異 | 白板で許容できる理由 | +| --- | --- | --- | +| 量子効率の波長依存性 | 実機は QE が波長で変化.シミュは RGB チャネル独立 | 無彩色シーン(白板)では影響なし | +| シャッター方式 | 実機はローリングシャッター歪み等可能性.シミュは瞬間取得 | 静止白板撮影では影響なし | +| 温度依存性 | 実機センサは温度で挙動変化 | 通常使用温度範囲で安定 | +| センサ毎の個体差 | 実機ごとに微差あり | 1 個体での評価では問題なし | + +### タイプ I と II の対応マッピング再整理 + +§対応マッピング表 §追加チェック項目 を **タイプ I / II** で再分類すると: + +| 区分 | 項目 | +| --- | --- | +| **タイプ I**(本質構造同一・対応概念なし) | AWB OFF(#2) / NR OFF(#5) / EDGE OFF(#6) / LSC HIGH_QUALITY(#7) / LSC 順方向適用(#8) / 量子化(#1) / COLOR_CORRECTION_GAINS / COLOR_CORRECTION_TRANSFORM / CONTROL_VIDEO_STABILIZATION_MODE | +| **タイプ II**(構造的差異あり・白板で許容) | **CONTROL_AF_MODE**(焦点モデル) / 絞り・口径食 / センサ ピクセル積分 / 暗電流・読み出しノイズ / QE 波長依存 / シャッター方式 / 温度依存 | +| **対応必要だった項目** | AE OFF(#3 = mean アンカー) / ガンマ補正(#4 = OETF 無効化) | + +### 残差 2.63× との関係 + +Step 8-13 残差 2.63× について: + +- **タイプ I**(対応概念なし)が原因の可能性は **ゼロ**(実機側で揃えた) +- **タイプ II**(許容差異)が原因の可能性は **小さいが排除不能** + - AF: 白板評価では影響軽微と推定.ただし舌模型評価時は再考必要 + - 絞り口径食: lscMap 補正残差として若干寄与 + - ピクセル積分: spp=1024 で緩和済み + - センサノイズ: 白板高輝度で相対影響小 +- **シミュ物理モデル誤差**(タイプ II とは別)が **最有力候補** + - 反射率精度・LED 配光モデル・メッシュ幾何精度等 + +つまり残差 2.63× の主因は **タイプ II の許容差異**ではなく,**シミュ物理モデル誤差**の側にある可能性が高い.それでもタイプ II の候補は完全には排除できないため,将来 Step 8-14 以降で物理モデル精緻化と並行して **タイプ II 寄与の独立評価**も検討する余地はある. + +## 動作確認の根拠 (Verification Evidence) + +Step C' 完了時の動作確認(`uv run sm-evaluate --image output/baseline/luminance.npy --config configs/eval_baseline_dng_target.yaml`)で以下を確認: + +| 確認項目 | 結果 | +| --- | --- | +| 新 config が正しく読み込まれる | ✓ | +| mean アンカー正規化が動作(`alpha=1.36257`, シミュ mean を 152.52 → 207.82 に揃え) | ✓ | +| OETF が適用されない(linear のまま) | ✓(コンソール出力に OETF 関連メッセージなし) | +| 動径 min/max 比が出力される | ✓(`radial min/max : 0.6151`) | +| 既存 569 + 新規 39 = 608 テスト全通過 | ✓(Step D 完了時に確認済) | + +→ シミュ側 (γ) の対応が **正常動作**することを確認. + +## Step 8-13 残差 2.63× の解釈(再確認) (Residual 2.63× Interpretation) + +実機側 8 項目の変更に **すべて整合させた上で** Step 8-13 を計測した結果: + +| 指標 | 実機 (DNG+LSC) | シミュ (Step 8-13) | 残差 | +| --- | --- | --- | --- | +| CoV | 0.0695 | 0.1826 | **2.63×** | +| 勾配 % | 8.35 | 24.65 | 2.95× | +| 中心/周辺 | 1.091 | 1.327 | 絶対差 0.236 | +| 動径 min/max | 0.860 | 0.615 | 絶対差 0.245 | + +この残差は **以下のいずれでもない**: + +| 残差候補 | 解消状況 | +| --- | --- | +| ガンマ補正のズレ | ✓ 解消済(両者 linear) | +| AWB のズレ | ✓ 解消済(実機 identity, シミュ per-channel 独立) | +| AE のズレ(絶対値スケール) | ✓ 解消済(mean アンカーで揃え) | +| NR/EDGE のアーティファクト | ✓ 解消済(実機 OFF) | +| LSC のズレ | ✓ 解消済(実機側で順方向適用) | +| ガンマ圧縮による過小評価 | ✓ 解消済(DNG linear で正直な値) | + +→ **残差 2.63× は ISP 関連の対応漏れではなく,シミュ側の物理モデル誤差**が主因と推定.候補: + +| 物理モデル要素 | 想定される影響 | +| --- | --- | +| 反射率の精度(現状 R=0.85) | 多重反射量の精度 → 形状指標に直接影響 | +| LED 配光モデル(現状 Lambertian) | 評価板への直接照射パターン → 中心/周辺比に影響 | +| メッシュ幾何精度(mm 精度) | 多重反射経路の集積 → 全体的なムラ | +| LED 位置の精度 | 過去 Step 8-9 で Fusion 360 STL から抽出済(ほぼ十分) | +| その他(センサ位置・FOV 等) | 過去 Step 8 で網羅試行済(影響軽微) | + +詳細な切り分けは別タスク(Step 8-14 以降の物理モデル精緻化)として残す. + +## 関連ドキュメント (Related Documents) + +### TECH 系(設計判断) + +- [TECH_01 定量撮影モード要求仕様](TECH_01_定量撮影モード要求仕様.md) — 実機側 8 項目変更の要求書 +- [TECH_03 定量モード実装報告と SHADING_MODE 相談](TECH_03_定量モード実装報告とSHADING_MODE相談.md) — MiniTias 側実装報告 +- [TECH_04 MiniTIAS への回答](TECH_04_MiniTIASへの回答.md) — SHADING_MODE 判断 +- [TECH_05 MiniTias-Evaluation DNG 対応要求仕様](TECH_05_MiniTiasEvaluation_DNG対応要求仕様.md) — Evaluation 側 DNG 対応 +- [TECH_06 MiniTias-Evaluation 引き継ぎ受領](TECH_06_MiniTias-Evaluation引き継ぎ受領.md) — DNG ローダ受領.LSC 順方向適用判明 +- [TECH_07 LSC 取り扱い方針](TECH_07_LSC取り扱い方針.md) — LSC の設計判断詳細 + +### TEST 系(試行検証) + +- [TEST_01 キャリブレーション検証履歴](../06_TEST/TEST_01_キャリブレーション検証履歴.md) — Step 8-1〜8-12 全イテレーション.Step 8-13 で本ドキュメントの内容を反映 +- [TEST_03 輝度マップ形状追跡](../06_TEST/TEST_03_輝度マップ形状追跡.md) — 「形のずれ」議論.finding 4 の前提変更 +- [TEST_05 PNG/DNG 評価比較](../06_TEST/TEST_05_PNG_DNG評価比較.md) — ガンマ補正の影響を数値実証 +- [TEST_06 外乱光影響評価](../06_TEST/TEST_06_外乱光影響評価.md) — 外乱光ロバスト性 + +### SPEC 系(仕様) + +- [SPEC_04 評価仕様](../04_SPEC/SPEC_04_評価仕様.md) — sm-evaluate パイプライン.OETF / normalization の正規仕様 diff --git "a/docs/06_TEST/TEST_01_\343\202\255\343\203\243\343\203\252\343\203\226\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\346\244\234\350\250\274\345\261\245\346\255\264.md" "b/docs/06_TEST/TEST_01_\343\202\255\343\203\243\343\203\252\343\203\226\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\346\244\234\350\250\274\345\261\245\346\255\264.md" index f098399..98b3d54 100644 --- "a/docs/06_TEST/TEST_01_\343\202\255\343\203\243\343\203\252\343\203\226\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\346\244\234\350\250\274\345\261\245\346\255\264.md" +++ "b/docs/06_TEST/TEST_01_\343\202\255\343\203\243\343\203\252\343\203\226\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\346\244\234\350\250\274\345\261\245\346\255\264.md" @@ -35,17 +35,19 @@ | Step | 設定 | 空間 | mean | CoV | 勾配% | C/P | CoV 残差倍率 | | --- | --- | --- | --- | --- | --- | --- | --- | -| 実測 TARGET | — | sRGB | 146.71 | 0.0427 | 6.10 | 1.065 | 1.0× | +| 実測 TARGET (旧 PNG sRGB) | — | sRGB | 146.71 | 0.0427 | 6.10 | 1.065 | 1.0× | | 8-1 spp=128 | R0.85, d8, linear | linear | 151.79 | 0.236 | 24.83 | 1.330 | 5.5× | | 8-1 spp=1024 (基準確定) | R0.85, d8, linear | linear | 152.02 | 0.183 | 24.72 | 1.328 | 4.3× | | 8-2 R=0.92 sweep | R0.92, d12, linear | linear | — | 0.170 | 19.42 | 1.241 | 4.0× | | 8-3 OETF 適用 | R0.85, d8, spp1024 | **sRGB** | 146.71* | **0.089** | **12.57** | **1.144** | **2.1×** | | 8-3 OETF + R=0.92 + d=12 | R0.92, d12 | sRGB | 146.71* | 0.081 | 9.72 | 1.108 | 1.9× | | 8-7 size 1.5×1.5 (Φe 保存) | linear, R0.85, d8 | linear | 429.99 | 0.186 | 26.21 | 1.355 | (形状指標 ≈ 8-1) | +| **実測 TARGET (新 DNG+LSC linear)** | — | **linear** | **207.82** | **0.0695** | **8.35** | **1.091** | **1.0×** | +| **8-13 新方針シミュ突合** | R0.85, d8, spp1024 | **linear** | 207.82* | **0.1826** | **24.65** | **1.327** | **2.63×** | \* mean アンカー固定.R, d, spp は `reflectance`, `max_depth`, `samples_per_pixel`. -最大の跳躍は **8-3 OETF(残差 4.3× → 2.1×)**.現時点の確定基準は **R=0.85, d=8, spp=1024, OETF=on** で残差 2.1×.残る 1.6〜1.9× の残差は **LSC(ISP レンズシェーディング補正)** が主因候補(未対処,[Step 8-N 候補](#step-8-n-候補-lsc-順方向モデル未着手) 参照). +最大の跳躍は **8-3 OETF(残差 4.3× → 2.1×)**.ただし [Step 8-13](#step-8-13-新方針-実機-dng--lsc-linear-でのシミュ突合) で **過去 2.1× は PNG sRGB ガンマ圧縮による楽観値**と判明.真の linear 比較での残差は **2.63×** が確定基準.現時点では **R=0.85, d=8, spp=1024, OETF=off, normalization mean=207.82** で残差 2.63×.残差解消は今後の **物理モデル精緻化** (反射率精度・LED 配光・幾何) に委ねる([TECH_07](../05_TECH/TECH_07_LSC取り扱い方針.md) / [TECH_08](../05_TECH/TECH_08_実機変更とシミュ側対応.md) 参照).LSC モデル化(Step 8-N 候補)は実機側で順方向適用するアプローチが確立したため **不採用**. ## イテレーション履歴 (Iteration Log) @@ -456,9 +458,58 @@ - **orthographic + ptracer は Mitsuba 3 では実行不能**:これは MiniTIAS のシーン構成では path/ptracer を直接比較できない技術的制約.perspective sensor でのみ検証可能だった. - **方向変更で形を実機に近づける道は無い**ことが(部分的に)実証された.**LSC モデル化が唯一の道**との結論を強化. - **次のアクション**: - - 本筋 LSC モデル化([Step 8-N](#step-8-n-候補-lsc-順方向モデル未着手))に進む + - 本筋 LSC モデル化([Step 8-N](#step-8-n-候補-lsc-順方向モデル不採用--step-8-13-で方針確定))に進む + - 注: 後続の Step 8-13 で **LSC モデル化は不要と確定**(実機側で LSC 順方向適用する方針に切替) -## Step 8-N 候補: LSC 順方向モデル(未着手) +### Step 8-13: 新方針 (実機 DNG + LSC linear) でのシミュ突合 + +- **日付**: 2026-06-02 +- **コミット**: 本イテレーション完了時点(`feature/dng-loader-import` ブランチで S2→Step D→C'→TECH_07/08 を累積) +- **背景・問題意識**: TECH_06 で MiniTias-Evaluation 側に DNG 読み込み + LSC 順方向適用が実装され,新実機ターゲット (CoV=0.0695) が確定.過去 Step 8-1〜8-12 は **PNG sRGB ドメイン**で比較していたが,[TECH_07](../05_TECH/TECH_07_LSC取り扱い方針.md) / [TECH_08](../05_TECH/TECH_08_実機変更とシミュ側対応.md) で「実機側 linear + LSC 順方向適用」と「シミュ orthographic」が同じドメイン (「LSC 完璧補正後相当」) に揃うことが判明.本イテレーションで実際に同じ linear ドメインでシミュ vs 実機を計測する. +- **仮説(物理動機)**: 過去 Step 8-3 で OETF 順方向適用後の残差 2.1× だったが,これは PNG sRGB ガンマ圧縮による楽観値の可能性.真の linear 比較では残差はもっと大きい.[TEST_05](TEST_05_PNG_DNG評価比較.md) で「PNG sRGB CoV=0.0423 → PNG 擬似 linear CoV=0.0901」と数値実証されており,真の残差は約 **2.66×** と予想. +- **試した変更**: + - `configs/eval_baseline_dng_target.yaml` 新規作成 (S3 Step C') + - `eval_baseline_linear.yaml` ベース.差分は 2 点だけ: + 1. `normalization.target_mean: 146.7146 → 207.82` (実機 DNG+LSC の白板 ROI mean) + 2. `metrics.radial_min_max: true` 追加 (S3 Step D で sm-evaluate に統合した新指標) + - OETF セクションを **意図的に書かない** (実機が linear なので両者 linear で比較するため,[TECH_08](../05_TECH/TECH_08_実機変更とシミュ側対応.md) §#4 ガンマ補正対応 参照) + - 既存シミュ baseline (`output/baseline/luminance.npy`) を新 config で評価 +- **期待した効果**: 残差 2.5〜3× 程度で TEST_05 の予想値 (2.66×) を実測確認 +- **実際の結果**: + + | 指標 | 実機 DNG+LSC | シミュ (本イテレーション) | 残差 | + | --- | --- | --- | --- | + | mean | 207.82 | 207.82*(mean アンカー後) | — | + | CoV | 0.0695 | **0.1826** | **2.63×** | + | 勾配 % | 8.35 | 24.65 | 2.95× | + | 中心/周辺 (C/P) | 1.0911 | 1.3271 | 絶対差 0.236 | + | 動径 min/max | 0.8600 | 0.6151 | 絶対差 0.245 | + | max/min 比 | 1.617 | 4.276 | 2.64× | + + - normalization alpha=1.36257(シミュ mean 152.52 → 207.82 に揃え) + - TEST_05 で予想した「真の残差 2.66×」とほぼ一致(CoV 2.63×) +- **解釈**: + - **過去 Step 8-3 の 2.1× は楽観値**だったことが確定.PNG sRGB ガンマ圧縮が CoV を約 1/2 に縮めていた(TEST_05 §4 参照). + - 真の linear 比較では残差 2.63× がベースライン.これが「LSC 関連を実機側で全部揃えた後に残る本質的な差」. + - これは **LSC 由来ではない別要因**: [TECH_07 §限界と注意点 §2](../05_TECH/TECH_07_LSC取り扱い方針.md) で明示. + - 残差の主因候補: **シミュ物理モデル誤差**(反射率精度・LED 配光モデル・メッシュ幾何精度).[TECH_08](../05_TECH/TECH_08_実機変更とシミュ側対応.md) で実機側変更全 8 項目へのシミュ側対応漏れがないことを網羅確認済. +- **学び**: + - **ガンマ圧縮は CoV を約 2 倍縮める**.過去 PNG 評価は楽観的だった.真の精度評価には DNG linear が必須. + - **「形を変える唯一の道は LSC モデル化」(過去 [TEST_03](TEST_03_輝度マップ形状追跡.md) finding 4)の前提変更**: 実機側で LSC 順方向適用するアプローチが MiniTias-Evaluation で確立した今,シミュ側 LSC モデル化は不要.Step 8-N (LSC 順方向モデル) は **不採用** ([TECH_07 §検討した代替案](../05_TECH/TECH_07_LSC取り扱い方針.md)). + - **過去 Step 8-1〜8-12 の試行は無駄ではなかった**: 「形は変わらない」確定により,物理モデル誤差以外の方向(emitter 配光・反射率・センサ方向)が排除された.残るは物理モデル誤差(タイプ II 構造的差異も小寄与だが排除不能,[TECH_08 §許容している構造的差異](../05_TECH/TECH_08_実機変更とシミュ側対応.md)). + - **「対応すべき項目」と「許容している構造的差異」の区別が明確化**: タイプ I (本質構造同一・対応概念なし) と タイプ II (構造的差異あり・白板で許容) に分けて整理. +- **次のアクション**: + - LSC 関連は完結([TECH_07](../05_TECH/TECH_07_LSC取り扱い方針.md) で明文化). + - シミュ側変更対応漏れも完結([TECH_08](../05_TECH/TECH_08_実機変更とシミュ側対応.md) で明文化). + - 残差 2.63× の解消は **Step 8-14 以降の物理モデル精緻化** として残す: + - 反射率の実物理整合度の検証(過去 Step 8-2 で R=0.85→0.92 のスイープ実施済.より精密な物理整合チェック) + - LED 配光モデルの詳細化(過去 Step 8-10/8-11 spot 試行済.より忠実な IES 取得が選択肢) + - メッシュ幾何精度の評価(過去 Step 8-9 で Fusion STL 抽出済.残る微差を評価) + - [TECH_04](../05_TECH/TECH_04_MiniTIASへの回答.md) フォールバック条件 C1 候補(SHADING_OFF 撮影)も並行検討の余地あり. + +## Step 8-N 候補: LSC 順方向モデル(不採用 — Step 8-13 で方針確定) + +> **2026-06-02 注記**: 本セクションは当時「実機側を変更しない前提」で「シミュ側に LSC モデル化を追加して残差を埋める」案として立案されていた.[Step 8-13](#step-8-13-新方針-実機-dng--lsc-linear-でのシミュ突合) で **実機側に LSC 順方向適用** するアプローチが MiniTias-Evaluation で確立した結果,**本案は不採用**となった.以下は歴史的記録として残す.詳細な却下理由は [TECH_07 §検討した代替案](../05_TECH/TECH_07_LSC取り扱い方針.md) 参照. - **位置付け**: 8-3 OETF 適用後の残差 **1.6〜1.9×**(CoV 0.089 vs 実測 0.043,勾配 12.57 vs 6.10)の **主因候補**. - **仮説(物理動機)**: 実測 PNG は実機 ISP の **レンズシェーディング補正**(周辺光量低下の電子補正)が掛かっており,**周辺を持ち上げて中央を相対的に押し下げる**.これは残差の符号(シミュ側の方が勾配・C/P が大きい)と整合.シミュ側で同じ補正を順方向に適用すれば消える可能性が高い. diff --git a/pyproject.toml b/pyproject.toml index c69bf61..f7f7abb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ "pydantic>=2.13.3", "pyvista>=0.45", "pyyaml>=6.0.3", + "rawpy>=0.21.0", "trimesh>=4.12.1", ] diff --git a/src/sm_tias_lightsim/cli/evaluate.py b/src/sm_tias_lightsim/cli/evaluate.py index f2a6b56..de6afe0 100644 --- a/src/sm_tias_lightsim/cli/evaluate.py +++ b/src/sm_tias_lightsim/cli/evaluate.py @@ -26,7 +26,10 @@ from sm_tias_lightsim.evaluation.normalize import apply_mean_anchor # noqa: E402 from sm_tias_lightsim.evaluation.oetf import apply_srgb_oetf, solve_exposure # noqa: E402 -from sm_tias_lightsim.evaluation.radial import calc_radial_profile # noqa: E402 +from sm_tias_lightsim.evaluation.radial import ( # noqa: E402 + calc_radial_min_max_ratio, + calc_radial_profile, +) from sm_tias_lightsim.evaluation.roi import extract_roi # noqa: E402 from sm_tias_lightsim.evaluation.visualize import ( # noqa: E402 save_histogram, @@ -278,6 +281,36 @@ return calc_radial_profile(roi_luminance) +def _run_radial_min_max( + roi_luminance: numpy.ndarray, + metrics_config: MetricsConfig | None, + radial_profile: numpy.ndarray | None, +) -> dict: + """動径 min/max 比を算出して返す. + + radial_min_max が True のとき,radial_profile が既に算出されていればそれを再利用し, + 未算出(None)の場合はその場で calc_radial_profile を呼び出して算出する. + 重複算出を避けるため,radial_profile の結果を優先的に使用する. + + Args: + roi_luminance: ROI 輝度配列(H x W, float64). + metrics_config: MetricsConfig インスタンス(radial_min_max フラグを持つ). + None の場合は無効扱い. + radial_profile: _run_radial_profile で算出済みのプロファイル配列. + None の場合はここで算出する. + + Returns: + 動径 min/max 比指標の辞書(radial_min_max_ratio / radial_min_distance / + radial_min / radial_max の 4 キー).無効時は空辞書. + """ + if metrics_config is None or not metrics_config.radial_min_max: + return {} + + # radial_profile が未算出の場合はここで算出する(重複算出を避けるため既存結果を優先) + profile = radial_profile if radial_profile is not None else calc_radial_profile(roi_luminance) + return calc_radial_min_max_ratio(profile) + + def _save_metrics_csv(metrics: dict, csv_path: Path) -> None: """均一性指標を CSV ファイルとして保存する. @@ -333,6 +366,10 @@ print(f" C/P ratio : {metrics['center_periphery_ratio']:.4f}") print(f" gradient [%] : {metrics['gradient_magnitude']:.2f}") + if "radial_min_max_ratio" in metrics: + print(f" radial min/max : {metrics['radial_min_max_ratio']:.4f}") + print(f" radial min dist: {metrics['radial_min_distance']:.4f}") + print(f"\nmetrics.csv : {csv_path}") print(f"figures/ : {figures_dir}") @@ -351,9 +388,10 @@ 8. calc_uniformity で均一性指標を算出する. 9. zones.enabled のときゾーン分析を実行する. 10. metrics.radial_profile のとき放射状プロファイルを算出する. - 11. metrics.csv を保存する. - 12. 可視化 PNG を figures/ に保存する. - 13. 算出指標のサマリーを標準出力に表示する. + 11. metrics.radial_min_max のとき動径 min/max 比を算出する(radial_profile 再利用). + 12. metrics.csv を保存する. + 13. 可視化 PNG を figures/ に保存する. + 14. 算出指標のサマリーを標準出力に表示する. Args: argv: 引数リスト.None のとき sys.argv[1:] を使用. @@ -443,6 +481,15 @@ print(f"エラー: 放射状プロファイルの算出に失敗しました — {exc}", file=sys.stderr) return 1 + try: + radial_min_max_stats = _run_radial_min_max( + roi_luminance, eval_config.metrics, radial_profile + ) + metrics.update(radial_min_max_stats) + except Exception as exc: # noqa: BLE001 + print(f"エラー: 動径 min/max 比の算出に失敗しました — {exc}", file=sys.stderr) + return 1 + csv_path = output_dir / _METRICS_FILENAME try: _save_metrics_csv(metrics, csv_path) diff --git a/src/sm_tias_lightsim/evaluation/dng_loader.py b/src/sm_tias_lightsim/evaluation/dng_loader.py new file mode 100644 index 0000000..5558155 --- /dev/null +++ b/src/sm_tias_lightsim/evaluation/dng_loader.py @@ -0,0 +1,214 @@ +"""DNG 読み込みモジュール. + +MiniTias-Evaluation/src/io/dng_loader.py を SmTIAS-LightSim にコピーしたもの. +オリジナルは MiniTias-Evaluation 側で管理する.バグ修正・機能追加はそちらで行い, +本プロジェクトに反映する形を取る. + +主要 API: +- load_image_dng(path, demosaic, black_level, output_format) -> numpy.ndarray + +詳細: docs/05_TECH/TECH_06_MiniTias-Evaluation引き継ぎ受領.md + +--- + +以下は実装上の重要な設計判断(コピー元 docstring より): + +- output_color=raw (カラーマトリクス非適用) を rgb_float32 / luma_float32 で使う +- output_color=sRGB を rgb_uint8 のみで使う +- gamma=(1,1) + no_auto_bright=True + output_bps=16 + use_camera_wb=False + user_wb=[1,1,1,1] + で linear raw を保つ +- Rec.709 luma は output_color=raw のとき固定加重プロキシ (CIE 輝度ではない) で + 既存 PNG 経路 (uniformity.py の to_grayscale) と互換 +""" + +from pathlib import Path + +import numpy +import rawpy + +# Rec.709 グレースケール変換係数(uniformity.py の REC709_COEFF_* と一致させる) +# 参照: ITU-R BT.709 (https://www.itu.int/rec/R-REC-BT.709/en) +_REC709_R = 0.2126 +_REC709_G = 0.7152 +_REC709_B = 0.0722 + +# demosaic 引数 → rawpy.DemosaicAlgorithm のマッピング +# 参照: https://letmaik.github.io/rawpy/api/rawpy.DemosaicAlgorithm.html +_DEMOSAIC_MAP: dict[str, rawpy.DemosaicAlgorithm] = { + # 最近傍は rawpy に専用 enum がないため LINEAR で代替 + "nearest": rawpy.DemosaicAlgorithm.LINEAR, + "bilinear": rawpy.DemosaicAlgorithm.LINEAR, + "ahd": rawpy.DemosaicAlgorithm.AHD, +} + +# 有効な output_format の一覧 +_VALID_OUTPUT_FORMATS = {"rgb_float32", "luma_float32", "rgb_uint8"} + +# rawpy が output_bps=16 で返す uint16 の最大値(0〜65535 を [0, 1] に正規化するため) +_UINT16_MAX = 65535.0 + + +def load_image_dng( + path: str, + demosaic: str = "bilinear", + black_level: int | None = None, + output_format: str = "rgb_float32", +) -> numpy.ndarray: + """MiniTIAS 定量モード DNG を読み込み配列として返す. + + rawpy の postprocess() を linear パラメータで呼び出すことで, + ISP(ガンマ・自動輝度・カメラ WB)の介入を最小化した linear raw 値を取り出す. + 具体的には gamma=(1,1), no_auto_bright=True, output_bps=16, use_camera_wb=False を指定する. + + Args: + path: DNG ファイルのパス. + demosaic: デモザイクアルゴリズム.既定 "bilinear". + "nearest"(最近傍)/ "bilinear"(双線形)/ "ahd"(適応的)から選択. + "nearest" は rawpy に専用実装がないため "bilinear" と同一の LINEAR を使用する. + bilinear(既定)は線形演算で比例性を保つ. + **AHD は適応的=非線形でチャンネル間の比例が崩れうるため量的評価では非推奨**. + black_level: 黒レベル(整数).None なら DNG Exif の + black_level_per_channel[0] から自動取得する.通常は 64(SH-02M). + output_format: 出力形式.既定 "rgb_float32". + "rgb_float32": (H, W, 3) float32, linear, 値域 [0.0, 1.0]. + output_color=raw(カラーマトリクス非適用)でカメラ固有チャンネルの + linear raw 信号を出力する.各チャンネルがそのセンサチャンネルの + 入射光量に比例する(チャンネル混合なし). + 飽和画素は 1.0 を超えることがある(クリップしない). + "luma_float32": (H, W) float32, linear, 値域 [0.0, 1.0]. + output_color=raw(カラーマトリクス非適用)でカメラ固有チャンネルの + linear raw 信号を取得し,Rec.709 重みで輝度に変換する + (uniformity.py の to_grayscale と同じ係数). + **output_color=raw のとき Rec.709 luma は厳密な CIE 輝度ではなく, + 既存 PNG 経路(uniformity.py の to_grayscale)と互換の固定加重輝度プロキシ + である**.Rec.709 係数を維持する理由は,既存パイプラインとの方法論的一貫性 + と DNG/PNG 評価指標の直接比較のためである. + "rgb_uint8": (H, W, 3) uint8, カラーマトリクス+sRGB OETF 適用後. + output_color=sRGB(カラーマトリクス適用)+ sRGB OETF を維持する. + 表示・PNG 互換専用であり,量的評価には linear 形式(rgb_float32 / + luma_float32)を使うこと. + + Returns: + numpy.ndarray. 形状・dtype は output_format に依存. + + Raises: + FileNotFoundError: ファイルが存在しない場合. + ValueError: demosaic または output_format の値が不正な場合, + または rawpy が DNG を読み込めなかった場合(形式が不正等). + """ + if not Path(path).exists(): + raise FileNotFoundError(f"DNG ファイルが見つかりません: {path}") + + if demosaic not in _DEMOSAIC_MAP: + raise ValueError( + f"demosaic の値が不正です: '{demosaic}'.有効な値: {sorted(_DEMOSAIC_MAP.keys())}" + ) + + if output_format not in _VALID_OUTPUT_FORMATS: + raise ValueError( + f"output_format の値が不正です: '{output_format}'." + f"有効な値: {sorted(_VALID_OUTPUT_FORMATS)}" + ) + + try: + raw = rawpy.imread(str(path)) + except Exception as exc: + raise ValueError( + f"DNG の読み込みに失敗しました(形式が不正の可能性があります): {path}" + ) from exc + + # rawpy.postprocess() は DNG Exif の black_level_per_channel / white_level を + # 内部で自動取得して黒レベル除去・正規化を行う. + # postprocess に black_level を直接渡す API はないため,引数値の検証は行わない. + # (将来の自前デモザイク実装時に black_level 引数を活用できるよう API として保持している) + # SH-02M: black_level_per_channel=[64,64,64,64], white_level=1023 (scale=959) + + # 共通パラメータ: linear 出力のための ISP 無効化設定 + # - gamma=(1,1): ガンマ補正なし(linear のまま出力) + # - no_auto_bright=True: 自動輝度調整を無効化 + # - output_bps=16: 16-bit 出力(精度確保のため) + # - use_camera_wb=False + user_wb=[1,1,1,1]: WB ゲインをすべて 1 に固定(無補正) + # - demosaic_algorithm: ユーザー指定アルゴリズム + # rawpy.postprocess は DNG Exif から黒レベルと white_level を読み取り, + # (raw - black_level) / (white_level - black_level) を 0〜65535 にスケールして出力する. + # gamma=(1,1) により出力は linear のまま保たれる. + # SH-02M の定量モード DNG は TONEMAP_MODE=CONTRAST_CURVE_LINEAR であり, + # sRGB OETF は適用されていないため,このパラメータで正しく linear raw が得られる. + _postprocess_common = { + "demosaic_algorithm": _DEMOSAIC_MAP[demosaic], + "gamma": (1, 1), + "no_auto_bright": True, + "output_bps": 16, + "use_camera_wb": False, + "user_wb": [1.0, 1.0, 1.0, 1.0], + } + + if output_format in ("rgb_float32", "luma_float32"): + # output_color=raw: カラーマトリクスを通さずカメラ固有チャンネルの linear raw 信号を出力. + # これにより各チャンネルがそのセンサチャンネルの入射光量に比例する(チャンネル混合なし). + # SmTIAS-LightSim がチャンネル単位でシミュ vs 実機を突合するため per-channel の + # センサ忠実度を優先する.output_color=sRGB(既定)はカラーマトリクスを適用するため + # 各出力チャンネルが他チャンネルの線形結合となり,量的評価に不適切である. + rgb16 = raw.postprocess( + **_postprocess_common, + output_color=rawpy.ColorSpace.raw, + ) + # rgb16: (H, W, 3) uint16,値域 0〜65535 + # (black_level 補正済み linear,white_level で正規化) + rgb_float = rgb16.astype(numpy.float32) / _UINT16_MAX + # rgb_float: (H, W, 3) float32, linear, 値域 [0, 1] + + raw.close() + + if output_format == "rgb_float32": + return rgb_float + + # output_format == "luma_float32" + # Rec.709 重みで輝度に変換(uniformity.py の to_grayscale と同じ係数). + # output_color=raw のとき Rec.709 luma は厳密な CIE 輝度ではなく固定加重輝度プロキシ. + # 既存 PNG 経路(uniformity.py の to_grayscale)との方法論的一貫性と + # DNG/PNG 評価指標の直接比較のために Rec.709 係数を維持する. + luma = ( + _REC709_R * rgb_float[:, :, 0] + + _REC709_G * rgb_float[:, :, 1] + + _REC709_B * rgb_float[:, :, 2] + ) + return luma # (H, W) float32 + + # output_format == "rgb_uint8" + # output_color=sRGB: カラーマトリクス+sRGB OETF 適用(既存 PNG 経路と互換). + # 表示・PNG 互換専用であり,量的評価には rgb_float32 / luma_float32 を使うこと. + # sRGB OETF: V <= 0.0031308 → 12.92 * V, else 1.055 * V^(1/2.4) - 0.055 + # 参照: IEC 61966-2-1 (sRGB standard) + rgb16 = raw.postprocess( + **_postprocess_common, + output_color=rawpy.ColorSpace.sRGB, + ) + rgb_float = rgb16.astype(numpy.float32) / 65535.0 + raw.close() + + rgb_srgb = _apply_srgb_oetf(rgb_float) + rgb_uint8 = numpy.clip(rgb_srgb * 255.0, 0, 255).astype(numpy.uint8) + return rgb_uint8 # (H, W, 3) uint8 + + +def _apply_srgb_oetf(linear: numpy.ndarray) -> numpy.ndarray: + """Linear float 画像に sRGB OETF を適用する. + + IEC 61966-2-1 に基づく区分関数: + V <= 0.0031308: out = 12.92 * V + V > 0.0031308: out = 1.055 * V^(1/2.4) - 0.055 + + Args: + linear: (H, W, 3) float32, 値域 [0.0, 1.0]. + + Returns: + sRGB OETF 適用後の (H, W, 3) float32, 値域 [0.0, 1.0]. + """ + # ゼロ以下の値はそのまま(負値クリップはしない) + out = numpy.where( + linear <= 0.0031308, + 12.92 * linear, + 1.055 * numpy.power(numpy.maximum(linear, 0.0), 1.0 / 2.4) - 0.055, + ) + return out.astype(numpy.float32) diff --git a/src/sm_tias_lightsim/evaluation/radial.py b/src/sm_tias_lightsim/evaluation/radial.py index 9987734..7a73bd2 100644 --- a/src/sm_tias_lightsim/evaluation/radial.py +++ b/src/sm_tias_lightsim/evaluation/radial.py @@ -52,3 +52,68 @@ profile = numpy.column_stack([bin_centers, mean_luminance]) return profile + + +def calc_radial_min_max_ratio(radial_profile: numpy.ndarray) -> dict: + """動径プロファイルの最小/最大輝度比を算出する. + + MiniTias-Evaluation の ``src/analysis/spatial.py:calc_radial_min_max_ratio`` を + SmTIAS-LightSim にマージしたもの. + + ``calc_radial_profile`` で得た N 点の輪帯平均輝度のうち,最小値を最大値で割った比 + (0〜1)を返す.1 に近いほど均一で,中心明・周辺暗の単調減衰では + 「最外輪帯 / 最内輪帯」に一致する.全 N 点の min/max を使うため, + 非単調プロファイル(例: 中央暗・周辺明)にもロバストに対応する. + ``nan`` ビン(空ビン)は無視する. + + References: + MiniTias-Evaluation ``src/analysis/spatial.py:calc_radial_min_max_ratio`` + [TEST_05](docs/06_TEST/TEST_05_PNG_DNG評価比較.md) — 新指標の物理的意味 + + Args: + radial_profile: 放射状プロファイル配列(shape: (N, 2), dtype: numpy.float64). + 列 0 = ビン中心距離, 列 1 = 平均輝度.``calc_radial_profile`` の戻り値を渡す. + 空ビン(nan)は算出から除外される. + + Returns: + 動径 min/max 比を格納した辞書.キーは: + + - ``"radial_min_max_ratio"`` (float): 最小輝度 / 最大輝度(0〜1). + 最大輝度が 0 の場合は ``float("inf")``. + - ``"radial_min_distance"`` (float): 最小輝度ビンの正規化距離. + - ``"radial_min"`` (float): プロファイルの最小輝度値(nan 除外後). + - ``"radial_max"`` (float): プロファイルの最大輝度値(nan 除外後). + """ + luminances = radial_profile[:, 1] + distances = radial_profile[:, 0] + + # 空ビン(nan)を除外して集計する + valid_mask = ~numpy.isnan(luminances) + valid_luminances = luminances[valid_mask] + valid_distances = distances[valid_mask] + + if valid_luminances.size == 0: + # 有効ビンがない場合は nan で返す + return { + "radial_min_max_ratio": float("nan"), + "radial_min_distance": float("nan"), + "radial_min": float("nan"), + "radial_max": float("nan"), + } + + radial_min = float(numpy.min(valid_luminances)) + radial_max = float(numpy.max(valid_luminances)) + + # max が 0 のときはゼロ除算を回避して inf を返す(zones.py の既存パターンに倣う) + radial_min_max_ratio = radial_min / radial_max if radial_max != 0.0 else float("inf") + + # 最小輝度をとるビンの正規化距離(補助情報) + min_idx = int(numpy.argmin(valid_luminances)) + radial_min_distance = float(valid_distances[min_idx]) + + return { + "radial_min_max_ratio": radial_min_max_ratio, + "radial_min_distance": radial_min_distance, + "radial_min": radial_min, + "radial_max": radial_max, + } diff --git a/src/sm_tias_lightsim/evaluation/roi.py b/src/sm_tias_lightsim/evaluation/roi.py index eb9117c..5a1533c 100644 --- a/src/sm_tias_lightsim/evaluation/roi.py +++ b/src/sm_tias_lightsim/evaluation/roi.py @@ -40,10 +40,7 @@ y_end = roi.y + roi.height if roi.x < 0 or roi.y < 0: - msg = ( - f"ROI の開始座標 (x={roi.x}, y={roi.y}) が負の値です." - "ROI 設定を確認してください." - ) + msg = f"ROI の開始座標 (x={roi.x}, y={roi.y}) が負の値です.ROI 設定を確認してください." raise ValueError(msg) if x_end > img_width or y_end > img_height: diff --git a/src/sm_tias_lightsim/scene/schema.py b/src/sm_tias_lightsim/scene/schema.py index c8a1f83..4054431 100644 --- a/src/sm_tias_lightsim/scene/schema.py +++ b/src/sm_tias_lightsim/scene/schema.py @@ -188,8 +188,12 @@ position: Vec3 = Field(..., description="位置 [mm]") direction: Vec3 = Field(..., description="光軸方向ベクトル") cutoff_angle: float = Field(..., description="光が完全に 0 になる角度(度,0 < cutoff ≤ 90)") - beam_width: float = Field(..., description="中央フラット領域の半角(度,0 ≤ beam_width ≤ cutoff_angle)") - intensity: float | Vec3 = Field(..., description="軸上強度 I_0 [W/sr](スカラまたは RGB タプル)") + beam_width: float = Field( + ..., description="中央フラット領域の半角(度,0 ≤ beam_width ≤ cutoff_angle)" + ) + intensity: float | Vec3 = Field( + ..., description="軸上強度 I_0 [W/sr](スカラまたは RGB タプル)" + ) # type フィールドで識別される discriminated union @@ -354,6 +358,14 @@ std: bool = Field(default=True, description="標準偏差") max_min: bool = Field(default=True, description="最大値/最小値比") radial_profile: bool = Field(default=True, description="放射状プロファイル") + radial_min_max: bool = Field( + default=False, + description=( + "動径 min/max 比(radial_min_max_ratio / radial_min_distance / " + "radial_min / radial_max の 4 列を metrics.csv に追加)." + "True のとき radial_profile の有無に関わらず内部で radial_profile を算出する." + ), + ) class EvaluationConfig(_FrozenBase): diff --git a/tests/cli/test_evaluate_radial_min_max.py b/tests/cli/test_evaluate_radial_min_max.py new file mode 100644 index 0000000..f32b20b --- /dev/null +++ b/tests/cli/test_evaluate_radial_min_max.py @@ -0,0 +1,741 @@ +"""cli/evaluate.py の _run_radial_min_max 関数と CLI 統合動作の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_04_評価仕様.md(動径指標・評価パイプライン) +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(evaluation/metrics セクション) +- src/sm_tias_lightsim/cli/evaluate.py(_run_radial_min_max 実装) +- src/sm_tias_lightsim/evaluation/radial.py(calc_radial_min_max_ratio 実装) + +テスト方針: + - subprocess ではなく main() / _run_radial_min_max() を直接呼ぶ(速度・カバレッジのため) + - ファイル I/O はモックせず tmp_path フィクスチャで実ファイルを使う (GUIDE_08) + - 乱数を使う場合は seed を必ず固定する (GUIDE_08) + - monkeypatch で calc_radial_profile の呼び出し有無を検証する + +注意: + matplotlib の Agg バックエンドは evaluate.py 内部で設定される. + テスト側でも安全のため import 前に設定する. +""" + +from __future__ import annotations + +import matplotlib # noqa: I001 + +# headless 環境でも動くよう Agg バックエンドを pyplot より前に設定する +matplotlib.use("Agg") + +from pathlib import Path +from unittest.mock import patch + +import numpy +import pytest + +from sm_tias_lightsim.cli.evaluate import _run_radial_min_max, main +from sm_tias_lightsim.evaluation.radial import calc_radial_profile +from sm_tias_lightsim.scene.schema import MetricsConfig + +# --------------------------------------------------------------------------- +# テスト用ヘルパー +# --------------------------------------------------------------------------- + + +def _make_luminance_array(seed: int = 0, size: int = 128) -> numpy.ndarray: + """ゼロを含まない合成輝度配列を返す(seed 固定). + + center が明るく periphery が暗い放射状勾配で radial_min_max が意味を持つ値になる. + """ + rng = numpy.random.RandomState(seed) + h, w = size, size + ys, xs = numpy.mgrid[0:h, 0:w] + cy, cx = h / 2.0, w / 2.0 + dist = numpy.sqrt(((xs - cx) / cx) ** 2 + ((ys - cy) / cy) ** 2) + luminance = 100.0 - 30.0 * dist + rng.normal(0, 2.0, (h, w)) + # ゼロを避けるためクリップ + return numpy.clip(luminance, 1.0, None).astype(numpy.float64) + + +def _make_eval_yaml_with_radial_min_max( + tmp_path: Path, + radial_min_max: bool = True, + radial_profile: bool = True, + x: int = 0, + y: int = 0, + width: int = 128, + height: int = 128, +) -> Path: + """radial_min_max フラグを含む評価 YAML を tmp_path に書き出す.""" + content = f"""\ +roi: + type: image_pixel + x: {x} + y: {y} + width: {width} + height: {height} +zones: + enabled: true + boundaries: [0.33, 0.66] +metrics: + cov: true + std: true + max_min: true + radial_profile: {"true" if radial_profile else "false"} + radial_min_max: {"true" if radial_min_max else "false"} +""" + yaml_path = tmp_path / "eval_radial_min_max.yaml" + yaml_path.write_text(content, encoding="utf-8") + return yaml_path + + +def _run_main(npy_path: Path, config_path: Path, output_dir: Path) -> int: + """main() を呼ぶ共通ラッパー(行長削減のため).""" + return main( + ["--image", str(npy_path), "--config", str(config_path), "--output", str(output_dir)] + ) + + +# --------------------------------------------------------------------------- +# B1. 無効時は空辞書: metrics_config is None で {} を返す +# --------------------------------------------------------------------------- + + +def test_run_radial_min_max_returns_empty_dict_when_metrics_config_is_none(): + """metrics_config が None のとき _run_radial_min_max が {} を返すこと. + + 仕様: metrics_config is None → 無効扱いで空辞書を返す + (cli/evaluate.py _run_radial_min_max) + """ + roi_luminance = _make_luminance_array(seed=0) + result = _run_radial_min_max(roi_luminance, None, None) + assert result == {} + + +# --------------------------------------------------------------------------- +# B2. 無効時は空辞書(False): radial_min_max=False で {} を返す +# --------------------------------------------------------------------------- + + +def test_run_radial_min_max_returns_empty_dict_when_radial_min_max_false(): + """MetricsConfig(radial_min_max=False) で _run_radial_min_max が {} を返すこと. + + 仕様: metrics_config.radial_min_max=False → 無効扱いで空辞書を返す + (cli/evaluate.py _run_radial_min_max) + """ + roi_luminance = _make_luminance_array(seed=1) + metrics_cfg = MetricsConfig(radial_min_max=False) + result = _run_radial_min_max(roi_luminance, metrics_cfg, None) + assert result == {} + + +def test_run_radial_min_max_returns_empty_dict_when_radial_min_max_default(): + """MetricsConfig() のデフォルト(radial_min_max=False)で {} を返すこと. + + 仕様: MetricsConfig.radial_min_max の既定値 False → 無効扱い + """ + roi_luminance = _make_luminance_array(seed=2) + metrics_cfg = MetricsConfig() # radial_min_max=False がデフォルト + result = _run_radial_min_max(roi_luminance, metrics_cfg, None) + assert result == {} + + +# --------------------------------------------------------------------------- +# B3. 有効時は 4 キー辞書 +# --------------------------------------------------------------------------- + + +def test_run_radial_min_max_returns_four_keys_when_enabled(): + """MetricsConfig(radial_min_max=True) + プロファイル提供で 4 キー辞書を返すこと. + + 仕様: radial_min_max=True のとき + radial_min_max_ratio / radial_min_distance / radial_min / radial_max + の 4 キーを持つ辞書を返す(cli/evaluate.py _run_radial_min_max) + """ + roi_luminance = _make_luminance_array(seed=3) + metrics_cfg = MetricsConfig(radial_min_max=True) + profile = calc_radial_profile(roi_luminance) + result = _run_radial_min_max(roi_luminance, metrics_cfg, profile) + + assert "radial_min_max_ratio" in result + assert "radial_min_distance" in result + assert "radial_min" in result + assert "radial_max" in result + + +def test_run_radial_min_max_ratio_in_valid_range(): + """radial_min_max_ratio が [0, 1] の範囲内であること(均一性指標として妥当). + + 仕様: calc_radial_min_max_ratio — 最小輝度 / 最大輝度(0〜1) + (evaluation/radial.py calc_radial_min_max_ratio) + """ + roi_luminance = _make_luminance_array(seed=4) + metrics_cfg = MetricsConfig(radial_min_max=True) + profile = calc_radial_profile(roi_luminance) + result = _run_radial_min_max(roi_luminance, metrics_cfg, profile) + + ratio = result["radial_min_max_ratio"] + assert 0.0 <= ratio <= 1.0 + + +def test_run_radial_min_max_radial_min_lte_radial_max(): + """radial_min が radial_max 以下であること(min ≤ max). + + 仕様: radial_min = プロファイルの最小輝度値,radial_max = 最大輝度値 + (evaluation/radial.py calc_radial_min_max_ratio) + """ + roi_luminance = _make_luminance_array(seed=5) + metrics_cfg = MetricsConfig(radial_min_max=True) + profile = calc_radial_profile(roi_luminance) + result = _run_radial_min_max(roi_luminance, metrics_cfg, profile) + + assert result["radial_min"] <= result["radial_max"] + + +def test_run_radial_min_max_ratio_equals_min_over_max(): + """radial_min_max_ratio が radial_min / radial_max と一致すること. + + 仕様: radial_min_max_ratio = 最小輝度 / 最大輝度 + (evaluation/radial.py calc_radial_min_max_ratio) + """ + roi_luminance = _make_luminance_array(seed=6) + metrics_cfg = MetricsConfig(radial_min_max=True) + profile = calc_radial_profile(roi_luminance) + result = _run_radial_min_max(roi_luminance, metrics_cfg, profile) + + expected_ratio = result["radial_min"] / result["radial_max"] + assert result["radial_min_max_ratio"] == pytest.approx(expected_ratio, rel=1e-10) + + +# --------------------------------------------------------------------------- +# B4. radial_profile 再利用: プロファイル提供時は calc_radial_profile を呼ばない +# --------------------------------------------------------------------------- + + +def test_run_radial_min_max_reuses_provided_profile_and_does_not_recalculate(): + """radial_profile 引数が None でない時は calc_radial_profile が呼ばれないこと. + + 仕様: radial_profile が既に算出されていればそれを再利用し,重複算出を避ける + (cli/evaluate.py _run_radial_min_max docstring) + """ + roi_luminance = _make_luminance_array(seed=7) + metrics_cfg = MetricsConfig(radial_min_max=True) + profile = calc_radial_profile(roi_luminance) + + with patch("sm_tias_lightsim.cli.evaluate.calc_radial_profile") as mock_calc: + _run_radial_min_max(roi_luminance, metrics_cfg, profile) + mock_calc.assert_not_called() + + +def test_run_radial_min_max_reuses_profile_values_unchanged(): + """提供した radial_profile のデータが再利用されて結果が変わらないこと. + + 仕様: radial_profile を再利用する — プロファイルの値は変更されない + """ + roi_luminance = _make_luminance_array(seed=8) + metrics_cfg = MetricsConfig(radial_min_max=True) + profile = calc_radial_profile(roi_luminance) + + # 同じプロファイルを 2 回渡しても結果が同じ + result1 = _run_radial_min_max(roi_luminance, metrics_cfg, profile) + result2 = _run_radial_min_max(roi_luminance, metrics_cfg, profile) + + assert result1["radial_min_max_ratio"] == pytest.approx(result2["radial_min_max_ratio"]) + assert result1["radial_min_distance"] == pytest.approx(result2["radial_min_distance"]) + + +# --------------------------------------------------------------------------- +# B5. radial_profile 内部算出: None + radial_min_max=True で calc_radial_profile が呼ばれる +# --------------------------------------------------------------------------- + + +def test_run_radial_min_max_calls_calc_radial_profile_when_profile_is_none(): + """radial_profile=None かつ radial_min_max=True で calc_radial_profile が呼ばれること. + + 仕様: radial_profile が None(未算出)の場合はその場で calc_radial_profile を呼び出す + (cli/evaluate.py _run_radial_min_max) + """ + roi_luminance = _make_luminance_array(seed=9) + metrics_cfg = MetricsConfig(radial_min_max=True) + + real_profile = calc_radial_profile(roi_luminance) + + with patch( + "sm_tias_lightsim.cli.evaluate.calc_radial_profile", + return_value=real_profile, + ) as mock_calc: + result = _run_radial_min_max(roi_luminance, metrics_cfg, None) + mock_calc.assert_called_once_with(roi_luminance) + + # 結果も正常に返ること + assert "radial_min_max_ratio" in result + + +def test_run_radial_min_max_with_none_profile_returns_valid_result(): + """radial_profile=None で呼んでも有効な 4 キー辞書が返ること(内部算出). + + 仕様: 未算出の場合はここで算出する(重複算出を避けるため既存結果を優先) + (cli/evaluate.py _run_radial_min_max) + """ + roi_luminance = _make_luminance_array(seed=10) + metrics_cfg = MetricsConfig(radial_min_max=True) + result = _run_radial_min_max(roi_luminance, metrics_cfg, None) + + assert "radial_min_max_ratio" in result + assert "radial_min_distance" in result + assert "radial_min" in result + assert "radial_max" in result + + +def test_run_radial_min_max_none_profile_yields_same_result_as_provided_profile(): + """radial_profile=None の内部算出結果がプロファイル提供版と一致すること. + + 仕様: 内部算出は calc_radial_profile(roi_luminance) を呼ぶので同じ結果になる + """ + roi_luminance = _make_luminance_array(seed=11) + metrics_cfg = MetricsConfig(radial_min_max=True) + profile = calc_radial_profile(roi_luminance) + + result_with_profile = _run_radial_min_max(roi_luminance, metrics_cfg, profile) + result_without_profile = _run_radial_min_max(roi_luminance, metrics_cfg, None) + + assert result_with_profile["radial_min_max_ratio"] == pytest.approx( + result_without_profile["radial_min_max_ratio"], rel=1e-10 + ) + assert result_with_profile["radial_min_distance"] == pytest.approx( + result_without_profile["radial_min_distance"], rel=1e-10 + ) + + +# --------------------------------------------------------------------------- +# C1. 既存 config では radial_min_max_ratio 列が存在しない +# --------------------------------------------------------------------------- + + +def test_existing_config_without_radial_min_max_flag_no_column(tmp_path: Path): + """radial_min_max を指定しない既存 config では CSV に radial_min_max_ratio 列が存在しないこと. + + 仕様: radial_min_max 既定 False で既存 configs は無修正で動作する + (cli/evaluate.py 実装サマリー「後方互換」) + """ + import pandas + + luminance = _make_luminance_array(seed=100) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + # radial_min_max を含まない既存相当 YAML + yaml_content = """\ +roi: + type: image_pixel + x: 0 + y: 0 + width: 128 + height: 128 +metrics: + cov: true + std: true + max_min: true + radial_profile: true +""" + config_path = tmp_path / "eval_no_radial_min_max.yaml" + config_path.write_text(yaml_content, encoding="utf-8") + + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + + df = pandas.read_csv(output_dir / "metrics.csv") + assert "radial_min_max_ratio" not in df.columns + assert "radial_min_distance" not in df.columns + assert "radial_min" not in df.columns + assert "radial_max" not in df.columns + + +# --------------------------------------------------------------------------- +# C2. 新フラグで 4 列追加される +# --------------------------------------------------------------------------- + + +def test_radial_min_max_true_adds_four_columns_to_csv(tmp_path: Path): + """metrics.radial_min_max: true を有効化すると CSV に 4 列が追加されること. + + 仕様: 出力 CSV: 既存末尾に radial_min_max_ratio / radial_min_distance / + radial_min / radial_max の 4 列追加 + (cli/evaluate.py 実装サマリー「出力 CSV」) + """ + import pandas + + luminance = _make_luminance_array(seed=101) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max(tmp_path, radial_min_max=True) + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + + df = pandas.read_csv(output_dir / "metrics.csv") + assert "radial_min_max_ratio" in df.columns + assert "radial_min_distance" in df.columns + assert "radial_min" in df.columns + assert "radial_max" in df.columns + + +def test_radial_min_max_true_csv_values_are_float(tmp_path: Path): + """radial_min_max 列の値が float であること. + + 仕様: すべて float(metrics.csv カラム定義,SPEC_04) + """ + import pandas + + luminance = _make_luminance_array(seed=102) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max(tmp_path, radial_min_max=True) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + + df = pandas.read_csv(output_dir / "metrics.csv") + # 型として float に変換可能であること + ratio = float(df["radial_min_max_ratio"].iloc[0]) + dist = float(df["radial_min_distance"].iloc[0]) + rmin = float(df["radial_min"].iloc[0]) + rmax = float(df["radial_max"].iloc[0]) + + # 値域チェック + assert 0.0 <= ratio <= 1.0 + assert 0.0 <= dist <= 1.0 + assert rmin <= rmax + + +def test_radial_min_max_true_main_returns_0(tmp_path: Path): + """radial_min_max: true を含む YAML で main() が 0 を返すこと. + + 仕様: 正常終了時は 0 を返す(cli/evaluate.py main) + """ + luminance = _make_luminance_array(seed=103) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max(tmp_path, radial_min_max=True) + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + + +# --------------------------------------------------------------------------- +# C3. CSV 列順序の後方互換 +# --------------------------------------------------------------------------- + + +def test_radial_min_max_columns_appended_after_existing_columns(tmp_path: Path): + """radial_min_max の 4 列が既存列(cov/std/max_min_ratio 等)の後に追加されること. + + 仕様: 既存列(cov/std/max 等)の順序が変わらない(4 列は末尾に追加) + (cli/evaluate.py 実装サマリー「出力 CSV」) + """ + import pandas + + luminance = _make_luminance_array(seed=104) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max(tmp_path, radial_min_max=True) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + + df = pandas.read_csv(output_dir / "metrics.csv") + cols = list(df.columns) + + # 既存列が先頭側に存在すること + expected_early_cols = ["cov", "std", "max_min_ratio", "mean", "max", "min"] + for col in expected_early_cols: + assert col in cols, f"列 {col} が CSV に存在しない" + + # radial_min_max_ratio の位置が cov より後ろであること + cov_idx = cols.index("cov") + ratio_idx = cols.index("radial_min_max_ratio") + assert ratio_idx > cov_idx, "radial_min_max_ratio は cov より後ろにあるべき" + + +def test_existing_columns_order_unchanged_with_radial_min_max(tmp_path: Path): + """radial_min_max 有効時と無効時で既存列の順序が変わらないこと. + + 仕様: 既存列の順序が変わらない(後方互換) + """ + import pandas + + luminance = _make_luminance_array(seed=105) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + # radial_min_max=False(既存相当) + cfg_dir_no = tmp_path / "no" + cfg_dir_no.mkdir(parents=True, exist_ok=True) + config_no = _make_eval_yaml_with_radial_min_max(cfg_dir_no, radial_min_max=False) + out_no = tmp_path / "output_no" + _run_main(npy_path, config_no, out_no) + df_no = pandas.read_csv(out_no / "metrics.csv") + + # radial_min_max=True + cfg_dir_yes = tmp_path / "yes" + cfg_dir_yes.mkdir(parents=True, exist_ok=True) + config_yes = _make_eval_yaml_with_radial_min_max(cfg_dir_yes, radial_min_max=True) + out_yes = tmp_path / "output_yes" + _run_main(npy_path, config_yes, out_yes) + df_yes = pandas.read_csv(out_yes / "metrics.csv") + + # 既存列は両方の CSV に存在し,df_no の列順序が df_yes でも保たれること + for col in df_no.columns: + assert col in df_yes.columns, f"既存列 {col} が radial_min_max=True の CSV に存在しない" + + # 既存列部分の順序が同じ + existing_in_yes = [c for c in df_yes.columns if c in list(df_no.columns)] + assert existing_in_yes == list(df_no.columns) + + +# --------------------------------------------------------------------------- +# C4. radial_profile: false + radial_min_max: true で独立動作 +# --------------------------------------------------------------------------- + + +def test_radial_profile_false_with_radial_min_max_true_no_png(tmp_path: Path): + """radial_profile: false かつ radial_min_max: true のとき + radial_profile.png が生成されないこと. + + 仕様: radial_min_max は radial_profile フラグとは独立して動作する + radial_profile.png は metrics.radial_profile=True のときのみ生成される + (cli/evaluate.py 実装サマリー「C4. radial_profile: false + radial_min_max: true」) + """ + luminance = _make_luminance_array(seed=106) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max( + tmp_path, radial_min_max=True, radial_profile=False + ) + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + + # radial_profile.png は生成されない + assert not (output_dir / "figures" / "radial_profile.png").exists() + + +def test_radial_profile_false_with_radial_min_max_true_has_four_csv_columns(tmp_path: Path): + """radial_profile: false かつ radial_min_max: true のとき CSV に 4 列が追加されること. + + 仕様: radial_min_max は radial_profile フラグとは独立して動作する + radial_profile.png がなくても 4 列は CSV に追加される + """ + import pandas + + luminance = _make_luminance_array(seed=107) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max( + tmp_path, radial_min_max=True, radial_profile=False + ) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + + df = pandas.read_csv(output_dir / "metrics.csv") + assert "radial_min_max_ratio" in df.columns + assert "radial_min_distance" in df.columns + assert "radial_min" in df.columns + assert "radial_max" in df.columns + + +def test_radial_profile_true_with_radial_min_max_true_both_work(tmp_path: Path): + """radial_profile: true かつ radial_min_max: true のとき PNG も 4 列も生成されること. + + 仕様: radial_profile と radial_min_max は独立して有効化できる + 両方 True のとき radial_profile.png + 4 列が揃う + """ + import pandas + + luminance = _make_luminance_array(seed=108) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max( + tmp_path, radial_min_max=True, radial_profile=True + ) + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + + # radial_profile.png が生成される + assert (output_dir / "figures" / "radial_profile.png").exists() + + # 4 列も存在する + df = pandas.read_csv(output_dir / "metrics.csv") + assert "radial_min_max_ratio" in df.columns + + +# --------------------------------------------------------------------------- +# C5. エラー処理: _run_radial_min_max 内で例外が出たとき main() が 1 を返す +# --------------------------------------------------------------------------- + + +def test_radial_min_max_exception_returns_1(tmp_path: Path): + """_run_radial_min_max が例外を投げたとき main() が 1 を返すこと. + + 仕様: 動径 min/max 比の算出に失敗したとき return 1 + (cli/evaluate.py main: except Exception → return 1) + """ + luminance = _make_luminance_array(seed=109) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max(tmp_path, radial_min_max=True) + output_dir = tmp_path / "output" + + with patch( + "sm_tias_lightsim.cli.evaluate._run_radial_min_max", + side_effect=RuntimeError("テスト用エラー"), + ): + result = _run_main(npy_path, config_path, output_dir) + + assert result == 1 + + +def test_radial_min_max_exception_writes_stderr(tmp_path: Path, capsys): + """_run_radial_min_max が例外を投げたとき stderr にエラーメッセージが出ること. + + 仕様: 動径 min/max 比の算出に失敗しました — {exc} が stderr に出力される + (cli/evaluate.py main) + """ + luminance = _make_luminance_array(seed=110) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max(tmp_path, radial_min_max=True) + output_dir = tmp_path / "output" + + with patch( + "sm_tias_lightsim.cli.evaluate._run_radial_min_max", + side_effect=RuntimeError("テスト用エラー"), + ): + main(["--image", str(npy_path), "--config", str(config_path), "--output", str(output_dir)]) + + captured = capsys.readouterr() + assert "エラー" in captured.err + + +def test_radial_min_max_inner_exception_returns_1(tmp_path: Path): + """calc_radial_min_max_ratio が例外を投げたとき main() が 1 を返すこと. + + 仕様: 内部関数の例外もキャッチして return 1 になる + (cli/evaluate.py main: except Exception → return 1) + """ + luminance = _make_luminance_array(seed=111) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max(tmp_path, radial_min_max=True) + output_dir = tmp_path / "output" + + with patch( + "sm_tias_lightsim.cli.evaluate.calc_radial_min_max_ratio", + side_effect=ValueError("テスト用内部エラー"), + ): + result = _run_main(npy_path, config_path, output_dir) + + assert result == 1 + + +# --------------------------------------------------------------------------- +# コンソール出力の確認 +# --------------------------------------------------------------------------- + + +def test_radial_min_max_summary_printed_when_enabled(tmp_path: Path, capsys): + """radial_min_max: true のとき radial min/max と radial min dist がコンソールに出ること. + + 仕様: コンソール出力: 代表 2 行(radial min/max, radial min dist)のみ追加 + (cli/evaluate.py _print_summary) + """ + luminance = _make_luminance_array(seed=112) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max(tmp_path, radial_min_max=True) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + + captured = capsys.readouterr() + assert "radial min/max" in captured.out + assert "radial min dist" in captured.out + + +def test_radial_min_max_summary_not_printed_when_disabled(tmp_path: Path, capsys): + """radial_min_max: false のとき radial min/max がコンソールに出ないこと. + + 仕様: radial_min_max_ratio がメトリクスにないとき _print_summary は出力しない + (cli/evaluate.py _print_summary: if "radial_min_max_ratio" in metrics) + """ + luminance = _make_luminance_array(seed=113) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max(tmp_path, radial_min_max=False) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + + captured = capsys.readouterr() + assert "radial min/max" not in captured.out + + +# --------------------------------------------------------------------------- +# D2. slow テスト(実 output/baseline/luminance.npy がある場合) +# --------------------------------------------------------------------------- + +#: output/baseline/luminance.npy の絶対パス +BASELINE_LUMINANCE_NPY = ( + Path(__file__).parent.parent.parent / "output" / "baseline" / "luminance.npy" +) + + +@pytest.mark.slow +@pytest.mark.skipif( + not BASELINE_LUMINANCE_NPY.exists(), + reason="output/baseline/luminance.npy が存在しない場合はスキップ", +) +def test_radial_min_max_with_real_baseline_npy(tmp_path: Path): + """実 output/baseline/luminance.npy で radial_min_max: true が動作すること. + + 仕様: 動径 min/max 比 — 20 点輪帯平均の min/max で 1.0 で均一(SPEC_04 動径指標) + """ + import pandas + + config_path = _make_eval_yaml_with_radial_min_max( + tmp_path, + radial_min_max=True, + radial_profile=True, + # baseline の ROI に合わせる + x=24, + y=37, + width=464, + height=450, + ) + output_dir = tmp_path / "output" + result = main( + [ + "--image", + str(BASELINE_LUMINANCE_NPY), + "--config", + str(config_path), + "--output", + str(output_dir), + ] + ) + assert result == 0 + + df = pandas.read_csv(output_dir / "metrics.csv") + assert "radial_min_max_ratio" in df.columns + + ratio = float(df["radial_min_max_ratio"].iloc[0]) + # シミュ linear の均一性は悪いため ratio は 1.0 より相当小さいはず + # 存在確認として 0.0〜1.0 の範囲内であることのみチェック + assert 0.0 < ratio <= 1.0 diff --git a/tests/evaluation/test_dng_loader.py b/tests/evaluation/test_dng_loader.py new file mode 100644 index 0000000..b5490ad --- /dev/null +++ b/tests/evaluation/test_dng_loader.py @@ -0,0 +1,699 @@ +"""evaluation/dng_loader.py および evaluation/radial.py の仕様ベーステスト. + +仕様出典: +- docs/05_TECH/TECH_06_MiniTias-Evaluation引き継ぎ受領.md +- docs/05_TECH/TECH_05_MiniTiasEvaluation_DNG対応要求仕様.md +- docs/01_GUIDE/GUIDE_08_テスト方針.md + +テスト対象: + A. load_image_dng() — DNG 読み込み I/F の仕様検証 + B. calc_radial_min_max_ratio() — 動径 min/max 比の仕様検証 + C. 検証 V4 — 実機 DNG での再現性統合テスト(TECH_06 §6 V4) + +テスト方針: + - 実 DNG ファイル必須のテスト(A5〜A8, V4)は DNG 未存在時にスキップ + - A5〜A8 / V4 は @pytest.mark.slow を付ける(DNG 処理は数秒かかる) + - ファイル I/O はモックしない(GUIDE_08) + - 乱数不使用のため seed 固定不要 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import numpy +import numpy.testing +import pytest + +from sm_tias_lightsim.evaluation.dng_loader import load_image_dng +from sm_tias_lightsim.evaluation.radial import calc_radial_min_max_ratio, calc_radial_profile + +# --------------------------------------------------------------------------- +# テストデータのパス定義 +# --------------------------------------------------------------------------- + +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_QUANTITATIVE_DIR = _REPO_ROOT / "reference_measurement" / "quantitative" +_DNG_PATH = _QUANTITATIVE_DIR / "MiniTIAS_QM_20260601_124500.dng" +_META_PATH = _QUANTITATIVE_DIR / "MiniTIAS_QM_20260601_124500.meta.json" + +_DNG_EXISTS = _DNG_PATH.exists() + +# --------------------------------------------------------------------------- +# A. load_image_dng の単体仕様テスト +# --------------------------------------------------------------------------- + + +class TestLoadImageDngValidation: + """A1〜A4: 引数バリデーション・ファイルエラーの仕様検証.""" + + def test_a1_invalid_demosaic_raises_value_error(self, tmp_path): + """A1: 未知の demosaic 引数は ValueError を送出すること. + + 仕様: demosaic は "nearest" / "bilinear" / "ahd" のいずれか (TECH_06 §2) + 存在しないファイルパスでも引数バリデーションが先に実行される実装であれば + FileNotFoundError になる可能性がある.実装上の順序を鑑み,demosaic エラーが + 送出されることを確認する(存在するファイルを用意して確実に demosaic チェックに到達させる). + """ + # 空ファイルを用意して FileNotFoundError を回避する + dummy = tmp_path / "dummy.dng" + dummy.write_bytes(b"") + # ValueError (demosaic) または ValueError (形式不正) のどちらかになりうる + # 実装では demosaic チェックは exist チェックより後にある + # まず存在しないパスで demosaic バリデーションが動作するか確認 + with pytest.raises(ValueError, match="demosaic"): + load_image_dng(str(dummy), demosaic="invalid_algo") + + def test_a1_valid_demosaic_nearest_passes_validation(self, tmp_path): + """A1: 有効な demosaic 引数 "nearest" はバリデーションを通過すること. + + バリデーション通過後に壊れたファイルで ValueError が出ることを確認する. + 注: pytest の tmp_path はテスト名からディレクトリ名を生成するため, + エラーメッセージのパスにテスト名由来の文字列が含まれる可能性がある. + そのため,エラーメッセージの内容チェックは行わず, + ValueError が送出されること(= demosaic バリデーション通過後にエラー)のみ確認する. + """ + dummy = tmp_path / "dummy.dng" + dummy.write_bytes(b"") + # bilinear(有効)と同様に ValueError が出ること(FileNotFoundError ではない) + # = demosaic 引数が有効と判定された後,ファイル読み込み段階でエラーになる + with pytest.raises(ValueError): + load_image_dng(str(dummy), demosaic="nearest") + + def test_a1_valid_demosaic_ahd_passes_validation(self, tmp_path): + """A1: 有効な demosaic 引数 "ahd" はバリデーションを通過すること.""" + dummy = tmp_path / "dummy.dng" + dummy.write_bytes(b"") + with pytest.raises(ValueError): + load_image_dng(str(dummy), demosaic="ahd") + + def test_a2_invalid_output_format_raises_value_error(self, tmp_path): + """A2: 未知の output_format 引数は ValueError を送出すること. + + 仕様: output_format は "rgb_float32" / "luma_float32" / "rgb_uint8" のいずれか + (dng_loader.py docstring, TECH_06 §2) + """ + dummy = tmp_path / "dummy.dng" + dummy.write_bytes(b"") + with pytest.raises(ValueError, match="output_format"): + load_image_dng(str(dummy), output_format="invalid_format") + + def test_a3_file_not_found_raises_file_not_found_error(self): + """A3: 存在しないパスは FileNotFoundError を送出すること. + + 仕様: FileNotFoundError: ファイルが存在しない場合 (dng_loader.py docstring) + """ + nonexistent = "/nonexistent/path/to/file.dng" + with pytest.raises(FileNotFoundError): + load_image_dng(nonexistent) + + def test_a4_broken_dng_raises_value_error(self, tmp_path): + """A4: 壊れた DNG ファイル(空ファイル)は ValueError を送出すること. + + 仕様: ValueError: rawpy が DNG を読み込めなかった場合(形式が不正等) + (dng_loader.py docstring) + """ + broken = tmp_path / "broken.dng" + broken.write_bytes(b"") + with pytest.raises(ValueError): + load_image_dng(str(broken)) + + +@pytest.mark.slow +@pytest.mark.skipif(not _DNG_EXISTS, reason=f"実 DNG ファイルが存在しません: {_DNG_PATH}") +class TestLoadImageDngOutputFormats: + """A5〜A8: 実 DNG ファイルを用いた出力形式の仕様検証. + + DNG ファイル: reference_measurement/quantitative/MiniTIAS_QM_20260601_124500.dng + """ + + def test_a5_rgb_float32_shape_dtype_range(self): + """A5: rgb_float32 出力の形状 (H,W,3)・dtype float32・値域確認. + + 仕様: + - 形状 (H, W, 3) + - dtype float32 + - 値域はおよそ [0, 1](飽和画素は 1.0 を超えることがある) + (dng_loader.py docstring) + """ + result = load_image_dng(str(_DNG_PATH), output_format="rgb_float32") + assert result.ndim == 3 + assert result.shape[2] == 3 + assert result.dtype == numpy.float32 + # 値域: 下限は 0 以上,上限はおおよそ 1.x(飽和含む) + assert float(result.min()) >= 0.0 + assert float(result.max()) <= 2.0 # 飽和含む上限の目安 + + def test_a6_luma_float32_shape_dtype(self): + """A6: luma_float32 出力の形状 (H,W)・dtype float32 確認. + + 仕様: + - 形状 (H, W) + - dtype float32 + (dng_loader.py docstring) + """ + result = load_image_dng(str(_DNG_PATH), output_format="luma_float32") + assert result.ndim == 2 + assert result.dtype == numpy.float32 + + def test_a7_rgb_uint8_shape_dtype_range(self): + """A7: rgb_uint8 出力の形状 (H,W,3)・dtype uint8・値域 [0,255] 確認. + + 仕様: + - 形状 (H, W, 3) + - dtype uint8 + - 値域 [0, 255] + (dng_loader.py docstring) + """ + result = load_image_dng(str(_DNG_PATH), output_format="rgb_uint8") + assert result.ndim == 3 + assert result.shape[2] == 3 + assert result.dtype == numpy.uint8 + assert int(result.min()) >= 0 + assert int(result.max()) <= 255 + + def test_a8_luma_rec709_weighting(self): + """A8: luma_float32 が Rec.709 加重 (R×0.2126 + G×0.7152 + B×0.0722) と一致すること. + + 仕様: luma = 0.2126*R + 0.7152*G + 0.0722*B (Rec.709 加重プロキシ) + (TECH_06 §2.4, dng_loader.py _REC709_R/G/B 定数) + + 実装では rgb_float32 から直接 Rec.709 加重を計算するため, + 相対誤差は理論上 0 (float32 の精度内)になる. + """ + rgb = load_image_dng(str(_DNG_PATH), output_format="rgb_float32") + luma = load_image_dng(str(_DNG_PATH), output_format="luma_float32") + + expected_luma = ( + 0.2126 * rgb[:, :, 0] + 0.7152 * rgb[:, :, 1] + 0.0722 * rgb[:, :, 2] + ).astype(numpy.float32) + + # float32 精度内で完全一致するはず (実装が単純な線形結合のため) + numpy.testing.assert_allclose(luma, expected_luma, rtol=1e-5, atol=1e-6) + + def test_a5_landscape_orientation(self): + """A5 補足: rgb_float32 は DNG センサ native の landscape 向き (幅 > 高さ) であること. + + 仕様: DNG はセンサ native が landscape (TECH_06 §2.3) + SH-02M: 3264×2448 (landscape) → (H=2448, W=3264) で読み込まれる + """ + result = load_image_dng(str(_DNG_PATH), output_format="rgb_float32") + height, width = result.shape[:2] + assert width > height, f"landscape であること (width={width} > height={height})" + + def test_a6_luma_and_rgb_consistent_shape(self): + """A6 補足: luma_float32 の (H,W) と rgb_float32 の (H,W) が一致すること.""" + rgb = load_image_dng(str(_DNG_PATH), output_format="rgb_float32") + luma = load_image_dng(str(_DNG_PATH), output_format="luma_float32") + assert rgb.shape[:2] == luma.shape + + +# --------------------------------------------------------------------------- +# B. calc_radial_min_max_ratio の単体仕様テスト +# --------------------------------------------------------------------------- + + +class TestCalcRadialMinMaxRatio: + """B1〜B6: calc_radial_min_max_ratio の仕様検証.""" + + def test_b5_return_dict_has_required_keys(self): + """B5: 戻り値辞書に必須キーがすべて存在すること. + + 仕様: 戻り値キーは "radial_min_max_ratio" / "radial_min_distance" / + "radial_min" / "radial_max" (radial.py docstring) + """ + profile = numpy.array([[0.1, 1.0], [0.5, 0.8], [0.9, 0.5]]) + result = calc_radial_min_max_ratio(profile) + assert "radial_min_max_ratio" in result + assert "radial_min_distance" in result + assert "radial_min" in result + assert "radial_max" in result + + def test_b1_monotonic_decreasing_profile(self): + """B1: 単調減衰プロファイル (中心 1.0 → 周辺 0.5) での典型ケース. + + 仕様: + - radial_min_max_ratio ≈ 0.5 + - radial_min_distance は最外ビンの距離 + (radial.py docstring: 「中心明・周辺暗の単調減衰では最外輪帯/最内輪帯に一致」) + """ + # 距離 [0.1, 0.5, 0.9],輝度 [1.0, 0.75, 0.5] + profile = numpy.array([[0.1, 1.0], [0.5, 0.75], [0.9, 0.5]]) + result = calc_radial_min_max_ratio(profile) + assert result["radial_min_max_ratio"] == pytest.approx(0.5, rel=1e-6) + assert result["radial_min_distance"] == pytest.approx(0.9, rel=1e-6) + assert result["radial_min"] == pytest.approx(0.5, rel=1e-6) + assert result["radial_max"] == pytest.approx(1.0, rel=1e-6) + + def test_b2_uniform_profile_ratio_is_one(self): + """B2: 均一プロファイル(全ビン同値)では radial_min_max_ratio == 1.0. + + 仕様: 1 に近いほど均一 (radial.py docstring) + """ + profile = numpy.array([[0.1, 1.0], [0.5, 1.0], [0.9, 1.0]]) + result = calc_radial_min_max_ratio(profile) + assert result["radial_min_max_ratio"] == pytest.approx(1.0, rel=1e-6) + + def test_b3_all_zero_luminance_returns_inf(self): + """B3: 全ビン 0 の場合,radial_min_max_ratio == float("inf"). + + 仕様: 最大輝度が 0 の場合は float("inf") (radial.py docstring) + """ + profile = numpy.array([[0.1, 0.0], [0.5, 0.0], [0.9, 0.0]]) + result = calc_radial_min_max_ratio(profile) + assert result["radial_min_max_ratio"] == float("inf") + + def test_b4_nan_bins_are_excluded(self): + """B4: nan ビンを含む場合,非 nan ビンのみで min/max を算出すること. + + 仕様: nan ビン(空ビン)は算出から除外される (radial.py docstring,移植時補強) + """ + # nan ビンを含む 5 点プロファイル + profile = numpy.array( + [ + [0.1, 0.9], + [0.3, numpy.nan], # nan ビン + [0.5, 0.7], + [0.7, numpy.nan], # nan ビン + [0.9, 0.5], + ] + ) + result = calc_radial_min_max_ratio(profile) + # 有効ビン: [0.9, 0.7, 0.5] → min=0.5, max=0.9 + assert result["radial_min"] == pytest.approx(0.5, rel=1e-6) + assert result["radial_max"] == pytest.approx(0.9, rel=1e-6) + assert result["radial_min_max_ratio"] == pytest.approx(0.5 / 0.9, rel=1e-6) + + def test_b4_all_nan_returns_nan_for_all_keys(self): + """B4 補足: 全ビン nan の場合,全キーが nan を返すこと. + + 仕様: 全 nan なら全キー nan (radial.py docstring) + """ + profile = numpy.array( + [ + [0.1, numpy.nan], + [0.5, numpy.nan], + [0.9, numpy.nan], + ] + ) + result = calc_radial_min_max_ratio(profile) + import math + + assert math.isnan(result["radial_min_max_ratio"]) + assert math.isnan(result["radial_min_distance"]) + assert math.isnan(result["radial_min"]) + assert math.isnan(result["radial_max"]) + + def test_b6_non_monotonic_center_peak(self): + """B6: 非単調プロファイル(中央ピーク)での min/max 計算確認. + + 仕様: 全 N 点の min/max を使うため非単調プロファイルにもロバスト (radial.py docstring) + 例: [0.5, 1.0, 0.5] → min=0.5, max=1.0, ratio=0.5 + """ + profile = numpy.array([[0.1, 0.5], [0.5, 1.0], [0.9, 0.5]]) + result = calc_radial_min_max_ratio(profile) + assert result["radial_min"] == pytest.approx(0.5, rel=1e-6) + assert result["radial_max"] == pytest.approx(1.0, rel=1e-6) + assert result["radial_min_max_ratio"] == pytest.approx(0.5, rel=1e-6) + + def test_b1_min_distance_is_outer_bin_for_monotonic_decrease(self): + """B1 補足: 単調減衰プロファイルで radial_min_distance が最外ビンの距離になること. + + 仕様: 「中心明・周辺暗の単調減衰では最外輪帯/最内輪帯に一致」(radial.py docstring) + """ + # ビン距離を 5 点で設定 + distances = [0.1, 0.3, 0.5, 0.7, 0.9] + luminances = [1.0, 0.9, 0.8, 0.7, 0.6] + profile = numpy.array(list(zip(distances, luminances, strict=True))) + result = calc_radial_min_max_ratio(profile) + assert result["radial_min_distance"] == pytest.approx(0.9, rel=1e-6) + + def test_b_single_valid_bin(self): + """単一有効ビン(残りは nan)では min == max となり ratio == 1.0 になること.""" + profile = numpy.array( + [ + [0.1, numpy.nan], + [0.5, 0.7], + [0.9, numpy.nan], + ] + ) + result = calc_radial_min_max_ratio(profile) + assert result["radial_min"] == pytest.approx(0.7, rel=1e-6) + assert result["radial_max"] == pytest.approx(0.7, rel=1e-6) + assert result["radial_min_max_ratio"] == pytest.approx(1.0, rel=1e-6) + + +# --------------------------------------------------------------------------- +# C. 検証 V4 — 実機 DNG での再現性統合テスト (TECH_06 §6) +# --------------------------------------------------------------------------- + + +def _upsample_bilinear(grid: numpy.ndarray, shape: tuple[int, int]) -> numpy.ndarray: + """(rows, cols) グリッドを画像サイズ shape へバイリニア補間する. + + run_uniformity_dng.py と同一実装. + + Args: + grid: 補間元グリッド (rows x cols, float64). + shape: 補間先の画像サイズ (height, width). + + Returns: + 補間後の配列 (height x width, float64). + """ + rows, cols = grid.shape + h, w = shape + ys = numpy.linspace(0, rows - 1, h) + xs = numpy.linspace(0, cols - 1, w) + y0 = numpy.floor(ys).astype(int) + y1 = numpy.minimum(y0 + 1, rows - 1) + x0 = numpy.floor(xs).astype(int) + x1 = numpy.minimum(x0 + 1, cols - 1) + wy = (ys - y0)[:, None] + wx = (xs - x0)[None, :] + top = grid[y0][:, x0] * (1 - wx) + grid[y0][:, x1] * wx + bot = grid[y1][:, x0] * (1 - wx) + grid[y1][:, x1] * wx + return top * (1 - wy) + bot * wy + + +def _build_lsc_gain_rgb(meta_path: Path, shape: tuple[int, int]) -> numpy.ndarray: + """meta.json の lscMap (rows, cols, 4) を (H, W, 3) の RGB ゲインへ補間する. + + 4ch = [R, G_even, G_odd, B].G は G_even/G_odd の平均を用いる. + run_uniformity_dng.py の lsc_gain_rgb 関数と同一ロジック. + + Args: + meta_path: meta.json ファイルのパス. + shape: 補間先の画像サイズ (height, width). + + Returns: + RGB ゲインの配列 (H x W x 3, float64). + """ + meta = json.loads(meta_path.read_text(encoding="utf-8")) + rows = meta["lscMapRowCount"] + cols = meta["lscMapColumnCount"] + lsc_arr = numpy.array(meta["lscMap"], dtype=numpy.float64).reshape(rows, cols, 4) + + gr = _upsample_bilinear(lsc_arr[:, :, 0], shape) + gg = _upsample_bilinear((lsc_arr[:, :, 1] + lsc_arr[:, :, 2]) / 2.0, shape) + gb = _upsample_bilinear(lsc_arr[:, :, 3], shape) + return numpy.stack([gr, gg, gb], axis=2) + + +def _build_roi_luminance_from_dng( + dng_path: Path, + meta_path: Path, + roi_x: int, + roi_y: int, + roi_w: int, + roi_h: int, +) -> numpy.ndarray: + """DNG から portrait + LSC 補正 + ROI 抽出済みの 0〜255 linear 輝度配列を構築する. + + 評価フロー(run_uniformity_dng.py と同一): + 1. load_image_dng(rgb_float32) で landscape linear raw 取得 + 2. lscMap をバイリニア補間して RGB ゲインを生成,順方向乗算 + 3. Rec.709 加重で輝度化 + 4. ×255 で 0〜255 スケール化 + 5. rot90(-1) で portrait 回転 + 6. ROI 抽出 + + Args: + dng_path: DNG ファイルのパス. + meta_path: meta.json ファイルのパス. + roi_x, roi_y: ROI 左上座標(portrait 座標系). + roi_w, roi_h: ROI の幅・高さ. + + Returns: + ROI 輝度配列 (roi_h, roi_w, float64),0〜255 linear スケール. + """ + # 1. DNG 読み込み (landscape, linear, output_color=raw) + rgb = load_image_dng(str(dng_path), output_format="rgb_float32") + + # 2. LSC 順方向適用 (TECH_06 §2.1: 順方向が正しい) + lsc_gain = _build_lsc_gain_rgb(meta_path, rgb.shape[:2]) + rgb_corrected = rgb * lsc_gain + + # 3. Rec.709 加重輝度化 (uniformity.py と同じ係数) + luma = ( + 0.2126 * rgb_corrected[:, :, 0].astype(numpy.float64) + + 0.7152 * rgb_corrected[:, :, 1].astype(numpy.float64) + + 0.0722 * rgb_corrected[:, :, 2].astype(numpy.float64) + ) + + # 4. 0〜255 スケール化 (handoff CSV の mean≈207 と整合) + luma *= 255.0 + + # 5. portrait 向き合わせ (TECH_06 §2.3: rot90(-1)) + luma_portrait = numpy.rot90(luma, -1) + + # 6. ROI 抽出 + return luma_portrait[roi_y : roi_y + roi_h, roi_x : roi_x + roi_w] + + +# 白板 ROI 座標 (MiniTias-Evaluation/config/roi_config.json より) +# minitias.whiteboard: {x: 539, y: 1015, width: 1400, height: 1409} +_WHITEBOARD_ROI = {"x": 539, "y": 1015, "width": 1400, "height": 1409} + +# 期待値 (expected_results/MiniTIAS_QM_20260601_124500_uniformity.csv) +_EXPECTED_UNIFORMITY = { + "cov": 0.06953368075883541, + "std": 14.450766770999131, + "max_min_ratio": 1.6166283764597607, + "mean": 207.82398707065312, + "max": 239.95092166595663, + "min": 148.42676595311465, +} + +# 期待値 (TECH_06 §4 の空間解析指標) +_EXPECTED_SPATIAL = { + "center_periphery_ratio": 1.0911, + "gradient_magnitude": 8.35, + "radial_min_max_ratio": 0.8600, +} + +# 許容誤差 +_UNIFORMITY_RTOL = 1e-2 # 相対誤差 1%(CSV 期待値との照合) +_SPATIAL_ATOL = 0.05 # 絶対誤差(TECH_06 §4 の丸め値との照合) + + +@pytest.mark.slow +@pytest.mark.skipif(not _DNG_EXISTS, reason=f"実 DNG ファイルが存在しません: {_DNG_PATH}") +class TestV4ReproducibilityWithRealDng: + """検証 V4 — 実機 DNG での再現性統合テスト (TECH_06 §6). + + 評価データ: + DNG: reference_measurement/quantitative/MiniTIAS_QM_20260601_124500.dng + meta.json: reference_measurement/quantitative/MiniTIAS_QM_20260601_124500.meta.json + 期待値 CSV: expected_results/MiniTIAS_QM_20260601_124500_uniformity.csv + + 評価条件: + linear (output_color=raw) / portrait / LSC 補正あり / 白板 ROI + (TECH_06 §4: 「暗闇 = MiniTIAS のみ撮影」) + """ + + @pytest.fixture(scope="class") + def roi_luminance(self): + """クラス全体で共有する ROI 輝度配列(重い DNG 処理を 1 回だけ実行).""" + roi = _WHITEBOARD_ROI + return _build_roi_luminance_from_dng( + _DNG_PATH, + _META_PATH, + roi_x=roi["x"], + roi_y=roi["y"], + roi_w=roi["width"], + roi_h=roi["height"], + ) + + def test_v4_uniformity_cov(self, roi_luminance): + """V4-1: CoV が期待値 ≈ 0.0695 と相対誤差 1% 以内で一致すること. + + 仕様: CoV = std / mean (uniformity.py calc_uniformity) + 期待値出典: expected_results/MiniTIAS_QM_20260601_124500_uniformity.csv + """ + from sm_tias_lightsim.uniformity import calc_uniformity + + results = calc_uniformity(roi_luminance) + expected = _EXPECTED_UNIFORMITY["cov"] + actual = results["cov"] + assert actual == pytest.approx(expected, rel=_UNIFORMITY_RTOL), ( + f"CoV: expected={expected:.6f}, actual={actual:.6f}" + ) + + def test_v4_uniformity_std(self, roi_luminance): + """V4-2: std が期待値 ≈ 14.45 と相対誤差 1% 以内で一致すること. + + 期待値出典: expected_results/MiniTIAS_QM_20260601_124500_uniformity.csv + """ + from sm_tias_lightsim.uniformity import calc_uniformity + + results = calc_uniformity(roi_luminance) + expected = _EXPECTED_UNIFORMITY["std"] + actual = results["std"] + assert actual == pytest.approx(expected, rel=_UNIFORMITY_RTOL), ( + f"std: expected={expected:.6f}, actual={actual:.6f}" + ) + + def test_v4_uniformity_max_min_ratio(self, roi_luminance): + """V4-3: max/min 比が期待値 ≈ 1.617 と相対誤差 1% 以内で一致すること. + + 期待値出典: expected_results/MiniTIAS_QM_20260601_124500_uniformity.csv + """ + from sm_tias_lightsim.uniformity import calc_uniformity + + results = calc_uniformity(roi_luminance) + expected = _EXPECTED_UNIFORMITY["max_min_ratio"] + actual = results["max_min_ratio"] + assert actual == pytest.approx(expected, rel=_UNIFORMITY_RTOL), ( + f"max_min_ratio: expected={expected:.6f}, actual={actual:.6f}" + ) + + def test_v4_uniformity_mean(self, roi_luminance): + """V4-4: mean が期待値 ≈ 207.82 と相対誤差 1% 以内で一致すること. + + 期待値出典: expected_results/MiniTIAS_QM_20260601_124500_uniformity.csv + """ + from sm_tias_lightsim.uniformity import calc_uniformity + + results = calc_uniformity(roi_luminance) + expected = _EXPECTED_UNIFORMITY["mean"] + actual = results["mean"] + assert actual == pytest.approx(expected, rel=_UNIFORMITY_RTOL), ( + f"mean: expected={expected:.6f}, actual={actual:.6f}" + ) + + def test_v4_uniformity_max(self, roi_luminance): + """V4-5: max が期待値 ≈ 239.95 と相対誤差 1% 以内で一致すること. + + 期待値出典: expected_results/MiniTIAS_QM_20260601_124500_uniformity.csv + """ + from sm_tias_lightsim.uniformity import calc_uniformity + + results = calc_uniformity(roi_luminance) + expected = _EXPECTED_UNIFORMITY["max"] + actual = results["max"] + assert actual == pytest.approx(expected, rel=_UNIFORMITY_RTOL), ( + f"max: expected={expected:.6f}, actual={actual:.6f}" + ) + + def test_v4_uniformity_min(self, roi_luminance): + """V4-6: min が期待値 ≈ 148.43 と相対誤差 1% 以内で一致すること. + + 期待値出典: expected_results/MiniTIAS_QM_20260601_124500_uniformity.csv + """ + from sm_tias_lightsim.uniformity import calc_uniformity + + results = calc_uniformity(roi_luminance) + expected = _EXPECTED_UNIFORMITY["min"] + actual = results["min"] + assert actual == pytest.approx(expected, rel=_UNIFORMITY_RTOL), ( + f"min: expected={expected:.6f}, actual={actual:.6f}" + ) + + def test_v4_spatial_center_periphery_ratio(self, roi_luminance): + """V4-7: 中心/周辺比が期待値 ≈ 1.0911 と絶対誤差 0.05 以内で一致すること. + + 仕様: center_periphery_ratio = center_mean / periphery_mean (zones.py calc_zone_stats) + 期待値出典: TECH_06 §4 + """ + from sm_tias_lightsim.evaluation.zones import ( + calc_zone_stats, + classify_zones, + compute_normalized_distance_map, + ) + + h, w = roi_luminance.shape + dmap = compute_normalized_distance_map(h, w) + zone_map = classify_zones(dmap) + zone_stats = calc_zone_stats(roi_luminance, zone_map) + + expected = _EXPECTED_SPATIAL["center_periphery_ratio"] + actual = zone_stats["center_periphery_ratio"] + assert abs(actual - expected) <= _SPATIAL_ATOL, ( + f"center_periphery_ratio: expected={expected:.4f}, actual={actual:.4f}" + ) + + def test_v4_spatial_gradient_magnitude(self, roi_luminance): + """V4-8: 勾配 (%) が期待値 ≈ 8.35 と絶対誤差 0.05 以内で一致すること. + + 仕様: gradient_magnitude = (center_mean - periphery_mean) / center_mean × 100 + (zones.py calc_zone_stats) + 期待値出典: TECH_06 §4 + """ + from sm_tias_lightsim.evaluation.zones import ( + calc_zone_stats, + classify_zones, + compute_normalized_distance_map, + ) + + h, w = roi_luminance.shape + dmap = compute_normalized_distance_map(h, w) + zone_map = classify_zones(dmap) + zone_stats = calc_zone_stats(roi_luminance, zone_map) + + expected = _EXPECTED_SPATIAL["gradient_magnitude"] + actual = zone_stats["gradient_magnitude"] + assert abs(actual - expected) <= _SPATIAL_ATOL, ( + f"gradient_magnitude: expected={expected:.2f}%, actual={actual:.2f}%" + ) + + def test_v4_radial_min_max_ratio(self, roi_luminance): + """V4-9: 動径 min/max 比が期待値 ≈ 0.8600 と絶対誤差 0.05 以内で一致すること. + + 仕様: radial_min_max_ratio = min(profile) / max(profile),nan 除外 + (radial.py calc_radial_min_max_ratio) + 期待値出典: TECH_06 §4 + """ + profile = calc_radial_profile(roi_luminance) + result = calc_radial_min_max_ratio(profile) + + expected = _EXPECTED_SPATIAL["radial_min_max_ratio"] + actual = result["radial_min_max_ratio"] + assert abs(actual - expected) <= _SPATIAL_ATOL, ( + f"radial_min_max_ratio: expected={expected:.4f}, actual={actual:.4f}" + ) + + def test_v4_lsc_forward_application_increases_mean(self, roi_luminance): + """V4 補足: LSC 順方向適用によって ROI 平均輝度が増加すること. + + 仕様: lscMap は gain > 1 の場合が多い(周辺を持ち上げる)ので, + LSC 適用後の mean は未適用より高いかほぼ同等になるはずである. + (TECH_06 §2.1: 「順方向適用が正しい」) + + この検証は LSC が「逆適用」でなく「順方向適用」されていることの副証となる. + """ + # LSC なしの ROI を構築して比較 + rgb_no_lsc = load_image_dng(str(_DNG_PATH), output_format="rgb_float32") + luma_no_lsc = ( + 0.2126 * rgb_no_lsc[:, :, 0].astype(numpy.float64) + + 0.7152 * rgb_no_lsc[:, :, 1].astype(numpy.float64) + + 0.0722 * rgb_no_lsc[:, :, 2].astype(numpy.float64) + ) * 255.0 + luma_portrait_no_lsc = numpy.rot90(luma_no_lsc, -1) + roi_no_lsc = luma_portrait_no_lsc[ + _WHITEBOARD_ROI["y"] : _WHITEBOARD_ROI["y"] + _WHITEBOARD_ROI["height"], + _WHITEBOARD_ROI["x"] : _WHITEBOARD_ROI["x"] + _WHITEBOARD_ROI["width"], + ] + + mean_with_lsc = float(numpy.mean(roi_luminance)) + mean_without_lsc = float(numpy.mean(roi_no_lsc)) + + # LSC ゲインはシェーディング補正(周辺を持ち上げる)なので, + # 順方向適用では平均が上がるか同等のはず + assert mean_with_lsc >= mean_without_lsc * 0.95, ( + f"LSC 順方向適用後の mean ({mean_with_lsc:.2f}) が " + f"未適用 ({mean_without_lsc:.2f}) より著しく低い:逆適用になっていないか確認" + ) + + def test_v4_portrait_orientation_after_rot90(self, roi_luminance): + """V4 補足: ROI が portrait 向き(height > width ではないが適切なサイズ)であること. + + 仕様: rot90(-1) で portrait 向きに変換 (TECH_06 §2.3) + 白板 ROI は 1400×1409 (ほぼ正方形) なので向きの確認は ROI の親画像で行う. + """ + # ROI の形状が指定通りであることを確認 + expected_h = _WHITEBOARD_ROI["height"] + expected_w = _WHITEBOARD_ROI["width"] + assert roi_luminance.shape == (expected_h, expected_w), ( + f"ROI shape: expected=({expected_h}, {expected_w}), actual={roi_luminance.shape}" + ) diff --git a/tests/evaluation/test_oetf.py b/tests/evaluation/test_oetf.py index f3c2edd..95689f3 100644 --- a/tests/evaluation/test_oetf.py +++ b/tests/evaluation/test_oetf.py @@ -25,7 +25,6 @@ from sm_tias_lightsim.evaluation.oetf import apply_srgb_oetf, solve_exposure, srgb_oetf - # --------------------------------------------------------------------------- # 1. srgb_oetf — IEC 61966-2-1 の既知点 # --------------------------------------------------------------------------- @@ -157,6 +156,7 @@ lo (c <= 0.0031308): 12.92 * c hi (c > 0.0031308): 1.055 * c^(1/2.4) - 0.055 """ + # 参照実装と同じ計算 def ref_srgb_oetf(c: numpy.ndarray) -> numpy.ndarray: c = numpy.clip(c, 0.0, 1.0) @@ -429,7 +429,8 @@ def test_grayscale_oetf_channel_vs_luminance_equivalence(): - """無彩色(R=G=B)入力で「チャネル別 OETF → Rec.709 加重和」と「luminance に直接 OETF」が厳密一致すること. + """無彩色(R=G=B)入力で「チャネル別 OETF → Rec.709 加重和」と + 「luminance に直接 OETF」が厳密一致すること. 仕様: SPEC_03「luma vs luminance と演算順序」節 無彩色画素(R=G=B=x)では: diff --git a/tests/evaluation/test_visualize.py b/tests/evaluation/test_visualize.py index cdb3718..9f502f6 100644 --- a/tests/evaluation/test_visualize.py +++ b/tests/evaluation/test_visualize.py @@ -192,9 +192,7 @@ 仕様: 値は 0(center), 1(middle), 2(periphery)のいずれか(zones.py classify_zones) """ # 手動で 3 ゾーン全てを含む 3x3 マップを作成する - zone_map = numpy.array( - [[0, 0, 1], [0, 1, 2], [1, 2, 2]], dtype=numpy.int8 - ) + zone_map = numpy.array([[0, 0, 1], [0, 1, 2], [1, 2, 2]], dtype=numpy.int8) output_path = tmp_path / "zone_map_all_zones.png" save_zone_map(zone_map, output_path) assert output_path.exists() diff --git a/tests/scene/test_metrics_radial_min_max_schema.py b/tests/scene/test_metrics_radial_min_max_schema.py new file mode 100644 index 0000000..92c4585 --- /dev/null +++ b/tests/scene/test_metrics_radial_min_max_schema.py @@ -0,0 +1,251 @@ +# pyright: reportArgumentType=none, reportCallIssue=none +"""scene/schema.py の MetricsConfig.radial_min_max フィールド仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_04_評価仕様.md(動径指標・評価パイプライン) +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(evaluation/metrics セクション) +- src/sm_tias_lightsim/scene/schema.py(MetricsConfig 定義) + +テスト方針: + - Pydantic モデルを直接構築して振る舞いを検証する(YAML パースは別途) + - 既存フィールド(cov/std/max_min/radial_profile)への非干渉を確認する + - YAML ロード経由の動作も確認する +""" + +from __future__ import annotations + +import pytest +import yaml +from pydantic import ValidationError + +from sm_tias_lightsim.scene.schema import EvaluationConfig, MetricsConfig + +# --------------------------------------------------------------------------- +# A1. 既定値: MetricsConfig() で radial_min_max == False +# --------------------------------------------------------------------------- + + +def test_metrics_radial_min_max_default_is_false(): + """MetricsConfig() のインスタンスで radial_min_max の既定値が False であること. + + 仕様: MetricsConfig.radial_min_max: bool = Field(default=False, ...) + (schema.py MetricsConfig) + """ + cfg = MetricsConfig() + assert cfg.radial_min_max is False + + +# --------------------------------------------------------------------------- +# A2. 明示 True: MetricsConfig(radial_min_max=True) で True になること +# --------------------------------------------------------------------------- + + +def test_metrics_radial_min_max_explicit_true(): + """MetricsConfig(radial_min_max=True) で radial_min_max が True になること. + + 仕様: MetricsConfig.radial_min_max: bool = Field(default=False, ...) + True を明示指定すると True が設定される(schema.py MetricsConfig) + """ + cfg = MetricsConfig(radial_min_max=True) + assert cfg.radial_min_max is True + + +def test_metrics_radial_min_max_explicit_false(): + """MetricsConfig(radial_min_max=False) で radial_min_max が False になること. + + 仕様: MetricsConfig.radial_min_max: bool = Field(default=False, ...) + """ + cfg = MetricsConfig(radial_min_max=False) + assert cfg.radial_min_max is False + + +# --------------------------------------------------------------------------- +# A3. 既存フィールドへの非干渉 +# --------------------------------------------------------------------------- + + +def test_metrics_radial_min_max_does_not_affect_cov_default(): + """radial_min_max 指定有無で cov の既定値が変わらないこと. + + 仕様: MetricsConfig.cov: bool = Field(default=True, ...) — 非干渉 + """ + cfg_default = MetricsConfig() + cfg_true = MetricsConfig(radial_min_max=True) + + assert cfg_default.cov == cfg_true.cov + assert cfg_default.cov is True + + +def test_metrics_radial_min_max_does_not_affect_std_default(): + """radial_min_max 指定有無で std の既定値が変わらないこと. + + 仕様: MetricsConfig.std: bool = Field(default=True, ...) — 非干渉 + """ + cfg_default = MetricsConfig() + cfg_true = MetricsConfig(radial_min_max=True) + + assert cfg_default.std == cfg_true.std + assert cfg_default.std is True + + +def test_metrics_radial_min_max_does_not_affect_max_min_default(): + """radial_min_max 指定有無で max_min の既定値が変わらないこと. + + 仕様: MetricsConfig.max_min: bool = Field(default=True, ...) — 非干渉 + """ + cfg_default = MetricsConfig() + cfg_true = MetricsConfig(radial_min_max=True) + + assert cfg_default.max_min == cfg_true.max_min + assert cfg_default.max_min is True + + +def test_metrics_radial_min_max_does_not_affect_radial_profile_default(): + """radial_min_max 指定有無で radial_profile の既定値が変わらないこと. + + 仕様: MetricsConfig.radial_profile: bool = Field(default=True, ...) — 非干渉 + radial_min_max とは独立したフィールド(schema.py MetricsConfig) + """ + cfg_default = MetricsConfig() + cfg_true = MetricsConfig(radial_min_max=True) + + assert cfg_default.radial_profile == cfg_true.radial_profile + assert cfg_default.radial_profile is True + + +def test_metrics_config_all_fields_together(): + """radial_min_max=True を含む全フィールド指定で正常にパースされること. + + 仕様: MetricsConfig の全フィールドが独立して指定可能(schema.py MetricsConfig) + """ + cfg = MetricsConfig( + cov=True, std=False, max_min=True, radial_profile=False, radial_min_max=True + ) + assert cfg.cov is True + assert cfg.std is False + assert cfg.max_min is True + assert cfg.radial_profile is False + assert cfg.radial_min_max is True + + +def test_metrics_config_is_frozen_for_radial_min_max(): + """MetricsConfig が frozen(イミュータブル)で radial_min_max も変更不可であること. + + 仕様: _FrozenBase → frozen=True(schema.py ConfigDict) + """ + cfg = MetricsConfig() + with pytest.raises(ValidationError): + cfg.radial_min_max = True # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# A4. YAML ロード: YAML で metrics.radial_min_max: true を指定すると True +# --------------------------------------------------------------------------- + + +def test_yaml_load_metrics_radial_min_max_true(): + """YAML で metrics.radial_min_max: true を指定したとき True が反映されること. + + 仕様: EvaluationConfig.metrics.radial_min_max が YAML 経由で設定される + (schema.py MetricsConfig / EvaluationConfig) + """ + yaml_content = """\ +roi: + type: image_pixel + x: 0 + y: 0 + width: 100 + height: 100 +metrics: + cov: true + std: true + max_min: true + radial_profile: true + radial_min_max: true +""" + data = yaml.safe_load(yaml_content) + cfg = EvaluationConfig.model_validate(data) + assert cfg.metrics is not None + assert cfg.metrics.radial_min_max is True + + +def test_yaml_load_metrics_radial_min_max_false(): + """YAML で metrics.radial_min_max: false を指定したとき False が反映されること. + + 仕様: MetricsConfig.radial_min_max: bool = Field(default=False, ...) + """ + yaml_content = """\ +roi: + type: image_pixel + x: 0 + y: 0 + width: 100 + height: 100 +metrics: + cov: true + radial_min_max: false +""" + data = yaml.safe_load(yaml_content) + cfg = EvaluationConfig.model_validate(data) + assert cfg.metrics is not None + assert cfg.metrics.radial_min_max is False + + +def test_yaml_load_metrics_radial_min_max_omitted_defaults_false(): + """YAML で metrics.radial_min_max を省略したとき既定値 False が設定されること. + + 仕様: MetricsConfig.radial_min_max: bool = Field(default=False, ...) + 既存 YAML(eval_baseline.yaml / eval_baseline_linear.yaml / eval_real.yaml 相当)は + radial_min_max を含まないため後方互換として False になる必要がある + """ + yaml_content = """\ +roi: + type: image_pixel + x: 0 + y: 0 + width: 100 + height: 100 +metrics: + cov: true + std: true + max_min: true + radial_profile: true +""" + data = yaml.safe_load(yaml_content) + cfg = EvaluationConfig.model_validate(data) + assert cfg.metrics is not None + assert cfg.metrics.radial_min_max is False + + +def test_yaml_load_eval_baseline_linear_equivalent_has_radial_min_max_false(): + """eval_baseline_linear.yaml 相当の YAML では radial_min_max が False であること. + + 仕様: 既存 configs(eval_baseline.yaml / eval_baseline_linear.yaml)は + radial_min_max を含まず,未指定時は False(後方互換) + """ + yaml_content = """\ +roi: + type: image_pixel + x: 24 + y: 37 + width: 464 + height: 450 +zones: + enabled: true + boundaries: + - 0.33 + - 0.66 +metrics: + cov: true + std: true + max_min: true + radial_profile: true +normalization: + enabled: true + type: mean_anchor + target_mean: 146.7146 +""" + data = yaml.safe_load(yaml_content) + cfg = EvaluationConfig.model_validate(data) + assert cfg.metrics is not None + assert cfg.metrics.radial_min_max is False diff --git a/uv.lock b/uv.lock index 36fe48f..d6a1ab9 100644 --- a/uv.lock +++ b/uv.lock @@ -939,6 +939,29 @@ ] [[package]] +name = "rawpy" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/15/92324724209650167c8c8b0c8c0006c99d07494b9b41f7d6435a37737323/rawpy-0.27.0.tar.gz", hash = "sha256:45251e46c1d891a62919a4ac200a9828f825e3c59f89cea2f1daee0900ff15ec", size = 560718, upload-time = "2026-05-07T08:29:23.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/97/46415db86271d977390f607d7a7733d86abb65f2dee29ab81441e092b3d4/rawpy-0.27.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8722fcd00b242e404ab8297d1f7bdd7d0bbe2a30c41a70815cbec74eb4583bed", size = 2051102, upload-time = "2026-05-07T08:28:56.466Z" }, + { url = "https://files.pythonhosted.org/packages/d0/33/206372f73d215c4a379a91081c8445f352b752eab85613445af79cc86855/rawpy-0.27.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0192f3ce982ea06cdb82196e96dca9e9d252f8715d2b363bcbb79553e2e89e", size = 2945220, upload-time = "2026-05-07T08:28:57.99Z" }, + { url = "https://files.pythonhosted.org/packages/85/fc/6f26df1bc1663366727665f082b00b8cdd70e555e5dad43208dd58fd08bd/rawpy-0.27.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2262088bd4fd768edd7576857a9215cfff5e84d61942336401ee5d9ca8f677e0", size = 2941195, upload-time = "2026-05-07T08:28:59.61Z" }, + { url = "https://files.pythonhosted.org/packages/56/fb/3c754322c080477633644e453bdaebfeb5177249859c37302ad392a93a0e/rawpy-0.27.0-cp312-cp312-win_amd64.whl", hash = "sha256:2965d8c70af1b4ed6177a098871de1cc9204854278b9260d1e79639df40391ae", size = 913608, upload-time = "2026-05-07T08:29:01.593Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/aec7b4f6befd4752f23a5710bc7687631588006b17c4f9dc3a5198ae12b6/rawpy-0.27.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7758606d5de8497a695511fbace40cb80138329ed0ad7a530d51bdc07eef1a6b", size = 2050987, upload-time = "2026-05-07T08:29:03.374Z" }, + { url = "https://files.pythonhosted.org/packages/f0/29/ff05f8ebd08fa99c185b60b0088c7950016f0ac1e7e56042e5dbe67fd85c/rawpy-0.27.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1f4a1846247deff8b4c84b5b400541603d48be90bd36fa6d9a1f5fada023cae", size = 2944727, upload-time = "2026-05-07T08:29:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/84f15204f80a75ccd6f36b5530878339231faa334fbc47c83a5b45de19cf/rawpy-0.27.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:45b1b08a8d6fdc0139fea4c1389c17cb6f3d01b0581e71eef1337949a486550c", size = 2940136, upload-time = "2026-05-07T08:29:06.803Z" }, + { url = "https://files.pythonhosted.org/packages/b6/18/49c498d363ba3c3935872244b27e01b514d68ea531c325cff173b849ec81/rawpy-0.27.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e42d6218b507584b41ffdb86e1a6d8e2672bfe284a59df1dfdc57b3f8a27ffe", size = 913591, upload-time = "2026-05-07T08:29:08.378Z" }, + { url = "https://files.pythonhosted.org/packages/fc/67/b5aa2517dd4e1c0d0f1644e7c24c78a896df7835aeaf3f20abd31a2455b1/rawpy-0.27.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cd80c608d741aed9dc014cdac2af3f83796bc73148dbb7b0ec1fa2ba715d7a53", size = 2050437, upload-time = "2026-05-07T08:29:09.918Z" }, + { url = "https://files.pythonhosted.org/packages/8e/23/ec4e4dc5550e96d931ea5d0c3266fc42a9ce18b621572de24f0e683d02c9/rawpy-0.27.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb018c5d3273d09245bf8d4a7c6da9f2c4191b1ed04f45af4fcf978e2b2372ae", size = 2945830, upload-time = "2026-05-07T08:29:11.613Z" }, + { url = "https://files.pythonhosted.org/packages/b8/48/64e97637398494e6b74f8388c5a17d726219a5ddc9f7c283ff9f77849a05/rawpy-0.27.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a949e9258af1d915b4316b77c8de9d9637d3624e6fe85a5bfb6cb4de15f485d6", size = 2940100, upload-time = "2026-05-07T08:29:13.647Z" }, + { url = "https://files.pythonhosted.org/packages/4d/01/1854b2f789d7a6d94152ba451bdaadae0010d4247099d9c2719b381d4b6b/rawpy-0.27.0-cp314-cp314-win_amd64.whl", hash = "sha256:3cac371c3b6302eb88bd1f33d43c7a92b5e553fdff14258b564f81e173a68c26", size = 940931, upload-time = "2026-05-07T08:29:15.604Z" }, +] + +[[package]] name = "requests" version = "2.33.1" source = { registry = "https://pypi.org/simple" } @@ -1036,6 +1059,7 @@ { name = "pydantic" }, { name = "pyvista" }, { name = "pyyaml" }, + { name = "rawpy" }, { name = "trimesh" }, ] @@ -1056,6 +1080,7 @@ { name = "pydantic", specifier = ">=2.13.3" }, { name = "pyvista", specifier = ">=0.45" }, { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "rawpy", specifier = ">=0.21.0" }, { name = "trimesh", specifier = ">=4.12.1" }, ]