diff --git a/scripts/compare_ambient_maps.py b/scripts/compare_ambient_maps.py new file mode 100644 index 0000000..6713a1c --- /dev/null +++ b/scripts/compare_ambient_maps.py @@ -0,0 +1,92 @@ +"""暗闇 vs 室内(外乱光)の輝度マップを共通スケールで並べるスライドを生成する. + +REPORT_03(外乱光影響評価)に対応.compare_ambient_radial.py(動径オーバーレイ)の +輝度マップ版で,同一 DNG 定量モード(linear + LSC 補正,露出/ISO 固定)の 2 枚を +**共通の絶対カラースケール** で並置し,空間パターンがほぼ同じこと(外乱光ロバスト性)を示す. + +共通スケールにする理由(各自正規化しない): +- 両者は同じ linear 単位・同じ露出/ISO・同じ LSC 補正なので絶対値が正当に比較可能. +- 共通カラースケールでないと「2 枚がほぼ同じ」が言えない(各自スケールだと差が誇張/隠蔽される). + +対象(REPORT_03 の条件 A/B): +- 暗闇 (lights OFF) : SmTIAS_QM_20260601_124500 +- 室内 (lights ON) : SmTIAS_QM_20260601_102753 + +使い方: + python scripts/compare_ambient_maps.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.config import load_roi_config # noqa: E402 +from src.io.dng_pipeline import dng_luminance # noqa: E402 +from src.io.loader import extract_roi # noqa: E402 + +# REPORT_03 の条件 A(暗闇=室内灯OFF)/ 条件 B(室内=室内灯ON) +DARK_PATH = PROJECT_ROOT / "data" / "smtias" / "quantitative" / "SmTIAS_QM_20260601_124500.dng" +INDOOR_PATH = PROJECT_ROOT / "data" / "smtias" / "quantitative" / "SmTIAS_QM_20260601_102753.dng" +ROI_CONFIG = PROJECT_ROOT / "config" / "roi_config.json" +ROI_KEY = "smtias.whiteboard" +OUTPUT_PATH = PROJECT_ROOT / "output" / "figures" / "compare_ambient_maps.png" + + +def _roi_luminance(path: Path, roi: dict) -> np.ndarray: + """DNG(linear + LSC)の ROI 輝度画像を返す.""" + luma = dng_luminance(str(path), apply_lsc=True) + return extract_roi(luma, roi["x"], roi["y"], roi["width"], roi["height"]) + + +def main() -> None: + """共通スケールの 2 枚並べスライドを生成して保存する.""" + roi = load_roi_config(str(ROI_CONFIG), ROI_KEY) + if roi is None: + raise SystemExit(f"ROI '{ROI_KEY}' が config に見つかりません") + + dark = _roi_luminance(DARK_PATH, roi) + indoor = _roi_luminance(INDOOR_PATH, roi) + + # 共通カラースケール: 2 枚を結合した 1〜99 パーセンタイル(ノイズ外れ値にロバスト) + both = np.concatenate([dark.ravel(), indoor.ravel()]) + vmin = float(np.percentile(both, 1)) + vmax = float(np.percentile(both, 99)) + + fig, axes = plt.subplots(1, 2, figsize=(13, 6)) + panels = [ + (axes[0], dark, "Dark (lights OFF) 124500"), + (axes[1], indoor, "Indoor (lights ON) 102753"), + ] + im = None + for ax, luminance, title in panels: + im = ax.imshow( + luminance, cmap="hot", interpolation="nearest", vmin=vmin, vmax=vmax + ) + ax.set_title(title, fontsize=11) + ax.set_xlabel("X (px)") + ax.set_ylabel("Y (px)") + + cbar = fig.colorbar(im, ax=axes, fraction=0.025, pad=0.02) + cbar.set_label("Luminance (absolute, linear 0-255 scale)") + + fig.suptitle( + "Ambient light effect on luminance map (DNG, linear + LSC, shared scale)", + fontsize=13, 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" 共通カラースケール vmin={vmin:.1f} vmax={vmax:.1f}") + print(f" Dark (124500) ROI mean = {dark.mean():.2f}") + print(f" Indoor (102753) ROI mean = {indoor.mean():.2f}") + + +if __name__ == "__main__": + main() diff --git a/scripts/compare_ambient_radial.py b/scripts/compare_ambient_radial.py new file mode 100644 index 0000000..8b78475 --- /dev/null +++ b/scripts/compare_ambient_radial.py @@ -0,0 +1,94 @@ +"""暗闇 vs 室内(外乱光)の動径プロファイル重ね描きスライドを生成する. + +REPORT_03(外乱光影響評価)に対応.同一 DNG 定量モード(linear + LSC 補正,露出/ISO 固定)で +撮った 2 枚を,動径プロファイルを **絶対輝度のまま** 1 枚に重ねて比較する. + +軸を絶対輝度にする理由(正規化しない): +- 両者は同じ linear 単位・同じ露出/ISO・同じ LSC 補正なので絶対値が正当に比較可能. +- 「外光を足したのに暗い」(暗闇 raw mean 441.2 > 室内 431.4)という逆転は絶対値でしか見えない. + 各自の最大で正規化するとこの"レベルも含めてほぼ同一"という情報が消える. +- 2 本がほぼ重なること自体が「外乱光にロバスト」というメッセージになる. + +対象(REPORT_03 の条件 A/B): +- 暗闇 (lights OFF) : SmTIAS_QM_20260601_124500 → min/max 0.860 +- 室内 (lights ON) : SmTIAS_QM_20260601_102753 → min/max 0.853 + +使い方: + python scripts/compare_ambient_radial.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.config import load_roi_config # noqa: E402 +from src.io.dng_pipeline import dng_luminance # noqa: E402 +from src.io.loader import extract_roi # noqa: E402 + +# REPORT_03 の条件 A(暗闇=室内灯OFF)/ 条件 B(室内=室内灯ON) +DARK_PATH = PROJECT_ROOT / "data" / "smtias" / "quantitative" / "SmTIAS_QM_20260601_124500.dng" +INDOOR_PATH = PROJECT_ROOT / "data" / "smtias" / "quantitative" / "SmTIAS_QM_20260601_102753.dng" +ROI_CONFIG = PROJECT_ROOT / "config" / "roi_config.json" +ROI_KEY = "smtias.whiteboard" +OUTPUT_PATH = PROJECT_ROOT / "output" / "figures" / "compare_ambient_radial.png" + + +def _radial(path: Path, roi: dict) -> tuple[np.ndarray, dict]: + """DNG(linear + LSC)の ROI 動径プロファイルと min/max 統計を返す.""" + luma = dng_luminance(str(path), apply_lsc=True) + luma = extract_roi(luma, roi["x"], roi["y"], roi["width"], roi["height"]) + distance_map = calc_distance_map(*luma.shape) + profile = calc_radial_profile(luma, distance_map) + radial = calc_radial_min_max_ratio(profile) + return profile, radial + + +def main() -> None: + """重ね描きスライドを生成して保存する.""" + roi = load_roi_config(str(ROI_CONFIG), ROI_KEY) + if roi is None: + raise SystemExit(f"ROI '{ROI_KEY}' が config に見つかりません") + + dark_prof, dark_r = _radial(DARK_PATH, roi) + indoor_prof, indoor_r = _radial(INDOOR_PATH, roi) + + fig, ax = plt.subplots(figsize=(9, 6)) + + ax.plot( + dark_prof[:, 0], dark_prof[:, 1], + marker="o", ms=5, color="#1f3b73", + label=f"Dark (lights OFF) min/max = {dark_r['radial_min_max_ratio']:.3f}", + ) + ax.plot( + indoor_prof[:, 0], indoor_prof[:, 1], + marker="s", ms=5, color="#e08214", + label=f"Indoor (lights ON) min/max = {indoor_r['radial_min_max_ratio']:.3f}", + ) + + ax.set_title("Ambient light effect on radial profile (DNG, linear + LSC)", fontsize=12) + ax.set_xlabel("Normalized distance from center") + ax.set_ylabel("Mean luminance (absolute, linear 0-255 scale)") + ax.grid(True, linestyle="--", alpha=0.5) + ax.legend(loc="upper right", fontsize=10) + fig.tight_layout() + + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(str(OUTPUT_PATH), dpi=150) + plt.close(fig) + print(f"重ね描きスライドを保存しました: {OUTPUT_PATH}") + print(f" Dark (124500) min/max = {dark_r['radial_min_max_ratio']:.4f}") + print(f" Indoor (102753) min/max = {indoor_r['radial_min_max_ratio']:.4f}") + + +if __name__ == "__main__": + main() diff --git a/scripts/compare_png_dng_slide.py b/scripts/compare_png_dng_slide.py new file mode 100644 index 0000000..b28f64d --- /dev/null +++ b/scripts/compare_png_dng_slide.py @@ -0,0 +1,185 @@ +"""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() diff --git a/scripts/plot_radial_clean.py b/scripts/plot_radial_clean.py new file mode 100644 index 0000000..1dc89e6 --- /dev/null +++ b/scripts/plot_radial_clean.py @@ -0,0 +1,123 @@ +"""動径輝度プロファイルを「min/max 比注記・凡例なし」で出力する一回限りのスクリプト. + +既存の動径プロファイル図(plot_radial_profile)から,右上の min/max ratio 注記と +左下の凡例を除いたクリーン版 PNG を出力する.データは run_uniformity_dng.py と同じ +DNG パイプライン(LSC 補正あり・portrait・linear)で再計算する. + +使い方: + python scripts/plot_radial_clean.py \\ + --image data/smtias/quantitative/SmTIAS_QM_20260601_124500.dng +""" + +import argparse +import sys +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 +import numpy as np # noqa: E402 + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +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 # noqa: E402 + + +def plot_radial_profile_clean(radial_profile: np.ndarray, output_path: str) -> None: + """動径プロファイルを min/max 比注記・凡例なしで保存する. + + plot_radial_profile と同一の見た目だが,右上の min/max ratio テキストと + 左下の凡例(Mean Luminance / Max / Min)を描画しない. + 最大点(緑)・最小点(赤)のマーカーは残す. + + Args: + radial_profile: 放射状プロファイルの NumPy 配列(N x 2, float64). + 列 0 = 正規化距離, 列 1 = 平均輝度. + output_path: 出力先ファイルパス(PNG). + """ + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + + distances = radial_profile[:, 0] + luminances = radial_profile[:, 1] + + min_idx = int(np.argmin(luminances)) + max_idx = int(np.argmax(luminances)) + lmin = float(luminances[min_idx]) + lmax = float(luminances[max_idx]) + + fig, ax = plt.subplots(figsize=(8, 5)) + ax.plot(distances, luminances, marker="o", color="steelblue") + ax.scatter(distances[max_idx], lmax, color="green", s=80, zorder=5) + ax.scatter(distances[min_idx], lmin, color="red", s=80, zorder=5) + + ax.set_title("Radial Luminance Profile") + ax.set_xlabel("Normalized Distance from Center") + ax.set_ylabel("Mean Luminance") + ax.grid(True, linestyle="--", alpha=0.5) + # min/max ratio 注記・凡例ともに描画しない + fig.tight_layout() + fig.savefig(output_path, dpi=150) + plt.close(fig) + + print(f"クリーン版 動径プロファイルを保存しました: {output_path}") + + +def main() -> None: + """エントリーポイント.""" + parser = argparse.ArgumentParser( + description="動径輝度プロファイルを min/max 比注記・凡例なしで出力する." + ) + parser.add_argument("--image", required=True, help="解析対象の DNG ファイルパス") + parser.add_argument( + "--config", + default=str(PROJECT_ROOT / "config" / "roi_config.json"), + help="ROI 設定ファイルのパス(デフォルト: config/roi_config.json)", + ) + parser.add_argument( + "--roi", + default="smtias.whiteboard", + help="ROI のキー(ドット区切り,デフォルト: smtias.whiteboard)", + ) + parser.add_argument( + "--output", + default=str(PROJECT_ROOT / "output"), + help="出力ディレクトリのパス(デフォルト: output/)", + ) + parser.add_argument( + "--no-lsc", + action="store_true", + help="LSC(レンズシェーディング)補正を無効化する(既定は補正あり)", + ) + args = parser.parse_args() + + roi = None + if args.config: + try: + roi = load_roi_config(args.config, args.roi) + except FileNotFoundError as e: + print(f"警告: {e} → 画像全体を解析します") + + luma_full = dng_luminance(args.image, apply_lsc=not args.no_lsc) + if roi is not None: + luminance = extract_roi( + luma_full, roi["x"], roi["y"], roi["width"], roi["height"] + ) + else: + luminance = luma_full + + spatial = calc_spatial_uniformity(luminance) + + stem = Path(args.image).stem + output_path = ( + Path(args.output) / "figures" / f"{stem}_radial_profile_clean.png" + ) + plot_radial_profile_clean(spatial["radial_profile"], str(output_path)) + + +if __name__ == "__main__": + main()