diff --git a/scripts/viewer.py b/scripts/viewer.py index 8d67618..198fcb8 100644 --- a/scripts/viewer.py +++ b/scripts/viewer.py @@ -77,90 +77,57 @@ return pandas.read_csv(SUMMARY_CSV) -def render_tab_overview( - df: pandas.DataFrame, spatial_df: pandas.DataFrame | None -) -> None: - """タブ1「全体比較」を描画する. +def _build_stats_df( + df: pandas.DataFrame, cols: list[str] +) -> pandas.DataFrame: + """指定カラムの列間統計サマリー DataFrame を生成する. Args: - df: サマリー DataFrame. - spatial_df: 空間分析サマリー DataFrame.存在しない場合は None. + df: 元の DataFrame. + cols: 統計を集計する列名リスト. + + Returns: + 各列の mean/std/cv/min/max/range を行とした DataFrame. + index.name は「指標」に設定される. """ - 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) - - # 画像間変動係数(再現性指標) - st.subheader("画像間ばらつき(再現性)") - cv_data = {} - for col in numeric_cols: + stats_data = {} + for col in 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}", + stats_data[col] = { + "平均 (Mean)": f"{col_mean:.4f}", + "標準偏差 (SD)": f"{col_std:.4f}", + "変動係数 (CV)": ( + f"{col_std / col_mean:.6f}" if col_mean != 0 else "N/A" + ), + "最小 (Min)": f"{numpy.min(values):.4f}", + "最大 (Max)": f"{numpy.max(values):.4f}", + "範囲 (Range)": 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) が小さいほど画像間の再現性が高い") + result = pandas.DataFrame(stats_data).T + result.index.name = "指標" + return result - # 空間分析の画像間ばらつき - 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("指標比較グラフ") +def _render_bar_chart( + data_df: pandas.DataFrame, + image_names: list[str], + metrics: list[tuple[str, str, str, str]], +) -> None: + """2x2 の棒グラフを描画して Streamlit に表示する. + + Args: + data_df: 各指標の値を持つ DataFrame. + image_names: X 軸に表示する画像名リスト. + metrics: (列名, タイトル, Y 軸ラベル, 色) のリスト(4 要素). + """ 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.bar(range(len(image_names)), data_df[col], color=color, edgecolor="none") ax.set_title(title) ax.set_xlabel("Image") ax.set_ylabel(ylabel) @@ -172,11 +139,105 @@ plt.close(fig) -def render_tab_individual(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("全体均一性 (Overall Uniformity)") + + # 指標テーブル + 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"] + st.dataframe(_build_stats_df(df, numeric_cols), use_container_width=True) + st.caption("※ 変動係数 (CV) が小さいほど画像間の再現性が高い") + + # 比較グラフ(2x2 棒グラフ) + st.subheader("指標比較グラフ") + image_names = df["image_name"].tolist() + 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"), + ] + _render_bar_chart(df, image_names, metrics) + + # 空間分析セクション + if spatial_df is not None: + st.header("空間分析 (Spatial Analysis)") + + # 空間分析指標テーブル + st.subheader("指標テーブル") + spatial_display_df = spatial_df[ + [ + "image_name", + "center_mean", + "middle_mean", + "periphery_mean", + "center_periphery_ratio", + "gradient_magnitude", + ] + ].copy() + spatial_fmt_2f = { + "center_mean": "{:.2f}", + "middle_mean": "{:.2f}", + "periphery_mean": "{:.2f}", + "gradient_magnitude": "{:.2f}", + } + spatial_fmt_4f = {"center_periphery_ratio": "{:.4f}"} + for col, fmt in {**spatial_fmt_2f, **spatial_fmt_4f}.items(): + spatial_display_df[col] = spatial_display_df[col].map(fmt.format) + st.dataframe(spatial_display_df, use_container_width=True) + + # 空間分析 統計サマリー + st.subheader("統計サマリー") + spatial_cols = [ + "center_mean", + "middle_mean", + "periphery_mean", + "center_periphery_ratio", + "gradient_magnitude", + ] + st.dataframe( + _build_stats_df(spatial_df, spatial_cols), use_container_width=True + ) + st.caption("※ 変動係数 (CV) が小さいほど画像間の再現性が高い") + + # 空間分析 比較グラフ(2x2 棒グラフ) + st.subheader("指標比較グラフ") + spatial_metrics = [ + ("center_periphery_ratio", "Center/Periphery Ratio", "Ratio", "steelblue"), + ("gradient_magnitude", "Gradient Magnitude", "Gradient", "coral"), + ("center_mean", "Center Mean Luminance", "Luminance", "mediumseagreen"), + ("periphery_mean", "Periphery Mean Luminance", "Luminance", "mediumpurple"), + ] + spatial_image_names = spatial_df["image_name"].tolist() + _render_bar_chart(spatial_df, spatial_image_names, spatial_metrics) + + +def render_tab_individual( + df: pandas.DataFrame, spatial_df: pandas.DataFrame | None +) -> None: """タブ2「個別画像」を描画する. Args: df: サマリー DataFrame. + spatial_df: 空間分析サマリー DataFrame.存在しない場合は None. """ st.header("個別画像") @@ -235,6 +296,18 @@ col3.metric("CoV", f"{row['cov']:.4f}") col4.metric("最大/最小比", f"{row['max_min_ratio']:.4f}") + # 空間分析 metric カード + if spatial_df is not None: + spatial_rows = spatial_df[spatial_df["image_name"] == selected] + if not spatial_rows.empty: + spatial_row = spatial_rows.iloc[0] + st.subheader("空間分析指標") + scol1, scol2, scol3, scol4 = st.columns(4) + scol1.metric("中心平均", f"{spatial_row['center_mean']:.2f}") + scol2.metric("周辺平均", f"{spatial_row['periphery_mean']:.2f}") + scol3.metric("中心/周辺比", f"{spatial_row['center_periphery_ratio']:.4f}") + scol4.metric("勾配量(%)", f"{spatial_row['gradient_magnitude']:.2f}") + # 元画像・輝度マップ・ヒストグラムを横並びで表示 original_path = DATA_DIR / f"{selected}.png" luminance_map_path = FIGURES_DIR / f"{selected}_luminance_map.png" @@ -268,6 +341,28 @@ else: st.warning(f"ヒストグラムが見つかりません: {histogram_path.name}") + # 放射状プロファイル・ゾーンマップ + st.subheader("放射状プロファイル / ゾーンマップ") + radial_profile_path = FIGURES_DIR / f"{selected}_radial_profile.png" + zone_map_path = FIGURES_DIR / f"{selected}_zone_map.png" + profile_col, zone_col = st.columns(2) + + with profile_col: + st.caption("放射状プロファイル") + if radial_profile_path.exists(): + st.image(str(radial_profile_path), use_container_width=True) + else: + st.warning( + f"放射状プロファイルが見つかりません: {radial_profile_path.name}" + ) + + with zone_col: + st.caption("ゾーンマップ") + if zone_map_path.exists(): + st.image(str(zone_map_path), use_container_width=True) + else: + st.warning(f"ゾーンマップが見つかりません: {zone_map_path.name}") + def main() -> None: """Streamlit アプリのエントリーポイント.""" @@ -296,7 +391,7 @@ render_tab_overview(df, spatial_df) with tab_individual: - render_tab_individual(df) + render_tab_individual(df, spatial_df) if __name__ == "__main__": diff --git a/tests/scripts/test_viewer.py b/tests/scripts/test_viewer.py index c1be5a5..f83b168 100644 --- a/tests/scripts/test_viewer.py +++ b/tests/scripts/test_viewer.py @@ -543,3 +543,83 @@ assert "None" in annotation or "Optional" in annotation, ( f"spatial_df の型ヒントに None が含まれない: {annotation}" ) + + +# --------------------------------------------------------------------------- +# render_tab_individual 引数テスト +# --------------------------------------------------------------------------- + + +class TestRenderTabIndividual: + """render_tab_individual 関数の引数仕様テスト群. + + render_tab_individual(df, spatial_df) の形式(2 引数)で呼び出せること, + および spatial_df=None で正常動作することを検証する. + Streamlit の描画は単体テストでは検証せず,引数の受け付けのみ確認する. + """ + + def test_render_tab_individual_accepts_two_arguments(self) -> None: + """正常: render_tab_individual が df と spatial_df の 2 引数を受け付けること.""" + from scripts.viewer import render_tab_individual + import inspect + + sig = inspect.signature(render_tab_individual) + params = list(sig.parameters.keys()) + assert "df" in params, "df パラメータが存在しない" + assert "spatial_df" in params, "spatial_df パラメータが存在しない" + + def test_render_tab_individual_has_exactly_two_parameters(self) -> None: + """正常: render_tab_individual のパラメータがちょうど 2 個であること.""" + from scripts.viewer import render_tab_individual + import inspect + + sig = inspect.signature(render_tab_individual) + params = list(sig.parameters.keys()) + assert len(params) == 2, ( + f"パラメータ数が 2 でない: {params}" + ) + + def test_render_tab_individual_spatial_df_is_nullable(self) -> None: + """正常: render_tab_individual の spatial_df 引数が None を受け付けること(型ヒント確認).""" + from scripts.viewer import render_tab_individual + import inspect + + sig = inspect.signature(render_tab_individual) + 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}" + ) + + def test_render_tab_individual_first_param_is_df(self) -> None: + """正常: render_tab_individual の第1引数が df であること.""" + from scripts.viewer import render_tab_individual + import inspect + + sig = inspect.signature(render_tab_individual) + params = list(sig.parameters.keys()) + assert params[0] == "df", f"第1引数が df でない: {params[0]}" + + def test_render_tab_individual_second_param_is_spatial_df(self) -> None: + """正常: render_tab_individual の第2引数が spatial_df であること.""" + from scripts.viewer import render_tab_individual + import inspect + + sig = inspect.signature(render_tab_individual) + params = list(sig.parameters.keys()) + assert params[1] == "spatial_df", f"第2引数が spatial_df でない: {params[1]}" + + def test_render_tab_individual_df_annotation_is_dataframe(self) -> None: + """正常: render_tab_individual の df 引数が DataFrame の型ヒントを持つこと.""" + from scripts.viewer import render_tab_individual + import inspect + + sig = inspect.signature(render_tab_individual) + df_param = sig.parameters.get("df") + assert df_param is not None, "df パラメータが存在しない" + annotation = str(df_param.annotation) + assert "DataFrame" in annotation, ( + f"df の型ヒントに DataFrame が含まれない: {annotation}" + )