Newer
Older
SmTIAS-LightSim / src / sm_tias_lightsim / evaluation / pipeline.py
"""評価パイプラインの本体(sm-evaluate の処理を関数として提供する).

`evaluate_image()` が「画像読込 → 評価設定読込 → ROI 抽出 → 正規化 / OETF →
各種指標 → metrics.csv 保存 → 図保存」の一連の処理を行う.CLI(`cli/evaluate.py`)は
引数のパースと例外→終了コードの変換だけを担い,本モジュールを呼ぶ.

スイープスクリプトのようにプロセス内から繰り返し評価する用途では
`save_figures=False` を渡すと figures/ の描画(1 条件あたり約 1.8 秒)を丸ごと省ける.

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

from __future__ import annotations

from collections.abc import Iterator
from contextlib import contextmanager
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.fullimage import calc_fullimage_min_max_ratio  # 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"


class EvaluationStepError(RuntimeError):
    """評価パイプラインのどの段階で失敗したかを文言として保持する例外.

    CLI は `print(f"エラー: {exc}")` するだけで従来どおりの段階別メッセージを出せる.
    error-handling.md の「独自例外は最小限」に沿って階層は作らず,この 1 クラスのみとする
    (段階名を CLI 側で分岐せずにメッセージを保つための最小限の器).
    """


@contextmanager
def _step(
    label: str,
    catch: type[Exception] | tuple[type[Exception], ...] = Exception,
    keep_message: tuple[type[Exception], ...] = (),
) -> Iterator[None]:
    """処理段階の例外を「段階名 — 原因」の EvaluationStepError に包み直す文脈マネージャ.

    段階ごとに同じ try/except を並べる代わりに使う(段階が増えても書き方が揃う).

    Args:
        label: 失敗時に前置きする段階名(例: "ROI 抽出に失敗しました").
        catch: 包む例外の型.段階ごとに想定する型だけを指定すると,
            想定外の例外はそのまま送出される.
        keep_message: 例外のメッセージ自体が完結しているため段階名を前置きしない型
            (例: "画像ファイルが見つかりません: <path>" を持つ FileNotFoundError).

    Yields:
        None(処理本体を with ブロックに書く).

    Raises:
        EvaluationStepError: with ブロック内で catch / keep_message の例外が起きた場合.
    """
    try:
        yield
    except keep_message as exc:
        raise EvaluationStepError(str(exc)) from exc
    except catch as exc:
        msg = f"{label} — {exc}"
        raise EvaluationStepError(msg) from exc


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 _run_fullimage_min_max(
    roi_luminance: numpy.ndarray,
    metrics_config: MetricsConfig | None,
) -> dict:
    """画像全体のノイズ補正付き min/max 比を算出して返す.

    輪帯平均の min/max(`_run_radial_min_max`)は方位方向に平均するため,
    オフ軸 LED のような方位依存の不均一を打ち消してしまう.本指標は平滑化で
    MC ノイズだけを落とし,残るノイズの寄与を差し引いた min/max を返す.

    Args:
        roi_luminance: ROI 輝度配列(H x W, float64).
        metrics_config: MetricsConfig インスタンス(fullimage_min_max フラグと
            fullimage_sigma を持つ).None の場合は無効扱い.

    Returns:
        画像全体 min/max 比指標の辞書(fullimage_min_max_ratio / fullimage_min /
        fullimage_max / fullimage_noise_min / fullimage_noise_max の 5 キー).無効時は空辞書.
    """
    if metrics_config is None or not metrics_config.fullimage_min_max:
        return {}

    return calc_fullimage_min_max_ratio(roi_luminance, sigma=metrics_config.fullimage_sigma)


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 が指定されている場合のみ)
    result.update(_run_reference_metrics(roi_luminance, metrics_config.shape_reference, config_dir))
    return result


def _skipped_reference_metrics(reason: str) -> dict:
    """SSIM / NCC を算出できないときの戻り値(警告を出して NaN を返す).

    Args:
        reason: 警告として表示する理由(末尾に句点を含めない).

    Returns:
        ssim / ncc を float("nan") にした辞書.
    """
    print(f"  警告: {reason}.SSIM/NCC をスキップします.")
    return {"ssim": float("nan"), "ncc": float("nan")}


def _run_reference_metrics(
    roi_luminance: numpy.ndarray,
    reference_path_str: str | None,
    config_dir: Path,
) -> dict:
    """参照画像との SSIM / NCC を算出する(参照が使えないときは NaN).

    参照画像が読めない・ROI と shape が違うといった理由で算出できない場合は,
    警告を出して NaN を返す(指標の列構成を条件によらず一定に保つため).

    Args:
        roi_luminance: ROI 輝度配列(H x W, float64).
        reference_path_str: shape_reference のパス文字列.None なら参照なし(NaN).
        config_dir: 評価 YAML のディレクトリ(相対パス解決の基点).

    Returns:
        ssim / ncc の 2 キーを持つ辞書.
    """
    if reference_path_str is None:
        return {"ssim": float("nan"), "ncc": float("nan")}

    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:
            return _skipped_reference_metrics(
                f"shape_reference の shape {ref_luminance.shape} が "
                f"ROI の shape {roi_luminance.shape} と異なります"
            )
        return {
            "ssim": calc_ssim(roi_luminance, ref_luminance),
            "ncc": calc_ncc(roi_luminance, ref_luminance),
        }
    except FileNotFoundError:
        return _skipped_reference_metrics(f"shape_reference ファイルが見つかりません: {ref_path}")
    except Exception as exc:  # noqa: BLE001
        return _skipped_reference_metrics(f"shape_reference の読み込みに失敗しました — {exc}")


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) -> None:
    """評価サマリーを標準出力に表示する.

    Args:
        metrics: 指標名 → 値の辞書.
        csv_path: metrics.csv の保存先パス(表示用).
        figures_dir: figures/ ディレクトリのパス(表示用).
            図を保存しない場合は None(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 "fullimage_min_max_ratio" in metrics:
        print(f"  fullimage min/max: {metrics['fullimage_min_max_ratio']:.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}")
    if figures_dir is not None:
        print(f"figures/        : {figures_dir}")


def evaluate_image(
    image_path: Path,
    config_path: Path,
    output_dir: Path,
    save_figures: bool = True,
) -> dict:
    """画像 1 枚を評価し,metrics.csv(と図)を出力して指標の辞書を返す.

    処理フロー:
        1. 画像ファイルを読み込み,輝度配列を得る.
        2. YAML 設定ファイルを読み込み,EvaluationConfig を得る.
        3. 出力ディレクトリ(save_figures のとき figures/ も)を作成する.
        4. extract_roi で ROI を切り出す.
        5. normalization.enabled のとき linear mean anchor 正規化を適用する.
        6. oetf.enabled のとき sRGB OETF を適用する.
        7. calc_uniformity で均一性指標を算出する.
        8. zones.enabled のときゾーン分析を実行する.
        9. metrics.radial_profile のとき放射状プロファイルを算出する.
        10. metrics.radial_min_max のとき動径 min/max 比を算出する(radial_profile 再利用).
        11. metrics.fullimage_min_max のとき画像全体のノイズ補正付き min/max 比を算出する.
        12. metrics.shape_metrics のとき形状指標を算出する.
        13. metrics.csv を保存する.
        14. save_figures のとき可視化 PNG を figures/ に保存する.
        15. 算出指標のサマリーを標準出力に表示する.

    Args:
        image_path: 評価対象の画像ファイル(PNG または .npy).
        config_path: 評価設定 YAML(SceneConfig または EvaluationConfig 互換).
        output_dir: 出力先ディレクトリ(存在しなければ親ごと作成する).
        save_figures: False のとき figures/ を作らず図も保存しない
            (スイープ等で図が不要なときに描画時間を省くため).

    Returns:
        指標名 → 値の辞書(metrics.csv に書いた内容と同じ).

    Raises:
        EvaluationStepError: いずれかの段階で失敗した場合(失敗した段階を示す文言を持つ).
    """
    image_path = Path(image_path).resolve()
    config_path = Path(config_path).resolve()
    output_dir = Path(output_dir).resolve()

    with _step("画像の読み込みに失敗しました", keep_message=(FileNotFoundError,)):
        luminance = _load_luminance(image_path)

    with _step("設定 YAML の読み込みに失敗しました", keep_message=(FileNotFoundError,)):
        eval_config = _load_eval_config(config_path)

    figures_dir = output_dir / _FIGURES_DIRNAME if save_figures else None
    with _step("出力ディレクトリを作成できません", OSError):
        output_dir.mkdir(parents=True, exist_ok=True)
        if figures_dir is not None:
            figures_dir.mkdir(parents=True, exist_ok=True)

    with _step("ROI 抽出に失敗しました", (ValueError, NotImplementedError)):
        roi_luminance = extract_roi(luminance, eval_config.roi)

    # 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:
        with _step("linear 正規化に失敗しました", ValueError):
            roi_luminance = _apply_normalization_to_luminance(roi_luminance, norm_cfg)

    # 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:
        with _step("OETF 適用に失敗しました", ValueError):
            roi_luminance = _apply_oetf_to_luminance(roi_luminance, oetf_cfg)

    with _step("均一性指標の算出に失敗しました", ValueError):
        metrics: dict = dict(calc_uniformity(roi_luminance))

    with _step("ゾーン分析に失敗しました", ValueError):
        zone_map, zone_stats = _run_zone_analysis(roi_luminance, eval_config.zones)
        metrics.update(zone_stats)

    with _step("放射状プロファイルの算出に失敗しました"):
        radial_profile = _run_radial_profile(roi_luminance, eval_config.metrics)

    with _step("動径 min/max 比の算出に失敗しました"):
        metrics.update(
            _run_radial_min_max(roi_luminance, eval_config.metrics, radial_profile)
        )

    with _step("画像全体 min/max 比の算出に失敗しました"):
        metrics.update(_run_fullimage_min_max(roi_luminance, eval_config.metrics))

    with _step("形状指標の算出に失敗しました"):
        metrics.update(
            _run_shape_metrics(roi_luminance, eval_config.metrics, config_path.parent)
        )

    csv_path = output_dir / _METRICS_FILENAME
    with _step("metrics.csv の保存に失敗しました"):
        _save_metrics_csv(metrics, csv_path)

    if figures_dir is not None:
        with _step("可視化 PNG の保存に失敗しました"):
            _save_figures(roi_luminance, figures_dir, radial_profile, zone_map)

    _print_summary(metrics, csv_path, figures_dir)
    return metrics