"""実機とシミュの輝度マップの「形」を,全灯と単灯 3 灯(下・右・左)で突き合わせる.
評価の決め手は集約指標(min 等)ではなく輝度マップの形そのものである(ユーザー方針).
本スクリプトは 4 構成それぞれについて,実機(DNG 由来の白板 ROI)とシミュを同じ処理
(シミュ ROI 解像度へリサイズ → メディアン 5 → ガウス σ=4 → ROI 平均で正規化)で並べ,
- 実機 / シミュ / 差分(シミュ − 実機)のマップ
- 形の一致度: 差分の RMS・最大,SSIM,NCC,方位フーリエ(a1・b1・a2・a4),
上下比・左右比,傾き(1 次平面フィットの振幅と方位),お椀(2 次成分)の大きさ
を出力する.正規化は ROI 平均(=1)にする.単灯は絶対輝度が実機とシミュで揃っていない
(露出・LED 出力差)ので,形だけを比べるにはこれが自然.
実行方法:
uv run python scripts/compare_real_vs_sim_shapes.py
uv run python scripts/compare_real_vs_sim_shapes.py --sim-set legacy # ρ=0.85 blend の旧レンダ
"""
from __future__ import annotations
import argparse
import csv
import sys
from pathlib import Path
import matplotlib
import numpy as np
from scipy.ndimage import gaussian_filter, median_filter
from skimage.transform import resize
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from analyze_fourfold_component import aggregate, ring_harmonics # noqa: E402
from build_radial_comparison import MAX_DISTANCE # noqa: E402
from sm_tias_lightsim.evaluation.roi_presets import SIM_ROI, extract_roi_xywh # noqa: E402
from sm_tias_lightsim.evaluation.shape_metrics import calc_ncc, calc_ssim # noqa: E402
from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map # noqa: E402
plt.rcParams["font.family"] = ["Yu Gothic", "MS Gothic", "DejaVu Sans"]
ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "output" / "real_vs_sim"
REAL_DIR = ROOT / "lightsim" / "luminance_maps"
CONFIGS = ("full", "bottom", "left", "right")
CONFIG_LABEL = {"full": "全灯 18 灯", "bottom": "下 単灯", "left": "左 単灯", "right": "右 単灯"}
REAL = {
"full": REAL_DIR / "full_MiniTIAS_QM_20260601_124500.npy",
"bottom": REAL_DIR / "bottom_SmTIAS_QM_20260615_141420.npy",
"left": REAL_DIR / "left_SmTIAS_QM_20260615_150300.npy",
"right": REAL_DIR / "right_SmTIAS_QM_20260615_170948.npy",
}
SIM_SETS = {
# TEST_14 で確定した内壁(純 diffuse ρ=0.81)・spp 4096(本スクリプト用にサーバでレンダ)
"rho081": {
"label": "ρ=0.81 純 diffuse・spp 4096",
"full": OUT_DIR / "sim_rho081_spp4096" / "luminance.npy",
"bottom": OUT_DIR / "sim_single_bottom_rho081_spp4096" / "luminance.npy",
"left": OUT_DIR / "sim_single_left_rho081_spp4096" / "luminance.npy",
"right": OUT_DIR / "sim_single_right_rho081_spp4096" / "luminance.npy",
},
# TEST_12/14 の旧レンダ(blendbsdf ρ=0.85・ω=0.05・α=0.1・spp 1024)
"legacy": {
"label": "ρ=0.85 光沢 5%(旧材質)・spp 1024",
"full": ROOT / "output" / "full_base_fix" / "luminance.npy",
"bottom": ROOT / "output" / "singles_fix" / "bottom" / "luminance.npy",
"left": ROOT / "output" / "singles_fix" / "left" / "luminance.npy",
"right": ROOT / "output" / "singles_fix" / "right" / "luminance.npy",
},
}
MEDIAN = 5
SIGMA = 4.0
DISPLAY_RANGE = (0.85, 1.15) # ROI 平均 = 1 に対する表示レンジ
DIFF_RANGE = 0.08
def load_sim(path: Path) -> np.ndarray:
"""シミュ luminance.npy → ROI(生)."""
arr = np.load(path).astype(np.float64)
if arr.ndim == 3:
arr = 0.2126 * arr[..., 0] + 0.7152 * arr[..., 1] + 0.0722 * arr[..., 2]
return extract_roi_xywh(arr, SIM_ROI)
def load_real(path: Path, shape: tuple[int, int]) -> np.ndarray:
"""実機 ROI(DNG 由来・LSC 適用済み)をシミュ ROI の解像度へリサイズして返す(生)."""
arr = np.load(path).astype(np.float64)
return resize(arr, shape, anti_aliasing=True, preserve_range=True)
def normalized_shape(roi: np.ndarray) -> np.ndarray:
"""平滑化(メディアン 5 → ガウス σ=4)して ROI 平均で正規化した形."""
smooth = gaussian_filter(median_filter(roi, size=MEDIAN), sigma=SIGMA)
return smooth / float(smooth.mean())
def plane_fit(shape_map: np.ndarray) -> tuple[float, float, float]:
"""1 次平面(傾き)を当てはめ,(振幅 %, 方位 deg, 2 次成分の振幅 %) を返す.
振幅は ROI の半幅あたりの変化量(TEST_14 の「傾き」と同じ定義).方位は画像座標で
+X(右)を 0 deg,+Y(画像上)を 90 deg とする.2 次成分は傾きを除いた後の
x²+y² 項(お椀)の振幅.
"""
h, w = shape_map.shape
y, x = np.mgrid[0:h, 0:w]
x = (x - w / 2.0) / (w / 2.0)
y = -(y - h / 2.0) / (h / 2.0) # 画像上を +Y にする
design = np.stack([np.ones(x.size), x.ravel(), y.ravel(),
(x**2 + y**2).ravel(), (x**2 - y**2).ravel(), (x * y).ravel()], axis=1)
coeffs, *_ = np.linalg.lstsq(design, shape_map.ravel(), rcond=None)
tilt = float(np.hypot(coeffs[1], coeffs[2])) * 100.0
azimuth = float(np.degrees(np.arctan2(coeffs[2], coeffs[1])))
bowl = float(coeffs[3]) * 100.0
return tilt, azimuth, bowl
def shape_metrics(sim_map: np.ndarray, real_map: np.ndarray, sim_roi: np.ndarray,
real_roi: np.ndarray) -> dict[str, float]:
"""形の一致度と,各マップの形の記述子を返す."""
diff = sim_map - real_map
inner = compute_normalized_distance_map(*sim_map.shape) <= MAX_DISTANCE
out = {
"rms_diff": float(np.sqrt(np.mean(diff[inner] ** 2))),
"max_abs_diff": float(np.abs(diff[inner]).max()),
"ssim": float(calc_ssim(sim_map, real_map,
data_range=float(DISPLAY_RANGE[1] - DISPLAY_RANGE[0]))),
"ncc": float(calc_ncc(sim_map, real_map)),
}
for tag, roi, smap in (("sim", sim_roi, sim_map), ("real", real_roi, real_map)):
rings = ring_harmonics(roi, (1, 2, 4))
for m in (1, 2, 4):
out[f"{tag}_a{m}"], _ = aggregate(rings, f"a{m}")
out[f"{tag}_b{m}"], _ = aggregate(rings, f"b{m}")
h, w = smap.shape
out[f"{tag}_top_bottom"] = float(smap[: h // 3].mean() / smap[-(h // 3):].mean())
out[f"{tag}_left_right"] = float(smap[:, : w // 3].mean() / smap[:, -(w // 3):].mean())
out[f"{tag}_peak_mean"] = float(np.percentile(smap, 99.0))
out[f"{tag}_tilt"], out[f"{tag}_tilt_az"], out[f"{tag}_bowl"] = plane_fit(smap)
return out
def cell_title(who: str, cfg: str, m: dict, tag: str) -> str:
"""マップの見出し(上下比・左右比・傾き・お椀)."""
return (f"{who} {CONFIG_LABEL[cfg]}\n上下比 {m[f'{tag}_top_bottom']:.3f}・"
f"左右比 {m[f'{tag}_left_right']:.3f}\n傾き {m[f'{tag}_tilt']:.1f}%@"
f"{m[f'{tag}_tilt_az']:.0f}°・お椀 {m[f'{tag}_bowl']:+.1f}%")
def draw(results: dict[str, dict], maps: dict[str, tuple[np.ndarray, np.ndarray]],
sim_label: str, output: Path) -> None:
"""3 行(実機 / シミュ / 差分)× 構成数の列のパネル(results にある構成だけ描く)."""
configs = [c for c in CONFIGS if c in results]
fig, axes = plt.subplots(3, len(configs), figsize=(4.6 * len(configs), 13.5), squeeze=False)
for c, cfg in enumerate(configs):
real_map, sim_map = maps[cfg]
m = results[cfg]
for r, (data, title) in enumerate((
(real_map, cell_title("実機", cfg, m, "real")),
(sim_map, cell_title("シミュ", cfg, m, "sim")),
)):
ax = axes[r, c]
im = ax.imshow(data, cmap="hot", vmin=DISPLAY_RANGE[0], vmax=DISPLAY_RANGE[1])
ax.set_title(title, fontsize=8.5)
ax.axis("off")
if c == len(configs) - 1:
fig.colorbar(im, ax=ax, fraction=0.046, label="輝度 / ROI 平均")
ax = axes[2, c]
im = ax.imshow(sim_map - real_map, cmap="coolwarm", vmin=-DIFF_RANGE, vmax=DIFF_RANGE)
ax.set_title(f"差分(シミュ − 実機)\nRMS {m['rms_diff']:.3f}・最大 "
f"{m['max_abs_diff']:.3f}\nSSIM {m['ssim']:.3f}・NCC {m['ncc']:.3f}",
fontsize=8.5)
ax.axis("off")
if c == len(configs) - 1:
fig.colorbar(im, ax=ax, fraction=0.046)
fig.suptitle(f"輝度マップの形の一致 — 実機 vs シミュ({sim_label})."
"全セル同一処理(シミュ解像度へリサイズ・メディアン 5・ガウス σ=4・ROI 平均 = 1)",
fontsize=12)
fig.tight_layout()
output.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(output, dpi=110)
plt.close(fig)
print(f"出力: {output}")
def main() -> int:
"""4 構成の形の比較を図と表にする."""
parser = argparse.ArgumentParser(description="実機 vs シミュの輝度マップの形の比較")
parser.add_argument("--sim-set", choices=tuple(SIM_SETS), default="rho081")
args = parser.parse_args()
sim_set = SIM_SETS[args.sim_set]
results: dict[str, dict] = {}
maps: dict[str, tuple[np.ndarray, np.ndarray]] = {}
for cfg in CONFIGS:
sim_path, real_path = sim_set[cfg], REAL[cfg]
if not sim_path.exists() or not real_path.exists():
print(f"スキップ {cfg}: {sim_path if not sim_path.exists() else real_path} が無い")
continue
sim_roi = load_sim(sim_path)
real_roi = load_real(real_path, sim_roi.shape)
sim_map, real_map = normalized_shape(sim_roi), normalized_shape(real_roi)
results[cfg] = shape_metrics(sim_map, real_map, sim_roi, real_roi)
maps[cfg] = (real_map, sim_map)
if not results:
print("エラー: 比較できる構成がありません")
return 1
draw(results, maps, sim_set["label"], OUT_DIR / f"shapes_{args.sim_set}.png")
with (OUT_DIR / f"shapes_{args.sim_set}.csv").open("w", newline="", encoding="utf-8") as fh:
keys = ["config"] + list(next(iter(results.values())).keys())
writer = csv.DictWriter(fh, fieldnames=keys)
writer.writeheader()
for cfg, m in results.items():
writer.writerow({"config": cfg, **m})
print(f"\n=== 形の一致({sim_set['label']})===")
print(f"{'構成':>8} {'RMS差':>6} {'最大差':>6} {'SSIM':>6} {'NCC':>6} | "
f"{'上下比 実/シ':>13} {'左右比 実/シ':>13} {'傾き% 実/シ':>13} {'方位 実/シ':>12} "
f"{'お椀% 実/シ':>12} {'b1% 実/シ':>11}")
for cfg, m in results.items():
print(f"{CONFIG_LABEL[cfg]:>8} {m['rms_diff']:6.3f} {m['max_abs_diff']:6.3f} "
f"{m['ssim']:6.3f} {m['ncc']:6.3f} | "
f"{m['real_top_bottom']:6.3f}/{m['sim_top_bottom']:6.3f} "
f"{m['real_left_right']:6.3f}/{m['sim_left_right']:6.3f} "
f"{m['real_tilt']:6.1f}/{m['sim_tilt']:6.1f} "
f"{m['real_tilt_az']:5.0f}/{m['sim_tilt_az']:5.0f} "
f"{m['real_bowl']:+5.1f}/{m['sim_bowl']:+5.1f} "
f"{m['real_b1'] * 100:+5.1f}/{m['sim_b1'] * 100:+5.1f}")
return 0
if __name__ == "__main__":
sys.exit(main())