"""exporter.py の単体テスト.
仕様 (SPEC_01_アーキテクチャ設計.md):
- export_results: 単一結果を CSV 出力
- export_summary: バッチ結果を集約 CSV 出力
- ensure_output_dirs: output/figures/ と output/results/ を作成
テスト方針 (GUIDE_08_テスト方針.md):
- pytest を使用
- tmp_path フィクスチャで一時ディレクトリを利用
"""
import csv
from pathlib import Path
import numpy as np
import pytest
from src.export.exporter import (
SPATIAL_FIELDNAMES,
SUMMARY_FIELDNAMES,
calc_batch_statistics,
ensure_output_dirs,
export_batch_statistics,
export_results,
export_spatial_summary,
export_summary,
)
# ---------------------------------------------------------------------------
# ensure_output_dirs テスト
# ---------------------------------------------------------------------------
class TestEnsureOutputDirs:
"""ensure_output_dirs 関数のテスト群."""
def test_creates_figures_directory(self, tmp_path: Path) -> None:
"""正常: figures/ サブディレクトリが作成されること."""
ensure_output_dirs(str(tmp_path))
assert (tmp_path / "figures").is_dir()
def test_creates_results_directory(self, tmp_path: Path) -> None:
"""正常: results/ サブディレクトリが作成されること."""
ensure_output_dirs(str(tmp_path))
assert (tmp_path / "results").is_dir()
def test_creates_reports_directory(self, tmp_path: Path) -> None:
"""正常: reports/ サブディレクトリが作成されること."""
ensure_output_dirs(str(tmp_path))
assert (tmp_path / "reports").is_dir()
def test_idempotent_when_dirs_already_exist(self, tmp_path: Path) -> None:
"""正常: 既にディレクトリが存在する場合でもエラーにならないこと."""
ensure_output_dirs(str(tmp_path))
# 2 回目の呼び出しでも例外が発生しないこと
ensure_output_dirs(str(tmp_path))
assert (tmp_path / "figures").is_dir()
assert (tmp_path / "results").is_dir()
assert (tmp_path / "reports").is_dir()
def test_creates_nested_base_directory(self, tmp_path: Path) -> None:
"""正常: base_path が存在しない場合でも自動作成されること."""
nested_base = tmp_path / "new_output"
ensure_output_dirs(str(nested_base))
assert (nested_base / "figures").is_dir()
assert (nested_base / "results").is_dir()
assert (nested_base / "reports").is_dir()
# ---------------------------------------------------------------------------
# export_results テスト
# ---------------------------------------------------------------------------
class TestExportResults:
"""export_results 関数のテスト群."""
@pytest.fixture
def sample_results(self) -> dict:
"""テスト用の均一性指標辞書を返す fixture."""
return {
"cov": 0.05,
"std": 10.0,
"max_min_ratio": 1.2,
"mean": 200.0,
"max": 220.0,
"min": 180.0,
}
def test_csv_file_is_created(self, sample_results: dict, tmp_path: Path) -> None:
"""正常: CSV ファイルが生成されること."""
output_path = str(tmp_path / "results.csv")
export_results(sample_results, output_path)
assert Path(output_path).exists()
def test_csv_has_correct_header(self, sample_results: dict, tmp_path: Path) -> None:
"""正常: CSV の 1 行目にヘッダーが含まれること."""
output_path = str(tmp_path / "results.csv")
export_results(sample_results, output_path)
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
fieldnames = reader.fieldnames
assert set(fieldnames) == set(sample_results.keys())
def test_csv_has_correct_values(self, sample_results: dict, tmp_path: Path) -> None:
"""正常: CSV の値が正しく書き込まれていること."""
output_path = str(tmp_path / "results.csv")
export_results(sample_results, output_path)
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = list(reader)
assert len(rows) == 1
row = rows[0]
assert float(row["cov"]) == pytest.approx(0.05)
assert float(row["std"]) == pytest.approx(10.0)
assert float(row["mean"]) == pytest.approx(200.0)
assert float(row["max"]) == pytest.approx(220.0)
assert float(row["min"]) == pytest.approx(180.0)
def test_creates_parent_directory_automatically(
self, sample_results: dict, tmp_path: Path
) -> None:
"""正常: 出力ディレクトリが存在しない場合に自動作成されること."""
nested_path = str(tmp_path / "results" / "uniformity.csv")
export_results(sample_results, nested_path)
assert Path(nested_path).exists()
# ---------------------------------------------------------------------------
# export_summary テスト
# ---------------------------------------------------------------------------
class TestExportSummary:
"""export_summary 関数のテスト群."""
@pytest.fixture
def sample_all_results(self) -> list[dict]:
"""テスト用の複数画像の均一性指標リストを返す fixture."""
return [
{
"image_name": "image_001",
"mean": 200.0,
"std": 10.0,
"cov": 0.05,
"max_min_ratio": 1.2,
"max": 220.0,
"min": 180.0,
},
{
"image_name": "image_002",
"mean": 150.0,
"std": 20.0,
"cov": 0.133,
"max_min_ratio": 1.5,
"max": 200.0,
"min": 100.0,
},
]
def test_csv_file_is_created(
self, sample_all_results: list[dict], tmp_path: Path
) -> None:
"""正常: CSV ファイルが生成されること."""
output_path = str(tmp_path / "summary.csv")
export_summary(sample_all_results, output_path)
assert Path(output_path).exists()
def test_csv_has_correct_summary_fieldnames(
self, sample_all_results: list[dict], tmp_path: Path
) -> None:
"""正常: CSV のヘッダーが SUMMARY_FIELDNAMES と一致すること."""
output_path = str(tmp_path / "summary.csv")
export_summary(sample_all_results, output_path)
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
fieldnames = reader.fieldnames
assert list(fieldnames) == SUMMARY_FIELDNAMES
def test_csv_has_correct_row_count(
self, sample_all_results: list[dict], tmp_path: Path
) -> None:
"""正常: CSV の行数が入力リストのサイズと一致すること."""
output_path = str(tmp_path / "summary.csv")
export_summary(sample_all_results, output_path)
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = list(reader)
assert len(rows) == len(sample_all_results)
def test_csv_first_row_values_are_correct(
self, sample_all_results: list[dict], tmp_path: Path
) -> None:
"""正常: CSV の 1 行目の値が正しいこと."""
output_path = str(tmp_path / "summary.csv")
export_summary(sample_all_results, output_path)
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = list(reader)
first = rows[0]
assert first["image_name"] == "image_001"
assert float(first["mean"]) == pytest.approx(200.0)
assert float(first["std"]) == pytest.approx(10.0)
def test_csv_second_row_values_are_correct(
self, sample_all_results: list[dict], tmp_path: Path
) -> None:
"""正常: CSV の 2 行目の値が正しいこと."""
output_path = str(tmp_path / "summary.csv")
export_summary(sample_all_results, output_path)
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = list(reader)
second = rows[1]
assert second["image_name"] == "image_002"
assert float(second["mean"]) == pytest.approx(150.0)
def test_summary_fieldnames_constant_has_correct_keys(self) -> None:
"""正常: SUMMARY_FIELDNAMES 定数が期待されるキーを持つこと."""
expected = ["image_name", "mean", "std", "cov", "max_min_ratio", "max", "min"]
assert SUMMARY_FIELDNAMES == expected
def test_creates_parent_directory_automatically(
self, sample_all_results: list[dict], tmp_path: Path
) -> None:
"""正常: 出力ディレクトリが存在しない場合に自動作成されること."""
nested_path = str(tmp_path / "results" / "summary.csv")
export_summary(sample_all_results, nested_path)
assert Path(nested_path).exists()
def test_empty_list_creates_header_only_csv(self, tmp_path: Path) -> None:
"""エッジケース: 空リストを渡した場合にヘッダーのみの CSV が生成されること."""
output_path = str(tmp_path / "empty_summary.csv")
export_summary([], output_path)
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = list(reader)
assert len(rows) == 0
assert list(reader.fieldnames) == SUMMARY_FIELDNAMES
# ---------------------------------------------------------------------------
# export_spatial_summary テスト
# ---------------------------------------------------------------------------
class TestExportSpatialSummary:
"""export_spatial_summary 関数のテスト群."""
@pytest.fixture
def sample_spatial_results(self) -> list[dict]:
"""テスト用の空間解析結果リストを返す fixture."""
return [
{
"image_name": "image_001",
"center_mean": 180.0,
"middle_mean": 165.0,
"periphery_mean": 140.0,
"center_periphery_ratio": 1.29,
"gradient_magnitude": 22.2,
},
{
"image_name": "image_002",
"center_mean": 200.0,
"middle_mean": 190.0,
"periphery_mean": 170.0,
"center_periphery_ratio": 1.18,
"gradient_magnitude": 15.0,
},
]
def test_csv_file_is_created(
self, sample_spatial_results: list[dict], tmp_path: Path
) -> None:
"""正常: CSV ファイルが生成されること."""
output_path = str(tmp_path / "spatial_summary.csv")
export_spatial_summary(sample_spatial_results, output_path)
assert Path(output_path).exists()
def test_csv_has_correct_spatial_fieldnames(
self, sample_spatial_results: list[dict], tmp_path: Path
) -> None:
"""正常: CSV のヘッダーが SPATIAL_FIELDNAMES と一致すること."""
output_path = str(tmp_path / "spatial_summary.csv")
export_spatial_summary(sample_spatial_results, output_path)
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
fieldnames = reader.fieldnames
assert list(fieldnames) == SPATIAL_FIELDNAMES
def test_csv_has_correct_row_count(
self, sample_spatial_results: list[dict], tmp_path: Path
) -> None:
"""正常: CSV の行数が入力リストのサイズと一致すること."""
output_path = str(tmp_path / "spatial_summary.csv")
export_spatial_summary(sample_spatial_results, output_path)
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = list(reader)
assert len(rows) == len(sample_spatial_results)
def test_csv_first_row_values_are_correct(
self, sample_spatial_results: list[dict], tmp_path: Path
) -> None:
"""正常: CSV の 1 行目の値が正しいこと."""
output_path = str(tmp_path / "spatial_summary.csv")
export_spatial_summary(sample_spatial_results, output_path)
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = list(reader)
first = rows[0]
assert first["image_name"] == "image_001"
assert float(first["center_mean"]) == pytest.approx(180.0)
assert float(first["periphery_mean"]) == pytest.approx(140.0)
assert float(first["center_periphery_ratio"]) == pytest.approx(1.29)
def test_csv_second_row_values_are_correct(
self, sample_spatial_results: list[dict], tmp_path: Path
) -> None:
"""正常: CSV の 2 行目の値が正しいこと."""
output_path = str(tmp_path / "spatial_summary.csv")
export_spatial_summary(sample_spatial_results, output_path)
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = list(reader)
second = rows[1]
assert second["image_name"] == "image_002"
assert float(second["gradient_magnitude"]) == pytest.approx(15.0)
def test_spatial_fieldnames_constant_has_correct_keys(self) -> None:
"""正常: SPATIAL_FIELDNAMES 定数が期待されるキーを持つこと."""
expected = [
"image_name",
"center_mean",
"middle_mean",
"periphery_mean",
"center_periphery_ratio",
"gradient_magnitude",
]
assert SPATIAL_FIELDNAMES == expected
def test_creates_parent_directory_automatically(
self, sample_spatial_results: list[dict], tmp_path: Path
) -> None:
"""正常: 出力ディレクトリが存在しない場合に自動作成されること."""
nested_path = str(tmp_path / "results" / "spatial_summary.csv")
export_spatial_summary(sample_spatial_results, nested_path)
assert Path(nested_path).exists()
def test_empty_list_creates_header_only_csv(self, tmp_path: Path) -> None:
"""エッジケース: 空リストを渡した場合にヘッダーのみの CSV が生成されること."""
output_path = str(tmp_path / "empty_spatial.csv")
export_spatial_summary([], output_path)
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = list(reader)
assert len(rows) == 0
assert list(reader.fieldnames) == SPATIAL_FIELDNAMES
def test_extra_keys_in_dict_are_ignored(self, tmp_path: Path) -> None:
"""正常: 余分なキーを含む辞書を渡しても CSV には SPATIAL_FIELDNAMES のみ出力されること."""
results_with_extra = [
{
"image_name": "image_001",
"center_mean": 180.0,
"middle_mean": 165.0,
"periphery_mean": 140.0,
"center_periphery_ratio": 1.29,
"gradient_magnitude": 22.2,
"extra_key": "should_be_ignored",
}
]
output_path = str(tmp_path / "spatial_summary.csv")
export_spatial_summary(results_with_extra, output_path)
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
fieldnames = reader.fieldnames
assert list(fieldnames) == SPATIAL_FIELDNAMES
# ---------------------------------------------------------------------------
# calc_batch_statistics テスト
# ---------------------------------------------------------------------------
class TestCalcBatchStatistics:
"""calc_batch_statistics 関数のテスト群.
仕様 (SPEC_02 バッチ解析 / 実装サマリー):
- 入力: 均一性指標と空間解析結果を含む辞書リスト
- 出力: {"uniformity": {...}, "spatial": {...}} の形式
- uniformity 対象指標: mean, std, cov, max_min_ratio
- spatial 対象指標: center_mean, middle_mean, periphery_mean,
center_periphery_ratio, gradient_magnitude
- numpy 母集団標準偏差(ddof=0)を使用
- cv = std / mean
"""
@pytest.fixture
def two_image_results(self) -> list[dict]:
"""テスト用の 2 画像分の解析結果リストを返す fixture."""
return [
{
"image_name": "image_001",
"mean": 200.0,
"std": 10.0,
"cov": 0.05,
"max_min_ratio": 1.2,
"max": 220.0,
"min": 180.0,
"center_mean": 180.0,
"middle_mean": 165.0,
"periphery_mean": 140.0,
"center_periphery_ratio": 1.286,
"gradient_magnitude": 22.2,
},
{
"image_name": "image_002",
"mean": 210.0,
"std": 20.0,
"cov": 0.095,
"max_min_ratio": 1.4,
"max": 240.0,
"min": 160.0,
"center_mean": 200.0,
"middle_mean": 185.0,
"periphery_mean": 160.0,
"center_periphery_ratio": 1.25,
"gradient_magnitude": 20.0,
},
]
def test_returns_dict_with_uniformity_and_spatial_keys(
self, two_image_results: list[dict]
) -> None:
"""正常: 戻り値が "uniformity" と "spatial" キーを持つ辞書であること."""
result = calc_batch_statistics(two_image_results)
assert isinstance(result, dict)
assert "uniformity" in result
assert "spatial" in result
def test_uniformity_contains_exactly_four_metrics(
self, two_image_results: list[dict]
) -> None:
"""正常: uniformity 部に mean, std, cov, max_min_ratio の 4 指標のみが含まれること."""
result = calc_batch_statistics(two_image_results)
expected_metrics = {"mean", "std", "cov", "max_min_ratio"}
assert expected_metrics == set(result["uniformity"].keys())
def test_spatial_contains_exactly_five_metrics(
self, two_image_results: list[dict]
) -> None:
"""正常: spatial 部に 5 指標のみが含まれること."""
result = calc_batch_statistics(two_image_results)
expected_metrics = {
"center_mean",
"middle_mean",
"periphery_mean",
"center_periphery_ratio",
"gradient_magnitude",
}
assert expected_metrics == set(result["spatial"].keys())
def test_each_metric_has_five_stat_subkeys(
self, two_image_results: list[dict]
) -> None:
"""正常: 各指標が mean, std, min, max, cv の 5 つのサブキーを持つこと."""
result = calc_batch_statistics(two_image_results)
expected_subkeys = {"mean", "std", "min", "max", "cv"}
for category in ("uniformity", "spatial"):
for metric_name, metric_values in result[category].items():
assert expected_subkeys == set(metric_values.keys()), (
f"{category}.{metric_name} のキーが期待と異なる"
)
def test_uniformity_mean_mean_is_arithmetic_mean(
self, two_image_results: list[dict]
) -> None:
"""正常: uniformity.mean.mean が算術平均と一致すること.
(200.0 + 210.0) / 2 = 205.0
"""
result = calc_batch_statistics(two_image_results)
assert result["uniformity"]["mean"]["mean"] == pytest.approx(205.0)
def test_uniformity_mean_std_uses_population_std_ddof0(
self, two_image_results: list[dict]
) -> None:
"""正常: uniformity.mean.std が母集団標準偏差(ddof=0)で算出されること.
values = [200.0, 210.0]
std(ddof=0) = sqrt(((200-205)^2 + (210-205)^2) / 2) = 5.0
"""
result = calc_batch_statistics(two_image_results)
assert result["uniformity"]["mean"]["std"] == pytest.approx(5.0)
def test_uniformity_mean_std_differs_from_sample_std(
self, two_image_results: list[dict]
) -> None:
"""正常: uniformity.mean.std がサンプル標準偏差(ddof=1)と異なること.
ddof=0 (母集団): 5.0
ddof=1 (標本): 7.07... (2 画像の場合)
"""
result = calc_batch_statistics(two_image_results)
sample_std = float(np.std([200.0, 210.0], ddof=1))
# ddof=0 の結果はサンプル標準偏差と一致しないはず
assert result["uniformity"]["mean"]["std"] != pytest.approx(sample_std)
def test_uniformity_mean_min_is_minimum_value(
self, two_image_results: list[dict]
) -> None:
"""正常: uniformity.mean.min が最小値と一致すること."""
result = calc_batch_statistics(two_image_results)
assert result["uniformity"]["mean"]["min"] == pytest.approx(200.0)
def test_uniformity_mean_max_is_maximum_value(
self, two_image_results: list[dict]
) -> None:
"""正常: uniformity.mean.max が最大値と一致すること."""
result = calc_batch_statistics(two_image_results)
assert result["uniformity"]["mean"]["max"] == pytest.approx(210.0)
def test_uniformity_mean_cv_is_std_divided_by_mean(
self, two_image_results: list[dict]
) -> None:
"""正常: uniformity.mean.cv が std / mean と一致すること.
std=5.0, mean=205.0 -> cv = 5.0 / 205.0
"""
result = calc_batch_statistics(two_image_results)
expected_cv = 5.0 / 205.0
assert result["uniformity"]["mean"]["cv"] == pytest.approx(expected_cv)
def test_uniformity_cov_mean_is_correct(
self, two_image_results: list[dict]
) -> None:
"""正常: uniformity.cov.mean が (0.05 + 0.095) / 2 と一致すること."""
result = calc_batch_statistics(two_image_results)
expected = (0.05 + 0.095) / 2
assert result["uniformity"]["cov"]["mean"] == pytest.approx(expected)
def test_uniformity_max_min_ratio_min_is_correct(
self, two_image_results: list[dict]
) -> None:
"""正常: uniformity.max_min_ratio.min が 1.2 と一致すること."""
result = calc_batch_statistics(two_image_results)
assert result["uniformity"]["max_min_ratio"]["min"] == pytest.approx(1.2)
def test_uniformity_max_min_ratio_max_is_correct(
self, two_image_results: list[dict]
) -> None:
"""正常: uniformity.max_min_ratio.max が 1.4 と一致すること."""
result = calc_batch_statistics(two_image_results)
assert result["uniformity"]["max_min_ratio"]["max"] == pytest.approx(1.4)
def test_spatial_center_mean_mean_is_correct(
self, two_image_results: list[dict]
) -> None:
"""正常: spatial.center_mean.mean が (180.0 + 200.0) / 2 = 190.0 と一致すること."""
result = calc_batch_statistics(two_image_results)
assert result["spatial"]["center_mean"]["mean"] == pytest.approx(190.0)
def test_spatial_gradient_magnitude_std_is_correct(
self, two_image_results: list[dict]
) -> None:
"""正常: spatial.gradient_magnitude.std が母集団標準偏差と一致すること."""
result = calc_batch_statistics(two_image_results)
expected_std = float(np.std([22.2, 20.0], ddof=0))
assert result["spatial"]["gradient_magnitude"]["std"] == pytest.approx(expected_std)
def test_spatial_gradient_magnitude_cv_is_std_over_mean(
self, two_image_results: list[dict]
) -> None:
"""正常: spatial.gradient_magnitude.cv が std / mean と一致すること."""
result = calc_batch_statistics(two_image_results)
values = [22.2, 20.0]
expected_mean = float(np.mean(values))
expected_std = float(np.std(values, ddof=0))
expected_cv = expected_std / expected_mean
assert result["spatial"]["gradient_magnitude"]["cv"] == pytest.approx(expected_cv)
def test_all_stat_values_are_float_type(self, two_image_results: list[dict]) -> None:
"""正常: 全ての統計値が float 型であること."""
result = calc_batch_statistics(two_image_results)
for category in ("uniformity", "spatial"):
for metric_name, metric_values in result[category].items():
for stat_name, stat_value in metric_values.items():
assert isinstance(stat_value, float), (
f"{category}.{metric_name}.{stat_name} が float でない: {type(stat_value)}"
)
def test_single_image_std_is_zero(self) -> None:
"""エッジケース: 画像が 1 枚の場合 std は 0.0 となること(母集団標準偏差 ddof=0)."""
single = [
{
"image_name": "image_001",
"mean": 200.0,
"std": 10.0,
"cov": 0.05,
"max_min_ratio": 1.2,
"max": 220.0,
"min": 180.0,
"center_mean": 180.0,
"middle_mean": 165.0,
"periphery_mean": 140.0,
"center_periphery_ratio": 1.286,
"gradient_magnitude": 22.2,
}
]
result = calc_batch_statistics(single)
assert result["uniformity"]["mean"]["std"] == pytest.approx(0.0)
def test_single_image_cv_is_zero_when_std_is_zero(self) -> None:
"""エッジケース: 画像が 1 枚の場合 cv は 0.0 となること(std=0 の場合)."""
single = [
{
"image_name": "image_001",
"mean": 200.0,
"std": 10.0,
"cov": 0.05,
"max_min_ratio": 1.2,
"max": 220.0,
"min": 180.0,
"center_mean": 180.0,
"middle_mean": 165.0,
"periphery_mean": 140.0,
"center_periphery_ratio": 1.286,
"gradient_magnitude": 22.2,
}
]
result = calc_batch_statistics(single)
assert result["uniformity"]["mean"]["cv"] == pytest.approx(0.0)
def test_single_image_min_equals_max(self) -> None:
"""エッジケース: 画像が 1 枚の場合 min と max は同一値となること."""
single = [
{
"image_name": "image_001",
"mean": 200.0,
"std": 10.0,
"cov": 0.05,
"max_min_ratio": 1.2,
"max": 220.0,
"min": 180.0,
"center_mean": 180.0,
"middle_mean": 165.0,
"periphery_mean": 140.0,
"center_periphery_ratio": 1.286,
"gradient_magnitude": 22.2,
}
]
result = calc_batch_statistics(single)
assert result["uniformity"]["mean"]["min"] == pytest.approx(200.0)
assert result["uniformity"]["mean"]["max"] == pytest.approx(200.0)
def test_three_images_population_std_is_correct(self) -> None:
"""正常: 3 画像での std が母集団標準偏差(ddof=0)で正しく算出されること.
values = [100, 200, 300]
mean = 200, std(ddof=0) = sqrt(((100-200)^2 + (200-200)^2 + (300-200)^2) / 3)
= sqrt(20000/3) = sqrt(6666.67) ≈ 81.65
"""
three_images = [
{
"image_name": f"image_00{i}",
"mean": m,
"std": 5.0,
"cov": 0.02,
"max_min_ratio": 1.1,
"max": m + 10,
"min": m - 10,
"center_mean": m,
"middle_mean": m - 5.0,
"periphery_mean": m - 10.0,
"center_periphery_ratio": 1.1,
"gradient_magnitude": 5.0,
}
for i, m in enumerate([100.0, 200.0, 300.0])
]
result = calc_batch_statistics(three_images)
expected_std = float(np.std([100.0, 200.0, 300.0], ddof=0))
assert result["uniformity"]["mean"]["std"] == pytest.approx(expected_std)
def test_uniformly_same_values_cv_is_zero(self) -> None:
"""エッジケース: 全画像で指標値が同一の場合 cv は 0.0 となること."""
identical = [
{
"image_name": f"image_00{i}",
"mean": 200.0,
"std": 10.0,
"cov": 0.05,
"max_min_ratio": 1.2,
"max": 220.0,
"min": 180.0,
"center_mean": 180.0,
"middle_mean": 165.0,
"periphery_mean": 140.0,
"center_periphery_ratio": 1.286,
"gradient_magnitude": 22.2,
}
for i in range(3)
]
result = calc_batch_statistics(identical)
assert result["uniformity"]["mean"]["cv"] == pytest.approx(0.0)
assert result["spatial"]["center_mean"]["cv"] == pytest.approx(0.0)
# ---------------------------------------------------------------------------
# export_batch_statistics テスト
# ---------------------------------------------------------------------------
class TestExportBatchStatistics:
"""export_batch_statistics 関数のテスト群.
仕様 (実装サマリー):
- 行: 各指標名,列: statistic, mean, std, min, max, cv
- uniformity と spatial の全指標を 1 ファイルにまとめる
"""
@pytest.fixture
def sample_stats(self) -> dict:
"""テスト用の集計統計辞書を返す fixture."""
return {
"uniformity": {
"mean": {"mean": 205.0, "std": 5.0, "min": 200.0, "max": 210.0, "cv": 0.0244},
"std": {"mean": 15.0, "std": 5.0, "min": 10.0, "max": 20.0, "cv": 0.333},
"cov": {"mean": 0.0725, "std": 0.0225, "min": 0.05, "max": 0.095, "cv": 0.310},
"max_min_ratio": {"mean": 1.3, "std": 0.1, "min": 1.2, "max": 1.4, "cv": 0.077},
},
"spatial": {
"center_mean": {"mean": 190.0, "std": 10.0, "min": 180.0, "max": 200.0, "cv": 0.053},
"middle_mean": {"mean": 175.0, "std": 10.0, "min": 165.0, "max": 185.0, "cv": 0.057},
"periphery_mean": {"mean": 150.0, "std": 10.0, "min": 140.0, "max": 160.0, "cv": 0.067},
"center_periphery_ratio": {
"mean": 1.268, "std": 0.018, "min": 1.25, "max": 1.286, "cv": 0.014
},
"gradient_magnitude": {"mean": 21.1, "std": 1.1, "min": 20.0, "max": 22.2, "cv": 0.052},
},
}
def test_csv_file_is_created(self, sample_stats: dict, tmp_path: Path) -> None:
"""正常: CSV ファイルが生成されること."""
output_path = str(tmp_path / "summary_statistics.csv")
export_batch_statistics(sample_stats, output_path)
assert Path(output_path).exists()
def test_csv_has_correct_header(self, sample_stats: dict, tmp_path: Path) -> None:
"""正常: CSV のヘッダーが statistic, mean, std, min, max, cv であること."""
output_path = str(tmp_path / "summary_statistics.csv")
export_batch_statistics(sample_stats, output_path)
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
fieldnames = reader.fieldnames
assert list(fieldnames) == ["statistic", "mean", "std", "min", "max", "cv"]
def test_csv_row_count_equals_uniformity_plus_spatial(
self, sample_stats: dict, tmp_path: Path
) -> None:
"""正常: CSV の行数が uniformity 指標数 + spatial 指標数と一致すること."""
output_path = str(tmp_path / "summary_statistics.csv")
export_batch_statistics(sample_stats, output_path)
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = list(reader)
expected_count = len(sample_stats["uniformity"]) + len(sample_stats["spatial"])
assert len(rows) == expected_count
def test_csv_statistic_column_contains_all_metric_names(
self, sample_stats: dict, tmp_path: Path
) -> None:
"""正常: statistic 列に全指標名(uniformity + spatial)が含まれること."""
output_path = str(tmp_path / "summary_statistics.csv")
export_batch_statistics(sample_stats, output_path)
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
statistic_names = [row["statistic"] for row in reader]
all_expected_metrics = list(sample_stats["uniformity"].keys()) + list(
sample_stats["spatial"].keys()
)
assert set(statistic_names) == set(all_expected_metrics)
def test_uniformity_mean_row_values_are_correct(
self, sample_stats: dict, tmp_path: Path
) -> None:
"""正常: uniformity の mean 指標行の全列値が正しく書き込まれていること."""
output_path = str(tmp_path / "summary_statistics.csv")
export_batch_statistics(sample_stats, output_path)
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = {row["statistic"]: row for row in reader}
mean_row = rows["mean"]
assert float(mean_row["mean"]) == pytest.approx(205.0)
assert float(mean_row["std"]) == pytest.approx(5.0)
assert float(mean_row["min"]) == pytest.approx(200.0)
assert float(mean_row["max"]) == pytest.approx(210.0)
assert float(mean_row["cv"]) == pytest.approx(0.0244)
def test_spatial_center_mean_row_values_are_correct(
self, sample_stats: dict, tmp_path: Path
) -> None:
"""正常: spatial の center_mean 指標行の全列値が正しく書き込まれていること."""
output_path = str(tmp_path / "summary_statistics.csv")
export_batch_statistics(sample_stats, output_path)
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = {row["statistic"]: row for row in reader}
cm_row = rows["center_mean"]
assert float(cm_row["mean"]) == pytest.approx(190.0)
assert float(cm_row["std"]) == pytest.approx(10.0)
assert float(cm_row["min"]) == pytest.approx(180.0)
assert float(cm_row["max"]) == pytest.approx(200.0)
assert float(cm_row["cv"]) == pytest.approx(0.053)
def test_spatial_gradient_magnitude_row_values_are_correct(
self, sample_stats: dict, tmp_path: Path
) -> None:
"""正常: spatial の gradient_magnitude 指標行の全列値が正しく書き込まれていること."""
output_path = str(tmp_path / "summary_statistics.csv")
export_batch_statistics(sample_stats, output_path)
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = {row["statistic"]: row for row in reader}
grad_row = rows["gradient_magnitude"]
assert float(grad_row["mean"]) == pytest.approx(21.1)
assert float(grad_row["std"]) == pytest.approx(1.1)
assert float(grad_row["min"]) == pytest.approx(20.0)
assert float(grad_row["max"]) == pytest.approx(22.2)
assert float(grad_row["cv"]) == pytest.approx(0.052)
def test_creates_parent_directory_automatically(
self, sample_stats: dict, tmp_path: Path
) -> None:
"""正常: 出力ディレクトリが存在しない場合に自動作成されること."""
nested_path = str(tmp_path / "results" / "summary_statistics.csv")
export_batch_statistics(sample_stats, nested_path)
assert Path(nested_path).exists()
def test_uniformity_metrics_appear_before_spatial_metrics(
self, sample_stats: dict, tmp_path: Path
) -> None:
"""正常: CSV の先頭 4 行が uniformity 指標,後続 5 行が spatial 指標であること."""
output_path = str(tmp_path / "summary_statistics.csv")
export_batch_statistics(sample_stats, output_path)
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = list(reader)
uniformity_count = len(sample_stats["uniformity"])
uniformity_names = set(sample_stats["uniformity"].keys())
for row in rows[:uniformity_count]:
assert row["statistic"] in uniformity_names, (
f"先頭 {uniformity_count} 行に uniformity 以外の指標が含まれている: {row['statistic']}"
)
spatial_names = set(sample_stats["spatial"].keys())
for row in rows[uniformity_count:]:
assert row["statistic"] in spatial_names, (
f"後続行に spatial 以外の指標が含まれている: {row['statistic']}"
)
def test_integration_calc_then_export(self, tmp_path: Path) -> None:
"""統合: calc_batch_statistics の結果を export_batch_statistics で出力できること."""
all_results = [
{
"image_name": "image_001",
"mean": 200.0,
"std": 10.0,
"cov": 0.05,
"max_min_ratio": 1.2,
"max": 220.0,
"min": 180.0,
"center_mean": 180.0,
"middle_mean": 165.0,
"periphery_mean": 140.0,
"center_periphery_ratio": 1.286,
"gradient_magnitude": 22.2,
},
{
"image_name": "image_002",
"mean": 210.0,
"std": 20.0,
"cov": 0.095,
"max_min_ratio": 1.4,
"max": 240.0,
"min": 160.0,
"center_mean": 200.0,
"middle_mean": 185.0,
"periphery_mean": 160.0,
"center_periphery_ratio": 1.25,
"gradient_magnitude": 20.0,
},
]
stats = calc_batch_statistics(all_results)
output_path = str(tmp_path / "summary_statistics.csv")
export_batch_statistics(stats, output_path)
assert Path(output_path).exists()
with open(output_path, encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = list(reader)
assert len(rows) == 9 # uniformity 4 + spatial 5
statistic_names = {row["statistic"] for row in rows}
assert "mean" in statistic_names
assert "cov" in statistic_names
assert "center_mean" in statistic_names
assert "gradient_magnitude" in statistic_names