diff --git a/CLAUDE.md b/CLAUDE.md index 431845d..937a928 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: ステップ 2(MiniTIAS 照明均一性評価)完了.次はステップ 3 +最新: TECH_05 Phase 1(DNG 読み込み I/F)と動径 min/max 比指標を実装.次は DNG の既存パイプライン統合(TECH_05 Phase 4〜) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは GUIDE_05「進捗記録の運用ルール(CLAUDE.md / PROGRESS.md)」を参照. ## 必須ルール(コード実装時) @@ -64,6 +64,13 @@ - アーキテクチャ設計: docs/04_SPEC/SPEC_01_アーキテクチャ設計.md +### 05_TECH(技術設計) + +- DNG 対応要求仕様: docs/05_TECH/TECH_01_DNG対応要求仕様.md +- SmTIAS-LightSim への引き継ぎ: docs/05_TECH/TECH_02_SmTIAS-LightSimへの引き継ぎ.md + ### 07_REPORT(報告書) - 照明均一性評価報告書: docs/07_REPORT/REPORT_01_照明均一性評価報告書.md +- PNG/DNG 評価比較報告書: docs/07_REPORT/REPORT_02_PNG_DNG評価比較報告書.md +- 外乱光影響評価報告書: docs/07_REPORT/REPORT_03_外乱光影響評価報告書.md 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 1fe9fe5..ebeb2b5 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" @@ -10,6 +10,7 @@ | --- | --- | --- | | NumPy | 数値計算・配列操作 | 画像データの行列演算の基盤 | | OpenCV (`opencv-python`) | 画像の読み込み・前処理 | PNG 読み込み,ROI 抽出,色空間変換に対応 | +| rawpy (`rawpy>=0.21.0`) | DNG (RAW_SENSOR) 読み込み | MiniTIAS 定量モードの 10bit Bayer DNG をデモザイク・linear 展開(libraw ベース) | | colour-science | CIE L\*a\*b\* 変換,ΔE 計算 | CIE 規格準拠の色彩計算を正確に実装済み | | Matplotlib | グラフ・図の生成 | 論文掲載用の高品質な図を出力可能 | | pandas | 数値結果の集計・CSV 出力 | 比較表の作成・データ管理に便利 | diff --git "a/docs/05_TECH/TECH_01_DNG\345\257\276\345\277\234\350\246\201\346\261\202\344\273\225\346\247\230.md" "b/docs/05_TECH/TECH_01_DNG\345\257\276\345\277\234\350\246\201\346\261\202\344\273\225\346\247\230.md" new file mode 100644 index 0000000..b77b907 --- /dev/null +++ "b/docs/05_TECH/TECH_01_DNG\345\257\276\345\277\234\350\246\201\346\261\202\344\273\225\346\247\230.md" @@ -0,0 +1,438 @@ +# MiniTias-Evaluation DNG 対応要求仕様 (DNG Support Specification for MiniTias-Evaluation) + +> **本ドキュメントについて(取り込み注記)**: SmTIAS-LightSim プロジェクトから受領した DNG 対応要求仕様(先方での名称は TECH_05)を,本プロジェクトの命名規則(GUIDE_03)に従い `docs/05_TECH/TECH_01_DNG対応要求仕様.md` として取り込んだもの.文中の他ドキュメント参照(TECH_01/03/04・TEST_01・SPEC_04 等)は **SmTIAS-LightSim 側のドキュメント**を指し,本プロジェクト内には存在しない.本プロジェクトでの対応状況は `docs/PROGRESS.md` を参照. + +本ドキュメントは SmTIAS-LightSim プロジェクトから **MiniTias-Evaluation** プロジェクトに対する **DNG 対応要求仕様** である.MiniTias 側で実装された定量撮影モード([TECH_01](TECH_01_定量撮影モード要求仕様.md) / [TECH_03](TECH_03_定量モード実装報告とSHADING_MODE相談.md))の出力フォーマットが PNG から DNG (RAW_SENSOR) に変更されたことに伴い,MiniTias-Evaluation 側にも DNG 読み込み・解析機能の追加を依頼する. + +## 想定読者 (Intended Reader) + +- **MiniTias-Evaluation 側 Claude セッション**: 本ドキュメントを最初に読んで作業着手する +- **人間レビュー (rinto)**: 両セッションを橋渡しする + +## 概要 (Overview) + +MiniTias-Evaluation([MiniTias-Evaluation](https://github.com/rintoHasegawa/MiniTias-Evaluation) リポジトリ)に以下の機能追加を要求する: + +1. **DNG 読み込み I/F** の新規実装(10-bit BGGR Bayer 展開) +2. **メタデータ JSON 読み込み I/F** の新規実装(撮影条件・LSC マップ・hot pixel リスト等) +3. **既存 PNG 経路と並列対応**(後方互換) +4. **既存評価指標との互換**(CoV / 勾配 / C/P / max-min 等が DNG 入力でも同じロジックで算出可能) + +これらの実装は **SmTIAS-LightSim 側にコピーされる前提**で,再利用性の高い設計とする. + +## 背景 (Background) + +### 三項プロジェクトの関係 + +```text +┌─────────────────────────┐ ┌──────────────────────────┐ +│ MiniTias │ │ MiniTias-Evaluation │ +│ (Flutter / Android) │ ──→ │ (Python) │ +│ 撮影アプリ │ │ 実機データ評価ツール │ +│ - PNG 保存(通常モード) │ │ - 評価指標算出 │ +│ - DNG 保存(定量モード) │ │ - 可視化 │ +└─────────────────────────┘ └──────────────────────────┘ + │ + │ コピー(uniformity.py 等) + ▼ + ┌──────────────────────────┐ + │ SmTIAS-LightSim │ + │ (Python / Mitsuba 3) │ + │ シミュ vs 実機比較 │ + └──────────────────────────┘ +``` + +### 経緯のまとめ + +- 2026-05-28: SmTIAS-LightSim 側 Step 8 キャリブで「実機 PNG 自体が定量データとして揺らぐ」を発見 ([TEST_01](../06_TEST/TEST_01_キャリブレーション検証履歴.md)) +- 2026-05-28: MiniTias 側に [TECH_01 定量撮影モード要求仕様](TECH_01_定量撮影モード要求仕様.md) を発行 +- 2026-06-01: MiniTias 側で実装完了.AQUOS sense3 (SH-02M) が LEVEL_3 で DNG 経路採用 ([TECH_03](TECH_03_定量モード実装報告とSHADING_MODE相談.md)) +- 2026-06-01: SHADING_MODE = HIGH_QUALITY を本流維持で合意 ([TECH_04](TECH_04_MiniTIASへの回答.md)) +- **本ドキュメント (2026-06-01)**: MiniTias-Evaluation 側にも DNG 対応を依頼 + +### MiniTias-Evaluation の現状 + +MiniTias-Evaluation の I/O は現状 PNG 専用: + +```python +# 既存 I/F (src/io/loader.py) +def load_image(path: str) -> numpy.ndarray: + """PNG 画像を読み込み RGB 配列として返す(H x W x 3, uint8).""" +``` + +→ DNG 経路の追加が必要. + +## 役割分担 (Role Distribution) + +DNG 読み込みの責務は **MiniTias-Evaluation を一次窓口**とする.既存パターン(`uniformity.py` が MiniTias-Evaluation から SmTIAS-LightSim にコピー)を踏襲する: + +| プロジェクト | 責務 | +| --- | --- | +| **MiniTias** | DNG + meta.json の保存(実装済) | +| **MiniTias-Evaluation(一次窓口)** | DNG/meta.json の読み込み I/F を正規実装.既存評価指標との並列対応 | +| **SmTIAS-LightSim(コピー側)** | MiniTias-Evaluation の DNG 読み込み I/F をコピー(`uniformity.py` パターン継承) | + +→ MiniTias-Evaluation の実装完了後,SmTIAS-LightSim 側 Claude セッションでコピー作業を実施する. + +## 要求仕様 (Requirements) + +### 1. DNG 読み込み I/F + +新規モジュール `src/io/dng_loader.py` を作成: + +```python +def load_image_dng( + path: str, + demosaic: str = "bilinear", + black_level: int | None = None, + output_format: str = "rgb_float32", +) -> numpy.ndarray: + """MiniTIAS 定量モード DNG を読み込み配列として返す. + + Args: + path: DNG ファイルのパス. + demosaic: デモザイクアルゴリズム.既定 "bilinear". + "nearest"(最近傍)/ "bilinear"(双線形)/ "ahd"(適応的)から選択. + black_level: 黒レベル.None なら meta.json から自動取得 or DNG Exif から取得. + 通常は 64(SH-02M). + output_format: 出力形式.既定 "rgb_float32". + "rgb_float32": (H, W, 3) float32, linear, 値域 [0.0, 1.0](飽和は 1.0 でクリップしない) + "luma_float32": (H, W) float32, linear, 値域 [0.0, 1.0](Rec.709 重み) + "rgb_uint8": (H, W, 3) uint8, sRGB OETF 適用後(既存 PNG 経路と互換) + + Returns: + numpy.ndarray. 形状・型は output_format に依存. + + Raises: + FileNotFoundError: ファイルが存在しない場合. + ValueError: rawpy が DNG を読み込めなかった場合. + """ +``` + +#### 実装方針 + +- `rawpy.imread()` で 10-bit BGGR Bayer を読み込み +- 黒レベル除去(meta.json `sensorCharacteristics.black_level_pattern` から取得,未提供時は DNG Exif) +- デモザイク(`postprocess(demosaic_algorithm=...)` で実装) +- カラーマトリクス適用(DNG Exif `ColorMatrix1` を参照.無彩色シーンでは影響軽微だが選択肢として用意) +- 出力形式の変換 + +#### `output_format` の使い分け + +- `rgb_float32` / `luma_float32`: SmTIAS-LightSim のシミュ vs 実機比較に推奨(linear ドメインで突合) +- `rgb_uint8`: 既存 MiniTias-Evaluation パイプラインとの互換用(既存 `analysis/uniformity.py` 等が uint8 前提の場合に有用) + +### 2. メタデータ JSON 読み込み I/F + +新規モジュール `src/io/metadata_loader.py` を作成: + +```python +def load_metadata(path: str) -> dict: + """MiniTIAS 定量モード meta.json を読み込み辞書として返す. + + Args: + path: meta.json のパス. + + Returns: + 辞書.キー: settings / actual / sensorCharacteristics / lscMap / lscMapRowCount / + lscMapColumnCount / hardwareLevel 等. + 構造は TECH_03 のスキーマ準拠. + + Raises: + FileNotFoundError: ファイルが存在しない場合. + json.JSONDecodeError: JSON 形式が不正な場合. + """ + + +def load_lsc_map(path: str) -> numpy.ndarray: + """meta.json から LSC マップを (4, M, N) 配列として抽出する. + + Args: + path: meta.json のパス. + + Returns: + numpy.ndarray of shape (4, M, N), dtype=float32. + 4 = [R, G_even, G_odd, B], M = lscMapRowCount, N = lscMapColumnCount. + 値域: ≥ 1.0(無補正). + + Raises: + FileNotFoundError / KeyError / ValueError: 仕様外の場合. + """ + + +def load_hot_pixel_list(path: str) -> list[tuple[int, int]]: + """meta.json からホット画素リストを (col, row) のタプルリストとして抽出する. + + Args: + path: meta.json のパス. + + Returns: + ホット画素座標のリスト.MiniTias 側で取得実装後に有効化.未提供時は空リスト. + """ +``` + +### 3. Hot pixel マスク機能 + +メタデータから取得したホット画素を画像上でマスクする機能: + +```python +def mask_hot_pixels( + image: numpy.ndarray, + hot_pixels: list[tuple[int, int]], + method: str = "neighbor_mean", +) -> numpy.ndarray: + """ホット画素位置を周辺平均で置換する. + + Args: + image: 入力画像 (H, W) or (H, W, 3). + hot_pixels: ホット画素座標 (col, row) のリスト. + method: 置換方法."neighbor_mean"(周辺 3x3 平均)/ "zero"(0 で置換). + + Returns: + マスク済み画像.形状・型は入力と同じ. + """ +``` + +### 4. LSC マップ参照機能(オプション・将来検証用) + +本流の処理では使わないが,将来のフォールバック検証用に実装: + +```python +def apply_lsc_inverse( + image: numpy.ndarray, + lsc_map: numpy.ndarray, +) -> numpy.ndarray: + """LSC マップを画像に逆適用する(補正前 raw 相当に戻す). + + Args: + image: 補正済み画像 (H, W) or (H, W, 3).DNG (HIGH_QUALITY) を想定. + lsc_map: LSC ゲインマップ (4, M, N). + + Returns: + 逆適用後の画像.image_raw = image / interpolate(lsc_map, image.shape). + """ +``` + +- LSC マップ (13×17) を画像解像度 (例 3264×2448) にバイリニア補間 +- 画素ごとに割り算で逆適用 +- SHADING_MODE = OFF 撮影との比較検証に使用([TECH_04](TECH_04_MiniTIASへの回答.md) フォールバック条件 C1〜C3) + +### 5. 既存 PNG 経路との並列対応(後方互換) + +既存 `load_image` は **無修正**で温存.新規 DNG 関数を並列追加する形: + +```python +# 既存(無修正) +load_image(path) -> RGB uint8 + +# 新規追加 +load_image_dng(path, ...) -> 設定可能形式 +``` + +ユーザー側で拡張子を見て呼び分ける,もしくは統一インターフェースを別途用意する(後述). + +#### 統一インターフェースの選択肢 + +```python +def load_image_any(path: str, **kwargs) -> numpy.ndarray: + """拡張子から自動判定して読み込む. + + .png → load_image (uint8 RGB) + .dng → load_image_dng (kwargs で形式指定) + """ + ext = Path(path).suffix.lower() + if ext == ".png": + return load_image(path) + elif ext == ".dng": + return load_image_dng(path, **kwargs) + else: + raise ValueError(f"未対応のフォーマット: {ext}") +``` + +これは MiniTias-Evaluation 側の判断で実装するかは任意.推奨は実装. + +### 6. 評価指標との互換 + +既存 `src/analysis/uniformity.py` 等の評価関数は **無修正**で DNG 入力に対応できるよう,DNG 読み込み I/F の出力を既存関数の入力仕様に合わせる: + +| 既存関数の入力仕様 | DNG 経路での対応 | +| --- | --- | +| RGB uint8 (H, W, 3) | `output_format="rgb_uint8"` で uint8 にスケール(10-bit → 8-bit + sRGB OETF 適用) | +| Luma uint8 (H, W) | 上記後に Rec.709 重みでスカラー化 | +| Float32 linear (H, W, 3) | `output_format="rgb_float32"` で直接出力 | + +→ 既存解析パイプラインの修正コストを最小化する設計. + +## データフォーマット詳細 (Data Format Details) + +### DNG (RAW_SENSOR) の値域 + +- 10-bit raw: 整数値 0〜1023 +- 黒レベル: 64(`SENSOR_BLACK_LEVEL_PATTERN` から取得) +- 黒レベル除去後: 0〜959 +- 飽和判定: 値 > 900 を要警告(飽和の影響) +- DNG Exif にカラーマトリクス `ColorMatrix1/2` 等が埋め込み済み(rawpy が自動で参照) + +### sRGB OETF の扱い + +- 定量モード DNG は **linear ドメイン**(TONEMAP_MODE = CONTRAST_CURVE 線形のため sRGB OETF 未適用) +- 既存通常モード PNG は sRGB OETF 適用済(端末の TONEMAP デフォルト) +- 評価時の OETF の扱いは **解析対象次第**: + - 形状指標(CoV / 勾配等): linear ドメインで算出が物理的に整合 + - 既存実機との互換比較: sRGB ドメインで算出(既存 MiniTias-Evaluation の挙動と一致) + +→ `output_format` パラメータでユーザーが選択可能にする.既定は `rgb_float32`(linear)を推奨. + +### デモザイクアルゴリズム + +`rawpy.imread().postprocess(demosaic_algorithm=...)` のパラメータ: + +| 値 | 速度 | 品質 | 用途 | +| --- | --- | --- | --- | +| `rawpy.DemosaicAlgorithm.LINEAR` | 速 | 中 | バイリニア.**既定推奨** | +| `rawpy.DemosaicAlgorithm.AHD` | 中 | 高 | 適応的.カラー画像で有利だが照明均一性評価には過剰 | +| `rawpy.DemosaicAlgorithm.DHT` | 中 | 高 | 高品質.処理重め | + +→ 既定は `LINEAR` で運用.`demosaic` 引数でユーザーが切り替え可能. + +## 推奨実装方針 (Recommended Implementation) + +### ディレクトリ構造(提案) + +MiniTias-Evaluation 既存構造に従い: + +```text +src/io/ +├── __init__.py +├── loader.py # 既存 PNG 読み込み(無修正) +├── dng_loader.py # 新規: DNG 読み込み +├── metadata_loader.py # 新規: meta.json 読み込み +└── unified_loader.py # 新規(任意): 統一 I/F load_image_any +``` + +### 追加依存パッケージ + +`pyproject.toml` の dependencies に: + +```toml +[project] +dependencies = [ + "rawpy>=0.21.0", # DNG 読み込み + # 既存依存はそのまま +] +``` + +または `requirements.txt` に: + +```text +rawpy>=0.21.0 +``` + +### 段階的実装(Phase 分割) + +| Phase | 内容 | 推定工数 | +| --- | --- | --- | +| Phase 1 | DNG 読み込み I/F (`dng_loader.py`) と単体テスト | 1 日 | +| Phase 2 | メタデータ JSON 読み込み I/F (`metadata_loader.py`) と単体テスト | 半日 | +| Phase 3 | LSC マップ参照・hot pixel マスク機能の実装 | 半日 | +| Phase 4 | 既存解析パイプラインとの統合・動作確認 | 半日 | +| Phase 5 | 既存 PNG / 新規 DNG の並列対応検証(V1〜V3) | 半日 | +| Phase 6 | ドキュメント更新(SPEC_01/SPEC_02 等) | 半日 | + +合計: 約 3.5 日.`/implement` パイプラインで Phase ごとに進める. + +## SmTIAS-LightSim 側へのコピー手順 (Copy to SmTIAS-LightSim) + +MiniTias-Evaluation での実装完了後,SmTIAS-LightSim 側 Claude セッションで以下を実施: + +1. **コピー対象ファイル**: + - `MiniTias-Evaluation/src/io/dng_loader.py` → `SmTIAS-LightSim/src/sm_tias_lightsim/evaluation/dng_loader.py` + - `MiniTias-Evaluation/src/io/metadata_loader.py` → `SmTIAS-LightSim/src/sm_tias_lightsim/evaluation/metadata.py` +2. **既存パターンに従う** (`uniformity.py` と同じ流れ) +3. **ファイル先頭にコピー元を明記**: + ```python + """DNG 読み込みモジュール. + + MiniTias-Evaluation/src/io/dng_loader.py を SmTIAS-LightSim にコピーしたもの. + オリジナルは MiniTias-Evaluation 側で管理する.バグ修正・機能追加はそちらで行い, + 本プロジェクトに反映する形を取る. + """ + ``` +4. **既存 sm-evaluate との統合**: 既存 `cli/evaluate.py` に DNG 入力経路を追加 +5. **テストを SmTIAS-LightSim 側でも実装**(MiniTias-Evaluation 側のテストをコピー+プロジェクト固有部分を追加) + +## 検証方法 (Verification) + +実装完了後,以下を実施して受け入れ判定とする: + +### V1. PNG vs DNG 整合性 + +- 同一シーン(白板)を MiniTias で **通常モード PNG** + **定量モード DNG** で連続撮影 +- 両方を MiniTias-Evaluation で評価 +- 評価指標(CoV / 勾配 / C/P 等)の差を確認 + - **完全一致は期待しない**(定量モードは ISP 介入最小化のため差が出るのが正常) + - **DNG 経路でも妥当な値が出る**ことを確認(CoV > 0, 勾配 > 0 等の基本検証) + +### V2. 既存 PNG 経路の回帰 + +- 既存通常モード PNG での評価指標が **変化しない**こと +- 既存テストが全合格すること + +### V3. メタデータ JSON 読み込み + +- `load_metadata` で `settings.exposure_time_ns` 等が正しく取得できる +- `load_lsc_map` で (4, 13, 17) の配列が返る +- `load_hot_pixel_list` で空リスト or リスト(MiniTias 側実装次第)が返る + +### V4. SmTIAS-LightSim 側へのコピー後検証 + +- コピー後の SmTIAS-LightSim 側で同じ DNG を読み込んで **同じ評価指標**が出ること +- SmTIAS-LightSim の既存テストが全合格 + +## 開放質問 (Open Questions) + +実装着手時に確認が必要な事項: + +| ID | 質問 | 確認方法 | +| --- | --- | --- | +| Q1 | rawpy のバージョン互換性は問題ないか | 環境で `pip install rawpy` → 実 DNG で動作確認 | +| Q2 | デモザイク手法の既定値は `LINEAR` で OK か | 数枚の DNG で AHD と比較して照明均一性指標がほぼ同じか確認 | +| Q3 | カラーマトリクス適用は無彩色シーンで影響軽微か | 既定 OFF(rawpy デフォルト)か ON(DNG Exif 参照)かを実機で比較 | +| Q4 | 既存 `analysis/uniformity.py` は uint8 / float32 のどちらを受けるか | コードを確認.既存仕様に合わせて `output_format` の既定を決める | +| Q5 | 統一 I/F `load_image_any` を実装するか | MiniTias-Evaluation 側判断.SmTIAS-LightSim 側では便利だが任意 | +| Q6 | LSC 逆適用機能は Phase 3 で実装するか後回しか | フォールバック条件 C1〜C3 未発動なので Phase 3 では雛形のみで OK | +| Q7 | 既存ドキュメント (SPEC_01/SPEC_02) の更新範囲 | DNG 経路追加に伴う仕様変更を反映.Phase 6 で対応 | + +## 引き継ぎ手順 (Hand-off Procedure) + +MiniTias-Evaluation 側 Claude セッションでの作業着手手順.[TECH_02 MiniTIAS 引き継ぎ手順](TECH_02_MiniTIAS引き継ぎ手順.md) と同様の構造. + +### Step A: 環境準備 + +- rawpy パッケージを追加(`pip install rawpy` または `pyproject.toml` 更新 + `uv sync` 等) +- サンプル DNG を取得(MiniTias 側で撮影済の `reference_measurement/quantitative/` から 1 枚) + +### Step B: Phase 1(DNG 読み込み)から順次実装 + +- `/implement TECH_05 Phase 1: DNG 読み込み I/F` で起動 +- coder → tester → refactorer → doc 更新の標準パイプライン +- Phase 1 完了後に動作確認 → Phase 2 へ + +### Step C: 完了の伝達方法 + +- MiniTias-Evaluation 側で `/implement` 完了後,SmTIAS-LightSim 側で確認できる形にする: + - MiniTias-Evaluation `src/io/dng_loader.py` の最終形をリポジトリ master にマージ + - rinto が SmTIAS-LightSim 側 Claude セッションを開いて「コピーしてほしい」と指示 + - SmTIAS-LightSim 側 Claude が MiniTias-Evaluation のローカルリポジトリから読み込んでコピー + +## 既存ドキュメントとの整合 (Cross-References) + +- [TECH_01 定量撮影モード要求仕様](TECH_01_定量撮影モード要求仕様.md) — 撮影モード側の要求書 +- [TECH_02 MiniTIAS 引き継ぎ手順](TECH_02_MiniTIAS引き継ぎ手順.md) — 引き継ぎパターンの参考 +- [TECH_03 定量モード実装報告と SHADING_MODE 相談](TECH_03_定量モード実装報告とSHADING_MODE相談.md) — MiniTias 側実装報告(meta.json スキーマ等) +- [TECH_04 MiniTIAS への回答](TECH_04_MiniTIASへの回答.md) — SHADING_MODE 判断とフェーズ A/B 分割 +- [TEST_01 キャリブレーション検証履歴](../06_TEST/TEST_01_キャリブレーション検証履歴.md) — 経緯 +- [TEST_03 輝度マップ形状追跡](../06_TEST/TEST_03_輝度マップ形状追跡.md) — 形のずれ議論 +- [SPEC_04 評価仕様](../04_SPEC/SPEC_04_評価仕様.md) — sm-evaluate パイプライン +- [Android Camera2 API DNG](https://developer.android.com/reference/android/hardware/camera2/DngCreator) +- [rawpy ドキュメント](https://letmaik.github.io/rawpy/api/) diff --git "a/docs/05_TECH/TECH_02_SmTIAS-LightSim\343\201\270\343\201\256\345\274\225\343\201\215\347\266\231\343\201\216.md" "b/docs/05_TECH/TECH_02_SmTIAS-LightSim\343\201\270\343\201\256\345\274\225\343\201\215\347\266\231\343\201\216.md" new file mode 100644 index 0000000..3db0f13 --- /dev/null +++ "b/docs/05_TECH/TECH_02_SmTIAS-LightSim\343\201\270\343\201\256\345\274\225\343\201\215\347\266\231\343\201\216.md" @@ -0,0 +1,72 @@ +# SmTIAS-LightSim への引き継ぎ (Hand-off to SmTIAS-LightSim) + +本ドキュメントは,MiniTias-Evaluation で実装した **DNG 読み込み I/F** を SmTIAS-LightSim 側へコピー・統合するための引き継ぎ書である.要求仕様は [TECH_01](TECH_01_DNG対応要求仕様.md)(SmTIAS-LightSim 側での名称は TECH_05). + +## 1. 実装スコープ (What is done / not done) + +TECH_01 の Phase 分割のうち,本引き継ぎ時点で完了しているのは **Phase 1(DNG 読み込み I/F)のみ**. + +| TECH_01 Phase | 内容 | 状態 | +| --- | --- | --- | +| Phase 1 | DNG 読み込み I/F (`dng_loader.py`) | ✅ **完了**(コピー対象) | +| Phase 2 | メタデータ JSON 読み込み I/F (`metadata_loader.py`) | ❌ 未実装 | +| Phase 3 | LSC マップ参照・hot pixel マスク | △ LSC 順適用はプロトタイプのみ(`run_uniformity_dng.py` 内) | +| Phase 4〜6 | 既存パイプライン統合 / PNG vs DNG / SPEC 更新 | ❌ 未着手 | + +## 2. コピー対象ファイル (Files to copy) + +| コピー元(MiniTias-Evaluation) | コピー先(SmTIAS-LightSim) | 区分 | +| --- | --- | --- | +| `src/io/dng_loader.py` | `src/sm_tias_lightsim/evaluation/dng_loader.py` | ★ 正規コピー対象 | +| `scripts/run_uniformity_dng.py` | (参考)`cli/` 等に統合 | LSC 順適用・向き合わせ・評価フローの参考 | +| `src/analysis/spatial.py` の `calc_radial_min_max_ratio` | 既存 spatial/uniformity 相当へ**マージ** | 動径 min/max 比指標(追加指標) | + +- **ファイル先頭にコピー元を明記**(TECH_01 指定): + ```python + """DNG 読み込みモジュール. + + MiniTias-Evaluation/src/io/dng_loader.py を SmTIAS-LightSim にコピーしたもの. + オリジナルは MiniTias-Evaluation 側で管理する.バグ修正・機能追加はそちらで行い, + 本プロジェクトに反映する形を取る. + """ + ``` +- `spatial.py` は SmTIAS-LightSim 側の既存均一性コードと競合しうるため,**丸ごとコピーせず `calc_radial_min_max_ratio` のみマージ**すること. + +## 3. ★重要な設計判断・注意点(コピーだけでは伝わらない) + +以下は実装の前提であり,**TECH_01(要求仕様)の記述と食い違う点を含む**.必ず反映すること. + +1. **DNG は LSC 未補正 — `apply_lsc_inverse` は誤り**: RAW_SENSOR DNG はレンズシェーディング**未補正**(補正は DNG OpcodeList2 の GainMap / meta.json lscMap として付随するが rawpy は適用しない).TECH_01 の `apply_lsc_inverse`(「DNG=補正済み」前提で逆適用)は実態と逆.照明ムラを測るには **lscMap を順方向に適用**してレンズビネットを除去する(`image × interpolate(lscMap)`). +2. **linear 系出力は `output_color=raw`**: カラーマトリクスを通すとチャンネル混合で per-channel 比例が崩れる.`rgb_float32`/`luma_float32` は `output_color=rawpy.ColorSpace.raw` で読む(`rgb_uint8` のみ sRGB).これにより入射光量とセンサ信号の比例(放射輝度リニアリティ)を担保. +3. **向き**: DNG はセンサ native が **landscape**.PNG/preview と同じ portrait にするには `rot90(-1)`(左右反転なし.フロントカメラ id=1 の preview はミラーされるが RAW はミラーされない). +4. **luma の Rec.709 は固定加重プロキシ**: `output_color=raw` では Rec.709 luma は厳密な CIE 輝度ではなく,既存 PNG 経路互換の固定加重. +5. **PNG は厳密に linear 化できない**: トーンカーブ・8bit・透過関数不明のため.DNG 採用の根拠([REPORT_02](../07_REPORT/REPORT_02_PNG_DNG評価比較報告書.md)). + +## 4. 引き渡す評価結果(暗闇=MiniTIAS のみ) + +代表結果は **外乱光に汚染されていない「MiniTIAS の照明のみ」の撮影**(`MiniTIAS_QM_20260601_124500`,室内灯 OFF)を採用する.外乱光の有無で評価はほぼ変わらない([REPORT_03](../07_REPORT/REPORT_03_外乱光影響評価報告書.md))が,純粋な装置照明の均一性を代表させるため暗闇条件を選定. + +評価条件: linear(output_color=raw)/ portrait / LSC 補正あり / 白板 ROI. + +| 指標 | 値 | +| --- | --- | +| CoV | 0.0695 | +| max/min 比 | 1.617 | +| 中心/周辺比 | 1.0911 | +| 勾配 (%) | 8.35 | +| 動径 min/max 比 | 0.8600 | + +成果物(図・CSV)はハンドオフ束 `handoff_smtias_lightsim/results_dark_124500/` に同梱. + +## 5. コピー後の検証 (Verification — TECH_01 V4) + +- コピー後の SmTIAS-LightSim 側で**同じ DNG**(`MiniTIAS_QM_20260601_124500.dng`)を読み込み,**上表と同じ評価指標**が出ること. +- SmTIAS-LightSim の既存テストが全合格すること. +- 依存追加: `rawpy>=0.21.0`. + +## 6. 参照 (References) + +- [TECH_01 DNG 対応要求仕様](TECH_01_DNG対応要求仕様.md) +- [REPORT_02 PNG/DNG 評価比較報告書](../07_REPORT/REPORT_02_PNG_DNG評価比較報告書.md) +- [REPORT_03 外乱光影響評価報告書](../07_REPORT/REPORT_03_外乱光影響評価報告書.md) +- 設計判断・経緯の詳細: `docs/PROGRESS.md` diff --git "a/docs/07_REPORT/REPORT_02_PNG_DNG\350\251\225\344\276\241\346\257\224\350\274\203\345\240\261\345\221\212\346\233\270.md" "b/docs/07_REPORT/REPORT_02_PNG_DNG\350\251\225\344\276\241\346\257\224\350\274\203\345\240\261\345\221\212\346\233\270.md" new file mode 100644 index 0000000..db04287 --- /dev/null +++ "b/docs/07_REPORT/REPORT_02_PNG_DNG\350\251\225\344\276\241\346\257\224\350\274\203\345\240\261\345\221\212\346\233\270.md" @@ -0,0 +1,101 @@ +# PNG / DNG 照明均一性評価 比較報告書 (PNG vs DNG Uniformity Evaluation Comparison) + +## 概要 (Overview) + +MiniTIAS 撮影アプリの定量撮影モードの出力フォーマットが **PNG から DNG (RAW_SENSOR) に変更**された([TECH_01](../05_TECH/TECH_01_DNG対応要求仕様.md))ことに伴い,照明均一性評価の結果が PNG 経路と DNG 経路でどのように変わるかを比較した. + +結論を先に述べると,**PNG→DNG は単なるファイル形式の変更ではなく,「何を測っているか」が変わる**.PNG は sRGB エンコード済み・端末 ISP でレンズシェーディング補正済みの「処理後」データであり,DNG は linear・補正前の「物理生信号」である.このため評価指標の数値は大きく変化する. + +## 前提・対象データ (Scope and Samples) + +| 項目 | 内容 | +| --- | --- | +| PNG サンプル | `data/minitias/whiteboard/MiniTIAS_20260408_140317.png`(2026-04-08 撮影) | +| DNG サンプル | `data/minitias/quantitative/MiniTIAS_QM_20260601_102753.dng`(2026-06-01 撮影,SH-02M 定量モード) | +| 評価領域 (ROI) | `config/roi_config.json` の `minitias.whiteboard`(白板中央,全条件で同一) | +| 向き | portrait に統一(DNG は landscape native のため `rot90(-1)` で PNG と整合) | +| 評価指標 | CoV / max-min比 / 中心-周辺比 / 勾配 / 動径 min/max 比(20点輪帯平均の最小/最大) | + +> **重要な前提(本報告書の限界)**: PNG(4月)と DNG(6月)は**別セッションの撮影でフレーミングが異なる**.したがって本報告書は,TECH_01 の検証 V1 が要求する「同一シーンを PNG/DNG で連続撮影した厳密な対照比較」**ではない**.**評価手法・ドメインの違いが指標に与える影響**を既存サンプルで定量的に示すものである.厳密な同一シーン比較は今後の課題(後述). + +## 評価条件(ドメイン)の違い (Domain Differences) + +PNG 経路と DNG 経路は,評価に入る前の信号の性質が根本的に異なる. + +| 観点 | PNG(従来) | DNG(新規・定量モード) | +| --- | --- | --- | +| 信号ドメイン | **sRGB**(OETF/ガンマ適用済み) | **linear**(TONEMAP=CONTRAST_CURVE_LINEAR,OETF 未適用) | +| レンズシェーディング | **端末 ISP で補正済み**(SHADING_MODE=HIGH_QUALITY が YUV/JPEG に適用) | **未補正**(RAW_SENSOR は ISP 前.補正は DNG OpcodeList2 の GainMap / meta.json の lscMap として付随するのみ) | +| ビット深度 | 8bit | 10bit | +| 入射光量との関係 | ガンマで非線形 | **比例(放射輝度リニア)** | + +→ つまり PNG をそのまま評価した「従来値」と,DNG を物理的に正しく評価した値は,**同じものを測っていない**.公平に比較するにはドメインを揃える必要がある. + +## 結果比較 (Results) + +同一 ROI で算出した均一性指標(数値が大きいほどムラが大きい.動径 min/max のみ 1.0 に近いほど均一): + +| 条件 | CoV | max/min | 中心/周辺比 | 勾配(%) | 動径 min/max | +| --- | --- | --- | --- | --- | --- | +| **PNG sRGB(従来評価)** | 0.0423 | 1.387 | 1.066 | 6.23 | 0.8964 | +| PNG 擬似linear化(inverse-sRGB 仮定)※ | 0.0901 | 2.031 | 1.148 | 12.88 | 0.7902 | +| DNG linear(LSC 補正なし) | 0.1477 | 2.287 | 1.456 | 31.33 | 0.5304 | +| **DNG linear(LSC 補正あり)** | 0.0689 | 1.641 | 1.095 | 8.69 | 0.8533 | + +> ※ **「PNG 擬似linear化」は近似であり,真のシーン linear(入射光量比例)ではない**.詳細は考察「4. PNG は厳密には linear 化できない」を参照. + +## 考察:なぜ結果が変わるのか (Analysis) + +数値変化は主に3つの要因に分解できる. + +### 1. ドメイン(sRGB ガンマ)の影響 — 最大の要因 + +同じ PNG データでも,**sRGB のまま(CoV 0.0423)か擬似linear化(CoV 0.0901)かで CoV が約2倍**変わる(撮影は同一,ドメインのみ変更).sRGB の OETF は暗部を持ち上げ明部を圧縮するため,**ムラを実際より穏やかに見せる**.従来の PNG 評価値(CoV 0.042)は,このガンマ圧縮により**楽観的に小さく出ていた**. + +なお CoV・max/min・動径比は**スケール不変**なので,0〜255 と 0〜1 のスケール差では変わらない.変化はあくまでドメイン(ガンマ)によるもの. + +### 2. レンズシェーディング補正の有無 + +DNG raw は未補正のため,**レンズビネット(端ほど暗い)が指標に混入**する.LSC 補正なしの DNG は中心/周辺比 1.456・勾配 31.3%・動径 min/max 0.530 と大きなムラを示すが,これは**照明ムラではなくレンズ特性**を多く含む.lscMap を順方向適用してレンズビネットを除去すると,中心/周辺比 1.456→1.095,勾配 31.3%→8.7%,動径 min/max 0.530→0.853 と**劇的に平坦化**する(残りが照明そのもののムラ). + +### 3. 撮影セッションの違い + +PNG(4月)と DNG(6月)は別撮影で,照明配置・フレーミング・距離が異なる.この差も数値に含まれるため,厳密な大小比較はできない(前提参照). + +### 4. PNG は厳密には linear 化できない(重要な限界) + +上表の「PNG 擬似linear化」は inverse-sRGB OETF を掛けたものだが,これは**真のシーン linear(入射光量比例)を復元しない**.理由: + +- **透過関数が不明**: 本サンプル PNG は ICC プロファイル・gAMA・EXIF を一切持たず,**sRGB である保証すらない**(sRGB と仮定して逆変換しているだけ). +- **トーンカーブが残る(最大の壁)**: 端末 PNG は `linear生信号 →[WB・カラー行列・レンズ補正・トーンカーブ・NR・シャープ化]→ 表示RGB → sRGB符号化 → 8bit` を経る.inverse-sRGB が戻せるのは最後の符号化だけで,得られるのは「**表示参照 linear**」.間のトーンカーブ(端末固有・非公開のコントラスト曲線)は焼き込まれたままで,入射光量には比例しない. +- **不可逆な損失**: 8bit 量子化(暗部の段差が逆ガンマで増幅)・クリップ(白飛び/黒潰れ)は復元不能. + +→ つまり **PNG は確実には linear 化できない**.これこそが定量 DNG モード採用の理由([TECH_01](../05_TECH/TECH_01_DNG対応要求仕様.md) 背景「実機 PNG 自体が定量データとして揺らぐ」)であり,DNG (RAW_SENSOR, TONEMAP=CONTRAST_CURVE_LINEAR) は最初から真のシーン linear を出すことでこの問題を回避する. + +### 公平な比較(ドメインを揃える,ただし近似) + +両者を linear ドメインに寄せると比較の目安は得られる(PNG は上記の通り**擬似linear=近似**である点に注意): + +- **PNG 擬似linear化: CoV 0.0901**(近似) vs **DNG linear+LSC: CoV 0.0689**(真の linear) +- → 目安としては **DNG(LSC補正)の方が均一**.従来の「PNG 0.042 < DNG 0.069 で DNG が悪い」という見え方は,sRGB ガンマが PNG のムラを圧縮していた錯覚. +- ただし PNG 側が近似である以上この比較は厳密ではなく,**確実な比較には同一シーンを DNG で撮影することが必要**(後述 残課題). + +## 結論と推奨 (Conclusion) + +1. **PNG→DNG の移行で評価値が変わるのは正常**.主因は形式ではなく,sRGB→linear のドメイン変化とレンズシェーディング補正の有無. +2. **物理的に正しい照明均一性評価は DNG linear + LSC 補正経路**で行うべき.入射光量に比例した信号で,レンズ特性を分離して照明ムラのみを測れる. +3. 従来の **PNG sRGB 評価値は,ガンマ圧縮によりムラを過小評価**していた点に注意.過去の PNG 値と DNG 値を近づけるには PNG を擬似linear化(inverse-sRGB)できるが,**PNG は真のシーン linear を復元できない**(トーンカーブ・8bit・透過関数不明のため).したがって**厳密な比較には DNG での再撮影が必要**. +4. ノイズに弱い画素単位 max/min 比に代わり,**動径 min/max 比(20点輪帯平均)**をロバストな減衰指標として併用する. + +## 残課題 (Open Issues) + +- **厳密な V1 比較**: 同一シーン(白板)を同一フレーミングで PNG(通常モード)+ DNG(定量モード)連続撮影し,同条件で比較する([TECH_01](../05_TECH/TECH_01_DNG対応要求仕様.md) 検証 V1). +- **ROI のフレーミング差対応**: PNG/DNG で白板の写る位置・スケールが異なるため,キャプチャごとの ROI 調整または同一フレーミング撮影が必要(TECH_01 Phase 5). +- **LSC 順適用機能の正式実装**: 現状は検証スクリプトでの順適用.本流の評価フローへ正式統合する(TECH_01 Phase 3〜4). + +## 参照 (References) + +- [TECH_01 DNG 対応要求仕様](../05_TECH/TECH_01_DNG対応要求仕様.md) +- [REPORT_01 照明均一性評価報告書](REPORT_01_照明均一性評価報告書.md) +- 評価実装: `src/io/dng_loader.py`, `src/analysis/spatial.py`, `scripts/run_uniformity_dng.py` +- 設計判断・経緯の詳細: `docs/PROGRESS.md` diff --git "a/docs/07_REPORT/REPORT_03_\345\244\226\344\271\261\345\205\211\345\275\261\351\237\277\350\251\225\344\276\241\345\240\261\345\221\212\346\233\270.md" "b/docs/07_REPORT/REPORT_03_\345\244\226\344\271\261\345\205\211\345\275\261\351\237\277\350\251\225\344\276\241\345\240\261\345\221\212\346\233\270.md" new file mode 100644 index 0000000..694ebca --- /dev/null +++ "b/docs/07_REPORT/REPORT_03_\345\244\226\344\271\261\345\205\211\345\275\261\351\237\277\350\251\225\344\276\241\345\240\261\345\221\212\346\233\270.md" @@ -0,0 +1,77 @@ +# 外乱光影響評価報告書 (Ambient Light Influence Evaluation) + +## 概要 (Overview) + +MiniTIAS の照明均一性測定が,**外部の室内照明(外乱光)にどれだけ影響を受けるか**を評価した.SmTIAS 装置自前の照明のみで撮影した DNG と,それに室内灯(外乱光)を加えて撮影した DNG を,同一指標で比較する. + +結論を先に述べると,**外乱光の有無で評価結果はほぼ変わらない(指標差 1% 未満)**.SmTIAS 自前照明が支配的で,測定は室内照明環境に対してロバストである. + +## 前提・対象データ (Scope and Samples) + +| 項目 | 内容 | +| --- | --- | +| 条件 A(MiniTIAS のみ) | `MiniTIAS_QM_20260601_124500`(室内灯 OFF,2026-06-01 12:45 撮影) | +| 条件 B(MiniTIAS + 外光) | `MiniTIAS_QM_20260601_102753`(室内灯 ON,2026-06-01 10:27 撮影) | +| 機種 / モード | SH-02M / 定量モード(RAW_SENSOR,露出・ISO 等は両者同一固定) | +| 評価領域 (ROI) | `config/roi_config.json` の `minitias.whiteboard`(白板中央,両者同一) | +| 評価条件 | linear(output_color=raw)/ portrait / LSC 補正あり・なし両方 | + +> **重要な前提(本評価の限界)**: 条件 A・B は**別セッションの撮影**(時刻が異なり,三脚固定でないため微小な位置ずれの可能性).したがって「外乱光の純粋な寄与」を厳密に分離したものではなく,**外乱光の影響がセッション間ばらつきと比べて支配的かどうか**を判定する位置づけである.厳密な切り分けは今後の課題(後述). + +## 結果 (Results) + +### raw センサ統計(黒レベル=64,全画素) + +| 条件 | raw mean | min | max | std | mean R/G/B | +| --- | --- | --- | --- | --- | --- | +| A: MiniTIAS のみ | 441.2 | 89 | 983 | 161.0 | 355.9 / 537.4 / 333.6 | +| B: MiniTIAS + 外光 | 431.4 | 90 | 949 | 155.7 | 349.4 / 525.4 / 325.1 | + +### 均一性指標(白板 ROI,数値が大きいほどムラ大.動径 min/max のみ 1.0 で均一) + +| 条件 | ROI mean | CoV | max/min | 中心/周辺比 | 勾配(%) | 動径 min/max | +| --- | --- | --- | --- | --- | --- | --- | +| **A: MiniTIAS のみ**(LSC 補正) | 207.82 | 0.0695 | 1.617 | 1.0911 | 8.35 | 0.8600 | +| **B: MiniTIAS + 外光**(LSC 補正) | 208.77 | 0.0689 | 1.641 | 1.0952 | 8.69 | 0.8533 | +| A: MiniTIAS のみ(LSC なし) | 170.58 | 0.1464 | — | 1.4511 | 31.09 | 0.5343 | +| B: MiniTIAS + 外光(LSC なし) | 171.39 | 0.1477 | — | 1.4563 | 31.33 | 0.5304 | +| **差(A−B,LSC 補正)** | −0.95 | +0.0006 | −0.024 | −0.004 | −0.34 | +0.007 | + +輝度マップ・ヒストグラムも両条件でほぼ重なり,空間パターン・分布形状に有意な違いは見られない. + +## 考察 (Analysis) + +### 1. 均一性指標はほぼ同一 — 外乱光の影響は評価に表れない + +LSC 補正後の CoV は 0.0695(A)vs 0.0689(B),勾配は 8.35%(A)vs 8.69%(B),動径 min/max は 0.860(A)vs 0.853(B)と,**すべて 1% 未満の差**.外乱光を加えても照明均一性の評価結果は実質的に変わらない.SmTIAS 自前照明が白板を支配的に照らしており,室内照明の寄与は相対的に微小と考えられる. + +### 2. 「外光を足したのに暗い」という矛盾 — 外乱光の寄与はばらつき以下 + +外乱光は信号を**加える**しかできないため,物理的には B(MiniTIAS+外光)≧ A(MiniTIAS のみ)の明るさになるはずである.しかし実測では **A の方が明るい**(raw mean 441.2 vs 431.4,R/G/B 各チャンネルとも A が上). + +これは,外乱光の寄与が **セッション間のばらつき(≈2〜3%:撮影時刻 10:27 vs 12:45 の差,微小な位置ずれ,照明のドリフト等)に埋もれている**ことを意味する.すなわち **外乱光の影響 < セッション間ばらつき**であり,外乱光の純寄与はこのデータからは分離できないほど小さい. + +### 3. 評価上の含意 + +照明均一性評価(CoV・勾配等)に対して,**通常の室内照明環境は実害を与えない**.これは測定器としての SmTIAS にとって望ましい性質(外乱光に対するロバスト性)である. + +## 結論 (Conclusion) + +1. **外乱光(室内照明)の有無で,照明均一性の評価指標はほぼ変わらない(差 1% 未満)**.SmTIAS 自前照明が支配的. +2. 明るさの差(≈2〜3%)はむしろ「MiniTIAS のみ」の方が明るく,物理的に逆向き.→ **外乱光の寄与はセッション間ばらつき以下**で,測定には埋もれて見えない. +3. したがって **MiniTIAS の照明均一性測定は,通常の室内照明環境に対してロバスト**と判断できる. + +## 限界と厳密な切り分け方法 (Limitations & Controlled Test) + +本評価は別セッション撮影のため,外乱光の純寄与を 2〜3% のばらつきから分離できていない.外乱光の影響「だけ」を正確に測るには,以下の対照実験が必要: + +- **位置・露出を完全固定**(三脚等)し,**室内灯のみを ON/OFF** した連続2枚を撮影する(A: MiniTIAS のみ / B: MiniTIAS + 外光). +- 差分画像 **B − A** が外乱光の純寄与.これにより「一様なオフセットか/窓・照明方向の勾配か」「何 DN 乗るか」まで定量できる. +- この方式ならセッション間ばらつきを排除でき,外乱光の空間分布・絶対量を分離評価できる. + +## 参照 (References) + +- [REPORT_02 PNG/DNG 評価比較報告書](REPORT_02_PNG_DNG評価比較報告書.md) +- [TECH_01 DNG 対応要求仕様](../05_TECH/TECH_01_DNG対応要求仕様.md) +- 評価実装: `scripts/run_uniformity_dng.py`(linear + LSC 補正経路) +- 対象データ: `data/minitias/quantitative/`(gitignore 対象) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index fb9f89d..8c81cb2 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -12,3 +12,11 @@ - [x] **ステップ 2: MiniTIAS 照明均一性評価 完了**: 照明均一性の評価指標算出・可視化を実装し,REPORT_01(照明均一性評価報告書)を作成済み.次はステップ 3. - 注: 本エントリはテンプレート同期で PROGRESS.md を新設した際の移記ベースライン(詳細経緯は `git log` を参照).以降の進捗は本ファイル末尾に追記する. + +- [x] **TECH_05 Phase 1: DNG 読み込み I/F + 動径 min/max 比指標**: MiniTias 定量モードの出力が PNG→DNG(RAW_SENSOR) に変わったため DNG 読み込み I/F(`src/io/dng_loader.py`)を新設.あわせて照明均一性に動径 min/max 比指標を追加.SmTIAS-LightSim へのコピー前提で再利用性重視. + - **設計判断(線形性)**: linear 系出力は `output_color=raw`(カラーマトリクス非適用)とし,入射光量とセンサ信号の比例(放射輝度リニアリティ)を担保.カラーマトリクス経路はチャンネル混合で per-channel 比例が崩れるため不採用.luma は既存 PNG 経路互換のため Rec.709 維持(raw 経路では厳密な CIE 輝度でなく固定加重プロキシ). + - **重要な発見(仕様との食い違い)**: RAW_SENSOR DNG はレンズシェーディング**未補正**(補正は DNG の OpcodeList2 GainMap・meta.json lscMap として付随するが rawpy は適用しない/lscMap ゲインは 1〜5).TECH_05 の `apply_lsc_inverse` は「DNG=補正済み」前提だが実際は未補正.照明ムラを測るには lscMap を**順方向適用**してレンズビネットを除去する向きで Phase 3 を設計すること.LSC 順適用で白板 ROI の CoV は約半減を実測. + - **向き**: DNG はセンサ native が landscape,PNG/preview は portrait.PNG と揃えるには `rot90(-1)`(左右反転なし).フロントカメラ(id=1) の preview はミラーされるが RAW はミラーされないため反転不要(ラベル位置で確認). + - **PNG vs DNG 比較の注意**: 違いはスケール(0-1/0-255)でなくドメイン(PNG=sRGB / DNG=linear).CoV 等はスケール不変なので,公平比較には PNG を inverse-sRGB で linear 化する必要がある. + - **動径 min/max 比**: 画素単位 max/min は外れ画素に弱いため,20点輪帯平均の min/max をロバスト指標として追加. + - **後段(未着手)**: TECH_05 Phase 4(既存パイプライン統合)/ Phase 5(PNG vs DNG,撮影フレーミング差への ROI 対応)/ Phase 6(SPEC_01/02 更新)/ ビューア(viewer.py) の DNG 対応.SPEC のフル更新は TECH_05 Phase 6 に委ねる.`scripts/run_uniformity_dng.py` は PNG 版の DNG 対応ランナーとして暫定追加(共通化は将来). diff --git a/pyproject.toml b/pyproject.toml index fc13c4f..7a1347a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ requires-python = ">=3.11" dependencies = [ "jinja2", + "rawpy>=0.21.0", ] [tool.pytest.ini_options] diff --git a/requirements.txt b/requirements.txt index 7ef9f0e..8dd4fe5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ streamlit jinja2 scikit-image +rawpy>=0.21.0 diff --git a/scripts/run_uniformity.py b/scripts/run_uniformity.py index 53f4906..b79741a 100644 --- a/scripts/run_uniformity.py +++ b/scripts/run_uniformity.py @@ -129,6 +129,7 @@ print(f" 周辺平均輝度 : {zone_stats['periphery_mean']:.2f}") print(f" 中心/周辺比 : {zone_stats['center_periphery_ratio']:.4f}") print(f" 勾配量 (%) : {zone_stats['gradient_magnitude']:.2f}") + print(f" 動径min/max比 : {spatial['radial_min_max_ratio']:.4f}") # 可視化 luminance_map_path = str(output_base / "figures" / f"{stem}_luminance_map.png") @@ -149,7 +150,11 @@ print(f"解析完了: {image_path}") - return {"image_name": stem, **results, **zone_stats}, luminance + radial_ratio = { + "radial_min_max_ratio": spatial["radial_min_max_ratio"], + "radial_min_distance": spatial["radial_min_distance"], + } + return {"image_name": stem, **results, **zone_stats, **radial_ratio}, luminance def main() -> None: diff --git a/scripts/run_uniformity_dng.py b/scripts/run_uniformity_dng.py new file mode 100644 index 0000000..88d74ed --- /dev/null +++ b/scripts/run_uniformity_dng.py @@ -0,0 +1,258 @@ +"""MiniTIAS 定量モード DNG の照明均一性評価スクリプト(PNG 版 run_uniformity.py の DNG 対応版). + +PNG 版と同じ出力一式(輝度マップ / ヒストグラム / 動径プロファイル / ゾーンマップ / +均一性 CSV / コンソール指標)を,DNG に対して同じ ROI・同じ向き(portrait)で出力する. + +PNG 版との違い: +- 読み込みを load_image_dng(linear, output_color=raw)に置換 +- DNG は RAW_SENSOR で未補正のため,meta.json の lscMap でレンズシェーディングを順適用(--no-lsc で無効化可) +- DNG はセンサ native が landscape のため,PNG と同じ portrait へ rot90(-1) で向き合わせ + +使い方: + python scripts/run_uniformity_dng.py --image data/minitias/quantitative/xxx.dng +""" + +import argparse +import json +import sys +from pathlib import Path + +import numpy as np + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from src.analysis.spatial import calc_spatial_uniformity # noqa: E402 +from src.analysis.uniformity import ( # noqa: E402 + REC709_COEFF_B, + REC709_COEFF_G, + REC709_COEFF_R, + calc_uniformity, +) +from src.export.exporter import ensure_output_dirs, export_results # noqa: E402 +from src.io.dng_loader import load_image_dng # noqa: E402 +from src.io.loader import extract_roi # noqa: E402 +from src.visualization.plotter import ( # noqa: E402 + plot_histogram, + plot_luminance_map, + plot_radial_profile, + plot_zone_map, +) + + +def load_roi_config(config_path: str, roi_key: str) -> dict | None: + """ROI 設定ファイルを読み込んで指定キーの ROI を返す. + + Args: + config_path: JSON 設定ファイルのパス. + roi_key: ドット区切りのキー(例: "minitias.whiteboard"). + + Returns: + ROI の辞書(x, y, width, height).キーが存在しない場合は None. + + Raises: + FileNotFoundError: 設定ファイルが存在しない場合. + """ + config_file = Path(config_path) + if not config_file.exists(): + raise FileNotFoundError(f"設定ファイルが見つかりません: {config_path}") + + with config_file.open(encoding="utf-8") as f: + config = json.load(f) + + # ドット区切りでネストされたキーを辿る + node = config + for key in roi_key.split("."): + if not isinstance(node, dict) or key not in node: + return None + node = node[key] + + return node if isinstance(node, dict) else None + + +def _upsample(grid: np.ndarray, shape: tuple[int, int]) -> np.ndarray: + """(rows, cols) グリッドを画像サイズ shape へバイリニア補間する. + + Args: + grid: 補間元グリッドの NumPy 配列(rows x cols, float64). + shape: 補間先の画像サイズ (height, width). + + Returns: + 補間後の NumPy 配列(height x width, float64). + """ + rows, cols = grid.shape + h, w = shape + ys = np.linspace(0, rows - 1, h) + xs = np.linspace(0, cols - 1, w) + y0 = np.floor(ys).astype(int) + y1 = np.minimum(y0 + 1, rows - 1) + x0 = np.floor(xs).astype(int) + x1 = np.minimum(x0 + 1, cols - 1) + wy = (ys - y0)[:, None] + wx = (xs - x0)[None, :] + top = grid[y0][:, x0] * (1 - wx) + grid[y0][:, x1] * wx + bot = grid[y1][:, x0] * (1 - wx) + grid[y1][:, x1] * wx + return top * (1 - wy) + bot * wy + + +def lsc_gain_rgb(meta_path: Path, shape: tuple[int, int]) -> np.ndarray: + """meta.json の lscMap (4, M, N) を (H, W, 3) の RGB ゲインへ補間する. + + 4ch = [R, G_even, G_odd, B].G は G_even/G_odd の平均を用いる. + + Args: + meta_path: meta.json ファイルのパス. + shape: 補間先の画像サイズ (height, width). + + Returns: + RGB ゲインの NumPy 配列(H x W x 3, float64). + """ + meta = json.loads(meta_path.read_text(encoding="utf-8")) + rows, cols = meta["lscMapRowCount"], meta["lscMapColumnCount"] + m = np.array(meta["lscMap"], dtype=np.float64).reshape(rows, cols, 4) + gr = _upsample(m[:, :, 0], shape) + gg = _upsample((m[:, :, 1] + m[:, :, 2]) / 2.0, shape) + gb = _upsample(m[:, :, 3], shape) + return np.stack([gr, gg, gb], axis=2) + + +def dng_luminance(image_path: str, apply_lsc: bool) -> np.ndarray: + """DNG から portrait・linear・(任意で LSC 補正済)の輝度画像を返す. + + - output_format="rgb_float32"(output_color=raw, linear)で読み込み + - apply_lsc=True なら lscMap を順適用してレンズビネットを除去 + - Rec.709 で輝度化(uniformity.py と同じ係数) + - PNG と同じ portrait へ rot90(-1) で向き合わせ + + Args: + image_path: DNG ファイルのパス. + apply_lsc: True の場合,同名 .meta.json から LSC ゲインを読み込んで補正する. + + Returns: + portrait 向きの輝度画像(H x W, float64).値域は 0〜255 の linear スケール. + """ + rgb = load_image_dng(image_path, output_format="rgb_float32") # (H,W,3) linear landscape + + if apply_lsc: + meta_path = Path(image_path).with_suffix("").with_suffix(".meta.json") + if not meta_path.exists(): + # ".meta.json" は二重拡張子なので with_suffix では拾えない場合がある + meta_path = Path(str(Path(image_path).with_suffix("")) + ".meta.json") + if not meta_path.exists(): + raise FileNotFoundError(f"meta.json が見つかりません(LSC 補正に必要): {meta_path}") + rgb = rgb * lsc_gain_rgb(meta_path, rgb.shape[:2]) + + luma = ( + REC709_COEFF_R * rgb[:, :, 0] + + REC709_COEFF_G * rgb[:, :, 1] + + REC709_COEFF_B * rgb[:, :, 2] + ) + # linear のまま 0〜255 スケールで表現する(PNG 図と軸を揃えて比較するため). + # CoV・max/min・中心周辺比はスケール不変なので不変,mean/std のみ ×255 となる. + # 既存 plot_histogram が range=(0,255) 固定であることにも整合する. + luma = luma * 255.0 + return np.rot90(luma, -1) # portrait(PNG と同じ向き) + + +def analyze_single_dng( + image_path: str, + roi: dict | None, + output_base: Path, + apply_lsc: bool, +) -> None: + """DNG 1 枚を解析し,PNG 版と同じ出力一式を生成する. + + Args: + image_path: 解析対象 DNG ファイルのパス. + roi: ROI の辞書(x, y, width, height).None の場合は画像全体を解析. + output_base: 出力ルートディレクトリのパス. + apply_lsc: True の場合,LSC(レンズシェーディング)補正を適用する. + """ + print(f"解析開始: {image_path}") + luma_full = dng_luminance(image_path, apply_lsc) + print(f"画像サイズ: {luma_full.shape[1]} x {luma_full.shape[0]} px(portrait)") + print(f"LSC 補正: {'あり' if apply_lsc else 'なし'} / ドメイン: linear(output_color=raw)") + + if roi is not None: + print( + f"ROI を適用します: x={roi['x']}, y={roi['y']}, " + f"width={roi['width']}, height={roi['height']}" + ) + luminance = extract_roi(luma_full, roi["x"], roi["y"], roi["width"], roi["height"]) + else: + print("ROI 未指定: 画像全体を解析します") + luminance = luma_full + + results = calc_uniformity(luminance) + print("--- 均一性指標(輝度は 0〜255 linear スケール)---") + print(f" 平均輝度 : {results['mean']:.2f}") + print(f" 標準偏差 : {results['std']:.2f}") + print(f" CoV : {results['cov']:.4f}") + print(f" 最大/最小比 : {results['max_min_ratio']:.4f}") + print(f" 最大輝度 : {results['max']:.2f}") + print(f" 最小輝度 : {results['min']:.2f}") + + spatial = calc_spatial_uniformity(luminance) + zone_stats = spatial["zone_stats"] + print("--- 空間解析指標 ---") + print(f" 中心平均輝度 : {zone_stats['center_mean']:.2f}") + print(f" 中間平均輝度 : {zone_stats['middle_mean']:.2f}") + print(f" 周辺平均輝度 : {zone_stats['periphery_mean']:.2f}") + print(f" 中心/周辺比 : {zone_stats['center_periphery_ratio']:.4f}") + print(f" 勾配量 (%) : {zone_stats['gradient_magnitude']:.2f}") + print(f" 動径min/max比 : {spatial['radial_min_max_ratio']:.4f}") + + stem = Path(image_path).stem # 例: MiniTIAS_QM_20260601_102753 + figures = output_base / "figures" + plot_luminance_map(luminance, str(figures / f"{stem}_luminance_map.png")) + plot_histogram(luminance, str(figures / f"{stem}_histogram.png")) + plot_radial_profile(spatial["radial_profile"], str(figures / f"{stem}_radial_profile.png")) + plot_zone_map(luminance, spatial["zone_map"], str(figures / f"{stem}_zone_map.png")) + export_results(results, str(output_base / "results" / f"{stem}_uniformity.csv")) + + print(f"解析完了: {image_path}") + + +def main() -> None: + """エントリーポイント.""" + parser = argparse.ArgumentParser( + description="MiniTIAS 定量モード DNG の照明均一性を評価する(PNG 版と同じ出力一式)." + ) + parser.add_argument("--image", required=True, help="解析対象の DNG ファイルパス") + parser.add_argument( + "--config", + default=str(PROJECT_ROOT / "config" / "roi_config.json"), + help="ROI 設定ファイルのパス(デフォルト: config/roi_config.json)", + ) + parser.add_argument( + "--roi", + default="minitias.whiteboard", + help="ROI のキー(ドット区切り,デフォルト: minitias.whiteboard)", + ) + parser.add_argument( + "--output", + default=str(PROJECT_ROOT / "output"), + help="出力ディレクトリのパス(デフォルト: output/)", + ) + parser.add_argument( + "--no-lsc", + action="store_true", + help="LSC(レンズシェーディング)補正を無効化する(既定は補正あり)", + ) + args = parser.parse_args() + + output_base = Path(args.output) + ensure_output_dirs(args.output) + + roi = None + if args.config: + try: + roi = load_roi_config(args.config, args.roi) + except FileNotFoundError as e: + print(f"警告: {e} → 画像全体を解析します") + + analyze_single_dng(args.image, roi, output_base, apply_lsc=not args.no_lsc) + + +if __name__ == "__main__": + main() diff --git a/src/analysis/spatial.py b/src/analysis/spatial.py index 184e276..5282fa8 100644 --- a/src/analysis/spatial.py +++ b/src/analysis/spatial.py @@ -150,6 +150,48 @@ return profile +def calc_radial_min_max_ratio(radial_profile: numpy.ndarray) -> dict: + """動径プロファイルの最小/最大輝度比を算出する. + + 20 点の輪帯平均輝度のうち最小値を最大値で割った比(0〜1)を返す. + 1 に近いほど均一で,中心明・周辺暗の単調減衰では「最外輪帯 / 最内輪帯」に一致する. + 全 20 点の min/max を使うため,非単調プロファイルにもロバストに対応する. + + Args: + radial_profile: 放射状プロファイルの NumPy 配列(N x 2, float64). + 列 0 = ビン中心距離, 列 1 = 平均輝度. + + Returns: + 動径 min/max 比を格納した辞書.キーは: + "radial_min_max_ratio" (float): 最小輝度 / 最大輝度(0〜1). + 最大輝度が 0 の場合は float("inf"). + "radial_min_distance" (float): 最小輝度ビンの正規化距離. + "radial_min" (float): プロファイルの最小輝度値. + "radial_max" (float): プロファイルの最大輝度値. + """ + luminances = radial_profile[:, 1] + distances = radial_profile[:, 0] + + radial_min = float(numpy.min(luminances)) + radial_max = float(numpy.max(luminances)) + + # max が 0 のときはゼロ除算を回避して inf を返す(calc_zone_stats の既存パターンに倣う) + radial_min_max_ratio = ( + radial_min / radial_max if radial_max != 0.0 else float("inf") + ) + + # 最小輝度をとるビンの正規化距離(補助情報) + min_idx = int(numpy.argmin(luminances)) + radial_min_distance = float(distances[min_idx]) + + return { + "radial_min_max_ratio": radial_min_max_ratio, + "radial_min_distance": radial_min_distance, + "radial_min": radial_min, + "radial_max": radial_max, + } + + def calc_spatial_uniformity(luminance: numpy.ndarray) -> dict: """空間的均一性指標を一括算出するファサード関数. @@ -162,16 +204,20 @@ Returns: 空間分析結果を格納した辞書.キーは: "zone_stats" (dict), "radial_profile" (numpy.ndarray), - "zone_map" (numpy.ndarray). + "zone_map" (numpy.ndarray), + "radial_min_max_ratio" (float), "radial_min_distance" (float), + "radial_min" (float), "radial_max" (float). """ height, width = luminance.shape distance_map = calc_distance_map(height, width) zone_map = classify_zones(distance_map) zone_stats = calc_zone_stats(luminance, zone_map) radial_profile = calc_radial_profile(luminance, distance_map) + radial_ratio_stats = calc_radial_min_max_ratio(radial_profile) return { "zone_stats": zone_stats, "radial_profile": radial_profile, "zone_map": zone_map, + **radial_ratio_stats, } diff --git a/src/export/exporter.py b/src/export/exporter.py index 1931aa4..a3521ec 100644 --- a/src/export/exporter.py +++ b/src/export/exporter.py @@ -13,6 +13,8 @@ "periphery_mean", "center_periphery_ratio", "gradient_magnitude", + "radial_min_max_ratio", + "radial_min_distance", ] @@ -119,6 +121,8 @@ "periphery_mean", "center_periphery_ratio", "gradient_magnitude", + "radial_min_max_ratio", + "radial_min_distance", ] def _calc_metric_stats(metric_name: str) -> dict: diff --git a/src/io/dng_loader.py b/src/io/dng_loader.py new file mode 100644 index 0000000..70c77eb --- /dev/null +++ b/src/io/dng_loader.py @@ -0,0 +1,193 @@ +"""MiniTIAS 定量モード DNG 読み込みモジュール.""" + +from pathlib import Path + +import numpy +import rawpy + + +# Rec.709 グレースケール変換係数(uniformity.py の REC709_COEFF_* と一致させる) +# 参照: ITU-R BT.709 (https://www.itu.int/rec/R-REC-BT.709/en) +_REC709_R = 0.2126 +_REC709_G = 0.7152 +_REC709_B = 0.0722 + +# demosaic 引数 → rawpy.DemosaicAlgorithm のマッピング +# 参照: https://letmaik.github.io/rawpy/api/rawpy.DemosaicAlgorithm.html +_DEMOSAIC_MAP: dict[str, rawpy.DemosaicAlgorithm] = { + "nearest": rawpy.DemosaicAlgorithm.LINEAR, # 最近傍は rawpy に専用 enum がないため LINEAR で代替 + "bilinear": rawpy.DemosaicAlgorithm.LINEAR, + "ahd": rawpy.DemosaicAlgorithm.AHD, +} + +# 有効な output_format の一覧 +_VALID_OUTPUT_FORMATS = {"rgb_float32", "luma_float32", "rgb_uint8"} + +# rawpy が output_bps=16 で返す uint16 の最大値(0〜65535 を [0, 1] に正規化するため) +_UINT16_MAX = 65535.0 + + +def load_image_dng( + path: str, + demosaic: str = "bilinear", + black_level: int | None = None, + output_format: str = "rgb_float32", +) -> numpy.ndarray: + """MiniTIAS 定量モード DNG を読み込み配列として返す. + + rawpy の postprocess() を linear パラメータで呼び出すことで, + ISP(ガンマ・自動輝度・カメラ WB)の介入を最小化した linear raw 値を取り出す. + 具体的には gamma=(1,1), no_auto_bright=True, output_bps=16, use_camera_wb=False を指定する. + + Args: + path: DNG ファイルのパス. + demosaic: デモザイクアルゴリズム.既定 "bilinear". + "nearest"(最近傍)/ "bilinear"(双線形)/ "ahd"(適応的)から選択. + "nearest" は rawpy に専用実装がないため "bilinear" と同一の LINEAR を使用する. + bilinear(既定)は線形演算で比例性を保つ. + **AHD は適応的=非線形でチャンネル間の比例が崩れうるため量的評価では非推奨**. + black_level: 黒レベル(整数).None なら DNG Exif の + black_level_per_channel[0] から自動取得する.通常は 64(SH-02M). + output_format: 出力形式.既定 "rgb_float32". + "rgb_float32": (H, W, 3) float32, linear, 値域 [0.0, 1.0]. + output_color=raw(カラーマトリクス非適用)でカメラ固有チャンネルの + linear raw 信号を出力する.各チャンネルがそのセンサチャンネルの + 入射光量に比例する(チャンネル混合なし). + 飽和画素は 1.0 を超えることがある(クリップしない). + "luma_float32": (H, W) float32, linear, 値域 [0.0, 1.0]. + output_color=raw(カラーマトリクス非適用)でカメラ固有チャンネルの + linear raw 信号を取得し,Rec.709 重みで輝度に変換する + (uniformity.py の to_grayscale と同じ係数). + **output_color=raw のとき Rec.709 luma は厳密な CIE 輝度ではなく, + 既存 PNG 経路(uniformity.py の to_grayscale)と互換の固定加重輝度プロキシ + である**.Rec.709 係数を維持する理由は,既存パイプラインとの方法論的一貫性 + と DNG/PNG 評価指標の直接比較のためである. + "rgb_uint8": (H, W, 3) uint8, カラーマトリクス+sRGB OETF 適用後. + output_color=sRGB(カラーマトリクス適用)+ sRGB OETF を維持する. + 表示・PNG 互換専用であり,量的評価には linear 形式(rgb_float32 / + luma_float32)を使うこと. + + Returns: + numpy.ndarray. 形状・dtype は output_format に依存. + + Raises: + FileNotFoundError: ファイルが存在しない場合. + ValueError: demosaic または output_format の値が不正な場合, + または rawpy が DNG を読み込めなかった場合(形式が不正等). + """ + if not Path(path).exists(): + raise FileNotFoundError(f"DNG ファイルが見つかりません: {path}") + + if demosaic not in _DEMOSAIC_MAP: + raise ValueError( + f"demosaic の値が不正です: '{demosaic}'." + f"有効な値: {sorted(_DEMOSAIC_MAP.keys())}" + ) + + if output_format not in _VALID_OUTPUT_FORMATS: + raise ValueError( + f"output_format の値が不正です: '{output_format}'." + f"有効な値: {sorted(_VALID_OUTPUT_FORMATS)}" + ) + + try: + raw = rawpy.imread(str(path)) + except Exception as exc: + raise ValueError( + f"DNG の読み込みに失敗しました(形式が不正の可能性があります): {path}" + ) from exc + + # rawpy.postprocess() は DNG Exif の black_level_per_channel / white_level を + # 内部で自動取得して黒レベル除去・正規化を行う. + # postprocess に black_level を直接渡す API はないため,引数値の検証は行わない. + # (将来の自前デモザイク実装時に black_level 引数を活用できるよう API として保持している) + # SH-02M: black_level_per_channel=[64,64,64,64], white_level=1023 (scale=959) + + # 共通パラメータ: linear 出力のための ISP 無効化設定 + # - gamma=(1,1): ガンマ補正なし(linear のまま出力) + # - no_auto_bright=True: 自動輝度調整を無効化 + # - output_bps=16: 16-bit 出力(精度確保のため) + # - use_camera_wb=False + user_wb=[1,1,1,1]: WB ゲインをすべて 1 に固定(無補正) + # - demosaic_algorithm: ユーザー指定アルゴリズム + # rawpy.postprocess は DNG Exif から黒レベルと white_level を読み取り, + # (raw - black_level) / (white_level - black_level) を 0〜65535 にスケールして出力する. + # gamma=(1,1) により出力は linear のまま保たれる. + # SH-02M の定量モード DNG は TONEMAP_MODE=CONTRAST_CURVE_LINEAR であり, + # sRGB OETF は適用されていないため,このパラメータで正しく linear raw が得られる. + _postprocess_common = { + "demosaic_algorithm": _DEMOSAIC_MAP[demosaic], + "gamma": (1, 1), + "no_auto_bright": True, + "output_bps": 16, + "use_camera_wb": False, + "user_wb": [1.0, 1.0, 1.0, 1.0], + } + + if output_format in ("rgb_float32", "luma_float32"): + # output_color=raw: カラーマトリクスを通さずカメラ固有チャンネルの linear raw 信号を出力. + # これにより各チャンネルがそのセンサチャンネルの入射光量に比例する(チャンネル混合なし). + # SmTIAS-LightSim がチャンネル単位でシミュ vs 実機を突合するため per-channel の + # センサ忠実度を優先する.output_color=sRGB(既定)はカラーマトリクスを適用するため + # 各出力チャンネルが他チャンネルの線形結合となり,量的評価に不適切である. + rgb16 = raw.postprocess( + **_postprocess_common, + output_color=rawpy.ColorSpace.raw, + ) + # rgb16: (H, W, 3) uint16,値域 0〜65535(black_level 補正済み linear,white_level で正規化) + rgb_float = rgb16.astype(numpy.float32) / _UINT16_MAX + # rgb_float: (H, W, 3) float32, linear, 値域 [0, 1] + + raw.close() + + if output_format == "rgb_float32": + return rgb_float + + # output_format == "luma_float32" + # Rec.709 重みで輝度に変換(uniformity.py の to_grayscale と同じ係数). + # output_color=raw のとき Rec.709 luma は厳密な CIE 輝度ではなく固定加重輝度プロキシ. + # 既存 PNG 経路(uniformity.py の to_grayscale)との方法論的一貫性と + # DNG/PNG 評価指標の直接比較のために Rec.709 係数を維持する. + luma = ( + _REC709_R * rgb_float[:, :, 0] + + _REC709_G * rgb_float[:, :, 1] + + _REC709_B * rgb_float[:, :, 2] + ) + return luma # (H, W) float32 + + # output_format == "rgb_uint8" + # output_color=sRGB: カラーマトリクス+sRGB OETF 適用(既存 PNG 経路と互換). + # 表示・PNG 互換専用であり,量的評価には rgb_float32 / luma_float32 を使うこと. + # sRGB OETF: V <= 0.0031308 → 12.92 * V, else 1.055 * V^(1/2.4) - 0.055 + # 参照: IEC 61966-2-1 (sRGB standard) + rgb16 = raw.postprocess( + **_postprocess_common, + output_color=rawpy.ColorSpace.sRGB, + ) + rgb_float = rgb16.astype(numpy.float32) / 65535.0 + raw.close() + + rgb_srgb = _apply_srgb_oetf(rgb_float) + rgb_uint8 = numpy.clip(rgb_srgb * 255.0, 0, 255).astype(numpy.uint8) + return rgb_uint8 # (H, W, 3) uint8 + + +def _apply_srgb_oetf(linear: numpy.ndarray) -> numpy.ndarray: + """Linear float 画像に sRGB OETF を適用する. + + IEC 61966-2-1 に基づく区分関数: + V <= 0.0031308: out = 12.92 * V + V > 0.0031308: out = 1.055 * V^(1/2.4) - 0.055 + + Args: + linear: (H, W, 3) float32, 値域 [0.0, 1.0]. + + Returns: + sRGB OETF 適用後の (H, W, 3) float32, 値域 [0.0, 1.0]. + """ + # ゼロ以下の値はそのまま(負値クリップはしない) + out = numpy.where( + linear <= 0.0031308, + 12.92 * linear, + 1.055 * numpy.power(numpy.maximum(linear, 0.0), 1.0 / 2.4) - 0.055, + ) + return out.astype(numpy.float32) diff --git a/src/visualization/plotter.py b/src/visualization/plotter.py index c58059c..1136a86 100644 --- a/src/visualization/plotter.py +++ b/src/visualization/plotter.py @@ -1,5 +1,6 @@ """輝度マップ・ヒストグラム・空間分析可視化モジュール.""" +import math from pathlib import Path import matplotlib.pyplot as plt @@ -63,6 +64,9 @@ def plot_radial_profile(radial_profile: numpy.ndarray, output_path: str) -> None: """放射状輝度プロファイルを折れ線グラフとして保存する. + 最小輝度点(赤マーカー)と最大輝度点(緑マーカー)を強調表示し, + 動径 min/max 比をグラフ右上にアノテーションとして表示する. + Args: radial_profile: 放射状プロファイルの NumPy 配列(N x 2, float64). 列 0 = 正規化距離, 列 1 = 平均輝度. @@ -70,12 +74,44 @@ """ _ensure_parent(output_path) + distances = radial_profile[:, 0] + luminances = radial_profile[:, 1] + + # min/max 点のインデックスを特定する + min_idx = int(numpy.argmin(luminances)) + max_idx = int(numpy.argmax(luminances)) + lmin = float(luminances[min_idx]) + lmax = float(luminances[max_idx]) + ratio = lmin / lmax if lmax != 0.0 else float("inf") + fig, ax = plt.subplots(figsize=(8, 5)) - ax.plot(radial_profile[:, 0], radial_profile[:, 1], marker="o", color="steelblue") + ax.plot(distances, luminances, marker="o", color="steelblue", label="Mean Luminance") + + # 最大点(緑)・最小点(赤)を強調マーカーで重ねる + ax.scatter( + distances[max_idx], lmax, + color="green", s=80, zorder=5, label=f"Max: {lmax:.2f}", + ) + ax.scatter( + distances[min_idx], lmin, + color="red", s=80, zorder=5, label=f"Min: {lmin:.2f}", + ) + + # 動径 min/max 比をグラフ右上にテキスト注記する + ratio_text = "min/max ratio: inf" if math.isinf(ratio) else f"min/max ratio: {ratio:.4f}" + ax.text( + 0.98, 0.97, ratio_text, + transform=ax.transAxes, + ha="right", va="top", + fontsize=10, + bbox={"boxstyle": "round,pad=0.3", "facecolor": "white", "alpha": 0.8}, + ) + ax.set_title("Radial Luminance Profile") ax.set_xlabel("Normalized Distance from Center") ax.set_ylabel("Mean Luminance") ax.grid(True, linestyle="--", alpha=0.5) + ax.legend(loc="lower left") fig.tight_layout() fig.savefig(output_path, dpi=150) plt.close(fig) diff --git a/tests/analysis/test_radial_min_max.py b/tests/analysis/test_radial_min_max.py new file mode 100644 index 0000000..dd27514 --- /dev/null +++ b/tests/analysis/test_radial_min_max.py @@ -0,0 +1,645 @@ +"""calc_radial_min_max_ratio の単体テスト. + +仕様 (実装サマリー / TECH_05_MiniTiasEvaluation_DNG対応要求仕様.md): + - calc_radial_min_max_ratio(radial_profile) -> dict + - 引数: 20 点動径プロファイル配列 (N x 2, float64) + - 返り値キー: + "radial_min_max_ratio" : min(profile[:,1]) / max(profile[:,1]) + "radial_min_distance" : 最小輝度ビンの正規化距離 + "radial_min" : プロファイルの最小輝度値 + "radial_max" : プロファイルの最大輝度値 + - max=0 のゼロ除算は float("inf") でガード + + - calc_spatial_uniformity の戻り値トップレベルに上記 4 キーを追加 + (既存キー zone_stats / radial_profile / zone_map は不変) + + - SPATIAL_FIELDNAMES に radial_min_max_ratio / radial_min_distance を追加 + + - export_spatial_summary が新指標を CSV に書き出せること + +テスト方針 (GUIDE_08_テスト方針.md): + - pytest を使用 + - numpy で合成した既知のプロファイルで期待値と比較する +""" + +import csv +from pathlib import Path + +import numpy +import numpy as np +import pytest + +from src.analysis.spatial import ( + calc_radial_min_max_ratio, + calc_spatial_uniformity, +) +from src.export.exporter import SPATIAL_FIELDNAMES, export_spatial_summary + + +# --------------------------------------------------------------------------- +# calc_radial_min_max_ratio テスト +# --------------------------------------------------------------------------- + + +class TestCalcRadialMinMaxRatio: + """calc_radial_min_max_ratio 関数のテスト群.""" + + def _make_profile(self, luminances: list[float]) -> numpy.ndarray: + """輝度値リストから動径プロファイル配列 (N x 2) を生成するヘルパー.""" + n = len(luminances) + distances = numpy.linspace(0.025, 0.975, n) + lums = numpy.array(luminances, dtype=numpy.float64) + return numpy.column_stack([distances, lums]) + + def test_return_value_has_all_required_keys(self) -> None: + """仕様: 返り値に radial_min_max_ratio / radial_min_distance / radial_min / radial_max + の 4 キーが含まれること.""" + profile = self._make_profile([100.0, 90.0, 80.0, 70.0, 60.0]) + result = calc_radial_min_max_ratio(profile) + + assert "radial_min_max_ratio" in result + assert "radial_min_distance" in result + assert "radial_min" in result + assert "radial_max" in result + + def test_ratio_is_min_divided_by_max(self) -> None: + """仕様: radial_min_max_ratio が min / max と一致すること(手計算値).""" + # min=60, max=100 → ratio=0.6 + profile = self._make_profile([100.0, 90.0, 80.0, 70.0, 60.0]) + result = calc_radial_min_max_ratio(profile) + + assert result["radial_min"] == pytest.approx(60.0) + assert result["radial_max"] == pytest.approx(100.0) + assert result["radial_min_max_ratio"] == pytest.approx(60.0 / 100.0) + + def test_ratio_exact_computation_known_values(self) -> None: + """仕様: 既知の min/max で ratio が手計算値と一致すること.""" + # min=40, max=200 → ratio=0.2 + profile = self._make_profile([200.0, 150.0, 100.0, 70.0, 40.0]) + result = calc_radial_min_max_ratio(profile) + assert result["radial_min_max_ratio"] == pytest.approx(40.0 / 200.0) + + def test_uniform_profile_ratio_is_1(self) -> None: + """仕様: 均一プロファイル(全ビン同値)では ratio=1.0 であること.""" + profile = self._make_profile([150.0] * 20) + result = calc_radial_min_max_ratio(profile) + assert result["radial_min_max_ratio"] == pytest.approx(1.0) + + def test_ratio_is_between_0_and_1_for_positive_values(self) -> None: + """仕様: 全値が正のプロファイルでは 0 < ratio <= 1 であること.""" + profile = self._make_profile([100.0, 80.0, 60.0, 40.0, 20.0]) + result = calc_radial_min_max_ratio(profile) + assert 0.0 < result["radial_min_max_ratio"] <= 1.0 + + def test_max_is_zero_returns_inf(self) -> None: + """仕様: max=0 のゼロ除算ガード — float("inf") が返ること.""" + profile = self._make_profile([0.0, 0.0, 0.0, 0.0, 0.0]) + result = calc_radial_min_max_ratio(profile) + assert result["radial_min_max_ratio"] == float("inf") + + def test_min_distance_is_distance_of_minimum_luminance_bin(self) -> None: + """仕様: radial_min_distance が最小輝度をとるビンの正規化距離であること.""" + # 最小値が最後のビン(最外)にある場合 + luminances = [100.0, 90.0, 80.0, 70.0, 60.0] # 最後が最小 + profile = self._make_profile(luminances) + result = calc_radial_min_max_ratio(profile) + + # プロファイルの最小値インデックス = 4(最後) + expected_distance = profile[4, 0] + assert result["radial_min_distance"] == pytest.approx(expected_distance) + + def test_min_distance_for_non_monotonic_profile(self) -> None: + """仕様: 非単調プロファイルでも最小輝度ビンの距離を正しく返すこと.""" + # 中間のビンが最小 + luminances = [100.0, 120.0, 40.0, 90.0, 80.0] # index 2 が最小 + profile = self._make_profile(luminances) + result = calc_radial_min_max_ratio(profile) + + expected_distance = profile[2, 0] + assert result["radial_min_distance"] == pytest.approx(expected_distance) + + def test_radial_min_equals_min_of_luminance_column(self) -> None: + """仕様: radial_min がプロファイルの輝度列の最小値であること.""" + luminances = [100.0, 50.0, 80.0, 30.0, 70.0] + profile = self._make_profile(luminances) + result = calc_radial_min_max_ratio(profile) + assert result["radial_min"] == pytest.approx(30.0) + + def test_radial_max_equals_max_of_luminance_column(self) -> None: + """仕様: radial_max がプロファイルの輝度列の最大値であること.""" + luminances = [100.0, 50.0, 80.0, 30.0, 70.0] + profile = self._make_profile(luminances) + result = calc_radial_min_max_ratio(profile) + assert result["radial_max"] == pytest.approx(100.0) + + def test_return_values_are_float_type(self) -> None: + """仕様: 戻り値の各フィールドが Python float 型であること.""" + profile = self._make_profile([100.0, 80.0, 60.0]) + result = calc_radial_min_max_ratio(profile) + for key in result: + assert isinstance(result[key], float), f"{key} が float でない: {type(result[key])}" + + def test_20_point_profile_standard_size(self) -> None: + """仕様: 20 点の動径プロファイル(仕様の標準サイズ)で正常に動作すること.""" + # 単調減衰 20 点プロファイル(仕様の標準サイズ) + luminances = numpy.linspace(200.0, 100.0, 20).tolist() + profile = self._make_profile(luminances) + result = calc_radial_min_max_ratio(profile) + + assert result["radial_min_max_ratio"] == pytest.approx(100.0 / 200.0) + assert result["radial_min"] == pytest.approx(100.0) + assert result["radial_max"] == pytest.approx(200.0) + + def test_monotone_decreasing_ratio_is_less_than_1(self) -> None: + """仕様: 単調減衰プロファイルでは ratio < 1.0 であること.""" + luminances = numpy.linspace(200.0, 100.0, 20).tolist() + profile = self._make_profile(luminances) + result = calc_radial_min_max_ratio(profile) + assert result["radial_min_max_ratio"] < 1.0 + + def test_monotone_decreasing_min_distance_is_at_periphery(self) -> None: + """仕様: 単調減衰プロファイルでは radial_min_distance が最外付近にあること. + + 単調減衰(中心明・周辺暗)の合成画像では最小輝度ビンが最外(距離 1.0 付近)になる. + """ + luminances = numpy.linspace(200.0, 100.0, 20).tolist() + profile = self._make_profile(luminances) + result = calc_radial_min_max_ratio(profile) + + # 最外ビン(距離が最大のビン)に最小値があること + max_distance = profile[-1, 0] + assert result["radial_min_distance"] == pytest.approx(max_distance) + + +# --------------------------------------------------------------------------- +# calc_spatial_uniformity の新 4 キー テスト +# --------------------------------------------------------------------------- + + +class TestCalcSpatialUniformityNewKeys: + """calc_spatial_uniformity が新 4 キーを含む仕様検証テスト群.""" + + @pytest.fixture + def sample_luminance(self) -> numpy.ndarray: + """テスト用の 20x20 均一輝度画像を返す fixture.""" + return numpy.full((20, 20), 128.0, dtype=numpy.float64) + + @pytest.fixture + def decaying_luminance(self) -> numpy.ndarray: + """中心明・周辺暗の合成輝度画像(30x30)を返す fixture. + + 中心座標からの距離に応じて輝度が低下する画像. + """ + h, w = 30, 30 + cy, cx = h / 2, w / 2 + y_coords, x_coords = numpy.mgrid[0:h, 0:w] + dist = numpy.sqrt(((x_coords - cx) / cx) ** 2 + ((y_coords - cy) / cy) ** 2) + dist_norm = dist / dist.max() + # 中心=200, 周辺=100 の線形減衰 + luminance = 200.0 - 100.0 * dist_norm + return luminance.astype(numpy.float64) + + def test_return_value_has_radial_min_max_ratio_key( + self, sample_luminance: numpy.ndarray + ) -> None: + """仕様: calc_spatial_uniformity の戻り値に 'radial_min_max_ratio' キーが存在すること.""" + result = calc_spatial_uniformity(sample_luminance) + assert "radial_min_max_ratio" in result + + def test_return_value_has_radial_min_distance_key( + self, sample_luminance: numpy.ndarray + ) -> None: + """仕様: calc_spatial_uniformity の戻り値に 'radial_min_distance' キーが存在すること.""" + result = calc_spatial_uniformity(sample_luminance) + assert "radial_min_distance" in result + + def test_return_value_has_radial_min_key( + self, sample_luminance: numpy.ndarray + ) -> None: + """仕様: calc_spatial_uniformity の戻り値に 'radial_min' キーが存在すること.""" + result = calc_spatial_uniformity(sample_luminance) + assert "radial_min" in result + + def test_return_value_has_radial_max_key( + self, sample_luminance: numpy.ndarray + ) -> None: + """仕様: calc_spatial_uniformity の戻り値に 'radial_max' キーが存在すること.""" + result = calc_spatial_uniformity(sample_luminance) + assert "radial_max" in result + + def test_existing_keys_are_preserved(self, sample_luminance: numpy.ndarray) -> None: + """仕様: 既存キー zone_stats / radial_profile / zone_map が変わらず保持されること.""" + result = calc_spatial_uniformity(sample_luminance) + assert "zone_stats" in result + assert "radial_profile" in result + assert "zone_map" in result + + def test_radial_min_max_ratio_type_is_float( + self, sample_luminance: numpy.ndarray + ) -> None: + """仕様: radial_min_max_ratio が float 型であること.""" + result = calc_spatial_uniformity(sample_luminance) + assert isinstance(result["radial_min_max_ratio"], float) + + def test_radial_min_distance_type_is_float( + self, sample_luminance: numpy.ndarray + ) -> None: + """仕様: radial_min_distance が float 型であること.""" + result = calc_spatial_uniformity(sample_luminance) + assert isinstance(result["radial_min_distance"], float) + + def test_uniform_image_ratio_is_1(self, sample_luminance: numpy.ndarray) -> None: + """仕様: 均一輝度画像では radial_min_max_ratio が 1.0 であること.""" + result = calc_spatial_uniformity(sample_luminance) + assert result["radial_min_max_ratio"] == pytest.approx(1.0, abs=1e-5) + + def test_decaying_image_ratio_is_between_0_and_1( + self, decaying_luminance: numpy.ndarray + ) -> None: + """仕様: 単調減衰画像では 0 < radial_min_max_ratio < 1 であること.""" + result = calc_spatial_uniformity(decaying_luminance) + ratio = result["radial_min_max_ratio"] + assert 0.0 < ratio < 1.0 + + def test_decaying_image_min_distance_is_at_periphery( + self, decaying_luminance: numpy.ndarray + ) -> None: + """仕様: 単調減衰画像では radial_min_distance が最外付近(> 0.5)にあること.""" + result = calc_spatial_uniformity(decaying_luminance) + # 中心明・周辺暗なので最小輝度は周辺側にあるはず + assert result["radial_min_distance"] > 0.5 + + def test_radial_min_max_ratio_is_consistent_with_radial_profile( + self, sample_luminance: numpy.ndarray + ) -> None: + """仕様: radial_min_max_ratio が radial_profile の min/max 比と一致すること.""" + result = calc_spatial_uniformity(sample_luminance) + profile = result["radial_profile"] + luminances = profile[:, 1] + expected_ratio = float(numpy.min(luminances)) / float(numpy.max(luminances)) if float(numpy.max(luminances)) != 0.0 else float("inf") + assert result["radial_min_max_ratio"] == pytest.approx(expected_ratio, abs=1e-6) + + def test_new_keys_do_not_overwrite_zone_stats_content( + self, sample_luminance: numpy.ndarray + ) -> None: + """仕様: 新 4 キーの追加が zone_stats の内容を変更しないこと.""" + result = calc_spatial_uniformity(sample_luminance) + zone_stats = result["zone_stats"] + expected_zone_keys = { + "center_mean", "center_std", + "middle_mean", "middle_std", + "periphery_mean", "periphery_std", + "center_periphery_ratio", "gradient_magnitude", + } + assert set(zone_stats.keys()) == expected_zone_keys + + +# --------------------------------------------------------------------------- +# SPATIAL_FIELDNAMES の新カラム テスト +# --------------------------------------------------------------------------- + + +class TestSpatialFieldnamesNewColumns: + """SPATIAL_FIELDNAMES に新カラムが追加された仕様検証テスト群.""" + + def test_spatial_fieldnames_contains_radial_min_max_ratio(self) -> None: + """仕様: SPATIAL_FIELDNAMES に 'radial_min_max_ratio' が含まれること.""" + assert "radial_min_max_ratio" in SPATIAL_FIELDNAMES + + def test_spatial_fieldnames_contains_radial_min_distance(self) -> None: + """仕様: SPATIAL_FIELDNAMES に 'radial_min_distance' が含まれること.""" + assert "radial_min_distance" in SPATIAL_FIELDNAMES + + def test_spatial_fieldnames_still_contains_base_columns(self) -> None: + """仕様: SPATIAL_FIELDNAMES が既存の 6 基本カラムをすべて含むこと(削除・改名なし).""" + base_fields = [ + "image_name", + "center_mean", + "middle_mean", + "periphery_mean", + "center_periphery_ratio", + "gradient_magnitude", + ] + for field in base_fields: + assert field in SPATIAL_FIELDNAMES, ( + f"必須フィールド '{field}' が SPATIAL_FIELDNAMES から消えている(削除・改名は禁止)" + ) + + def test_spatial_fieldnames_has_at_least_8_columns(self) -> None: + """仕様: SPATIAL_FIELDNAMES が基本 6 + 新規 2 = 最低 8 カラムを持つこと.""" + assert len(SPATIAL_FIELDNAMES) >= 8 + + +# --------------------------------------------------------------------------- +# export_spatial_summary の新指標書き出し テスト +# --------------------------------------------------------------------------- + + +class TestExportSpatialSummaryNewMetrics: + """export_spatial_summary が新指標を書き出せる仕様検証テスト群.""" + + @pytest.fixture + def sample_spatial_results_with_new_metrics(self) -> list[dict]: + """新 2 指標(radial_min_max_ratio / radial_min_distance)を含む空間解析結果リスト.""" + return [ + { + "image_name": "image_001", + "center_mean": 180.0, + "middle_mean": 165.0, + "periphery_mean": 140.0, + "center_periphery_ratio": 1.29, + "gradient_magnitude": 22.2, + "radial_min_max_ratio": 0.75, + "radial_min_distance": 0.95, + }, + { + "image_name": "image_002", + "center_mean": 200.0, + "middle_mean": 190.0, + "periphery_mean": 170.0, + "center_periphery_ratio": 1.18, + "gradient_magnitude": 15.0, + "radial_min_max_ratio": 0.82, + "radial_min_distance": 0.90, + }, + ] + + def test_csv_contains_radial_min_max_ratio_column( + self, + sample_spatial_results_with_new_metrics: list[dict], + tmp_path: Path, + ) -> None: + """仕様: 出力 CSV のヘッダーに 'radial_min_max_ratio' が含まれること.""" + output_path = str(tmp_path / "spatial_new.csv") + export_spatial_summary(sample_spatial_results_with_new_metrics, output_path) + + with open(output_path, encoding="utf-8") as f: + reader = csv.DictReader(f) + fieldnames = reader.fieldnames + assert "radial_min_max_ratio" in fieldnames + + def test_csv_contains_radial_min_distance_column( + self, + sample_spatial_results_with_new_metrics: list[dict], + tmp_path: Path, + ) -> None: + """仕様: 出力 CSV のヘッダーに 'radial_min_distance' が含まれること.""" + output_path = str(tmp_path / "spatial_new.csv") + export_spatial_summary(sample_spatial_results_with_new_metrics, output_path) + + with open(output_path, encoding="utf-8") as f: + reader = csv.DictReader(f) + fieldnames = reader.fieldnames + assert "radial_min_distance" in fieldnames + + def test_csv_radial_min_max_ratio_values_are_written_correctly( + self, + sample_spatial_results_with_new_metrics: list[dict], + tmp_path: Path, + ) -> None: + """仕様: radial_min_max_ratio の値が正しく書き込まれること.""" + output_path = str(tmp_path / "spatial_new.csv") + export_spatial_summary(sample_spatial_results_with_new_metrics, output_path) + + with open(output_path, encoding="utf-8") as f: + reader = csv.DictReader(f) + rows = list(reader) + + assert float(rows[0]["radial_min_max_ratio"]) == pytest.approx(0.75) + assert float(rows[1]["radial_min_max_ratio"]) == pytest.approx(0.82) + + def test_csv_radial_min_distance_values_are_written_correctly( + self, + sample_spatial_results_with_new_metrics: list[dict], + tmp_path: Path, + ) -> None: + """仕様: radial_min_distance の値が正しく書き込まれること.""" + output_path = str(tmp_path / "spatial_new.csv") + export_spatial_summary(sample_spatial_results_with_new_metrics, output_path) + + with open(output_path, encoding="utf-8") as f: + reader = csv.DictReader(f) + rows = list(reader) + + assert float(rows[0]["radial_min_distance"]) == pytest.approx(0.95) + assert float(rows[1]["radial_min_distance"]) == pytest.approx(0.90) + + def test_csv_fieldnames_match_spatial_fieldnames_constant( + self, + sample_spatial_results_with_new_metrics: list[dict], + tmp_path: Path, + ) -> None: + """仕様: CSV ヘッダーが SPATIAL_FIELDNAMES 定数と完全一致すること.""" + output_path = str(tmp_path / "spatial_new.csv") + export_spatial_summary(sample_spatial_results_with_new_metrics, output_path) + + with open(output_path, encoding="utf-8") as f: + reader = csv.DictReader(f) + fieldnames = reader.fieldnames + assert list(fieldnames) == SPATIAL_FIELDNAMES + + +# --------------------------------------------------------------------------- +# calc_batch_statistics の新指標集計 テスト +# --------------------------------------------------------------------------- + + +class TestCalcBatchStatisticsNewMetrics: + """calc_batch_statistics が新 2 指標(radial_min_max_ratio / radial_min_distance)を + 集計する仕様検証テスト群.""" + + @pytest.fixture + def results_with_radial_metrics(self) -> list[dict]: + """radial_min_max_ratio / radial_min_distance を含む 2 画像分の結果リスト.""" + return [ + { + "image_name": "image_001", + "mean": 200.0, + "std": 10.0, + "cov": 0.05, + "max_min_ratio": 1.2, + "max": 220.0, + "min": 180.0, + "center_mean": 180.0, + "middle_mean": 165.0, + "periphery_mean": 140.0, + "center_periphery_ratio": 1.286, + "gradient_magnitude": 22.2, + "radial_min_max_ratio": 0.75, + "radial_min_distance": 0.95, + }, + { + "image_name": "image_002", + "mean": 210.0, + "std": 20.0, + "cov": 0.095, + "max_min_ratio": 1.4, + "max": 240.0, + "min": 160.0, + "center_mean": 200.0, + "middle_mean": 185.0, + "periphery_mean": 160.0, + "center_periphery_ratio": 1.25, + "gradient_magnitude": 20.0, + "radial_min_max_ratio": 0.80, + "radial_min_distance": 0.90, + }, + ] + + def test_spatial_contains_radial_min_max_ratio( + self, results_with_radial_metrics: list[dict] + ) -> None: + """仕様: calc_batch_statistics の spatial 部に 'radial_min_max_ratio' が含まれること.""" + from src.export.exporter import calc_batch_statistics + + result = calc_batch_statistics(results_with_radial_metrics) + assert "radial_min_max_ratio" in result["spatial"] + + def test_spatial_contains_radial_min_distance( + self, results_with_radial_metrics: list[dict] + ) -> None: + """仕様: calc_batch_statistics の spatial 部に 'radial_min_distance' が含まれること.""" + from src.export.exporter import calc_batch_statistics + + result = calc_batch_statistics(results_with_radial_metrics) + assert "radial_min_distance" in result["spatial"] + + def test_radial_min_max_ratio_mean_is_correct( + self, results_with_radial_metrics: list[dict] + ) -> None: + """仕様: radial_min_max_ratio の mean が (0.75 + 0.80) / 2 = 0.775 であること.""" + from src.export.exporter import calc_batch_statistics + + result = calc_batch_statistics(results_with_radial_metrics) + assert result["spatial"]["radial_min_max_ratio"]["mean"] == pytest.approx(0.775) + + def test_radial_min_distance_mean_is_correct( + self, results_with_radial_metrics: list[dict] + ) -> None: + """仕様: radial_min_distance の mean が (0.95 + 0.90) / 2 = 0.925 であること.""" + from src.export.exporter import calc_batch_statistics + + result = calc_batch_statistics(results_with_radial_metrics) + assert result["spatial"]["radial_min_distance"]["mean"] == pytest.approx(0.925) + + def test_radial_min_max_ratio_std_is_population_std( + self, results_with_radial_metrics: list[dict] + ) -> None: + """仕様: radial_min_max_ratio の std が母集団標準偏差(ddof=0)で算出されること.""" + from src.export.exporter import calc_batch_statistics + + result = calc_batch_statistics(results_with_radial_metrics) + expected_std = float(np.std([0.75, 0.80], ddof=0)) + assert result["spatial"]["radial_min_max_ratio"]["std"] == pytest.approx(expected_std) + + def test_radial_min_max_ratio_has_five_stat_subkeys( + self, results_with_radial_metrics: list[dict] + ) -> None: + """仕様: radial_min_max_ratio が mean/std/min/max/cv の 5 サブキーを持つこと.""" + from src.export.exporter import calc_batch_statistics + + result = calc_batch_statistics(results_with_radial_metrics) + expected_subkeys = {"mean", "std", "min", "max", "cv"} + assert expected_subkeys == set( + result["spatial"]["radial_min_max_ratio"].keys() + ) + + def test_radial_min_distance_has_five_stat_subkeys( + self, results_with_radial_metrics: list[dict] + ) -> None: + """仕様: radial_min_distance が mean/std/min/max/cv の 5 サブキーを持つこと.""" + from src.export.exporter import calc_batch_statistics + + result = calc_batch_statistics(results_with_radial_metrics) + expected_subkeys = {"mean", "std", "min", "max", "cv"} + assert expected_subkeys == set( + result["spatial"]["radial_min_distance"].keys() + ) + + +# --------------------------------------------------------------------------- +# plot_radial_profile の最大/最小マーカー テスト +# --------------------------------------------------------------------------- + + +class TestPlotRadialProfileMinMaxMarkers: + """plot_radial_profile に最大/最小マーカーと注記が追加された仕様検証テスト群. + + テスト方針: 可視化テストは「出力ファイルが生成されること程度でよい」(GUIDE_08). + ここではファイル生成確認と、既知のプロファイルで例外が発生しないことを確認する. + """ + + @pytest.fixture(autouse=True) + def _setup_mpl(self) -> None: + """GUI なし環境でも動作するバックエンドを設定する.""" + import matplotlib + matplotlib.use("Agg") + + @pytest.fixture + def monotone_profile(self) -> numpy.ndarray: + """単調減衰の 20 点動径プロファイル(min/max が両端に存在).""" + distances = numpy.linspace(0.025, 0.975, 20) + luminances = numpy.linspace(200.0, 100.0, 20) + return numpy.column_stack([distances, luminances]) + + @pytest.fixture + def non_monotone_profile(self) -> numpy.ndarray: + """非単調の 20 点動径プロファイル(中間に極値が存在).""" + distances = numpy.linspace(0.025, 0.975, 20) + # 中間に谷があるプロファイル + luminances = numpy.array( + [200.0, 190.0, 180.0, 170.0, 160.0, 150.0, 140.0, 130.0, 120.0, 110.0, + 80.0, 110.0, 120.0, 130.0, 140.0, 150.0, 160.0, 170.0, 180.0, 190.0], + dtype=numpy.float64, + ) + return numpy.column_stack([distances, luminances]) + + @pytest.fixture + def uniform_profile(self) -> numpy.ndarray: + """均一の 20 点動径プロファイル(min=max の境界ケース).""" + distances = numpy.linspace(0.025, 0.975, 20) + luminances = numpy.full(20, 150.0) + return numpy.column_stack([distances, luminances]) + + def test_output_file_is_created_with_monotone_profile( + self, monotone_profile: numpy.ndarray, tmp_path: Path + ) -> None: + """仕様: 単調減衰プロファイルで出力ファイルが生成されること.""" + from src.visualization.plotter import plot_radial_profile + + output_path = str(tmp_path / "radial_monotone.png") + plot_radial_profile(monotone_profile, output_path) + assert Path(output_path).exists() + assert Path(output_path).stat().st_size > 0 + + def test_output_file_is_created_with_non_monotone_profile( + self, non_monotone_profile: numpy.ndarray, tmp_path: Path + ) -> None: + """仕様: 非単調プロファイルでも出力ファイルが生成されること(例外なし).""" + from src.visualization.plotter import plot_radial_profile + + output_path = str(tmp_path / "radial_non_monotone.png") + plot_radial_profile(non_monotone_profile, output_path) + assert Path(output_path).exists() + + def test_output_file_is_created_with_uniform_profile( + self, uniform_profile: numpy.ndarray, tmp_path: Path + ) -> None: + """仕様: min=max の均一プロファイルでもゼロ除算なく出力ファイルが生成されること.""" + from src.visualization.plotter import plot_radial_profile + + output_path = str(tmp_path / "radial_uniform.png") + # min=max → ratio=1.0 のケース(ゼロ除算は発生しない) + plot_radial_profile(uniform_profile, output_path) + assert Path(output_path).exists() + + def test_signature_is_unchanged(self) -> None: + """仕様: plot_radial_profile のシグネチャが不変であること(引数 radial_profile, output_path).""" + import inspect + from src.visualization.plotter import plot_radial_profile + + sig = inspect.signature(plot_radial_profile) + params = list(sig.parameters.keys()) + assert params == ["radial_profile", "output_path"], ( + f"シグネチャが変更されている: {params}" + ) diff --git a/tests/analysis/test_spatial.py b/tests/analysis/test_spatial.py index c9f9277..c0d5193 100644 --- a/tests/analysis/test_spatial.py +++ b/tests/analysis/test_spatial.py @@ -379,9 +379,16 @@ assert "zone_map" in result def test_return_value_has_exactly_3_keys(self, sample_luminance) -> None: - """正常: 戻り値のキー数が 3 であること.""" + """正常: 戻り値に最低 3 つの基本キー(zone_stats/radial_profile/zone_map)が存在すること. + + 仕様変更(動径 min/max 比の追加)によりトップレベルキー数は 3 超になりうる. + 基本 3 キーの存在を保証し,追加キーを許容する形に修正する. + """ result = calc_spatial_uniformity(sample_luminance) - assert len(result) == 3 + assert "zone_stats" in result + assert "radial_profile" in result + assert "zone_map" in result + assert len(result) >= 3 def test_zone_stats_is_dict(self, sample_luminance) -> None: """正常: zone_stats が dict 型であること.""" diff --git a/tests/export/test_exporter.py b/tests/export/test_exporter.py index f75ee56..9167c2d 100644 --- a/tests/export/test_exporter.py +++ b/tests/export/test_exporter.py @@ -340,8 +340,12 @@ assert float(second["gradient_magnitude"]) == pytest.approx(15.0) def test_spatial_fieldnames_constant_has_correct_keys(self) -> None: - """正常: SPATIAL_FIELDNAMES 定数が期待されるキーを持つこと.""" - expected = [ + """正常: SPATIAL_FIELDNAMES 定数が既存の基本キーをすべて含むこと. + + 仕様変更(動径 min/max 比の追加)により新カラムが追加されるため, + 既存の 6 カラムが含まれていることを確認し,追加カラムを許容する. + """ + base_fields = [ "image_name", "center_mean", "middle_mean", @@ -349,7 +353,9 @@ "center_periphery_ratio", "gradient_magnitude", ] - assert SPATIAL_FIELDNAMES == expected + for field in base_fields: + assert field in SPATIAL_FIELDNAMES, f"必須フィールド '{field}' が SPATIAL_FIELDNAMES に含まれていない" + assert len(SPATIAL_FIELDNAMES) >= len(base_fields) def test_creates_parent_directory_automatically( self, sample_spatial_results: list[dict], tmp_path: Path @@ -412,7 +418,10 @@ @pytest.fixture def two_image_results(self) -> list[dict]: - """テスト用の 2 画像分の解析結果リストを返す fixture.""" + """テスト用の 2 画像分の解析結果リストを返す fixture. + + 仕様変更(動径 min/max 比の追加)に対応し radial_min_max_ratio / radial_min_distance を追加. + """ return [ { "image_name": "image_001", @@ -427,6 +436,8 @@ "periphery_mean": 140.0, "center_periphery_ratio": 1.286, "gradient_magnitude": 22.2, + "radial_min_max_ratio": 0.75, + "radial_min_distance": 0.95, }, { "image_name": "image_002", @@ -441,6 +452,8 @@ "periphery_mean": 160.0, "center_periphery_ratio": 1.25, "gradient_magnitude": 20.0, + "radial_min_max_ratio": 0.80, + "radial_min_distance": 0.90, }, ] @@ -464,16 +477,20 @@ def test_spatial_contains_exactly_five_metrics( self, two_image_results: list[dict] ) -> None: - """正常: spatial 部に 5 指標のみが含まれること.""" + """正常: spatial 部に基本 5 指標が含まれること. + + 仕様変更(動径 min/max 比の追加)により spatial キーは 5 超になりうる. + 基本 5 指標の存在を確認し,追加指標を許容する形に修正する. + """ result = calc_batch_statistics(two_image_results) - expected_metrics = { + base_metrics = { "center_mean", "middle_mean", "periphery_mean", "center_periphery_ratio", "gradient_magnitude", } - assert expected_metrics == set(result["spatial"].keys()) + assert base_metrics.issubset(set(result["spatial"].keys())) def test_each_metric_has_five_stat_subkeys( self, two_image_results: list[dict] @@ -620,6 +637,8 @@ "periphery_mean": 140.0, "center_periphery_ratio": 1.286, "gradient_magnitude": 22.2, + "radial_min_max_ratio": 0.75, + "radial_min_distance": 0.95, } ] result = calc_batch_statistics(single) @@ -641,6 +660,8 @@ "periphery_mean": 140.0, "center_periphery_ratio": 1.286, "gradient_magnitude": 22.2, + "radial_min_max_ratio": 0.75, + "radial_min_distance": 0.95, } ] result = calc_batch_statistics(single) @@ -662,6 +683,8 @@ "periphery_mean": 140.0, "center_periphery_ratio": 1.286, "gradient_magnitude": 22.2, + "radial_min_max_ratio": 0.75, + "radial_min_distance": 0.95, } ] result = calc_batch_statistics(single) @@ -689,6 +712,8 @@ "periphery_mean": m - 10.0, "center_periphery_ratio": 1.1, "gradient_magnitude": 5.0, + "radial_min_max_ratio": 0.8, + "radial_min_distance": 0.9, } for i, m in enumerate([100.0, 200.0, 300.0]) ] @@ -712,6 +737,8 @@ "periphery_mean": 140.0, "center_periphery_ratio": 1.286, "gradient_magnitude": 22.2, + "radial_min_max_ratio": 0.75, + "radial_min_distance": 0.95, } for i in range(3) ] @@ -886,7 +913,11 @@ ) def test_integration_calc_then_export(self, tmp_path: Path) -> None: - """統合: calc_batch_statistics の結果を export_batch_statistics で出力できること.""" + """統合: calc_batch_statistics の結果を export_batch_statistics で出力できること. + + 仕様変更(動径 min/max 比の追加)により行数は 9 以上になりうる. + 基本指標の存在を確認し,行数は >= 9 に修正する. + """ all_results = [ { "image_name": "image_001", @@ -901,6 +932,8 @@ "periphery_mean": 140.0, "center_periphery_ratio": 1.286, "gradient_magnitude": 22.2, + "radial_min_max_ratio": 0.75, + "radial_min_distance": 0.95, }, { "image_name": "image_002", @@ -915,6 +948,8 @@ "periphery_mean": 160.0, "center_periphery_ratio": 1.25, "gradient_magnitude": 20.0, + "radial_min_max_ratio": 0.80, + "radial_min_distance": 0.90, }, ] stats = calc_batch_statistics(all_results) @@ -926,7 +961,8 @@ reader = csv.DictReader(f) rows = list(reader) - assert len(rows) == 9 # uniformity 4 + spatial 5 + # uniformity 4 + spatial 5 以上(動径 min/max 比の追加で 7 になりうる) + assert len(rows) >= 9 statistic_names = {row["statistic"] for row in rows} assert "mean" in statistic_names assert "cov" in statistic_names diff --git a/tests/io/test_dng_loader.py b/tests/io/test_dng_loader.py new file mode 100644 index 0000000..7ce1edf --- /dev/null +++ b/tests/io/test_dng_loader.py @@ -0,0 +1,437 @@ +"""dng_loader.py の単体テスト. + +仕様 (TECH_05_MiniTiasEvaluation_DNG対応要求仕様.md): + - load_image_dng: DNG ファイルを読み込み numpy.ndarray として返す + - output_format="rgb_float32": (H, W, 3) float32 linear [0, 1] + - output_format="luma_float32": (H, W) float32 linear [0, 1], Rec.709 係数 + - output_format="rgb_uint8": (H, W, 3) uint8 sRGB OETF 適用後 [0, 255] + - demosaic 既定は "bilinear" + - エラー: FileNotFoundError(存在しないパス), ValueError(不正 format/demosaic) + - Rec.709 係数 (0.2126, 0.7152, 0.0722) は uniformity.py の REC709_COEFF_* と一致すること + +テスト方針 (GUIDE_08_テスト方針.md): + - pytest を使用 + - サンプル DNG は .gitignore 対象のため不在時は pytest.mark.skipif でスキップ + - 合成 DNG(rawpy モック)または実 DNG で検証する +""" + +from pathlib import Path + +import numpy +import numpy as np +import pytest + +# サンプル DNG パス(.gitignore 対象のため不在環境では実 DNG テストをスキップ) +_SAMPLE_DNG_PATH = ( + Path(__file__).parent.parent.parent + / "data" + / "minitias" + / "quantitative" + / "MiniTIAS_QM_20260601_102753.dng" +) +_DNG_AVAILABLE = _SAMPLE_DNG_PATH.exists() + +# Rec.709 係数(仕様: uniformity.py の REC709_COEFF_* と一致) +_REC709_R = 0.2126 +_REC709_G = 0.7152 +_REC709_B = 0.0722 + + +# --------------------------------------------------------------------------- +# エラーパス テスト(DNG 不要 — パス検証のみ) +# --------------------------------------------------------------------------- + + +class TestLoadImageDngErrors: + """load_image_dng のエラー送出テスト群.""" + + def test_raises_file_not_found_for_nonexistent_path(self) -> None: + """仕様 V2 / エラー仕様: 存在しないパスで FileNotFoundError が送出されること.""" + from src.io.dng_loader import load_image_dng + + with pytest.raises(FileNotFoundError): + load_image_dng("/nonexistent/path/image.dng") + + def test_file_not_found_message_contains_path(self, tmp_path: Path) -> None: + """エラー仕様: FileNotFoundError のメッセージにパスが含まれること.""" + from src.io.dng_loader import load_image_dng + + missing = str(tmp_path / "missing.dng") + with pytest.raises(FileNotFoundError, match="missing.dng"): + load_image_dng(missing) + + def test_raises_value_error_for_invalid_output_format(self, tmp_path: Path) -> None: + """エラー仕様: 不正な output_format で ValueError が送出されること. + + 仕様では rgb_float32 / luma_float32 / rgb_uint8 の 3 種のみ有効. + """ + from src.io.dng_loader import load_image_dng + + # 実在するダミーファイルを作成(パス存在チェック通過のため) + dummy = tmp_path / "dummy.dng" + dummy.write_bytes(b"FAKE_DNG_CONTENT") + + with pytest.raises(ValueError): + load_image_dng(str(dummy), output_format="invalid_format") + + def test_raises_value_error_for_invalid_demosaic(self, tmp_path: Path) -> None: + """エラー仕様: 不正な demosaic 値で ValueError が送出されること. + + 仕様では nearest / bilinear / ahd の 3 種のみ有効. + """ + from src.io.dng_loader import load_image_dng + + dummy = tmp_path / "dummy.dng" + dummy.write_bytes(b"FAKE_DNG_CONTENT") + + with pytest.raises(ValueError): + load_image_dng(str(dummy), demosaic="invalid_demosaic") + + def test_value_error_for_invalid_format_message_is_descriptive( + self, tmp_path: Path + ) -> None: + """エラー仕様: ValueError メッセージに不正な値が含まれること.""" + from src.io.dng_loader import load_image_dng + + dummy = tmp_path / "dummy.dng" + dummy.write_bytes(b"FAKE_DNG_CONTENT") + + with pytest.raises(ValueError, match="bad_format"): + load_image_dng(str(dummy), output_format="bad_format") + + def test_invalid_output_format_rejected_before_file_read( + self, tmp_path: Path + ) -> None: + """仕様: 不正な output_format は rawpy 読み込み前にバリデーションされること. + + ファイルが存在するが内容が DNG でない場合でも ValueError が先に出ること. + (存在するが不正な内容のファイルは rawpy で ValueError になる可能性があるが, + output_format バリデーションはその前に実行されること) + """ + from src.io.dng_loader import load_image_dng + + dummy = tmp_path / "dummy.dng" + dummy.write_bytes(b"NOT_A_DNG") + + with pytest.raises(ValueError): + load_image_dng(str(dummy), output_format="unknown_format") + + +# --------------------------------------------------------------------------- +# output_format 値ガード テスト(DNG 不要) +# --------------------------------------------------------------------------- + + +class TestOutputFormatValidation: + """output_format バリデーション専用テスト群. + + 仕様: 有効な output_format は rgb_float32 / luma_float32 / rgb_uint8 の 3 種のみ. + """ + + @pytest.mark.parametrize( + "invalid_format", + ["RGB", "float32", "uint8", "luma", "rgb", "luma_uint8", ""], + ) + def test_invalid_format_raises_value_error( + self, invalid_format: str, tmp_path: Path + ) -> None: + """エラー仕様: 有効でない output_format は ValueError を送出すること.""" + from src.io.dng_loader import load_image_dng + + dummy = tmp_path / "dummy.dng" + dummy.write_bytes(b"FAKE") + + with pytest.raises(ValueError): + load_image_dng(str(dummy), output_format=invalid_format) + + @pytest.mark.parametrize( + "invalid_demosaic", + ["linear", "LINEAR", "adaptive", "vng", ""], + ) + def test_invalid_demosaic_raises_value_error( + self, invalid_demosaic: str, tmp_path: Path + ) -> None: + """エラー仕様: 有効でない demosaic は ValueError を送出すること.""" + from src.io.dng_loader import load_image_dng + + dummy = tmp_path / "dummy.dng" + dummy.write_bytes(b"FAKE") + + with pytest.raises(ValueError): + load_image_dng(str(dummy), demosaic=invalid_demosaic) + + +# --------------------------------------------------------------------------- +# 実 DNG テスト(DNG ファイルが存在する環境のみ) +# --------------------------------------------------------------------------- + +_skip_if_no_dng = pytest.mark.skipif( + not _DNG_AVAILABLE, + reason=f"サンプル DNG が存在しない: {_SAMPLE_DNG_PATH}", +) + + +@_skip_if_no_dng +class TestLoadImageDngRgbFloat32: + """output_format="rgb_float32" の仕様検証テスト群.""" + + @pytest.fixture + def rgb_float32_result(self) -> numpy.ndarray: + """rgb_float32 形式で DNG を読み込んだ結果を返す fixture.""" + from src.io.dng_loader import load_image_dng + + return load_image_dng(str(_SAMPLE_DNG_PATH), output_format="rgb_float32") + + def test_shape_is_3d_with_3_channels( + self, rgb_float32_result: numpy.ndarray + ) -> None: + """仕様: rgb_float32 の shape が (H, W, 3) であること.""" + assert rgb_float32_result.ndim == 3 + assert rgb_float32_result.shape[2] == 3 + + def test_dtype_is_float32(self, rgb_float32_result: numpy.ndarray) -> None: + """仕様: rgb_float32 の dtype が float32 であること.""" + assert rgb_float32_result.dtype == numpy.float32 + + def test_values_are_non_negative(self, rgb_float32_result: numpy.ndarray) -> None: + """仕様: rgb_float32 の値が 0 以上であること(linear raw, black_level 補正済み).""" + assert numpy.all(rgb_float32_result >= 0.0) + + def test_values_are_mostly_within_0_to_1( + self, rgb_float32_result: numpy.ndarray + ) -> None: + """仕様: rgb_float32 の値がおおむね [0, 1] 範囲内であること. + + 仕様では「飽和は 1.0 を超えることがある(クリップしない)」とあるが, + 通常の撮影では大部分のピクセルが 1.0 以下のはず. + 99 パーセンタイルが 1.0 以下であることを確認する. + """ + p99 = numpy.percentile(rgb_float32_result, 99) + assert p99 <= 1.0, f"99 パーセンタイルが 1.0 超: {p99}" + + def test_mean_value_is_positive_and_reasonable( + self, rgb_float32_result: numpy.ndarray + ) -> None: + """仕様 V1 系: CoV > 0 等の妥当性確認 — 平均値が正の有限値であること.""" + mean_val = float(numpy.mean(rgb_float32_result)) + assert mean_val > 0.0 + assert numpy.isfinite(mean_val) + + def test_std_is_positive(self, rgb_float32_result: numpy.ndarray) -> None: + """仕様 V1 系: 画像内に輝度のバラツキが存在すること(CoV > 0 に相当).""" + std_val = float(numpy.std(rgb_float32_result)) + assert std_val > 0.0 + + +@_skip_if_no_dng +class TestLoadImageDngLumaFloat32: + """output_format="luma_float32" の仕様検証テスト群.""" + + @pytest.fixture + def luma_and_rgb(self) -> tuple[numpy.ndarray, numpy.ndarray]: + """luma_float32 と rgb_float32 の両方を読み込んで返す fixture.""" + from src.io.dng_loader import load_image_dng + + luma = load_image_dng(str(_SAMPLE_DNG_PATH), output_format="luma_float32") + rgb = load_image_dng(str(_SAMPLE_DNG_PATH), output_format="rgb_float32") + return luma, rgb + + def test_shape_is_2d( + self, luma_and_rgb: tuple[numpy.ndarray, numpy.ndarray] + ) -> None: + """仕様: luma_float32 の shape が (H, W) の 2 次元配列であること.""" + luma, _ = luma_and_rgb + assert luma.ndim == 2 + + def test_dtype_is_float32( + self, luma_and_rgb: tuple[numpy.ndarray, numpy.ndarray] + ) -> None: + """仕様: luma_float32 の dtype が float32 であること.""" + luma, _ = luma_and_rgb + assert luma.dtype == numpy.float32 + + def test_shape_matches_rgb_hw( + self, luma_and_rgb: tuple[numpy.ndarray, numpy.ndarray] + ) -> None: + """仕様: luma_float32 の shape が rgb_float32 の (H, W) と一致すること.""" + luma, rgb = luma_and_rgb + assert luma.shape == rgb.shape[:2] + + def test_values_are_non_negative( + self, luma_and_rgb: tuple[numpy.ndarray, numpy.ndarray] + ) -> None: + """仕様: luma_float32 の値が 0 以上であること.""" + luma, _ = luma_and_rgb + assert numpy.all(luma >= 0.0) + + def test_luma_is_rec709_weighted_sum_of_rgb( + self, luma_and_rgb: tuple[numpy.ndarray, numpy.ndarray] + ) -> None: + """仕様: luma_float32 が Rec.709 係数 (0.2126, 0.7152, 0.0722) で rgb_float32 から + 導出した輝度と一致すること. + + luma = 0.2126 * R + 0.7152 * G + 0.0722 * B + この係数は uniformity.py の REC709_COEFF_* と一致する. + """ + luma, rgb = luma_and_rgb + expected_luma = ( + _REC709_R * rgb[:, :, 0] + + _REC709_G * rgb[:, :, 1] + + _REC709_B * rgb[:, :, 2] + ).astype(numpy.float32) + numpy.testing.assert_allclose( + luma, expected_luma, rtol=1e-5, atol=1e-6, + err_msg="luma_float32 が Rec.709 係数による rgb_float32 の加重和と一致しない", + ) + + def test_mean_value_is_positive_and_finite( + self, luma_and_rgb: tuple[numpy.ndarray, numpy.ndarray] + ) -> None: + """仕様 V1 系: luma_float32 の平均値が正の有限値であること.""" + luma, _ = luma_and_rgb + mean_val = float(numpy.mean(luma)) + assert mean_val > 0.0 + assert numpy.isfinite(mean_val) + + +@_skip_if_no_dng +class TestLoadImageDngRgbUint8: + """output_format="rgb_uint8" の仕様検証テスト群.""" + + @pytest.fixture + def rgb_uint8_result(self) -> numpy.ndarray: + """rgb_uint8 形式で DNG を読み込んだ結果を返す fixture.""" + from src.io.dng_loader import load_image_dng + + return load_image_dng(str(_SAMPLE_DNG_PATH), output_format="rgb_uint8") + + def test_shape_is_3d_with_3_channels( + self, rgb_uint8_result: numpy.ndarray + ) -> None: + """仕様: rgb_uint8 の shape が (H, W, 3) であること.""" + assert rgb_uint8_result.ndim == 3 + assert rgb_uint8_result.shape[2] == 3 + + def test_dtype_is_uint8(self, rgb_uint8_result: numpy.ndarray) -> None: + """仕様: rgb_uint8 の dtype が uint8 であること.""" + assert rgb_uint8_result.dtype == numpy.uint8 + + def test_values_are_in_0_to_255_range( + self, rgb_uint8_result: numpy.ndarray + ) -> None: + """仕様: rgb_uint8 の値が [0, 255] の範囲内であること(uint8 なので常に満たす).""" + assert numpy.all(rgb_uint8_result >= 0) + assert numpy.all(rgb_uint8_result <= 255) + + def test_mean_value_is_positive_and_finite( + self, rgb_uint8_result: numpy.ndarray + ) -> None: + """仕様 V1 系: rgb_uint8 の平均値が正の有限値であること(CoV > 0 等の妥当性).""" + mean_val = float(numpy.mean(rgb_uint8_result)) + assert mean_val > 0.0 + assert numpy.isfinite(mean_val) + + +@_skip_if_no_dng +class TestLoadImageDngDemosaic: + """demosaic 引数の仕様検証テスト群.""" + + def test_default_demosaic_is_bilinear_callable(self) -> None: + """仕様: 既定 demosaic="bilinear" で正常に読み込めること.""" + from src.io.dng_loader import load_image_dng + + # 引数なし(既定値 bilinear)で例外が発生しないこと + result = load_image_dng(str(_SAMPLE_DNG_PATH)) + assert isinstance(result, numpy.ndarray) + + def test_demosaic_ahd_does_not_raise(self) -> None: + """仕様: demosaic="ahd" で例外が発生しないこと.""" + from src.io.dng_loader import load_image_dng + + result = load_image_dng(str(_SAMPLE_DNG_PATH), demosaic="ahd") + assert isinstance(result, numpy.ndarray) + assert result.ndim == 3 # rgb_float32 既定 + + def test_demosaic_nearest_does_not_raise(self) -> None: + """仕様: demosaic="nearest" で例外が発生しないこと.""" + from src.io.dng_loader import load_image_dng + + result = load_image_dng(str(_SAMPLE_DNG_PATH), demosaic="nearest") + assert isinstance(result, numpy.ndarray) + + def test_demosaic_bilinear_explicit_does_not_raise(self) -> None: + """仕様: demosaic="bilinear" を明示的に指定しても例外が発生しないこと.""" + from src.io.dng_loader import load_image_dng + + result = load_image_dng(str(_SAMPLE_DNG_PATH), demosaic="bilinear") + assert isinstance(result, numpy.ndarray) + + def test_bilinear_and_ahd_return_same_shape(self) -> None: + """仕様: bilinear と ahd で同一 shape の結果が返ること(センサ解像度が同一のため).""" + from src.io.dng_loader import load_image_dng + + bilinear = load_image_dng( + str(_SAMPLE_DNG_PATH), demosaic="bilinear", output_format="rgb_float32" + ) + ahd = load_image_dng( + str(_SAMPLE_DNG_PATH), demosaic="ahd", output_format="rgb_float32" + ) + assert bilinear.shape == ahd.shape + + +@_skip_if_no_dng +class TestLoadImageDngLinearity: + """線形性(linear ドメイン)の仕様検証テスト群. + + 仕様: rgb_float32 の各チャンネルが入射光量に比例すること(カラーマトリクス非適用). + 直接的な「減光プロファイル vs 生 Bayer 比較」は実機依存のため, + ここでは proxy 検証(no_auto_bright + gamma=(1,1) の確認)を行う. + """ + + def test_rgb_float32_and_luma_float32_have_consistent_brightness(self) -> None: + """仕様 線形性: rgb_float32 から計算した Rec.709 luma と luma_float32 が一致すること. + + これは linear ドメインでの輝度計算の一貫性を確認する検証である. + """ + from src.io.dng_loader import load_image_dng + + rgb = load_image_dng(str(_SAMPLE_DNG_PATH), output_format="rgb_float32") + luma = load_image_dng(str(_SAMPLE_DNG_PATH), output_format="luma_float32") + + computed_luma = ( + _REC709_R * rgb[:, :, 0] + + _REC709_G * rgb[:, :, 1] + + _REC709_B * rgb[:, :, 2] + ).astype(numpy.float32) + + numpy.testing.assert_allclose( + luma, computed_luma, rtol=1e-5, atol=1e-6, + ) + + def test_rgb_float32_channels_are_all_positive(self) -> None: + """仕様 線形性: 黒レベル除去後の全チャンネル値が非負であること(線形担保の確認).""" + from src.io.dng_loader import load_image_dng + + rgb = load_image_dng(str(_SAMPLE_DNG_PATH), output_format="rgb_float32") + # 黒レベル補正済み linear raw なので全値が 0 以上 + assert numpy.all(rgb >= 0.0), "黒レベル補正後に負値が存在する(線形性が崩れている)" + + def test_rgb_float32_green_channel_has_highest_mean(self) -> None: + """仕様 線形性: BGGR Bayer では G チャンネルのサンプリング密度が高く, + 平均輝度が他チャンネルより大きい(または近い)ことを確認する. + + カラーマトリクス非適用(output_color=raw)のため, + G チャンネル(Rec.709 重み 0.7152 が最大)の値が全体で高い傾向がある. + """ + from src.io.dng_loader import load_image_dng + + rgb = load_image_dng(str(_SAMPLE_DNG_PATH), output_format="rgb_float32") + mean_r = float(numpy.mean(rgb[:, :, 0])) + mean_g = float(numpy.mean(rgb[:, :, 1])) + mean_b = float(numpy.mean(rgb[:, :, 2])) + + # G > B であることは BGGR の物理的特性から期待できる + # (R との大小関係は照明スペクトルに依存するため厳密な比較はしない) + assert mean_g > mean_b, ( + f"G チャンネル平均 ({mean_g:.4f}) が B チャンネル平均 ({mean_b:.4f}) 以下" + )