"""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"
SPATIAL_CSV = RESULTS_DIR / "summary_spatial.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 _build_stats_df(
df: pandas.DataFrame, cols: list[str]
) -> pandas.DataFrame:
"""指定カラムの列間統計サマリー DataFrame を生成する.
Args:
df: 元の DataFrame.
cols: 統計を集計する列名リスト.
Returns:
各列の mean/std/cv/min/max/range を行とした DataFrame.
index.name は「指標」に設定される.
"""
stats_data = {}
for col in cols:
values = df[col].values
col_mean = numpy.mean(values)
col_std = numpy.std(values, ddof=0)
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}",
}
result = pandas.DataFrame(stats_data).T
result.index.name = "指標"
return result
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))
# 長い名前を短縮してグラフを読みやすくする
short_names = [name[-8:] for name in image_names]
for ax, (col, title, ylabel, color) in zip(axes.ravel(), metrics):
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)
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_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)
# SSIM 再現性評価
ssim_csv = RESULTS_DIR / "summary_ssim.csv"
if ssim_csv.exists():
st.header("再現性評価 (Reproducibility)")
# summary_ssim.csv の 1 行目(集計統計)を読み込んで表示
ssim_stats = pandas.read_csv(ssim_csv, nrows=1)
# 位置合わせなし SSIM
st.subheader("位置合わせなし SSIM (Raw SSIM)")
col1, col2, col3, col4 = st.columns(4)
col1.metric("平均 SSIM", f"{ssim_stats['mean_ssim'].iloc[0]:.6f}")
col2.metric("最小 SSIM", f"{ssim_stats['min_ssim'].iloc[0]:.6f}")
col3.metric("最大 SSIM", f"{ssim_stats['max_ssim'].iloc[0]:.6f}")
col4.metric("標準偏差", f"{ssim_stats['std_ssim'].iloc[0]:.6f}")
# 位置合わせ後 SSIM(列が存在する場合のみ表示)
has_registered = (
"mean_ssim_registered" in ssim_stats.columns
and pandas.notna(ssim_stats["mean_ssim_registered"].iloc[0])
and ssim_stats["mean_ssim_registered"].iloc[0] != ""
)
if has_registered:
st.subheader("位置合わせ後 SSIM (Registered SSIM)")
rcol1, rcol2, rcol3, rcol4 = st.columns(4)
rcol1.metric(
"平均 SSIM (登録後)",
f"{float(ssim_stats['mean_ssim_registered'].iloc[0]):.6f}",
)
rcol2.metric(
"最小 SSIM (登録後)",
f"{float(ssim_stats['min_ssim_registered'].iloc[0]):.6f}",
)
rcol3.metric(
"最大 SSIM (登録後)",
f"{float(ssim_stats['max_ssim_registered'].iloc[0]):.6f}",
)
rcol4.metric(
"標準偏差 (登録後)",
f"{float(ssim_stats['std_ssim_registered'].iloc[0]):.6f}",
)
# シフト量メトリクス
st.subheader("シフト量 (Shift Magnitude)")
scol1, scol2 = st.columns(2)
scol1.metric(
"平均シフト (px)",
f"{float(ssim_stats['mean_shift_magnitude'].iloc[0]):.4f}",
)
scol2.metric(
"最大シフト (px)",
f"{float(ssim_stats['max_shift_magnitude'].iloc[0]):.4f}",
)
st.caption("※ SSIM は 1.0 に近いほど画像間の構造的類似度が高く,再現性が良好")
def render_tab_individual(
df: pandas.DataFrame, spatial_df: pandas.DataFrame | None
) -> None:
"""タブ2「個別画像」を描画する.
Args:
df: サマリー DataFrame.
spatial_df: 空間分析サマリー DataFrame.存在しない場合は None.
"""
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}")
# 空間分析 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"
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}")
# 放射状プロファイル・ゾーンマップ
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 アプリのエントリーポイント."""
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
# 空間分析 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, spatial_df)
with tab_individual:
render_tab_individual(df, spatial_df)
if __name__ == "__main__":
main()