Newer
Older
SmTIAS-LightSim / tests / cli / test_evaluate_shape_metrics.py
"""cli/evaluate.py の _run_shape_metrics 関数と CLI 統合動作の仕様ベーステスト.

仕様出典:
- docs/05_TECH/TECH_11_形状指標の見直しと新指標導入.md
- docs/04_SPEC/SPEC_04_評価仕様.md
- src/sm_tias_lightsim/cli/evaluate.py(_run_shape_metrics 実装)
- src/sm_tias_lightsim/evaluation/shape_metrics.py(純関数実装)

テスト方針:
  - subprocess ではなく main() / _run_shape_metrics() を直接呼ぶ(速度・カバレッジのため)
  - ファイル I/O はモックせず tmp_path フィクスチャで実ファイルを使う (GUIDE_08)
  - 乱数を使う場合は seed を必ず固定する (GUIDE_08)

注意:
  matplotlib の Agg バックエンドは evaluate.py 内部で設定される.
  テスト側でも安全のため import 前に設定する.
"""

from __future__ import annotations

import math
import textwrap

import matplotlib  # noqa: I001

# headless 環境でも動くよう Agg バックエンドを pyplot より前に設定する
matplotlib.use("Agg")

from pathlib import Path

import numpy
import pandas
import pytest

from sm_tias_lightsim.cli.evaluate import _run_shape_metrics, main
from sm_tias_lightsim.scene.schema import MetricsConfig

# ===========================================================================
# テスト用ヘルパー関数
# ===========================================================================


def _make_luminance_array(seed: int = 0, h: int = 64, w: int = 64) -> numpy.ndarray:
    """放射状勾配の合成輝度配列(seed 固定).

    中心が明るく周辺が暗い放射状勾配で shape 指標が意味のある値になる.
    """
    rng = numpy.random.RandomState(seed)
    ys, xs = numpy.mgrid[0:h, 0:w]
    cy, cx = h / 2.0, w / 2.0
    dist = numpy.sqrt(((xs - cx) / cx) ** 2 + ((ys - cy) / cy) ** 2)
    luminance = 100.0 - 30.0 * dist + rng.normal(0, 1.0, (h, w))
    return numpy.clip(luminance, 1.0, None).astype(numpy.float64)


def _make_eval_yaml(
    tmp_path: Path,
    shape_metrics: bool = True,
    shape_reference: str | None = None,
    x: int = 0,
    y: int = 0,
    width: int = 64,
    height: int = 64,
    include_zones: bool = True,
    include_radial: bool = True,
) -> Path:
    """shape_metrics フラグを含む評価 YAML を tmp_path に書き出す."""
    ref_line = "null" if shape_reference is None else f'"{shape_reference}"'
    shape_metrics_str = "true" if shape_metrics else "false"
    radial_line = "  radial_profile: true\n" if include_radial else ""

    lines = [
        "roi:",
        "  type: image_pixel",
        f"  x: {x}",
        f"  y: {y}",
        f"  width: {width}",
        f"  height: {height}",
    ]
    if include_zones:
        lines += [
            "zones:",
            "  enabled: true",
            "  boundaries: [0.33, 0.66]",
        ]
    lines += [
        "metrics:",
        "  cov: true",
        "  std: true",
        "  max_min: true",
    ]
    if include_radial:
        lines.append("  radial_profile: true")
    lines += [
        f"  shape_metrics: {shape_metrics_str}",
        f"  shape_reference: {ref_line}",
    ]

    content = "\n".join(lines) + "\n"
    yaml_path = tmp_path / "eval_shape.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)]
    )


# ===========================================================================
# G1. shape_metrics=True で metrics.csv に shape 指標 12 列が末尾追加される
# ===========================================================================


