Newer
Older
SmTIAS-LightSim / src / sm_tias_lightsim / cli / evaluate.py
"""sm-evaluate コマンドラインエントリポイント.

PNG または luminance.npy を入力として均一性評価を実行し,
metrics.csv と可視化 PNG を出力ディレクトリに保存する.

matplotlib のバックエンドは Agg を強制する(headless 環境対応).
evaluate は Mitsuba を使わないため,render.py と異なり遅延 import は不要.
ただし Agg バックエンドの設定順序を保つため,matplotlib.use() は pyplot import より前に置く.
"""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

import matplotlib

# headless 環境(サーバ・CI 等)でも動くよう Agg バックエンドを強制する.
# visualize.py より先に設定することで,pyplot import 時に Agg が有効になる.
matplotlib.use("Agg")

import numpy  # noqa: E402
import pandas  # noqa: E402
from PIL import Image  # noqa: E402

from sm_tias_lightsim.evaluation.oetf import apply_srgb_oetf, solve_exposure  # noqa: E402
from sm_tias_lightsim.evaluation.radial import calc_radial_profile  # noqa: E402
from sm_tias_lightsim.evaluation.roi import extract_roi  # noqa: E402
from sm_tias_lightsim.evaluation.visualize import (  # noqa: E402
    save_histogram,
    save_luminance_map,
    save_radial_profile,
    save_zone_map,
)
from sm_tias_lightsim.evaluation.zones import (  # noqa: E402
    calc_zone_stats,
    classify_zones,
    compute_normalized_distance_map,
)
from sm_tias_lightsim.scene.schema import EvaluationConfig, OetfConfig  # noqa: E402
from sm_tias_lightsim.uniformity import calc_uniformity, to_grayscale  # noqa: E402

# 出力ファイル名を一元管理する(変更時の影響範囲を最小化するため)
_METRICS_FILENAME = "metrics.csv"
_FIGURES_DIRNAME = "figures"
_LUMINANCE_MAP_FILENAME = "luminance_map.png"
_HISTOGRAM_FILENAME = "histogram.png"
_RADIAL_PROFILE_FILENAME = "radial_profile.png"
_ZONE_MAP_FILENAME = "zone_map.png"


def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    """コマンドライン引数をパースして返す.

    Args:
        argv: 引数リスト.None のとき sys.argv[1:] を使用.

    Returns:
        パース済み Namespace.
    """
    parser = argparse.ArgumentParser(
        prog="sm-evaluate",
        description=(
            "PNG または luminance.npy を読み込み,均一性指標 CSV と可視化 PNG を出力する."
        ),
    )
    parser.add_argument(
        "--image",
        "-i",
        required=True,
        type=Path,
        metavar="IMAGE_PATH",
        help="評価対象の画像ファイル(PNG または .npy).",
    )
    parser.add_argument(
        "--config",
        "-c",
        required=True,
        type=Path,
        metavar="YAML_PATH",
        help="評価設定 YAML ファイルのパス(SceneConfig または EvaluationConfig 互換).",
    )
    parser.add_argument(
        "--output",
        "-o",
        required=True,
        type=Path,
        metavar="OUTPUT_DIR",
        help="出力先ディレクトリ(存在しない場合は親ごと作成する).",
    )
    return parser.parse_args(argv)


