"""TEST_03 用「輝度マップ形状追跡」比較パネルを生成する.
各改善 Step での輝度マップの「形」を 2×2 グリッドで並べる.
各画像は ROI 抽出後に mean を target_mean (=146.7146) に揃える線形ゲインで正規化し,
共有 colorbar (実機 ROI の min-max ベース) で描画する.
これにより:
- 線形ゲインなので形状指標 (CoV / 勾配 / C/P / max-min) は不変
- 絶対値スケールが全画像で揃い「形の異同」が直接比較できる
- 共有 colorbar で色の濃淡が画像間で物理的に同じ意味を持つ
使い方:
uv run python scripts/build_luminance_evolution_panel.py
出力:
docs/06_TEST/figures/luminance_map_evolution.png
ドキュメント:
docs/06_TEST/TEST_03_輝度マップ形状追跡.md(観察・解釈・LSC モデル化の意義)
docs/06_TEST/TEST_01_キャリブレーション検証履歴.md(Step 毎の改善履歴)
新しい改善 Step を追加する場合は PANELS リストに 1 エントリ追加するだけ.
"""
from __future__ import annotations
from pathlib import Path
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 _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]
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 = [
{
"loader": _load_real,
"args": (),
"title": "Real Measurement (TARGET)",
"subtitle": "central peak, near-symmetric (camera optics anisotropy)",
},
{
"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 + mean normalization",
},
{
"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 (mean-anchored)",
},
{
"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",
},
{
"loader": _load_sim_linear,
"args": (ROOT / "output" / "baseline_v3_leds" / "luminance.npy",),
"title": "Step 8-9: LED Positions from Fusion STL",
"subtitle": "Accurate LED layout (3+gap+3 per board), 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))
# PANELS 数に応じてグリッドを自動決定
n = len(panels_data)
if n <= 2:
rows, cols = 1, n
elif n <= 4:
rows, cols = 2, 2
elif n <= 6:
rows, cols = 2, 3
elif n <= 9:
rows, cols = 3, 3
else:
cols = 4
rows = (n + cols - 1) // cols
fig, axes = plt.subplots(rows, cols, figsize=(cols * 7, rows * 6.5))
axes_flat = axes.flatten() if hasattr(axes, "flatten") else [axes]
last_im = None
for ax, (panel, roi, m) in zip(axes_flat, panels_data):
last_im = ax.imshow(roi, cmap="hot", vmin=vmin, vmax=vmax, aspect="auto")
ax.set_axis_off()
title = (
f"{panel['title']}\n"
f"{panel['subtitle']}\n"
f"mean={m['mean']:.2f} CoV={m['CoV']:.4f} "
f"grad={m['grad']:.2f}% C/P={m['CP']:.3f}"
)
ax.set_title(title, fontsize=10, loc="left")
# 余ったマス目は非表示
for ax in axes_flat[n:]:
ax.set_axis_off()
# 共有 colorbar
cbar = fig.colorbar(
last_im,
ax=axes_flat.tolist() if hasattr(axes_flat, "tolist") else list(axes_flat),
shrink=0.7,
label=f"Luminance (mean-anchored to {TARGET_MEAN})",
)
cbar.ax.tick_params(labelsize=9)
fig.suptitle(
"Luminance Map Shape Tracking (shared colorbar, mean-anchored)",
fontsize=14,
fontweight="bold",
)
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__":
main()