class TestShapeMetricsCsvColumns:
    """G1: shape_metrics=True で CSV に shape 指標列が追加されること."""

    _CENTROID_COLS = [
        "intensity_centroid_x_px",
        "intensity_centroid_y_px",
        "intensity_centroid_x_norm",
        "intensity_centroid_y_norm",
        "intensity_centroid_offset_norm",
    ]
    _ARD_COLS = ["angular_variance", "radial_variance", "angular_ratio"]
    _PCA_COLS = ["pca_primary_axis_angle_deg", "pca_eccentricity"]
    _SSIM_NCC_COLS = ["ssim", "ncc"]

    def test_centroid_columns_present_when_shape_metrics_true(self, tmp_path: Path):
        """G1: shape_metrics=True で CSV に輝度重心 5 列が含まれること.

        仕様: intensity_centroid_x_px / intensity_centroid_y_px /
        intensity_centroid_x_norm / intensity_centroid_y_norm /
        intensity_centroid_offset_norm
        (TECH_11 §1. 輝度重心 + cli/evaluate.py _run_shape_metrics)
        """
        luminance = _make_luminance_array(seed=0)
        npy_path = tmp_path / "luminance.npy"
        numpy.save(npy_path, luminance)

        config_path = _make_eval_yaml(tmp_path, shape_metrics=True)
        output_dir = tmp_path / "output"
        result = _run_main(npy_path, config_path, output_dir)

        assert result == 0
        df = pandas.read_csv(output_dir / "metrics.csv")
        for col in self._CENTROID_COLS:
            assert col in df.columns, f"列 '{col}' が metrics.csv に存在しない"

    def test_ard_columns_present_when_shape_metrics_true(self, tmp_path: Path):
        """G1: shape_metrics=True で CSV に角度-放射分解 3 列が含まれること.

        仕様: angular_variance / radial_variance / angular_ratio
        (TECH_11 §2. 角度-放射分解 + cli/evaluate.py _run_shape_metrics)
        """
        luminance = _make_luminance_array(seed=1)
        npy_path = tmp_path / "luminance.npy"
        numpy.save(npy_path, luminance)

        config_path = _make_eval_yaml(tmp_path, shape_metrics=True)
        output_dir = tmp_path / "output"
        result = _run_main(npy_path, config_path, output_dir)

        assert result == 0
        df = pandas.read_csv(output_dir / "metrics.csv")
        for col in self._ARD_COLS:
            assert col in df.columns, f"列 '{col}' が metrics.csv に存在しない"

    def test_pca_columns_present_when_shape_metrics_true(self, tmp_path: Path):
        """G1: shape_metrics=True で CSV に PCA 指標 2 列が含まれること.

        仕様: pca_primary_axis_angle_deg / pca_eccentricity
        (TECH_11 §5. 輝度分布 PCA + cli/evaluate.py _run_shape_metrics)
        """
        luminance = _make_luminance_array(seed=2)
        npy_path = tmp_path / "luminance.npy"
        numpy.save(npy_path, luminance)

        config_path = _make_eval_yaml(tmp_path, shape_metrics=True)
        output_dir = tmp_path / "output"
        result = _run_main(npy_path, config_path, output_dir)

        assert result == 0
        df = pandas.read_csv(output_dir / "metrics.csv")
        for col in self._PCA_COLS:
            assert col in df.columns, f"列 '{col}' が metrics.csv に存在しない"

    def test_ssim_ncc_columns_present_but_nan_when_no_reference(self, tmp_path: Path):
        """G1: shape_reference が null のとき ssim / ncc 列は存在するが NaN であること.

        仕様: shape_reference が無効なら空欄 or NaN
        (cli/evaluate.py _run_shape_metrics: reference_path_str is None → NaN)
        """
        luminance = _make_luminance_array(seed=3)
        npy_path = tmp_path / "luminance.npy"
        numpy.save(npy_path, luminance)

        config_path = _make_eval_yaml(tmp_path, shape_metrics=True, shape_reference=None)
        output_dir = tmp_path / "output"
        result = _run_main(npy_path, config_path, output_dir)

        assert result == 0
        df = pandas.read_csv(output_dir / "metrics.csv")
        for col in self._SSIM_NCC_COLS:
            assert col in df.columns, f"列 '{col}' が metrics.csv に存在しない"
            assert pandas.isna(df[col].iloc[0]), f"列 '{col}' は NaN であるべき"

    def test_total_shape_columns_count_is_12_without_reference(self, tmp_path: Path):
        """G1: shape_reference なしで shape 指標が 12 列(centroid5 + ARD3 + PCA2 + ssim/ncc2)追加されること.

        仕様: metrics.csv に shape 指標 12 列を末尾追加
        (cli/evaluate.py _run_shape_metrics)
        """
        luminance = _make_luminance_array(seed=4)

        # まず shape_metrics=False で基準列数を取得
        dir_false = tmp_path / "false"
        dir_false.mkdir(exist_ok=True)
        npy_false = dir_false / "luminance.npy"
        numpy.save(npy_false, luminance)
        config_false = _make_eval_yaml(dir_false, shape_metrics=False)
        output_false = tmp_path / "output_false"
        _run_main(npy_false, config_false, output_false)
        df_base = pandas.read_csv(output_false / "metrics.csv")
        base_cols = len(df_base.columns)

        # shape_metrics=True で実行
        dir_true = tmp_path / "true"
        dir_true.mkdir(exist_ok=True)
        npy_true = dir_true / "luminance.npy"
        numpy.save(npy_true, luminance)
        config_true = _make_eval_yaml(dir_true, shape_metrics=True)
        output_true = tmp_path / "output_true"
        _run_main(npy_true, config_true, output_true)
        df_shape = pandas.read_csv(output_true / "metrics.csv")

        shape_cols = len(df_shape.columns)
        added_cols = shape_cols - base_cols
        assert added_cols == 12, f"shape 指標 12 列が追加されるべきだが {added_cols} 列追加された"