def _apply_oetf_to_luminance(
    roi_luminance: numpy.ndarray,
    oetf_cfg: OetfConfig,
) -> numpy.ndarray:
    """OETF 順方向適用:ROI linear luminance を sRGB 符号化空間へ変換する.

    無彩色シーン(シミュ LED・材料が RGB 等価)では luminance への直接 OETF 適用は
    「チャネル別 sRGB OETF → Rec.709 luma 加重和」と厳密一致する(SPEC_03 参照).

    露出ソルバは ROI 上で解く(target_mean は実測 ROI mean のため ROI 上で収束させる).

    Args:
        roi_luminance: ROI 切り出し済みの linear 輝度配列(H' x W', float64).
        oetf_cfg: OetfConfig(enabled=True が前提).

    Returns:
        OETF 適用後の ROI 輝度配列(H' x W', float64, quantize_uint8=True なら [0,255]).

    Raises:
        ValueError: oetf_cfg.exposure が None の場合(anchor_mean モードで必須).
    """
    if oetf_cfg.exposure is None:
        msg = "oetf.enabled=True かつ anchor_mean モードでは exposure 設定が必須です."
        raise ValueError(msg)

    roi_lin = roi_luminance.astype(numpy.float64)
    target_mean = oetf_cfg.exposure.target_mean
    # ROI linear 値で露出スケールを決定(実測 mean が ROI mean のため ROI 上で解く)
    scale, clipped = solve_exposure(roi_lin, target_mean)
    clip_pct = clipped * 100.0
    print(f"  OETF exposure  : scale={scale:.6g}  clip={clip_pct:.1f}%")

    # ROI に対してのみ OETF を適用する(指標算出は ROI 上で行うため)
    return apply_srgb_oetf(roi_lin, scale, quantize_uint8=oetf_cfg.quantize_uint8)


def _load_luminance(image_path: Path) -> numpy.ndarray:
    """画像ファイルを読み込み,輝度配列(2D float64)を返す.

    拡張子が .npy の場合は numpy.load で読み込む.
    それ以外(PNG 等)は PIL で RGB 画像として読み込み,to_grayscale で変換する.
    .npy が 3D(H, W, 3)だった場合も to_grayscale を通す.

    Args:
        image_path: 読み込む画像ファイルのパス.

    Returns:
        輝度配列(H x W, float64).

    Raises:
        FileNotFoundError: ファイルが存在しない場合.
        ValueError: ファイルを輝度配列に変換できない場合.
    """
    if not image_path.exists():
        msg = f"画像ファイルが見つかりません: {image_path}"
        raise FileNotFoundError(msg)

    if image_path.suffix.lower() == ".npy":
        data = numpy.load(image_path)
        if data.ndim == 3:
            return to_grayscale(data.astype(numpy.float64))
        if data.ndim == 2:
            return data.astype(numpy.float64)
        msg = f".npy の次元数が不正です(ndim={data.ndim}).2D または 3D を期待します."
        raise ValueError(msg)

    pil_image = Image.open(image_path).convert("RGB")
    return to_grayscale(numpy.array(pil_image, dtype=numpy.float64))


def _load_eval_config(config_path: Path) -> EvaluationConfig:
    """YAML を読み込んで EvaluationConfig を返す.

    SceneConfig の evaluation サブツリー,または EvaluationConfig 互換の
    トップレベル YAML の両方をサポートする.

    Args:
        config_path: YAML ファイルのパス.

    Returns:
        EvaluationConfig インスタンス.

    Raises:
        FileNotFoundError: ファイルが存在しない場合.
        pydantic.ValidationError: スキーマ違反.
        yaml.YAMLError: YAML 構文エラー.
    """
    import yaml

    if not config_path.exists():
        msg = f"設定 YAML ファイルが見つかりません: {config_path}"
        raise FileNotFoundError(msg)

    with config_path.open(encoding="utf-8") as f:
        data = yaml.safe_load(f)

    # "evaluation" キーがあれば SceneConfig 埋め込みとみなしてサブツリーを取得する
    if isinstance(data, dict) and "evaluation" in data:
        eval_data = data["evaluation"]
    else:
        eval_data = data

    return EvaluationConfig.model_validate(eval_data)


def _run_zone_analysis(
    roi_luminance: numpy.ndarray,
    zones_config: object,
) -> tuple[numpy.ndarray, dict] | tuple[None, dict]:
    """ゾーン分析を実行し,zone_map とゾーン統計を返す.

    Args:
        roi_luminance: ROI 輝度配列(H x W, float64).
        zones_config: ZonesConfig インスタンス(enabled / boundaries を持つ).

    Returns:
        (zone_map, zone_stats) のタプル.
        zones が無効なら (None, {}) を返す.

    Raises:
        ValueError: ゾーン分析の計算に失敗した場合.
    """
    if zones_config is None or not zones_config.enabled:
        return None, {}

    h, w = roi_luminance.shape
    distance_map = compute_normalized_distance_map(h, w)
    zone_map = classify_zones(distance_map, zones_config.boundaries)
    zone_stats = calc_zone_stats(roi_luminance, zone_map)
    return zone_map, zone_stats


