diff --git a/CLAUDE.md b/CLAUDE.md index c3bd1f7..ab43740 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,8 @@ 既知の制約(受容済み): per-face ベイクはメッシュ境界の継ぎ目・粗メッシュのブロック感・低 samples の MC ノイズ・scalar_rgb の遅さ(高 samples 非現実的)あり.精細な評価は 2D `sm-evaluate` で行う(不変).隣接内壁の見かけ段差は調査の結果「外向き ≈0 面の混在」が主因と判明=幾何カリングで解消(内壁照度自体は物理どおりほぼ均一). 次のアクション(可視化・コミット後の別タスク): シーム/粒状感の解消には全メッシュ UV 統合+シームレスライトマップベイク(別プロジェクト規模).必要時に別途 `/implement`.path ベイク高速化(wavefront/GPU)も別タスク. 補足: **実機搭載 LED の物性確定**(LED Paradise LP-2835D90VW / Ra98 SunLightLED 2835 4000K、Vf 3.1V・If 60mA・Φv 16–19 lm @60mA・配光角 120°・規格通り 60mA 駆動前提).規格値から独立推定した radiance ≈ 2030 W/(m²·sr) は現状校正値 2807 と 1.38× 差で、反射率 0.85→0.89 への調整で物理範囲内に説明可能.既知差分 #3(LED 実 W 値未検証)クローズ.**形状指標への影響なし**(線形ゲイン).配光は厳密 Lambertian 仮定継続(詳細 IES 未提供、実 SMD 2835 のやや前方凸性は ISP LSC と打ち消し合う近似として現状妥当).詳細: [SPEC_03 LED セクション](docs/04_SPEC/SPEC_03_基準設計の物理構成.md#led光源). -補足: **輝度マップ「形」の追跡を導入**([TEST_01 輝度マップ形状の追跡セクション](docs/06_TEST/TEST_01_キャリブレーション検証履歴.md#輝度マップ形状の追跡-luminance-map-shape-tracking) + `scripts/build_luminance_evolution_panel.py`).重要な発見: **これまでの全 7 改善ステップ (8-1〜8-7) で輝度マップの「形」は全く変わっていない**.OETF は単調変換で形を変えず,発光面サイズ変更も形を変えない.**実機は中央付近にピークを持つ楕円状,シミュは下端のみ明の Y 軸単調勾配**.**LSC モデル化は「数値残差縮小」と「形の構造的改善」の両方を担う唯一の改善候補**.以後の改善判断は「数値 + 形」の 2 軸で行う. +補足: **輝度マップ「形」の追跡を導入**([TEST_03 輝度マップ形状追跡](docs/06_TEST/TEST_03_輝度マップ形状追跡.md) + `scripts/build_luminance_evolution_panel.py`).重要な発見: **これまでの全 7 改善ステップ (8-1〜8-7) で輝度マップの「形」は全く変わっていない**.OETF は単調変換で形を変えず,発光面サイズ変更も形を変えない.**実機は中央付近にピークを持つ楕円状,シミュは下端のみ明の Y 軸単調勾配**.**LSC モデル化は「数値残差縮小」と「形の構造的改善」の両方を担う唯一の改善候補**.以後の改善判断は「数値 + 形」の 2 軸で行う. +補足: **Step 8-8 完了**.linear 評価モードに mean 揃え正規化を導入(`evaluation/normalize.py` の `apply_mean_anchor` + `EvaluationConfig.normalization` スキーマ + CLI 統合 + OETF 排他バリデーション).OETF 露出アンカー(Step 8-3)と同じ思想を linear ドメインに対称展開.`configs/eval_baseline_linear.yaml` で有効化(target_mean=146.7146).形状指標 (CoV/勾配/C/P/max-min) は厳密不変,mean のみ target_mean に揃う.動作確認: α=0.961934 で baseline luminance.npy が mean=146.7146, CoV=0.182577 で評価可能.テスト 62 件追加(既存 441 + 新規 62 = 503 件全通過). 次のアクション(キャリブ再開時): 残差に **LSC(ISP レンズシェーディング)順方向モデル**を当てる(物理動機づけ低自由度形、overfitting 回避・取得パラメータ数明記). 実装済みモジュール: @@ -20,6 +21,7 @@ - `src/sm_tias_lightsim/evaluation/radial.py` — 20 ビン放射状輝度プロファイル - `src/sm_tias_lightsim/evaluation/roi.py` — `image_pixel` 型矩形 ROI 抽出(2D/3D 配列対応) - `src/sm_tias_lightsim/evaluation/oetf.py` — 標準 sRGB OETF(IEC 61966-2-1)+ 露出ソルバ(OETF 後 ROI mean を実測値に対数二分でアンカー)+ 順方向適用.シミュ linear を実測 sRGB ドメインへ変換 +- `src/sm_tias_lightsim/evaluation/normalize.py` — linear mean anchor 正規化(`apply_mean_anchor`).OETF と排他適用,形状指標は厳密不変で mean のみ target_mean に揃う.`configs/eval_baseline_linear.yaml` で有効化 - `src/sm_tias_lightsim/evaluation/visualize.py` — matplotlib 可視化(輝度マップ / ヒストグラム / 放射プロファイル / ゾーンマップ).MiniTias-Evaluation 互換スタイル(hot cmap,zone_map は luminance 背景 + 境界線) - `src/sm_tias_lightsim/scene/schema.py` — Pydantic ベース YAML スキーマ + ローダ.SensorConfig は perspective / orthographic 両対応 - `src/sm_tias_lightsim/geometry/loader.py` — STL 読込(trimesh)+ `LoadedMesh` データクラス @@ -31,7 +33,7 @@ - `src/sm_tias_lightsim/render/runner.py` — variant 管理 + `mi.render` 実行 - `src/sm_tias_lightsim/render/output.py` — PNG / luminance.npy 出力 - `src/sm_tias_lightsim/cli/render.py` — `sm-render` CLI(YAML → PNG / luminance.npy) -- `src/sm_tias_lightsim/cli/evaluate.py` — `sm-evaluate` CLI(PNG / luminance.npy → metrics.csv + figures/).`oetf.enabled` 時は ROI 抽出後・指標算出前に sRGB OETF を順方向適用(後方互換、シミュ用設定のみ) +- `src/sm_tias_lightsim/cli/evaluate.py` — `sm-evaluate` CLI(PNG / luminance.npy → metrics.csv + figures/).`oetf.enabled` 時は ROI 抽出後・指標算出前に sRGB OETF を順方向適用,`normalization.enabled` 時は OETF 前段に linear mean 揃え正規化(OETF と排他).後方互換、シミュ用設定のみ - `src/sm_tias_lightsim/visualize/baker.py` — face 中心の照度ベイク.`mode="direct"`(shadow ray + cosine + 距離減衰、直接光のみ、後方互換)/ `mode="path"`(scalar_rgb・完全シーン・コサイン重点 hemisphere MC、多重反射込み per-face、`exclude_mesh_ids` 対応).`max_depth` 引数で path ベイクの反射回数を render 設定から独立設定(None で render 継承=後方互換) - `src/sm_tias_lightsim/visualize/projection.py` — orthographic センサ投影の純関数(評価板メッシュ特定 / `project_luminance_to_vertices` 頂点 bilinear 投影 / `compute_orthographic_uv` テクスチャ UV / `compute_face_camera_mask` 幾何 Z ベース巻き順非依存の評価面選択 / `compute_inward_face_mask` 法線がシーン中心側を向く内向き面判定=幾何カリング用).`sensors.py` look_at@scale 規約と整合(SPEC_03「画像↔物理の向き」検証済) - `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 フォールバック) diff --git a/configs/eval_baseline_linear.yaml b/configs/eval_baseline_linear.yaml index b3f98f0..8d8e2fa 100644 --- a/configs/eval_baseline_linear.yaml +++ b/configs/eval_baseline_linear.yaml @@ -2,6 +2,9 @@ # 発光面サイズ近似(point-source approximation)の実証で「linear ベースで mean と # 形状指標が両方とも保たれるか」を確認するために使用.eval_baseline.yaml から # oetf セクションのみ取り除いた構成. +# normalization(mean anchor 正規化)を追加: シミュ linear の絶対値スケールを +# 実測 baseline mean(146.7146)に揃えることで,linear ドメインでの公平比較を可能にする. +# 線形ゲインは形状指標(CoV / 勾配 / C/P / max-min)に不変. roi: type: image_pixel x: 24 @@ -18,3 +21,7 @@ std: true max_min: true radial_profile: true +normalization: + enabled: true + type: mean_anchor + target_mean: 146.7146 # 実測 ROI mean(uint8 スケール): MiniTIAS_20260408_144434 diff --git "a/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" index 3bf3f40..a04637f 100644 --- "a/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" +++ "b/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" @@ -160,16 +160,28 @@ mode: anchor_mean # OETF 後 ROI mean を target_mean に対数二分で合わせる(実測 AE アンカー) target_mean: 146.7146 # 実測 ROI mean(uint8 [0,255] スケール) quantize_uint8: true # OETF 後を実測 PNG 同様 uint8 量子化 + normalization: # 任意.省略時 normalization 無効(後方互換).OETF と排他 + enabled: true + type: mean_anchor # mean_anchor のみ(将来 max_anchor / range_anchor 等を拡張予定) + target_mean: 146.7146 # ROI mean を target_mean に線形ゲインで揃える(uint8 スケール想定) ``` 省略した場合は `sm-evaluate` の `--config` で別ファイルを指定する. #### oetf(任意・後方互換) -シミュは linear radiance、実測 PNG は ISP の sRGB ガンマ符号化値であり、無次元指標(CoV/勾配/比)は非線形変換下で不変でないため、**シミュ vs 実測の数値突合には OETF 順方向適用が必須**(詳細は [SPEC_03「キャリブレーション経過」](SPEC_03_基準設計の物理構成.md)).`oetf.enabled=true` のとき `sm-evaluate` は ROI 抽出後・指標算出前に標準 sRGB OETF を適用する.露出は AE が制御する量=実測 ROI mean にアンカー(線形ゲインなので形状指標に自由度を持ち込まない).`oetf` キー省略時は `enabled=False` 扱いで従来どおり linear 評価. +シミュは linear radiance、実測 PNG は ISP の sRGB ガンマ符号化値であり、無次元指標(CoV/勾配/比)は非線形変換下で不変でないため、**シミュ vs 実測の数値突合には OETF 順方向適用が必須**(詳細は [SPEC_04「OETF 順方向適用の仕様」](SPEC_04_評価仕様.md)).`oetf.enabled=true` のとき `sm-evaluate` は ROI 抽出後・指標算出前に標準 sRGB OETF を適用する.露出は AE が制御する量=実測 ROI mean にアンカー(線形ゲインなので形状指標に自由度を持ち込まない).`oetf` キー省略時は `enabled=False` 扱いで従来どおり linear 評価. **重要(非対称性)**: OETF はシミュ出力にのみ適用する.実測 PNG は既に sRGB 符号化済みのため、実測用評価設定(`configs/eval_real.yaml` 等)には `oetf` を**付けない**(二重適用は誤り). +#### normalization(任意・linear モード用) + +linear ドメインでの評価結果について **mean を target_mean に揃える線形ゲイン正規化** を行う.OETF の露出アンカーと同じ思想を,OETF を介さない linear 評価モードに対称展開したもの.線形ゲインなので **形状指標 (CoV / 勾配 / C/P / max-min 比) は厳密に不変**で,絶対値スケールだけが揃う.主用途は「同じ ROI に対し設定を変えた複数シミュレーション結果の形を視覚的・数値的に公平比較する」こと([TEST_03 輝度マップ形状追跡](../06_TEST/TEST_03_輝度マップ形状追跡.md) 参照). + +**排他制御**: `oetf.enabled=true` と `normalization.enabled=true` の同時指定は **ValidationError**(OETF の露出ソルバが既に mean アンカーを行うため二重適用になる).`oetf` を使う場合は `normalization` を省略 or `enabled: false` にする. + +`type` は現状 `mean_anchor` のみ.将来 `max_anchor` / `range_anchor` 等を追加する余地のための Literal 設計. + ## バリデーション `scene/schema.py` で以下をチェック(Pydantic 想定): 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 a6e4c87..197058b 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" @@ -29,23 +29,26 @@ ▼ ROI 設定 (image_pixel 型) に従い (x, y, width, height) 領域を切り出す [3] ROI 抽出 │ + ▼ normalization.enabled = true のとき: linear ドメインで mean を target_mean に揃える線形ゲイン +[4] mean 正規化 (任意・linear モード用、OETF と排他) + │ ▼ oetf.enabled = true のとき: 標準 sRGB OETF を順方向適用 + 実測 mean アンカー -[4] OETF 順方向適用 (任意・シミュ用) +[5] OETF 順方向適用 (任意・シミュ用、normalization と排他) │ ▼ (任意) quantize_uint8: float → uint8 量子化 -[5] 量子化 +[6] 量子化 │ ▼ CoV, std, max-min, mean, max, min を計算 -[6] 全体指標 +[7] 全体指標 │ ▼ 3 ゾーン分類 (center/middle/periphery) → 中心/周辺比,勾配 % -[7] ゾーン指標 +[8] ゾーン指標 │ ▼ 20 ビンの放射状プロファイル -[8] 動径指標 +[9] 動径指標 │ ▼ metrics.csv, figures/{luminance_map, histogram, radial_profile, zone_map}.png -[9] 出力 +[10] 出力 ``` 各ステージの実装は以下のモジュールに分かれる: @@ -55,10 +58,11 @@ | [1] 読み込み | `cli/evaluate.py`(PNG: PIL,npy: numpy) | | [2] luma 変換 | `uniformity.py`(Rec.709 加重和) | | [3] ROI 抽出 | `evaluation/roi.py` | -| [4] OETF | `evaluation/oetf.py` | -| [5-7] 指標 | `uniformity.py` + `evaluation/zones.py` | -| [8] 動径 | `evaluation/radial.py` | -| [9] 可視化 | `evaluation/visualize.py` | +| [4] mean 正規化 | `evaluation/normalize.py` | +| [5] OETF | `evaluation/oetf.py` | +| [6-8] 指標 | `uniformity.py` + `evaluation/zones.py` | +| [9] 動径 | `evaluation/radial.py` | +| [10] 可視化 | `evaluation/visualize.py` | ## ROI 仕様 (Region of Interest) @@ -141,6 +145,56 @@ `oetf.quantize_uint8: true` で float → uint8 量子化を施す.実測 PNG が uint8 のため,公平比較には量子化を入れる.量子化ノイズ ≈ ±0.5 単位は無次元指標にほぼ影響しない(spp=1024 の MC ノイズの方が大きい). +## mean 正規化(linear モード用) (Mean Anchor Normalization for Linear Mode) + +OETF を適用しない linear ドメインでの評価結果について **mean を target_mean に揃える線形ゲイン正規化** を行う.OETF の露出アンカーと同じ思想を,OETF を介さない linear 評価モードに対称展開したもの. + +### 物理動機 + +シミュ側の `radiance` 値は scene YAML での暫定値(実測 mean アンカー)で決まるが,**シーン設定を変えると(例: 発光面サイズや反射率の変更)絶対値スケールが大きく動く** ([Step 8-7](../06_TEST/TEST_01_キャリブレーション検証履歴.md#step-8-7-発光面サイズ検証-2835-vs-1515φe-保存) で実証.size を 2.8×3.5 → 1.5×1.5 mm に変えると mean が 2.83× 上昇).数値指標 (CoV / 勾配 / C/P) は **線形ゲインに対し不変** なので形状指標の比較は問題ないが,**輝度マップを画像で見比べる際に color scale が桁違いになり「形」の比較が阻害される** . + +linear ドメインで mean を共通の target_mean に揃えれば,color scale を揃えた状態で複数シミュ結果の形を視覚的・数値的に公平比較できる. + +### 計算式 + +```text +α = target_mean / values.mean() +normalized = values × α +``` + +線形ゲイン α を全要素に乗算するのみ.返り値は `(normalized, α)` のタプル.`values.mean() == 0` の場合は `ValueError`. + +### 不変性(厳密) + +| 指標 | 正規化前後で | +| --- | --- | +| mean | target_mean に厳密一致 | +| CoV (`std / mean`) | **不変**(分子分母同時 α 倍) | +| max/min 比 | **不変** | +| 中心/周辺比 (C/P) | **不変** | +| 勾配 % | **不変** | +| std | α 倍 | +| max, min | α 倍 | + +### OETF との排他制御 + +`oetf.enabled=true` と `normalization.enabled=true` を同時指定すると **ValidationError**(OETF の露出ソルバが既に mean アンカーを行うため二重適用になる).`EvaluationConfig._validate_oetf_normalization_exclusive` で保証. + +| OETF | normalization | 挙動 | +| --- | --- | --- | +| 有効 | 省略 or 無効 | OETF 内の露出ソルバが mean アンカー(既存動作) | +| 有効 | 有効 | **ValidationError**(排他制約違反) | +| 無効 | 有効 | linear ドメインで mean 揃え(**本機能の主用途**) | +| 無効 | 省略 or 無効 | 無変換(生 luminance のまま,後方互換) | + +### 後方互換 + +`normalization` キー省略時は無変換.既存設定(`configs/eval_real.yaml`,`configs/eval_baseline.yaml`)は変更不要.`configs/eval_baseline_linear.yaml` のみ新規追加された設定. + +### 主用途 + +[TEST_03 輝度マップ形状追跡](../06_TEST/TEST_03_輝度マップ形状追跡.md) における **複数 Step の輝度マップ形状比較**.発光面サイズ・反射率等を変えたシミュレーション結果を,共通の色スケールで並べることで「形」の異同を視覚的に判定できる. + ## メトリクス定義 (Metrics Definitions) ROI 内の輝度配列 `Y` を入力に,以下を算出.計算式は `uniformity.py` 実装と一致. @@ -276,6 +330,7 @@ - `src/sm_tias_lightsim/cli/evaluate.py`: CLI - `src/sm_tias_lightsim/uniformity.py`: Rec.709 luma + CoV/std/max-min - `src/sm_tias_lightsim/evaluation/oetf.py`: 標準 sRGB OETF + 露出アンカー + - `src/sm_tias_lightsim/evaluation/normalize.py`: mean 正規化(linear モード用) - `src/sm_tias_lightsim/evaluation/roi.py`: ROI 抽出 - `src/sm_tias_lightsim/evaluation/zones.py`: 3 ゾーン分類 + C/P + 勾配 % - `src/sm_tias_lightsim/evaluation/radial.py`: 動径プロファイル 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 b82eb75..6299adf 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" @@ -256,6 +256,39 @@ - 検証用ファイル `configs/scene_baseline_emitter_real.yaml`, `configs/eval_baseline_linear.yaml`, `output/emitter_real/`, `output/baseline_linear_npy/` は **検証アーティファクト**として保持(後から再現できるように). - 本筋 LSC モデル化([Step 8-N](#step-8-n-候補-lsc-順方向モデル未着手))に進む. +### Step 8-8: linear 評価モードへの mean 揃え正規化導入 + +- **日付**: 2026-05-27 +- **背景・問題意識**: Step 8-7 で size 1.5×1.5 検証時,linear ドメインでの絶対値 mean が 429.99(baseline は 152.02)まで跳ね上がった.**形状指標は不変だったが,輝度マップを画像で並べると color scale が桁違いで「形」の比較が阻害される**.[TEST_03 輝度マップ形状追跡](TEST_03_輝度マップ形状追跡.md) で問題化. +- **仮説(物理動機)**: OETF 順方向適用([Step 8-3](#step-8-3-oetf-順方向適用手法訂正))の露出アンカーと同じ思想を linear ドメインに対称展開する.線形ゲイン `α = target_mean / mean` は形状指標 (CoV / 勾配 / C/P / max-min) に対し **厳密に不変** なので,「絶対値スケールだけ揃える」操作として安全に適用できる. +- **試した変更**: `sm-evaluate` に `normalization` セクションを追加実装. + - 新規モジュール `src/sm_tias_lightsim/evaluation/normalize.py` の `apply_mean_anchor(values, target_mean) -> (normalized, alpha)`. + - `EvaluationConfig` スキーマに `NormalizationConfig`(enabled / type Literal["mean_anchor"] / target_mean)追加. + - `model_validator` で OETF + normalization 同時有効化を **ValidationError** として禁止(露出アンカー二重適用回避). + - ROI 抽出後・OETF 前段に正規化ステージを挿入(パイプライン [4] に該当). + - `configs/eval_baseline_linear.yaml` に `normalization.enabled: true / target_mean: 146.7146` を設定. +- **期待した効果**: linear 評価結果の mean が常に 146.7146 に揃い,形状指標は変更前と一致. +- **実際の結果**(`output/baseline/luminance.npy` で動作確認): + + | 指標 | 正規化前 (mean=152.52) | 正規化後 (mean=146.71) | 差 | + | --- | --- | --- | --- | + | mean | 152.5204 | **146.7146** | target_mean に厳密一致 | + | CoV | 0.1826 | 0.182577 | ≈ 不変(誤差レベル) | + | max/min | 4.2759 | 4.2759 | 厳密一致 | + | C/P | 1.3271 | 1.3271 | 厳密一致 | + | 勾配 % | 24.65 | 24.65 | 不変 | + | std | 27.85 | 26.7868 | α=0.962 倍にスケール(想定通り) | + + α = target_mean / src_mean = 146.7146 / 152.52 = **0.961934**. +- **解釈**: 線形ゲイン正規化の物理的不変性が**実証された**.形状指標は誤差レベルで一致し,mean は厳密に target_mean に揃う.後段の TEST_03 比較パネル再生成に向けた評価本体の対称化が完了. +- **学び**: + - **「データに合わせて回す係数ゼロ」の操作は,linear / sRGB 両ドメインに対称に展開できる**.OETF 露出アンカー(Step 8-3)と本 Step は同じ思想で,適用ドメインが違うだけ. + - 排他制御をスキーマレベル(`model_validator`)で実装することで,二重適用のリスクを使用時点ではなく設定時点で防げる. + - 単体テスト・スキーマテスト・CLI 統合テストの 62 件追加で挙動の厳密性(mean 一致・形状指標不変)が保証された. +- **次のアクション**: + - TEST_03 の比較パネル生成スクリプト (`scripts/build_luminance_evolution_panel.py`) が利用する Step 8-1 / 8-7 の入力を **linear + normalization** 評価結果に揃え,color scale を共有して「形」の比較を強化する(別フェーズ・別コミット). + - 本筋 LSC モデル化([Step 8-N](#step-8-n-候補-lsc-順方向モデル未着手))は引き続き未着手. + ## Step 8-N 候補: LSC 順方向モデル(未着手) - **位置付け**: 8-3 OETF 適用後の残差 **1.6〜1.9×**(CoV 0.089 vs 実測 0.043,勾配 12.57 vs 6.10)の **主因候補**. diff --git a/src/sm_tias_lightsim/cli/evaluate.py b/src/sm_tias_lightsim/cli/evaluate.py index 7231188..f2a6b56 100644 --- a/src/sm_tias_lightsim/cli/evaluate.py +++ b/src/sm_tias_lightsim/cli/evaluate.py @@ -24,6 +24,7 @@ import pandas # noqa: E402 from PIL import Image # noqa: E402 +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.roi import extract_roi # noqa: E402 @@ -38,7 +39,13 @@ classify_zones, compute_normalized_distance_map, ) -from sm_tias_lightsim.scene.schema import EvaluationConfig, OetfConfig # noqa: E402 +from sm_tias_lightsim.scene.schema import ( # noqa: E402 + EvaluationConfig, + MetricsConfig, + NormalizationConfig, + OetfConfig, + ZonesConfig, +) from sm_tias_lightsim.uniformity import calc_uniformity, to_grayscale # noqa: E402 # 出力ファイル名を一元管理する(変更時の影響範囲を最小化するため) @@ -128,6 +135,33 @@ return apply_srgb_oetf(roi_lin, scale, quantize_uint8=oetf_cfg.quantize_uint8) +def _apply_normalization_to_luminance( + roi_luminance: numpy.ndarray, + norm_cfg: NormalizationConfig, +) -> numpy.ndarray: + """linear mean anchor 正規化を ROI 輝度配列に適用する. + + ROI mean を norm_cfg.target_mean に揃える線形ゲインを適用する. + 形状指標(CoV / 勾配 / C/P 比 / max-min 比)は線形ゲインに不変なので, + 絶対値スケールだけが変わる. + + Args: + roi_luminance: ROI 切り出し済みの linear 輝度配列(H' x W', float64). + norm_cfg: NormalizationConfig(enabled=True が前提). + + Returns: + 正規化後の ROI 輝度配列(H' x W', float64). + + Raises: + ValueError: ROI mean が 0 の場合(apply_mean_anchor から伝播). + """ + roi_lin = roi_luminance.astype(numpy.float64) + target_mean = norm_cfg.target_mean + normalized, alpha = apply_mean_anchor(roi_lin, target_mean) + print(f" normalization : alpha={alpha:.6g} target_mean={target_mean}") + return normalized + + def _load_luminance(image_path: Path) -> numpy.ndarray: """画像ファイルを読み込み,輝度配列(2D float64)を返す. @@ -199,13 +233,14 @@ def _run_zone_analysis( roi_luminance: numpy.ndarray, - zones_config: object, + zones_config: ZonesConfig | None, ) -> tuple[numpy.ndarray, dict] | tuple[None, dict]: """ゾーン分析を実行し,zone_map とゾーン統計を返す. Args: roi_luminance: ROI 輝度配列(H x W, float64). zones_config: ZonesConfig インスタンス(enabled / boundaries を持つ). + None の場合は無効扱い. Returns: (zone_map, zone_stats) のタプル. @@ -226,13 +261,14 @@ def _run_radial_profile( roi_luminance: numpy.ndarray, - metrics_config: object, + metrics_config: MetricsConfig | None, ) -> numpy.ndarray | None: """放射状プロファイルを算出して返す. Args: roi_luminance: ROI 輝度配列(H x W, float64). metrics_config: MetricsConfig インスタンス(radial_profile フラグを持つ). + None の場合は無効扱い. Returns: 放射状プロファイル配列((n_bins, 2)),無効時は None. @@ -310,12 +346,14 @@ 3. YAML 設定ファイルを読み込み,EvaluationConfig を得る. 4. 出力ディレクトリと figures/ サブディレクトリを作成する. 5. extract_roi で ROI を切り出す. - 6. calc_uniformity で均一性指標を算出する. - 7. zones.enabled のときゾーン分析を実行する. - 8. metrics.radial_profile のとき放射状プロファイルを算出する. - 9. metrics.csv を保存する. - 10. 可視化 PNG を figures/ に保存する. - 11. 算出指標のサマリーを標準出力に表示する. + 6. normalization.enabled のとき linear mean anchor 正規化を適用する. + 7. oetf.enabled のとき sRGB OETF を適用する. + 8. calc_uniformity で均一性指標を算出する. + 9. zones.enabled のときゾーン分析を実行する. + 10. metrics.radial_profile のとき放射状プロファイルを算出する. + 11. metrics.csv を保存する. + 12. 可視化 PNG を figures/ に保存する. + 13. 算出指標のサマリーを標準出力に表示する. Args: argv: 引数リスト.None のとき sys.argv[1:] を使用. @@ -363,6 +401,16 @@ print(f"エラー: ROI 抽出に失敗しました — {exc}", file=sys.stderr) return 1 + # linear mean anchor 正規化(enabled のとき ROI 抽出後・OETF 前に挟む). + # OETF との併用禁止は EvaluationConfig の model_validator で保証されている. + norm_cfg = eval_config.normalization + if norm_cfg is not None and norm_cfg.enabled: + try: + roi_luminance = _apply_normalization_to_luminance(roi_luminance, norm_cfg) + except ValueError as exc: + print(f"エラー: linear 正規化に失敗しました — {exc}", file=sys.stderr) + return 1 + # OETF 順方向適用(enabled のとき ROI 抽出後・指標算出前に挟む). # eval_real.yaml(実測 PNG)は oetf を設定しないこと: # 実測 PNG は既に sRGB 符号化済みのため,ここで OETF を掛けると二重適用になる. diff --git a/src/sm_tias_lightsim/evaluation/normalize.py b/src/sm_tias_lightsim/evaluation/normalize.py new file mode 100644 index 0000000..faff00f --- /dev/null +++ b/src/sm_tias_lightsim/evaluation/normalize.py @@ -0,0 +1,49 @@ +"""linear ドメイン正規化モジュール. + +シミュの生 luminance 配列に対して,mean を指定値(target_mean)に揃える +線形ゲインを適用する. + +線形ゲインは形状指標(CoV / 勾配 / C/P 比 / max-min 比)に厳密に不変であり, +絶対値スケールだけを合わせる操作である.これにより複数設計のシミュ結果を +linear ドメインで公平比較できる. + +OETF 順方向適用(oetf.py の露出アンカー)と同じ思想を linear モードに +対称に展開したものであり,OETF との併用は禁止する(EvaluationConfig の +model_validator で検証する). +""" + +from __future__ import annotations + +import numpy + + +def apply_mean_anchor( + values: numpy.ndarray, + target_mean: float, +) -> tuple[numpy.ndarray, float]: + """入力配列の mean を target_mean に揃える線形ゲインを適用する. + + $\\alpha = \\text{target\\_mean} / \\text{values.mean()}$ + として,``values * alpha`` を返す. + + CoV = std / mean,max-min 比,勾配,C/P 比などの形状指標はスカラー倍で + 不変なので,絶対値スケールだけが揃う(線形ゲインの性質). + + Args: + values: 入力 ROI 配列(任意形状・任意次元,float64 推奨). + target_mean: 目標 mean 値(uint8 スケール [0, 255] 想定だが任意 float 可). + + Returns: + (normalized, alpha) のタプル. + normalized: ``values * alpha`` の配列(dtype は values と同じ). + alpha: 適用した線形ゲイン(target_mean / values.mean()). + + Raises: + ValueError: values.mean() が 0 の場合(ゼロ除算を防ぐ). + """ + src_mean = float(numpy.mean(values)) + if src_mean == 0.0: + msg = "values の mean が 0 のため,mean anchor 正規化を適用できません." + raise ValueError(msg) + alpha = target_mean / src_mean + return values * alpha, alpha diff --git a/src/sm_tias_lightsim/scene/schema.py b/src/sm_tias_lightsim/scene/schema.py index b3fbd3f..d0e4383 100644 --- a/src/sm_tias_lightsim/scene/schema.py +++ b/src/sm_tias_lightsim/scene/schema.py @@ -253,6 +253,45 @@ # --------------------------------------------------------------------------- +# Normalization(linear mean anchor 正規化)設定 +# --------------------------------------------------------------------------- + + +class NormalizationConfig(_FrozenBase): + """linear ドメイン mean anchor 正規化設定. + + enabled=True のとき sm-evaluate は ROI 抽出後・指標算出前に + ``apply_mean_anchor`` で mean を target_mean に揃える線形ゲインを適用する. + + 線形ゲインは形状指標(CoV / 勾配 / C/P 比 / max-min 比)に不変なので, + 絶対値スケールだけを実測と合わせた上で形状の公平比較が可能になる. + + OETF との併用禁止: oetf.enabled=True かつ normalization.enabled=True は + EvaluationConfig の model_validator でバリデーションエラーになる. + (OETF が内部で露出アンカーを持つため,二重アンカーになるのを防ぐ) + + 後方互換: EvaluationConfig.normalization を省略すると無効扱いで + 既存の linear 評価が継続される. + """ + + enabled: bool = Field(default=False, description="mean anchor 正規化の有効/無効.") + type: Literal["mean_anchor"] = Field( + default="mean_anchor", + description=( + "正規化種別.mean_anchor: ROI mean を target_mean に揃える線形ゲイン適用." + "将来の拡張(max_anchor / range_anchor 等)に備えた Literal 設計." + ), + ) + target_mean: float = Field( + ..., + description=( + "目標 ROI mean(uint8 スケール [0, 255] 想定だが任意 float 可)." + "実測 ROI mean(例: 146.7146)を指定する." + ), + ) + + +# --------------------------------------------------------------------------- # Evaluation # --------------------------------------------------------------------------- @@ -300,9 +339,10 @@ class EvaluationConfig(_FrozenBase): - """評価設定(ROI・ゾーン・指標・OETF). + """評価設定(ROI・ゾーン・指標・OETF・正規化). oetf フィールドを省略した既存 YAML は後方互換(OETF 無効扱い)で動作する. + normalization フィールドを省略した既存 YAML も後方互換(正規化無効扱い)で動作する. """ roi: RoiConfig @@ -316,6 +356,37 @@ "(実測は既に sRGB 符号化済みのため二重適用になる)." ), ) + normalization: NormalizationConfig | None = Field( + default=None, + description=( + "linear ドメイン mean anchor 正規化設定.省略時は正規化無効." + "oetf.enabled=True との併用は禁止(バリデーションエラー)." + ), + ) + + @model_validator(mode="after") + def _validate_oetf_normalization_exclusive(self) -> EvaluationConfig: + """OETF と normalization の同時有効化を禁止する. + + OETF は内部で露出アンカー(mean ベースの線形ゲイン)を持つため, + normalization との併用は二重アンカーになり意味をなさない. + + Returns: + 検証済みの EvaluationConfig 自身. + + Raises: + ValueError: oetf.enabled=True かつ normalization.enabled=True の場合. + """ + oetf_on = self.oetf is not None and self.oetf.enabled + norm_on = self.normalization is not None and self.normalization.enabled + if oetf_on and norm_on: + msg = ( + "oetf.enabled=True と normalization.enabled=True は同時に設定できません." + "OETF が内部で露出アンカーを持つため,linear mean anchor との併用は" + "二重アンカーになります.どちらか一方のみ有効にしてください." + ) + raise ValueError(msg) + return self # --------------------------------------------------------------------------- diff --git a/tests/cli/test_evaluate_normalization.py b/tests/cli/test_evaluate_normalization.py new file mode 100644 index 0000000..83546a2 --- /dev/null +++ b/tests/cli/test_evaluate_normalization.py @@ -0,0 +1,760 @@ +"""cli/evaluate.py の normalization(linear mean anchor 正規化)に関する仕様ベーステスト. + +仕様出典: +- 実装サマリー(CLI 後方互換・ログ出力・metrics.csv mean 一致・形状指標不変性) +- docs/04_SPEC/SPEC_04_評価仕様.md(評価パイプライン Step 6: normalization) +- docs/04_SPEC/SPEC_03_基準設計の物理構成.md(「形状指標への影響(重要)」節) +- docs/01_GUIDE/GUIDE_08_テスト方針.md(テスト方針) + +テスト方針: + - subprocess ではなく main() を直接呼ぶ(速度・カバレッジのため) + - ファイル I/O はモックせず tmp_path フィクスチャで実ファイルを使う (GUIDE_08) + - 乱数を使う場合は seed を必ず固定する (GUIDE_08) + - CLI 統合テストとして「パイプライン全体を通した振る舞い」を検証する + +注意: + matplotlib の Agg バックエンドは evaluate.py 内部で設定される. + テスト側でも安全のため import 前に設定する. +""" + +from __future__ import annotations + +import matplotlib # noqa: I001 + +# headless 環境でも動くよう Agg バックエンドを pyplot より前に設定する +matplotlib.use("Agg") + +from pathlib import Path + +import numpy +import pytest + +from sm_tias_lightsim.cli.evaluate import main + +# --------------------------------------------------------------------------- +# テスト用定数 +# --------------------------------------------------------------------------- + +#: output/baseline/luminance.npy の絶対パス(実際に存在する前提) +BASELINE_LUMINANCE_NPY = ( + Path(__file__).parent.parent.parent / "output" / "baseline" / "luminance.npy" +) + +#: configs/eval_baseline_linear.yaml の絶対パス +EVAL_BASELINE_LINEAR_YAML = ( + Path(__file__).parent.parent.parent / "configs" / "eval_baseline_linear.yaml" +) + +#: configs/eval_baseline.yaml の絶対パス(OETF on, normalization なし) +EVAL_BASELINE_YAML = ( + Path(__file__).parent.parent.parent / "configs" / "eval_baseline.yaml" +) + +# --------------------------------------------------------------------------- +# テスト用ヘルパー +# --------------------------------------------------------------------------- + + +def _make_luminance_array(seed: int = 0, size: int = 256) -> numpy.ndarray: + """ゼロを含まない合成輝度配列を返す(seed 固定). + + max/min 計算が走るようゼロを含まない値にする (GUIDE_08 補助ガイダンス) + """ + rng = numpy.random.RandomState(seed) + return numpy.full((size, size), 100.0) + rng.normal(0, 5, (size, size)) + + +def _make_normalization_yaml( + tmp_path: Path, + target_mean: float = 146.7146, + x: int = 32, + y: int = 32, + width: int = 192, + height: int = 192, + zones_enabled: bool = True, + radial_profile: bool = True, +) -> Path: + """normalization.enabled=true を含む評価 YAML を tmp_path に書き出す. + + 仕様: eval_baseline_linear.yaml の normalization セクション相当 + """ + content = f"""\ +roi: + type: image_pixel + x: {x} + y: {y} + width: {width} + height: {height} +zones: + enabled: {'true' if zones_enabled else 'false'} + boundaries: [0.33, 0.66] +metrics: + cov: true + std: true + max_min: true + radial_profile: {'true' if radial_profile else 'false'} +normalization: + enabled: true + type: mean_anchor + target_mean: {target_mean} +""" + yaml_path = tmp_path / "eval_normalization.yaml" + yaml_path.write_text(content, encoding="utf-8") + return yaml_path + + +def _make_plain_yaml( + tmp_path: Path, + x: int = 32, + y: int = 32, + width: int = 192, + height: int = 192, +) -> Path: + """normalization なし(後方互換)の評価 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 +""" + yaml_path = tmp_path / "eval_plain.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)] + ) + + +# --------------------------------------------------------------------------- +# 1. 正常系 — normalization.enabled=true で main() が 0 を返すこと +# --------------------------------------------------------------------------- + + +def test_normalization_enabled_returns_0(tmp_path: Path): + """normalization.enabled=true を含む YAML で main() が 0 を返すこと. + + 仕様: normalization.enabled=True のとき ROI 抽出後・OETF 前に apply_mean_anchor を適用する + (cli/evaluate.py main 処理フロー) + """ + luminance = _make_luminance_array(seed=300) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + config_path = _make_normalization_yaml(tmp_path, target_mean=80.0) + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + + +def test_normalization_enabled_creates_metrics_csv(tmp_path: Path): + """normalization.enabled=true のとき metrics.csv が生成されること. + + 仕様: normalization 後の ROI で均一性指標を算出し CSV に保存する + """ + luminance = _make_luminance_array(seed=301) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + config_path = _make_normalization_yaml(tmp_path, target_mean=100.0) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + assert (output_dir / "metrics.csv").exists() + + +# --------------------------------------------------------------------------- +# 2. mean が target_mean に一致すること +# --------------------------------------------------------------------------- + + +def test_normalization_mean_equals_target(tmp_path: Path): + """normalization.enabled=true のとき metrics.csv の mean が target_mean に一致すること. + + 仕様(実装サマリー「線形ゲインの不変性」,CLI 後方互換): + metrics.csv の mean が target_mean に一致する + NOTE: 合成データは ROI = 配列全体と一致する設定にする + (size=256, ROI x=0,y=0,w=256,h=256 → ROI mean == 配列 mean) + """ + import pandas + + rng = numpy.random.RandomState(302) + luminance = numpy.full((256, 256), 100.0) + rng.normal(0, 5, (256, 256)) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + target_mean = 146.7146 + # ROI = 配列全体にして ROI mean が配列 mean に一致するようにする + content = f"""\ +roi: + type: image_pixel + x: 0 + y: 0 + width: 256 + height: 256 +normalization: + enabled: true + type: mean_anchor + target_mean: {target_mean} +""" + config_path = tmp_path / "eval_full_roi.yaml" + config_path.write_text(content, encoding="utf-8") + + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + + df = pandas.read_csv(output_dir / "metrics.csv") + mean_val = float(df["mean"].iloc[0]) + # apply_mean_anchor の後 mean は target_mean に厳密一致するはず + assert mean_val == pytest.approx(target_mean, rel=1e-6) + + +# --------------------------------------------------------------------------- +# 3. 形状指標の不変性(CoV / max-min / C/P / 勾配) +# --------------------------------------------------------------------------- + + +def test_normalization_cov_same_as_without_normalization(tmp_path: Path): + """normalization 適用前後で CoV が不変であること(CLI レベル検証). + + 仕様(実装サマリー「線形ゲインの不変性」): + 線形ゲインは CoV に不変 + normalization 後の CoV == normalization 前の CoV + """ + import pandas + + rng = numpy.random.RandomState(303) + luminance = numpy.full((256, 256), 80.0) + rng.normal(0, 5, (256, 256)) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + roi_kwargs = {"x": 0, "y": 0, "width": 256, "height": 256} + + # normalization なし + config_no_norm = _make_plain_yaml(tmp_path, **roi_kwargs) + out_no_norm = tmp_path / "output_no_norm" + _run_main(npy_path, config_no_norm, out_no_norm) + df_no_norm = pandas.read_csv(out_no_norm / "metrics.csv") + cov_no_norm = float(df_no_norm["cov"].iloc[0]) + + # normalization あり + content_norm = """\ +roi: + type: image_pixel + x: 0 + y: 0 + width: 256 + height: 256 +normalization: + enabled: true + type: mean_anchor + target_mean: 200.0 +""" + config_norm = tmp_path / "eval_norm.yaml" + config_norm.write_text(content_norm, encoding="utf-8") + out_norm = tmp_path / "output_norm" + _run_main(npy_path, config_norm, out_norm) + df_norm = pandas.read_csv(out_norm / "metrics.csv") + cov_norm = float(df_norm["cov"].iloc[0]) + + # 線形ゲインで CoV は変わらない + assert cov_norm == pytest.approx(cov_no_norm, rel=1e-8) + + +def test_normalization_max_min_ratio_same_as_without(tmp_path: Path): + """normalization 適用前後で max/min 比が不変であること(CLI レベル検証). + + 仕様(実装サマリー「線形ゲインの不変性」): + 線形ゲインは max/min 比に不変 + """ + import pandas + + rng = numpy.random.RandomState(304) + luminance = numpy.full((256, 256), 80.0) + rng.uniform(1.0, 20.0, (256, 256)) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + roi_kwargs = {"x": 0, "y": 0, "width": 256, "height": 256} + + config_no_norm = _make_plain_yaml(tmp_path, **roi_kwargs) + out_no_norm = tmp_path / "output_no_norm" + _run_main(npy_path, config_no_norm, out_no_norm) + df_no_norm = pandas.read_csv(out_no_norm / "metrics.csv") + ratio_before = float(df_no_norm["max_min_ratio"].iloc[0]) + + content_norm = """\ +roi: + type: image_pixel + x: 0 + y: 0 + width: 256 + height: 256 +normalization: + enabled: true + type: mean_anchor + target_mean: 50.0 +""" + config_norm = tmp_path / "eval_norm.yaml" + config_norm.write_text(content_norm, encoding="utf-8") + out_norm = tmp_path / "output_norm" + _run_main(npy_path, config_norm, out_norm) + df_norm = pandas.read_csv(out_norm / "metrics.csv") + ratio_after = float(df_norm["max_min_ratio"].iloc[0]) + + assert ratio_after == pytest.approx(ratio_before, rel=1e-8) + + +def test_normalization_gradient_same_as_without(tmp_path: Path): + """normalization 適用前後で勾配(gradient_magnitude)が不変であること. + + 仕様(実装サマリー「線形ゲインの不変性」): + 線形ゲインは勾配 % に不変 + """ + import pandas + + rng = numpy.random.RandomState(305) + luminance = numpy.full((256, 256), 80.0) + rng.normal(0, 5, (256, 256)) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + roi_kwargs = {"x": 0, "y": 0, "width": 256, "height": 256} + + config_no_norm = _make_plain_yaml(tmp_path, **roi_kwargs) + out_no_norm = tmp_path / "output_no_norm" + _run_main(npy_path, config_no_norm, out_no_norm) + df_no_norm = pandas.read_csv(out_no_norm / "metrics.csv") + grad_before = float(df_no_norm["gradient_magnitude"].iloc[0]) + + content_norm = """\ +roi: + type: image_pixel + x: 0 + y: 0 + width: 256 + height: 256 +zones: + enabled: true + boundaries: [0.33, 0.66] +normalization: + enabled: true + type: mean_anchor + target_mean: 200.0 +""" + config_norm = tmp_path / "eval_norm.yaml" + config_norm.write_text(content_norm, encoding="utf-8") + out_norm = tmp_path / "output_norm" + _run_main(npy_path, config_norm, out_norm) + df_norm = pandas.read_csv(out_norm / "metrics.csv") + grad_after = float(df_norm["gradient_magnitude"].iloc[0]) + + assert grad_after == pytest.approx(grad_before, rel=1e-8) + + +def test_normalization_cp_ratio_same_as_without(tmp_path: Path): + """normalization 適用前後で C/P 比(center_periphery_ratio)が不変であること. + + 仕様(実装サマリー「線形ゲインの不変性」): + 線形ゲインは C/P 比に不変 + """ + import pandas + + rng = numpy.random.RandomState(306) + luminance = numpy.full((256, 256), 80.0) + rng.normal(0, 5, (256, 256)) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + roi_kwargs = {"x": 0, "y": 0, "width": 256, "height": 256} + + config_no_norm = _make_plain_yaml(tmp_path, **roi_kwargs) + out_no_norm = tmp_path / "output_no_norm" + _run_main(npy_path, config_no_norm, out_no_norm) + df_no_norm = pandas.read_csv(out_no_norm / "metrics.csv") + cp_before = float(df_no_norm["center_periphery_ratio"].iloc[0]) + + content_norm = """\ +roi: + type: image_pixel + x: 0 + y: 0 + width: 256 + height: 256 +zones: + enabled: true + boundaries: [0.33, 0.66] +normalization: + enabled: true + type: mean_anchor + target_mean: 150.0 +""" + config_norm = tmp_path / "eval_norm.yaml" + config_norm.write_text(content_norm, encoding="utf-8") + out_norm = tmp_path / "output_norm" + _run_main(npy_path, config_norm, out_norm) + df_norm = pandas.read_csv(out_norm / "metrics.csv") + cp_after = float(df_norm["center_periphery_ratio"].iloc[0]) + + assert cp_after == pytest.approx(cp_before, rel=1e-8) + + +# --------------------------------------------------------------------------- +# 4. ログ出力の確認 +# --------------------------------------------------------------------------- + + +def test_normalization_prints_alpha_and_target_mean(tmp_path: Path, capsys): + """normalization.enabled=true のとき標準出力に alpha と target_mean が表示されること. + + 仕様(実装サマリー「CLI 後方互換」): + `normalization : alpha=... target_mean=...` のログが出る + """ + luminance = _make_luminance_array(seed=307) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + config_path = _make_normalization_yaml(tmp_path, target_mean=100.0) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + + captured = capsys.readouterr() + # cli/evaluate.py の print フォーマット: " normalization : alpha=... target_mean=..." + assert "normalization : alpha=" in captured.out + assert "target_mean=" in captured.out + + +def test_normalization_disabled_no_normalization_log(tmp_path: Path, capsys): + """normalization.enabled=false(または省略)のとき normalization ログが出ないこと. + + 仕様: normalization が無効なとき _apply_normalization_to_luminance は呼ばれない + (cli/evaluate.py main: `if norm_cfg is not None and norm_cfg.enabled`) + NOTE: ログフォーマット "normalization : alpha=" を検索する(パス名との混同を避ける) + """ + luminance = _make_luminance_array(seed=308) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + config_path = _make_plain_yaml(tmp_path) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + + captured = capsys.readouterr() + # "normalization : alpha=" はログとして出力されるフォーマット(cli/evaluate.py) + # normalization が無効なときはこのログは出ない + assert "normalization : alpha=" not in captured.out + + +# --------------------------------------------------------------------------- +# 5. 後方互換性 — normalization なし既存 YAML は動作変更なし +# --------------------------------------------------------------------------- + + +def test_backward_compat_no_normalization_key(tmp_path: Path): + """normalization キーなしの既存 YAML が後方互換で動くこと. + + 仕様(実装サマリー「CLI 後方互換」): + normalization セクションがない既存 config(eval_baseline.yaml, eval_real.yaml)は + 動作変更なし + """ + luminance = _make_luminance_array(seed=309) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + config_path = _make_plain_yaml(tmp_path) + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + assert (output_dir / "metrics.csv").exists() + + +def test_backward_compat_oetf_yaml_unaffected(tmp_path: Path): + """oetf.enabled=true の既存 YAML(eval_baseline.yaml 相当)が動作変更なしであること. + + 仕様(実装サマリー「CLI 後方互換」): + eval_baseline.yaml (OETF on) は動作変更なし + """ + luminance = _make_luminance_array(seed=310) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + # eval_baseline.yaml 相当(OETF on, normalization なし) + content = """\ +roi: + type: image_pixel + x: 32 + y: 32 + width: 192 + height: 192 +oetf: + enabled: true + transfer: srgb + exposure: + mode: anchor_mean + target_mean: 146.7146 + quantize_uint8: true +""" + config_path = tmp_path / "eval_oetf_only.yaml" + config_path.write_text(content, encoding="utf-8") + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + assert (output_dir / "metrics.csv").exists() + + +# --------------------------------------------------------------------------- +# 6. 異常系 — OETF + normalization 同時有効化で main() が 1 を返すこと +# --------------------------------------------------------------------------- + + +def test_oetf_and_normalization_both_enabled_returns_1(tmp_path: Path): + """oetf.enabled=true かつ normalization.enabled=true のとき main() が 1 を返すこと. + + 仕様(実装サマリー「スキーマ排他性」): + oetf.enabled=True かつ normalization.enabled=True は ValidationError + → cli/evaluate.py は return 1 + """ + luminance = _make_luminance_array(seed=311) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + content = """\ +roi: + type: image_pixel + x: 32 + y: 32 + width: 192 + height: 192 +oetf: + enabled: true + transfer: srgb + exposure: + mode: anchor_mean + target_mean: 146.7146 + quantize_uint8: true +normalization: + enabled: true + type: mean_anchor + target_mean: 146.7146 +""" + config_path = tmp_path / "eval_both_enabled.yaml" + config_path.write_text(content, encoding="utf-8") + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 1 + + +def test_oetf_and_normalization_both_enabled_stderr_message(tmp_path: Path, capsys): + """oetf + normalization 同時有効化時にエラーメッセージが stderr に出ること.""" + luminance = _make_luminance_array(seed=312) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + content = """\ +roi: + type: image_pixel + x: 32 + y: 32 + width: 192 + height: 192 +oetf: + enabled: true + transfer: srgb + exposure: + mode: anchor_mean + target_mean: 146.7146 +normalization: + enabled: true + type: mean_anchor + target_mean: 146.7146 +""" + config_path = tmp_path / "eval_both_enabled.yaml" + config_path.write_text(content, encoding="utf-8") + output_dir = tmp_path / "output" + main(["--image", str(npy_path), "--config", str(config_path), "--output", str(output_dir)]) + + captured = capsys.readouterr() + assert "エラー" in captured.err + + +# --------------------------------------------------------------------------- +# 7. 統合テスト — output/baseline/luminance.npy + eval_baseline_linear.yaml +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif( + not BASELINE_LUMINANCE_NPY.exists(), + reason="output/baseline/luminance.npy が存在しない場合はスキップ", +) +def test_baseline_linear_yaml_returns_0(): + """output/baseline/luminance.npy + configs/eval_baseline_linear.yaml で main() が 0 を返すこと. + + 仕様(実装サマリー「動作確認済み(参考)」): + 入力: output/baseline/luminance.npy + 設定: configs/eval_baseline_linear.yaml(normalization.enabled=true, target_mean=146.7146) + 期待: 正常終了 + """ + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "output" + result = main( + [ + "--image", str(BASELINE_LUMINANCE_NPY), + "--config", str(EVAL_BASELINE_LINEAR_YAML), + "--output", str(output_dir), + ] + ) + assert result == 0 + + +@pytest.mark.skipif( + not BASELINE_LUMINANCE_NPY.exists(), + reason="output/baseline/luminance.npy が存在しない場合はスキップ", +) +def test_baseline_linear_yaml_mean_close_to_target(): + """eval_baseline_linear.yaml 実行後 metrics.csv の mean が target_mean に一致. + + 仕様(実装サマリー「動作確認済み(参考)」): + target_mean=146.7146 + 期待出力: mean=146.7146 + apply_mean_anchor の後 mean は target_mean に厳密一致する + """ + import tempfile + + import pandas + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "output" + main( + [ + "--image", str(BASELINE_LUMINANCE_NPY), + "--config", str(EVAL_BASELINE_LINEAR_YAML), + "--output", str(output_dir), + ] + ) + df = pandas.read_csv(output_dir / "metrics.csv") + mean_val = float(df["mean"].iloc[0]) + # apply_mean_anchor は厳密に mean = target_mean にする + assert mean_val == pytest.approx(146.7146, rel=1e-5) + + +@pytest.mark.skipif( + not BASELINE_LUMINANCE_NPY.exists(), + reason="output/baseline/luminance.npy が存在しない場合はスキップ", +) +def test_baseline_linear_yaml_cov_close_to_expected(): + """eval_baseline_linear.yaml 実行後,metrics.csv の CoV が仕様値 0.182577 に近いこと. + + 仕様(実装サマリー「動作確認済み(参考)」): + 期待出力: CoV=0.182577 + 形状指標は linear ゲインに不変なので normalization 前後で同一 + """ + import tempfile + + import pandas + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "output" + main( + [ + "--image", str(BASELINE_LUMINANCE_NPY), + "--config", str(EVAL_BASELINE_LINEAR_YAML), + "--output", str(output_dir), + ] + ) + df = pandas.read_csv(output_dir / "metrics.csv") + cov_val = float(df["cov"].iloc[0]) + # 仕様値 CoV=0.182577 に ±0.005 程度の許容範囲で一致すること + assert cov_val == pytest.approx(0.182577, abs=0.005) + + +@pytest.mark.skipif( + not BASELINE_LUMINANCE_NPY.exists(), + reason="output/baseline/luminance.npy が存在しない場合はスキップ", +) +def test_baseline_linear_yaml_max_min_close_to_expected(): + """eval_baseline_linear.yaml 実行後,metrics.csv の max/min 比が仕様値 4.2759 に近いこと. + + 仕様(実装サマリー「動作確認済み(参考)」): + 期待出力: max/min=4.2759 + """ + import tempfile + + import pandas + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "output" + main( + [ + "--image", str(BASELINE_LUMINANCE_NPY), + "--config", str(EVAL_BASELINE_LINEAR_YAML), + "--output", str(output_dir), + ] + ) + df = pandas.read_csv(output_dir / "metrics.csv") + ratio_val = float(df["max_min_ratio"].iloc[0]) + # 仕様値 max/min=4.2759 に ±0.05 の許容範囲 + assert ratio_val == pytest.approx(4.2759, abs=0.05) + + +@pytest.mark.skipif( + not BASELINE_LUMINANCE_NPY.exists(), + reason="output/baseline/luminance.npy が存在しない場合はスキップ", +) +def test_baseline_linear_yaml_cp_ratio_close_to_expected(): + """eval_baseline_linear.yaml 実行後,metrics.csv の C/P 比が仕様値 1.3271 に近いこと. + + 仕様(実装サマリー「動作確認済み(参考)」): + 期待出力: C/P=1.3271 + """ + import tempfile + + import pandas + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "output" + main( + [ + "--image", str(BASELINE_LUMINANCE_NPY), + "--config", str(EVAL_BASELINE_LINEAR_YAML), + "--output", str(output_dir), + ] + ) + df = pandas.read_csv(output_dir / "metrics.csv") + cp_val = float(df["center_periphery_ratio"].iloc[0]) + # 仕様値 C/P=1.3271 に ±0.02 の許容範囲 + assert cp_val == pytest.approx(1.3271, abs=0.02) + + +@pytest.mark.skipif( + not BASELINE_LUMINANCE_NPY.exists(), + reason="output/baseline/luminance.npy が存在しない場合はスキップ", +) +def test_baseline_linear_yaml_gradient_close_to_expected(): + """eval_baseline_linear.yaml 実行後,metrics.csv の勾配が仕様値 24.65 に近いこと. + + 仕様(実装サマリー「動作確認済み(参考)」): + 期待出力: 勾配=24.65 + """ + import tempfile + + import pandas + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "output" + main( + [ + "--image", str(BASELINE_LUMINANCE_NPY), + "--config", str(EVAL_BASELINE_LINEAR_YAML), + "--output", str(output_dir), + ] + ) + df = pandas.read_csv(output_dir / "metrics.csv") + grad_val = float(df["gradient_magnitude"].iloc[0]) + # 仕様値 勾配=24.65 に ±0.5 の許容範囲 + assert grad_val == pytest.approx(24.65, abs=0.5) diff --git a/tests/evaluation/test_normalize.py b/tests/evaluation/test_normalize.py new file mode 100644 index 0000000..b618427 --- /dev/null +++ b/tests/evaluation/test_normalize.py @@ -0,0 +1,356 @@ +"""evaluation/normalize.py の仕様ベーステスト. + +仕様出典: +- 実装サマリー(apply_mean_anchor 関数仕様) +- docs/04_SPEC/SPEC_03_基準設計の物理構成.md(「形状指標への影響(重要)」節) +- docs/04_SPEC/SPEC_04_評価仕様.md(評価パイプライン・メトリクス定義) +- docs/01_GUIDE/GUIDE_08_テスト方針.md(テスト方針) + +テスト対象: + apply_mean_anchor(values, target_mean) -> (normalized, alpha) + +テスト方針: + - 既知の入力・出力で数値精度を検証する(実装詳細でなく振る舞いを検証) + - ファイル I/O はモックせず実 ndarray を使う (GUIDE_08) + - 乱数を使う場合は seed を必ず固定する (GUIDE_08) +""" + +from __future__ import annotations + +import numpy +import numpy.testing +import pytest + +from sm_tias_lightsim.evaluation.normalize import apply_mean_anchor + +# --------------------------------------------------------------------------- +# 1. 正常系 — mean が target_mean に一致すること +# --------------------------------------------------------------------------- + + +def test_mean_equals_target_mean_uniform_array(): + """一様配列に apply_mean_anchor を適用すると mean が target_mean に厳密一致すること. + + 仕様: apply_mean_anchor: 出力の mean は target_mean に厳密一致(浮動小数点誤差を許容) + """ + values = numpy.ones((10, 10), dtype=numpy.float64) * 50.0 + target_mean = 100.0 + normalized, _ = apply_mean_anchor(values, target_mean) + assert numpy.mean(normalized) == pytest.approx(target_mean, rel=1e-10) + + +def test_mean_equals_target_mean_random_array(): + """乱数配列に apply_mean_anchor を適用すると mean が target_mean に一致すること. + + 仕様: apply_mean_anchor: α = target_mean / values.mean() を全要素に乗算 + → normalized.mean() == target_mean + """ + rng = numpy.random.RandomState(42) + values = rng.uniform(1.0, 100.0, (50, 50)) + target_mean = 146.7146 + normalized, _ = apply_mean_anchor(values, target_mean) + assert numpy.mean(normalized) == pytest.approx(target_mean, rel=1e-10) + + +def test_mean_equals_target_mean_1d_array(): + """1D 配列でも mean が target_mean に一致すること(任意形状サポート). + + 仕様: apply_mean_anchor: 入力 numpy.ndarray(任意形状・任意次元) + """ + values = numpy.array([10.0, 20.0, 30.0, 40.0, 50.0]) + target_mean = 75.0 + normalized, _ = apply_mean_anchor(values, target_mean) + assert numpy.mean(normalized) == pytest.approx(target_mean, rel=1e-10) + + +# --------------------------------------------------------------------------- +# 2. alpha 値の正確性 +# --------------------------------------------------------------------------- + + +def test_alpha_equals_target_mean_over_src_mean(): + """返却される alpha が target_mean / values.mean() に一致すること. + + 仕様: apply_mean_anchor: alpha = target_mean / values.mean() + """ + values = numpy.ones((5, 5), dtype=numpy.float64) * 30.0 + target_mean = 90.0 + _, alpha = apply_mean_anchor(values, target_mean) + expected_alpha = target_mean / numpy.mean(values) + assert alpha == pytest.approx(expected_alpha, rel=1e-12) + + +def test_alpha_less_than_one_when_src_mean_larger(): + """src_mean > target_mean のとき alpha < 1 になること(スケールダウン). + + 仕様: apply_mean_anchor: α = target_mean / src_mean + src_mean > target_mean → α < 1 + """ + values = numpy.ones((10,)) * 200.0 + target_mean = 100.0 + _, alpha = apply_mean_anchor(values, target_mean) + assert alpha < 1.0 + + +def test_alpha_greater_than_one_when_src_mean_smaller(): + """src_mean < target_mean のとき alpha > 1 になること(スケールアップ). + + 仕様: apply_mean_anchor: α = target_mean / src_mean + src_mean < target_mean → α > 1 + """ + values = numpy.ones((10,)) * 50.0 + target_mean = 200.0 + _, alpha = apply_mean_anchor(values, target_mean) + assert alpha > 1.0 + + +def test_alpha_equals_one_when_src_mean_equals_target(): + """src_mean == target_mean のとき alpha == 1.0 になること. + + 仕様: α = target_mean / values.mean() + values.mean() == target_mean → α = 1.0 + """ + values = numpy.ones((10,)) * 100.0 + target_mean = 100.0 + normalized, alpha = apply_mean_anchor(values, target_mean) + assert alpha == pytest.approx(1.0, rel=1e-12) + numpy.testing.assert_array_almost_equal(normalized, values) + + +def test_alpha_matches_documented_example(): + """実装サマリーの既知値(α=0.961934)に近いことを確認する. + + 仕様(実装サマリー「動作確認済み(参考)」): + 入力: output/baseline/luminance.npy + α = 0.961934 + NOTE: luminance.npy の実際の値は不明なため, + 同等の src_mean から alpha を計算して検証する(alpha の計算式テスト). + 仕様の target_mean=146.7146, α=0.961934 から逆算すると + src_mean = 146.7146 / 0.961934 ≈ 152.52 + """ + src_mean = 146.7146 / 0.961934 # ≈ 152.52 + values = numpy.ones((10, 10), dtype=numpy.float64) * src_mean + target_mean = 146.7146 + _, alpha = apply_mean_anchor(values, target_mean) + assert alpha == pytest.approx(0.961934, rel=1e-5) + + +# --------------------------------------------------------------------------- +# 3. 線形ゲインの不変性(形状指標 CoV / max-min / C/P 比) +# --------------------------------------------------------------------------- + + +def test_cov_is_invariant_to_mean_anchor(): + """apply_mean_anchor の適用後に CoV(std/mean)が不変であること. + + 仕様(SPEC_03「形状指標への影響(重要)」,実装サマリー「線形ゲインの不変性」): + 線形ゲインは形状指標(CoV = std/mean)に厳密に不変 + 数学的根拠: (α·std) / (α·mean) = std / mean + """ + rng = numpy.random.RandomState(0) + values = rng.uniform(50.0, 150.0, (100, 100)) + target_mean = 200.0 + + cov_before = numpy.std(values) / numpy.mean(values) + normalized, _ = apply_mean_anchor(values, target_mean) + cov_after = numpy.std(normalized) / numpy.mean(normalized) + + assert cov_after == pytest.approx(cov_before, rel=1e-10) + + +def test_max_min_ratio_is_invariant_to_mean_anchor(): + """apply_mean_anchor の適用後に max/min 比が不変であること. + + 仕様(SPEC_03「形状指標への影響(重要)」,実装サマリー「線形ゲインの不変性」): + 線形ゲインは max/min 比に不変 + 数学的根拠: (α·max) / (α·min) = max / min + """ + rng = numpy.random.RandomState(1) + values = rng.uniform(10.0, 200.0, (50, 50)) # min > 0 を保証 + target_mean = 80.0 + + ratio_before = numpy.max(values) / numpy.min(values) + normalized, _ = apply_mean_anchor(values, target_mean) + ratio_after = numpy.max(normalized) / numpy.min(normalized) + + assert ratio_after == pytest.approx(ratio_before, rel=1e-10) + + +def test_center_periphery_ratio_is_invariant_to_mean_anchor(): + """apply_mean_anchor の適用後に中心/周辺比が不変であること. + + 仕様(SPEC_03「形状指標への影響(重要)」,実装サマリー「線形ゲインの不変性」): + 線形ゲインは C/P 比に不変 + 数学的根拠: (α·center_mean) / (α·periphery_mean) = center_mean / periphery_mean + """ + rng = numpy.random.RandomState(2) + # 中心と周辺で異なる輝度を持つ配列を合成する + arr = rng.uniform(50.0, 100.0, (60, 60)) + center = arr[20:40, 20:40] + periphery = numpy.concatenate([arr[:20, :].ravel(), arr[40:, :].ravel()]) + + cp_before = numpy.mean(center) / numpy.mean(periphery) + normalized, _ = apply_mean_anchor(arr, 120.0) + center_norm = normalized[20:40, 20:40] + periphery_norm = numpy.concatenate([normalized[:20, :].ravel(), normalized[40:, :].ravel()]) + cp_after = numpy.mean(center_norm) / numpy.mean(periphery_norm) + + assert cp_after == pytest.approx(cp_before, rel=1e-10) + + +def test_gradient_magnitude_is_invariant_to_mean_anchor(): + """apply_mean_anchor の適用後に勾配((center-periphery)/center * 100)が不変であること. + + 仕様(SPEC_03「形状指標への影響(重要)」,実装サマリー「線形ゲインの不変性」): + 線形ゲインは勾配 % に不変 + 数学的根拠: (α·c - α·p) / (α·c) * 100 = (c - p) / c * 100 + """ + rng = numpy.random.RandomState(3) + arr = rng.uniform(50.0, 100.0, (60, 60)) + center = arr[20:40, 20:40] + periphery = numpy.concatenate([arr[:20, :].ravel(), arr[40:, :].ravel()]) + + c_mean = numpy.mean(center) + p_mean = numpy.mean(periphery) + gradient_before = (c_mean - p_mean) / c_mean * 100.0 + + normalized, _ = apply_mean_anchor(arr, 200.0) + center_norm = normalized[20:40, 20:40] + periphery_norm = numpy.concatenate([normalized[:20, :].ravel(), normalized[40:, :].ravel()]) + c_mean_norm = numpy.mean(center_norm) + p_mean_norm = numpy.mean(periphery_norm) + gradient_after = (c_mean_norm - p_mean_norm) / c_mean_norm * 100.0 + + assert gradient_after == pytest.approx(gradient_before, rel=1e-10) + + +# --------------------------------------------------------------------------- +# 4. 形状保持(入力 shape == 出力 shape) +# --------------------------------------------------------------------------- + + +def test_output_shape_matches_input_shape_2d(): + """2D 入力の shape が出力でも保持されること. + + 仕様: apply_mean_anchor: 入力 numpy.ndarray(任意形状・任意次元) + 出力 normalized は values と同形状 + """ + values = numpy.ones((100, 120), dtype=numpy.float64) * 50.0 + normalized, _ = apply_mean_anchor(values, 100.0) + assert normalized.shape == (100, 120) + + +def test_output_shape_matches_input_shape_1d(): + """1D 入力の shape が出力でも保持されること.""" + values = numpy.ones((500,), dtype=numpy.float64) * 30.0 + normalized, _ = apply_mean_anchor(values, 60.0) + assert normalized.shape == (500,) + + +def test_output_shape_matches_input_shape_3d(): + """3D 入力の shape が出力でも保持されること(任意次元サポート).""" + values = numpy.ones((10, 20, 3), dtype=numpy.float64) * 50.0 + normalized, _ = apply_mean_anchor(values, 100.0) + assert normalized.shape == (10, 20, 3) + + +# --------------------------------------------------------------------------- +# 5. dtype 保持 +# --------------------------------------------------------------------------- + + +def test_dtype_preserved_float64(): + """float64 入力の dtype が出力でも保持されること. + + 仕様: apply_mean_anchor: 出力 dtype は values と同じ + """ + values = numpy.ones((10, 10), dtype=numpy.float64) * 50.0 + normalized, _ = apply_mean_anchor(values, 100.0) + assert normalized.dtype == numpy.float64 + + +def test_dtype_preserved_float32(): + """float32 入力の dtype が出力でも保持されること. + + 仕様: apply_mean_anchor: 出力 dtype は values と同じ + """ + values = numpy.ones((10, 10), dtype=numpy.float32) * 50.0 + normalized, _ = apply_mean_anchor(values, 100.0) + assert normalized.dtype == numpy.float32 + + +def test_dtype_float_int_input_casts_correctly(): + """整数配列を渡したとき,numpy の演算ルールで正しく動作すること. + + 仕様: apply_mean_anchor は任意の numpy 数値配列を受け付ける + NOTE: int 配列 * float alpha は numpy がキャストして float64 になる + """ + values = numpy.array([10, 20, 30, 40, 50], dtype=numpy.int32) + target_mean = 60.0 + normalized, alpha = apply_mean_anchor(values, target_mean) + assert numpy.mean(normalized) == pytest.approx(target_mean, rel=1e-10) + + +# --------------------------------------------------------------------------- +# 6. エラー: values.mean() == 0 で ValueError +# --------------------------------------------------------------------------- + + +def test_zero_mean_raises_value_error(): + """values の mean が 0 のとき ValueError が発生すること. + + 仕様: apply_mean_anchor: values.mean() == 0 の場合は ValueError + """ + values = numpy.zeros((10, 10), dtype=numpy.float64) + with pytest.raises(ValueError, match="mean"): + apply_mean_anchor(values, 100.0) + + +def test_all_negative_sum_to_zero_raises_value_error(): + """[-1, 0, 1] のように mean が 0 になる配列でも ValueError が発生すること. + + 仕様: values.mean() == 0 の場合は ValueError + """ + values = numpy.array([-1.0, 0.0, 1.0]) + with pytest.raises(ValueError): + apply_mean_anchor(values, 50.0) + + +# --------------------------------------------------------------------------- +# 7. 返却値の型チェック(tuple[ndarray, float]) +# --------------------------------------------------------------------------- + + +def test_return_type_is_tuple_of_ndarray_and_float(): + """戻り値が (numpy.ndarray, float) のタプルであること. + + 仕様: apply_mean_anchor: 出力 (normalized_array, alpha) のタプル + """ + values = numpy.ones((10,), dtype=numpy.float64) * 50.0 + result = apply_mean_anchor(values, 100.0) + assert isinstance(result, tuple) + assert len(result) == 2 + normalized, alpha = result + assert isinstance(normalized, numpy.ndarray) + assert isinstance(alpha, float) + + +# --------------------------------------------------------------------------- +# 8. 連続適用の冪等性(同じ target_mean で 2 回適用すると 2 回目は変化なし) +# --------------------------------------------------------------------------- + + +def test_idempotent_when_applied_twice(): + """target_mean に揃えた配列に再度同じ target_mean で適用すると変化がないこと. + + 仕様: 正規化後 mean == target_mean → 2 回目の alpha == 1.0 + """ + rng = numpy.random.RandomState(99) + values = rng.uniform(10.0, 100.0, (30, 30)) + target_mean = 80.0 + + normalized_once, _ = apply_mean_anchor(values, target_mean) + normalized_twice, alpha2 = apply_mean_anchor(normalized_once, target_mean) + + assert alpha2 == pytest.approx(1.0, rel=1e-10) + numpy.testing.assert_array_almost_equal(normalized_twice, normalized_once) diff --git a/tests/scene/test_normalization_schema.py b/tests/scene/test_normalization_schema.py new file mode 100644 index 0000000..542d5e1 --- /dev/null +++ b/tests/scene/test_normalization_schema.py @@ -0,0 +1,384 @@ +# pyright: reportArgumentType=none, reportCallIssue=none +# Pydantic v2 の ValidationError は pydantic_core 由来で,Pylance/Pyright が +# BaseException のサブクラスと推論しないため pytest.raises(ValidationError) を +# 誤検知する.また「必須フィールドを意図的に欠落させる」テスト(line 78 等)でも +# reportCallIssue が誤発火するため,ファイル単位で抑止する(builder.py と同じ方針). +"""scene/schema.py の NormalizationConfig / EvaluationConfig.normalization 仕様ベーステスト. + +仕様出典: +- 実装サマリー(NormalizationConfig クラス,EvaluationConfig.normalization フィールド, + OETF + normalization 同時有効化禁止 model_validator) +- docs/04_SPEC/SPEC_04_評価仕様.md(評価パイプライン) +- docs/01_GUIDE/GUIDE_08_テスト方針.md(テスト方針) +- src/sm_tias_lightsim/scene/schema.py(NormalizationConfig, EvaluationConfig 定義) + +テスト方針: + - Pydantic モデルを直接構築して振る舞いを検証する(YAML パースは不要) + - ファイル I/O はモックしない (GUIDE_08) + - 後方互換性(normalization キーなし = 正規化無効)を重点的に検証する +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from sm_tias_lightsim.scene.schema import EvaluationConfig, NormalizationConfig + +# --------------------------------------------------------------------------- +# 共通ヘルパー +# --------------------------------------------------------------------------- + + +def _make_minimal_eval_data(**overrides: object) -> dict: + """最小構成の EvaluationConfig データを返す.""" + data: dict = { + "roi": {"type": "image_pixel", "x": 0, "y": 0, "width": 100, "height": 100}, + } + data.update(overrides) + return data + + +# --------------------------------------------------------------------------- +# 1. NormalizationConfig — フィールド検証 +# --------------------------------------------------------------------------- + + +def test_normalization_config_valid_mean_anchor(): + """type=mean_anchor + target_mean で正常にパースされること. + + 仕様: NormalizationConfig: type=mean_anchor(Literal),target_mean が必須(schema.py) + """ + cfg = NormalizationConfig(enabled=True, type="mean_anchor", target_mean=146.7146) + assert cfg.enabled is True + assert cfg.type == "mean_anchor" + assert cfg.target_mean == pytest.approx(146.7146) + + +def test_normalization_config_defaults(): + """NormalizationConfig のデフォルト値を確認する. + + 仕様: + - enabled: bool = False(省略時は正規化無効) + - type: Literal["mean_anchor"] = "mean_anchor"(デフォルト) + (schema.py NormalizationConfig) + """ + cfg = NormalizationConfig(target_mean=100.0) + assert cfg.enabled is False + assert cfg.type == "mean_anchor" + + +def test_normalization_config_missing_target_mean_raises(): + """target_mean を省略したとき ValidationError が発生すること. + + 仕様: target_mean: float = Field(..., ...) — 必須フィールド(schema.py) + """ + with pytest.raises(ValidationError): + NormalizationConfig(enabled=True) + + +def test_normalization_config_invalid_type_raises(): + """type に未対応の値(例: max_anchor)で ValidationError が発生すること. + + 仕様: type: Literal["mean_anchor"] — mean_anchor のみ(schema.py) + """ + with pytest.raises(ValidationError): + NormalizationConfig(type="max_anchor", target_mean=100.0) # type: ignore[call-arg] + + +def test_normalization_config_is_frozen(): + """NormalizationConfig が frozen(イミュータブル)であること. + + 仕様: _FrozenBase → frozen=True(schema.py ConfigDict) + """ + cfg = NormalizationConfig(target_mean=100.0) + with pytest.raises(ValidationError): + cfg.enabled = True # type: ignore[misc] + + +def test_normalization_config_target_mean_various_values(): + """target_mean に任意の float 値が受け付けられること. + + 仕様: target_mean: float — 任意 float 可(schema.py NormalizationConfig) + """ + # uint8 スケールの実測 mean 相当 + cfg1 = NormalizationConfig(target_mean=146.7146) + assert cfg1.target_mean == pytest.approx(146.7146) + + # 0 以外の任意 float + cfg2 = NormalizationConfig(target_mean=255.0) + assert cfg2.target_mean == pytest.approx(255.0) + + cfg3 = NormalizationConfig(target_mean=1.0) + assert cfg3.target_mean == pytest.approx(1.0) + + +# --------------------------------------------------------------------------- +# 2. EvaluationConfig.normalization — 後方互換性 +# --------------------------------------------------------------------------- + + +def test_evaluation_config_normalization_none_when_omitted(): + """normalization キーを省略したとき EvaluationConfig.normalization が None になること. + + 仕様: normalization: NormalizationConfig | None = Field(default=None, ...) + 省略時は正規化無効(schema.py EvaluationConfig) + """ + data = _make_minimal_eval_data() + cfg = EvaluationConfig.model_validate(data) + assert cfg.normalization is None + + +def test_evaluation_config_normalization_none_means_disabled(): + """normalization=None のとき正規化が適用されないこと(enabled=False 相当を確認). + + 仕様: 省略時は正規化無効扱い(schema.py / cli/evaluate.py) + """ + data = _make_minimal_eval_data(normalization=None) + cfg = EvaluationConfig.model_validate(data) + assert cfg.normalization is None + # cli/evaluate.py では `if norm_cfg is not None and norm_cfg.enabled:` でスキップ + norm_cfg = cfg.normalization + is_applied = norm_cfg is not None and norm_cfg.enabled + assert is_applied is False + + +def test_evaluation_config_normalization_disabled_explicit(): + """normalization.enabled=False を明示したとき正規化が無効であること. + + 仕様: enabled: bool = False(省略時デフォルト)(schema.py) + """ + data = _make_minimal_eval_data( + normalization={"enabled": False, "target_mean": 100.0} + ) + cfg = EvaluationConfig.model_validate(data) + assert cfg.normalization is not None + assert cfg.normalization.enabled is False + is_applied = cfg.normalization is not None and cfg.normalization.enabled + assert is_applied is False + + +def test_evaluation_config_normalization_enabled(): + """normalization.enabled=True で正常にパースされること. + + 仕様: eval_baseline_linear.yaml の normalization セクション相当 + """ + data = _make_minimal_eval_data( + normalization={ + "enabled": True, + "type": "mean_anchor", + "target_mean": 146.7146, + } + ) + cfg = EvaluationConfig.model_validate(data) + assert cfg.normalization is not None + assert cfg.normalization.enabled is True + assert cfg.normalization.type == "mean_anchor" + assert cfg.normalization.target_mean == pytest.approx(146.7146) + + +def test_evaluation_config_backward_compat_real_yaml(): + """既存の eval_real.yaml 相当(normalization キーなし)が後方互換で動くこと. + + 仕様: normalization フィールドを省略した既存 YAML も後方互換(正規化無効扱い)で動作する + (schema.py EvaluationConfig docstring) + """ + data = { + "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}, + } + cfg = EvaluationConfig.model_validate(data) + assert cfg.normalization is None + assert cfg.oetf is None + + +def test_evaluation_config_normalization_invalid_type_raises(): + """normalization.type に未対応の値を指定したとき ValidationError が発生すること. + + 仕様: type: Literal["mean_anchor"](schema.py NormalizationConfig) + """ + data = _make_minimal_eval_data( + normalization={"enabled": True, "type": "max_anchor", "target_mean": 100.0} + ) + with pytest.raises(ValidationError): + EvaluationConfig.model_validate(data) + + +def test_evaluation_config_normalization_missing_target_mean_raises(): + """normalization.target_mean を省略したとき ValidationError が発生すること. + + 仕様: target_mean: float = Field(...) — 必須(schema.py NormalizationConfig) + """ + data = _make_minimal_eval_data( + normalization={"enabled": True, "type": "mean_anchor"} + ) + with pytest.raises(ValidationError): + EvaluationConfig.model_validate(data) + + +# --------------------------------------------------------------------------- +# 3. OETF + normalization 同時有効化の排他制御 +# --------------------------------------------------------------------------- + + +def test_oetf_and_normalization_both_enabled_raises(): + """oetf.enabled=True かつ normalization.enabled=True のとき ValidationError が発生すること. + + 仕様(実装サマリー「スキーマ排他性」,schema.py EvaluationConfig model_validator): + oetf.enabled=True かつ normalization.enabled=True は ValidationError + (OETF が内部で露出アンカーを持つため,二重アンカーになるのを防ぐ) + """ + data = _make_minimal_eval_data( + oetf={ + "enabled": True, + "transfer": "srgb", + "exposure": {"mode": "anchor_mean", "target_mean": 146.7146}, + }, + normalization={ + "enabled": True, + "type": "mean_anchor", + "target_mean": 146.7146, + }, + ) + with pytest.raises(ValidationError): + EvaluationConfig.model_validate(data) + + +def test_oetf_enabled_normalization_disabled_is_valid(): + """oetf.enabled=True かつ normalization.enabled=False は有効であること. + + 仕様: 片方だけ有効化は OK(スキーマ排他性は両方 True のみ禁止) + """ + data = _make_minimal_eval_data( + oetf={ + "enabled": True, + "transfer": "srgb", + "exposure": {"mode": "anchor_mean", "target_mean": 146.7146}, + }, + normalization={ + "enabled": False, + "type": "mean_anchor", + "target_mean": 146.7146, + }, + ) + cfg = EvaluationConfig.model_validate(data) + assert cfg.oetf is not None + assert cfg.oetf.enabled is True + assert cfg.normalization is not None + assert cfg.normalization.enabled is False + + +def test_oetf_disabled_normalization_enabled_is_valid(): + """oetf.enabled=False かつ normalization.enabled=True は有効であること. + + 仕様: 片方だけ有効化は OK + """ + data = _make_minimal_eval_data( + oetf={"enabled": False}, + normalization={ + "enabled": True, + "type": "mean_anchor", + "target_mean": 146.7146, + }, + ) + cfg = EvaluationConfig.model_validate(data) + assert cfg.oetf is not None + assert cfg.oetf.enabled is False + assert cfg.normalization is not None + assert cfg.normalization.enabled is True + + +def test_both_disabled_is_valid(): + """oetf.enabled=False かつ normalization.enabled=False は有効であること(無変換). + + 仕様: 両方 None または両方 disabled は OK(無変換) + """ + data = _make_minimal_eval_data( + oetf={"enabled": False}, + normalization={"enabled": False, "target_mean": 100.0}, + ) + cfg = EvaluationConfig.model_validate(data) + assert cfg.oetf is not None + assert cfg.oetf.enabled is False + assert cfg.normalization is not None + assert cfg.normalization.enabled is False + + +def test_both_none_is_valid(): + """oetf=None かつ normalization=None は有効であること(既存 YAML 後方互換). + + 仕様: 両方 None は OK + """ + data = _make_minimal_eval_data() + cfg = EvaluationConfig.model_validate(data) + assert cfg.oetf is None + assert cfg.normalization is None + + +def test_oetf_none_normalization_enabled_is_valid(): + """oetf=None(省略)かつ normalization.enabled=True は有効であること. + + 仕様: 片方だけ有効化は OK(None は disabled 扱い) + """ + data = _make_minimal_eval_data( + normalization={ + "enabled": True, + "type": "mean_anchor", + "target_mean": 100.0, + } + ) + cfg = EvaluationConfig.model_validate(data) + assert cfg.oetf is None + assert cfg.normalization is not None + assert cfg.normalization.enabled is True + + +def test_oetf_enabled_normalization_none_is_valid(): + """oetf.enabled=True かつ normalization=None(省略)は有効であること. + + 仕様: 片方だけ有効化は OK + """ + data = _make_minimal_eval_data( + oetf={ + "enabled": True, + "transfer": "srgb", + "exposure": {"mode": "anchor_mean", "target_mean": 146.7146}, + } + ) + cfg = EvaluationConfig.model_validate(data) + assert cfg.oetf is not None + assert cfg.oetf.enabled is True + assert cfg.normalization is None + + +# --------------------------------------------------------------------------- +# 4. eval_baseline_linear.yaml 相当の YAML ロード検証 +# --------------------------------------------------------------------------- + + +def test_eval_baseline_linear_yaml_equivalent_parses_correctly(): + """eval_baseline_linear.yaml 相当のデータが正しくパースされること. + + 仕様: configs/eval_baseline_linear.yaml の normalization セクション相当 + """ + data = { + "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, + }, + } + cfg = EvaluationConfig.model_validate(data) + assert cfg.normalization is not None + assert cfg.normalization.enabled is True + assert cfg.normalization.type == "mean_anchor" + assert cfg.normalization.target_mean == pytest.approx(146.7146) + assert cfg.oetf is None + assert cfg.roi.x == 24 + assert cfg.roi.y == 37 + assert cfg.roi.width == 464 + assert cfg.roi.height == 450