"""スライド用「シミュ vs 暗室実機」形状比較パネルを生成する.
シミュレーション輝度マップと暗室撮影 DNG (現行キャリブ TARGET) を,
**各画像を独立に [0, 100] へ正規化**して共有 colorbar で左右に並べる比較図.
正規化の基準は 20 ビン動径プロファイルの最小値→0・最大値→100 で,
絶対的な明るさの違い (mean) も全体コントラストの強弱もキャンセルし,
「照明の形 (空間パターン)」だけを直接比較できるようにする.
これにより:
- 各画像がフル 0〜100 レンジを使うので「量の違い」ではなく「形」が見える
- 動径 min/max を基準にするため画素単位のノイズ・外れ値に影響されにくい
- 形状指標 (CoV / 勾配 / C/P / 動径 min/max) はオフセット込み正規化で値が
変わるため,指標は **物理 ROI のまま** 算出して title に表示する
(表示用配列だけを 0〜100 正規化する)
実機側 (暗室 DNG) の評価は MiniTias-Evaluation の正規手順を再現する:
load_image_dng(rgb_float32, output_color=raw, linear)
→ meta.json lscMap を順方向適用 (レンズビネット除去)
→ Rec.709 luma × 255 → rot90(-1) で portrait 向き合わせ
→ 白板 ROI 抽出
これは reference_scripts/run_uniformity_dng.py と同一 (mean=207.82 を再現).
シミュ側は output/baseline/luminance.npy を SIM_ROI で抽出.
使い方:
uv run python scripts/build_sim_vs_darkroom_panel.py
出力:
output/_slide_panels/sim_vs_darkroom_normalized.png
ドキュメント:
docs/06_TEST/TEST_01_キャリブレーション検証履歴.md (§Step 8-13 実機 DNG 突合)
docs/05_TECH/TECH_07_LSC取り扱い方針.md (LSC 順方向適用の役割分担)
"""
from __future__ import annotations
import json
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
from sm_tias_lightsim.evaluation.dng_loader import load_image_dng
from sm_tias_lightsim.evaluation.radial import (
calc_radial_min_max_ratio,
calc_radial_profile,
)
from sm_tias_lightsim.evaluation.zones import (
calc_zone_stats,
classify_zones,
compute_normalized_distance_map,
)
ROOT = Path(__file__).resolve().parents[1]
# Rec.709 luma 係数 (uniformity.py / dng_loader.py と一致)
_REC709 = (0.2126, 0.7152, 0.0722)
# 実機 暗室 DNG (Step 8-13 以降の正規 baseline)
REAL_DNG = ROOT / "reference_measurement" / "quantitative" / "MiniTIAS_QM_20260601_124500.dng"
REAL_META = ROOT / "reference_measurement" / "quantitative" / "MiniTIAS_QM_20260601_124500.meta.json"
# 白板 ROI (portrait, 旧 PNG と同一座標. DNG portrait は 2448x3264 で同一フレーミング)
REAL_ROI = {"x": 539, "y": 1015, "w": 1400, "h": 1409}
# シミュ baseline (max_depth=16, R=0.85, spp=1024)
SIM_NPY = ROOT / "output" / "baseline" / "luminance.npy"
SIM_ROI = {"x": 24, "y": 37, "w": 464, "h": 450} # configs/eval_baseline_dng_target.yaml
OUT_PATH = ROOT / "output" / "_slide_panels" / "sim_vs_darkroom_normalized.png"
def _rec709_luma(rgb: np.ndarray) -> np.ndarray:
"""(H, W, 3) RGB → (H, W) Rec.709 luma."""
return _REC709[0] * rgb[..., 0] + _REC709[1] * rgb[..., 1] + _REC709[2] * 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 _upsample(grid: np.ndarray, shape: tuple[int, int]) -> np.ndarray:
"""(rows, cols) グリッドを (H, W) へバイリニア補間する (run_uniformity_dng.py と同一)."""
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 (rows, cols, 4=[R,Geven,Godd,B]) → (H, W, 3) RGB ゲイン."""
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 _load_real_darkroom() -> np.ndarray:
"""暗室 DNG → linear raw → LSC 順適用 → Rec.709×255 → portrait → 白板 ROI (物理値)."""
rgb = load_image_dng(str(REAL_DNG), output_format="rgb_float32") # linear landscape
rgb = rgb * _lsc_gain_rgb(REAL_META, rgb.shape[:2]) # LSC 順方向適用 (ビネット除去)
luma = _rec709_luma(rgb) * 255.0 # 0〜255 linear スケール
luma = np.rot90(luma, -1) # portrait (PNG / ROI と同じ向き)
return _extract_roi(luma, REAL_ROI)
def _load_sim() -> np.ndarray:
"""シミュ luminance.npy → ROI (物理値)."""
arr = np.load(SIM_NPY)
if arr.ndim == 3:
arr = _rec709_luma(arr)
return _extract_roi(arr, SIM_ROI)
def _minmax_normalize(roi: np.ndarray) -> np.ndarray:
"""各画像を実画素の最小値→0・最大値→100 へ線形正規化する.
元の輝度マップ (expected_results の luminance_map.png) はデータ実 min/max へ
自動スケールして描かれるため,実画素 min/max を用いると左パネル (実機) が
元画像と同じ見た目になる.mean・全体コントラストの違いはキャンセルされ,
空間パターン (形) だけが共有 0〜100 スケールで比較できる.
クリップは起きない (両端が実 min/max に一致するため).
注: 動径 min/max と違い画素ノイズの外れ値にレンジ端が引っ張られうる.
"""
vmin = float(roi.min())
vmax = float(roi.max())
return (roi - vmin) / (vmax - vmin) * 100.0
def _compute_metrics(roi: np.ndarray) -> dict[str, float]:
"""正規評価関数で CoV / 勾配% / C/P / 動径 min/max を算出する (CLI と同一定義)."""
mean = float(roi.mean())
cov = float(roi.std() / mean)
dist = compute_normalized_distance_map(*roi.shape)
zone_stats = calc_zone_stats(roi, classify_zones(dist))
rmm = calc_radial_min_max_ratio(calc_radial_profile(roi))
return {
"CoV": cov,
"grad": zone_stats["gradient_magnitude"],
"CP": zone_stats["center_periphery_ratio"],
"rmm": rmm["radial_min_max_ratio"],
}
def _title(name: str, subtitle: str, m: dict[str, float]) -> str:
return (
f"{name}\n{subtitle}\n"
f"CoV={m['CoV']:.4f} grad={m['grad']:.2f}% "
f"C/P={m['CP']:.3f} r.min/max={m['rmm']:.3f}"
)
def main() -> None:
real_raw = _load_real_darkroom()
sim_raw = _load_sim()
# 指標は物理 ROI のまま算出 (CoV/勾配/C/P/動径 min/max はオフセット込み正規化で変わるため)
real_m = _compute_metrics(real_raw)
sim_m = _compute_metrics(sim_raw)
# 表示用配列だけ「実画素 min→0 / max→100」で各画像独立に正規化
# (左パネルが元の輝度マップと同じ見た目になる)
real_disp = _minmax_normalize(real_raw)
sim_disp = _minmax_normalize(sim_raw)
# 共有スケール: 0〜100 固定 (各画像の実 min/max がこの両端に対応)
vmin, vmax = 0.0, 100.0
fig, axes = plt.subplots(1, 2, figsize=(15, 7))
axes[0].imshow(real_disp, cmap="hot", vmin=vmin, vmax=vmax, aspect="auto")
axes[0].set_box_aspect(1.0) # パネルを正方形に固定
axes[0].set_axis_off()
axes[0].set_title(
_title(
"Real (dark-room DNG, TARGET)",
"linear + LSC forward, white-board ROI",
real_m,
),
fontsize=11,
loc="left",
)
last_im = axes[1].imshow(sim_disp, cmap="hot", vmin=vmin, vmax=vmax, aspect="auto")
axes[1].set_box_aspect(1.0) # パネルを正方形に固定
axes[1].set_axis_off()
axes[1].set_title(
_title(
"Simulation (baseline)",
"R=0.85, d=16, spp=1024, linear",
sim_m,
),
fontsize=11,
loc="left",
)
cbar = fig.colorbar(
last_im,
ax=axes.tolist(),
shrink=0.75,
label="Normalized intensity (pixel min -> 0, max -> 100, per image)",
)
cbar.ax.tick_params(labelsize=9)
fig.suptitle(
"Simulation vs Dark-room Measurement "
"(shape-focused: each map normalized to its pixel min/max -> 0/100)",
fontsize=14,
fontweight="bold",
)
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
plt.savefig(OUT_PATH, dpi=120, bbox_inches="tight")
print(f"Saved: {OUT_PATH}")
print(" Display normalization: per-image pixel min->0, max->100 (vmin/vmax=0/100)")
print(f" Real : CoV={real_m['CoV']:.4f} grad={real_m['grad']:.2f}% "
f"C/P={real_m['CP']:.3f} r.min/max={real_m['rmm']:.3f}")
print(f" Sim : CoV={sim_m['CoV']:.4f} grad={sim_m['grad']:.2f}% "
f"C/P={sim_m['CP']:.3f} r.min/max={sim_m['rmm']:.3f}")
if __name__ == "__main__":
main()