diff --git a/CLAUDE.md b/CLAUDE.md index 5e36ff8..1adde03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,13 +5,15 @@ ## 開発進捗 -現在の進捗: フェーズ 7(実装)— Step 1〜5 完了. -次のアクション: `/implement Step 6: evaluate CLI + 可視化`([PLAN_02 参照](docs/03_PLAN/PLAN_02_開発ステップ.md)) +現在の進捗: フェーズ 7(実装)— Step 1〜6 完了. +次のアクション: `/implement Step 7: 基準設計のキャリブレーション`([PLAN_02 参照](docs/03_PLAN/PLAN_02_開発ステップ.md)) 実装済みモジュール: - `src/sm_tias_lightsim/uniformity.py` — Rec.709 輝度変換 + CoV/std/max-min(MiniTias-Evaluation コピー) - `src/sm_tias_lightsim/evaluation/zones.py` — 正規化楕円距離マップ + 3 ゾーン分類 + ゾーン別統計 - `src/sm_tias_lightsim/evaluation/radial.py` — 20 ビン放射状輝度プロファイル +- `src/sm_tias_lightsim/evaluation/roi.py` — `image_pixel` 型矩形 ROI 抽出(2D/3D 配列対応) +- `src/sm_tias_lightsim/evaluation/visualize.py` — matplotlib 可視化(輝度マップ / ヒストグラム / 放射プロファイル / ゾーンマップ) - `src/sm_tias_lightsim/scene/schema.py` — Pydantic ベース YAML スキーマ + ローダ - `src/sm_tias_lightsim/geometry/loader.py` — STL 読込(trimesh)+ `LoadedMesh` データクラス - `src/sm_tias_lightsim/geometry/transforms.py` — mm⇄m 単位スケール変換 @@ -20,6 +22,7 @@ - `src/sm_tias_lightsim/render/runner.py` — variant 管理 + `mi.render` 実行 - `src/sm_tias_lightsim/render/output.py` — PNG / luminance.npy 出力 - `src/sm_tias_lightsim/cli/render.py` — `sm-render` CLI(YAML → PNG / luminance.npy) +- `src/sm_tias_lightsim/cli/evaluate.py` — `sm-evaluate` CLI(PNG / luminance.npy → metrics.csv + figures/) ※ ステップ完了時にここを更新すること. ## 必須ルール(コード実装時) diff --git a/pyproject.toml b/pyproject.toml index 0215d06..aa16c98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ [project.scripts] sm-tias-lightsim = "sm_tias_lightsim:main" sm-render = "sm_tias_lightsim.cli.render:main" +sm-evaluate = "sm_tias_lightsim.cli.evaluate:main" [build-system] requires = ["uv_build>=0.11.7,<0.12.0"] diff --git a/src/sm_tias_lightsim/cli/evaluate.py b/src/sm_tias_lightsim/cli/evaluate.py new file mode 100644 index 0000000..565964a --- /dev/null +++ b/src/sm_tias_lightsim/cli/evaluate.py @@ -0,0 +1,364 @@ +"""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.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 # 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 _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) + + +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 + + 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 diff --git a/src/sm_tias_lightsim/evaluation/roi.py b/src/sm_tias_lightsim/evaluation/roi.py new file mode 100644 index 0000000..eb9117c --- /dev/null +++ b/src/sm_tias_lightsim/evaluation/roi.py @@ -0,0 +1,57 @@ +"""ROI(関心領域)抽出モジュール. + +SPEC_02 の evaluation.roi 仕様に基づき,輝度画像または RGB 画像から +矩形 ROI を切り出す.現在は image_pixel 型のみ対応. +""" + +from __future__ import annotations + +import numpy + +from sm_tias_lightsim.scene.schema import RoiConfig + + +def extract_roi(image: numpy.ndarray, roi: RoiConfig) -> numpy.ndarray: + """画像から ROI(関心領域)を矩形クロップで抽出する. + + image_pixel 型の ROI 設定に従い,`roi.x`, `roi.y`, `roi.width`, `roi.height` で + 矩形領域を切り出す.2D(輝度)・3D(RGB)どちらの配列も受け付ける. + + Args: + image: 輝度画像(H x W, float64)または RGB 画像(H x W x 3). + 先頭 2 軸が H, W であること. + roi: ROI 設定(RoiConfig).type は "image_pixel" のみ対応. + + Returns: + ROI でクロップされた配列.入力と同じ次元数を持つ. + + Raises: + NotImplementedError: roi.type が "image_pixel" 以外の場合. + ValueError: ROI が画像の範囲外にはみ出す場合. + """ + # scene_face 等の将来型は NotImplementedError で明示的に未対応を伝える(SPEC_02 参照) + if roi.type != "image_pixel": + msg = f"ROI type '{roi.type}' は未対応です.現在は 'image_pixel' のみサポートしています." + raise NotImplementedError(msg) + + img_height, img_width = image.shape[:2] + + x_end = roi.x + roi.width + y_end = roi.y + roi.height + + if roi.x < 0 or roi.y < 0: + msg = ( + f"ROI の開始座標 (x={roi.x}, y={roi.y}) が負の値です." + "ROI 設定を確認してください." + ) + raise ValueError(msg) + + if x_end > img_width or y_end > img_height: + msg = ( + f"ROI (x={roi.x}, y={roi.y}, width={roi.width}, height={roi.height}) が " + f"画像サイズ (width={img_width}, height={img_height}) を超えています." + "ROI 設定を確認してください." + ) + raise ValueError(msg) + + return image[roi.y : y_end, roi.x : x_end] diff --git a/src/sm_tias_lightsim/evaluation/visualize.py b/src/sm_tias_lightsim/evaluation/visualize.py new file mode 100644 index 0000000..7b5c3a5 --- /dev/null +++ b/src/sm_tias_lightsim/evaluation/visualize.py @@ -0,0 +1,136 @@ +"""可視化モジュール. + +matplotlib を使って輝度マップ・ヒストグラム・放射状プロファイル・ゾーンマップを +PNG として保存する.画面表示は行わない(Agg バックエンド前提). + +各関数は figure を生成 → savefig → plt.close(fig) の形式で実装し, +メモリリークを防ぐ. +""" + +from __future__ import annotations + +from pathlib import Path + +import matplotlib + +# headless 環境(サーバ・CI 等)でも動くよう Agg バックエンドを強制する. +# pyplot を import する前に設定しないと既存バックエンドが優先されてしまうため, +# このモジュール内では matplotlib.use() を pyplot import より前に置く. +matplotlib.use("Agg") + +import matplotlib.patches # noqa: E402 +import matplotlib.pyplot as plt # noqa: E402 +import numpy # noqa: E402 + +# ゾーンラベルと RGB 値(classify_zones の 0=center/1=middle/2=periphery に対応) +_ZONE_LABELS = ["center", "middle", "periphery"] +_ZONE_COLORS_HEX = ["#e74c3c", "#f39c12", "#2ecc71"] +# hex カラーを [0, 1] RGB タプルに変換しておく(save_zone_map 呼び出しごとの変換を避けるため) +_ZONE_COLORS_RGB: list[tuple[float, float, float]] = [ + (int(h[1:3], 16) / 255.0, int(h[3:5], 16) / 255.0, int(h[5:7], 16) / 255.0) + for h in _ZONE_COLORS_HEX +] + + +def _save_figure(fig: matplotlib.figure.Figure, output_path: Path, dpi: int = 150) -> None: + """figure を PNG として保存してメモリを解放する. + + tight_layout → savefig → plt.close の順序を統一するためのヘルパ. + + Args: + fig: 保存対象の Figure. + output_path: 保存先 PNG パス. + dpi: 解像度(デフォルト 150). + """ + fig.tight_layout() + fig.savefig(output_path, dpi=dpi) + plt.close(fig) + + +def save_luminance_map(luminance: numpy.ndarray, output_path: Path) -> None: + """輝度マップを PNG として保存する. + + Args: + luminance: 輝度画像(H x W, float). + output_path: 保存先 PNG パス. + """ + fig, ax = plt.subplots(figsize=(6, 5)) + im = ax.imshow(luminance, cmap="viridis", origin="upper") + fig.colorbar(im, ax=ax, label="Luminance") + ax.set_title("Luminance Map") + ax.set_xlabel("X [px]") + ax.set_ylabel("Y [px]") + _save_figure(fig, output_path) + + +def save_histogram(luminance: numpy.ndarray, output_path: Path, bins: int = 50) -> None: + """輝度ヒストグラムを PNG として保存する. + + Args: + luminance: 輝度画像(H x W, float). + output_path: 保存先 PNG パス. + bins: ヒストグラムのビン数.デフォルトは 50. + """ + fig, ax = plt.subplots(figsize=(6, 4)) + ax.hist(luminance.ravel(), bins=bins, color="#3498db", edgecolor="none", alpha=0.8) + ax.set_title("Luminance Histogram") + ax.set_xlabel("Luminance") + ax.set_ylabel("Pixel Count") + _save_figure(fig, output_path) + + +def save_radial_profile(profile: numpy.ndarray, output_path: Path) -> None: + """放射状輝度プロファイルを線グラフとして PNG に保存する. + + `evaluation/radial.calc_radial_profile` が返す (n_bins, 2) 配列を受け取る. + + Args: + profile: 放射状プロファイル配列(shape: (n_bins, 2)). + 列 0: 正規化距離(ビン中心),列 1: 平均輝度.空ビンは nan. + output_path: 保存先 PNG パス. + """ + distances = profile[:, 0] + mean_luminance = profile[:, 1] + + fig, ax = plt.subplots(figsize=(6, 4)) + ax.plot(distances, mean_luminance, marker="o", markersize=3, linewidth=1.5, color="#2c3e50") + ax.set_title("Radial Luminance Profile") + ax.set_xlabel("Normalized Distance from Center") + ax.set_ylabel("Mean Luminance") + ax.set_xlim(0.0, 1.0) + ax.grid(True, linestyle="--", alpha=0.5) + _save_figure(fig, output_path) + + +def save_zone_map(zone_map: numpy.ndarray, output_path: Path) -> None: + """ゾーンマップを 3 色で PNG として保存する. + + `evaluation/zones.classify_zones` が返す int8 配列(0=center, 1=middle, 2=periphery) + を 3 色に着色して凡例付きで保存する. + + Args: + zone_map: ゾーンラベルマップ(shape: (H, W), dtype: int8). + 値は 0(center), 1(middle), 2(periphery). + output_path: 保存先 PNG パス. + """ + height, width = zone_map.shape + rgb = numpy.zeros((height, width, 3), dtype=numpy.float32) + + for label, color_rgb in enumerate(_ZONE_COLORS_RGB): + mask = zone_map == label + rgb[mask] = color_rgb + + fig, ax = plt.subplots(figsize=(6, 5)) + ax.imshow(rgb, origin="upper") + ax.set_title("Zone Map") + ax.set_xlabel("X [px]") + ax.set_ylabel("Y [px]") + + # imshow は自動凡例を持たないため,凡例パッチを手動で生成する + legend_patches = [ + matplotlib.patches.Patch(color=_ZONE_COLORS_HEX[i], label=_ZONE_LABELS[i]) + for i in range(len(_ZONE_LABELS)) + ] + ax.legend(handles=legend_patches, loc="upper right") + + _save_figure(fig, output_path) diff --git a/src/sm_tias_lightsim/scene/__init__.py b/src/sm_tias_lightsim/scene/__init__.py index d0e284c..c885722 100644 --- a/src/sm_tias_lightsim/scene/__init__.py +++ b/src/sm_tias_lightsim/scene/__init__.py @@ -1,9 +1,13 @@ -"""scene パッケージ — YAML スキーマ定義・シーン構築モジュール群.""" +"""scene パッケージ — YAML スキーマ定義・シーン構築モジュール群. -from sm_tias_lightsim.scene.builder import build_scene +build_scene は Mitsuba を import するため,スキーマだけ必要な場面(評価 CLI 等)で +不要に Mitsuba をロードしないよう,パッケージ直下では再エクスポートしない. +利用側は from sm_tias_lightsim.scene.builder import build_scene と直接 import すること. +""" + from sm_tias_lightsim.scene.schema import ( SceneConfig, load_scene_yaml, ) -__all__ = ["SceneConfig", "build_scene", "load_scene_yaml"] +__all__ = ["SceneConfig", "load_scene_yaml"] diff --git a/tests/cli/test_evaluate.py b/tests/cli/test_evaluate.py new file mode 100644 index 0000000..7258c7b --- /dev/null +++ b/tests/cli/test_evaluate.py @@ -0,0 +1,629 @@ +"""cli/evaluate.py の仕様ベーステスト(統合テスト). + +仕様出典: +- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(evaluate パイプライン・CLI エントリポイント) +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(evaluation セクション) +- docs/03_PLAN/PLAN_02_開発ステップ.md(Step 6 完了条件) +- docs/01_GUIDE/GUIDE_08_テスト方針.md(テスト方針) + +Step 6 完了条件: + シミュ PNG と実測 PNG の両方で同じ指標 CSV が出ること + metrics.csv + figures/{luminance_map, histogram, radial_profile, zone_map}.png が出力される + +テスト方針: + - subprocess ではなく main() を直接呼ぶ(速度・カバレッジのため) + - ファイル I/O はモックせず tmp_path フィクスチャで実ファイルを使う (GUIDE_08) + - 乱数を使う場合は seed を必ず固定する (GUIDE_08) + +注意: + matplotlib の Agg バックエンドは evaluate.py 内部で設定される. + テスト側でも安全のため import 前に設定する. +""" + +from __future__ import annotations + +import matplotlib # noqa: I001 + +# headless 環境でも動くよう Agg バックエンドを pyplot より前に設定する +matplotlib.use("Agg") + +from pathlib import Path + +import numpy +import pytest + +from sm_tias_lightsim.cli.evaluate import main + +# --------------------------------------------------------------------------- +# テスト用定数 +# --------------------------------------------------------------------------- + +#: tests/fixtures/eval_smoke.yaml の絶対パス +EVAL_SMOKE_YAML = Path(__file__).parent.parent / "fixtures" / "eval_smoke.yaml" + +# --------------------------------------------------------------------------- +# テスト用ヘルパー +# --------------------------------------------------------------------------- + + +def _make_luminance_array(seed: int = 0, size: int = 256) -> numpy.ndarray: + """ゼロを含まない合成輝度配列を返す(seed 固定). + + max/min 計算が走るようゼロを含まない値にする (GUIDE_08 補助ガイダンス) + """ + rng = numpy.random.RandomState(seed) + return numpy.full((size, size), 100.0) + rng.normal(0, 5, (size, size)) + + +def _make_eval_yaml( + tmp_path: Path, + x: int = 32, + y: int = 32, + width: int = 192, + height: int = 192, + zones_enabled: bool = True, + radial_profile: bool = True, +) -> Path: + """評価専用 YAML(トップレベルが EvaluationConfig 互換)を tmp_path に書き出す. + + 仕様: evaluation キーなし → トップレベルを EvaluationConfig として検証する + (cli/evaluate.py _load_eval_config) + """ + content = f"""\ +roi: + type: image_pixel + x: {x} + y: {y} + width: {width} + height: {height} +zones: + enabled: {'true' if zones_enabled else 'false'} + boundaries: [0.33, 0.66] +metrics: + cov: true + std: true + max_min: true + radial_profile: {'true' if radial_profile else 'false'} +""" + yaml_path = tmp_path / "eval_config.yaml" + yaml_path.write_text(content, encoding="utf-8") + return yaml_path + + +def _run_main(npy_path: Path, config_path: Path, output_dir: Path) -> int: + """main() を呼ぶ共通ラッパー(行長削減のため).""" + return main( + ["--image", str(npy_path), "--config", str(config_path), "--output", str(output_dir)] + ) + + +# --------------------------------------------------------------------------- +# 1. 正常系 — .npy 2D 入力 + 評価専用 YAML +# --------------------------------------------------------------------------- + + +def test_npy_2d_returns_0(tmp_path: Path): + """2D .npy 輝度配列を入力として main() が 0 を返すこと. + + 仕様: .npy は 2D ならそのまま使用(cli/evaluate.py _load_luminance) + """ + luminance = _make_luminance_array(seed=0) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + config_path = _make_eval_yaml(tmp_path) + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + + +def test_npy_2d_creates_metrics_csv(tmp_path: Path): + """2D .npy 入力後に metrics.csv が生成されること. + + 仕様: 出力: output_dir/metrics.csv(cli/evaluate.py 処理フロー ステップ 8) + """ + luminance = _make_luminance_array(seed=0) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + config_path = _make_eval_yaml(tmp_path) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + assert (output_dir / "metrics.csv").exists() + + +def test_npy_2d_metrics_csv_contains_expected_columns(tmp_path: Path): + """metrics.csv に必須列が含まれること. + + 仕様: + - 基本指標: cov, std, max_min_ratio, mean, max, min (uniformity.py calc_uniformity) + - ゾーン指標: center_mean, center_std, middle_mean, middle_std, + periphery_mean, periphery_std, center_periphery_ratio, gradient_magnitude + (zones.py calc_zone_stats) + """ + import pandas + + luminance = _make_luminance_array(seed=1) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + config_path = _make_eval_yaml(tmp_path, zones_enabled=True) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + + df = pandas.read_csv(output_dir / "metrics.csv") + expected_cols = { + "cov", "std", "max_min_ratio", "mean", "max", "min", + "center_mean", "center_std", "middle_mean", "middle_std", + "periphery_mean", "periphery_std", "center_periphery_ratio", "gradient_magnitude", + } + assert expected_cols.issubset(set(df.columns)) + + +def test_npy_2d_creates_figures(tmp_path: Path): + """2D .npy 入力後に figures/ 内の 4 つの PNG が生成されること. + + 仕様: output_dir/figures/{luminance_map, histogram, radial_profile, zone_map}.png + (SPEC_01 全体パイプライン) + """ + luminance = _make_luminance_array(seed=2) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + config_path = _make_eval_yaml(tmp_path, zones_enabled=True, radial_profile=True) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + + figures_dir = output_dir / "figures" + assert (figures_dir / "luminance_map.png").exists() + assert (figures_dir / "histogram.png").exists() + assert (figures_dir / "radial_profile.png").exists() + assert (figures_dir / "zone_map.png").exists() + + +# --------------------------------------------------------------------------- +# 2. 正常系 — PNG 入力 +# --------------------------------------------------------------------------- + + +def test_png_input_returns_0(tmp_path: Path): + """合成 RGB PNG を入力として main() が 0 を返すこと. + + 仕様: PNG は PIL で RGB 読み込み → to_grayscale 経由(cli/evaluate.py _load_luminance) + """ + from PIL import Image + + rng = numpy.random.RandomState(3) + rgb = (numpy.full((256, 256, 3), 100) + rng.randint(0, 30, (256, 256, 3))).astype(numpy.uint8) + pil_img = Image.fromarray(rgb, mode="RGB") + png_path = tmp_path / "render.png" + pil_img.save(png_path) + + config_path = _make_eval_yaml(tmp_path) + output_dir = tmp_path / "output" + result = main( + ["--image", str(png_path), "--config", str(config_path), "--output", str(output_dir)] + ) + assert result == 0 + + +def test_png_input_creates_metrics_csv(tmp_path: Path): + """PNG 入力後に metrics.csv が生成されること.""" + from PIL import Image + + rng = numpy.random.RandomState(4) + rgb = (numpy.full((256, 256, 3), 100) + rng.randint(0, 30, (256, 256, 3))).astype(numpy.uint8) + pil_img = Image.fromarray(rgb, mode="RGB") + png_path = tmp_path / "render.png" + pil_img.save(png_path) + + config_path = _make_eval_yaml(tmp_path) + output_dir = tmp_path / "output" + main( + ["--image", str(png_path), "--config", str(config_path), "--output", str(output_dir)] + ) + assert (output_dir / "metrics.csv").exists() + + +# --------------------------------------------------------------------------- +# 3. 正常系 — SceneConfig 形式 YAML(evaluation サブツリー) +# --------------------------------------------------------------------------- + + +def test_scene_config_yaml_with_evaluation_key_returns_0(tmp_path: Path): + """evaluation キーを持つ YAML(SceneConfig 形式)でも成功すること. + + 仕様: "evaluation" キーがあれば SceneConfig 埋め込みとみなし, + data["evaluation"] を EvaluationConfig として検証する + (cli/evaluate.py _load_eval_config) + 注意: SceneConfig の他フィールドは無視されるため,evaluation キーだけあれば十分 + """ + luminance = _make_luminance_array(seed=5) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + # evaluation キーを持つ YAML(他フィールドはダミー) + yaml_content = """\ +# SceneConfig 形式: evaluation サブツリーを持つダミー YAML +some_other_key: dummy_value +evaluation: + roi: + type: image_pixel + x: 32 + y: 32 + width: 192 + height: 192 + zones: + enabled: true + boundaries: [0.33, 0.66] + metrics: + cov: true + std: true + max_min: true + radial_profile: true +""" + config_path = tmp_path / "scene_with_eval.yaml" + config_path.write_text(yaml_content, encoding="utf-8") + + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + assert (output_dir / "metrics.csv").exists() + + +# --------------------------------------------------------------------------- +# 4. 正常系 — .npy 3D(RGB)入力 +# --------------------------------------------------------------------------- + + +def test_npy_3d_rgb_returns_0(tmp_path: Path): + """3D の .npy(H, W, 3)を渡すと to_grayscale で 2D 化されて成功すること. + + 仕様: .npy が 3D(H, W, 3)だった場合も to_grayscale を通す + (cli/evaluate.py _load_luminance) + """ + rng = numpy.random.RandomState(6) + rgb = numpy.full((256, 256, 3), 100.0) + rng.normal(0, 5, (256, 256, 3)) + npy_path = tmp_path / "luminance_rgb.npy" + numpy.save(npy_path, rgb) + + config_path = _make_eval_yaml(tmp_path) + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + + +def test_npy_3d_rgb_creates_metrics_csv(tmp_path: Path): + """3D .npy 入力後に metrics.csv が生成されること.""" + rng = numpy.random.RandomState(7) + rgb = numpy.full((256, 256, 3), 100.0) + rng.normal(0, 5, (256, 256, 3)) + npy_path = tmp_path / "luminance_rgb.npy" + numpy.save(npy_path, rgb) + + config_path = _make_eval_yaml(tmp_path) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + assert (output_dir / "metrics.csv").exists() + + +# --------------------------------------------------------------------------- +# 5. 正常系 — zones.enabled = false +# --------------------------------------------------------------------------- + + +def test_zones_disabled_csv_no_zone_columns(tmp_path: Path): + """zones.enabled=false の場合,metrics.csv に center_mean 等のカラムが含まれないこと. + + 仕様: zones.enabled のときゾーン分析を実行する(cli/evaluate.py 処理フロー ステップ 6) + """ + import pandas + + luminance = _make_luminance_array(seed=8) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + config_path = _make_eval_yaml(tmp_path, zones_enabled=False) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + + df = pandas.read_csv(output_dir / "metrics.csv") + assert "center_mean" not in df.columns + assert "gradient_magnitude" not in df.columns + + +def test_zones_disabled_no_zone_map_png(tmp_path: Path): + """zones.enabled=false の場合,zone_map.png が生成されないこと. + + 仕様: zone_map が None のとき save_zone_map は呼ばれない(cli/evaluate.py 処理フロー) + """ + luminance = _make_luminance_array(seed=9) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + config_path = _make_eval_yaml(tmp_path, zones_enabled=False) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + + assert not (output_dir / "figures" / "zone_map.png").exists() + + +# --------------------------------------------------------------------------- +# 6. 正常系 — metrics.radial_profile = false +# --------------------------------------------------------------------------- + + +def test_radial_profile_disabled_no_png(tmp_path: Path): + """metrics.radial_profile=false の場合,radial_profile.png が生成されないこと. + + 仕様: metrics.radial_profile が有効のとき放射状プロファイルを算出する + (cli/evaluate.py 処理フロー ステップ 7) + """ + luminance = _make_luminance_array(seed=10) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + config_path = _make_eval_yaml(tmp_path, radial_profile=False) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + + assert not (output_dir / "figures" / "radial_profile.png").exists() + + +# --------------------------------------------------------------------------- +# 7. 正常系 — eval_smoke.yaml フィクスチャを使ったスモークテスト +# --------------------------------------------------------------------------- + + +def test_eval_smoke_yaml_fixture_returns_0(tmp_path: Path): + """tests/fixtures/eval_smoke.yaml を使って main() が 0 を返すこと. + + 仕様: eval_smoke.yaml(評価専用,evaluation キーなし,トップレベルが EvaluationConfig) + """ + # eval_smoke.yaml の ROI: x=112, y=112, width=32, height=32 + # → 画像は 144x144 以上必要 + luminance = _make_luminance_array(seed=99, size=256) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + output_dir = tmp_path / "output" + result = main( + [ + "--image", str(npy_path), + "--config", str(EVAL_SMOKE_YAML), + "--output", str(output_dir), + ] + ) + assert result == 0 + + +# --------------------------------------------------------------------------- +# 8. 異常系 — 画像ファイルが存在しない +# --------------------------------------------------------------------------- + + +def test_nonexistent_image_returns_1(tmp_path: Path): + """存在しない画像ファイルを渡すと main() が 1 を返すこと. + + 仕様: FileNotFoundError 捕捉 → return 1(cli/evaluate.py 処理フロー ステップ 1) + """ + config_path = _make_eval_yaml(tmp_path) + output_dir = tmp_path / "output" + result = main( + [ + "--image", str(tmp_path / "nonexistent.npy"), + "--config", str(config_path), + "--output", str(output_dir), + ] + ) + assert result == 1 + + +def test_nonexistent_image_stderr_message(tmp_path: Path, capsys): + """存在しない画像ファイルのエラーメッセージが stderr に出ること.""" + config_path = _make_eval_yaml(tmp_path) + output_dir = tmp_path / "output" + main( + [ + "--image", str(tmp_path / "nonexistent.npy"), + "--config", str(config_path), + "--output", str(output_dir), + ] + ) + captured = capsys.readouterr() + assert "エラー" in captured.err + + +# --------------------------------------------------------------------------- +# 9. 異常系 — YAML ファイルが存在しない +# --------------------------------------------------------------------------- + + +def test_nonexistent_config_returns_1(tmp_path: Path): + """存在しない YAML ファイルを渡すと main() が 1 を返すこと. + + 仕様: FileNotFoundError 捕捉 → return 1(cli/evaluate.py 処理フロー ステップ 2) + """ + luminance = _make_luminance_array(seed=11) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + output_dir = tmp_path / "output" + result = main( + [ + "--image", str(npy_path), + "--config", str(tmp_path / "nonexistent.yaml"), + "--output", str(output_dir), + ] + ) + assert result == 1 + + +# --------------------------------------------------------------------------- +# 10. 異常系 — YAML が EvaluationConfig として不正(roi が無い) +# --------------------------------------------------------------------------- + + +def test_invalid_config_no_roi_returns_1(tmp_path: Path): + """roi フィールドが欠落した YAML を渡すと main() が 1 を返すこと. + + 仕様: Pydantic ValidationError → return 1(cli/evaluate.py 処理フロー ステップ 2) + """ + luminance = _make_luminance_array(seed=12) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + # roi フィールドが無い不正 YAML + invalid_content = """\ +zones: + enabled: true + boundaries: [0.33, 0.66] +""" + config_path = tmp_path / "invalid_eval.yaml" + config_path.write_text(invalid_content, encoding="utf-8") + + output_dir = tmp_path / "output" + result = main( + [ + "--image", str(npy_path), + "--config", str(config_path), + "--output", str(output_dir), + ] + ) + assert result == 1 + + +# --------------------------------------------------------------------------- +# 11. 異常系 — ROI が画像範囲外 +# --------------------------------------------------------------------------- + + +def test_roi_out_of_bounds_returns_1(tmp_path: Path): + """ROI が画像範囲外のとき main() が 1 を返すこと. + + 仕様: extract_roi が ValueError → return 1(cli/evaluate.py 処理フロー ステップ 4) + """ + # 小さい画像(64x64)だが ROI は x=200, y=200 → 範囲外 + luminance = _make_luminance_array(seed=13, size=64) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + out_of_bounds_content = """\ +roi: + type: image_pixel + x: 200 + y: 200 + width: 50 + height: 50 +""" + config_path = tmp_path / "out_of_bounds.yaml" + config_path.write_text(out_of_bounds_content, encoding="utf-8") + + output_dir = tmp_path / "output" + result = main( + [ + "--image", str(npy_path), + "--config", str(config_path), + "--output", str(output_dir), + ] + ) + assert result == 1 + + +# --------------------------------------------------------------------------- +# 12. 異常系 — 輝度に 0 が含まれて max/min が計算不能 +# --------------------------------------------------------------------------- + + +def test_zero_luminance_returns_1(tmp_path: Path): + """輝度に 0 が含まれる場合,calc_uniformity が ValueError を投げて main() が 1 を返すこと. + + 仕様: 輝度の最小値が 0 の場合は ValueError(uniformity.py calc_uniformity) + → return 1(cli/evaluate.py 処理フロー ステップ 5) + """ + # 全画素を 0 にする(min == 0 → ValueError) + luminance = numpy.zeros((256, 256), dtype=numpy.float64) + npy_path = tmp_path / "zero_luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml(tmp_path) + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 1 + + +# --------------------------------------------------------------------------- +# 13. 異常系 — 必須引数欠落(argparse が SystemExit を投げる) +# --------------------------------------------------------------------------- + + +def test_missing_image_arg_exits(): + """--image を渡さないと SystemExit が発生すること. + + 仕様: --image は必須引数(cli/evaluate.py _parse_args, required=True) + argparse の required=True は SystemExit を発生させる. + """ + with pytest.raises(SystemExit) as exc_info: + main(["--config", "dummy.yaml", "--output", "/tmp/out"]) + assert exc_info.value.code != 0 + + +def test_missing_config_arg_exits(): + """--config を渡さないと SystemExit が発生すること.""" + with pytest.raises(SystemExit) as exc_info: + main(["--image", "dummy.npy", "--output", "/tmp/out"]) + assert exc_info.value.code != 0 + + +def test_missing_output_arg_exits(): + """--output を渡さないと SystemExit が発生すること.""" + with pytest.raises(SystemExit) as exc_info: + main(["--image", "dummy.npy", "--config", "dummy.yaml"]) + assert exc_info.value.code != 0 + + +def test_no_args_exits(): + """引数なしで呼ぶと SystemExit が発生すること.""" + with pytest.raises(SystemExit) as exc_info: + main([]) + assert exc_info.value.code != 0 + + +# --------------------------------------------------------------------------- +# 14. 短縮引数 -i / -c / -o +# --------------------------------------------------------------------------- + + +def test_short_args_work(tmp_path: Path): + """-i / -c / -o の短縮形でも --image / --config / --output と同等に動くこと. + + 仕様: --image/-i, --config/-c, --output/-o の短縮形を提供する + (cli/evaluate.py _parse_args) + """ + luminance = _make_luminance_array(seed=14) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + config_path = _make_eval_yaml(tmp_path) + output_dir = tmp_path / "output" + result = main(["-i", str(npy_path), "-c", str(config_path), "-o", str(output_dir)]) + assert result == 0 + + +# --------------------------------------------------------------------------- +# 15. 出力ディレクトリの自動作成 +# --------------------------------------------------------------------------- + + +def test_deep_nested_output_dir_created(tmp_path: Path): + """存在しない深いネストのディレクトリも自動作成されること. + + 仕様: output_dir.mkdir(parents=True, exist_ok=True) で親ごと作成する + (cli/evaluate.py 処理フロー ステップ 3) + """ + luminance = _make_luminance_array(seed=15) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + config_path = _make_eval_yaml(tmp_path) + + deep_output = tmp_path / "deep" / "nested" / "out" + assert not deep_output.exists() + result = main( + [ + "--image", str(npy_path), + "--config", str(config_path), + "--output", str(deep_output), + ] + ) + assert result == 0 + assert deep_output.exists() + assert (deep_output / "metrics.csv").exists() diff --git a/tests/evaluation/test_roi.py b/tests/evaluation/test_roi.py new file mode 100644 index 0000000..18f7cd6 --- /dev/null +++ b/tests/evaluation/test_roi.py @@ -0,0 +1,218 @@ +"""evaluation/roi.py の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(evaluation/roi 層の責務) +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(evaluation.roi スキーマ) +- docs/01_GUIDE/GUIDE_08_テスト方針.md(テスト方針) + +evaluation/roi の責務: + 画像 + ROI 定義 → ROI 内の輝度配列 + image_pixel / scene_face 両対応(現在は image_pixel のみ実装) + +テスト方針: + - RoiConfig を直接構築して extract_roi の振る舞いを検証する + - ファイル I/O はモックせず実 ndarray を使う (GUIDE_08) + - numpy.testing.assert_array_equal で配列値の一致を確認する +""" + +from __future__ import annotations + +import numpy +import numpy.testing +import pytest + +from sm_tias_lightsim.evaluation.roi import extract_roi +from sm_tias_lightsim.scene.schema import RoiConfig # noqa: I001 + +# --------------------------------------------------------------------------- +# テスト用ヘルパー +# --------------------------------------------------------------------------- + + +def _make_roi(x: int, y: int, width: int, height: int) -> RoiConfig: + """テスト用 RoiConfig を構築するヘルパー(image_pixel 固定).""" + return RoiConfig(type="image_pixel", x=x, y=y, width=width, height=height) + + +# --------------------------------------------------------------------------- +# 1. 2D 入力(輝度配列) +# --------------------------------------------------------------------------- + + +def test_extract_roi_2d_shape(): + """2D 輝度配列から切り出した ROI の shape が (height, width) になること. + + 仕様: 画像 + ROI 定義 → ROI 内の輝度配列(SPEC_01 evaluation/roi 責務) + 入力 100x100,中央 ROI (x=20, y=20, w=40, h=40) → shape (40, 40) + """ + image = numpy.zeros((100, 100), dtype=numpy.float64) + roi = _make_roi(x=20, y=20, width=40, height=40) + result = extract_roi(image, roi) + assert result.shape == (40, 40) + + +def test_extract_roi_2d_values_match(): + """2D 配列の ROI 切り出し値が元の配列の対応領域と一致すること. + + 仕様: ROI クロップは元配列の対応領域と値が同一でなければならない + """ + rng = numpy.random.RandomState(42) + image = rng.uniform(1.0, 200.0, (100, 100)).astype(numpy.float64) + roi = _make_roi(x=20, y=20, width=40, height=40) + result = extract_roi(image, roi) + expected = image[20:60, 20:60] + numpy.testing.assert_array_equal(result, expected) + + +# --------------------------------------------------------------------------- +# 2. 3D 入力(RGB 配列) +# --------------------------------------------------------------------------- + + +def test_extract_roi_3d_shape(): + """3D RGB 配列から切り出した ROI の shape が (height, width, 3) になること. + + 仕様: 先頭 2 軸が H, W であること(evaluation/roi.py extract_roi docstring) + """ + image = numpy.zeros((100, 100, 3), dtype=numpy.uint8) + roi = _make_roi(x=20, y=20, width=40, height=40) + result = extract_roi(image, roi) + assert result.shape == (40, 40, 3) + + +def test_extract_roi_3d_values_match(): + """3D RGB 配列の ROI 切り出し値が元の配列の対応領域と一致すること.""" + rng = numpy.random.RandomState(0) + image = rng.randint(0, 256, (100, 100, 3), dtype=numpy.uint8) + roi = _make_roi(x=20, y=20, width=40, height=40) + result = extract_roi(image, roi) + expected = image[20:60, 20:60, :] + numpy.testing.assert_array_equal(result, expected) + + +# --------------------------------------------------------------------------- +# 3. 境界ケース(ぴったり端) +# --------------------------------------------------------------------------- + + +def test_extract_roi_2d_exact_boundary(): + """x+width == img_width かつ y+height == img_height でエラーにならないこと. + + 仕様: 画像範囲内であれば ROI 抽出は成功しなければならない + """ + image = numpy.ones((100, 100), dtype=numpy.float64) + roi = _make_roi(x=60, y=60, width=40, height=40) # 60+40 == 100 == img_width + result = extract_roi(image, roi) + assert result.shape == (40, 40) + + +def test_extract_roi_3d_exact_boundary(): + """3D 配列でも x+width == img_width かつ y+height == img_height でエラーにならないこと.""" + image = numpy.ones((100, 100, 3), dtype=numpy.float64) + roi = _make_roi(x=60, y=60, width=40, height=40) + result = extract_roi(image, roi) + assert result.shape == (40, 40, 3) + + +def test_extract_roi_starts_at_origin(): + """x=0, y=0 から始まる ROI(左上角からのクロップ)でエラーにならないこと.""" + image = numpy.arange(10000, dtype=numpy.float64).reshape(100, 100) + roi = _make_roi(x=0, y=0, width=10, height=10) + result = extract_roi(image, roi) + assert result.shape == (10, 10) + numpy.testing.assert_array_equal(result, image[0:10, 0:10]) + + +# --------------------------------------------------------------------------- +# 4. 範囲外(はみ出し) +# --------------------------------------------------------------------------- + + +def test_extract_roi_raises_when_x_overflow(): + """x + width > img_width で ValueError が発生すること. + + 仕様: ROI が画像の範囲外にはみ出す場合は ValueError (evaluation/roi.py docstring) + """ + image = numpy.zeros((100, 100), dtype=numpy.float64) + roi = _make_roi(x=70, y=0, width=40, height=10) # 70+40=110 > 100 + with pytest.raises(ValueError): + extract_roi(image, roi) + + +def test_extract_roi_raises_when_y_overflow(): + """y + height > img_height で ValueError が発生すること.""" + image = numpy.zeros((100, 100), dtype=numpy.float64) + roi = _make_roi(x=0, y=70, width=10, height=40) # 70+40=110 > 100 + with pytest.raises(ValueError): + extract_roi(image, roi) + + +def test_extract_roi_raises_when_both_overflow(): + """x + width > img_width かつ y + height > img_height で ValueError が発生すること.""" + image = numpy.zeros((100, 100), dtype=numpy.float64) + roi = _make_roi(x=70, y=70, width=40, height=40) # 110 > 100 in both axes + with pytest.raises(ValueError): + extract_roi(image, roi) + + +# --------------------------------------------------------------------------- +# 5. ゼロサイズではないが範囲外(x が画像幅以上) +# --------------------------------------------------------------------------- + + +def test_extract_roi_raises_when_x_equals_img_width(): + """x が img_width と等しい(x = img_width)場合に ValueError が発生すること. + + 仕様: x + width > img_width の場合にエラー (roi.py docstring) + x=100, width=1 → 101 > 100 → ValueError + """ + image = numpy.zeros((100, 100), dtype=numpy.float64) + roi = _make_roi(x=100, y=0, width=1, height=10) # 100+1=101 > 100 + with pytest.raises(ValueError): + extract_roi(image, roi) + + +def test_extract_roi_raises_when_y_equals_img_height(): + """y が img_height と等しい(y = img_height)場合に ValueError が発生すること.""" + image = numpy.zeros((100, 100), dtype=numpy.float64) + roi = _make_roi(x=0, y=100, width=10, height=1) # 100+1=101 > 100 + with pytest.raises(ValueError): + extract_roi(image, roi) + + +# --------------------------------------------------------------------------- +# 6. 1D 配列の取り扱い +# --------------------------------------------------------------------------- + + +def test_extract_roi_1d_array_raises(): + """1D 配列を渡したとき ValueError または IndexError が発生すること. + + 仕様: 先頭 2 軸が H, W であること(docstring). + 1D 配列では image.shape[:2] のアンパックが失敗するため例外が発生する. + 実装は shape[:2] を img_height, img_width にアンパックするので + ValueError か IndexError(または同等のエラー)が発生することを確認する. + """ + image = numpy.ones(100, dtype=numpy.float64) # 1D + roi = _make_roi(x=0, y=0, width=10, height=10) + with pytest.raises((ValueError, IndexError)): + extract_roi(image, roi) + + +# --------------------------------------------------------------------------- +# 7. image_pixel 以外の ROI type +# --------------------------------------------------------------------------- + + +def test_extract_roi_raises_not_implemented_for_scene_face(): + """roi.type が 'image_pixel' 以外のとき NotImplementedError が発生すること. + + 仕様: scene_face 型は将来実装予定(SPEC_02 evaluation.roi節), + 現在は image_pixel のみサポート(evaluation/roi.py docstring) + """ + # Pydantic の Literal["image_pixel"] バリデーションを回避するため + # model_construct でダミーオブジェクトを構築する + roi = RoiConfig.model_construct(type="scene_face", x=0, y=0, width=10, height=10) + image = numpy.zeros((100, 100), dtype=numpy.float64) + with pytest.raises(NotImplementedError): + extract_roi(image, roi) diff --git a/tests/evaluation/test_visualize.py b/tests/evaluation/test_visualize.py new file mode 100644 index 0000000..382ad06 --- /dev/null +++ b/tests/evaluation/test_visualize.py @@ -0,0 +1,201 @@ +"""evaluation/visualize.py の仕様ベーステスト(smoke テスト). + +仕様出典: +- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(evaluation/visualize 層の責務) +- docs/01_GUIDE/GUIDE_08_テスト方針.md(可視化は smoke テストで十分) + +テスト方針 (GUIDE_08): + 可視化の smoke テストは「ファイルが生成されたか」「ファイルサイズが 0 でないか」程度で十分. + 図の見栄えはテストしない. + matplotlib.use("Agg") を pyplot の import より前に呼ぶこと. +""" + +from __future__ import annotations + +import matplotlib + +# headless 環境でも動くよう Agg バックエンドを pyplot より前に設定する +matplotlib.use("Agg") + +from pathlib import Path + +import numpy + +from sm_tias_lightsim.evaluation.visualize import ( + save_histogram, + save_luminance_map, + save_radial_profile, + save_zone_map, +) + +# --------------------------------------------------------------------------- +# テスト用ヘルパー:合成輝度配列 +# --------------------------------------------------------------------------- + + +def _make_luminance(size: int = 16) -> numpy.ndarray: + """テスト用の合成輝度配列(全画素 100.0 ± 小さなノイズ)を返す. + + 乱数 seed を固定して再現性を保証する (GUIDE_08: seed を必ず固定する) + """ + rng = numpy.random.RandomState(0) + return numpy.full((size, size), 100.0) + rng.normal(0, 5, (size, size)) + + +# --------------------------------------------------------------------------- +# 1. save_luminance_map +# --------------------------------------------------------------------------- + + +def test_save_luminance_map_creates_file(tmp_path: Path): + """save_luminance_map を呼ぶと PNG ファイルが生成されること. + + 仕様: 輝度マップを PNG として保存する(visualize.py save_luminance_map) + """ + luminance = _make_luminance() + output_path = tmp_path / "luminance_map.png" + save_luminance_map(luminance, output_path) + assert output_path.exists() + + +def test_save_luminance_map_file_not_empty(tmp_path: Path): + """生成された PNG ファイルのサイズが 0 より大きいこと. + + 仕様: 正しい PNG として書き出されること(visualize.py save_luminance_map) + """ + luminance = _make_luminance() + output_path = tmp_path / "luminance_map.png" + save_luminance_map(luminance, output_path) + assert output_path.stat().st_size > 0 + + +# --------------------------------------------------------------------------- +# 2. save_histogram +# --------------------------------------------------------------------------- + + +def test_save_histogram_creates_file(tmp_path: Path): + """save_histogram を呼ぶと PNG ファイルが生成されること. + + 仕様: 輝度ヒストグラムを PNG として保存する(visualize.py save_histogram) + """ + luminance = _make_luminance() + output_path = tmp_path / "histogram.png" + save_histogram(luminance, output_path) + assert output_path.exists() + + +def test_save_histogram_file_not_empty(tmp_path: Path): + """生成された histogram PNG ファイルのサイズが 0 より大きいこと.""" + luminance = _make_luminance() + output_path = tmp_path / "histogram.png" + save_histogram(luminance, output_path) + assert output_path.stat().st_size > 0 + + +def test_save_histogram_custom_bins(tmp_path: Path): + """bins パラメータを変えても PNG ファイルが生成されること. + + 仕様: bins=50(デフォルト)(visualize.py save_histogram) + """ + luminance = _make_luminance() + output_path = tmp_path / "histogram_10bins.png" + save_histogram(luminance, output_path, bins=10) + assert output_path.exists() + assert output_path.stat().st_size > 0 + + +# --------------------------------------------------------------------------- +# 3. save_radial_profile +# --------------------------------------------------------------------------- + + +def test_save_radial_profile_creates_file(tmp_path: Path): + """save_radial_profile を呼ぶと PNG ファイルが生成されること. + + 仕様: 放射状輝度プロファイルを線グラフとして PNG に保存する + profile shape: (n_bins, 2),列 0: 距離,列 1: 輝度(visualize.py) + """ + rng = numpy.random.RandomState(1) + distances = numpy.linspace(0.0, 1.0, 20) + luminances = 100.0 + rng.normal(0, 5, 20) + profile = numpy.column_stack([distances, luminances]) # (20, 2) + output_path = tmp_path / "radial_profile.png" + save_radial_profile(profile, output_path) + assert output_path.exists() + + +def test_save_radial_profile_file_not_empty(tmp_path: Path): + """生成された radial_profile PNG ファイルのサイズが 0 より大きいこと.""" + distances = numpy.linspace(0.0, 1.0, 20) + luminances = numpy.linspace(120.0, 80.0, 20) # 線形減衰 + profile = numpy.column_stack([distances, luminances]) + output_path = tmp_path / "radial_profile.png" + save_radial_profile(profile, output_path) + assert output_path.stat().st_size > 0 + + +def test_save_radial_profile_with_nan_does_not_crash(tmp_path: Path): + """NaN を含む行がある profile を渡してもクラッシュしないこと. + + 仕様: 空ビンは nan で表現される(評価仕様の空ビン扱い) + matplotlib の plot は NaN を自動でスキップする(線が途切れる挙動)ため, + クラッシュせずにファイルが生成されること. + """ + distances = numpy.linspace(0.0, 1.0, 20) + luminances = numpy.linspace(120.0, 80.0, 20) + # 空ビンを模擬するため,いくつかの行を NaN にする + luminances[5] = float("nan") + luminances[10] = float("nan") + luminances[15] = float("nan") + profile = numpy.column_stack([distances, luminances]) + output_path = tmp_path / "radial_profile_nan.png" + save_radial_profile(profile, output_path) + assert output_path.exists() + assert output_path.stat().st_size > 0 + + +# --------------------------------------------------------------------------- +# 4. save_zone_map +# --------------------------------------------------------------------------- + + +def test_save_zone_map_creates_file(tmp_path: Path): + """save_zone_map を呼ぶと PNG ファイルが生成されること. + + 仕様: ゾーンマップを 3 色で PNG として保存する(visualize.py save_zone_map) + 入力: int8 配列(0=center, 1=middle, 2=periphery) + """ + from sm_tias_lightsim.evaluation.zones import classify_zones, compute_normalized_distance_map + + distance_map = compute_normalized_distance_map(16, 16) + zone_map = classify_zones(distance_map) # dtype: int8 + output_path = tmp_path / "zone_map.png" + save_zone_map(zone_map, output_path) + assert output_path.exists() + + +def test_save_zone_map_file_not_empty(tmp_path: Path): + """生成された zone_map PNG ファイルのサイズが 0 より大きいこと.""" + from sm_tias_lightsim.evaluation.zones import classify_zones, compute_normalized_distance_map + + distance_map = compute_normalized_distance_map(16, 16) + zone_map = classify_zones(distance_map) + output_path = tmp_path / "zone_map.png" + save_zone_map(zone_map, output_path) + assert output_path.stat().st_size > 0 + + +def test_save_zone_map_accepts_all_zone_values(tmp_path: Path): + """全 3 ゾーン(0/1/2)が含まれる zone_map を渡しても PNG が生成されること. + + 仕様: 値は 0(center), 1(middle), 2(periphery)のいずれか(zones.py classify_zones) + """ + # 手動で 3 ゾーン全てを含む 3x3 マップを作成する + zone_map = numpy.array( + [[0, 0, 1], [0, 1, 2], [1, 2, 2]], dtype=numpy.int8 + ) + output_path = tmp_path / "zone_map_all_zones.png" + save_zone_map(zone_map, output_path) + assert output_path.exists() + assert output_path.stat().st_size > 0 diff --git a/tests/fixtures/eval_smoke.yaml b/tests/fixtures/eval_smoke.yaml new file mode 100644 index 0000000..78443e2 --- /dev/null +++ b/tests/fixtures/eval_smoke.yaml @@ -0,0 +1,14 @@ +roi: + type: image_pixel + x: 112 + y: 112 + width: 32 + height: 32 +zones: + enabled: true + boundaries: [0.33, 0.66] +metrics: + cov: true + std: true + max_min: true + radial_profile: true