Newer
Older
SmTIAS-Evaluation / scripts / run_uniformity_dng.py
"""SmTIAS 定量モード DNG の照明均一性評価スクリプト(PNG 版 run_uniformity.py の DNG 対応版).

PNG 版と同じ出力一式(輝度マップ / ヒストグラム / 動径プロファイル / ゾーンマップ /
均一性 CSV / コンソール指標)を,DNG に対して同じ ROI・同じ向き(portrait)で出力する.

PNG 版との違い:
- 読み込みを load_image_dng(linear, output_color=raw)に置換
- DNG は RAW_SENSOR で未補正のため,meta.json の lscMap でレンズシェーディングを順適用(--no-lsc で無効化可)
- DNG はセンサ native が landscape のため,PNG と同じ portrait へ rot90(-1) で向き合わせ

使い方:
    python scripts/run_uniformity_dng.py --image data/smtias/quantitative/xxx.dng
"""

import argparse
import json
import sys
from pathlib import Path

import numpy as np

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.analysis.uniformity import (  # noqa: E402
    REC709_COEFF_B,
    REC709_COEFF_G,
    REC709_COEFF_R,
    calc_uniformity,
)
from src.export.exporter import ensure_output_dirs, export_results  # noqa: E402
from src.io.dng_loader import load_image_dng  # noqa: E402
from src.io.loader import extract_roi  # noqa: E402
from src.visualization.plotter import (  # noqa: E402
    plot_histogram,
    plot_luminance_map,
    plot_radial_profile,
    plot_zone_map,
)


def load_roi_config(config_path: str, roi_key: str) -> dict | None:
    """ROI 設定ファイルを読み込んで指定キーの ROI を返す.

    Args:
        config_path: JSON 設定ファイルのパス.
        roi_key: ドット区切りのキー(例: "smtias.whiteboard").

    Returns:
        ROI の辞書(x, y, width, height).キーが存在しない場合は None.

    Raises:
        FileNotFoundError: 設定ファイルが存在しない場合.
    """
    config_file = Path(config_path)
    if not config_file.exists():
        raise FileNotFoundError(f"設定ファイルが見つかりません: {config_path}")

    with config_file.open(encoding="utf-8") as f:
        config = json.load(f)

    # ドット区切りでネストされたキーを辿る
    node = config
    for key in roi_key.split("."):
        if not isinstance(node, dict) or key not in node:
            return None
        node = node[key]

    return node if isinstance(node, dict) else None


