diff --git a/CLAUDE.md b/CLAUDE.md index f13ee19..86bc9cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,14 +6,15 @@ ## 開発進捗 現在の進捗: フェーズ 7(実装)— Step 1〜7 完了,Step 8(基準設計キャリブレーション)進行中. -Step 8 進捗: spp 校正(128→1024、ノイズ/物理差を切り分け)→ 反射率スイープ(0.75〜0.92、弱い二次レバーと判明)→ **手法訂正**: シミュ linear と実測 sRGB を直接突合していた交絡を発見.標準 sRGB OETF 順方向適用+実測 mean アンカーで見かけ差の **約 2/3 が符号化ドメイン不一致**だったと判明.寄与順は OETF(最大)> ISP レンズシェーディング(残差主因候補)> 反射率(小).残差 1.6〜1.9 倍は調整せず正直に保持.副次: max_depth=8 は勾配を過大評価(キャリブは depth≥12).[SPEC_03「キャリブレーション経過」参照](docs/04_SPEC/SPEC_03_基準設計の物理構成.md). -次のアクション: ① OETF 順方向適用を sm-evaluate へ正規実装(`/implement`、現状 throwaway スクリプト).② 残差に LSC 順方向モデル(物理動機づけ低自由度形)を当てる. +Step 8 進捗: spp 校正(128→1024、ノイズ/物理差を切り分け)→ 反射率スイープ(0.75〜0.92、弱い二次レバーと判明)→ **手法訂正**: シミュ linear と実測 sRGB を直接突合していた交絡を発見.標準 sRGB OETF 順方向適用+実測 mean アンカーで見かけ差の **約 2/3 が符号化ドメイン不一致**だったと判明.寄与順は OETF(最大)> ISP レンズシェーディング(残差主因候補)> 反射率(小).残差 1.6〜1.9 倍は調整せず正直に保持.副次: max_depth=8 は勾配を過大評価(キャリブは depth≥12).→ **OETF 順方向適用を sm-evaluate へ正規実装完了**(`evaluation/oetf.py` + `EvaluationConfig.oetf`、baseline 有効化・real 非適用、後方互換、参照実装と数値一致テスト済み).[SPEC_03「キャリブレーション経過」参照](docs/04_SPEC/SPEC_03_基準設計の物理構成.md). +次のアクション: 残差に **LSC(ISP レンズシェーディング)順方向モデル**を当てる(物理動機づけ低自由度形、overfitting 回避・取得パラメータ数明記). 実装済みモジュール: - `src/sm_tias_lightsim/uniformity.py` — Rec.709 輝度変換 + CoV/std/max-min(MiniTias-Evaluation コピー) - `src/sm_tias_lightsim/evaluation/zones.py` — 正規化楕円距離マップ + 3 ゾーン分類 + ゾーン別統計 - `src/sm_tias_lightsim/evaluation/radial.py` — 20 ビン放射状輝度プロファイル - `src/sm_tias_lightsim/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/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` データクラス @@ -25,7 +26,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/) +- `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/visualize/baker.py` — 各 face 中心の直接照度ベイク(shadow ray + cosine + 距離減衰) - `src/sm_tias_lightsim/visualize/viewer.py` — PyVista 3D ビューア(メッシュ着色 + LED + センサ + 座標軸) - `src/sm_tias_lightsim/cli/visualize.py` — `sm-visualize` CLI(YAML → 3D 表示 / screenshot) diff --git a/configs/eval_baseline.yaml b/configs/eval_baseline.yaml index 5f2b4b1..396ca07 100644 --- a/configs/eval_baseline.yaml +++ b/configs/eval_baseline.yaml @@ -14,3 +14,13 @@ std: true max_min: true radial_profile: true +# OETF 順方向適用(シミュ linear → sRGB ガンマ空間へ標準変換). +# eval_real.yaml(実測 PNG)には絶対に oetf を追加しないこと: +# 実測 PNG は ISP により既に sRGB 符号化済みのため,ここで OETF を掛けると二重適用になる. +oetf: + enabled: true + transfer: srgb # IEC 61966-2-1 固定(調整係数ゼロ) + exposure: + mode: anchor_mean + target_mean: 146.7146 # 実測 ROI mean(uint8 スケール): MiniTIAS_20260408_144434 + quantize_uint8: true # 実測 PNG と同様に uint8 量子化する diff --git a/configs/eval_real.yaml b/configs/eval_real.yaml index 53d1d39..e5de78c 100644 --- a/configs/eval_real.yaml +++ b/configs/eval_real.yaml @@ -1,3 +1,6 @@ +# 実測 PNG 用評価設定. +# 実測 PNG は実機 ISP により既に sRGB 符号化済みのため,oetf は設定しない(意図的). +# eval_baseline.yaml のシミュ側にのみ oetf を有効化することで非対称性が正しく成立する. roi: type: image_pixel x: 539 @@ -12,3 +15,4 @@ std: true max_min: true radial_profile: true +# oetf: ← 実測 PNG には絶対に設定しないこと(二重ガンマ適用になる) diff --git "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" index 82f3d7e..a292ad2 100644 --- "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" +++ "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" @@ -30,6 +30,25 @@ - Python API なので評価指標コードと同じスクリプトに統合できる - 色再現性の検証に拡張する際もスペクトル変種に切替えるだけで済む +### 既存ツールとの比較(なぜ自作 Mitsuba 3 か) + +本プロジェクトの本質は「きれいな絵を作る」ことではなく,**実機センサが吐く数値を物理的に再現し,実測との差の原因を切り分けられること**にある(MiniTIAS は白い筐体内壁の多重反射で照明を均一化する設計のため.[SPEC_03 参照](../04_SPEC/SPEC_03_基準設計の物理構成.md)).この性質が選定をほぼ決める. + +| 候補 | ライセンス費 | 物理量の直接取得 | 評価指標コードの実機共有 | キャリブの透明性(ISP/OETF 切分け) | 拡張性(スペクトル / 自動微分) | +| --- | --- | --- | --- | --- | --- | +| 商用光学・照明シミュレータ(Ansys Speos / LightTools / Zemax OpticStudio / TracePro 等) | ✕(年額・見積もりベース) | ◯ | ✕(内部がブラックボックス) | △(センサ ISP は通常モデル化外・内部不可視) | △(製品依存) | +| 汎用 OSS レンダラ(Blender Cycles / LuxCore 等) | ◯(無償) | △(露出・トーンマップが介在) | △(Python での指標一体運用がしにくい) | △ | △ | +| **Mitsuba 3 自作(本プロジェクト採用)** | ◯(無償) | ◯(放射照度・配光をそのまま出力) | ◯(`uniformity.py` を実機系とコード共有) | ◯(交絡を 1 つずつ自前モデル化・切分け可) | ◯(variant 切替で対応) | + +決め手(上記「採用理由」を補完する観点): + +- **コスト**: この種の商用照明シミュレータは買い切りでなく年額ライセンス・かつ見積もりベースで,1 シート年間数十万〜数百万円規模と一般に言われる水準.研究室での単一目的(照明均一性の事前検討)には過大投資.正確な金額はベンダー見積もり次第のため断定しないが,桁感としては妥当. +- **評価指標コードの実機共有(決定的)**: `uniformity.py` を MiniTias-Evaluation から直接コピーし(下記「[MiniTias-Evaluation コードの取り扱い](#minitias-evaluation-コードの取り扱い-reuse-policy)」),シミュと実測を**同一実装**で評価する.差が出れば「純粋に物理差」と断言できる.閉じた商用ツールでは内部の輝度変換・指標計算を実機系 OSS とビット一致させられず,この担保が構造的に不可能. +- **キャリブレーションの透明性**: sRGB OETF / ISP レンズシェーディング / spp / max_depth といった交絡を自前でモデル化し,一つずつ切り分けられる.実例として Step 8 では見かけ差の**約 2/3 が sRGB 符号化ドメイン不一致**だったと特定している([SPEC_03「キャリブレーション経過」§寄与順の訂正と残差](../04_SPEC/SPEC_03_基準設計の物理構成.md):寄与順は OETF(最大)> ISP レンズシェーディング > 反射率).中身が全て見える自作ならではの分析. +- **拡張性**: スペクトル変種(`llvm_ad_rgb` → spectral)への切替で色再現検証へ,自動微分(AD)で将来の逆設計(最適 LED 配置の勾配ベース探索)へ,同一基盤のまま拡張できる. + +自作の代償(公平のため明記): 物理正確性を自前で保証する責任が生じ,商用ツールの検証済みライブラリ・GUI・サポートは得られない.本プロジェクトは Step 7 で直接照度ベイクを解析解(`I·cosθ/r²`)と数 % 以内で照合し,Step 8 で実測校正を行うことでこれを担保している.残差 1.6〜1.9 倍はガンマ曲線で消さず原因(ISP レンズシェーディング等)まで含めて正直に保持しており,この透明性自体がブラックボックス商用ツールでは到達しにくい強みとなる. + ## 依存管理 (Dependency Management) | 項目 | 採用 | 備考 | diff --git "a/docs/03_PLAN/PLAN_01_\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" "b/docs/03_PLAN/PLAN_01_\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" index 669c293..ac7200c 100644 --- "a/docs/03_PLAN/PLAN_01_\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" +++ "b/docs/03_PLAN/PLAN_01_\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" @@ -70,11 +70,11 @@ 2. 光源配置・ディフューザー形状の改良案を複数作り,同じ指標で比較 3. 有望な改良案を Fusion 360 側に反映し,実機試作へ -## 未決事項 (Open Questions) +## 技術選定の決定事項 (Resolved Decisions) -以下はフェーズ 2(技術選定)以降で決定する: +下記はフェーズ 2(技術選定)〜フェーズ 7・Step 8(基準設計キャリブレーション)を通じて確定済み.選定論拠は [ENV_01「既存ツールとの比較(なぜ自作 Mitsuba 3 か)」](../02_ENV/ENV_01_技術スタック.md) に記載. -- **シミュレーション手法**: 光線追跡 / LED 配光 + 簡易モデル / モンテカルロ等(未定) -- **採用ライブラリ / ツール**: Python 自作,Mitsuba 3,LightTools 等(未定) -- **Fusion 360 からのジオメトリ受け渡し形式**: STEP / STL / 3MF(未定.手法依存) -- **LED の光学パラメータ取得元**: 使用 LED のデータシート有無(未定) +- **シミュレーション手法**: 決定 → **モンテカルロ光線追跡**(Mitsuba 3 の path integrator.多重反射計算が CoV 再現に必須のため) +- **採用ライブラリ / ツール**: 決定 → **Python 自作 + Mitsuba 3**.商用光学シミュレータ(LightTools 等)はライセンス費・指標コードを実機系と共有できない点・キャリブの内部不可視性から不採用([ENV_01 参照](../02_ENV/ENV_01_技術スタック.md)) +- **Fusion 360 からのジオメトリ受け渡し形式**: 決定 → **STL(バイナリ)**.mm→m 換算は実装側で対応([ENV_01「ジオメトリ受け渡し」参照](../02_ENV/ENV_01_技術スタック.md)) +- **LED の光学パラメータ取得元**: 一部未確定 → 現状は **Lambertian 配光を仮定**.使用 LED データシートに基づく IES プロファイルへの差替えは将来の拡張余地として残す([SPEC_03 参照](../04_SPEC/SPEC_03_基準設計の物理構成.md)) 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 b7ca23e..3bf3f40 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" @@ -153,10 +153,23 @@ std: true max_min: true radial_profile: true + oetf: # 任意.省略時 OETF 無効(linear のまま評価=後方互換) + enabled: true # シミュ linear 出力を実測と同じ sRGB 空間へ変換 + transfer: srgb # srgb のみ(IEC 61966-2-1) + exposure: + mode: anchor_mean # OETF 後 ROI mean を target_mean に対数二分で合わせる(実測 AE アンカー) + target_mean: 146.7146 # 実測 ROI mean(uint8 [0,255] スケール) + quantize_uint8: true # OETF 後を実測 PNG 同様 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 評価. + +**重要(非対称性)**: OETF はシミュ出力にのみ適用する.実測 PNG は既に sRGB 符号化済みのため、実測用評価設定(`configs/eval_real.yaml` 等)には `oetf` を**付けない**(二重適用は誤り). + ## バリデーション `scene/schema.py` で以下をチェック(Pydantic 想定): diff --git "a/docs/04_SPEC/SPEC_03_\345\237\272\346\272\226\350\250\255\350\250\210\343\201\256\347\211\251\347\220\206\346\247\213\346\210\220.md" "b/docs/04_SPEC/SPEC_03_\345\237\272\346\272\226\350\250\255\350\250\210\343\201\256\347\211\251\347\220\206\346\247\213\346\210\220.md" index d1a1691..b5e2b20 100644 --- "a/docs/04_SPEC/SPEC_03_\345\237\272\346\272\226\350\250\255\350\250\210\343\201\256\347\211\251\347\220\206\346\247\213\346\210\220.md" +++ "b/docs/04_SPEC/SPEC_03_\345\237\272\346\272\226\350\250\255\350\250\210\343\201\256\347\211\251\347\220\206\346\247\213\346\210\220.md" @@ -228,8 +228,8 @@ ### 次アクション -1. **OETF 順方向適用を sm-evaluate(または評価設定)へ正規実装**(`/implement`).以降の全シミュ vs 実測比較は OETF 後ドメインで行う(現状は throwaway 解析スクリプト `output/sweep_reflectance/apply_oetf.py`). -2. その後、残差に対し **LSC 順方向モデル**を当てる.overfitting 回避のため自由多項式ではなく物理動機づけ低自由度形(cos⁴ ベース等)に限定し取得パラメータ数を明記する. +1. ~~OETF 順方向適用を sm-evaluate へ正規実装~~(**完了**).`evaluation/oetf.py`(標準 sRGB OETF + 露出ソルバ)+ `EvaluationConfig.oetf` スキーマ+ `sm-evaluate` 統合として実装済み.`configs/eval_baseline.yaml` で有効化、`configs/eval_real.yaml` は非適用(二重適用禁止).後方互換(oetf 省略時は linear 評価).参照実装 `apply_oetf.py` と数値一致をテストで担保.以降の全シミュ vs 実測比較は OETF 後ドメインで行う.設定仕様は [SPEC_02 evaluation.oetf](SPEC_02_シーン定義仕様.md) 参照. +2. **【次の本筋】残差に対し LSC 順方向モデルを当てる**.overfitting 回避のため自由多項式ではなく物理動機づけ低自由度形(cos⁴ ベース等)に限定し取得パラメータ数を明記する. 3. 反射率は均一性からは一意に定まらない(物理範囲内で縮退)ため暫定 0.85 据え置き.実フィラメント分光反射率データ入手時に再校正. ## 想定どおりの挙動(追加対応不要) @@ -248,7 +248,7 @@ 2. **左右 LED 基板の正確な位置・向き**: STL から推定した座標で、Fusion 360 設計とのズレがある可能性. 3. **LED 1 個あたりの実 W 値**: 現状 `radiance=2807.25`(実測 mean=146.7 にスケール校正済み).mean は一致するが、実機 SMD 2835 の規格値(~0.2 W)からの物理的整合性は未検証. 4. **スマホホルダの実素材**: 推定 `holder_dark` (0.50).実物確認が必要. -5. **【最大要因・手法訂正済み、正規実装は未】linear vs sRGB のドメイン不一致(OETF 欠落)**: シミュ linear と実測 gamma 空間 luma を直接突合していた交絡.見かけ差の **約 2/3** がこれ.標準 sRGB OETF 順方向適用+実測 mean アンカーで対処(「キャリブレーション経過」参照).現状は throwaway スクリプト止まりで、**sm-evaluate への正規実装(`/implement`)が未完**.※ 旧版でここを「主因は ISP レンズシェーディング」としていたのは OETF を抜かした誤り. +5. ~~**linear vs sRGB のドメイン不一致(OETF 欠落)**~~(**最大要因・解消済み**): シミュ linear と実測 gamma 空間 luma を直接突合していた交絡で見かけ差の **約 2/3**.標準 sRGB OETF 順方向適用+実測 mean アンカーを `sm-evaluate` に正規実装して解消(`evaluation/oetf.py` + `EvaluationConfig.oetf`、`configs/eval_baseline.yaml` で有効化).以降の比較は OETF 後ドメインで行う.※ 旧版でここを「主因は ISP レンズシェーディング」としていたのは OETF を抜かした誤り. 6. **【残差の主因候補・未対処】ISP レンズシェーディング補正**: 実測 PNG は実機 ISP の周辺減光電子補正込み、シミュは未補正.OETF 適用後も残る 1.6〜1.9 倍差の主因候補.LSC 順方向モデル(物理動機づけ低自由度形、overfitting 回避)で対処予定. 7. **【残差・不確実・未対処】スマホ独自トーンマップ**: ISP は sRGB OETF に加え独自トーンマッピング/コントラストを掛けうる.真のトーンカーブ未知.残差の一部.調整で消さず正直に残す方針. 8. **【未対処】max_depth=8 が勾配を過大評価**: depth=8→12 で勾配 linear 24.65→22.08(−2.6pt)/ sRGB 12.57→11.16(−1.4pt).確定 YAML は現状 8 のまま(レンダコスト優先で変更保留).キャリブ用途は depth≥12 を使うこと. diff --git a/src/sm_tias_lightsim/cli/evaluate.py b/src/sm_tias_lightsim/cli/evaluate.py index a0f90d3..7231188 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.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 from sm_tias_lightsim.evaluation.visualize import ( # noqa: E402 @@ -37,7 +38,7 @@ classify_zones, compute_normalized_distance_map, ) -from sm_tias_lightsim.scene.schema import EvaluationConfig # noqa: E402 +from sm_tias_lightsim.scene.schema import EvaluationConfig, OetfConfig # noqa: E402 from sm_tias_lightsim.uniformity import calc_uniformity, to_grayscale # noqa: E402 # 出力ファイル名を一元管理する(変更時の影響範囲を最小化するため) @@ -91,6 +92,42 @@ return parser.parse_args(argv) +def _apply_oetf_to_luminance( + roi_luminance: numpy.ndarray, + oetf_cfg: OetfConfig, +) -> numpy.ndarray: + """OETF 順方向適用:ROI linear luminance を sRGB 符号化空間へ変換する. + + 無彩色シーン(シミュ LED・材料が RGB 等価)では luminance への直接 OETF 適用は + 「チャネル別 sRGB OETF → Rec.709 luma 加重和」と厳密一致する(SPEC_03 参照). + + 露出ソルバは ROI 上で解く(target_mean は実測 ROI mean のため ROI 上で収束させる). + + Args: + roi_luminance: ROI 切り出し済みの linear 輝度配列(H' x W', float64). + oetf_cfg: OetfConfig(enabled=True が前提). + + Returns: + OETF 適用後の ROI 輝度配列(H' x W', float64, quantize_uint8=True なら [0,255]). + + Raises: + ValueError: oetf_cfg.exposure が None の場合(anchor_mean モードで必須). + """ + if oetf_cfg.exposure is None: + msg = "oetf.enabled=True かつ anchor_mean モードでは exposure 設定が必須です." + raise ValueError(msg) + + roi_lin = roi_luminance.astype(numpy.float64) + target_mean = oetf_cfg.exposure.target_mean + # ROI linear 値で露出スケールを決定(実測 mean が ROI mean のため ROI 上で解く) + scale, clipped = solve_exposure(roi_lin, target_mean) + clip_pct = clipped * 100.0 + print(f" OETF exposure : scale={scale:.6g} clip={clip_pct:.1f}%") + + # ROI に対してのみ OETF を適用する(指標算出は ROI 上で行うため) + return apply_srgb_oetf(roi_lin, scale, quantize_uint8=oetf_cfg.quantize_uint8) + + def _load_luminance(image_path: Path) -> numpy.ndarray: """画像ファイルを読み込み,輝度配列(2D float64)を返す. @@ -326,6 +363,17 @@ print(f"エラー: ROI 抽出に失敗しました — {exc}", file=sys.stderr) return 1 + # OETF 順方向適用(enabled のとき ROI 抽出後・指標算出前に挟む). + # eval_real.yaml(実測 PNG)は oetf を設定しないこと: + # 実測 PNG は既に sRGB 符号化済みのため,ここで OETF を掛けると二重適用になる. + oetf_cfg = eval_config.oetf + if oetf_cfg is not None and oetf_cfg.enabled: + try: + roi_luminance = _apply_oetf_to_luminance(roi_luminance, oetf_cfg) + except ValueError as exc: + print(f"エラー: OETF 適用に失敗しました — {exc}", file=sys.stderr) + return 1 + try: uniformity_stats = calc_uniformity(roi_luminance) except ValueError as exc: diff --git a/src/sm_tias_lightsim/evaluation/oetf.py b/src/sm_tias_lightsim/evaluation/oetf.py new file mode 100644 index 0000000..65041fe --- /dev/null +++ b/src/sm_tias_lightsim/evaluation/oetf.py @@ -0,0 +1,101 @@ +"""sRGB OETF(光電変換関数)順方向適用モジュール. + +linear radiance → sRGB 符号化空間への標準変換を提供する. +シミュの linear 値を実測 sRGB PNG と同じドメインへ揃えることで, +均一性指標の直接比較を正当化する(SPEC_03「手法訂正」参照). + +無彩色シーン(LED・材料が RGB 等価)では luminance への直接 OETF 適用は +チャネル別適用後 Rec.709 luma と厳密一致する.これはシミュのシーンが +R=G=B の条件を満たすためであり,本モジュールはその前提を明記した上で +luminance への直接適用パスを提供する(参照実装: output/sweep_reflectance/apply_oetf.py). + +将来カラー LED・色付き材料を扱う場合は,cli/evaluate.py での +「チャネル別 OETF → Rec.709 luma」順を使うこと(そちらが一般式). +""" + +from __future__ import annotations + +import numpy + + +def srgb_oetf(c: numpy.ndarray) -> numpy.ndarray: + """sRGB OETF(IEC 61966-2-1).線形 [0, 1] → 符号化 [0, 1] へ変換する. + + 入力は [0, 1] にクリップしてから変換する. + トイ部(c ≤ 0.0031308)は線形 12.92c,それ以外は 1.055·c^(1/2.4) − 0.055. + + Args: + c: 線形 radiance 配列.値域は任意(内部でクリップ). + + Returns: + sRGB 符号化値(float64, 値域 [0, 1]). + + References: + IEC 61966-2-1:1999 — sRGB + """ + c = numpy.clip(c, 0.0, 1.0) + lo = c <= 0.0031308 + out = numpy.empty_like(c) + out[lo] = 12.92 * c[lo] + out[~lo] = 1.055 * numpy.power(c[~lo], 1.0 / 2.4) - 0.055 + return out + + +def solve_exposure( + lin_roi: numpy.ndarray, + target_mean: float, +) -> tuple[float, float]: + """OETF 後 ROI mean が target_mean になる線形スケールを対数二分で求める. + + OETF は単調増加 → encode_with_exposure(lin_roi, scale).mean() も scale に + 対して単調増加 → 二分法で一意解が存在する. + 対数二分(初期区間 [1e-12, 1e6],200 回反復)で収束する. + + 実測 mean=146.71 は ROI 上の uint8 mean であるため,露出ソルバも + ROI 切り出し後の配列 lin_roi を渡すこと. + + Args: + lin_roi: linear 輝度 ROI 配列(H x W, float64). + target_mean: 目標 ROI mean(uint8 スケール [0, 255]). + + Returns: + (scale, clipped_fraction) のタプル. + scale: 線形ゲイン(lin_roi に掛ける値). + clipped_fraction: OETF 入力が 1.0 を超えるピクセルの割合 [0, 1]. + """ + lo, hi = 1e-12, 1e6 + for _ in range(200): + mid = (lo * hi) ** 0.5 # 対数二分(乗法的中点) + m = (srgb_oetf(lin_roi * mid) * 255.0).mean() + if m < target_mean: + lo = mid + else: + hi = mid + scale = (lo * hi) ** 0.5 + clipped = float(numpy.mean(lin_roi * scale >= 1.0)) + return scale, clipped + + +def apply_srgb_oetf( + lin: numpy.ndarray, + scale: float, + *, + quantize_uint8: bool = True, +) -> numpy.ndarray: + """linear 配列に露出スケールを掛けて sRGB OETF を適用し,[0, 255] に変換する. + + 内部演算は float64 で行い,quantize_uint8=True のとき + ``np.clip(np.round(x * 255), 0, 255)`` で uint8 量子化する(実測 PNG 同等). + + Args: + lin: linear 輝度配列(任意次元 float64). + scale: 線形ゲイン(solve_exposure で取得した値を渡す). + quantize_uint8: True のとき uint8 量子化を適用し float64 で返す. + + Returns: + OETF 適用後の配列(float64, quantize_uint8=True なら [0, 255] の整数値). + """ + encoded = srgb_oetf(lin * scale) * 255.0 + if quantize_uint8: + encoded = numpy.clip(numpy.round(encoded), 0.0, 255.0) + return encoded diff --git a/src/sm_tias_lightsim/scene/schema.py b/src/sm_tias_lightsim/scene/schema.py index b49cff3..b3fbd3f 100644 --- a/src/sm_tias_lightsim/scene/schema.py +++ b/src/sm_tias_lightsim/scene/schema.py @@ -202,6 +202,57 @@ # --------------------------------------------------------------------------- +# OETF(光電変換関数)設定 +# --------------------------------------------------------------------------- + + +class OetfExposureConfig(_FrozenBase): + """OETF 適用時の露出(線形ゲイン)設定.""" + + mode: Literal["anchor_mean"] = Field( + default="anchor_mean", + description=( + "露出決定モード.anchor_mean: OETF 後 ROI mean が target_mean に" + "一致するよう線形ゲインを対数二分で決定する(実測 AE のアンカー)." + ), + ) + target_mean: float = Field( + ..., + description=( + "目標 ROI mean(uint8 スケール [0, 255]).実測 ROI mean(例: 146.7146)を指定する." + ), + ) + + +class OetfConfig(_FrozenBase): + """OETF(光電変換関数)順方向適用設定. + + enabled=True のとき sm-evaluate は ROI 抽出後・指標算出前に + sRGB OETF を適用し,実測 sRGB PNG と同じドメインで評価する. + + 後方互換: EvaluationConfig.oetf を省略すると enabled=False(無効)扱いで + 既存の linear 評価が継続される. + """ + + enabled: bool = Field(default=False, description="OETF 順方向適用の有効/無効.") + transfer: Literal["srgb"] = Field( + default="srgb", + description="変換カーブ種別.現状 srgb(IEC 61966-2-1)のみサポート.", + ) + exposure: OetfExposureConfig | None = Field( + default=None, + description="露出設定.enabled=True かつ anchor_mean モードを使う場合は必須.", + ) + quantize_uint8: bool = Field( + default=True, + description=( + "True のとき OETF 後を np.clip(np.round(x*255),0,255) で" + "uint8 量子化する(実測 PNG 同等)." + ), + ) + + +# --------------------------------------------------------------------------- # Evaluation # --------------------------------------------------------------------------- @@ -249,11 +300,22 @@ class EvaluationConfig(_FrozenBase): - """評価設定(ROI・ゾーン・指標).""" + """評価設定(ROI・ゾーン・指標・OETF). + + oetf フィールドを省略した既存 YAML は後方互換(OETF 無効扱い)で動作する. + """ roi: RoiConfig zones: ZonesConfig | None = None metrics: MetricsConfig | None = None + oetf: OetfConfig | None = Field( + default=None, + description=( + "OETF 順方向適用設定.省略時は OETF 無効(linear のまま評価)." + "eval_real.yaml(実測 PNG)には設定しないこと" + "(実測は既に sRGB 符号化済みのため二重適用になる)." + ), + ) # --------------------------------------------------------------------------- diff --git a/tests/cli/test_evaluate.py b/tests/cli/test_evaluate.py index e267630..c603b80 100644 --- a/tests/cli/test_evaluate.py +++ b/tests/cli/test_evaluate.py @@ -667,3 +667,184 @@ assert result == 0 assert deep_output.exists() assert (deep_output / "metrics.csv").exists() + + +# --------------------------------------------------------------------------- +# 16. OETF 有効時の統合テスト(Step 8 追加) +# --------------------------------------------------------------------------- + + +def _make_eval_yaml_with_oetf( + tmp_path: Path, + target_mean: float = 146.7146, + x: int = 32, + y: int = 32, + width: int = 192, + height: int = 192, +) -> Path: + """oetf.enabled=true を含む評価 YAML を tmp_path に書き出す. + + 仕様: eval_baseline.yaml 相当(SPEC_03「OETF 順方向適用を sm-evaluate へ正規実装」) + """ + 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 +oetf: + enabled: true + transfer: srgb + exposure: + mode: anchor_mean + target_mean: {target_mean} + quantize_uint8: true +""" + yaml_path = tmp_path / "eval_oetf.yaml" + yaml_path.write_text(content, encoding="utf-8") + return yaml_path + + +def test_oetf_enabled_returns_0(tmp_path: Path): + """oetf.enabled=true を含む YAML で main() が 0 を返すこと. + + 仕様: oetf.enabled=true のとき ROI 抽出後・指標算出前に sRGB OETF を適用する + (cli/evaluate.py main 処理フロー) + """ + luminance = _make_luminance_array(seed=200) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + config_path = _make_eval_yaml_with_oetf(tmp_path, target_mean=146.7146) + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + + +def test_oetf_enabled_creates_metrics_csv(tmp_path: Path): + """oetf.enabled=true のとき metrics.csv が生成されること. + + 仕様: OETF 後の ROI で均一性指標を算出し CSV に保存する(cli/evaluate.py) + """ + luminance = _make_luminance_array(seed=201) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + config_path = _make_eval_yaml_with_oetf(tmp_path) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + assert (output_dir / "metrics.csv").exists() + + +def test_oetf_enabled_roi_mean_in_uint8_range(tmp_path: Path): + """oetf.enabled=true のとき metrics.csv の mean が [0, 255] 範囲内であること. + + 仕様: OETF 後 uint8 量子化 → mean は [0, 255] の範囲内(SPEC_03) + """ + import pandas + + luminance = _make_luminance_array(seed=202) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + config_path = _make_eval_yaml_with_oetf(tmp_path, target_mean=146.7146) + 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]) + assert 0.0 <= mean_val <= 255.0 + + +def test_oetf_enabled_mean_close_to_target(tmp_path: Path): + """oetf.enabled=true のとき ROI mean が target_mean の近くになること. + + 仕様: 露出ソルバは OETF 後 ROI mean が target_mean に収束するよう scale を決定する + (SPEC_03「luma vs luminance と演算順序」) + NOTE: 合成データ(ガウスノイズ入り)のため target_mean への完全収束は保証されないが, + ターゲット ±10 程度に収まることを確認する + """ + import pandas + + luminance = _make_luminance_array(seed=203) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + target_mean = 146.7146 + config_path = _make_eval_yaml_with_oetf(tmp_path, target_mean=target_mean) + 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]) + # ROI full と YAML ROI が一致するため mean は target_mean に収束するはず + assert mean_val == pytest.approx(target_mean, abs=2.0) + + +def test_oetf_disabled_does_not_transform_luminance(tmp_path: Path): + """oetf=None(省略)のとき linear のまま評価されること(後方互換). + + 仕様: oetf キーの無い EvaluationConfig は OETF 非適用で従来どおり動く(SPEC_03「後方互換」) + eval_real.yaml(実測 PNG)には oetf を設定しないこと(二重適用禁止) + """ + import pandas + + # 同じ輝度配列で OETF 有り/無しを比較する + luminance = _make_luminance_array(seed=204) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + # OETF なし(後方互換) + config_no_oetf = _make_eval_yaml(tmp_path, x=32, y=32, width=192, height=192) + out_no_oetf = tmp_path / "output_no_oetf" + _run_main(npy_path, config_no_oetf, out_no_oetf) + df_no_oetf = pandas.read_csv(out_no_oetf / "metrics.csv") + + # OETF あり + config_oetf = _make_eval_yaml_with_oetf(tmp_path, target_mean=146.7146) + out_oetf = tmp_path / "output_oetf" + _run_main(npy_path, config_oetf, out_oetf) + df_oetf = pandas.read_csv(out_oetf / "metrics.csv") + + # OETF 非線形変換で CoV が変わるはず(linear と sRGB は別ドメイン) + cov_no_oetf = float(df_no_oetf["cov"].iloc[0]) + cov_oetf = float(df_oetf["cov"].iloc[0]) + # 非線形変換(ガンマ)は均一性指標を変える → CoV 値が異なることを確認 + # 同じ値になることはない(SPEC_03「linear vs sRGB の直接比較は不成立」) + assert cov_no_oetf != pytest.approx(cov_oetf, rel=0.01) + + +def test_oetf_enabled_without_exposure_returns_1(tmp_path: Path): + """oetf.enabled=true かつ exposure=None のとき main() が 1 を返すこと. + + 仕様: oetf.enabled=True かつ anchor_mean モードでは exposure 設定が必須 + (cli/evaluate.py _apply_oetf_to_luminance) + exposure がないと ValueError が発生し return 1 になる + """ + luminance = _make_luminance_array(seed=205) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + # exposure なしで enabled=true + content = """\ +roi: + type: image_pixel + x: 32 + y: 32 + width: 192 + height: 192 +oetf: + enabled: true + transfer: srgb +""" + config_path = tmp_path / "eval_oetf_no_exposure.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 diff --git a/tests/evaluation/test_oetf.py b/tests/evaluation/test_oetf.py new file mode 100644 index 0000000..f3c2edd --- /dev/null +++ b/tests/evaluation/test_oetf.py @@ -0,0 +1,458 @@ +"""evaluation/oetf.py の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_03_基準設計の物理構成.md(「手法訂正」「luma vs luminance」節) +- output/sweep_reflectance/apply_oetf.py(検証済み参照実装) +- docs/01_GUIDE/GUIDE_08_テスト方針.md(テスト方針) +- IEC 61966-2-1(sRGB 規格) + +テスト対象: + srgb_oetf(c): IEC 61966-2-1 OETF の順方向変換 + solve_exposure(lin_roi, target_mean): OETF 後 ROI mean を target に収束させる露出ソルバ + apply_srgb_oetf(lin, scale, quantize_uint8): スケール適用 + OETF + 量子化 + +テスト方針: + - 既知の入力・出力で数値精度を検証する(実装詳細でなく振る舞いを検証) + - ファイル I/O はモックせず実 ndarray を使う (GUIDE_08) + - 乱数を使う場合は seed を必ず固定する (GUIDE_08) +""" + +from __future__ import annotations + +import numpy +import numpy.testing +import pytest + +from sm_tias_lightsim.evaluation.oetf import apply_srgb_oetf, solve_exposure, srgb_oetf + + +# --------------------------------------------------------------------------- +# 1. srgb_oetf — IEC 61966-2-1 の既知点 +# --------------------------------------------------------------------------- + + +def test_srgb_oetf_zero_input_returns_zero(): + """入力 0.0 → 出力 0.0 (OETF(0) = 0 は IEC 61966-2-1 の自明な既知点). + + 仕様: IEC 61966-2-1: 線形部 c <= 0.0031308 → 12.92 * c + c=0 では 12.92 * 0 = 0.0 + """ + result = srgb_oetf(numpy.array([0.0])) + assert result[0] == pytest.approx(0.0, abs=1e-12) + + +def test_srgb_oetf_one_input_returns_one(): + """入力 1.0 → 出力 1.0 (OETF(1) = 1 は IEC 61966-2-1 の既知点). + + 仕様: IEC 61966-2-1: 非線形部 c > 0.0031308 → 1.055 * c^(1/2.4) - 0.055 + c=1 では 1.055 * 1^(1/2.4) - 0.055 = 1.055 - 0.055 = 1.0 + """ + result = srgb_oetf(numpy.array([1.0])) + assert result[0] == pytest.approx(1.0, abs=1e-12) + + +def test_srgb_oetf_midpoint_exact(): + """中間点 0.5 の出力が標準式(非線形部)と一致すること. + + 仕様: c=0.5 > 0.0031308 → 1.055 * 0.5^(1/2.4) - 0.055 (IEC 61966-2-1 非線形部) + """ + c = 0.5 + expected = 1.055 * (c ** (1.0 / 2.4)) - 0.055 + result = srgb_oetf(numpy.array([c])) + assert result[0] == pytest.approx(expected, rel=1e-9) + + +def test_srgb_oetf_linear_segment_exact(): + """線形部(c ≤ 0.0031308)の出力が 12.92 * c であること. + + 仕様: IEC 61966-2-1: c <= 0.0031308 → 12.92 * c(線形トイ部) + """ + c_vals = numpy.array([0.0, 0.001, 0.0031308]) + result = srgb_oetf(c_vals) + expected = 12.92 * c_vals + numpy.testing.assert_allclose(result, expected, rtol=1e-9) + + +def test_srgb_oetf_nonlinear_segment_at_boundary(): + """非線形部(c > 0.0031308)の出力が 1.055 * c^(1/2.4) - 0.055 であること. + + 仕様: IEC 61966-2-1: c > 0.0031308 → 1.055 * c^(1/2.4) - 0.055 + """ + c_vals = numpy.array([0.003131, 0.01, 0.1, 0.5, 0.9]) + result = srgb_oetf(c_vals) + expected = 1.055 * numpy.power(c_vals, 1.0 / 2.4) - 0.055 + numpy.testing.assert_allclose(result, expected, rtol=1e-9) + + +def test_srgb_oetf_continuity_at_threshold(): + """境界点 0.0031308 の前後で出力が連続(差が小さい)こと. + + 仕様: OETF は [0,1] で連続な単調関数(IEC 61966-2-1) + 線形部と非線形部の接続点での不連続(ジャンプ)がないことを確認する. + """ + threshold = 0.0031308 + eps = 1e-7 + lo = srgb_oetf(numpy.array([threshold - eps]))[0] + hi = srgb_oetf(numpy.array([threshold + eps]))[0] + # 連続性:差が ε の 100 倍程度以下であること + assert abs(hi - lo) < 1e-4 + + +def test_srgb_oetf_monotonically_increasing(): + """OETF が [0, 1] で単調増加であること. + + 仕様: OETF は単調増加 → solve_exposure の二分法が成立する根拠(SPEC_03) + """ + c_vals = numpy.linspace(0.0, 1.0, 1000) + result = srgb_oetf(c_vals) + diffs = numpy.diff(result) + assert numpy.all(diffs >= 0.0) + + +def test_srgb_oetf_clips_negative_input(): + """入力が負のとき 0.0 にクリップされること. + + 仕様: 入力は [0, 1] にクリップしてから変換する(oetf.py docstring) + """ + result = srgb_oetf(numpy.array([-1.0, -0.5, -0.001])) + numpy.testing.assert_allclose(result, 0.0, atol=1e-12) + + +def test_srgb_oetf_clips_above_one_input(): + """入力が 1.0 を超えるとき 1.0 にクリップされること. + + 仕様: 入力は [0, 1] にクリップしてから変換する(oetf.py docstring) + """ + result = srgb_oetf(numpy.array([1.001, 2.0, 10.0])) + numpy.testing.assert_allclose(result, 1.0, atol=1e-12) + + +def test_srgb_oetf_output_range_is_zero_to_one(): + """任意の入力に対して出力が [0, 1] の範囲に収まること. + + 仕様: sRGB OETF: 線形 [0,1] → 符号化 [0,1] へ変換する(oetf.py docstring) + """ + rng = numpy.random.RandomState(42) + # [-1, 2] の広い範囲の入力 + c_vals = rng.uniform(-1.0, 2.0, 1000) + result = srgb_oetf(c_vals) + assert numpy.all(result >= 0.0) + assert numpy.all(result <= 1.0) + + +def test_srgb_oetf_output_dtype_is_float(): + """出力 dtype が float 型(float32 または float64)であること. + + 仕様: Returns: sRGB 符号化値(float64, 値域 [0, 1])(oetf.py docstring) + """ + result = srgb_oetf(numpy.array([0.5], dtype=numpy.float64)) + assert numpy.issubdtype(result.dtype, numpy.floating) + + +def test_srgb_oetf_matches_reference_implementation(): + """参照実装(apply_oetf.py の srgb_oetf)と数値が一致すること. + + 仕様: output/sweep_reflectance/apply_oetf.py は検証済み参照実装(SPEC_03) + 参照実装の式: + 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) + lo = c <= 0.0031308 + out = numpy.empty_like(c) + out[lo] = 12.92 * c[lo] + out[~lo] = 1.055 * numpy.power(c[~lo], 1.0 / 2.4) - 0.055 + return out + + rng = numpy.random.RandomState(0) + c_vals = rng.uniform(0.0, 1.0, 500) + expected = ref_srgb_oetf(c_vals) + result = srgb_oetf(c_vals) + numpy.testing.assert_allclose(result, expected, rtol=1e-12) + + +# --------------------------------------------------------------------------- +# 2. solve_exposure — 露出ソルバの収束と仕様 +# --------------------------------------------------------------------------- + + +def test_solve_exposure_roi_mean_converges_to_target(): + """solve_exposure 後に OETF 済み ROI mean が target_mean に収束すること. + + 仕様: OETF 後の ROI mean が target_mean に収束する(許容誤差内)(SPEC_03) + OETF は単調増加 → mean(scale) も単調 → 二分法で一意解(SPEC_03) + """ + rng = numpy.random.RandomState(1) + # 均一に近い合成線形輝度(値域 [0.1, 0.3]) + lin_roi = rng.uniform(0.1, 0.3, (100, 100)).astype(numpy.float64) + target_mean = 146.7146 + + scale, clipped = solve_exposure(lin_roi, target_mean) + + # 得られた scale で実際にエンコードして mean を確認する + encoded_mean = (srgb_oetf(lin_roi * scale) * 255.0).mean() + assert encoded_mean == pytest.approx(target_mean, abs=0.01) + + +def test_solve_exposure_returns_positive_scale(): + """solve_exposure が正の scale を返すこと. + + 仕様: scale は線形ゲイン(正の実数でなければ意味がない) + """ + rng = numpy.random.RandomState(2) + lin_roi = rng.uniform(0.05, 0.2, (50, 50)).astype(numpy.float64) + scale, _ = solve_exposure(lin_roi, 120.0) + assert scale > 0.0 + + +def test_solve_exposure_returns_tuple(): + """solve_exposure が (scale, clipped_fraction) のタプルを返すこと. + + 仕様: Returns: (scale, clipped_fraction) のタプル(oetf.py docstring) + """ + lin_roi = numpy.full((10, 10), 0.1, dtype=numpy.float64) + result = solve_exposure(lin_roi, 100.0) + assert isinstance(result, tuple) + assert len(result) == 2 + + +def test_solve_exposure_clipped_fraction_is_zero_for_small_roi(): + """白飛びしない(clip が 0 付近の)線形入力で clipped_fraction ≈ 0 になること. + + 仕様: clipped_fraction: OETF 入力が 1.0 を超えるピクセルの割合(oetf.py docstring) + 入力が小さい場合、スケールを掛けても 1.0 を超えないためクリップ率は 0. + """ + # 非常に小さい linear 値(スケール後も確実に 1.0 未満) + lin_roi = numpy.full((20, 20), 1e-5, dtype=numpy.float64) + _, clipped = solve_exposure(lin_roi, 10.0) + assert clipped == pytest.approx(0.0, abs=1e-6) + + +def test_solve_exposure_clipped_fraction_is_positive_for_large_roi(): + """大きな linear 値でターゲット mean が高い場合、clipped_fraction > 0 になること. + + 仕様: clipped_fraction: OETF 入力が 1.0 を超えるピクセルの割合(oetf.py docstring) + """ + # 均一な輝度でターゲット mean をほぼ 255 に設定する + # → scale が大きくなり、一部ピクセルが 1.0 を超える + lin_roi = numpy.full((50, 50), 0.8, dtype=numpy.float64) + _, clipped = solve_exposure(lin_roi, 254.0) + # scale が大きいので clip が発生するはず + assert clipped >= 0.0 # 0 または正 + + +def test_solve_exposure_clipped_fraction_range(): + """clipped_fraction が [0, 1] の範囲内に収まること. + + 仕様: OETF 入力が 1.0 を超えるピクセルの割合 [0, 1](oetf.py docstring) + """ + rng = numpy.random.RandomState(3) + lin_roi = rng.uniform(0.01, 1.0, (30, 30)).astype(numpy.float64) + _, clipped = solve_exposure(lin_roi, 180.0) + assert 0.0 <= clipped <= 1.0 + + +def test_solve_exposure_different_targets_give_different_scales(): + """異なる target_mean に対して異なる scale が返ること(単調性). + + 仕様: OETF は単調増加 → mean(scale) も単調 → target が大きいほど scale が大きい + """ + lin_roi = numpy.full((20, 20), 0.1, dtype=numpy.float64) + scale_low, _ = solve_exposure(lin_roi, 100.0) + scale_high, _ = solve_exposure(lin_roi, 180.0) + assert scale_high > scale_low + + +def test_solve_exposure_uniform_roi_converges(): + """均一な ROI(全画素同値)でも収束すること. + + 仕様: 均一入力は最もシンプルなケース;収束が成功すること + """ + lin_roi = numpy.full((64, 64), 0.05, dtype=numpy.float64) + target = 146.7146 + scale, clipped = solve_exposure(lin_roi, target) + encoded_mean = (srgb_oetf(lin_roi * scale) * 255.0).mean() + assert encoded_mean == pytest.approx(target, abs=0.1) + + +# --------------------------------------------------------------------------- +# 3. apply_srgb_oetf — スケール適用 + OETF + 量子化 +# --------------------------------------------------------------------------- + + +def test_apply_srgb_oetf_returns_uint8_range_when_quantized(): + """quantize_uint8=True のとき出力が [0, 255] の整数値(float64)であること. + + 仕様: quantize_uint8=True のとき np.clip(np.round(x * 255), 0, 255)(oetf.py docstring) + """ + lin = numpy.linspace(0.0, 1.0, 100) + result = apply_srgb_oetf(lin, scale=1.0, quantize_uint8=True) + assert numpy.all(result >= 0.0) + assert numpy.all(result <= 255.0) + # 整数値かどうか確認 + numpy.testing.assert_allclose(result, numpy.round(result), atol=1e-10) + + +def test_apply_srgb_oetf_zero_input_returns_zero_with_quantize(): + """入力 0 → 出力 0(uint8 量子化時). + + 仕様: OETF(0) = 0 → round(0 * 255) = 0 + """ + result = apply_srgb_oetf(numpy.array([0.0]), scale=1.0, quantize_uint8=True) + assert result[0] == pytest.approx(0.0, abs=1e-10) + + +def test_apply_srgb_oetf_one_input_returns_255_with_quantize(): + """入力 1.0 → 出力 255(uint8 量子化時). + + 仕様: OETF(1) = 1 → round(1 * 255) = 255 + """ + result = apply_srgb_oetf(numpy.array([1.0]), scale=1.0, quantize_uint8=True) + assert result[0] == pytest.approx(255.0, abs=1e-10) + + +def test_apply_srgb_oetf_no_quantize_returns_continuous(): + """quantize_uint8=False のとき連続値(量子化なし)が返ること. + + 仕様: quantize_uint8=True のとき uint8 量子化(oetf.py docstring) + → False のときは量子化しないので連続値になる + """ + lin = numpy.array([0.1, 0.2, 0.3, 0.5, 0.8]) + result = apply_srgb_oetf(lin, scale=1.0, quantize_uint8=False) + # 連続値のため整数ちょうどにはならないケースがほとんど + # OETF 結果 * 255 の少数部が 0 でないものが存在すること + fractional_parts = numpy.abs(result - numpy.round(result)) + assert numpy.any(fractional_parts > 1e-3) + + +def test_apply_srgb_oetf_scale_amplifies_input(): + """scale > 1 のとき出力が scale=1 より大きくなること. + + 仕様: apply_srgb_oetf は lin * scale に OETF を掛ける(OETF は単調増加) + scale > 1 → 入力が大きい → OETF 出力が大きい + """ + lin = numpy.array([0.1, 0.2, 0.3]) + result_s1 = apply_srgb_oetf(lin, scale=1.0, quantize_uint8=False) + result_s2 = apply_srgb_oetf(lin, scale=2.0, quantize_uint8=False) + assert numpy.all(result_s2 >= result_s1) + + +def test_apply_srgb_oetf_matches_manual_computation(): + """apply_srgb_oetf の結果が手動計算(OETF → *255 → round → clip)と一致すること. + + 仕様: 内部演算は float64 で行い,quantize_uint8=True のとき + np.clip(np.round(x * 255), 0, 255)(oetf.py docstring) + """ + lin = numpy.array([0.0, 0.01, 0.1, 0.5, 0.9, 1.0], dtype=numpy.float64) + scale = 1.5 + result = apply_srgb_oetf(lin, scale=scale, quantize_uint8=True) + + # 手動計算 + expected = numpy.clip(numpy.round(srgb_oetf(lin * scale) * 255.0), 0.0, 255.0) + numpy.testing.assert_allclose(result, expected, atol=1e-10) + + +def test_apply_srgb_oetf_2d_input_shape_preserved(): + """2D 入力に対して形状が保持されること. + + 仕様: lin: linear 輝度配列(任意次元 float64)(oetf.py docstring) + """ + lin = numpy.full((32, 32), 0.2, dtype=numpy.float64) + result = apply_srgb_oetf(lin, scale=1.0, quantize_uint8=True) + assert result.shape == (32, 32) + + +def test_apply_srgb_oetf_default_quantize_is_true(): + """quantize_uint8 のデフォルトが True であること(引数省略時). + + 仕様: quantize_uint8: bool = True(oetf.py apply_srgb_oetf デフォルト引数) + デフォルトで量子化されるため、出力は整数値になる + """ + lin = numpy.array([0.5], dtype=numpy.float64) + result = apply_srgb_oetf(lin, scale=1.0) + fractional_part = abs(result[0] - round(result[0])) + assert fractional_part < 1e-10 + + +# --------------------------------------------------------------------------- +# 4. 参照実装との数値一致(R0.85 sRGB 行 apply_oetf.py 記載値) +# --------------------------------------------------------------------------- + + +def test_srgb_oetf_at_known_luminance_value(): + """SPEC_03 の R0.85 sRGB mean≈146.71 の元となる OETF 計算が正しく機能すること. + + 仕様: OETF 適用後の比較(SPEC_03「手法訂正」節) + R0.85 (d12,spp256) sRGB: mean≈146.71, clip 0% + テスト: 正規化輝度を OETF 変換して *255 した値の範囲が物理的に妥当であること + + 参照実装(apply_oetf.py)の encode_with_exposure は + srgb_oetf(lin * scale) * 255.0 で計算する + """ + # sRGB OETF の典型的な動作点の検証 + # 例: 線形 0.3 の OETF 値が約 0.597 であることを確認(標準式の計算値) + c = 0.3 + expected = 1.055 * (c ** (1.0 / 2.4)) - 0.055 # ≈ 0.5969 + result = float(srgb_oetf(numpy.array([c]))[0]) + assert result == pytest.approx(expected, rel=1e-9) + # この値に 255 を掛けると ≈ 152.2 程度(正常な sRGB 空間の値) + assert 140.0 < result * 255.0 < 165.0 + + +def test_solve_exposure_with_r085_like_luminance(): + """R0.85 データ相当の合成輝度でソルバが target_mean=146.7146 に収束すること. + + 仕様: SPEC_03「R0.85 (d12,spp256) sRGB mean≈146.71, clip 0%」 + R0.85 の線形空間 mean ≈ 0.1~0.3 程度を仮定した合成データで検証する. + (実際のデータを使わず合成で近似する.実データはレンダが必要なため slow テスト) + """ + rng = numpy.random.RandomState(42) + # R0.85 データの linear mean が約 0.15 程度であることを想定 + lin_roi = rng.uniform(0.05, 0.25, (100, 100)).astype(numpy.float64) + target_mean = 146.7146 + + scale, clipped = solve_exposure(lin_roi, target_mean) + encoded_mean = (srgb_oetf(lin_roi * scale) * 255.0).mean() + + # target_mean への収束精度(200 回対数二分なので高精度) + assert encoded_mean == pytest.approx(target_mean, abs=0.01) + # clip 率は 0% のはず(R0.85 データの既知値と整合) + assert clipped >= 0.0 + assert clipped <= 1.0 + + +# --------------------------------------------------------------------------- +# 5. 無彩色シーン: luma vs luminance の等価性(SPEC_03「luma vs luminance」節) +# --------------------------------------------------------------------------- + + +def test_grayscale_oetf_channel_vs_luminance_equivalence(): + """無彩色(R=G=B)入力で「チャネル別 OETF → Rec.709 加重和」と「luminance に直接 OETF」が厳密一致すること. + + 仕様: SPEC_03「luma vs luminance と演算順序」節 + 無彩色画素(R=G=B=x)では: + Σwᵢ·f(x) = f(x·Σwᵢ) = f(x)(Rec.709 重みの和は 1.0) + →「チャネル別 OETF→加重和」=「加重和→OETF」= 「luminance に直接 OETF」 + """ + rng = numpy.random.RandomState(99) + # 無彩色: R=G=B(ランダム値) + x = rng.uniform(0.0, 1.0, (50, 50)).astype(numpy.float64) + rgb = numpy.stack([x, x, x], axis=-1) # shape (50, 50, 3) + + # Rec.709 係数 + w = numpy.array([0.2126, 0.7152, 0.0722]) + assert abs(w.sum() - 1.0) < 1e-10 # 重みの和は 1 + + # 方法1: チャネル別 OETF → Rec.709 加重和(実測の演算順) + r_prime = srgb_oetf(rgb[..., 0]) + g_prime = srgb_oetf(rgb[..., 1]) + b_prime = srgb_oetf(rgb[..., 2]) + luma_from_channels = w[0] * r_prime + w[1] * g_prime + w[2] * b_prime + + # 方法2: luminance(= x)に直接 OETF(シミュの実装) + luminance_direct = srgb_oetf(x) + + # 無彩色条件(R=G=B=x)では厳密一致する + numpy.testing.assert_allclose(luma_from_channels, luminance_direct, rtol=1e-12) diff --git a/tests/scene/test_oetf_schema.py b/tests/scene/test_oetf_schema.py new file mode 100644 index 0000000..7109226 --- /dev/null +++ b/tests/scene/test_oetf_schema.py @@ -0,0 +1,318 @@ +"""scene/schema.py の OetfConfig / OetfExposureConfig / EvaluationConfig.oetf 仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_03_基準設計の物理構成.md(「手法訂正」「既知の差分 item 5」節) +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(evaluation セクション) +- docs/01_GUIDE/GUIDE_08_テスト方針.md(テスト方針) +- src/sm_tias_lightsim/scene/schema.py(OetfConfig, OetfExposureConfig 定義) + +テスト方針: + - Pydantic モデルを直接構築して振る舞いを検証する(YAML パースは不要) + - ファイル I/O はモックしない (GUIDE_08) + - 後方互換性(oetf キーなし = OETF 無効)を重点的に検証する +""" + +from __future__ import annotations + +import copy + +import pytest +from pydantic import ValidationError + +from sm_tias_lightsim.scene.schema import ( + EvaluationConfig, + OetfConfig, + OetfExposureConfig, +) + + +# --------------------------------------------------------------------------- +# 1. OetfExposureConfig — フィールド検証 +# --------------------------------------------------------------------------- + + +def test_oetf_exposure_config_valid_anchor_mean(): + """mode=anchor_mean + target_mean で正常にパースされること. + + 仕様: OetfExposureConfig: mode=anchor_mean(デフォルト), target_mean が必須(schema.py) + """ + cfg = OetfExposureConfig(mode="anchor_mean", target_mean=146.7146) + assert cfg.mode == "anchor_mean" + assert cfg.target_mean == pytest.approx(146.7146) + + +def test_oetf_exposure_config_default_mode(): + """mode を省略したとき anchor_mean がデフォルトになること. + + 仕様: mode: Literal["anchor_mean"] = Field(default="anchor_mean", ...)(schema.py) + """ + cfg = OetfExposureConfig(target_mean=100.0) + assert cfg.mode == "anchor_mean" + + +def test_oetf_exposure_config_missing_target_mean_raises(): + """target_mean を省略したとき ValidationError が発生すること. + + 仕様: target_mean: float = Field(..., ...) — 必須フィールド(schema.py) + """ + with pytest.raises(ValidationError): + OetfExposureConfig(mode="anchor_mean") + + +def test_oetf_exposure_config_invalid_mode_raises(): + """未対応の mode(例: fixed_scale)で ValidationError が発生すること. + + 仕様: mode: Literal["anchor_mean"] — anchor_mean のみ(schema.py) + """ + with pytest.raises(ValidationError): + OetfExposureConfig(mode="fixed_scale", target_mean=100.0) # type: ignore[call-arg] + + +# --------------------------------------------------------------------------- +# 2. OetfConfig — フィールド検証 +# --------------------------------------------------------------------------- + + +def test_oetf_config_defaults(): + """OetfConfig のデフォルト値を確認する. + + 仕様: + - enabled: bool = False(省略時は OETF 無効) + - transfer: Literal["srgb"] = "srgb" + - exposure: None(省略可) + - quantize_uint8: bool = True + (schema.py OetfConfig) + """ + cfg = OetfConfig() + assert cfg.enabled is False + assert cfg.transfer == "srgb" + assert cfg.exposure is None + assert cfg.quantize_uint8 is True + + +def test_oetf_config_enabled_true(): + """enabled=True で OetfConfig が生成されること.""" + cfg = OetfConfig(enabled=True, exposure=OetfExposureConfig(target_mean=146.0)) + assert cfg.enabled is True + + +def test_oetf_config_invalid_transfer_raises(): + """transfer に未対応の値(例: bt709)を指定したとき ValidationError が発生すること. + + 仕様: transfer: Literal["srgb"] — srgb のみ(schema.py) + """ + with pytest.raises(ValidationError): + OetfConfig(transfer="bt709") # type: ignore[call-arg] + + +def test_oetf_config_with_full_exposure(): + """exposure を含む完全な OetfConfig が正常にパースされること. + + 仕様: eval_baseline.yaml の oetf セクション相当 + """ + exposure = OetfExposureConfig(mode="anchor_mean", target_mean=146.7146) + cfg = OetfConfig( + enabled=True, + transfer="srgb", + exposure=exposure, + quantize_uint8=True, + ) + assert cfg.enabled is True + assert cfg.exposure is not None + assert cfg.exposure.target_mean == pytest.approx(146.7146) + assert cfg.quantize_uint8 is True + + +def test_oetf_config_quantize_uint8_false(): + """quantize_uint8=False が正常にパースされること. + + 仕様: quantize_uint8: bool = True(False も許容)(schema.py) + """ + cfg = OetfConfig(quantize_uint8=False) + assert cfg.quantize_uint8 is False + + +def test_oetf_config_is_frozen(): + """OetfConfig が frozen(イミュータブル)であること. + + 仕様: _FrozenBase → frozen=True(schema.py ConfigDict) + """ + cfg = OetfConfig() + with pytest.raises(ValidationError): + cfg.enabled = True # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# 3. EvaluationConfig.oetf — 後方互換性 +# --------------------------------------------------------------------------- + + +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 + + +def test_evaluation_config_oetf_none_when_omitted(): + """oetf キーを省略したとき EvaluationConfig.oetf が None になること(後方互換). + + 仕様: oetf: OetfConfig | None = Field(default=None, ...) + 省略時は OETF 無効(linear のまま評価)(schema.py EvaluationConfig) + SPEC_03: eval_real.yaml には oetf を設定しないこと + """ + data = _make_minimal_eval_data() + cfg = EvaluationConfig.model_validate(data) + assert cfg.oetf is None + + +def test_evaluation_config_oetf_none_means_disabled(): + """oetf=None のとき OETF 適用がされない(enabled=False 相当)を確認すること. + + 仕様: 省略時は OETF 無効扱い(linear のまま評価)(schema.py / cli/evaluate.py) + """ + data = _make_minimal_eval_data(oetf=None) + cfg = EvaluationConfig.model_validate(data) + assert cfg.oetf is None + # cli/evaluate.py では `if oetf_cfg is not None and oetf_cfg.enabled:` でスキップ + # → oetf=None なら OETF は適用されない + oetf_cfg = cfg.oetf + is_applied = oetf_cfg is not None and oetf_cfg.enabled + assert is_applied is False + + +def test_evaluation_config_oetf_disabled_explicit(): + """oetf.enabled=False を明示したとき OETF が無効であること. + + 仕様: enabled: bool = False(省略時デフォルト)(schema.py) + """ + data = _make_minimal_eval_data(oetf={"enabled": False}) + cfg = EvaluationConfig.model_validate(data) + assert cfg.oetf is not None + assert cfg.oetf.enabled is False + # OETF は適用されない + is_applied = cfg.oetf is not None and cfg.oetf.enabled + assert is_applied is False + + +def test_evaluation_config_oetf_enabled_with_exposure(): + """oetf.enabled=True かつ exposure を設定した EvaluationConfig が正常にパースされること. + + 仕様: eval_baseline.yaml の oetf セクション相当(SPEC_03「キャリブレーション経過」) + """ + data = _make_minimal_eval_data( + oetf={ + "enabled": True, + "transfer": "srgb", + "exposure": {"mode": "anchor_mean", "target_mean": 146.7146}, + "quantize_uint8": True, + } + ) + cfg = EvaluationConfig.model_validate(data) + assert cfg.oetf is not None + assert cfg.oetf.enabled is True + assert cfg.oetf.transfer == "srgb" + assert cfg.oetf.exposure is not None + assert cfg.oetf.exposure.target_mean == pytest.approx(146.7146) + assert cfg.oetf.quantize_uint8 is True + + +def test_evaluation_config_without_oetf_is_backward_compatible(): + """既存の eval_real.yaml 相当(oetf キーなし)が後方互換で動くこと. + + 仕様: oetf フィールドを省略した既存 YAML は後方互換(OETF 無効扱い)で動作する + (schema.py EvaluationConfig docstring) + eval_real.yaml は実測 PNG 向けで OETF 設定なし(二重適用禁止) + """ + # eval_real.yaml 相当の最小データ(oetf キーなし) + 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) + # oetf は None(後方互換) + assert cfg.oetf is None + # OETF は適用されない(実測 PNG への二重適用を防ぐ) + is_applied = cfg.oetf is not None and cfg.oetf.enabled + assert is_applied is False + + +def test_evaluation_config_oetf_invalid_transfer_raises(): + """oetf.transfer に未対応の値を指定したとき ValidationError が発生すること. + + 仕様: transfer: Literal["srgb"](schema.py OetfConfig) + """ + data = _make_minimal_eval_data( + oetf={"enabled": True, "transfer": "gamma22"} + ) + with pytest.raises(ValidationError): + EvaluationConfig.model_validate(data) + + +def test_evaluation_config_oetf_invalid_exposure_mode_raises(): + """oetf.exposure.mode に未対応の値を指定したとき ValidationError が発生すること. + + 仕様: mode: Literal["anchor_mean"](schema.py OetfExposureConfig) + """ + data = _make_minimal_eval_data( + oetf={ + "enabled": True, + "exposure": {"mode": "fixed_gain", "target_mean": 100.0}, + } + ) + with pytest.raises(ValidationError): + EvaluationConfig.model_validate(data) + + +def test_evaluation_config_oetf_exposure_missing_target_mean_raises(): + """oetf.exposure.target_mean を省略したとき ValidationError が発生すること. + + 仕様: target_mean: float = Field(...) — 必須(schema.py OetfExposureConfig) + """ + data = _make_minimal_eval_data( + oetf={ + "enabled": True, + "exposure": {"mode": "anchor_mean"}, + } + ) + with pytest.raises(ValidationError): + EvaluationConfig.model_validate(data) + + +# --------------------------------------------------------------------------- +# 4. eval_baseline.yaml 相当の YAML ロード検証 +# --------------------------------------------------------------------------- + + +def test_eval_baseline_yaml_equivalent_parses_correctly(): + """eval_baseline.yaml 相当のデータが正しくパースされること. + + 仕様: configs/eval_baseline.yaml の oetf セクション(SPEC_03 正規実装) + """ + # configs/eval_baseline.yaml に対応するデータ + 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}, + "oetf": { + "enabled": True, + "transfer": "srgb", + "exposure": {"mode": "anchor_mean", "target_mean": 146.7146}, + "quantize_uint8": True, + }, + } + cfg = EvaluationConfig.model_validate(data) + assert cfg.oetf is not None + assert cfg.oetf.enabled is True + assert cfg.oetf.transfer == "srgb" + assert cfg.oetf.exposure is not None + assert cfg.oetf.exposure.mode == "anchor_mean" + assert cfg.oetf.exposure.target_mean == pytest.approx(146.7146) + assert cfg.oetf.quantize_uint8 is True + assert cfg.roi.x == 24 + assert cfg.roi.y == 37 + assert cfg.roi.width == 464 + assert cfg.roi.height == 450