# ===========================================================================
# G2. shape_metrics=False(デフォルト)で既存 metrics.csv 列順序が変わらない
# ===========================================================================


class TestShapeMetricsBackwardCompat:
    """G2: shape_metrics=False で既存列構造に影響しないこと(後方互換)."""

    def test_shape_metrics_false_no_shape_columns_in_csv(self, tmp_path: Path):
        """G2: shape_metrics=False(デフォルト)で CSV に shape 指標列が含まれないこと.

        仕様: shape_metrics=false (デフォルト) で既存 metrics.csv 列順序が変わらない
        (TECH_11 §設定方法 後方互換)
        """
        luminance = _make_luminance_array(seed=10)
        npy_path = tmp_path / "luminance.npy"
        numpy.save(npy_path, luminance)

        config_path = _make_eval_yaml(tmp_path, shape_metrics=False)
        output_dir = tmp_path / "output"
        result = _run_main(npy_path, config_path, output_dir)

        assert result == 0
        df = pandas.read_csv(output_dir / "metrics.csv")

        shape_columns = [
            "intensity_centroid_x_px",
            "intensity_centroid_y_px",
            "intensity_centroid_x_norm",
            "intensity_centroid_y_norm",
            "intensity_centroid_offset_norm",
            "angular_variance",
            "radial_variance",
            "angular_ratio",
            "pca_primary_axis_angle_deg",
            "pca_eccentricity",
            "ssim",
            "ncc",
        ]
        for col in shape_columns:
            assert col not in df.columns, f"shape_metrics=False なのに列 '{col}' が存在する"

    def test_core_columns_exist_regardless_of_shape_metrics(self, tmp_path: Path):
        """G2 補足: shape_metrics の値に関わらず基本指標列は常に存在すること.

        仕様: 既存 metrics.csv 列順序が変わらない(後方互換)
        (SPEC_04 §metrics.csv カラム定義)
        """
        luminance = _make_luminance_array(seed=11)
        npy_path = tmp_path / "luminance.npy"
        numpy.save(npy_path, luminance)

        core_columns = ["cov", "std", "max_min_ratio", "mean", "max", "min"]

        for sm_flag in [True, False]:
            subdir = tmp_path / ("true" if sm_flag else "false")
            subdir.mkdir(exist_ok=True)
            sub_npy = subdir / "luminance.npy"
            numpy.save(sub_npy, luminance)
            config = _make_eval_yaml(subdir, shape_metrics=sm_flag)
            output = subdir / "output"
            ret = _run_main(sub_npy, config, output)
            assert ret == 0
            df = pandas.read_csv(output / "metrics.csv")
            for col in core_columns:
                assert col in df.columns, f"shape_metrics={sm_flag} の場合に列 '{col}' が存在しない"

    def test_existing_yaml_without_shape_fields_runs_successfully(self, tmp_path: Path):
        """G2 補足: shape_metrics フィールドを一切含まない既存 YAML 相当の設定で正常終了すること.

        仕様: 既存の YAML は変更不要で動作する
        (TECH_11 §設定方法 後方互換)
        """
        luminance = _make_luminance_array(seed=12)
        npy_path = tmp_path / "luminance.npy"
        numpy.save(npy_path, luminance)

        # shape_metrics / shape_reference フィールドを一切含まない YAML
        yaml_content = textwrap.dedent("""\
            roi:
              type: image_pixel
              x: 0
              y: 0
              width: 64
              height: 64
            metrics:
              cov: true
              std: true
              max_min: true
        """)
        config_path = tmp_path / "eval_legacy.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


