"""PNG / DNG 比較スライド 1 枚を一括生成するスクリプト.
PNG(sRGB)と DNG(linear + LSC 補正)の照明均一性を「公平に」比較するため,
スケール差(8bit/10bit, sRGB/linear の絶対値差)を消して **減衰カーブの形** だけを
重ねて見せる.具体的には:
- 輝度マップ 2 枚: 各自の動径ピーク(最内ピーク輪帯平均)で正規化し,共通 colorbar で並べる
- 動径プロファイル重ね描き: 20 分割輪帯平均を **最大=1.0** で正規化して 1 枚に重ねる
(谷の底がそのまま動径 min/max 比に一致する)
- 数値表: 動径 min/max 比・CoV・max/min 比・中心/周辺比・勾配
注意: PNG(4月)と DNG(6月)は別セッション撮影でフレーミングが異なるため,
厳密な同一シーン比較ではない(REPORT_02 の前提を参照).本図は評価ドメインの違いが
減衰指標に与える影響を示すもの.
使い方:
python scripts/compare_png_dng_slide.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 ( # noqa: E402
calc_distance_map,
calc_radial_min_max_ratio,
calc_radial_profile,
)
from src.analysis.uniformity import ( # noqa: E402
REC709_COEFF_B,
REC709_COEFF_G,
REC709_COEFF_R,
calc_uniformity,
)
from src.analysis.spatial import calc_spatial_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, load_image # noqa: E402
# 比較対象(PNG sRGB と DNG linear+LSC)
PNG_PATH = PROJECT_ROOT / "data" / "smtias" / "whiteboard" / "SmTIAS_20260408_144434.png"
DNG_PATH = PROJECT_ROOT / "data" / "smtias" / "quantitative" / "SmTIAS_QM_20260601_124500.dng"
ROI_CONFIG = PROJECT_ROOT / "config" / "roi_config.json"
ROI_KEY = "smtias.whiteboard"
OUTPUT_PATH = PROJECT_ROOT / "output" / "figures" / "compare_png_dng_slide.png"
# 正規化輝度マップの共通カラースケール(動径ピーク=1.0 を基準とした相対輝度)
MAP_VMIN = 0.80
MAP_VMAX = 1.02
def _png_luminance(roi: dict) -> np.ndarray:
"""PNG を Rec.709 で輝度化し ROI を切り出す(sRGB ドメインのまま)."""
img = load_image(str(PNG_PATH))
luma = (
REC709_COEFF_R * img[:, :, 0]
+ REC709_COEFF_G * img[:, :, 1]
+ REC709_COEFF_B * img[:, :, 2]
)
return extract_roi(luma, roi["x"], roi["y"], roi["width"], roi["height"])
def _dng_luminance(roi: dict) -> np.ndarray:
"""DNG を linear + LSC 補正で輝度化し ROI を切り出す."""
luma = dng_luminance(str(DNG_PATH), apply_lsc=True)
return extract_roi(luma, roi["x"], roi["y"], roi["width"], roi["height"])
def _profile(luminance: np.ndarray) -> tuple[np.ndarray, dict, dict]:
"""動径プロファイル・min/max 統計・均一性/空間統計をまとめて算出する."""
distance_map = calc_distance_map(*luminance.shape)
profile = calc_radial_profile(luminance, distance_map)
radial = calc_radial_min_max_ratio(profile)
metrics = {**calc_uniformity(luminance), **calc_spatial_uniformity(luminance)["zone_stats"]}
metrics["radial_min_max_ratio"] = radial["radial_min_max_ratio"]
return profile, radial, metrics
def _draw_map(ax, luminance: np.ndarray, peak: float, title: str) -> None:
"""動径ピークで正規化した相対輝度マップを共通カラースケールで描く."""
relative = luminance / peak
im = ax.imshow(
relative, cmap="hot", interpolation="nearest", vmin=MAP_VMIN, vmax=MAP_VMAX
)
ax.set_title(title, fontsize=11)
ax.set_xlabel("X (px)")
ax.set_ylabel("Y (px)")
return im
def main() -> None:
"""比較スライドを生成して保存する."""
roi = load_roi_config(str(ROI_CONFIG), ROI_KEY)
if roi is None:
raise SystemExit(f"ROI '{ROI_KEY}' が config に見つかりません")
png_lum = _png_luminance(roi)
dng_lum = _dng_luminance(roi)
png_profile, png_radial, png_metrics = _profile(png_lum)
dng_profile, dng_radial, dng_metrics = _profile(dng_lum)
png_peak = png_radial["radial_max"]
dng_peak = dng_radial["radial_max"]
fig = plt.figure(figsize=(13, 9))
gs = fig.add_gridspec(2, 2, height_ratios=[1.05, 1.0], hspace=0.32, wspace=0.28)
# 上段: 正規化輝度マップ 2 枚(共通カラースケール)
ax_png = fig.add_subplot(gs[0, 0])
ax_dng = fig.add_subplot(gs[0, 1])
_draw_map(ax_png, png_lum, png_peak, "PNG (sRGB) relative luminance")
im = _draw_map(ax_dng, dng_lum, dng_peak, "DNG (linear + LSC) relative luminance")
cbar = fig.colorbar(im, ax=[ax_png, ax_dng], fraction=0.025, pad=0.02)
cbar.set_label("Relative luminance (radial peak = 1.0)")
# 下段左: 動径プロファイル重ね描き(max=1.0 正規化)
ax_prof = fig.add_subplot(gs[1, 0])
px = png_profile[:, 0]
py = png_profile[:, 1] / png_peak
dx = dng_profile[:, 0]
dy = dng_profile[:, 1] / dng_peak
ax_prof.plot(px, py, marker="o", ms=4, color="steelblue",
label=f"PNG (sRGB) min/max={png_radial['radial_min_max_ratio']:.3f}")
ax_prof.plot(dx, dy, marker="s", ms=4, color="darkorange",
label=f"DNG (linear+LSC) min/max={dng_radial['radial_min_max_ratio']:.3f}")
# 谷の底(= min/max 比)を水平線で強調
ax_prof.axhline(png_radial["radial_min_max_ratio"], color="steelblue", ls=":", alpha=0.5)
ax_prof.axhline(dng_radial["radial_min_max_ratio"], color="darkorange", ls=":", alpha=0.5)
ax_prof.set_title("Radial profile (normalized, peak = 1.0)", fontsize=11)
ax_prof.set_xlabel("Normalized distance from center")
ax_prof.set_ylabel("Normalized 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, 1])
ax_tbl.axis("off")
rows = [
("Radial min/max ratio", f"{png_metrics['radial_min_max_ratio']:.3f}",
f"{dng_metrics['radial_min_max_ratio']:.3f}"),
("CoV", f"{png_metrics['cov']:.3f}", f"{dng_metrics['cov']:.3f}"),
("Max/min ratio", f"{png_metrics['max_min_ratio']:.3f}",
f"{dng_metrics['max_min_ratio']:.3f}"),
("Center/periphery", f"{png_metrics['center_periphery_ratio']:.3f}",
f"{dng_metrics['center_periphery_ratio']:.3f}"),
("Gradient (%)", f"{png_metrics['gradient_magnitude']:.2f}",
f"{dng_metrics['gradient_magnitude']:.2f}"),
]
table = ax_tbl.table(
cellText=rows,
colLabels=["Metric", "PNG (sRGB)", "DNG (linear+LSC)"],
cellLoc="center",
loc="center",
)
table.auto_set_font_size(False)
table.set_fontsize(11)
table.scale(1.0, 1.8)
for j in range(3):
table[0, j].set_facecolor("#dfe6ee")
table[0, j].set_text_props(weight="bold")
ax_tbl.set_title(
"Lower min/max & higher CoV = DNG reveals stronger edge falloff\n"
"(sRGB gamma flattens PNG; not a strict same-scene comparison)",
fontsize=9, loc="center",
)
fig.suptitle("PNG -> DNG: how peripheral falloff appears under each domain",
fontsize=14, y=0.98)
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}")
print(f" PNG radial min/max = {png_radial['radial_min_max_ratio']:.4f}, CoV = {png_metrics['cov']:.4f}")
print(f" DNG radial min/max = {dng_radial['radial_min_max_ratio']:.4f}, CoV = {dng_metrics['cov']:.4f}")
if __name__ == "__main__":
main()