diff --git a/CLAUDE.md b/CLAUDE.md index 481fe09..f5aadb4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: 下・左・右の単灯切り分け評価を実施(TEST_04・`compare_single_leds.py`),output をデバイス別構造(`output/smtias/{figures,results}`・`output/compare/`)へ整理.次は output/results 側の whiteboard 分離 + 3 灯合成 vs 単灯加算の検証 +最新: 後側ロフト w=0.5(従来)vs w=1.4 の実機評価を実施(TEST_05・`compare_composite_vs_sum.py`/`compare_loft_housings.py` 新規).全灯均一性は従来同等以上(CoV 0.0695→0.0617,増光は暗部選択的 +13%)で TEST_10 シミュ予測を検証,全灯実測 ≈ 単灯加算(比 1.031)も確認.次は output/results 側の whiteboard 分離 + 単灯バランス逆転(下>左>右)の要因切り分け ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは GUIDE_05「進捗記録の運用ルール(CLAUDE.md / PROGRESS.md)」を参照. ## 必須ルール(コード実装時) @@ -80,3 +80,4 @@ - PNG/DNG 評価比較報告書: docs/06_TEST/TEST_02_PNG_DNG評価比較報告書.md - 外乱光影響評価報告書: docs/06_TEST/TEST_03_外乱光影響評価報告書.md - 単灯照明比較評価報告書: docs/06_TEST/TEST_04_単灯照明比較評価報告書.md +- 後側ロフト w=1.4 実機評価報告書: docs/06_TEST/TEST_05_後側ロフトw1p4実機評価報告書.md diff --git "a/docs/06_TEST/TEST_05_\345\276\214\345\201\264\343\203\255\343\203\225\343\203\210w1p4\345\256\237\346\251\237\350\251\225\344\276\241\345\240\261\345\221\212\346\233\270.md" "b/docs/06_TEST/TEST_05_\345\276\214\345\201\264\343\203\255\343\203\225\343\203\210w1p4\345\256\237\346\251\237\350\251\225\344\276\241\345\240\261\345\221\212\346\233\270.md" new file mode 100644 index 0000000..0e633b4 --- /dev/null +++ "b/docs/06_TEST/TEST_05_\345\276\214\345\201\264\343\203\255\343\203\225\343\203\210w1p4\345\256\237\346\251\237\350\251\225\344\276\241\345\240\261\345\221\212\346\233\270.md" @@ -0,0 +1,133 @@ +# 後側ロフト w=1.4 筐体 実機評価報告書 (Rear Housing Loft w=1.4 Field Evaluation) + +## 概要 (Overview) + +後側筐体のみをロフトパラメータ 1.4 版(SmTIAS-LightSim `configs/meshes/後側_w1p4.stl`)で再印刷・交換した SmTIAS 001 について,白板撮影による照明評価を実施した.**従来筐体の後側ロフトパラメータは 0.5** であり,本評価は後側ロフト w=0.5(従来)→ w=1.4(再印刷)の変更に対する実機比較である.評価は(1)**全灯点灯の均一性**(従来筐体 w=0.5 の正規ターゲットとの比較=SmTIAS-LightSim TEST_10 シミュレーション予測の実機検証),(2)**下・左・右の単灯切り分け**(TEST_04 と同条件の再評価),(3)**全灯実測 vs 単灯加算(下+左+右)**(TEST_04 限界節で挙げた線形加算仮説の定量検証)の 3 本立てである. + +結論を先に述べると, + +1. **全灯の均一性・形はほぼ従来筐体(w=0.5)と同等**(CoV 0.0695 → 0.0617 とむしろ微改善)であり,TEST_10 のシミュ予測「全灯の均一性・形はほぼ不変」と整合した.全体光量は +13%(画素比 w1.4/w0.5 の中央値 1.131)で,増光は像面上側の暗部で最大(+15〜17%)・中心明部で最小(+9%)と**暗部を選択的に持ち上げる**分布だった. +2. 単灯の明るさ順は **下 > 左 > 右** となり,従来筐体 w=0.5(TEST_04: 右 > 左 > 下)から**逆転**した. +3. 全灯実測は単灯加算とよく一致し(実測/加算の平均比 1.031,CoV は両者 0.062 で同一),**照明がほぼ線形加算で合成される**ことを実測で確認した. + +## 前提・対象データ (Scope and Samples) + +| 条件 | データ | 撮影日時 | +| --- | --- | --- | +| 全灯(白板に汚れあり) | `SmTIAS_QM_20260726_183416` | 2026-07-26 18:34 | +| 全灯(白板清掃後) | `SmTIAS_QM_20260727_124257` | 2026-07-27 12:42 | +| 下のみ点灯 | `SmTIAS_QM_20260727_134105` | 2026-07-27 13:41 | +| 左のみ点灯 | `SmTIAS_QM_20260727_134853` | 2026-07-27 13:48 | +| 右のみ点灯 | `SmTIAS_QM_20260727_140129` | 2026-07-27 14:01 | +| 比較対象: 従来筐体 w=0.5・全灯(正規ターゲット) | `SmTIAS_QM_20260601_124500` | 2026-06-01 12:45 | +| 比較対象: 従来筐体 w=0.5・単灯(下/左/右,TEST_04) | `SmTIAS_QM_20260615_{141420, 150300, 170948}` | 2026-06-15 | + +| 項目 | 内容 | +| --- | --- | +| 機種 / モード | SH-02M / 定量モード(RAW_SENSOR,AE/AWB OFF,ISO 40,露光 1/120 s) | +| ハード変更点 | 後側筐体のみロフト 1.4 版に交換(**従来筐体の後側ロフトは 0.5**).下側フレア部が外側へ最大約 10 mm 膨張・キャビティ体積 +6.3%.LED 基板・評価板側の形状は不変.前側筐体・LED カバー・評価板・LED は従来品 | +| 評価領域 (ROI) | `config/roi_config.json` の `smtias.whiteboard`(x=539, y=1015, 1400×1409,全枚共通) | +| 評価条件 | linear(output_color=raw)/ portrait / LSC 順適用(従来ターゲットと同一手順) | + +> **前提(本評価の限界)**: 単灯 3 枚は遮光作業を挟んだ別撮影である.また**下のみ点灯(`134105`)は ROI 下端(y≈1391〜1408,x≈68〜611)に遮光テープが写り込み**,最小輝度 8.33・max/min 比 11.48 と min 依存指標が無効化されている(TEST_04 初回右のみと同型の失敗).写り込み行を除外(y<1370)した参考値は mean 81.41・CoV 0.0576・max/min 1.560.CoV・中心/周辺比など面平均系の指標への影響は軽微(写り込みは ROI 面積の約 0.14%). + +## 結果 (Results) + +### 1. 全灯均一性 — 従来筐体 w=0.5 との比較(TEST_10 実機検証) + +| 指標 | 従来 w=0.5 (0601_124500) | w1p4・汚れあり (0726_183416) | w1p4・清掃後 (0727_124257) | +| --- | --- | --- | --- | +| 平均輝度 | 207.82 | 226.53 | **234.78** | +| 標準偏差 | 14.45 | 14.47 | 14.48 | +| CoV | 0.0695 | 0.0639 | **0.0617** | +| 最大/最小比 | 1.6166 | 1.5955 | **1.5925** | +| 中心/周辺比 | 1.0911 | 1.1059 | **1.0855** | +| 勾配 (%) | 8.35 | 9.58 | **7.87** | +| 動径 min/max 比 | 0.8600 | 0.8400 | 0.8550 | + +(**太字**=3 条件中もっとも良好な値) + +- 清掃後の w1p4 は CoV・max/min 比・中心/周辺比・勾配の全てで従来筐体と同等以上.動径 min/max 比のみ僅差で従来が上回るが差は 0.005. +- 標準偏差は 3 条件でほぼ同一(14.45〜14.48)であり,CoV の改善は主に平均輝度の上昇(+13%)による.キャビティ体積 +6.3% に伴う内面反射の寄与増と,撮影セッション間の光量差の両方が考えられる. +- シミュ予測(CoV 0.1717 → 0.1701,ほぼ不変)に対し,実機は 0.0695 → 0.0617(−11%).絶対値はシミュと実機で水準が異なるが,「悪化しない・形が変わらない」という予測の向きは実機でも支持された. +- **画素ごとの比 w1.4/w0.5**(`compare_loft_housings_all.png` 右上): 中央値 1.131・[p5, p95] = [1.090, 1.172].増光は一様でなく,**像面上側の暗部で最大(+15〜17%)・中心明部で最小(+9%)**という空間分布を持つ.暗部が選択的に持ち上がることが CoV 微改善(0.0695 → 0.0617)の実体である. +- 白板清掃の効果: 汚れあり → 清掃後で CoV 0.0639 → 0.0617.汚れあり版の輝度マップに見られた左中段(x≈100〜170,y≈750〜800)の暗点 2 つは清掃後に消失しており,白板の汚れと確認された. + +### 2. 単灯切り分け(w1p4)— 従来筐体 w=0.5(TEST_04)との比較 + +| 指標 | 下のみ | 左のみ | 右のみ | +| --- | --- | --- | --- | +| 平均輝度 | **81.21** | 75.19 | 71.33 | +| CoV | 0.0649(除外時 0.0576) | **0.0615** | 0.0822 | +| 最大/最小比 | (11.477)※ 除外時 1.560 | 1.675 | 1.796 | +| 中心/周辺比 | 1.116 | **1.106** | 1.118 | +| 勾配 (%) | 10.43 | **9.56** | 10.58 | +| 動径 min/max 比 | 0.829 | **0.848** | 0.828 | + +(※ 下のみは遮光テープ写り込みにより min 依存指標が無効.「除外時」は写り込み行除外の参考値.**太字**=3 灯中もっとも良好な値) + +- 明るさ順は **下 (81.2) > 左 (75.2) > 右 (71.3)**.従来筐体 w=0.5 の TEST_04(右 63.6 > 左 61.5 > 下 57.8)から**順位が逆転**し,従来最弱だった下灯が最強になった.筐体別 2×3 並置は `compare_loft_housings_singles.png` を参照. +- 単灯の平均輝度は TEST_04 比で全灯とも大幅増(下 +41%・左 +22%・右 +12%).変形が下側フレア部(下灯側の空間拡大)であることと,下灯の増分が最大であることは整合的である.ただし TEST_04 とは撮影日・遮光作業・白板状態が異なるため,筐体効果と条件差の切り分けは今後の課題. +- 均一性は左が最良・右が最劣(CoV 0.0822)となり,TEST_04(右が最良)から傾向が変わった. +- 各灯が異なる方向を照らす相補配置は従来同様に維持されている(比較図参照:下=像面中央〜上側を広く,左=像面右寄り,右=像面右下寄り). + +### 3. 全灯実測 vs 単灯加算(線形加算仮説の検証) + +| 指標 | 実測(全灯) | 加算(下+左+右) | +| --- | --- | --- | +| 平均輝度 | 234.78 | 227.73 | +| CoV | 0.0617 | 0.0617 | +| 最大/最小比 | 1.593 | 1.857※ | +| 中心/周辺比 | 1.085 | 1.113 | +| 勾配 (%) | 7.87 | 10.19 | +| 動径 min/max 比 | 0.855 | 0.840 | +| 実測/加算 比 | 平均 1.031・中央値 1.031・[p5, p95] = [0.994, 1.065] | — | + +(※ 加算マップは下のみ点灯の写り込みを含むため max/min 比は無効) + +- **実測 ≈ 加算が成立**: 画素ごとの実測/加算比は平均 1.031,90% の画素が 0.994〜1.065 に収まる.CoV は両者 0.062 で一致し,**「3 灯の相補で合成均一性が成立する」仮説(TEST_04)は定量的に支持された**. +- 系統的な残差: 比マップは中心部で僅かに 1 未満(≈0.96〜0.98)・周辺部で 1 超(≈1.03〜1.06)と緩やかな空間構造を持つ.全体 +3.1% のオフセットは,単灯撮影時の黒テープが(遮光した灯の LED カバー面での)内面反射も奪うこと,およびセッション内の光量ドリフトで説明できる範囲と考える.周辺で実測が相対的に明るいことは,実測の動径 min/max 比(0.855)が加算(0.840)より良い=**実灯合成は単純加算よりさらに均一**という結果に対応する. + +### 比較図・出力一覧 + +- 筐体比較・全灯(w0.5 vs w1.4,比マップ付き): `output/smtias/figures/compare_loft_housings_all.png` +- 筐体比較・単灯(w0.5 vs w1.4,2×3 並置): `output/smtias/figures/compare_loft_housings_singles.png` +- 単灯 3 枚比較(w1p4 のみ,共通絶対カラースケール): `output/smtias/figures/compare_single_leds_w1p4.png` +- 全灯実測 vs 単灯加算(比マップ付き): `output/smtias/figures/compare_composite_vs_sum_w1p4.png` +- 各撮影の個別出力(輝度マップ / ヒストグラム / 動径プロファイル / ゾーンマップ / CSV): `output/smtias/{figures,results}/SmTIAS_QM_2026072*_*` + +## 考察 (Analysis) + +### 1. ロフト 0.5 → 1.4 変形は全灯均一性を損なわない(TEST_10 の実機検証成立) + +CoV・max/min 比・中心/周辺比・勾配のいずれも従来筐体 w=0.5 と同等〜微改善であり,シミュ予測「均一性・形はほぼ不変」は実機で再現された.後側筐体の下側フレア部を最大 10 mm 膨張させても照明性能上の副作用はなく,むしろ光量 +13% は像面上側の暗部に厚く配分され(比マップ),均一性を僅かに押し上げた. + +### 2. 単灯バランスの逆転は下側キャビティ拡大と整合的 + +従来最弱だった下灯が最強(+41%)へ転じ,増分は下 > 左 > 右 の順で変形部位(下側)に近いほど大きい.これはキャビティ拡大による下灯光路の開放・内面反射増という筐体効果として説明可能である.一方で TEST_04 とは撮影条件が完全同一でないため,断定には従来筐体での同日再測が必要である. + +### 3. 線形加算モデルの成立 — 設計ツールとしての含意 + +実測 ≈ 単灯加算(±3〜6%)が確認されたことで,**単灯マップの加算による合成予測**が実用精度で成り立つ.今後の LED 配置・光量バランス検討は,全灯撮影を繰り返さずに単灯データの重み付き加算で机上検討できる.また灯別の劣化診断(単灯撮影 → 加算予測と全灯実測の乖離監視)にも応用できる. + +## 結論 (Conclusion) + +1. 後側ロフト w=0.5(従来)→ w=1.4 で全灯均一性は同等以上(CoV 0.0695 → 0.0617・光量 +13%)となり,**SmTIAS-LightSim TEST_10 のシミュ予測は実機検証された**. +2. 単灯の明るさ順は**下 > 左 > 右に逆転**し,変形部位(下側)に近い灯ほど明るさが増した.筐体効果として整合的だが,条件差の切り分けは残課題. +3. **全灯実測 ≈ 単灯加算**(平均比 1.031・CoV 一致)が成立し,3 灯相補による合成均一性の仮説(TEST_04)を定量的に裏付けた. + +## 限界と今後 (Limitations & Future Work) + +- 下のみ点灯(`134105`)は ROI 内に遮光テープが写り込み,min 依存指標が無効(面平均系は有効).撮影時にフレーム外で遮光する運用(TEST_04 で提起済み)が再度徹底されなかった.**単灯撮影の遮光手順を SPEC_03 に明文化する**ことを推奨する. +- 単灯バランス逆転(下 > 左 > 右)の要因切り分けには,従来筐体に戻した同日・同手順の単灯再測が必要. +- 従来筐体ターゲットは暗闇撮影,本評価の撮影環境の外乱光条件は未記録.TEST_03 の知見では定量モードは外乱光の影響を受けにくいが,厳密比較には撮影環境の統一が望ましい. +- 実測/加算比の空間構造(中心 <1・周辺 >1)の要因(遮光テープによる内面反射の喪失か,光量ドリフトか)は未分離.単灯撮影の順序を変えた再測や,遮光なし・カバー面反射条件の比較で切り分けられる. + +## 参照 (References) + +- [TEST_04 単灯照明比較評価報告書](TEST_04_単灯照明比較評価報告書.md) +- [TEST_03 外乱光影響評価報告書](TEST_03_外乱光影響評価報告書.md) +- SmTIAS-LightSim `docs/06_TEST/TEST_10_後側ロフト変形w1p4実験.md`(シミュレーション側の記録) +- [SPEC_03 TIAS 白板撮影プロトコル](../04_SPEC/SPEC_03_TIAS白板撮影プロトコル.md) +- 評価実装: `scripts/run_uniformity_dng.py`(個別指標算出)/`scripts/compare_single_leds.py`(3 単灯比較図,CLI 対応)/`scripts/compare_composite_vs_sum.py`(全灯実測 vs 単灯加算,新規)/`scripts/compare_loft_housings.py`(筐体 w0.5 vs w1.4 の全灯・単灯直接比較,新規) +- 対象データ: `data/smtias/quantitative/`(gitignore 対象) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index b2e1a85..f1a853d 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -37,3 +37,12 @@ - **output 構造の整理(再発防止込み)**: 混在していた `output/figures`・`output/results` をデバイス別へ集約 → `output/smtias/{figures,results}`(SmTIAS 全成果物)/`output/compare/`(smtias↔tias 横断比較)/`output/tias_dfk23*`(既存).さらに figures 内で非 QM(PNG 白板 `SmTIAS_20260408_*` セット)を `output/smtias/figures/whiteboard/` サブフォルダへ分離. - **スクリプト追従**: 再実行で散らからないよう出力先を更新.compare 系のハードコード `OUTPUT_PATH` を新構造へ(ambient/png_dng→`output/smtias/figures/`,smtias_tias→`output/compare/`)+ `mkdir(parents=True)` を追加.`run_uniformity*.py`・`plot_radial_clean.py` の `--output` 既定を `output/` → `output/smtias/` に変更(TIAS は従来どおり `--output` 明示で分離).`compare_png_dng_slide.py` の mkdir 重複も解消.pytest は 381 passed のまま(テストは tmp_path 依存でパス変更の影響なし). - **未着手(保留)**: `output/smtias/results/` 側は非 QM/summary を `whiteboard/` 配下へ分離する整理が未対応(figures のみ実施).3 灯合成の実測 vs 単灯加算(下+左+右)の検証も今後の課題(TEST_04 限界節). + +- [x] **後側ロフト w=1.4 筐体の実機評価(2026-07-27)**: 後側筐体のみロフト 1.4 版(SmTIAS-LightSim `後側_w1p4.stl`,下側フレア部が最大約 10 mm 膨張・キャビティ +6.3%)に交換した SmTIAS 001 を白板撮影で評価.**従来筐体の後側ロフトは 0.5**(比較の基準).全灯×2(汚れあり/清掃後)+単灯 3 枚を取得し,TEST_10(シミュ)の実機検証と TEST_04 限界節の「実測 vs 単灯加算」検証を実施.報告書 `docs/06_TEST/TEST_05_後側ロフトw1p4実機評価報告書.md`. + - **対象データ**: `data/smtias/quantitative/SmTIAS_QM_20260726_183416`(全灯・汚れあり)/`20260727_{124257=全灯清掃後,134105=下,134853=左,140129=右}`.取り込み名 `MiniTIAS_QM_*` は既存規約どおり `SmTIAS_QM_*` へリネーム(LED 条件はファイル名に含めず報告書側で管理). + - **結果(全灯)**: CoV 0.0695(従来筐体 0601_124500)→ 0.0617(w1p4 清掃後)と同等〜微改善.標準偏差はほぼ同一で改善は平均輝度 +13% による.TEST_10 のシミュ予測「均一性・形はほぼ不変」を実機で支持.白板清掃で CoV 0.0639→0.0617(汚れ由来の暗点 2 つが消失). + - **結果(単灯)**: 明るさ順が **下 81.2 > 左 75.2 > 右 71.3** と TEST_04(右>左>下)から逆転.増分は下 +41% > 左 +22% > 右 +12% で変形部位(下側)に近いほど大きく筐体効果として整合的.ただし撮影条件差との切り分けは残課題. + - **結果(加算検証)**: 全灯実測 ≈ 単灯加算が成立(実測/加算 比: 平均 1.031,[p5,p95]=[0.994,1.065],CoV は両者 0.062 で一致).3 灯相補仮説を定量的に裏付け.比マップは中心 <1・周辺 >1 の緩い構造(遮光テープの内面反射喪失 or 光量ドリフト,未分離). + - **失敗の再発(写り込み)**: 下のみ `134105` の ROI 下端(y≈1391–1408, x≈68–611, 面積比 0.14%)に遮光テープが写り込み min 依存指標が無効化(max/min 11.5).写り込み行除外の参考値 CoV 0.0576・max/min 1.560 を報告書に併記.TEST_04 で提起した「フレーム外で遮光」運用が徹底されず → SPEC_03 への手順明文化を推奨として記載. + - **スクリプト**: `compare_single_leds.py` を CLI 引数化(`--bottom/--left/--right/--output/--title-suffix`,既定値は従来撮影で挙動不変).`compare_composite_vs_sum.py` を新規追加(実測/加算の共通スケール並置+比マップ+動径重ね描き+指標表).出力は `compare_single_leds_w1p4.png`・`compare_composite_vs_sum_w1p4.png`. + - **筐体直接比較(w0.5 vs w1.4,追加実施)**: `compare_loft_housings.py` を新規追加し,全灯(比マップ付き)と単灯(2×3 並置+動径重ね描き 6 本)を筐体間で直接比較.画素比 w1.4/w0.5 は中央値 1.131・[p5,p95]=[1.090,1.172] で,**増光は像面上側の暗部で最大(+15〜17%)・中心明部で最小(+9%)**.暗部の選択的持ち上げが CoV 微改善の実体と判明.出力 `compare_loft_housings_{all,singles}.png`. diff --git a/scripts/compare_composite_vs_sum.py b/scripts/compare_composite_vs_sum.py new file mode 100644 index 0000000..5160251 --- /dev/null +++ b/scripts/compare_composite_vs_sum.py @@ -0,0 +1,205 @@ +"""全灯点灯の実測と単灯 3 枚の加算合成を比較するスクリプト. + +同一セッションで撮影した「全灯点灯」1 枚と「下・左・右の単灯」3 枚(いずれも白板 DNG) +について,単灯輝度マップの画素ごとの加算(下+左+右)が全灯実測を再現するかを検証する. +照明が線形加算で合成される(灯間の相互作用がない)なら 実測 ≈ 加算 となるはずで, +「3 灯の相補で合成均一性が成立する」仮説(TEST_04)の定量検証にあたる. + +構成: +- 上段: 全灯実測 / 単灯加算 の輝度マップ(共通絶対カラースケール)+ 比マップ(実測/加算) +- 下段左: 動径プロファイル重ね描き(実測・加算の絶対値) +- 下段右: 数値表(平均・CoV・max/min 比・中心/周辺比・勾配・動径 min/max 比 + 比の統計) + +すべて linear + LSC 補正・ROI=smtias.whiteboard で揃えて算出する. + +使い方: + python scripts/compare_composite_vs_sum.py --all --bottom --left --right +""" + +import argparse +import sys +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from src.analysis.spatial import ( # noqa: E402 + calc_distance_map, + calc_radial_min_max_ratio, + calc_radial_profile, + calc_spatial_uniformity, +) +from src.analysis.uniformity import calc_uniformity # noqa: E402 +from src.config import load_roi_config # noqa: E402 +from src.io.dng_pipeline import dng_luminance # noqa: E402 +from src.io.loader import extract_roi # noqa: E402 + +ROI_CONFIG = PROJECT_ROOT / "config" / "roi_config.json" +ROI_KEY = "smtias.whiteboard" +DEFAULT_OUTPUT = PROJECT_ROOT / "output" / "smtias" / "figures" / "compare_composite_vs_sum.png" + +# 共通絶対カラースケールの設定(外れ値に強いパーセンタイルで vmin/vmax を決める) +SCALE_PERCENTILES = (1.0, 99.0) + + +def _luminance(dng_path: Path, roi: dict) -> np.ndarray: + """DNG を linear + LSC 補正で輝度化し ROI を切り出す(portrait).""" + luma_full = dng_luminance(str(dng_path), apply_lsc=True) + return extract_roi(luma_full, roi["x"], roi["y"], roi["width"], roi["height"]) + + +def _metrics(luminance: np.ndarray) -> dict: + """均一性・空間・動径の指標をまとめて算出する.""" + distance_map = calc_distance_map(*luminance.shape) + profile = calc_radial_profile(luminance, distance_map) + radial = calc_radial_min_max_ratio(profile) + return { + **calc_uniformity(luminance), + **calc_spatial_uniformity(luminance)["zone_stats"], + "radial_min_max_ratio": radial["radial_min_max_ratio"], + "profile": profile, + } + + +def _parse_args() -> argparse.Namespace: + """CLI 引数を解釈する.""" + parser = argparse.ArgumentParser( + description="全灯実測と単灯 3 枚の加算合成(下+左+右)を比較する." + ) + parser.add_argument("--all", required=True, dest="all_on", help="全灯点灯の DNG パス") + parser.add_argument("--bottom", required=True, help="下のみ点灯の DNG パス") + parser.add_argument("--left", required=True, help="左のみ点灯の DNG パス") + parser.add_argument("--right", required=True, help="右のみ点灯の DNG パス") + parser.add_argument("--output", default=str(DEFAULT_OUTPUT), help="出力する比較図のパス") + parser.add_argument( + "--title-suffix", default="", help="図タイトルの末尾に付ける文字列(例: '(w1p4)')" + ) + return parser.parse_args() + + +def main() -> None: + """全灯実測 vs 単灯加算の比較図を生成して保存する.""" + args = _parse_args() + roi = load_roi_config(str(ROI_CONFIG), ROI_KEY) + if roi is None: + raise SystemExit(f"ROI '{ROI_KEY}' が config に見つかりません") + + measured = _luminance(Path(args.all_on), roi) + singles = [_luminance(Path(p), roi) for p in (args.bottom, args.left, args.right)] + summed = np.sum(singles, axis=0) + + labels = ["Measured (all LEDs)", "Sum (bottom+left+right)"] + lumas = [measured, summed] + metrics = [_metrics(luma) for luma in lumas] + + # 比マップ(実測 / 加算)とその統計 + ratio = measured / np.maximum(summed, 1e-6) + ratio_stats = { + "mean": float(np.mean(ratio)), + "median": float(np.median(ratio)), + "p5": float(np.percentile(ratio, 5)), + "p95": float(np.percentile(ratio, 95)), + } + + flat_all = np.concatenate([luma.ravel() for luma in lumas]) + vmin, vmax = np.percentile(flat_all, SCALE_PERCENTILES) + + fig = plt.figure(figsize=(15, 9)) + gs = fig.add_gridspec(2, 3, height_ratios=[1.0, 0.85], hspace=0.28, wspace=0.22) + + # 上段: 実測・加算(共通絶対スケール) + map_axes = [] + im = None + for col, (label, luma) in enumerate(zip(labels, lumas)): + ax = fig.add_subplot(gs[0, col]) + im = ax.imshow(luma, cmap="hot", interpolation="nearest", vmin=vmin, vmax=vmax) + ax.set_title(f"{label} (mean={metrics[col]['mean']:.1f})", fontsize=11) + ax.set_xlabel("X (px)") + if col == 0: + ax.set_ylabel("Y (px)") + map_axes.append(ax) + cbar = fig.colorbar(im, ax=map_axes, fraction=0.020, pad=0.02) + cbar.set_label(f"Luminance (common scale, p{SCALE_PERCENTILES[0]:g}-p{SCALE_PERCENTILES[1]:g})") + + # 上段右: 比マップ(実測/加算.1.0 中心の発散スケール) + ax_ratio = fig.add_subplot(gs[0, 2]) + half_range = max(abs(ratio_stats["p5"] - 1.0), abs(ratio_stats["p95"] - 1.0)) + im_r = ax_ratio.imshow( + ratio, cmap="RdBu_r", interpolation="nearest", + vmin=1.0 - half_range, vmax=1.0 + half_range, + ) + ax_ratio.set_title( + f"Ratio measured/sum (median={ratio_stats['median']:.3f})", fontsize=11 + ) + ax_ratio.set_xlabel("X (px)") + fig.colorbar(im_r, ax=ax_ratio, fraction=0.040, pad=0.02).set_label("Ratio") + + # 下段左: 動径プロファイル重ね描き(絶対値) + ax_prof = fig.add_subplot(gs[1, 0:2]) + for label, color, marker, m in zip(labels, ["darkorange", "steelblue"], ["o", "s"], metrics): + profile = m["profile"] + ax_prof.plot( + profile[:, 0], profile[:, 1], marker=marker, ms=4, color=color, + label=f"{label} CoV={m['cov']:.3f}", + ) + ax_prof.set_title("Radial profile (absolute luminance)", fontsize=11) + ax_prof.set_xlabel("Normalized distance from center") + ax_prof.set_ylabel("Mean luminance") + ax_prof.grid(True, linestyle="--", alpha=0.5) + ax_prof.legend(loc="lower left", fontsize=9) + + # 下段右: 数値表 + ax_tbl = fig.add_subplot(gs[1, 2]) + ax_tbl.axis("off") + metric_rows = [ + ("Mean luminance", "mean", "{:.2f}"), + ("CoV", "cov", "{:.3f}"), + ("Max/min ratio", "max_min_ratio", "{:.3f}"), + ("Center/periphery", "center_periphery_ratio", "{:.3f}"), + ("Gradient (%)", "gradient_magnitude", "{:.2f}"), + ("Radial min/max", "radial_min_max_ratio", "{:.3f}"), + ] + rows = [ + [name] + [fmt.format(m[key]) for m in metrics] + for name, key, fmt in metric_rows + ] + rows.append(["Ratio mean [p5, p95]", + f"{ratio_stats['mean']:.3f}", + f"[{ratio_stats['p5']:.3f}, {ratio_stats['p95']:.3f}]"]) + table = ax_tbl.table( + cellText=rows, + colLabels=["Metric", "Measured", "Sum"], + cellLoc="center", + loc="center", + ) + table.auto_set_font_size(False) + table.set_fontsize(9) + table.scale(1.0, 1.6) + for j in range(3): + table[0, j].set_facecolor("#dfe6ee") + table[0, j].set_text_props(weight="bold") + + title = "All-LEDs measured vs single-LED sum (bottom+left+right)" + if args.title_suffix: + title += f" {args.title_suffix}" + fig.suptitle(title, fontsize=14, y=0.98) + + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(str(output_path), dpi=150, bbox_inches="tight") + plt.close(fig) + + print(f"全灯実測 vs 単灯加算の比較図を保存しました: {output_path}") + for label, m in zip(labels, metrics): + print(f" {label}: mean={m['mean']:.2f}, CoV={m['cov']:.4f}, " + f"radial min/max={m['radial_min_max_ratio']:.4f}") + print(f" Ratio measured/sum: mean={ratio_stats['mean']:.4f}, " + f"median={ratio_stats['median']:.4f}, " + f"p5={ratio_stats['p5']:.4f}, p95={ratio_stats['p95']:.4f}") + + +if __name__ == "__main__": + main() diff --git a/scripts/compare_loft_housings.py b/scripts/compare_loft_housings.py new file mode 100644 index 0000000..ac8224d --- /dev/null +++ b/scripts/compare_loft_housings.py @@ -0,0 +1,351 @@ +"""後側筐体ロフト 0.5(従来)と 1.4 の白板照明を直接比較するスクリプト. + +後側筐体のロフトパラメータ違い(従来=0.5 / 再印刷=1.4)について, +全灯点灯と単灯(下・左・右)の白板 DNG を筐体間で直接比較する. + +出力(2 図): +- 全灯比較図: 輝度マップ 2 枚(共通絶対カラースケール)+ 比マップ(w1.4/w0.5) + + 動径プロファイル重ね描き + 指標表 +- 単灯比較図: 輝度マップ 2×3(行=筐体,列=下/左/右,共通絶対カラースケール) + + 動径プロファイル重ね描き(実線=w1.4,破線=w0.5)+ 指標表 + +すべて linear + LSC 補正・ROI=smtias.whiteboard で揃えて算出する. + +使い方: + python scripts/compare_loft_housings.py + python scripts/compare_loft_housings.py --w14-all-dng --suffix _shot1 --only all +""" + +import argparse +import sys +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np +from scipy.ndimage import binary_dilation, median_filter + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from src.analysis.spatial import ( # noqa: E402 + calc_distance_map, + calc_radial_min_max_ratio, + calc_radial_profile, + calc_spatial_uniformity, +) +from src.analysis.uniformity import calc_uniformity # noqa: E402 +from src.config import load_roi_config # noqa: E402 +from src.io.dng_pipeline import dng_luminance # noqa: E402 +from src.io.loader import extract_roi # noqa: E402 + +QUANT_DIR = PROJECT_ROOT / "data" / "smtias" / "quantitative" +ROI_CONFIG = PROJECT_ROOT / "config" / "roi_config.json" +ROI_KEY = "smtias.whiteboard" +FIGURES_DIR = PROJECT_ROOT / "output" / "smtias" / "figures" + +# 比較対象(筐体ロフト別.全灯 1 枚+単灯 3 枚) +HOUSINGS = { + "Loft w=0.5 (original)": { + "all": QUANT_DIR / "SmTIAS_QM_20260601_124500.dng", + "singles": [ + ("Bottom LED", QUANT_DIR / "SmTIAS_QM_20260615_141420.dng"), + ("Left LED", QUANT_DIR / "SmTIAS_QM_20260615_150300.dng"), + ("Right LED", QUANT_DIR / "SmTIAS_QM_20260615_170948.dng"), + ], + }, + "Loft w=1.4 (reprinted)": { + "all": QUANT_DIR / "SmTIAS_QM_20260727_124257.dng", + "singles": [ + ("Bottom LED", QUANT_DIR / "SmTIAS_QM_20260727_134105.dng"), + ("Left LED", QUANT_DIR / "SmTIAS_QM_20260727_134853.dng"), + ("Right LED", QUANT_DIR / "SmTIAS_QM_20260727_140129.dng"), + ], + }, +} +LED_COLORS = {"Bottom LED": "steelblue", "Left LED": "seagreen", "Right LED": "indianred"} + +# 共通絶対カラースケールの設定(外れ値に強いパーセンタイルで vmin/vmax を決める) +SCALE_PERCENTILES = (1.0, 99.0) + +# 暗点インペイントの設定 +DESPOT_SMOOTH_KERNEL = 5 # 画素ノイズ抑制用の小メディアン +DESPOT_BG_KERNEL = 61 # 照明背景推定用の大メディアン(汚れより十分大きく取る) +DESPOT_RATIO = 0.96 # 背景比でこの割合より暗い領域を汚れとみなす +DESPOT_DILATE = 7 # 汚れの薄い縁まで含めるためのマスク膨張回数 + +METRIC_ROWS = [ + ("Mean luminance", "mean", "{:.2f}"), + ("CoV", "cov", "{:.3f}"), + ("Max/min ratio", "max_min_ratio", "{:.3f}"), + ("Center/periphery", "center_periphery_ratio", "{:.3f}"), + ("Gradient (%)", "gradient_magnitude", "{:.2f}"), + ("Radial min/max", "radial_min_max_ratio", "{:.3f}"), +] + + +def _luminance(dng_path: Path, roi: dict) -> np.ndarray: + """DNG を linear + LSC 補正で輝度化し ROI を切り出す(portrait).""" + luma_full = dng_luminance(str(dng_path), apply_lsc=True) + return extract_roi(luma_full, roi["x"], roi["y"], roi["width"], roi["height"]) + + +def _inpaint_dark_spots(luminance: np.ndarray, label: str) -> np.ndarray: + """白板の汚れ等による局所的な暗点を背景メディアン値で埋める. + + 画素ノイズに埋もれないよう小メディアンで平滑化した上で,汚れより十分大きい + 大メディアン(照明の緩やかな勾配は追従して保存される)と比較し, + DESPOT_RATIO 未満に暗い領域を汚れとみなす.薄い縁まで含めるため + マスクを膨張させてから背景値で置換する. + """ + smoothed = median_filter(luminance, size=DESPOT_SMOOTH_KERNEL) + background = median_filter(luminance, size=DESPOT_BG_KERNEL) + mask = smoothed < background * DESPOT_RATIO + mask = binary_dilation(mask, iterations=DESPOT_DILATE) + if mask.any(): + print(f" 暗点インペイント({label}): {int(mask.sum())} px " + f"({mask.mean() * 100:.3f}%) を背景メディアンで置換") + return np.where(mask, background, luminance) + + +def _metrics(luminance: np.ndarray) -> dict: + """均一性・空間・動径の指標をまとめて算出する.""" + distance_map = calc_distance_map(*luminance.shape) + profile = calc_radial_profile(luminance, distance_map) + radial = calc_radial_min_max_ratio(profile) + return { + **calc_uniformity(luminance), + **calc_spatial_uniformity(luminance)["zone_stats"], + "radial_min_max_ratio": radial["radial_min_max_ratio"], + "profile": profile, + } + + +def _metrics_table(ax: plt.Axes, col_labels: list[str], metrics: list[dict]) -> None: + """指標表を描画する.""" + ax.axis("off") + rows = [ + [name] + [fmt.format(m[key]) for m in metrics] + for name, key, fmt in METRIC_ROWS + ] + table = ax.table( + cellText=rows, + colLabels=["Metric"] + col_labels, + cellLoc="center", + loc="center", + ) + table.auto_set_font_size(False) + table.set_fontsize(8) + table.scale(1.0, 1.6) + table.auto_set_column_width(list(range(len(col_labels) + 1))) + for j in range(len(col_labels) + 1): + table[0, j].set_facecolor("#dfe6ee") + table[0, j].set_text_props(weight="bold") + + +def plot_all_on(roi: dict, suffix: str = "", despot: bool = False) -> None: + """全灯点灯のロフト 0.5 vs 1.4 比較図を生成して保存する.""" + labels = list(HOUSINGS.keys()) + lumas = [_luminance(HOUSINGS[label]["all"], roi) for label in labels] + if despot: + lumas = [_inpaint_dark_spots(luma, label) for label, luma in zip(labels, lumas)] + metrics = [_metrics(luma) for luma in lumas] + + ratio = lumas[1] / np.maximum(lumas[0], 1e-6) + ratio_stats = { + "mean": float(np.mean(ratio)), + "median": float(np.median(ratio)), + "p5": float(np.percentile(ratio, 5)), + "p95": float(np.percentile(ratio, 95)), + } + + flat_all = np.concatenate([luma.ravel() for luma in lumas]) + vmin, vmax = np.percentile(flat_all, SCALE_PERCENTILES) + + fig = plt.figure(figsize=(15, 9)) + gs = fig.add_gridspec(2, 3, height_ratios=[1.0, 0.85], hspace=0.28, wspace=0.22) + + map_axes = [] + im = None + for col, (label, luma) in enumerate(zip(labels, lumas)): + ax = fig.add_subplot(gs[0, col]) + im = ax.imshow(luma, cmap="hot", interpolation="nearest", vmin=vmin, vmax=vmax) + ax.set_title(f"{label} (mean={metrics[col]['mean']:.1f})", fontsize=11) + ax.set_xlabel("X (px)") + if col == 0: + ax.set_ylabel("Y (px)") + map_axes.append(ax) + cbar = fig.colorbar(im, ax=map_axes, fraction=0.020, pad=0.02) + cbar.set_label( + f"Luminance (common scale, p{SCALE_PERCENTILES[0]:g}-p{SCALE_PERCENTILES[1]:g})" + ) + + # 比マップ(w1.4 / w0.5.中央値中心の発散スケール) + ax_ratio = fig.add_subplot(gs[0, 2]) + center = ratio_stats["median"] + half_range = max(ratio_stats["p95"] - center, center - ratio_stats["p5"]) + im_r = ax_ratio.imshow( + ratio, cmap="RdBu_r", interpolation="nearest", + vmin=center - half_range, vmax=center + half_range, + ) + ax_ratio.set_title(f"Ratio w1.4/w0.5 (median={ratio_stats['median']:.3f})", fontsize=11) + ax_ratio.set_xlabel("X (px)") + fig.colorbar(im_r, ax=ax_ratio, fraction=0.040, pad=0.02).set_label("Ratio") + + ax_prof = fig.add_subplot(gs[1, 0:2]) + for label, color, marker, m in zip(labels, ["gray", "darkorange"], ["o", "s"], metrics): + profile = m["profile"] + ax_prof.plot( + profile[:, 0], profile[:, 1], marker=marker, ms=4, color=color, + label=f"{label} radial min/max={m['radial_min_max_ratio']:.3f}", + ) + ax_prof.set_title("Radial profile (absolute luminance)", fontsize=11) + ax_prof.set_xlabel("Normalized distance from center") + ax_prof.set_ylabel("Mean luminance") + ax_prof.grid(True, linestyle="--", alpha=0.5) + ax_prof.legend(loc="lower left", fontsize=9) + + ax_tbl = fig.add_subplot(gs[1, 2]) + _metrics_table(ax_tbl, ["w=0.5", "w=1.4"], metrics) + + fig.suptitle("All-LEDs comparison: rear housing loft w=0.5 vs w=1.4", fontsize=14, y=0.98) + + output_path = FIGURES_DIR / f"compare_loft_housings_all{suffix}.png" + output_path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(str(output_path), dpi=150, bbox_inches="tight") + plt.close(fig) + + print(f"全灯の筐体比較図を保存しました: {output_path}") + for label, m in zip(labels, metrics): + print(f" {label}: mean={m['mean']:.2f}, CoV={m['cov']:.4f}") + print(f" Ratio w1.4/w0.5: mean={ratio_stats['mean']:.4f}, " + f"median={ratio_stats['median']:.4f}, " + f"p5={ratio_stats['p5']:.4f}, p95={ratio_stats['p95']:.4f}") + + +def plot_singles(roi: dict) -> None: + """単灯 3 種のロフト 0.5 vs 1.4 比較図(2×3)を生成して保存する.""" + housing_labels = list(HOUSINGS.keys()) + led_labels = [led for led, _ in HOUSINGS[housing_labels[0]]["singles"]] + lumas = { + housing: [_luminance(path, roi) for _, path in HOUSINGS[housing]["singles"]] + for housing in housing_labels + } + metrics = { + housing: [_metrics(luma) for luma in lumas[housing]] + for housing in housing_labels + } + + flat_all = np.concatenate( + [luma.ravel() for housing in housing_labels for luma in lumas[housing]] + ) + vmin, vmax = np.percentile(flat_all, SCALE_PERCENTILES) + + fig = plt.figure(figsize=(15, 13)) + gs = fig.add_gridspec( + 3, 3, height_ratios=[1.0, 1.0, 0.8], hspace=0.30, wspace=0.22 + ) + + map_axes = [] + im = None + for row, housing in enumerate(housing_labels): + for col, led in enumerate(led_labels): + ax = fig.add_subplot(gs[row, col]) + im = ax.imshow( + lumas[housing][col], cmap="hot", interpolation="nearest", + vmin=vmin, vmax=vmax, + ) + mean = metrics[housing][col]["mean"] + ax.set_title(f"{led} / {housing} (mean={mean:.1f})", fontsize=10) + if row == 1: + ax.set_xlabel("X (px)") + if col == 0: + ax.set_ylabel("Y (px)") + map_axes.append(ax) + cbar = fig.colorbar(im, ax=map_axes, fraction=0.020, pad=0.02) + cbar.set_label( + f"Luminance (common scale, p{SCALE_PERCENTILES[0]:g}-p{SCALE_PERCENTILES[1]:g})" + ) + + # 動径プロファイル重ね描き(色=LED,実線=w1.4 / 破線=w0.5) + ax_prof = fig.add_subplot(gs[2, 0:2]) + for housing, style in zip(housing_labels, ["--", "-"]): + for led, m in zip(led_labels, metrics[housing]): + profile = m["profile"] + ax_prof.plot( + profile[:, 0], profile[:, 1], style, color=LED_COLORS[led], + label=f"{led} ({housing.split()[1]})", + ) + ax_prof.set_title("Radial profile (absolute luminance)", fontsize=11) + ax_prof.set_xlabel("Normalized distance from center") + ax_prof.set_ylabel("Mean luminance") + ax_prof.grid(True, linestyle="--", alpha=0.5) + ax_prof.legend(loc="upper right", fontsize=8, ncol=2) + + # 指標表(列=LED×筐体) + ax_tbl = fig.add_subplot(gs[2, 2]) + col_labels = [] + tbl_metrics = [] + for led_idx, led in enumerate(led_labels): + for housing in housing_labels: + # 例: "B 0.5"(LED 頭文字+ロフト値) + col_labels.append(f"{led[0]} {housing.split()[1].removeprefix('w=')}") + tbl_metrics.append(metrics[housing][led_idx]) + _metrics_table(ax_tbl, col_labels, tbl_metrics) + + fig.suptitle( + "Single-LED comparison: rear housing loft w=0.5 vs w=1.4", fontsize=14, y=0.98 + ) + + output_path = FIGURES_DIR / "compare_loft_housings_singles.png" + output_path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(str(output_path), dpi=150, bbox_inches="tight") + plt.close(fig) + + print(f"単灯の筐体比較図を保存しました: {output_path}") + for housing in housing_labels: + for led, m in zip(led_labels, metrics[housing]): + print(f" {led} / {housing}: mean={m['mean']:.2f}, CoV={m['cov']:.4f}") + + +def main() -> None: + """ロフト 0.5 vs 1.4 の全灯・単灯比較図を生成して保存する.""" + parser = argparse.ArgumentParser( + description="後側筐体ロフト 0.5 vs 1.4 の全灯・単灯比較図を生成する." + ) + parser.add_argument( + "--w14-all-dng", + default=None, + help="w=1.4 側の全灯 DNG を差し替える(既定: 07-27 清掃後撮影)", + ) + parser.add_argument( + "--suffix", + default="", + help="出力ファイル名の末尾に付ける文字列(例: _shot1.既定図の上書きを避ける)", + ) + parser.add_argument( + "--only", + choices=["all", "singles", "both"], + default="both", + help="生成する図の選択(既定: both)", + ) + parser.add_argument( + "--despot", + action="store_true", + help="全灯図で白板汚れ等の局所暗点を局所メディアンでインペイントする", + ) + args = parser.parse_args() + + if args.w14_all_dng: + HOUSINGS["Loft w=1.4 (reprinted)"]["all"] = Path(args.w14_all_dng) + + roi = load_roi_config(str(ROI_CONFIG), ROI_KEY) + if roi is None: + raise SystemExit(f"ROI '{ROI_KEY}' が config に見つかりません") + if args.only in ("all", "both"): + plot_all_on(roi, args.suffix, args.despot) + if args.only in ("singles", "both"): + plot_singles(roi) + + +if __name__ == "__main__": + main() diff --git a/scripts/compare_single_leds.py b/scripts/compare_single_leds.py index 2a93467..76996f5 100644 --- a/scripts/compare_single_leds.py +++ b/scripts/compare_single_leds.py @@ -14,8 +14,11 @@ 使い方: python scripts/compare_single_leds.py + python scripts/compare_single_leds.py --bottom --left --right \ + --output output/smtias/figures/compare_single_leds_xxx.png --title-suffix "(w1p4)" """ +import argparse import sys from pathlib import Path @@ -36,9 +39,9 @@ from src.io.dng_pipeline import dng_luminance # noqa: E402 from src.io.loader import extract_roi # noqa: E402 -# 比較対象(単灯 3 枚.label, ファイル名, 描画色) +# 比較対象の既定値(単灯 3 枚.label, ファイル名, 描画色) QUANT_DIR = PROJECT_ROOT / "data" / "smtias" / "quantitative" -LEDS = [ +DEFAULT_LEDS = [ ("Bottom LED", QUANT_DIR / "SmTIAS_QM_20260615_141420.dng", "steelblue"), ("Left LED", QUANT_DIR / "SmTIAS_QM_20260615_150300.dng", "seagreen"), ("Right LED", QUANT_DIR / "SmTIAS_QM_20260615_170948.dng", "indianred"), @@ -71,15 +74,34 @@ return metrics +def _parse_args() -> argparse.Namespace: + """CLI 引数を解釈する(未指定時は従来撮影 3 枚・既定出力パス).""" + parser = argparse.ArgumentParser(description="下・左・右の単灯白板 DNG 3 枚を並べて比較する.") + parser.add_argument("--bottom", default=str(DEFAULT_LEDS[0][1]), help="下のみ点灯の DNG パス") + parser.add_argument("--left", default=str(DEFAULT_LEDS[1][1]), help="左のみ点灯の DNG パス") + parser.add_argument("--right", default=str(DEFAULT_LEDS[2][1]), help="右のみ点灯の DNG パス") + parser.add_argument("--output", default=str(OUTPUT_PATH), help="出力する比較図のパス") + parser.add_argument( + "--title-suffix", default="", help="図タイトルの末尾に付ける文字列(例: '(w1p4)')" + ) + return parser.parse_args() + + def main() -> None: """3 単灯の比較図を生成して保存する.""" + args = _parse_args() + leds = [ + (label, Path(path), color) + for (label, _, color), path in zip(DEFAULT_LEDS, [args.bottom, args.left, args.right]) + ] + output_path = Path(args.output) roi = load_roi_config(str(ROI_CONFIG), ROI_KEY) if roi is None: raise SystemExit(f"ROI '{ROI_KEY}' が config に見つかりません") - labels = [label for label, _, _ in LEDS] - colors = [color for _, _, color in LEDS] - lumas = [_luminance(path, roi) for _, path, _ in LEDS] + labels = [label for label, _, _ in leds] + colors = [color for _, _, color in leds] + lumas = [_luminance(path, roi) for _, path, _ in leds] metrics = [_metrics(luma) for luma in lumas] # 共通絶対カラースケール(3 枚をまとめてパーセンタイル) @@ -146,15 +168,15 @@ table[0, j].set_facecolor("#dfe6ee") table[0, j].set_text_props(weight="bold") - fig.suptitle( - "Single-LED illumination comparison (bottom / left / right)", - fontsize=14, y=0.98, - ) + title = "Single-LED illumination comparison (bottom / left / right)" + if args.title_suffix: + title += f" {args.title_suffix}" + fig.suptitle(title, fontsize=14, y=0.98) - OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) - fig.savefig(str(OUTPUT_PATH), dpi=150, bbox_inches="tight") + output_path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(str(output_path), dpi=150, bbox_inches="tight") plt.close(fig) - print(f"3 単灯比較図を保存しました: {OUTPUT_PATH}") + print(f"3 単灯比較図を保存しました: {output_path}") for label, m in zip(labels, metrics): print(f" {label}: mean={m['mean']:.2f}, CoV={m['cov']:.4f}, " f"radial min/max={m['radial_min_max_ratio']:.4f}") diff --git a/scripts/compare_w1p4_shots.py b/scripts/compare_w1p4_shots.py new file mode 100644 index 0000000..1da2621 --- /dev/null +++ b/scripts/compare_w1p4_shots.py @@ -0,0 +1,112 @@ +"""w1p4 全灯 2 撮影(初回 / 撮り直し)の中心凹みの再現性を比較するスクリプト. + +後側ロフト w=1.4 筐体の全灯白板は,輝度マップ中心部が周囲より僅かに暗い +「中心凹み(ドーナツ状)」を示す.これが特定撮影のアーティファクトか, +筐体由来で再現する特徴かを切り分けるため,独立した 2 撮影 +(2026-07-26 初回・2026-07-27 撮り直し)を共通スケール並置と +自ピーク正規化の動径プロファイルで比較する.参照として従来筐体 w=0.5 も重ねる. + +すべて linear + LSC 補正・ROI=smtias.whiteboard で揃えて算出する. + +使い方: + python scripts/compare_w1p4_shots.py +""" + +import sys +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from src.analysis.spatial import calc_distance_map, calc_radial_profile # noqa: E402 +from src.config import load_roi_config # noqa: E402 +from src.io.dng_pipeline import dng_luminance # noqa: E402 +from src.io.loader import extract_roi # noqa: E402 + +QUANT_DIR = PROJECT_ROOT / "data" / "smtias" / "quantitative" +SHOTS = [ + ("w1.4 #1 (07-26, dirty board)", QUANT_DIR / "SmTIAS_QM_20260726_183416.dng", "darkorange"), + ("w1.4 #2 (07-27, cleaned)", QUANT_DIR / "SmTIAS_QM_20260727_124257.dng", "crimson"), + ("w0.5 reference (06-01)", QUANT_DIR / "SmTIAS_QM_20260601_124500.dng", "gray"), +] +ROI_CONFIG = PROJECT_ROOT / "config" / "roi_config.json" +ROI_KEY = "smtias.whiteboard" +OUTPUT_PATH = PROJECT_ROOT / "output" / "smtias" / "figures" / "compare_w1p4_shots.png" + +# 共通絶対カラースケールの設定(外れ値に強いパーセンタイルで vmin/vmax を決める) +SCALE_PERCENTILES = (1.0, 99.0) + + +def main() -> None: + """w1p4 2 撮影の並置+正規化動径プロファイル図を生成して保存する.""" + roi = load_roi_config(str(ROI_CONFIG), ROI_KEY) + if roi is None: + raise SystemExit(f"ROI '{ROI_KEY}' が config に見つかりません") + + lumas = [ + extract_roi( + dng_luminance(str(path), apply_lsc=True), + roi["x"], roi["y"], roi["width"], roi["height"], + ) + for _, path, _ in SHOTS + ] + + profiles = [] + for (label, _, _), luma in zip(SHOTS, lumas): + distance_map = calc_distance_map(*luma.shape) + profile = calc_radial_profile(luma, distance_map) + profiles.append(profile) + center_val = profile[0, 1] + peak_idx = int(np.argmax(profile[:, 1])) + peak_r, peak_val = profile[peak_idx, 0], profile[peak_idx, 1] + dip = 1.0 - center_val / peak_val + print(f" {label}: center={center_val:.2f}, peak={peak_val:.2f} @r={peak_r:.2f}, " + f"center dip={dip * 100:.2f}%") + + fig = plt.figure(figsize=(15, 5)) + gs = fig.add_gridspec(1, 3, wspace=0.25) + + # 左・中央: w1.4 の 2 撮影を共通絶対スケールで並置 + flat = np.concatenate([lumas[0].ravel(), lumas[1].ravel()]) + vmin, vmax = np.percentile(flat, SCALE_PERCENTILES) + map_axes = [] + im = None + for col in range(2): + ax = fig.add_subplot(gs[0, col]) + im = ax.imshow(lumas[col], cmap="hot", interpolation="nearest", vmin=vmin, vmax=vmax) + ax.set_title(SHOTS[col][0], fontsize=10) + ax.set_xlabel("X (px)") + if col == 0: + ax.set_ylabel("Y (px)") + map_axes.append(ax) + cbar = fig.colorbar(im, ax=map_axes, fraction=0.030, pad=0.02) + cbar.set_label( + f"Luminance (common scale, p{SCALE_PERCENTILES[0]:g}-p{SCALE_PERCENTILES[1]:g})" + ) + + # 右: 自ピーク正規化の動径プロファイル(明るさ差を除いて形だけ比較) + ax_prof = fig.add_subplot(gs[0, 2]) + for (label, _, color), profile in zip(SHOTS, profiles): + ax_prof.plot( + profile[:, 0], profile[:, 1] / profile[:, 1].max(), + marker="o", ms=3, color=color, label=label, + ) + ax_prof.set_title("Radial profile (normalized to own peak)", fontsize=10) + ax_prof.set_xlabel("Normalized distance from center") + ax_prof.set_ylabel("Luminance / peak") + ax_prof.grid(True, linestyle="--", alpha=0.5) + ax_prof.legend(fontsize=8, loc="lower left") + + fig.suptitle("Center-dip reproducibility: two independent w=1.4 all-LED shots", fontsize=13) + + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(str(OUTPUT_PATH), dpi=150, bbox_inches="tight") + plt.close(fig) + print(f"w1p4 2 撮影の比較図を保存しました: {OUTPUT_PATH}") + + +if __name__ == "__main__": + main()