Newer
Older
SmTIAS-Evaluation / scripts / compare_single_leds.py
"""下・左・右の単灯白板画像 3 枚を 1 枚に並べて比較するスクリプト.

各 LED を単独点灯(他 2 灯を遮光)して撮影した白板 DNG 3 枚について,
照射の偏り・明るさ・均一性を横並びで比較する.3 灯合成時に互いを補い合えるか
(合成均一性の根拠)を読み取るための図.

構成:
- 上段: 下・左・右の輝度マップ 3 枚を **共通絶対カラースケール** で並置
  (どの LED が明るいか/どこを照らすかを同時に比較できる)
- 下段左: 動径プロファイル重ね描き(絶対値.明るさ差と減衰の形を同時に表示)
- 下段右: 数値表(平均・CoV・max/min 比・中心/周辺比・勾配・動径 min/max 比)

すべて linear + LSC 補正・ROI=smtias.whiteboard で揃えて算出する.

使い方:
    python scripts/compare_single_leds.py
    python scripts/compare_single_leds.py --bottom <dng> --left <dng> --right <dng> \
        --output output/smtias/figures/compare_single_leds_xxx.png --title-suffix "(w1p4)"
"""

import argparse
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,
    calc_spatial_uniformity,
)
from src.analysis.uniformity import calc_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

# 比較対象の既定値(単灯 3 枚.label, ファイル名, 描画色)
QUANT_DIR = PROJECT_ROOT / "data" / "smtias" / "quantitative"
DEFAULT_LEDS = [
    ("Bottom LED", QUANT_DIR / "SmTIAS_QM_20260615_141420.dng", "steelblue"),
    ("Left LED", QUANT_DIR / "SmTIAS_QM_20260615_150300.dng", "seagreen"),
    ("Right LED", QUANT_DIR / "SmTIAS_QM_20260615_170948.dng", "indianred"),
]
ROI_CONFIG = PROJECT_ROOT / "config" / "roi_config.json"
ROI_KEY = "smtias.whiteboard"
OUTPUT_PATH = PROJECT_ROOT / "output" / "smtias" / "figures" / "compare_single_leds.png"

# 共通絶対カラースケールの設定(外れ値に強いパーセンタイルで vmin/vmax を決める)
SCALE_PERCENTILES = (1.0, 99.0)


def _luminance(dng_path: Path, roi: dict) -> np.ndarray:
    """DNG を linear + LSC 補正で輝度化し ROI を切り出す(portrait)."""
    luma_full = dng_luminance(str(dng_path), apply_lsc=True)
    return extract_roi(luma_full, roi["x"], roi["y"], roi["width"], roi["height"])


def _metrics(luminance: np.ndarray) -> dict:
    """均一性・空間・動径の指標をまとめて算出する."""
    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"],
        "radial_min_max_ratio": radial["radial_min_max_ratio"],
        "profile": profile,
    }
    return metrics


def _parse_args() -> argparse.Namespace:
    """CLI 引数を解釈する(未指定時は従来撮影 3 枚・既定出力パス)."""
    parser = argparse.ArgumentParser(description="下・左・右の単灯白板 DNG 3 枚を並べて比較する.")
    parser.add_argument("--bottom", default=str(DEFAULT_LEDS[0][1]), help="下のみ点灯の DNG パス")
    parser.add_argument("--left", default=str(DEFAULT_LEDS[1][1]), help="左のみ点灯の DNG パス")
    parser.add_argument("--right", default=str(DEFAULT_LEDS[2][1]), help="右のみ点灯の DNG パス")
    parser.add_argument("--output", default=str(OUTPUT_PATH), help="出力する比較図のパス")
    parser.add_argument(
        "--title-suffix", default="", help="図タイトルの末尾に付ける文字列(例: '(w1p4)')"
    )
    return parser.parse_args()