def _run_radial_profile(
    roi_luminance: numpy.ndarray,
    metrics_config: object,
) -> numpy.ndarray | None:
    """放射状プロファイルを算出して返す.

    Args:
        roi_luminance: ROI 輝度配列(H x W, float64).
        metrics_config: MetricsConfig インスタンス(radial_profile フラグを持つ).

    Returns:
        放射状プロファイル配列((n_bins, 2)),無効時は None.
    """
    if metrics_config is None or not metrics_config.radial_profile:
        return None
    return calc_radial_profile(roi_luminance)


def _save_metrics_csv(metrics: dict, csv_path: Path) -> None:
    """均一性指標を CSV ファイルとして保存する.

    Args:
        metrics: 指標名 → 値の辞書.
        csv_path: 保存先 CSV パス.
    """
    df = pandas.DataFrame([metrics])
    df.to_csv(csv_path, index=False)


def _save_figures(
    roi_luminance: numpy.ndarray,
    figures_dir: Path,
    radial_profile: numpy.ndarray | None,
    zone_map: numpy.ndarray | None,
) -> None:
    """可視化 PNG を figures/ ディレクトリに保存する.

    Args:
        roi_luminance: ROI 輝度配列(H x W, float64).
        figures_dir: 保存先ディレクトリ(事前に mkdir 済みであること).
        radial_profile: 放射状プロファイル配列,None の場合は保存しない.
        zone_map: ゾーンマップ配列,None の場合は保存しない.
    """
    save_luminance_map(roi_luminance, figures_dir / _LUMINANCE_MAP_FILENAME)
    save_histogram(roi_luminance, figures_dir / _HISTOGRAM_FILENAME)

    if radial_profile is not None:
        save_radial_profile(radial_profile, figures_dir / _RADIAL_PROFILE_FILENAME)

    if zone_map is not None:
        save_zone_map(zone_map, figures_dir / _ZONE_MAP_FILENAME, luminance=roi_luminance)


def _print_summary(metrics: dict, csv_path: Path, figures_dir: Path) -> None:
    """評価サマリーを標準出力に表示する.

    Args:
        metrics: 指標名 → 値の辞書.
        csv_path: metrics.csv の保存先パス(表示用).
        figures_dir: figures/ ディレクトリのパス(表示用).
    """
    print("=== 均一性評価サマリー ===")
    print(f"  CoV            : {metrics['cov']:.6f}")
    print(f"  std            : {metrics['std']:.4f}")
    print(f"  max/min ratio  : {metrics['max_min_ratio']:.4f}")
    print(f"  mean           : {metrics['mean']:.4f}")
    print(f"  max            : {metrics['max']:.4f}")
    print(f"  min            : {metrics['min']:.4f}")

    if "center_periphery_ratio" in metrics:
        print(f"  C/P ratio      : {metrics['center_periphery_ratio']:.4f}")
        print(f"  gradient [%]   : {metrics['gradient_magnitude']:.2f}")

    print(f"\nmetrics.csv     : {csv_path}")
    print(f"figures/        : {figures_dir}")