# ===========================================================================
# G3. shape_reference の相対パスは config YAML のディレクトリ基準で解決される
# ===========================================================================


class TestShapeReferenceRelativePath:
    """G3: shape_reference の相対パス解決が config YAML のディレクトリ基準であること."""

    def test_relative_reference_path_resolved_from_config_dir(self, tmp_path: Path):
        """G3: shape_reference の相対パスが config YAML のディレクトリを基準に解決されること.

        仕様: shape_reference の相対パスは config YAML のディレクトリ基準で解決される
        (cli/evaluate.py _run_shape_metrics: config_dir / ref_path)
        """
        h, w = 64, 64

        # サブディレクトリに config YAML を置く
        config_dir = tmp_path / "configs"
        config_dir.mkdir()

        # 参照画像を config と同じディレクトリに置く
        ref_luminance = _make_luminance_array(seed=50, h=h, w=w)
        ref_npy_path = config_dir / "reference.npy"
        numpy.save(ref_npy_path, ref_luminance)

        # 評価対象の luminance.npy(ROI 全体が h×w に一致するよう設定)
        target_luminance = _make_luminance_array(seed=51, h=h, w=w)
        npy_path = tmp_path / "luminance.npy"
        numpy.save(npy_path, target_luminance)

        # shape_reference を相対パスで指定(config と同ディレクトリにある参照画像)
        yaml_content = textwrap.dedent("""\
            roi:
              type: image_pixel
              x: 0
              y: 0
              width: 64
              height: 64
            metrics:
              cov: true
              shape_metrics: true
              shape_reference: "reference.npy"
        """)
        config_path = config_dir / "eval_with_ref.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

        df = pandas.read_csv(output_dir / "metrics.csv")
        # SSIM / NCC が NaN でないこと(参照画像が正常に読み込まれた証拠)
        assert not pandas.isna(df["ssim"].iloc[0]), "相対パスが正しく解決されれば SSIM は NaN でない"
        assert not pandas.isna(df["ncc"].iloc[0]), "相対パスが正しく解決されれば NCC は NaN でない"

    def test_reference_file_not_found_gives_nan_not_error(self, tmp_path: Path):
        """G3 補足: 参照ファイルが存在しない場合は NaN を返し,異常終了しないこと.

        仕様: shape_reference ファイルが見つからない場合 → SSIM/NCC を NaN でスキップ
        (cli/evaluate.py _run_shape_metrics: FileNotFoundError → nan)
        """
        luminance = _make_luminance_array(seed=52)
        npy_path = tmp_path / "luminance.npy"
        numpy.save(npy_path, luminance)

        yaml_content = textwrap.dedent("""\
            roi:
              type: image_pixel
              x: 0
              y: 0
              width: 64
              height: 64
            metrics:
              cov: true
              shape_metrics: true
              shape_reference: "nonexistent_file.npy"
        """)
        config_path = tmp_path / "eval_bad_ref.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

        df = pandas.read_csv(output_dir / "metrics.csv")
        assert pandas.isna(df["ssim"].iloc[0])
        assert pandas.isna(df["ncc"].iloc[0])

    def test_absolute_reference_path_resolved_directly(self, tmp_path: Path):
        """G3 補足: 絶対パスの場合はそのまま使用されること.

        仕様: 絶対パスの場合はそのまま使用する
        (cli/evaluate.py _run_shape_metrics: if not ref_path.is_absolute() → config_dir / ref_path)
        """
        h, w = 64, 64
        ref_luminance = _make_luminance_array(seed=60, h=h, w=w)
        ref_npy_path = tmp_path / "absolute_reference.npy"
        numpy.save(ref_npy_path, ref_luminance)

        target_luminance = _make_luminance_array(seed=61, h=h, w=w)
        npy_path = tmp_path / "luminance.npy"
        numpy.save(npy_path, target_luminance)

        # 絶対パスで指定
        yaml_content = textwrap.dedent(f"""\
            roi:
              type: image_pixel
              x: 0
              y: 0
              width: 64
              height: 64
            metrics:
              cov: true
              shape_metrics: true
              shape_reference: "{ref_npy_path.as_posix()}"
        """)
        config_dir = tmp_path / "subdir"
        config_dir.mkdir()
        config_path = config_dir / "eval_abs_ref.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

        df = pandas.read_csv(output_dir / "metrics.csv")
        # SSIM / NCC が計算されていること(絶対パスで正しく解決された証拠)
        assert not pandas.isna(df["ssim"].iloc[0])
        assert not pandas.isna(df["ncc"].iloc[0])

    def test_shape_mismatch_reference_gives_nan_not_error(self, tmp_path: Path):
        """G3 補足: 参照画像の shape が ROI と異なる場合も NaN を返し,異常終了しないこと.

        仕様: shape が異なる場合は SSIM/NCC を自動スキップして NaN を格納する
        (cli/evaluate.py _run_shape_metrics: ref_luminance.shape != roi_luminance.shape)
        """
        # ターゲット: 64x64
        target_luminance = _make_luminance_array(seed=70, h=64, w=64)
        npy_path = tmp_path / "luminance.npy"
        numpy.save(npy_path, target_luminance)

        # 参照: 32x32(形状不一致)
        ref_luminance = _make_luminance_array(seed=71, h=32, w=32)
        ref_npy_path = tmp_path / "ref_small.npy"
        numpy.save(ref_npy_path, ref_luminance)

        yaml_content = textwrap.dedent(f"""\
            roi:
              type: image_pixel
              x: 0
              y: 0
              width: 64
              height: 64
            metrics:
              cov: true
              shape_metrics: true
              shape_reference: "{ref_npy_path.as_posix()}"
        """)
        config_path = tmp_path / "eval_mismatch_ref.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

        df = pandas.read_csv(output_dir / "metrics.csv")
        assert pandas.isna(df["ssim"].iloc[0])
        assert pandas.isna(df["ncc"].iloc[0])


