"""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 render_tab_overview(
df: pandas.DataFrame, spatial_df: pandas.DataFrame | None
) -> None:
"""タブ1「全体比較」を描画する.
Args:
df: サマリー DataFrame.
spatial_df: 空間分析サマリー DataFrame.存在しない場合は None.
"""
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:
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))
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
# 空間分析 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)
if __name__ == "__main__":
main()