Newer
Older
SmTIAS-Evaluation / scripts / run_uniformity.py
"""MiniTIAS 白板画像の照明均一性評価スクリプト.

使い方(単一画像):
    python scripts/run_uniformity.py --image data/minitias/whiteboard/xxx.png
    python scripts/run_uniformity.py --image data/minitias/whiteboard/xxx.png \\
        --config config/roi_config.json --roi minitias.whiteboard

使い方(ディレクトリ一括):
    python scripts/run_uniformity.py --image data/minitias/whiteboard/
"""

import argparse
import json
import sys
from pathlib import Path

import numpy as np

# プロジェクトルートを sys.path に追加(スクリプトを任意の場所から実行できるよう)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))

from src.analysis.reproducibility import calc_pairwise_ssim_registered  # noqa: E402
from src.analysis.spatial import calc_spatial_uniformity  # noqa: E402
from src.analysis.uniformity import calc_uniformity, to_grayscale  # noqa: E402
from src.export.exporter import (  # noqa: E402
    calc_batch_statistics,
    ensure_output_dirs,
    export_batch_statistics,
    export_results,
    export_spatial_summary,
    export_ssim_summary,
    export_summary,
)
from src.io.loader import extract_roi, load_image  # noqa: E402
from src.visualization.plotter import (  # noqa: E402
    plot_histogram,
    plot_luminance_map,
    plot_radial_profile,
    plot_zone_map,
)


def load_roi_config(config_path: str, roi_key: str) -> dict | None:
    """ROI 設定ファイルを読み込んで指定キーの ROI を返す.

    Args:
        config_path: JSON 設定ファイルのパス.
        roi_key: ドット区切りのキー(例: "minitias.whiteboard").

    Returns:
        ROI の辞書(x, y, width, height).キーが存在しない場合は None.

    Raises:
        FileNotFoundError: 設定ファイルが存在しない場合.
    """
    config_file = Path(config_path)
    if not config_file.exists():
        raise FileNotFoundError(f"設定ファイルが見つかりません: {config_path}")

    with config_file.open(encoding="utf-8") as f:
        config = json.load(f)

    # ドット区切りでネストされたキーを辿る
    node = config
    for key in roi_key.split("."):
        if not isinstance(node, dict) or key not in node:
            return None
        node = node[key]

    return node if isinstance(node, dict) else None


def analyze_single_image(
    image_path: str,
    roi: dict | None,
    output_base: Path,
) -> tuple[dict, np.ndarray] | tuple[None, None]:
    """1 枚の画像を解析し,結果を出力する.

    Args:
        image_path: 解析対象画像のパス.
        roi: ROI の辞書(x, y, width, height).None の場合は画像全体を解析.
        output_base: 出力ルートディレクトリのパス.

    Returns:
        (均一性指標に image_name を加えた辞書, 輝度マップ) のタプル.
        エラー時は (None, None).
    """
    print(f"解析開始: {image_path}")

    # 画像読み込み
    image = load_image(image_path)
    print(f"画像サイズ: {image.shape[1]} x {image.shape[0]} px")

    # ROI 切り出し
    if roi is not None:
        print(
            f"ROI を適用します: x={roi['x']}, y={roi['y']}, "
            f"width={roi['width']}, height={roi['height']}"
        )
        region = extract_roi(image, roi["x"], roi["y"], roi["width"], roi["height"])
    else:
        print("ROI 未指定: 画像全体を解析します")
        region = image

    # 輝度変換(Rec.709)
    luminance = to_grayscale(region)

    # 均一性指標算出
    results = calc_uniformity(luminance)
    print("--- 均一性指標 ---")
    print(f"  平均輝度     : {results['mean']:.2f}")
    print(f"  標準偏差     : {results['std']:.2f}")
    print(f"  CoV          : {results['cov']:.4f}")
    print(f"  最大/最小比  : {results['max_min_ratio']:.4f}")
    print(f"  最大輝度     : {results['max']:.2f}")
    print(f"  最小輝度     : {results['min']:.2f}")

    # 出力ファイル名(入力ファイル名ベース)
    stem = Path(image_path).stem

    # 空間解析(中心-周辺勾配)
    spatial = calc_spatial_uniformity(luminance)
    zone_stats = spatial["zone_stats"]
    print("--- 空間解析指標 ---")
    print(f"  中心平均輝度   : {zone_stats['center_mean']:.2f}")
    print(f"  中間平均輝度   : {zone_stats['middle_mean']:.2f}")
    print(f"  周辺平均輝度   : {zone_stats['periphery_mean']:.2f}")
    print(f"  中心/周辺比    : {zone_stats['center_periphery_ratio']:.4f}")
    print(f"  勾配量 (%)     : {zone_stats['gradient_magnitude']:.2f}")

    # 可視化
    luminance_map_path = str(output_base / "figures" / f"{stem}_luminance_map.png")
    plot_luminance_map(luminance, luminance_map_path)
    plot_histogram(luminance, str(output_base / "figures" / f"{stem}_histogram.png"))
    plot_radial_profile(
        spatial["radial_profile"],
        str(output_base / "figures" / f"{stem}_radial_profile.png"),
    )
    plot_zone_map(
        luminance,
        spatial["zone_map"],
        str(output_base / "figures" / f"{stem}_zone_map.png"),
    )

    # CSV 出力
    export_results(results, str(output_base / "results" / f"{stem}_uniformity.csv"))

    print(f"解析完了: {image_path}")

    return {"image_name": stem, **results, **zone_stats}, luminance