# ===========================================================================
# _run_shape_metrics 関数単体テスト
# ===========================================================================


class TestRunShapeMetricsFunction:
    """_run_shape_metrics 関数の直接テスト(CLI を介さない単体テスト)."""

    def test_returns_empty_dict_when_metrics_config_is_none(self, tmp_path: Path):
        """metrics_config が None のとき {} を返すこと.

        仕様: metrics_config is None → 無効扱いで空辞書を返す
        (cli/evaluate.py _run_shape_metrics)
        """
        roi_luminance = _make_luminance_array(seed=80)
        result = _run_shape_metrics(roi_luminance, None, tmp_path)
        assert result == {}

    def test_returns_empty_dict_when_shape_metrics_false(self, tmp_path: Path):
        """MetricsConfig(shape_metrics=False) のとき {} を返すこと.

        仕様: metrics_config.shape_metrics=False → 無効扱いで空辞書を返す
        (cli/evaluate.py _run_shape_metrics)
        """
        roi_luminance = _make_luminance_array(seed=81)
        metrics_cfg = MetricsConfig(shape_metrics=False)
        result = _run_shape_metrics(roi_luminance, metrics_cfg, tmp_path)
        assert result == {}

    def test_returns_12_keys_when_shape_metrics_true_no_reference(self, tmp_path: Path):
        """shape_metrics=True + reference なしで 12 キーの辞書を返すこと.

        仕様: shape_metrics=True で 12 列(centroid5+ARD3+PCA2+ssim/ncc2)
        (cli/evaluate.py _run_shape_metrics)
        """
        roi_luminance = _make_luminance_array(seed=82)
        metrics_cfg = MetricsConfig(shape_metrics=True, shape_reference=None)
        result = _run_shape_metrics(roi_luminance, metrics_cfg, tmp_path)

        assert len(result) == 12

    def test_expected_keys_present_in_result(self, tmp_path: Path):
        """_run_shape_metrics の戻り値に全 12 個の期待キーが含まれること.

        仕様: cli/evaluate.py _run_shape_metrics の戻り値キー定義
        """
        roi_luminance = _make_luminance_array(seed=83)
        metrics_cfg = MetricsConfig(shape_metrics=True, shape_reference=None)
        result = _run_shape_metrics(roi_luminance, metrics_cfg, tmp_path)

        expected_keys = {
            "intensity_centroid_x_px",
            "intensity_centroid_y_px",
            "intensity_centroid_x_norm",
            "intensity_centroid_y_norm",
            "intensity_centroid_offset_norm",
            "angular_variance",
            "radial_variance",
            "angular_ratio",
            "pca_primary_axis_angle_deg",
            "pca_eccentricity",
            "ssim",
            "ncc",
        }
        assert set(result.keys()) == expected_keys

    def test_ssim_and_ncc_are_nan_when_no_reference(self, tmp_path: Path):
        """shape_reference=None のとき ssim / ncc が NaN であること.

        仕様: shape_reference が無効なら float("nan") を格納する
        (cli/evaluate.py _run_shape_metrics: reference_path_str is None → nan)
        """
        roi_luminance = _make_luminance_array(seed=84)
        metrics_cfg = MetricsConfig(shape_metrics=True, shape_reference=None)
        result = _run_shape_metrics(roi_luminance, metrics_cfg, tmp_path)

        assert math.isnan(result["ssim"])
        assert math.isnan(result["ncc"])

    def test_ssim_and_ncc_computed_when_valid_reference(self, tmp_path: Path):
        """有効な shape_reference が指定されたとき ssim / ncc が NaN でないこと.

        仕様: SSIM / NCC は shape_reference が指定されたときのみ算出する
        (TECH_11 §3. SSIM, §4. NCC)
        """
        h, w = 64, 64
        roi_luminance = _make_luminance_array(seed=85, h=h, w=w)
        ref_luminance = _make_luminance_array(seed=86, h=h, w=w)
        ref_path = tmp_path / "ref.npy"
        numpy.save(ref_path, ref_luminance)

        # shape_reference に絶対パス文字列を指定
        metrics_cfg = MetricsConfig(shape_metrics=True, shape_reference=str(ref_path))
        result = _run_shape_metrics(roi_luminance, metrics_cfg, tmp_path)

        assert not math.isnan(result["ssim"]), "有効な参照画像があれば SSIM は NaN でない"
        assert not math.isnan(result["ncc"]), "有効な参照画像があれば NCC は NaN でない"

    def test_ssim_is_one_for_identical_reference(self, tmp_path: Path):
        """同一画像を参照に指定した場合,ssim ≈ 1.0 になること.

        仕様: 同一画像 → SSIM = 1.0 (calc_ssim 仕様)
        """
        h, w = 64, 64
        roi_luminance = _make_luminance_array(seed=87, h=h, w=w)
        ref_path = tmp_path / "ref_identical.npy"
        numpy.save(ref_path, roi_luminance.copy())

        metrics_cfg = MetricsConfig(shape_metrics=True, shape_reference=str(ref_path))
        result = _run_shape_metrics(roi_luminance, metrics_cfg, tmp_path)

        assert result["ssim"] == pytest.approx(1.0, abs=1e-5)

    def test_ncc_is_one_for_identical_reference(self, tmp_path: Path):
        """同一画像を参照に指定した場合,ncc ≈ 1.0 になること.

        仕様: 同一画像 → NCC = 1.0 (calc_ncc 仕様)
        """
        h, w = 64, 64
        roi_luminance = _make_luminance_array(seed=88, h=h, w=w)
        ref_path = tmp_path / "ref_identical_ncc.npy"
        numpy.save(ref_path, roi_luminance.copy())

        metrics_cfg = MetricsConfig(shape_metrics=True, shape_reference=str(ref_path))
        result = _run_shape_metrics(roi_luminance, metrics_cfg, tmp_path)

        assert result["ncc"] == pytest.approx(1.0, abs=1e-9)

    def test_all_non_ssim_ncc_values_are_finite(self, tmp_path: Path):
        """ssim / ncc 以外の shape 指標(重心・ARD・PCA)が有限値であること.

        仕様: 通常の輝度マップに対し全指標が計算可能であること
        """
        roi_luminance = _make_luminance_array(seed=89)
        metrics_cfg = MetricsConfig(shape_metrics=True, shape_reference=None)
        result = _run_shape_metrics(roi_luminance, metrics_cfg, tmp_path)

        non_reference_keys = [
            "intensity_centroid_x_px",
            "intensity_centroid_y_px",
            "intensity_centroid_x_norm",
            "intensity_centroid_y_norm",
            "intensity_centroid_offset_norm",
            "angular_variance",
            "radial_variance",
            "pca_primary_axis_angle_deg",
            "pca_eccentricity",
        ]
        for key in non_reference_keys:
            val = result[key]
            assert math.isfinite(val), f"キー '{key}' の値 {val} が有限でない"

    def test_eccentricity_in_valid_range(self, tmp_path: Path):
        """pca_eccentricity が [0, 1] の範囲内であること.

        仕様: eccentricity = √(1 - secondary/primary), 0=完全円, 1=極端な楕円
        (TECH_11 §5. 輝度分布 PCA)
        """
        roi_luminance = _make_luminance_array(seed=90)
        metrics_cfg = MetricsConfig(shape_metrics=True, shape_reference=None)
        result = _run_shape_metrics(roi_luminance, metrics_cfg, tmp_path)

        assert 0.0 <= result["pca_eccentricity"] <= 1.0

    def test_pca_angle_in_valid_range(self, tmp_path: Path):
        """pca_primary_axis_angle_deg が [-90, +90] の範囲内であること.

        仕様: primary_axis_angle_deg は [-90, +90] の範囲
        (TECH_11 §5. 輝度分布 PCA)
        """
        roi_luminance = _make_luminance_array(seed=91)
        metrics_cfg = MetricsConfig(shape_metrics=True, shape_reference=None)
        result = _run_shape_metrics(roi_luminance, metrics_cfg, tmp_path)

        assert -90.0 <= result["pca_primary_axis_angle_deg"] <= 90.0