def _upsample(grid: np.ndarray, shape: tuple[int, int]) -> np.ndarray:
    """(rows, cols) グリッドを画像サイズ shape へバイリニア補間する.

    Args:
        grid: 補間元グリッドの NumPy 配列(rows x cols, float64).
        shape: 補間先の画像サイズ (height, width).

    Returns:
        補間後の NumPy 配列(height x width, float64).
    """
    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 (4, M, N) を (H, W, 3) の RGB ゲインへ補間する.

    4ch = [R, G_even, G_odd, B].G は G_even/G_odd の平均を用いる.

    Args:
        meta_path: meta.json ファイルのパス.
        shape: 補間先の画像サイズ (height, width).

    Returns:
        RGB ゲインの NumPy 配列(H x W x 3, float64).
    """
    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 dng_luminance(image_path: str, apply_lsc: bool) -> np.ndarray:
    """DNG から portrait・linear・(任意で LSC 補正済)の輝度画像を返す.

    - output_format="rgb_float32"(output_color=raw, linear)で読み込み
    - apply_lsc=True なら lscMap を順適用してレンズビネットを除去
    - Rec.709 で輝度化(uniformity.py と同じ係数)
    - PNG と同じ portrait へ rot90(-1) で向き合わせ

    Args:
        image_path: DNG ファイルのパス.
        apply_lsc: True の場合,同名 .meta.json から LSC ゲインを読み込んで補正する.

    Returns:
        portrait 向きの輝度画像(H x W, float64).値域は 0〜255 の linear スケール.
    """
    rgb = load_image_dng(image_path, output_format="rgb_float32")  # (H,W,3) linear landscape

    if apply_lsc:
        meta_path = Path(image_path).with_suffix("").with_suffix(".meta.json")
        if not meta_path.exists():
            # ".meta.json" は二重拡張子なので with_suffix では拾えない場合がある
            meta_path = Path(str(Path(image_path).with_suffix("")) + ".meta.json")
        if not meta_path.exists():
            raise FileNotFoundError(f"meta.json が見つかりません(LSC 補正に必要): {meta_path}")
        rgb = rgb * lsc_gain_rgb(meta_path, rgb.shape[:2])

    luma = (
        REC709_COEFF_R * rgb[:, :, 0]
        + REC709_COEFF_G * rgb[:, :, 1]
        + REC709_COEFF_B * rgb[:, :, 2]
    )
    # linear のまま 0〜255 スケールで表現する(PNG 図と軸を揃えて比較するため).
    # CoV・max/min・中心周辺比はスケール不変なので不変,mean/std のみ ×255 となる.
    # 既存 plot_histogram が range=(0,255) 固定であることにも整合する.
    luma = luma * 255.0
    return np.rot90(luma, -1)  # portrait(PNG と同じ向き)


def analyze_single_dng(
    image_path: str,
    roi: dict | None,
    output_base: Path,
    apply_lsc: bool,
) -> None:
    """DNG 1 枚を解析し,PNG 版と同じ出力一式を生成する.

    Args:
        image_path: 解析対象 DNG ファイルのパス.
        roi: ROI の辞書(x, y, width, height).None の場合は画像全体を解析.
        output_base: 出力ルートディレクトリのパス.
        apply_lsc: True の場合,LSC(レンズシェーディング)補正を適用する.
    """
    print(f"解析開始: {image_path}")
    luma_full = dng_luminance(image_path, apply_lsc)
    print(f"画像サイズ: {luma_full.shape[1]} x {luma_full.shape[0]} px(portrait)")
    print(f"LSC 補正: {'あり' if apply_lsc else 'なし'} / ドメイン: linear(output_color=raw)")

    if roi is not None:
        print(
            f"ROI を適用します: x={roi['x']}, y={roi['y']}, "
            f"width={roi['width']}, height={roi['height']}"
        )
        luminance = extract_roi(luma_full, roi["x"], roi["y"], roi["width"], roi["height"])
    else:
        print("ROI 未指定: 画像全体を解析します")
        luminance = luma_full

    results = calc_uniformity(luminance)
    print("--- 均一性指標(輝度は 0〜255 linear スケール)---")
    print(f"  平均輝度     : {results['mean']:.2f}")
    print(f"  標準偏差     : {results['std']:.2f}")
    print(f"  CoV          : {results['cov']:.4f}")
    print(f"  最大/最小比  : {results['max_min_ratio']:.4f}")
    print(f"  最大輝度     : {results['max']:.2f}")
    print(f"  最小輝度     : {results['min']:.2f}")

    spatial = calc_spatial_uniformity(luminance)
    zone_stats = spatial["zone_stats"]
    print("--- 空間解析指標 ---")
    print(f"  中心平均輝度   : {zone_stats['center_mean']:.2f}")
    print(f"  中間平均輝度   : {zone_stats['middle_mean']:.2f}")
    print(f"  周辺平均輝度   : {zone_stats['periphery_mean']:.2f}")
    print(f"  中心/周辺比    : {zone_stats['center_periphery_ratio']:.4f}")
    print(f"  勾配量 (%)     : {zone_stats['gradient_magnitude']:.2f}")
    print(f"  動径min/max比  : {spatial['radial_min_max_ratio']:.4f}")

    stem = Path(image_path).stem  # 例: SmTIAS_QM_20260601_102753
    figures = output_base / "figures"
    plot_luminance_map(luminance, str(figures / f"{stem}_luminance_map.png"))
    plot_histogram(luminance, str(figures / f"{stem}_histogram.png"))
    plot_radial_profile(spatial["radial_profile"], str(figures / f"{stem}_radial_profile.png"))
    plot_zone_map(luminance, spatial["zone_map"], str(figures / f"{stem}_zone_map.png"))
    export_results(results, str(output_base / "results" / f"{stem}_uniformity.csv"))

    print(f"解析完了: {image_path}")


def main() -> None:
    """エントリーポイント."""
    parser = argparse.ArgumentParser(
        description="SmTIAS 定量モード DNG の照明均一性を評価する(PNG 版と同じ出力一式)."
    )
    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()

    output_base = Path(args.output)
    ensure_output_dirs(args.output)

    roi = None
    if args.config:
        try:
            roi = load_roi_config(args.config, args.roi)
        except FileNotFoundError as e:
            print(f"警告: {e}  → 画像全体を解析します")

    analyze_single_dng(args.image, roi, output_base, apply_lsc=not args.no_lsc)


if __name__ == "__main__":
    main()