def main() -> None:
    """エントリーポイント."""
    parser = argparse.ArgumentParser(
        description="MiniTIAS 白板画像の照明均一性を評価する."
    )
    parser.add_argument(
        "--image",
        required=True,
        help="解析対象の画像ファイルパスまたはディレクトリパス",
    )
    parser.add_argument(
        "--config",
        default=str(PROJECT_ROOT / "config" / "roi_config.json"),
        help="ROI 設定ファイルのパス(デフォルト: config/roi_config.json)",
    )
    parser.add_argument(
        "--roi",
        default="minitias.whiteboard",
        help="ROI のキー(ドット区切り,デフォルト: minitias.whiteboard)",
    )
    parser.add_argument(
        "--output",
        default=str(PROJECT_ROOT / "output"),
        help="出力ディレクトリのパス(デフォルト: output/)",
    )
    args = parser.parse_args()

    output_base = Path(args.output)
    ensure_output_dirs(args.output)

    # ROI 設定読み込み
    roi = None
    if args.config:
        try:
            roi = load_roi_config(args.config, args.roi)
        except FileNotFoundError as e:
            print(f"警告: {e}  → 画像全体を解析します")

    image_target = Path(args.image)

    if image_target.is_dir():
        # バッチ解析: ディレクトリ内の全 PNG を処理
        png_files = sorted(image_target.glob("*.png"))
        if not png_files:
            print(f"PNG ファイルが見つかりません: {image_target}")
            return

        print(f"バッチ解析: {len(png_files)} 枚の画像を処理します")

        all_results: list[dict] = []
        all_luminance_maps: list[np.ndarray] = []
        for png_path in png_files:
            try:
                result, luminance_map = analyze_single_image(
                    str(png_path), roi, output_base
                )
                if result is not None:
                    all_results.append(result)
                    all_luminance_maps.append(luminance_map)
            except Exception as e:
                print(f"エラー(スキップ): {png_path} — {e}")

        # まとめ CSV 出力
        if all_results:
            summary_path = str(output_base / "results" / "summary_uniformity.csv")
            export_summary(all_results, summary_path)
            spatial_summary_path = str(output_base / "results" / "summary_spatial.csv")
            export_spatial_summary(all_results, spatial_summary_path)

            # 画像間の集計統計を算出・出力
            batch_stats = calc_batch_statistics(all_results)
            stats_path = str(output_base / "results" / "summary_statistics.csv")
            export_batch_statistics(batch_stats, stats_path)

            print("--- 画像間の集計統計 ---")
            for category, metrics in batch_stats.items():
                print(f"  [{category}]")
                for metric_name, values in metrics.items():
                    print(
                        f"    {metric_name}: "
                        f"mean={values['mean']:.4f}, "
                        f"std={values['std']:.4f}, "
                        f"cv={values['cv']:.4f}"
                    )

            # SSIM による再現性評価(位相相関による位置合わせ付き)
            if len(all_luminance_maps) >= 2:
                print("SSIM による再現性評価(位置合わせ付き)を実行中...")
                ssim_results = calc_pairwise_ssim_registered(all_luminance_maps)
                ssim_path = str(output_base / "results" / "summary_ssim.csv")
                image_names = [r["image_name"] for r in all_results]
                export_ssim_summary(ssim_results, image_names, ssim_path)
                print("--- SSIM 再現性指標 ---")
                print("  [位置合わせなし]")
                print(f"    平均 SSIM    : {ssim_results['mean_ssim']:.6f}")
                print(f"    最小 SSIM    : {ssim_results['min_ssim']:.6f}")
                print(f"    最大 SSIM    : {ssim_results['max_ssim']:.6f}")
                print(f"    標準偏差     : {ssim_results['std_ssim']:.6f}")
                print("  [位置合わせ後]")
                print(f"    平均 SSIM    : {ssim_results['mean_ssim_registered']:.6f}")
                print(f"    最小 SSIM    : {ssim_results['min_ssim_registered']:.6f}")
                print(f"    最大 SSIM    : {ssim_results['max_ssim_registered']:.6f}")
                print(f"    標準偏差     : {ssim_results['std_ssim_registered']:.6f}")
                print("  [シフト量(ピクセル)]")
                print(f"    平均シフト   : {ssim_results['mean_shift_magnitude']:.4f}")
                print(f"    最大シフト   : {ssim_results['max_shift_magnitude']:.4f}")
                print(f"  ペア数         : {ssim_results['n_pairs']}")

        print(f"バッチ解析完了: {len(all_results)} / {len(png_files)} 枚成功")

    else:
        # 単一画像解析(後方互換)
        analyze_single_image(args.image, roi, output_base)  # 輝度マップは不使用


if __name__ == "__main__":
    main()