diff --git a/CLAUDE.md b/CLAUDE.md index b5568a1..d302f5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -現在の進捗: ステップ 1(プロジェクト骨格の構築)完了.次はステップ 2(MiniTIAS 照明均一性評価) +現在の進捗: ステップ 2(MiniTIAS 照明均一性評価)完了.次はステップ 3 ※ ステップ完了時にここを更新すること. ## 必須ルール(コード実装時) diff --git a/requirements.txt b/requirements.txt index 0ec7a8f..af813d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ scipy matplotlib pandas +streamlit diff --git a/scripts/viewer.py b/scripts/viewer.py new file mode 100644 index 0000000..46fe482 --- /dev/null +++ b/scripts/viewer.py @@ -0,0 +1,254 @@ +"""MiniTIAS 照明均一性評価 結果ビューア. + +使い方: + streamlit run scripts/viewer.py +""" + +import json +import sys +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy +import pandas +import streamlit as st +from PIL import Image, ImageDraw + + +def _find_project_root() -> Path: + """CLAUDE.md の存在でプロジェクトルートを特定する. + + Returns: + プロジェクトルートの Path. + + Raises: + FileNotFoundError: CLAUDE.md が見つからない場合. + """ + p = Path(__file__).resolve().parent + for parent in [p] + list(p.parents): + if (parent / "CLAUDE.md").exists(): + return parent + raise FileNotFoundError("CLAUDE.md not found — プロジェクトルートを特定できません") + + +PROJECT_ROOT = _find_project_root() +sys.path.insert(0, str(PROJECT_ROOT)) + +RESULTS_DIR = PROJECT_ROOT / "output" / "results" +FIGURES_DIR = PROJECT_ROOT / "output" / "figures" +DATA_DIR = PROJECT_ROOT / "data" / "minitias" / "whiteboard" +ROI_CONFIG = PROJECT_ROOT / "config" / "roi_config.json" +SUMMARY_CSV = RESULTS_DIR / "summary_uniformity.csv" + + +def load_roi() -> dict | None: + """ROI 設定を読み込む.""" + if not ROI_CONFIG.exists(): + return None + with open(ROI_CONFIG, encoding="utf-8") as f: + config = json.load(f) + return config.get("minitias", {}).get("whiteboard") + + +def overlay_roi(image_path: Path, roi: dict) -> Image.Image: + """元画像に ROI 矩形をオーバーレイした画像を返す.""" + img = Image.open(image_path).copy() + draw = ImageDraw.Draw(img) + x, y, w, h = roi["x"], roi["y"], roi["width"], roi["height"] + draw.rectangle([x, y, x + w, y + h], outline="lime", width=6) + return img + + +def load_summary() -> pandas.DataFrame: + """サマリー CSV を読み込む. + + Returns: + 均一性指標の DataFrame. + + Raises: + FileNotFoundError: summary_uniformity.csv が存在しない場合. + """ + if not SUMMARY_CSV.exists(): + raise FileNotFoundError( + f"サマリーファイルが見つかりません: {SUMMARY_CSV}\n" + "先に run_uniformity.py でバッチ解析を実行してください." + ) + return pandas.read_csv(SUMMARY_CSV) + + +def render_tab_overview(df: pandas.DataFrame) -> None: + """タブ1「全体比較」を描画する. + + Args: + df: サマリー DataFrame. + """ + st.header("全体比較") + + # 指標テーブル + st.subheader("均一性指標テーブル") + display_df = df.copy() + # 小数点以下4桁の指標と2桁の指標を一括フォーマットする + fmt_4f = {"cov": "{:.4f}", "max_min_ratio": "{:.4f}"} + fmt_2f = {"std": "{:.2f}", "mean": "{:.2f}", "max": "{:.2f}", "min": "{:.2f}"} + for col, fmt in {**fmt_4f, **fmt_2f}.items(): + display_df[col] = display_df[col].map(fmt.format) + st.dataframe(display_df, use_container_width=True) + + # 統計サマリー + st.subheader("統計サマリー") + numeric_cols = ["mean", "std", "cov", "max_min_ratio", "max", "min"] + summary_stats = df[numeric_cols].describe() + st.dataframe(summary_stats.style.format("{:.4f}"), use_container_width=True) + + # 比較グラフ(2x2 棒グラフ) + st.subheader("指標比較グラフ") + fig, axes = plt.subplots(2, 2, figsize=(14, 9)) + + image_names = df["image_name"].tolist() + # 長い名前を短縮してグラフを読みやすくする + short_names = [name[-8:] for name in image_names] + + metrics = [ + ("cov", "CoV (Coefficient of Variation)", "CoV", "steelblue"), + ("std", "Std (Standard Deviation)", "Std", "coral"), + ("max_min_ratio", "Max/Min Ratio", "Max/Min Ratio", "mediumseagreen"), + ("mean", "Mean Luminance", "Luminance", "mediumpurple"), + ] + + for ax, (col, title, ylabel, color) in zip(axes.ravel(), metrics): + ax.bar(range(len(image_names)), df[col], color=color, edgecolor="none") + ax.set_title(title) + ax.set_xlabel("Image") + ax.set_ylabel(ylabel) + ax.set_xticks(range(len(image_names))) + ax.set_xticklabels(short_names, rotation=45, ha="right", fontsize=8) + + fig.tight_layout() + st.pyplot(fig) + plt.close(fig) + + +def render_tab_individual(df: pandas.DataFrame) -> None: + """タブ2「個別画像」を描画する. + + Args: + df: サマリー DataFrame. + """ + st.header("個別画像") + + image_names = df["image_name"].tolist() + + # session_state でインデックスを管理 + if "individual_idx" not in st.session_state: + st.session_state.individual_idx = 0 + + def _go_prev() -> None: + new_idx = max(0, st.session_state.individual_idx - 1) + st.session_state.individual_idx = new_idx + st.session_state.individual_select = image_names[new_idx] + + def _go_next() -> None: + new_idx = min(len(image_names) - 1, st.session_state.individual_idx + 1) + st.session_state.individual_idx = new_idx + st.session_state.individual_select = image_names[new_idx] + + def _on_select() -> None: + st.session_state.individual_idx = image_names.index( + st.session_state.individual_select + ) + + # ナビゲーションボタン + btn_prev, btn_next, _ = st.columns([1, 1, 8]) + with btn_prev: + st.button( + "← 前へ", + on_click=_go_prev, + disabled=st.session_state.individual_idx <= 0, + ) + with btn_next: + st.button( + "次へ →", + on_click=_go_next, + disabled=st.session_state.individual_idx >= len(image_names) - 1, + ) + + # ドロップダウン(ボタン操作と同期) + selected = st.selectbox( + "画像を選択", + image_names, + index=st.session_state.individual_idx, + key="individual_select", + on_change=_on_select, + ) + + # 選択行の指標 + row = df[df["image_name"] == selected].iloc[0] + + st.subheader("均一性指標") + col1, col2, col3, col4 = st.columns(4) + col1.metric("平均輝度", f"{row['mean']:.2f}") + col2.metric("標準偏差", f"{row['std']:.2f}") + col3.metric("CoV", f"{row['cov']:.4f}") + col4.metric("最大/最小比", f"{row['max_min_ratio']:.4f}") + + # 元画像・輝度マップ・ヒストグラムを横並びで表示 + original_path = DATA_DIR / f"{selected}.png" + luminance_map_path = FIGURES_DIR / f"{selected}_luminance_map.png" + histogram_path = FIGURES_DIR / f"{selected}_histogram.png" + + st.subheader("元画像 / 輝度マップ / ヒストグラム") + orig_col, map_col, hist_col = st.columns([1, 2, 2]) + + with orig_col: + st.caption("元画像(ROI 表示)") + if original_path.exists(): + roi = load_roi() + if roi: + st.image(overlay_roi(original_path, roi), use_container_width=True) + else: + st.image(str(original_path), use_container_width=True) + else: + st.warning(f"元画像が見つかりません: {original_path.name}") + + with map_col: + st.caption("輝度マップ") + if luminance_map_path.exists(): + st.image(str(luminance_map_path), use_container_width=True) + else: + st.warning(f"輝度マップが見つかりません: {luminance_map_path.name}") + + with hist_col: + st.caption("輝度ヒストグラム") + if histogram_path.exists(): + st.image(str(histogram_path), use_container_width=True) + else: + st.warning(f"ヒストグラムが見つかりません: {histogram_path.name}") + + +def main() -> None: + """Streamlit アプリのエントリーポイント.""" + st.set_page_config( + page_title="MiniTIAS 照明均一性評価ビューア", + page_icon=None, + layout="wide", + ) + st.title("MiniTIAS 照明均一性評価ビューア") + + # サマリー CSV の読み込み + try: + df = load_summary() + except FileNotFoundError as e: + st.error(str(e)) + return + + tab_overview, tab_individual = st.tabs(["全体比較", "個別画像"]) + + with tab_overview: + render_tab_overview(df) + + with tab_individual: + render_tab_individual(df) + + +if __name__ == "__main__": + main() diff --git a/tests/scripts/test_viewer.py b/tests/scripts/test_viewer.py new file mode 100644 index 0000000..73256c7 --- /dev/null +++ b/tests/scripts/test_viewer.py @@ -0,0 +1,442 @@ +"""viewer.py のユーティリティ関数の単体テスト. + +テスト対象(仕様に基づく): + - _find_project_root: CLAUDE.md を探してプロジェクトルートを特定する + - CLAUDE.md が存在するディレクトリを返す + - CLAUDE.md が存在しない場合に FileNotFoundError を送出する + - load_roi: config/roi_config.json から ROI 設定を読み込む + - ファイルが存在しない場合 None を返す + - ファイルが存在し minitias.whiteboard キーがある場合 dict を返す + - ファイルが存在するが対応キーがない場合 None を返す + - overlay_roi: 元画像に ROI 矩形をオーバーレイした PIL Image を返す + - 戻り値が PIL Image であること + - 入力画像と同サイズであること + - ROI の x, y, width, height が正しく矩形に反映されること + - load_summary: output/results/summary_uniformity.csv を読み込む + - ファイルが存在しない場合 FileNotFoundError を送出する + - ファイルが存在する場合 DataFrame を返す + +テスト方針 (GUIDE_08_テスト方針.md): + - pytest を使用 + - tmp_path フィクスチャを利用 + - 合成画像(PIL.Image)でテストデータを生成する +""" + +import importlib +import json +import sys +import types +from pathlib import Path + +import numpy +import pandas +import pytest +from PIL import Image + +# スクリプトをインポートするため sys.path を調整 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) + + +# --------------------------------------------------------------------------- +# _find_project_root テスト +# --------------------------------------------------------------------------- + + +class TestFindProjectRoot: + """_find_project_root 関数のテスト群.""" + + def test_returns_path_containing_claude_md(self) -> None: + """正常: 返された Path に CLAUDE.md が存在すること.""" + from scripts.viewer import _find_project_root + + result = _find_project_root() + assert (result / "CLAUDE.md").exists() + + def test_returns_path_object(self) -> None: + """正常: 戻り値が Path オブジェクトであること.""" + from scripts.viewer import _find_project_root + + result = _find_project_root() + assert isinstance(result, Path) + + def test_raises_file_not_found_when_claude_md_absent( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """異常: CLAUDE.md が存在しないディレクトリ構造では FileNotFoundError を送出すること.""" + # viewer モジュールを tmp_path 配下に疑似的に配置させるため、 + # _find_project_root の __file__ を tmp_path 配下に差し替える + from scripts import viewer as viewer_module + + # 一時ディレクトリ構造(CLAUDE.md なし)で疑似モジュールを作成 + fake_script_path = tmp_path / "scripts" / "viewer.py" + + def patched_find_project_root() -> Path: + """CLAUDE.md を探す(tmp_path 内なので見つからない).""" + p = fake_script_path.resolve().parent + for parent in [p] + list(p.parents): + # tmp_path の外側には実際のプロジェクトルートがあるため、 + # tmp_path 内のみに制限して検索を打ち切る + if parent == tmp_path.parent: + break + if (parent / "CLAUDE.md").exists(): + return parent + raise FileNotFoundError("CLAUDE.md not found — プロジェクトルートを特定できません") + + with pytest.raises(FileNotFoundError): + patched_find_project_root() + + def test_error_message_mentions_claude_md( + self, tmp_path: Path + ) -> None: + """異常: FileNotFoundError のメッセージに CLAUDE.md が含まれること.""" + fake_script_path = tmp_path / "scripts" / "viewer.py" + + def patched_find_project_root() -> Path: + p = fake_script_path.resolve().parent + for parent in [p] + list(p.parents): + if parent == tmp_path.parent: + break + if (parent / "CLAUDE.md").exists(): + return parent + raise FileNotFoundError("CLAUDE.md not found — プロジェクトルートを特定できません") + + with pytest.raises(FileNotFoundError, match="CLAUDE.md"): + patched_find_project_root() + + +# --------------------------------------------------------------------------- +# load_roi テスト +# --------------------------------------------------------------------------- + + +class TestLoadRoi: + """load_roi 関数のテスト群.""" + + def test_returns_none_when_config_file_absent( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """正常: ROI 設定ファイルが存在しない場合 None を返すこと.""" + import scripts.viewer as viewer_module + + monkeypatch.setattr(viewer_module, "ROI_CONFIG", tmp_path / "no_such_config.json") + result = viewer_module.load_roi() + assert result is None + + def test_returns_dict_when_config_has_minitias_whiteboard( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """正常: minitias.whiteboard キーが存在する設定ファイルから ROI dict を返すこと.""" + import scripts.viewer as viewer_module + + config = { + "minitias": { + "whiteboard": {"x": 100, "y": 200, "width": 400, "height": 300} + } + } + config_path = tmp_path / "roi_config.json" + config_path.write_text(json.dumps(config), encoding="utf-8") + monkeypatch.setattr(viewer_module, "ROI_CONFIG", config_path) + + result = viewer_module.load_roi() + assert result == {"x": 100, "y": 200, "width": 400, "height": 300} + + def test_returns_roi_dict_with_required_keys( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """正常: 返された ROI が x, y, width, height キーを持つこと.""" + import scripts.viewer as viewer_module + + config = { + "minitias": { + "whiteboard": {"x": 10, "y": 20, "width": 50, "height": 60} + } + } + config_path = tmp_path / "roi_config.json" + config_path.write_text(json.dumps(config), encoding="utf-8") + monkeypatch.setattr(viewer_module, "ROI_CONFIG", config_path) + + result = viewer_module.load_roi() + assert result is not None + for key in ("x", "y", "width", "height"): + assert key in result, f"ROI に '{key}' キーが存在しない" + + def test_returns_none_when_minitias_key_absent( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """正常: minitias キーが存在しない設定ファイルの場合 None を返すこと.""" + import scripts.viewer as viewer_module + + config = {"other_device": {"whiteboard": {"x": 0, "y": 0, "width": 10, "height": 10}}} + config_path = tmp_path / "roi_config.json" + config_path.write_text(json.dumps(config), encoding="utf-8") + monkeypatch.setattr(viewer_module, "ROI_CONFIG", config_path) + + result = viewer_module.load_roi() + assert result is None + + def test_returns_none_when_whiteboard_key_absent( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """正常: minitias キーはあるが whiteboard キーがない場合 None を返すこと.""" + import scripts.viewer as viewer_module + + config = {"minitias": {"other_region": {"x": 0, "y": 0, "width": 10, "height": 10}}} + config_path = tmp_path / "roi_config.json" + config_path.write_text(json.dumps(config), encoding="utf-8") + monkeypatch.setattr(viewer_module, "ROI_CONFIG", config_path) + + result = viewer_module.load_roi() + assert result is None + + def test_return_type_is_dict_when_roi_exists( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """正常: ROI が存在する場合の戻り値が dict 型であること.""" + import scripts.viewer as viewer_module + + config = {"minitias": {"whiteboard": {"x": 5, "y": 5, "width": 100, "height": 100}}} + config_path = tmp_path / "roi_config.json" + config_path.write_text(json.dumps(config), encoding="utf-8") + monkeypatch.setattr(viewer_module, "ROI_CONFIG", config_path) + + result = viewer_module.load_roi() + assert isinstance(result, dict) + + +# --------------------------------------------------------------------------- +# overlay_roi テスト +# --------------------------------------------------------------------------- + + +class TestOverlayRoi: + """overlay_roi 関数のテスト群.""" + + @pytest.fixture + def sample_image_path(self, tmp_path: Path) -> Path: + """テスト用 PNG 画像(100x80 px の均一グレー)を生成して返す fixture.""" + img = Image.fromarray( + numpy.full((80, 100, 3), 128, dtype=numpy.uint8), mode="RGB" + ) + image_path = tmp_path / "sample.png" + img.save(image_path) + return image_path + + @pytest.fixture + def sample_roi(self) -> dict: + """テスト用 ROI(x=10, y=10, width=40, height=30)を返す fixture.""" + return {"x": 10, "y": 10, "width": 40, "height": 30} + + def test_returns_pil_image(self, sample_image_path: Path, sample_roi: dict) -> None: + """正常: 戻り値が PIL Image オブジェクトであること.""" + from scripts.viewer import overlay_roi + + result = overlay_roi(sample_image_path, sample_roi) + assert isinstance(result, Image.Image) + + def test_output_size_matches_input_size( + self, sample_image_path: Path, sample_roi: dict + ) -> None: + """正常: 戻り値の画像サイズが入力画像と同じであること.""" + from scripts.viewer import overlay_roi + + original = Image.open(sample_image_path) + result = overlay_roi(sample_image_path, sample_roi) + assert result.size == original.size + + def test_output_mode_matches_input_mode( + self, sample_image_path: Path, sample_roi: dict + ) -> None: + """正常: 戻り値の画像モードが入力画像と同じであること.""" + from scripts.viewer import overlay_roi + + original = Image.open(sample_image_path) + result = overlay_roi(sample_image_path, sample_roi) + assert result.mode == original.mode + + def test_original_image_is_not_modified( + self, sample_image_path: Path, sample_roi: dict + ) -> None: + """正常: 元画像ファイルが変更されないこと(コピーで処理されること).""" + from scripts.viewer import overlay_roi + + original_data = numpy.array(Image.open(sample_image_path)) + overlay_roi(sample_image_path, sample_roi) + after_data = numpy.array(Image.open(sample_image_path)) + assert numpy.array_equal(original_data, after_data) + + def test_roi_outline_pixels_are_lime_colored( + self, sample_image_path: Path + ) -> None: + """正常: ROI 矩形の輪郭ピクセルにライムカラーが描画されること.""" + from scripts.viewer import overlay_roi + + roi = {"x": 20, "y": 10, "width": 30, "height": 20} + result = overlay_roi(sample_image_path, roi) + result_array = numpy.array(result) + + # ライムカラーは (0, 255, 0) + # 矩形の上辺ピクセル(y=10)を確認 + # 枠線幅が 6px のため y=10〜15 にライムカラーが存在するはず + top_row = result_array[10, 20:51, :3] + lime_found = numpy.any( + (top_row[:, 0] == 0) & (top_row[:, 1] == 255) & (top_row[:, 2] == 0) + ) + assert lime_found, "ROI 上辺にライムカラーが描画されていない" + + def test_different_roi_positions_produce_different_outputs( + self, sample_image_path: Path + ) -> None: + """正常: 異なる ROI 位置では異なる画像が生成されること.""" + from scripts.viewer import overlay_roi + + roi_a = {"x": 5, "y": 5, "width": 20, "height": 20} + roi_b = {"x": 50, "y": 40, "width": 20, "height": 20} + + result_a = numpy.array(overlay_roi(sample_image_path, roi_a)) + result_b = numpy.array(overlay_roi(sample_image_path, roi_b)) + + assert not numpy.array_equal(result_a, result_b) + + +# --------------------------------------------------------------------------- +# load_summary テスト +# --------------------------------------------------------------------------- + + +class TestLoadSummary: + """load_summary 関数のテスト群.""" + + def test_raises_file_not_found_when_csv_absent( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """異常: summary_uniformity.csv が存在しない場合 FileNotFoundError を送出すること.""" + import scripts.viewer as viewer_module + + monkeypatch.setattr( + viewer_module, "SUMMARY_CSV", tmp_path / "no_such_summary.csv" + ) + with pytest.raises(FileNotFoundError): + viewer_module.load_summary() + + def test_error_message_contains_summary_filename( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """異常: FileNotFoundError のメッセージにファイル名が含まれること.""" + import scripts.viewer as viewer_module + + monkeypatch.setattr( + viewer_module, "SUMMARY_CSV", tmp_path / "summary_uniformity.csv" + ) + with pytest.raises(FileNotFoundError, match="summary_uniformity.csv"): + viewer_module.load_summary() + + def test_returns_dataframe_when_csv_exists( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """正常: CSV ファイルが存在する場合 DataFrame を返すこと.""" + import scripts.viewer as viewer_module + + csv_path = tmp_path / "summary_uniformity.csv" + df_expected = 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], + } + ) + df_expected.to_csv(csv_path, index=False) + monkeypatch.setattr(viewer_module, "SUMMARY_CSV", csv_path) + + result = viewer_module.load_summary() + assert isinstance(result, pandas.DataFrame) + + def test_loaded_dataframe_has_correct_columns( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """正常: 読み込んだ DataFrame が必要なカラムを持つこと.""" + import scripts.viewer as viewer_module + + csv_path = tmp_path / "summary_uniformity.csv" + df_input = pandas.DataFrame( + { + "image_name": ["img_001"], + "mean": [200.0], + "std": [5.0], + "cov": [0.025], + "max_min_ratio": [1.05], + "max": [210.0], + "min": [190.0], + } + ) + df_input.to_csv(csv_path, index=False) + monkeypatch.setattr(viewer_module, "SUMMARY_CSV", csv_path) + + result = viewer_module.load_summary() + expected_columns = {"image_name", "mean", "std", "cov", "max_min_ratio", "max", "min"} + assert expected_columns.issubset(set(result.columns)) + + def test_loaded_dataframe_row_count_matches_csv( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """正常: 読み込んだ DataFrame の行数が CSV のデータ行数と一致すること.""" + import scripts.viewer as viewer_module + + csv_path = tmp_path / "summary_uniformity.csv" + df_input = pandas.DataFrame( + { + "image_name": ["img_001", "img_002", "img_003"], + "mean": [200.0, 195.0, 198.0], + "std": [5.0, 6.0, 4.5], + "cov": [0.025, 0.030, 0.022], + "max_min_ratio": [1.05, 1.08, 1.04], + "max": [210.0, 205.0, 208.0], + "min": [190.0, 185.0, 192.0], + } + ) + df_input.to_csv(csv_path, index=False) + monkeypatch.setattr(viewer_module, "SUMMARY_CSV", csv_path) + + result = viewer_module.load_summary() + assert len(result) == 3 + + def test_loaded_values_match_csv_content( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """正常: 読み込んだ DataFrame の値が CSV の内容と一致すること.""" + import scripts.viewer as viewer_module + + csv_path = tmp_path / "summary_uniformity.csv" + df_input = pandas.DataFrame( + { + "image_name": ["img_001"], + "mean": [200.0], + "std": [5.0], + "cov": [0.025], + "max_min_ratio": [1.05], + "max": [210.0], + "min": [190.0], + } + ) + df_input.to_csv(csv_path, index=False) + monkeypatch.setattr(viewer_module, "SUMMARY_CSV", csv_path) + + result = viewer_module.load_summary() + assert result.iloc[0]["image_name"] == "img_001" + assert numpy.isclose(result.iloc[0]["mean"], 200.0) + assert numpy.isclose(result.iloc[0]["cov"], 0.025) + + def test_error_message_contains_run_uniformity_hint( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """異常: FileNotFoundError のメッセージに run_uniformity.py の実行を促す案内が含まれること.""" + import scripts.viewer as viewer_module + + monkeypatch.setattr( + viewer_module, "SUMMARY_CSV", tmp_path / "summary_uniformity.csv" + ) + with pytest.raises(FileNotFoundError, match="run_uniformity"): + viewer_module.load_summary()