"""sm-evaluate コマンドラインエントリポイント.
PNG または luminance.npy を入力として均一性評価を実行し,
metrics.csv と可視化 PNG を出力ディレクトリに保存する.
matplotlib のバックエンドは Agg を強制する(headless 環境対応).
evaluate は Mitsuba を使わないため,render.py と異なり遅延 import は不要.
ただし Agg バックエンドの設定順序を保つため,matplotlib.use() は pyplot import より前に置く.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
import matplotlib
# headless 環境(サーバ・CI 等)でも動くよう Agg バックエンドを強制する.
# visualize.py より先に設定することで,pyplot import 時に Agg が有効になる.
matplotlib.use("Agg")
import numpy # noqa: E402
import pandas # noqa: E402
from PIL import Image # noqa: E402
from sm_tias_lightsim.evaluation.normalize import apply_mean_anchor # noqa: E402
from sm_tias_lightsim.evaluation.oetf import apply_srgb_oetf, solve_exposure # noqa: E402
from sm_tias_lightsim.evaluation.radial import calc_radial_profile # noqa: E402
from sm_tias_lightsim.evaluation.roi import extract_roi # noqa: E402
from sm_tias_lightsim.evaluation.visualize import ( # noqa: E402
save_histogram,
save_luminance_map,
save_radial_profile,
save_zone_map,
)
from sm_tias_lightsim.evaluation.zones import ( # noqa: E402
calc_zone_stats,
classify_zones,
compute_normalized_distance_map,
)
from sm_tias_lightsim.scene.schema import ( # noqa: E402
EvaluationConfig,
MetricsConfig,
NormalizationConfig,
OetfConfig,
ZonesConfig,
)
from sm_tias_lightsim.uniformity import calc_uniformity, to_grayscale # noqa: E402
# 出力ファイル名を一元管理する(変更時の影響範囲を最小化するため)
_METRICS_FILENAME = "metrics.csv"
_FIGURES_DIRNAME = "figures"
_LUMINANCE_MAP_FILENAME = "luminance_map.png"
_HISTOGRAM_FILENAME = "histogram.png"
_RADIAL_PROFILE_FILENAME = "radial_profile.png"
_ZONE_MAP_FILENAME = "zone_map.png"
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""コマンドライン引数をパースして返す.
Args:
argv: 引数リスト.None のとき sys.argv[1:] を使用.
Returns:
パース済み Namespace.
"""
parser = argparse.ArgumentParser(
prog="sm-evaluate",
description=(
"PNG または luminance.npy を読み込み,均一性指標 CSV と可視化 PNG を出力する."
),
)
parser.add_argument(
"--image",
"-i",
required=True,
type=Path,
metavar="IMAGE_PATH",
help="評価対象の画像ファイル(PNG または .npy).",
)
parser.add_argument(
"--config",
"-c",
required=True,
type=Path,
metavar="YAML_PATH",
help="評価設定 YAML ファイルのパス(SceneConfig または EvaluationConfig 互換).",
)
parser.add_argument(
"--output",
"-o",
required=True,
type=Path,
metavar="OUTPUT_DIR",
help="出力先ディレクトリ(存在しない場合は親ごと作成する).",
)
return parser.parse_args(argv)
def _apply_oetf_to_luminance(
roi_luminance: numpy.ndarray,
oetf_cfg: OetfConfig,
) -> numpy.ndarray:
"""OETF 順方向適用:ROI linear luminance を sRGB 符号化空間へ変換する.
無彩色シーン(シミュ LED・材料が RGB 等価)では luminance への直接 OETF 適用は
「チャネル別 sRGB OETF → Rec.709 luma 加重和」と厳密一致する(SPEC_03 参照).
露出ソルバは ROI 上で解く(target_mean は実測 ROI mean のため ROI 上で収束させる).
Args:
roi_luminance: ROI 切り出し済みの linear 輝度配列(H' x W', float64).
oetf_cfg: OetfConfig(enabled=True が前提).
Returns:
OETF 適用後の ROI 輝度配列(H' x W', float64, quantize_uint8=True なら [0,255]).
Raises:
ValueError: oetf_cfg.exposure が None の場合(anchor_mean モードで必須).
"""
if oetf_cfg.exposure is None:
msg = "oetf.enabled=True かつ anchor_mean モードでは exposure 設定が必須です."
raise ValueError(msg)
roi_lin = roi_luminance.astype(numpy.float64)
target_mean = oetf_cfg.exposure.target_mean
# ROI linear 値で露出スケールを決定(実測 mean が ROI mean のため ROI 上で解く)
scale, clipped = solve_exposure(roi_lin, target_mean)
clip_pct = clipped * 100.0
print(f" OETF exposure : scale={scale:.6g} clip={clip_pct:.1f}%")
# ROI に対してのみ OETF を適用する(指標算出は ROI 上で行うため)
return apply_srgb_oetf(roi_lin, scale, quantize_uint8=oetf_cfg.quantize_uint8)
def _apply_normalization_to_luminance(
roi_luminance: numpy.ndarray,
norm_cfg: NormalizationConfig,
) -> numpy.ndarray:
"""linear mean anchor 正規化を ROI 輝度配列に適用する.
ROI mean を norm_cfg.target_mean に揃える線形ゲインを適用する.
形状指標(CoV / 勾配 / C/P 比 / max-min 比)は線形ゲインに不変なので,
絶対値スケールだけが変わる.
Args:
roi_luminance: ROI 切り出し済みの linear 輝度配列(H' x W', float64).
norm_cfg: NormalizationConfig(enabled=True が前提).
Returns:
正規化後の ROI 輝度配列(H' x W', float64).
Raises:
ValueError: ROI mean が 0 の場合(apply_mean_anchor から伝播).
"""
roi_lin = roi_luminance.astype(numpy.float64)
target_mean = norm_cfg.target_mean
normalized, alpha = apply_mean_anchor(roi_lin, target_mean)
print(f" normalization : alpha={alpha:.6g} target_mean={target_mean}")
return normalized
def _load_luminance(image_path: Path) -> numpy.ndarray:
"""画像ファイルを読み込み,輝度配列(2D float64)を返す.
拡張子が .npy の場合は numpy.load で読み込む.
それ以外(PNG 等)は PIL で RGB 画像として読み込み,to_grayscale で変換する.
.npy が 3D(H, W, 3)だった場合も to_grayscale を通す.
Args:
image_path: 読み込む画像ファイルのパス.
Returns:
輝度配列(H x W, float64).
Raises:
FileNotFoundError: ファイルが存在しない場合.
ValueError: ファイルを輝度配列に変換できない場合.
"""
if not image_path.exists():
msg = f"画像ファイルが見つかりません: {image_path}"
raise FileNotFoundError(msg)
if image_path.suffix.lower() == ".npy":
data = numpy.load(image_path)
if data.ndim == 3:
return to_grayscale(data.astype(numpy.float64))
if data.ndim == 2:
return data.astype(numpy.float64)
msg = f".npy の次元数が不正です(ndim={data.ndim}).2D または 3D を期待します."
raise ValueError(msg)
pil_image = Image.open(image_path).convert("RGB")
return to_grayscale(numpy.array(pil_image, dtype=numpy.float64))
def _load_eval_config(config_path: Path) -> EvaluationConfig:
"""YAML を読み込んで EvaluationConfig を返す.
SceneConfig の evaluation サブツリー,または EvaluationConfig 互換の
トップレベル YAML の両方をサポートする.
Args:
config_path: YAML ファイルのパス.
Returns:
EvaluationConfig インスタンス.
Raises:
FileNotFoundError: ファイルが存在しない場合.
pydantic.ValidationError: スキーマ違反.
yaml.YAMLError: YAML 構文エラー.
"""
import yaml
if not config_path.exists():
msg = f"設定 YAML ファイルが見つかりません: {config_path}"
raise FileNotFoundError(msg)
with config_path.open(encoding="utf-8") as f:
data = yaml.safe_load(f)
# "evaluation" キーがあれば SceneConfig 埋め込みとみなしてサブツリーを取得する
if isinstance(data, dict) and "evaluation" in data:
eval_data = data["evaluation"]
else:
eval_data = data
return EvaluationConfig.model_validate(eval_data)
def _run_zone_analysis(
roi_luminance: numpy.ndarray,
zones_config: ZonesConfig | None,
) -> tuple[numpy.ndarray, dict] | tuple[None, dict]:
"""ゾーン分析を実行し,zone_map とゾーン統計を返す.
Args:
roi_luminance: ROI 輝度配列(H x W, float64).
zones_config: ZonesConfig インスタンス(enabled / boundaries を持つ).
None の場合は無効扱い.
Returns:
(zone_map, zone_stats) のタプル.
zones が無効なら (None, {}) を返す.
Raises:
ValueError: ゾーン分析の計算に失敗した場合.
"""
if zones_config is None or not zones_config.enabled:
return None, {}
h, w = roi_luminance.shape
distance_map = compute_normalized_distance_map(h, w)
zone_map = classify_zones(distance_map, zones_config.boundaries)
zone_stats = calc_zone_stats(roi_luminance, zone_map)
return zone_map, zone_stats
def _run_radial_profile(
roi_luminance: numpy.ndarray,
metrics_config: MetricsConfig | None,
) -> numpy.ndarray | None:
"""放射状プロファイルを算出して返す.
Args:
roi_luminance: ROI 輝度配列(H x W, float64).
metrics_config: MetricsConfig インスタンス(radial_profile フラグを持つ).
None の場合は無効扱い.
Returns:
放射状プロファイル配列((n_bins, 2)),無効時は None.
"""
if metrics_config is None or not metrics_config.radial_profile:
return None
return calc_radial_profile(roi_luminance)
def _save_metrics_csv(metrics: dict, csv_path: Path) -> None:
"""均一性指標を CSV ファイルとして保存する.
Args:
metrics: 指標名 → 値の辞書.
csv_path: 保存先 CSV パス.
"""
df = pandas.DataFrame([metrics])
df.to_csv(csv_path, index=False)
def _save_figures(
roi_luminance: numpy.ndarray,
figures_dir: Path,
radial_profile: numpy.ndarray | None,
zone_map: numpy.ndarray | None,
) -> None:
"""可視化 PNG を figures/ ディレクトリに保存する.
Args:
roi_luminance: ROI 輝度配列(H x W, float64).
figures_dir: 保存先ディレクトリ(事前に mkdir 済みであること).
radial_profile: 放射状プロファイル配列,None の場合は保存しない.
zone_map: ゾーンマップ配列,None の場合は保存しない.
"""
save_luminance_map(roi_luminance, figures_dir / _LUMINANCE_MAP_FILENAME)
save_histogram(roi_luminance, figures_dir / _HISTOGRAM_FILENAME)
if radial_profile is not None:
save_radial_profile(radial_profile, figures_dir / _RADIAL_PROFILE_FILENAME)
if zone_map is not None:
save_zone_map(zone_map, figures_dir / _ZONE_MAP_FILENAME, luminance=roi_luminance)
def _print_summary(metrics: dict, csv_path: Path, figures_dir: Path) -> None:
"""評価サマリーを標準出力に表示する.
Args:
metrics: 指標名 → 値の辞書.
csv_path: metrics.csv の保存先パス(表示用).
figures_dir: figures/ ディレクトリのパス(表示用).
"""
print("=== 均一性評価サマリー ===")
print(f" CoV : {metrics['cov']:.6f}")
print(f" std : {metrics['std']:.4f}")
print(f" max/min ratio : {metrics['max_min_ratio']:.4f}")
print(f" mean : {metrics['mean']:.4f}")
print(f" max : {metrics['max']:.4f}")
print(f" min : {metrics['min']:.4f}")
if "center_periphery_ratio" in metrics:
print(f" C/P ratio : {metrics['center_periphery_ratio']:.4f}")
print(f" gradient [%] : {metrics['gradient_magnitude']:.2f}")
print(f"\nmetrics.csv : {csv_path}")
print(f"figures/ : {figures_dir}")
def main(argv: list[str] | None = None) -> int:
"""sm-evaluate コマンドのメインエントリポイント.
処理フロー:
1. コマンドライン引数をパースする.
2. 画像ファイルを読み込み,輝度配列を得る.
3. YAML 設定ファイルを読み込み,EvaluationConfig を得る.
4. 出力ディレクトリと figures/ サブディレクトリを作成する.
5. extract_roi で ROI を切り出す.
6. normalization.enabled のとき linear mean anchor 正規化を適用する.
7. oetf.enabled のとき sRGB OETF を適用する.
8. calc_uniformity で均一性指標を算出する.
9. zones.enabled のときゾーン分析を実行する.
10. metrics.radial_profile のとき放射状プロファイルを算出する.
11. metrics.csv を保存する.
12. 可視化 PNG を figures/ に保存する.
13. 算出指標のサマリーを標準出力に表示する.
Args:
argv: 引数リスト.None のとき sys.argv[1:] を使用.
Returns:
終了コード(成功時 0,失敗時 1).
"""
# argparse 自体のエラー(必須引数欠落等)は ArgumentParser が直接 sys.exit するため
# ここでは ValueError / FileNotFoundError 等の実行時エラーのみ捕捉する
args = _parse_args(argv)
image_path: Path = args.image.resolve()
config_path: Path = args.config.resolve()
output_dir: Path = args.output.resolve()
try:
luminance = _load_luminance(image_path)
except FileNotFoundError as exc:
print(f"エラー: {exc}", file=sys.stderr)
return 1
except Exception as exc: # noqa: BLE001
print(f"エラー: 画像の読み込みに失敗しました — {exc}", file=sys.stderr)
return 1
try:
eval_config = _load_eval_config(config_path)
except FileNotFoundError as exc:
print(f"エラー: {exc}", file=sys.stderr)
return 1
except Exception as exc: # noqa: BLE001
print(f"エラー: 設定 YAML の読み込みに失敗しました — {exc}", file=sys.stderr)
return 1
figures_dir = output_dir / _FIGURES_DIRNAME
try:
output_dir.mkdir(parents=True, exist_ok=True)
figures_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
print(f"エラー: 出力ディレクトリを作成できません — {exc}", file=sys.stderr)
return 1
try:
roi_luminance = extract_roi(luminance, eval_config.roi)
except (ValueError, NotImplementedError) as exc:
print(f"エラー: ROI 抽出に失敗しました — {exc}", file=sys.stderr)
return 1
# linear mean anchor 正規化(enabled のとき ROI 抽出後・OETF 前に挟む).
# OETF との併用禁止は EvaluationConfig の model_validator で保証されている.
norm_cfg = eval_config.normalization
if norm_cfg is not None and norm_cfg.enabled:
try:
roi_luminance = _apply_normalization_to_luminance(roi_luminance, norm_cfg)
except ValueError as exc:
print(f"エラー: linear 正規化に失敗しました — {exc}", file=sys.stderr)
return 1
# OETF 順方向適用(enabled のとき ROI 抽出後・指標算出前に挟む).
# eval_real.yaml(実測 PNG)は oetf を設定しないこと:
# 実測 PNG は既に sRGB 符号化済みのため,ここで OETF を掛けると二重適用になる.
oetf_cfg = eval_config.oetf
if oetf_cfg is not None and oetf_cfg.enabled:
try:
roi_luminance = _apply_oetf_to_luminance(roi_luminance, oetf_cfg)
except ValueError as exc:
print(f"エラー: OETF 適用に失敗しました — {exc}", file=sys.stderr)
return 1
try:
uniformity_stats = calc_uniformity(roi_luminance)
except ValueError as exc:
print(f"エラー: 均一性指標の算出に失敗しました — {exc}", file=sys.stderr)
return 1
metrics: dict = dict(uniformity_stats)
try:
zone_map, zone_stats = _run_zone_analysis(roi_luminance, eval_config.zones)
metrics.update(zone_stats)
except ValueError as exc:
print(f"エラー: ゾーン分析に失敗しました — {exc}", file=sys.stderr)
return 1
try:
radial_profile = _run_radial_profile(roi_luminance, eval_config.metrics)
except Exception as exc: # noqa: BLE001
print(f"エラー: 放射状プロファイルの算出に失敗しました — {exc}", file=sys.stderr)
return 1
csv_path = output_dir / _METRICS_FILENAME
try:
_save_metrics_csv(metrics, csv_path)
except Exception as exc: # noqa: BLE001
print(f"エラー: metrics.csv の保存に失敗しました — {exc}", file=sys.stderr)
return 1
try:
_save_figures(roi_luminance, figures_dir, radial_profile, zone_map)
except Exception as exc: # noqa: BLE001
print(f"エラー: 可視化 PNG の保存に失敗しました — {exc}", file=sys.stderr)
return 1
_print_summary(metrics, csv_path, figures_dir)
return 0