"""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"
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 列が追加されること.
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