Newer
Older
SmTIAS-Evaluation / scripts / viewer.py
"""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()