diff --git a/CLAUDE.md b/CLAUDE.md index d302f5f..9634673 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,3 +66,7 @@ ### 04_SPEC(仕様・設計) - アーキテクチャ設計: docs/04_SPEC/SPEC_01_アーキテクチャ設計.md + +### 07_REPORT(報告書) + +- 照明均一性評価報告書: docs/07_REPORT/REPORT_01_照明均一性評価報告書.md diff --git "a/docs/01_GUIDE/GUIDE_03_\343\203\225\343\202\241\343\202\244\343\203\253\345\221\275\345\220\215\350\246\217\345\211\207.md" "b/docs/01_GUIDE/GUIDE_03_\343\203\225\343\202\241\343\202\244\343\203\253\345\221\275\345\220\215\350\246\217\345\211\207.md" index f9d43d2..2840ec6 100644 --- "a/docs/01_GUIDE/GUIDE_03_\343\203\225\343\202\241\343\202\244\343\203\253\345\221\275\345\220\215\350\246\217\345\211\207.md" +++ "b/docs/01_GUIDE/GUIDE_03_\343\203\225\343\202\241\343\202\244\343\203\253\345\221\275\345\220\215\350\246\217\345\211\207.md" @@ -24,6 +24,7 @@ | `SPEC_` | `04_SPEC/` | ユーザーから見える仕様 | 企画書,画面遷移図,パラメータ設定 | | `TECH_` | `05_TECH/` | エンジニア向けの技術設計 | 通信プロトコル仕様,データベース設計,クラス設計 | | `TEST_` | `06_TEST/` | テスト計画,品質保証 | 単体テスト仕様書,シナリオテスト計画,品質基準 | +| `REPORT_` | `07_REPORT/` | 評価報告書,研究結果 | 照明均一性評価報告書,装置比較報告書 | ## 運用ルール (Operational Rules) diff --git "a/docs/07_REPORT/REPORT_01_\347\205\247\346\230\216\345\235\207\344\270\200\346\200\247\350\251\225\344\276\241\345\240\261\345\221\212\346\233\270.md" "b/docs/07_REPORT/REPORT_01_\347\205\247\346\230\216\345\235\207\344\270\200\346\200\247\350\251\225\344\276\241\345\240\261\345\221\212\346\233\270.md" new file mode 100644 index 0000000..a1bb80e --- /dev/null +++ "b/docs/07_REPORT/REPORT_01_\347\205\247\346\230\216\345\235\207\344\270\200\346\200\247\350\251\225\344\276\241\345\240\261\345\221\212\346\233\270.md" @@ -0,0 +1,152 @@ +# 照明均一性評価報告書 (Illumination Uniformity Evaluation Report) + +## 評価概要 (Evaluation Overview) + +- 装置: MiniTIAS(小型口腔内画像取得装置) +- 撮影日: 2026-04-08 +- 評価対象: 白板画像 12 枚 +- 手法: [SPEC_02_照明均一性評価アルゴリズム](../04_SPEC/SPEC_02_照明均一性評価アルゴリズム.md) +- 集計統計出力: バッチ解析時に `output/results/summary_statistics.csv` として自動出力される +- 輝度変換: ITU-R BT.709(Y = 0.2126R + 0.7152G + 0.0722B) +- 均一性指標: CoV,標準偏差,最大/最小比 +- 空間分析: 3 ゾーン(center d<0.33 / middle / periphery d≥0.66) +- ROI: x=539, y=1015, w=1400, h=1409 + +## 結果の要約 (Results Summary) + +### 均一性指標 (Uniformity Metrics) + +| 画像名 | 平均輝度 | 標準偏差 | CoV | 最大/最小比 | 最大値 | 最小値 | +| --- | --- | --- | --- | --- | --- | --- | +| MiniTIAS_20260408_140317 | 145.97 | 6.18 | 0.0423 | 1.387 | 161.65 | 116.52 | +| MiniTIAS_20260408_140342 | 145.31 | 6.18 | 0.0425 | 1.375 | 162.65 | 118.30 | +| MiniTIAS_20260408_140408 | 144.58 | 6.11 | 0.0423 | 1.493 | 161.65 | 108.30 | +| MiniTIAS_20260408_141805 | 145.06 | 6.17 | 0.0426 | 1.417 | 160.65 | 113.37 | +| MiniTIAS_20260408_141900 | 144.98 | 6.21 | 0.0428 | 1.422 | 165.65 | 116.52 | +| MiniTIAS_20260408_141945 | 144.85 | 6.21 | 0.0429 | 1.440 | 167.58 | 116.37 | +| MiniTIAS_20260408_144434 | 146.71 | 6.26 | 0.0426 | 1.392 | 164.65 | 118.30 | +| MiniTIAS_20260408_145011 | 146.59 | 6.22 | 0.0424 | 1.371 | 163.08 | 118.94 | +| MiniTIAS_20260408_145320 | 146.66 | 6.27 | 0.0427 | 1.374 | 162.65 | 118.37 | +| MiniTIAS_20260408_150524 | 146.74 | 6.24 | 0.0425 | 1.412 | 164.23 | 116.30 | +| MiniTIAS_20260408_155530 | 145.00 | 6.19 | 0.0427 | 1.358 | 160.65 | 118.30 | +| MiniTIAS_20260408_155541 | 144.95 | 6.20 | 0.0428 | 1.381 | 160.65 | 116.30 | + +| 統計量 | 平均輝度 | 標準偏差 | CoV | 最大/最小比 | 最大値 | 最小値 | +| --- | --- | --- | --- | --- | --- | --- | +| 平均 (Mean) | 145.62 | 6.20 | 0.0426 | 1.402 | 162.98 | 116.32 | +| 画像間 SD | 0.81 | 0.04 | 0.0002 | 0.036 | — | — | +| 画像間 CV | 0.0056 | 0.0067 | 0.0044 | 0.0257 | — | — | +| 最小 (Min) | 144.58 | 6.11 | 0.0423 | 1.358 | 160.65 | 108.30 | +| 最大 (Max) | 146.74 | 6.27 | 0.0429 | 1.493 | 167.58 | 118.94 | +| 範囲 (Range) | 2.16 | 0.16 | 0.0006 | 0.135 | 6.93 | 10.64 | + +### 空間分析指標 (Spatial Analysis Metrics) + +| 画像名 | 中心平均 | 中間平均 | 周辺平均 | 中心/周辺比 | 勾配量 (%) | +| --- | --- | --- | --- | --- | --- | +| MiniTIAS_20260408_140317 | 151.02 | 146.97 | 141.61 | 1.0664 | 6.23 | +| MiniTIAS_20260408_140342 | 150.33 | 146.31 | 140.97 | 1.0664 | 6.23 | +| MiniTIAS_20260408_140408 | 149.50 | 145.57 | 140.29 | 1.0656 | 6.16 | +| MiniTIAS_20260408_141805 | 149.92 | 146.07 | 140.81 | 1.0647 | 6.08 | +| MiniTIAS_20260408_141900 | 149.89 | 146.00 | 140.67 | 1.0655 | 6.15 | +| MiniTIAS_20260408_141945 | 149.79 | 145.85 | 140.54 | 1.0658 | 6.17 | +| MiniTIAS_20260408_144434 | 151.68 | 147.70 | 142.43 | 1.0650 | 6.10 | +| MiniTIAS_20260408_145011 | 151.46 | 147.58 | 142.34 | 1.0641 | 6.02 | +| MiniTIAS_20260408_145320 | 151.63 | 147.65 | 142.36 | 1.0651 | 6.11 | +| MiniTIAS_20260408_150524 | 151.68 | 147.73 | 142.46 | 1.0647 | 6.08 | +| MiniTIAS_20260408_155530 | 149.62 | 146.02 | 140.84 | 1.0623 | 5.86 | +| MiniTIAS_20260408_155541 | 149.60 | 145.96 | 140.78 | 1.0627 | 5.90 | + +| 統計量 | 中心平均 | 中間平均 | 周辺平均 | 中心/周辺比 | 勾配量 (%) | +| --- | --- | --- | --- | --- | --- | +| 平均 (Mean) | 150.51 | 146.62 | 141.34 | 1.0649 | 6.09 | +| 画像間 SD | 0.87 | 0.81 | 0.80 | 0.0012 | 0.11 | +| 画像間 CV | 0.0058 | 0.0055 | 0.0057 | 0.0012 | 0.0183 | +| 最小 (Min) | 149.50 | 145.57 | 140.29 | 1.0623 | 5.86 | +| 最大 (Max) | 151.68 | 147.73 | 142.46 | 1.0664 | 6.23 | +| 範囲 (Range) | 2.18 | 2.16 | 2.17 | 0.0041 | 0.37 | + +### 代表画像の可視化 (Representative Visualization) + +代表画像として MiniTIAS_20260408_144434(中央値に近い値)を使用する. + +[図: 輝度マップ(MiniTIAS_20260408_144434)] + +[図: 輝度ヒストグラム(MiniTIAS_20260408_144434)] + +[図: 放射状輝度プロファイル(MiniTIAS_20260408_144434)] + +[図: ゾーンオーバーレイマップ(MiniTIAS_20260408_144434)] + +## 考察 (Discussion) + +### 均一性の水準評価 (Uniformity Level Assessment) + +- CoV が全画像で 0.0423–0.0429 の範囲にあり,0.05 を下回る +- 一般的な照明工学の基準では CoV < 0.10 が許容範囲,CoV < 0.05 が良好とされる +- 本評価結果は「良好」の水準に該当する +- 標準偏差も 6.11–6.27 と安定しており,平均輝度に対して約 4% のばらつきに収まる + +### 最大/最小比の解釈 (Max/Min Ratio Interpretation) + +- 全画像の最大/最小比は 1.358–1.493 の範囲 +- MiniTIAS_20260408_140408 のみ 1.493 と他の画像より突出(最小値 108.30 が特異的に低い) +- この画像を除くと範囲は 1.358–1.440 に収まり,局所的な異常の可能性がある +- 最小値の低下は ROI 端部の影やセンサーノイズが原因と考えられる + +### 中心-周辺勾配の特徴 (Center-Periphery Gradient Characteristics) + +- 全画像で中心 > 中間 > 周辺の単調減少パターンが確認された +- 中心/周辺比は 1.0623–1.0664 で,中心が周辺より約 6.5% 明るい +- 勾配量は 5.86–6.23% で,中心から周辺にかけて一貫した輝度減衰がある +- これは典型的なビネッティング(周辺光量低下)パターンであり,以下が原因と推定される: + - LED 光源の配置が中心方向に集中している + - レンズ系のコサイン 4 乗則による周辺光量低下 + - 筐体内壁の反射特性の不均一 + +### 再現性の評価 (Reproducibility Assessment) + +画像間変動係数(CV = 標準偏差 / 平均,母集団標準偏差 ddof=0)に基づいた定量的な再現性評価を以下に示す. + +- **平均輝度の CV が 0.0056(約 0.6%)** → 撮影ごとの輝度レベルが極めて安定しており,高い再現性を示す +- **CoV の CV が 0.0044(約 0.4%)** → 均一性評価指標自体の再現性も高く,評価結果が撮影条件に依存しにくいことを示す +- **中心/周辺比の CV が 0.0012(約 0.1%)** → 空間的な照明パターンが撮影間で非常に安定しており,ビネッティング特性が固定されていることを確認 +- **勾配量の CV が 0.0183(約 1.8%)** → 他の指標と比べてやや大きいが,範囲は 0.37%(5.86–6.23%)に収まり,実用上は十分に安定している +- 以上より,12 枚の撮影間で照明条件が極めて安定しており,MiniTIAS の照明系は高い再現性を持つと結論する + +## 改善提案 (Improvement Proposals) + +### 拡散構造の最適化 (Diffuser Optimization) + +- 現状の約 6% の中心-周辺勾配を低減するため,拡散板の追加または変更を提案 +- 拡散板の厚みや材質を調整し,周辺方向への光の散乱を増加させる +- 目標: 勾配量を 3% 以下に低減 + +### 光源配置の調整 (Light Source Arrangement) + +- LED の配置角度を外向きに調整し,中心への光の集中を緩和する +- 追加の周辺 LED を配置し,周辺光量を直接的に補う + +### 反射板の追加 (Reflector Addition) + +- 筐体内壁の反射率を向上させる反射板またはコーティングを追加 +- 内壁での反射光により周辺光量を補完する + +### ソフトウェア補正 (Software Correction) + +- 暫定対策として,フラットフィールド補正を提案 +- 白板画像の平均輝度マップを正規化し,撮影画像を除算することで照明ムラを補正 +- ハードウェア改善と併用可能 + +## 今後の課題 (Future Work) + +- TIAS(従来型口腔内画像取得装置)との照明均一性の比較評価(開発ステップ 3) +- 長期間(日単位・週単位)にわたる経時安定性の評価 +- 動作温度の変化に対する照明特性の依存性評価 +- 異なるホワイトバランス・露出設定が均一性指標に与える影響の評価 +- 撮影距離(白板と開口部の距離)の変化に対する均一性の評価 + +## 参考資料 (References) + +- [SPEC_02_照明均一性評価アルゴリズム](../04_SPEC/SPEC_02_照明均一性評価アルゴリズム.md) +- ITU-R BT.709-6 diff --git a/scripts/run_uniformity.py b/scripts/run_uniformity.py index ad95532..6406d64 100644 --- a/scripts/run_uniformity.py +++ b/scripts/run_uniformity.py @@ -21,7 +21,9 @@ from src.analysis.spatial import calc_spatial_uniformity # noqa: E402 from src.analysis.uniformity import calc_uniformity, to_grayscale # noqa: E402 from src.export.exporter import ( # noqa: E402 + calc_batch_statistics, ensure_output_dirs, + export_batch_statistics, export_results, export_spatial_summary, export_summary, @@ -211,6 +213,22 @@ spatial_summary_path = str(output_base / "results" / "summary_spatial.csv") export_spatial_summary(all_results, spatial_summary_path) + # 画像間の集計統計を算出・出力 + batch_stats = calc_batch_statistics(all_results) + stats_path = str(output_base / "results" / "summary_statistics.csv") + export_batch_statistics(batch_stats, stats_path) + + print("--- 画像間の集計統計 ---") + for category, metrics in batch_stats.items(): + print(f" [{category}]") + for metric_name, values in metrics.items(): + print( + f" {metric_name}: " + f"mean={values['mean']:.4f}, " + f"std={values['std']:.4f}, " + f"cv={values['cv']:.4f}" + ) + print(f"バッチ解析完了: {len(all_results)} / {len(png_files)} 枚成功") else: diff --git a/scripts/viewer.py b/scripts/viewer.py index 46fe482..8d67618 100644 --- a/scripts/viewer.py +++ b/scripts/viewer.py @@ -39,6 +39,7 @@ DATA_DIR = PROJECT_ROOT / "data" / "minitias" / "whiteboard" ROI_CONFIG = PROJECT_ROOT / "config" / "roi_config.json" SUMMARY_CSV = RESULTS_DIR / "summary_uniformity.csv" +SPATIAL_CSV = RESULTS_DIR / "summary_spatial.csv" def load_roi() -> dict | None: @@ -76,11 +77,14 @@ return pandas.read_csv(SUMMARY_CSV) -def render_tab_overview(df: pandas.DataFrame) -> None: +def render_tab_overview( + df: pandas.DataFrame, spatial_df: pandas.DataFrame | None +) -> None: """タブ1「全体比較」を描画する. Args: df: サマリー DataFrame. + spatial_df: 空間分析サマリー DataFrame.存在しない場合は None. """ st.header("全体比較") @@ -100,6 +104,46 @@ summary_stats = df[numeric_cols].describe() st.dataframe(summary_stats.style.format("{:.4f}"), use_container_width=True) + # 画像間変動係数(再現性指標) + st.subheader("画像間ばらつき(再現性)") + cv_data = {} + for col in numeric_cols: + values = df[col].values + col_mean = numpy.mean(values) + col_std = numpy.std(values, ddof=0) + cv_data[col] = { + "平均": f"{col_mean:.4f}", + "標準偏差": f"{col_std:.4f}", + "変動係数 (CV)": f"{col_std / col_mean:.6f}" if col_mean != 0 else "N/A", + "範囲": f"{numpy.max(values) - numpy.min(values):.4f}", + } + cv_df = pandas.DataFrame(cv_data).T + cv_df.index.name = "指標" + st.dataframe(cv_df, use_container_width=True) + st.caption("※ 変動係数 (CV) が小さいほど画像間の再現性が高い") + + # 空間分析の画像間ばらつき + if spatial_df is not None: + st.subheader("空間分析 画像間ばらつき(再現性)") + spatial_cols = ["center_mean", "middle_mean", "periphery_mean", + "center_periphery_ratio", "gradient_magnitude"] + spatial_cv_data = {} + for col in spatial_cols: + values = spatial_df[col].values + col_mean = numpy.mean(values) + col_std = numpy.std(values, ddof=0) + spatial_cv_data[col] = { + "平均": f"{col_mean:.4f}", + "標準偏差": f"{col_std:.4f}", + "変動係数 (CV)": ( + f"{col_std / col_mean:.6f}" if col_mean != 0 else "N/A" + ), + "範囲": f"{numpy.max(values) - numpy.min(values):.4f}", + } + spatial_cv_df = pandas.DataFrame(spatial_cv_data).T + spatial_cv_df.index.name = "指標" + st.dataframe(spatial_cv_df, use_container_width=True) + # 比較グラフ(2x2 棒グラフ) st.subheader("指標比較グラフ") fig, axes = plt.subplots(2, 2, figsize=(14, 9)) @@ -241,10 +285,15 @@ st.error(str(e)) return + # 空間分析 CSV の読み込み(任意) + spatial_df = None + if SPATIAL_CSV.exists(): + spatial_df = pandas.read_csv(SPATIAL_CSV) + tab_overview, tab_individual = st.tabs(["全体比較", "個別画像"]) with tab_overview: - render_tab_overview(df) + render_tab_overview(df, spatial_df) with tab_individual: render_tab_individual(df) diff --git a/src/export/exporter.py b/src/export/exporter.py index 2d7b864..3f771ca 100644 --- a/src/export/exporter.py +++ b/src/export/exporter.py @@ -3,6 +3,8 @@ import csv from pathlib import Path +import numpy as np + SUMMARY_FIELDNAMES = ["image_name", "mean", "std", "cov", "max_min_ratio", "max", "min"] SPATIAL_FIELDNAMES = [ "image_name", @@ -87,3 +89,80 @@ writer.writerows(all_results) print(f"空間解析まとめ CSV を保存しました: {output_path}") + + +def calc_batch_statistics(all_results: list[dict]) -> dict: + """バッチ解析結果から画像間の集計統計を算出する. + + 各指標について mean, std, min, max, cv(画像間変動係数)を計算する. + + Args: + all_results: 均一性指標と空間解析結果を含む辞書リスト. + + Returns: + {"uniformity": {...}, "spatial": {...}} の形式. + 各値は {指標名: {"mean": ..., "std": ..., "min": ..., "max": ..., "cv": ...}} + の辞書. + """ + uniformity_metrics = ["mean", "std", "cov", "max_min_ratio"] + spatial_metrics = [ + "center_mean", + "middle_mean", + "periphery_mean", + "center_periphery_ratio", + "gradient_magnitude", + ] + + def _calc_metric_stats(metric_name: str) -> dict: + values = np.array([r[metric_name] for r in all_results if metric_name in r]) + m = float(np.mean(values)) + s = float(np.std(values, ddof=0)) + cv = s / m if m != 0 else 0.0 + return { + "mean": m, + "std": s, + "min": float(np.min(values)), + "max": float(np.max(values)), + "cv": cv, + } + + uniformity_stats = { + metric: _calc_metric_stats(metric) for metric in uniformity_metrics + } + spatial_stats = { + metric: _calc_metric_stats(metric) for metric in spatial_metrics + } + + return {"uniformity": uniformity_stats, "spatial": spatial_stats} + + +def export_batch_statistics(stats: dict, output_path: str) -> None: + """集計統計を CSV ファイルに出力する. + + 行: 各指標名,列: statistic, mean, std, min, max, cv + + Args: + stats: calc_batch_statistics の戻り値. + output_path: 出力先ファイルパス(CSV). + """ + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + + fieldnames = ["statistic", "mean", "std", "min", "max", "cv"] + rows = [] + for category in ("uniformity", "spatial"): + for metric_name, values in stats[category].items(): + rows.append({ + "statistic": metric_name, + "mean": values["mean"], + "std": values["std"], + "min": values["min"], + "max": values["max"], + "cv": values["cv"], + }) + + with Path(output_path).open("w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + + print(f"集計統計 CSV を保存しました: {output_path}") diff --git a/tests/export/test_exporter.py b/tests/export/test_exporter.py index cfb61fc..8b7afb8 100644 --- a/tests/export/test_exporter.py +++ b/tests/export/test_exporter.py @@ -13,12 +13,15 @@ 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, @@ -393,3 +396,545 @@ 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 diff --git a/tests/scripts/test_viewer.py b/tests/scripts/test_viewer.py index 73256c7..c1be5a5 100644 --- a/tests/scripts/test_viewer.py +++ b/tests/scripts/test_viewer.py @@ -440,3 +440,106 @@ ) with pytest.raises(FileNotFoundError, match="run_uniformity"): viewer_module.load_summary() + + +# --------------------------------------------------------------------------- +# SPATIAL_CSV 定数テスト +# --------------------------------------------------------------------------- + + +class TestSpatialCsvConstant: + """SPATIAL_CSV 定数のテスト群.""" + + def test_spatial_csv_constant_exists(self) -> None: + """正常: SPATIAL_CSV 定数が viewer モジュールに存在すること.""" + import scripts.viewer as viewer_module + + assert hasattr(viewer_module, "SPATIAL_CSV") + + def test_spatial_csv_is_path_object(self) -> None: + """正常: SPATIAL_CSV が Path オブジェクトであること.""" + import scripts.viewer as viewer_module + + assert isinstance(viewer_module.SPATIAL_CSV, Path) + + def test_spatial_csv_filename_is_summary_spatial(self) -> None: + """正常: SPATIAL_CSV のファイル名が summary_spatial.csv であること.""" + import scripts.viewer as viewer_module + + assert viewer_module.SPATIAL_CSV.name == "summary_spatial.csv" + + def test_spatial_csv_is_under_results_dir(self) -> None: + """正常: SPATIAL_CSV が results/ ディレクトリ配下であること.""" + import scripts.viewer as viewer_module + + assert viewer_module.SPATIAL_CSV.parent.name == "results" + + +# --------------------------------------------------------------------------- +# render_tab_overview 引数テスト +# --------------------------------------------------------------------------- + + +class TestRenderTabOverview: + """render_tab_overview 関数の引数仕様テスト群. + + render_tab_overview(df, spatial_df) の形式で呼び出せること, + および spatial_df=None で正常動作することを検証する. + Streamlit の描画は単体テストでは検証せず,引数の受け付けのみ確認する. + """ + + @pytest.fixture + def sample_df(self) -> "pandas.DataFrame": + """テスト用の均一性指標 DataFrame を返す fixture.""" + return pandas.DataFrame( + { + "image_name": ["img_001", "img_002"], + "mean": [200.0, 195.0], + "std": [5.0, 6.0], + "cov": [0.025, 0.030], + "max_min_ratio": [1.05, 1.08], + "max": [210.0, 205.0], + "min": [190.0, 185.0], + } + ) + + @pytest.fixture + def sample_spatial_df(self) -> "pandas.DataFrame": + """テスト用の空間分析指標 DataFrame を返す fixture.""" + return pandas.DataFrame( + { + "image_name": ["img_001", "img_002"], + "center_mean": [180.0, 185.0], + "middle_mean": [170.0, 175.0], + "periphery_mean": [150.0, 155.0], + "center_periphery_ratio": [1.2, 1.19], + "gradient_magnitude": [16.7, 16.2], + } + ) + + def test_render_tab_overview_accepts_two_arguments( + self, sample_df: "pandas.DataFrame", sample_spatial_df: "pandas.DataFrame" + ) -> None: + """正常: render_tab_overview が df と spatial_df の 2 引数を受け付けること.""" + from scripts.viewer import render_tab_overview + import inspect + + sig = inspect.signature(render_tab_overview) + params = list(sig.parameters.keys()) + assert "df" in params + assert "spatial_df" in params + + def test_render_tab_overview_spatial_df_has_default_or_nullable(self) -> None: + """正常: render_tab_overview の spatial_df 引数が None を受け付けること(型ヒント確認).""" + from scripts.viewer import render_tab_overview + import inspect + + sig = inspect.signature(render_tab_overview) + # spatial_df パラメータのアノテーションに None が含まれることを確認 + spatial_df_param = sig.parameters.get("spatial_df") + assert spatial_df_param is not None, "spatial_df パラメータが存在しない" + # アノテーションが Union/Optional の場合、文字列表現で None を確認 + annotation = str(spatial_df_param.annotation) + assert "None" in annotation or "Optional" in annotation, ( + f"spatial_df の型ヒントに None が含まれない: {annotation}" + )