"""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.normalize import apply_mean_anchor  # noqa: E402
from sm_tias_lightsim.evaluation.oetf import apply_srgb_oetf, solve_exposure  # noqa: E402
from sm_tias_lightsim.evaluation.radial import (  # noqa: E402
    calc_radial_min_max_ratio,
    calc_radial_profile,
)
from sm_tias_lightsim.evaluation.roi import extract_roi  # noqa: E402
from sm_tias_lightsim.evaluation.shape_metrics import (  # noqa: E402
    calc_angular_radial_decomposition,
    calc_intensity_centroid,
    calc_intensity_pca,
    calc_ncc,
    calc_ssim,
)
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 (  # noqa: E402
    EvaluationConfig,
    MetricsConfig,
    NormalizationConfig,
    OetfConfig,
    ZonesConfig,
)
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 _apply_normalization_to_luminance(
    roi_luminance: numpy.ndarray,
    norm_cfg: NormalizationConfig,
) -> numpy.ndarray:
    """linear mean anchor 正規化を ROI 輝度配列に適用する．

    ROI mean を norm_cfg.target_mean に揃える線形ゲインを適用する．
    形状指標（CoV / 勾配 / C/P 比 / max-min 比）は線形ゲインに不変なので，
    絶対値スケールだけが変わる．

    Args:
        roi_luminance: ROI 切り出し済みの linear 輝度配列（H' x W', float64）．
        norm_cfg: NormalizationConfig（enabled=True が前提）．

    Returns:
        正規化後の ROI 輝度配列（H' x W', float64）．

    Raises:
        ValueError: ROI mean が 0 の場合（apply_mean_anchor から伝播）．
    """
    roi_lin = roi_luminance.astype(numpy.float64)
    target_mean = norm_cfg.target_mean
    normalized, alpha = apply_mean_anchor(roi_lin, target_mean)
    print(f"  normalization  : alpha={alpha:.6g}  target_mean={target_mean}")
    return normalized


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: ZonesConfig | None,
) -> tuple[numpy.ndarray, dict] | tuple[None, dict]:
    """ゾーン分析を実行し，zone_map とゾーン統計を返す．

    Args:
        roi_luminance: ROI 輝度配列（H x W, float64）．
        zones_config: ZonesConfig インスタンス（enabled / boundaries を持つ）．
            None の場合は無効扱い．

    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: MetricsConfig | None,
) -> numpy.ndarray | None:
    """放射状プロファイルを算出して返す．

    Args:
        roi_luminance: ROI 輝度配列（H x W, float64）．
        metrics_config: MetricsConfig インスタンス（radial_profile フラグを持つ）．
            None の場合は無効扱い．

    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 _run_radial_min_max(
    roi_luminance: numpy.ndarray,
    metrics_config: MetricsConfig | None,
    radial_profile: numpy.ndarray | None,
) -> dict:
    """動径 min/max 比を算出して返す．

    radial_min_max が True のとき，radial_profile が既に算出されていればそれを再利用し，
    未算出（None）の場合はその場で calc_radial_profile を呼び出して算出する．
    重複算出を避けるため，radial_profile の結果を優先的に使用する．

    Args:
        roi_luminance: ROI 輝度配列（H x W, float64）．
        metrics_config: MetricsConfig インスタンス（radial_min_max フラグを持つ）．
            None の場合は無効扱い．
        radial_profile: _run_radial_profile で算出済みのプロファイル配列．
            None の場合はここで算出する．

    Returns:
        動径 min/max 比指標の辞書（radial_min_max_ratio / radial_min_distance /
        radial_min / radial_max の 4 キー）．無効時は空辞書．
    """
    if metrics_config is None or not metrics_config.radial_min_max:
        return {}

    # radial_profile が未算出の場合はここで算出する（重複算出を避けるため既存結果を優先）
    profile = radial_profile if radial_profile is not None else calc_radial_profile(roi_luminance)
    return calc_radial_min_max_ratio(profile)


def _load_reference_luminance(reference_path: Path) -> numpy.ndarray:
    """形状指標用の参照画像を読み込み，輝度配列（2D float64）を返す．

    SSIM / NCC の参照画像として使用する．_load_luminance と同一の変換ロジックを用い，
    .npy 形式（2D/3D）と PNG 形式の両方に対応する．
    呼び出し元（_run_shape_metrics）で shape を確認してから calc_ssim / calc_ncc に渡すこと．

    Args:
        reference_path: 参照画像ファイルのパス（.npy または .png）．

    Returns:
        輝度配列（H x W, float64）．

    Raises:
        FileNotFoundError: ファイルが存在しない場合．
        ValueError: ファイルを輝度配列に変換できない場合．
    """
    return _load_luminance(reference_path)


def _run_shape_metrics(
    roi_luminance: numpy.ndarray,
    metrics_config: MetricsConfig | None,
    config_dir: Path,
) -> dict:
    """形状指標（輝度重心 / 角度-放射分解 / PCA 主軸 / SSIM / NCC）を算出する．

    metrics.shape_metrics=True のとき，以下の指標を算出して辞書として返す:
    - 輝度重心 5 値 (calc_intensity_centroid)
    - 角度-放射分解 3 値 (calc_angular_radial_decomposition)
    - PCA 主軸 4 値 (calc_intensity_pca)
    - SSIM / NCC（shape_reference が指定されたときのみ，それ以外は float("nan")）

    shape_reference パスは config YAML のディレクトリを基準に解決する．
    絶対パスの場合はそのまま使用する．

    Args:
        roi_luminance: ROI 輝度配列（H x W, float64）．
        metrics_config: MetricsConfig インスタンス（shape_metrics フラグを持つ）．
            None の場合は無効扱い．
        config_dir: 評価 YAML ファイルのディレクトリ（shape_reference の相対パス解決用）．

    Returns:
        形状指標の辞書（12 キー）．無効時は空辞書．
        SSIM / NCC が計算不能な場合は float("nan") を格納する．
    """
    if metrics_config is None or not metrics_config.shape_metrics:
        return {}

    result: dict = {}

    # 輝度重心
    centroid = calc_intensity_centroid(roi_luminance)
    result["intensity_centroid_x_px"] = centroid["centroid_x_px"]
    result["intensity_centroid_y_px"] = centroid["centroid_y_px"]
    result["intensity_centroid_x_norm"] = centroid["centroid_x_norm"]
    result["intensity_centroid_y_norm"] = centroid["centroid_y_norm"]
    result["intensity_centroid_offset_norm"] = centroid["centroid_offset_norm"]

    # 角度-放射分解
    ard = calc_angular_radial_decomposition(roi_luminance)
    result["angular_variance"] = ard["angular_variance"]
    result["radial_variance"] = ard["radial_variance"]
    result["angular_ratio"] = ard["angular_ratio"]

    # PCA 主軸
    pca = calc_intensity_pca(roi_luminance)
    result["pca_primary_axis_angle_deg"] = pca["primary_axis_angle_deg"]
    result["pca_eccentricity"] = pca["eccentricity"]

    # SSIM / NCC（shape_reference が指定されている場合のみ）
    reference_path_str = metrics_config.shape_reference
    if reference_path_str is not None:
        ref_path = Path(reference_path_str)
        if not ref_path.is_absolute():
            ref_path = config_dir / ref_path

        try:
            ref_luminance = _load_reference_luminance(ref_path)
            # shape が一致するか確認（一致しない場合は警告してスキップ）
            if ref_luminance.shape != roi_luminance.shape:
                print(
                    f"  警告: shape_reference の shape {ref_luminance.shape} が "
                    f"ROI の shape {roi_luminance.shape} と異なるため SSIM/NCC をスキップします．"
                )
                result["ssim"] = float("nan")
                result["ncc"] = float("nan")
            else:
                result["ssim"] = calc_ssim(roi_luminance, ref_luminance)
                result["ncc"] = calc_ncc(roi_luminance, ref_luminance)
        except FileNotFoundError:
            print(
                f"  警告: shape_reference ファイルが見つかりません: {ref_path}．"
                "SSIM/NCC をスキップします．"
            )
            result["ssim"] = float("nan")
            result["ncc"] = float("nan")
        except Exception as exc:  # noqa: BLE001
            print(
                f"  警告: shape_reference の読み込みに失敗しました — {exc}．"
                "SSIM/NCC をスキップします．"
            )
            result["ssim"] = float("nan")
            result["ncc"] = float("nan")
    else:
        result["ssim"] = float("nan")
        result["ncc"] = float("nan")

    return result


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}")

    if "radial_min_max_ratio" in metrics:
        print(f"  radial min/max : {metrics['radial_min_max_ratio']:.4f}")
        print(f"  radial min dist: {metrics['radial_min_distance']:.4f}")

    if "intensity_centroid_offset_norm" in metrics:
        print(f"  centroid offset: {metrics['intensity_centroid_offset_norm']:.4f} (norm)")
        print(
            f"  centroid (norm): x={metrics['intensity_centroid_x_norm']:.4f}"
            f"  y={metrics['intensity_centroid_y_norm']:.4f}"
        )
        ang_ratio = metrics.get("angular_ratio", float("nan"))
        if ang_ratio != ang_ratio:  # NaN check
            print("  angular_ratio  : NaN (uniform image)")
        else:
            print(f"  angular_ratio  : {ang_ratio:.4f} (0=concentric, 1=directional)")
        print(f"  pca axis [deg] : {metrics['pca_primary_axis_angle_deg']:.1f}")
        print(f"  pca eccentricit: {metrics['pca_eccentricity']:.4f}")
        ssim_val = metrics.get("ssim", float("nan"))
        ncc_val = metrics.get("ncc", float("nan"))
        if ssim_val == ssim_val:  # not NaN
            print(f"  SSIM           : {ssim_val:.4f}")
            print(f"  NCC            : {ncc_val:.4f}")

    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. normalization.enabled のとき linear mean anchor 正規化を適用する．
        7. oetf.enabled のとき sRGB OETF を適用する．
        8. calc_uniformity で均一性指標を算出する．
        9. zones.enabled のときゾーン分析を実行する．
        10. metrics.radial_profile のとき放射状プロファイルを算出する．
        11. metrics.radial_min_max のとき動径 min/max 比を算出する（radial_profile 再利用）．
        12. metrics.shape_metrics のとき形状指標を算出する
            （重心 / 角度-放射分解 / PCA / SSIM / NCC，metrics.csv に 12 列末尾追加）．
        13. metrics.csv を保存する．
        14. 可視化 PNG を figures/ に保存する．
        15. 算出指標のサマリーを標準出力に表示する．

    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

    # linear mean anchor 正規化（enabled のとき ROI 抽出後・OETF 前に挟む）．
    # OETF との併用禁止は EvaluationConfig の model_validator で保証されている．
    norm_cfg = eval_config.normalization
    if norm_cfg is not None and norm_cfg.enabled:
        try:
            roi_luminance = _apply_normalization_to_luminance(roi_luminance, norm_cfg)
        except ValueError as exc:
            print(f"エラー: linear 正規化に失敗しました — {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

    try:
        radial_min_max_stats = _run_radial_min_max(
            roi_luminance, eval_config.metrics, radial_profile
        )
        metrics.update(radial_min_max_stats)
    except Exception as exc:  # noqa: BLE001
        print(f"エラー: 動径 min/max 比の算出に失敗しました — {exc}", file=sys.stderr)
        return 1

    try:
        shape_stats = _run_shape_metrics(roi_luminance, eval_config.metrics, config_path.parent)
        metrics.update(shape_stats)
    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