def main() -> None:
    """3 単灯の比較図を生成して保存する."""
    args = _parse_args()
    leds = [
        (label, Path(path), color)
        for (label, _, color), path in zip(DEFAULT_LEDS, [args.bottom, args.left, args.right])
    ]
    output_path = Path(args.output)
    roi = load_roi_config(str(ROI_CONFIG), ROI_KEY)
    if roi is None:
        raise SystemExit(f"ROI '{ROI_KEY}' が config に見つかりません")

    labels = [label for label, _, _ in leds]
    colors = [color for _, _, color in leds]
    lumas = [_luminance(path, roi) for _, path, _ in leds]
    metrics = [_metrics(luma) for luma in lumas]

    # 共通絶対カラースケール(3 枚をまとめてパーセンタイル)
    flat_all = np.concatenate([luma.ravel() for luma in lumas])
    vmin, vmax = np.percentile(flat_all, SCALE_PERCENTILES)

    fig = plt.figure(figsize=(15, 9))
    gs = fig.add_gridspec(2, 3, height_ratios=[1.0, 0.85], hspace=0.28, wspace=0.22)

    # 上段: 輝度マップ 3 枚(共通絶対スケール)
    map_axes = []
    im = None
    for col, (label, luma) in enumerate(zip(labels, lumas)):
        ax = fig.add_subplot(gs[0, col])
        im = ax.imshow(luma, cmap="hot", interpolation="nearest", vmin=vmin, vmax=vmax)
        ax.set_title(f"{label}  (mean={metrics[col]['mean']:.1f})", fontsize=11)
        ax.set_xlabel("X (px)")
        if col == 0:
            ax.set_ylabel("Y (px)")
        map_axes.append(ax)
    cbar = fig.colorbar(im, ax=map_axes, fraction=0.020, pad=0.02)
    cbar.set_label(f"Luminance (common scale, p{SCALE_PERCENTILES[0]:g}-p{SCALE_PERCENTILES[1]:g})")

    # 下段左: 動径プロファイル重ね描き(絶対値)
    ax_prof = fig.add_subplot(gs[1, 0:2])
    markers = ["o", "s", "^"]
    for label, color, marker, m in zip(labels, colors, markers, metrics):
        profile = m["profile"]
        ax_prof.plot(
            profile[:, 0], profile[:, 1], marker=marker, ms=4, color=color,
            label=f"{label}  CoV={m['cov']:.3f}",
        )
    ax_prof.set_title("Radial profile (absolute luminance)", fontsize=11)
    ax_prof.set_xlabel("Normalized distance from center")
    ax_prof.set_ylabel("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, 2])
    ax_tbl.axis("off")
    metric_rows = [
        ("Mean luminance", "mean", "{:.2f}"),
        ("CoV", "cov", "{:.3f}"),
        ("Max/min ratio", "max_min_ratio", "{:.3f}"),
        ("Center/periphery", "center_periphery_ratio", "{:.3f}"),
        ("Gradient (%)", "gradient_magnitude", "{:.2f}"),
        ("Radial min/max", "radial_min_max_ratio", "{:.3f}"),
    ]
    rows = [
        [name] + [fmt.format(m[key]) for m in metrics]
        for name, key, fmt in metric_rows
    ]
    table = ax_tbl.table(
        cellText=rows,
        colLabels=["Metric"] + labels,
        cellLoc="center",
        loc="center",
    )
    table.auto_set_font_size(False)
    table.set_fontsize(10)
    table.scale(1.0, 1.7)
    for j in range(len(labels) + 1):
        table[0, j].set_facecolor("#dfe6ee")
        table[0, j].set_text_props(weight="bold")

    title = "Single-LED illumination comparison (bottom / left / right)"
    if args.title_suffix:
        title += f" {args.title_suffix}"
    fig.suptitle(title, 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"3 単灯比較図を保存しました: {output_path}")
    for label, m in zip(labels, metrics):
        print(f"  {label}: mean={m['mean']:.2f}, CoV={m['cov']:.4f}, "
              f"radial min/max={m['radial_min_max_ratio']:.4f}")


if __name__ == "__main__":
    main()