Newer
Older
SmTIAS-LightSim / src / sm_tias_lightsim / cli / evaluate.py
@rintoHasegawa rintoHasegawa on 1 Jun 19 KB [add] S3 Step D sm-evaluate に動径 min/max 比を統合 — S3 方針見直し: 「sm-evaluate に DNG 入力経路を追加」当初案は不採用 (実機 DNG 評価は MiniTias-Evaluation の本業で完結.SmTIAS-LightSim 側で重複実装する必要なし).S3 を 3 項目に絞り込み: Step D (sm-evaluate に動径 min/max 比統合) + Step C' (新規シミュ baseline config 作成) + Step G (シミュ再評価 → Step 8-13 として TEST_01 記録).本コミットは Step D.MetricsConfig.radial_min_max: bool = False をスキーマ追加 (schema.py),cli/evaluate.py で _run_radial_min_max() 関数追加・main() 内で算出ステップ統合・_print_summary に代表 2 行追加.radial_profile を再利用 (重複算出回避),未算出時 (radial_profile: false) は関数内部で calc_radial_profile を呼んで自力算出 → 独立有効化可能.metrics.radial_min_max: true 時に metrics.csv 末尾に 4 列 (radial_min_max_ratio / radial_min_distance / radial_min / radial_max) を追加,既存列順序は不変で後方互換維持.既定 False で既存 3 configs (eval_baseline.yaml / eval_baseline_linear.yaml / eval_real.yaml) は無修正で動作.新規テスト 39 件追加 (tests/scene/test_metrics_radial_min_max_schema.py 250 行: A1-A4 スキーマ仕様 + tests/cli/test_evaluate_radial_min_max.py 740 行: B1-B5 関数仕様 + C1-C5 CLI 統合 + D2 slow 統合).既存 569 + 新規 39 = 608 件全通過.Refactorer 判断: プロダクションコード変更なし (既存パターン一貫性あり),テストコードのみ軽微な lint 修正 (未使用 import 削除・I001/E501).SPEC_04 §動径 min/max 比 (オプション) セクション新規追加 (4 列出力・nan 除外・独立有効化の仕様明記.evaluation/radial.py:calc_radial_min_max_ratio は MiniTias-Evaluation src/analysis/spatial.py からのマージと明記).CLAUDE.md 進捗欄に S3 方針見直しと Step D 完了 (608 件全通過) を追記.handoff_smtias_lightsim/ ディレクトリは S3 全体完了後に削除予定で本コミットには含めない.次フェーズ: Step C' (configs/eval_baseline_dng_target.yaml 作成: 既存 eval_baseline_linear.yaml ベース,target_mean=207.82,radial_min_max=true) → Step G (Step 8-13 計測)
"""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.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 _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}")

    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.csv を保存する.
        13. 可視化 PNG を figures/ に保存する.
        14. 算出指標のサマリーを標準出力に表示する.

    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

    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