def main(argv: list[str] | None = None) -> int:
    """sm-evaluate コマンドのメインエントリポイント.

    処理フロー:
        1. コマンドライン引数をパースする.
        2. 画像ファイルを読み込み,輝度配列を得る.
        3. YAML 設定ファイルを読み込み,EvaluationConfig を得る.
        4. 出力ディレクトリと figures/ サブディレクトリを作成する.
        5. extract_roi で ROI を切り出す.
        6. calc_uniformity で均一性指標を算出する.
        7. zones.enabled のときゾーン分析を実行する.
        8. metrics.radial_profile のとき放射状プロファイルを算出する.
        9. metrics.csv を保存する.
        10. 可視化 PNG を figures/ に保存する.
        11. 算出指標のサマリーを標準出力に表示する.

    Args:
        argv: 引数リスト.None のとき sys.argv[1:] を使用.

    Returns:
        終了コード(成功時 0,失敗時 1).
    """
    # argparse 自体のエラー(必須引数欠落等)は ArgumentParser が直接 sys.exit するため
    # ここでは ValueError / FileNotFoundError 等の実行時エラーのみ捕捉する
    args = _parse_args(argv)

    image_path: Path = args.image.resolve()
    config_path: Path = args.config.resolve()
    output_dir: Path = args.output.resolve()

    try:
        luminance = _load_luminance(image_path)
    except FileNotFoundError as exc:
        print(f"エラー: {exc}", file=sys.stderr)
        return 1
    except Exception as exc:  # noqa: BLE001
        print(f"エラー: 画像の読み込みに失敗しました — {exc}", file=sys.stderr)
        return 1

    try:
        eval_config = _load_eval_config(config_path)
    except FileNotFoundError as exc:
        print(f"エラー: {exc}", file=sys.stderr)
        return 1
    except Exception as exc:  # noqa: BLE001
        print(f"エラー: 設定 YAML の読み込みに失敗しました — {exc}", file=sys.stderr)
        return 1

    figures_dir = output_dir / _FIGURES_DIRNAME
    try:
        output_dir.mkdir(parents=True, exist_ok=True)
        figures_dir.mkdir(parents=True, exist_ok=True)
    except OSError as exc:
        print(f"エラー: 出力ディレクトリを作成できません — {exc}", file=sys.stderr)
        return 1

    try:
        roi_luminance = extract_roi(luminance, eval_config.roi)
    except (ValueError, NotImplementedError) as exc:
        print(f"エラー: ROI 抽出に失敗しました — {exc}", file=sys.stderr)
        return 1

    # OETF 順方向適用(enabled のとき ROI 抽出後・指標算出前に挟む).
    # eval_real.yaml(実測 PNG)は oetf を設定しないこと:
    # 実測 PNG は既に sRGB 符号化済みのため,ここで OETF を掛けると二重適用になる.
    oetf_cfg = eval_config.oetf
    if oetf_cfg is not None and oetf_cfg.enabled:
        try:
            roi_luminance = _apply_oetf_to_luminance(roi_luminance, oetf_cfg)
        except ValueError as exc:
            print(f"エラー: OETF 適用に失敗しました — {exc}", file=sys.stderr)
            return 1

    try:
        uniformity_stats = calc_uniformity(roi_luminance)
    except ValueError as exc:
        print(f"エラー: 均一性指標の算出に失敗しました — {exc}", file=sys.stderr)
        return 1

    metrics: dict = dict(uniformity_stats)

    try:
        zone_map, zone_stats = _run_zone_analysis(roi_luminance, eval_config.zones)
        metrics.update(zone_stats)
    except ValueError as exc:
        print(f"エラー: ゾーン分析に失敗しました — {exc}", file=sys.stderr)
        return 1

    try:
        radial_profile = _run_radial_profile(roi_luminance, eval_config.metrics)
    except Exception as exc:  # noqa: BLE001
        print(f"エラー: 放射状プロファイルの算出に失敗しました — {exc}", file=sys.stderr)
        return 1

    csv_path = output_dir / _METRICS_FILENAME
    try:
        _save_metrics_csv(metrics, csv_path)
    except Exception as exc:  # noqa: BLE001
        print(f"エラー: metrics.csv の保存に失敗しました — {exc}", file=sys.stderr)
        return 1

    try:
        _save_figures(roi_luminance, figures_dir, radial_profile, zone_map)
    except Exception as exc:  # noqa: BLE001
        print(f"エラー: 可視化 PNG の保存に失敗しました — {exc}", file=sys.stderr)
        return 1

    _print_summary(metrics, csv_path, figures_dir)
    return 0