diff --git "a/docs/06_TEST/TEST_03_\350\274\235\345\272\246\343\203\236\343\203\203\343\203\227\345\275\242\347\212\266\350\277\275\350\267\241.md" "b/docs/06_TEST/TEST_03_\350\274\235\345\272\246\343\203\236\343\203\203\343\203\227\345\275\242\347\212\266\350\277\275\350\267\241.md" index 8bfb759..e8afc23 100644 --- "a/docs/06_TEST/TEST_03_\350\274\235\345\272\246\343\203\236\343\203\203\343\203\227\345\275\242\347\212\266\350\277\275\350\267\241.md" +++ "b/docs/06_TEST/TEST_03_\350\274\235\345\272\246\343\203\236\343\203\203\343\203\227\345\275\242\347\212\266\350\277\275\350\267\241.md" @@ -19,20 +19,30 @@ ## 比較パネル (Comparison Panel) -![輝度マップ形状の追跡: 実機 (TARGET) と Step 8-1 / 8-3 / 8-7 の比較](figures/luminance_map_evolution.png) +![輝度マップ形状の追跡: 実機 (TARGET) と Step 8-1 / 8-3 / 8-7 の比較(共有 colorbar・実機色域基準)](figures/luminance_map_evolution.png) -生成スクリプト: [`scripts/build_luminance_evolution_panel.py`](../../scripts/build_luminance_evolution_panel.py).**各画像は個別正規化**(min-max を独立に取る)で並べているため,色スケールは画像ごとに異なる.これは「形の異同」を強調するためであり,**絶対値の比較は metrics.csv で行う**こと. +生成スクリプト: [`scripts/build_luminance_evolution_panel.py`](../../scripts/build_luminance_evolution_panel.py). + +### 表示方針: mean 揃え + 共有 colorbar(実機色域基準) + +[Step 8-8](TEST_01_キャリブレーション検証履歴.md#step-8-8-linear-評価モードへの-mean-揃え正規化導入) で導入した **mean 揃え正規化** をパネル生成にも適用している.具体的には: + +1. **各 ROI を mean=target_mean (146.7146) に線形ゲインで揃える** — 線形ゲインなので形状指標 (CoV / 勾配 / C/P / max-min) は厳密に不変 +2. **共有 colorbar の値域は実機 ROI の 5〜95 パーセンタイル** (≈ 134〜155) を採用 — 実機色域を「基準座標」として固定 +3. **シミュは実機色域からはみ出ると飽和** — 上端の暗領域は黒に振り切れ,下端の明領域は白に飽和 + +この表示方針により **「シミュが実機色域を逸脱している事実」が視覚的に直接見える**.数値 (CoV / 勾配) だけでは伝わらない「分布の広がりの違い」と「形(ピーク位置・対称性)の違い」が同時に確認できる. ## 観察された事実 (Observations) 実機 vs シミュ全 Step を並べて見て判明したこと: -| パネル | 形の特徴 | -| --- | --- | -| 実機 (TARGET) | ROI 中央〜下に明るい楕円状の大きな領域.左右にほぼ対称.カメラ系の異方性(光学系の周辺減光 + ISP LSC 補正残差)に支配される典型パターン | -| Step 8-1 (出発点,linear) | ROI 下端のみ明るく,上端に向かって単調に減衰する **Y 軸単調勾配**.LED 配置の物理的事実(下基板が Y=-70.2 のみで上対応の基板なし)を素直に反映 | -| Step 8-3 (OETF 適用後,sRGB) | 数値指標は半減(CoV 0.183 → 0.091)したが,**輝度マップの形は出発点と区別がつかない**.OETF はピクセル値の単調変換で**相対順序を変えない**ため形は構造的に不変 | -| Step 8-7 (発光面 size 1.5×1.5,linear) | 絶対値スケールは 2.83× 上昇したが,**形は出発点と同じ Y 軸単調勾配**.形状指標がほぼ不変だったことと整合 | +| パネル | 形の特徴 | 共有 colorbar での見え方 | +| --- | --- | --- | +| 実機 (TARGET) | ROI 中央〜下に明るい楕円状の大きな領域.左右にほぼ対称.カメラ系の異方性(光学系の周辺減光 + ISP LSC 補正残差)に支配される典型パターン | 全体が中間色(実機色域内)でグラデーション.CoV=0.0426 の穏やかな分布 | +| Step 8-1 (出発点,linear + mean 正規化) | ROI 下端のみ明るく,上端に向かって単調に減衰する **Y 軸単調勾配**.LED 配置の物理的事実(下基板が Y=-70.2 のみで上対応の基板なし)を素直に反映 | 下端は白く完全飽和,上端は黒く振り切れ.**実機色域に収まらない急峻な勾配**.CoV=0.1826 | +| Step 8-3 (OETF 適用後,sRGB) | 数値指標は半減(CoV 0.183 → 0.089)したが,**輝度マップの形は出発点と区別がつかない**.OETF はピクセル値の単調変換で**相対順序を変えない**ため形は構造的に不変 | Step 8-1 と全く同じ形.CoV だけが半減(OETF の非線形圧縮で分布が縮む)が,飽和パターンは継続 | +| Step 8-7 (発光面 size 1.5×1.5,linear + mean 正規化) | 絶対値スケールは 2.83× 上昇したが,**形は出発点と同じ Y 軸単調勾配**.形状指標がほぼ不変だったことと整合 | Step 8-1 と区別不能の形.mean 正規化で表示スケールが完全に揃う | ## 学び (Findings) @@ -64,6 +74,10 @@ として説明可能.シミュ側にこの **動径補正** を加えない限り,形を実機に合わせることはできない. +### 4. 「形」と「分布の広がり」は別次元の差 + +mean 揃え + 共有 colorbar 表示により判明したこと: シミュ vs 実機は **形(ピーク位置・対称性)が違う** だけでなく **分布の広がり(CoV)も違う**(実機 0.0426 vs シミュ 0.18+ 系で約 4.3×).LSC モデル化はこの両方を同時に縮める可能性があるが,どちらが先に解消するかは実装してみないと分からない. + ## LSC モデル化の意義 (再認識) (Significance of LSC Modeling) [TEST_01 Step 8-N 候補(LSC 順方向モデル)](TEST_01_キャリブレーション検証履歴.md#step-8-n-候補-lsc-順方向モデル未着手) は,単に「指標残差 1.6〜1.9× を縮める」だけでなく,**「シミュの空間勾配パターンに動径補正を重ね,実機の中央ピーク形状を作る」根本的な構造的改善** である. @@ -84,6 +98,14 @@ 出力先: `docs/06_TEST/figures/luminance_map_evolution.png`(`.gitignore` 対象.再生成可能) +### スクリプトの処理フロー + +1. 各データソース(実機 PNG / シミュ `luminance.npy`)から **生輝度配列** を読み込み +2. 実機: Rec.709 luma 変換 → ROI 抽出(`configs/eval_real.yaml` の roi 設定) +3. シミュ: 2D luminance.npy をそのまま ROI 抽出(`configs/eval_baseline.yaml` の roi 設定) +4. Step 8-3 のみ OETF + 露出アンカーを順方向適用,他は **mean 揃え正規化** ([Step 8-8 `apply_mean_anchor`](TEST_01_キャリブレーション検証履歴.md#step-8-8-linear-評価モードへの-mean-揃え正規化導入)) で mean=146.7146 に統一 +5. **共有 colorbar の値域は実機 ROI の 5〜95 パーセンタイル** で固定し,全画像を `hot` cmap + 同一 vmin/vmax で描画 + ### 新規 Step を追加する場合 `scripts/build_luminance_evolution_panel.py` の `PANELS` リストに 1 エントリ追加するだけで自動でパネルに並ぶ: @@ -92,10 +114,10 @@ PANELS = [ # 既存エントリ ... { - "image": ROOT / "output" / "" / "figures" / "luminance_map.png", + "loader": _load_sim_linear, # or _load_sim_oetf / _load_real + "args": (ROOT / "output" / "" / "luminance.npy",), "title": "Step 8-N: ", "subtitle": "", - "metrics_csv": ROOT / "output" / "" / "metrics.csv", }, ] ``` diff --git a/scripts/build_luminance_evolution_panel.py b/scripts/build_luminance_evolution_panel.py index dda3046..96adedd 100644 --- a/scripts/build_luminance_evolution_panel.py +++ b/scripts/build_luminance_evolution_panel.py @@ -1,10 +1,13 @@ """TEST_03 用「輝度マップ形状追跡」比較パネルを生成する. -各改善 Step での輝度マップの「形」を 2×2 グリッドで並べ, -タイトルに主要指標 (mean / CoV / 勾配 % / C/P) を併記する. +各改善 Step での輝度マップの「形」を 2×2 グリッドで並べる. +各画像は ROI 抽出後に mean を target_mean (=146.7146) に揃える線形ゲインで正規化し, +共有 colorbar (実機 ROI の min-max ベース) で描画する. -形を見せる目的なので各画像は個別正規化 (sm-evaluate デフォルト) のまま使う. -共有 colorbar にすると絶対値の差にばかり目が行き「形の異同」が見えにくくなる. +これにより: + - 線形ゲインなので形状指標 (CoV / 勾配 / C/P / max-min) は不変 + - 絶対値スケールが全画像で揃い「形の異同」が直接比較できる + - 共有 colorbar で色の濃淡が画像間で物理的に同じ意味を持つ 使い方: uv run python scripts/build_luminance_evolution_panel.py @@ -21,72 +24,142 @@ from __future__ import annotations -import csv from pathlib import Path -import matplotlib.image as mpimg import matplotlib.pyplot as plt +import numpy as np +from PIL import Image + +from sm_tias_lightsim.evaluation.normalize import apply_mean_anchor +from sm_tias_lightsim.evaluation.oetf import apply_srgb_oetf, solve_exposure ROOT = Path(__file__).resolve().parents[1] +TARGET_MEAN = 146.7146 + +# ROI 設定(configs と整合) +SIM_ROI = {"x": 24, "y": 37, "w": 464, "h": 450} # configs/eval_baseline.yaml +REAL_ROI = {"x": 539, "y": 1015, "w": 1400, "h": 1409} # configs/eval_real.yaml + +# 共有 colorbar の値域決定方針: 実機 ROI のパーセンタイル +# 5〜95 パーセンタイルで外れ値(センサノイズ・最暗最明画素)を除く. +REAL_VMIN_PERCENTILE = 5.0 +REAL_VMAX_PERCENTILE = 95.0 -def _load_metrics(csv_path: Path) -> dict[str, float]: - """sm-evaluate が書き出す metrics.csv から主要指標を取り出す.""" - with csv_path.open(encoding="utf-8") as f: - row = next(csv.DictReader(f)) - return { - "mean": float(row["mean"]), - "CoV": float(row["cov"]), - "grad": float(row["gradient_magnitude"]), - "CP": float(row["center_periphery_ratio"]), - } +def _rec709_luma(rgb: np.ndarray) -> np.ndarray: + """RGB 配列を Rec.709 luma に変換する (H, W, 3) → (H, W).""" + return 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2] -# パネル定義. ここに新規 Step を追加すれば自動で並ぶ. +def _extract_roi(arr: np.ndarray, roi: dict) -> np.ndarray: + """ROI 矩形を切り出して float64 に変換する.""" + x, y, w, h = roi["x"], roi["y"], roi["w"], roi["h"] + return arr[y : y + h, x : x + w].astype(np.float64) + + +def _load_real() -> np.ndarray: + """実機 PNG → Rec.709 luma → ROI 抽出 → mean アンカー(恒等近い).""" + png = ROOT / "reference_measurement" / "MiniTIAS_20260408_144434" / "MiniTIAS_20260408_144434.png" + img = np.array(Image.open(png)) + if img.ndim == 3: + img = img[..., :3] # 念のため RGBA → RGB + luma = _rec709_luma(img.astype(np.float64)) + else: + luma = img.astype(np.float64) + roi = _extract_roi(luma, REAL_ROI) + # 実機は既に mean ≈ 146.71 なので alpha ≈ 1.0(揺れ吸収) + normalized, _ = apply_mean_anchor(roi, TARGET_MEAN) + return normalized + + +def _load_sim_linear(npy_path: Path) -> np.ndarray: + """シミュ luminance.npy → ROI 抽出 → mean アンカー正規化(linear ドメイン).""" + arr = np.load(npy_path) + if arr.ndim == 3: + arr = _rec709_luma(arr) + roi = _extract_roi(arr, SIM_ROI) + normalized, _ = apply_mean_anchor(roi, TARGET_MEAN) + return normalized + + +def _load_sim_oetf(npy_path: Path) -> np.ndarray: + """シミュ luminance.npy → ROI 抽出 → OETF 順方向適用(sRGB ドメイン, uint8 量子化).""" + arr = np.load(npy_path) + if arr.ndim == 3: + arr = _rec709_luma(arr) + roi = _extract_roi(arr, SIM_ROI) + scale, _ = solve_exposure(roi, TARGET_MEAN) + return apply_srgb_oetf(roi, scale, quantize_uint8=True) + + +# パネル定義.新規 Step を追加する場合はここに 1 エントリ追加するだけ. PANELS = [ { - "image": ROOT / "MiniTias-Evaluation" / "figures" / "luminance_map_sample.png", + "loader": _load_real, + "args": (), "title": "Real Measurement (TARGET)", "subtitle": "central peak, near-symmetric (camera optics anisotropy)", - "metrics": {"mean": 146.71, "CoV": 0.0427, "grad": 6.10, "CP": 1.065}, }, { - "image": ROOT / "output" / "baseline_linear_npy" / "figures" / "luminance_map.png", + "loader": _load_sim_linear, + "args": (ROOT / "output" / "baseline" / "luminance.npy",), "title": "Step 8-1: Starting Point", - "subtitle": "R=0.85, d=8, spp=1024, linear (no OETF)", - "metrics_csv": ROOT / "output" / "baseline_linear_npy" / "metrics.csv", + "subtitle": "R=0.85, d=8, spp=1024, linear + mean normalization", }, { - "image": ROOT / "output" / "eval_oetf_r085" / "figures" / "luminance_map.png", + "loader": _load_sim_oetf, + "args": (ROOT / "output" / "baseline" / "luminance.npy",), "title": "Step 8-3: After OETF Forward Application", - "subtitle": "R=0.85, d=8, spp=1024, sRGB domain", - "metrics_csv": ROOT / "output" / "eval_oetf_r085" / "metrics.csv", + "subtitle": "R=0.85, d=8, spp=1024, sRGB domain (mean-anchored)", }, { - "image": ROOT / "output" / "emitter_real" / "figures" / "luminance_map.png", - "title": "Step 8-7: Real-chip Emitter Size (Φe preserved)", - "subtitle": "size 1.5x1.5 mm, linear", - "metrics_csv": ROOT / "output" / "emitter_real" / "metrics.csv", + "loader": _load_sim_linear, + "args": (ROOT / "output" / "emitter_real" / "luminance.npy",), + "title": "Step 8-7: Real-chip Emitter Size", + "subtitle": "size 1.5x1.5 mm (Φe preserved), linear + mean normalization", }, ] +def _compute_indicators(roi: np.ndarray) -> dict[str, float]: + """ROI 配列から主要指標 (mean / CoV / 勾配 % / C/P) を計算する. + + 勾配 % と C/P は ROI 中心からの正規化楕円距離 d で 3 ゾーン分類した値. + """ + mean = float(roi.mean()) + cov = float(roi.std() / mean) + + h, w = roi.shape + ys, xs = np.mgrid[0:h, 0:w] + cy, cx = (h - 1) / 2.0, (w - 1) / 2.0 + d = np.sqrt(((xs - cx) / (w / 2.0)) ** 2 + ((ys - cy) / (h / 2.0)) ** 2) + center_mask = d < 0.33 + periphery_mask = d >= 0.66 + center_mean = float(roi[center_mask].mean()) + periphery_mean = float(roi[periphery_mask].mean()) + cp = center_mean / periphery_mean + grad = (center_mean - periphery_mean) / center_mean * 100.0 + return {"mean": mean, "CoV": cov, "grad": grad, "CP": cp} + + def main() -> None: + # 全画像をロード + panels_data = [] + for panel in PANELS: + roi = panel["loader"](*panel["args"]) + metrics = _compute_indicators(roi) + panels_data.append((panel, roi, metrics)) + + # 共有 vmin/vmax を実機 ROI のパーセンタイルで決定(外れ値を除く) + real_roi = panels_data[0][1] + vmin = float(np.percentile(real_roi, REAL_VMIN_PERCENTILE)) + vmax = float(np.percentile(real_roi, REAL_VMAX_PERCENTILE)) + fig, axes = plt.subplots(2, 2, figsize=(14, 13)) - for ax, panel in zip(axes.flatten(), PANELS): - img_path = panel["image"] - if not img_path.is_file(): - ax.text(0.5, 0.5, f"MISSING\n{img_path}", ha="center", va="center") - ax.set_axis_off() - continue - ax.imshow(mpimg.imread(img_path)) + last_im = None + for ax, (panel, roi, m) in zip(axes.flatten(), panels_data): + last_im = ax.imshow(roi, cmap="hot", vmin=vmin, vmax=vmax, aspect="auto") ax.set_axis_off() - - metrics = panel.get("metrics") - if metrics is None and "metrics_csv" in panel: - metrics = _load_metrics(panel["metrics_csv"]) - - m = metrics title = ( f"{panel['title']}\n" f"{panel['subtitle']}\n" @@ -95,18 +168,28 @@ ) ax.set_title(title, fontsize=10, loc="left") + # 共有 colorbar + cbar = fig.colorbar( + last_im, + ax=axes.ravel().tolist(), + shrink=0.7, + label=f"Luminance (mean-anchored to {TARGET_MEAN})", + ) + cbar.ax.tick_params(labelsize=9) + fig.suptitle( - "Luminance Map Shape Tracking across Calibration Steps", + "Luminance Map Shape Tracking (shared colorbar, mean-anchored)", fontsize=14, fontweight="bold", ) - plt.tight_layout(rect=(0, 0, 1, 0.97)) out_dir = ROOT / "docs" / "06_TEST" / "figures" out_dir.mkdir(parents=True, exist_ok=True) out_path = out_dir / "luminance_map_evolution.png" plt.savefig(out_path, dpi=120, bbox_inches="tight") print(f"Saved: {out_path}") + print(f" Shared colorbar range: [{vmin:.2f}, {vmax:.2f}] " + f"({REAL_VMIN_PERCENTILE}-{REAL_VMAX_PERCENTILE} percentile of real ROI)") if __name__ == "__main__":