"""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 # 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"],
zone_stats,
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(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(f"--- SSIM 再現性指標 ---")
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(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()