Newer
Older
SmTIAS-LightSim / tests / evaluation / test_visualize.py
@rintoHasegawa rintoHasegawa on 1 Jun 10 KB [add] S2 DNG ローダと新指標を MiniTias-Evaluation からコピー — evaluation/dng_loader.py 新規 (handoff_smtias_lightsim/src/dng_loader.py の完全コピー+ docstring 差替: rawpy で 10-bit BGGR Bayer 展開・output_color=raw で linear カラーマトリクス非適用・gamma=(1,1)+no_auto_bright+use_camera_wb=False+user_wb=[1,1,1,1] で linear raw 保持・output_format rgb_float32/luma_float32/rgb_uint8 切替・Rec.709 luma は output_color=raw 時の固定加重プロキシで既存 PNG 経路と互換).evaluation/radial.py に calc_radial_min_max_ratio をマージ追加 (MiniTias-Evaluation の src/analysis/spatial.py から.動径 min/max 比 = 20 点輪帯平均の min/max で 1.0 で均一.画素単位 max/min よりノイズロバスト.nan ビン除外を移植時補強).pyproject.toml + uv.lock に rawpy>=0.21.0 追加 (実 rawpy==0.27.0).新規テスト 32 件 (tests/evaluation/test_dng_loader.py 341 行) で仕様 A1-D 全項目カバー.既存 537 + 新規 32 = 569 件全通過 (slow 除外).検証 V4 完全合格: 実 DNG (reference_measurement/quantitative/MiniTIAS_QM_20260601_124500.dng, 16MB, 暗闇撮影) を LSC 順方向適用 + portrait 向き合わせ (rot90 -1) + 白板 ROI で評価し,CoV/std/max-min/mean/max/min 6 件が相対誤差 < 1e-6 で MiniTias-Evaluation 期待値完全一致.中心/周辺比 1.0911 vs 1.091143・勾配 8.35 vs 8.352987・動径 min/max 0.8600 vs 0.859962 も絶対誤差 < 0.003.Refactorer 判断: _apply_srgb_oetf は既存 evaluation/oetf.py:apply_srgb_oetf と実装重複だがコピー方針優先で放置 (3 箇所以上の違反基準未満・用途異なる).副次変更: roi.py / test_oetf.py / test_visualize.py の軽微な lint 修正.handoff_smtias_lightsim/ ディレクトリは S3 完了後に削除予定で本コミットには含めない.CLAUDE.md 実装済みモジュール一覧に dng_loader.py と radial.py の calc_radial_min_max_ratio 追記+進捗欄に S2 完了 (V4 完全一致含む) を追記
"""evaluation/visualize.py の仕様ベーステスト(smoke テスト).

仕様出典:
- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(evaluation/visualize 層の責務)
- docs/01_GUIDE/GUIDE_08_テスト方針.md(可視化は smoke テストで十分)

テスト方針 (GUIDE_08):
  可視化の smoke テストは「ファイルが生成されたか」「ファイルサイズが 0 でないか」程度で十分.
  図の見栄えはテストしない.
  matplotlib.use("Agg") を pyplot の import より前に呼ぶこと.
"""

from __future__ import annotations

import matplotlib

# headless 環境でも動くよう Agg バックエンドを pyplot より前に設定する
matplotlib.use("Agg")

from pathlib import Path

import numpy

from sm_tias_lightsim.evaluation.visualize import (
    save_histogram,
    save_luminance_map,
    save_radial_profile,
    save_zone_map,
)

# ---------------------------------------------------------------------------
# テスト用ヘルパー:合成輝度配列
# ---------------------------------------------------------------------------


def _make_luminance(size: int = 16) -> numpy.ndarray:
    """テスト用の合成輝度配列(全画素 100.0 ± 小さなノイズ)を返す.

    乱数 seed を固定して再現性を保証する (GUIDE_08: seed を必ず固定する)
    """
    rng = numpy.random.RandomState(0)
    return numpy.full((size, size), 100.0) + rng.normal(0, 5, (size, size))


# ---------------------------------------------------------------------------
# 1. save_luminance_map
# ---------------------------------------------------------------------------


def test_save_luminance_map_creates_file(tmp_path: Path):
    """save_luminance_map を呼ぶと PNG ファイルが生成されること.

    仕様: 輝度マップを PNG として保存する(visualize.py save_luminance_map)
    """
    luminance = _make_luminance()
    output_path = tmp_path / "luminance_map.png"
    save_luminance_map(luminance, output_path)
    assert output_path.exists()


def test_save_luminance_map_file_not_empty(tmp_path: Path):
    """生成された PNG ファイルのサイズが 0 より大きいこと.

    仕様: 正しい PNG として書き出されること(visualize.py save_luminance_map)
    """
    luminance = _make_luminance()
    output_path = tmp_path / "luminance_map.png"
    save_luminance_map(luminance, output_path)
    assert output_path.stat().st_size > 0


# ---------------------------------------------------------------------------
# 2. save_histogram
# ---------------------------------------------------------------------------


def test_save_histogram_creates_file(tmp_path: Path):
    """save_histogram を呼ぶと PNG ファイルが生成されること.

    仕様: 輝度ヒストグラムを PNG として保存する(visualize.py save_histogram)
    """
    luminance = _make_luminance()
    output_path = tmp_path / "histogram.png"
    save_histogram(luminance, output_path)
    assert output_path.exists()


def test_save_histogram_file_not_empty(tmp_path: Path):
    """生成された histogram PNG ファイルのサイズが 0 より大きいこと."""
    luminance = _make_luminance()
    output_path = tmp_path / "histogram.png"
    save_histogram(luminance, output_path)
    assert output_path.stat().st_size > 0


def test_save_histogram_custom_bins(tmp_path: Path):
    """bins パラメータを変えても PNG ファイルが生成されること.

    仕様: bins=50(デフォルト)(visualize.py save_histogram)
    """
    luminance = _make_luminance()
    output_path = tmp_path / "histogram_10bins.png"
    save_histogram(luminance, output_path, bins=10)
    assert output_path.exists()
    assert output_path.stat().st_size > 0


# ---------------------------------------------------------------------------
# 3. save_radial_profile
# ---------------------------------------------------------------------------


def test_save_radial_profile_creates_file(tmp_path: Path):
    """save_radial_profile を呼ぶと PNG ファイルが生成されること.

    仕様: 放射状輝度プロファイルを線グラフとして PNG に保存する
         profile shape: (n_bins, 2),列 0: 距離,列 1: 輝度(visualize.py)
    """
    rng = numpy.random.RandomState(1)
    distances = numpy.linspace(0.0, 1.0, 20)
    luminances = 100.0 + rng.normal(0, 5, 20)
    profile = numpy.column_stack([distances, luminances])  # (20, 2)
    output_path = tmp_path / "radial_profile.png"
    save_radial_profile(profile, output_path)
    assert output_path.exists()


def test_save_radial_profile_file_not_empty(tmp_path: Path):
    """生成された radial_profile PNG ファイルのサイズが 0 より大きいこと."""
    distances = numpy.linspace(0.0, 1.0, 20)
    luminances = numpy.linspace(120.0, 80.0, 20)  # 線形減衰
    profile = numpy.column_stack([distances, luminances])
    output_path = tmp_path / "radial_profile.png"
    save_radial_profile(profile, output_path)
    assert output_path.stat().st_size > 0


def test_save_radial_profile_with_nan_does_not_crash(tmp_path: Path):
    """NaN を含む行がある profile を渡してもクラッシュしないこと.

    仕様: 空ビンは nan で表現される(評価仕様の空ビン扱い)
    matplotlib の plot は NaN を自動でスキップする(線が途切れる挙動)ため,
    クラッシュせずにファイルが生成されること.
    """
    distances = numpy.linspace(0.0, 1.0, 20)
    luminances = numpy.linspace(120.0, 80.0, 20)
    # 空ビンを模擬するため,いくつかの行を NaN にする
    luminances[5] = float("nan")
    luminances[10] = float("nan")
    luminances[15] = float("nan")
    profile = numpy.column_stack([distances, luminances])
    output_path = tmp_path / "radial_profile_nan.png"
    save_radial_profile(profile, output_path)
    assert output_path.exists()
    assert output_path.stat().st_size > 0


# ---------------------------------------------------------------------------
# 4. save_zone_map
# ---------------------------------------------------------------------------


def test_save_zone_map_creates_file(tmp_path: Path):
    """save_zone_map を呼ぶと PNG ファイルが生成されること.

    仕様: ゾーンマップを 3 色で PNG として保存する(visualize.py save_zone_map)
         入力: int8 配列(0=center, 1=middle, 2=periphery)
    """
    from sm_tias_lightsim.evaluation.zones import classify_zones, compute_normalized_distance_map

    distance_map = compute_normalized_distance_map(16, 16)
    zone_map = classify_zones(distance_map)  # dtype: int8
    output_path = tmp_path / "zone_map.png"
    save_zone_map(zone_map, output_path)
    assert output_path.exists()


def test_save_zone_map_file_not_empty(tmp_path: Path):
    """生成された zone_map PNG ファイルのサイズが 0 より大きいこと."""
    from sm_tias_lightsim.evaluation.zones import classify_zones, compute_normalized_distance_map

    distance_map = compute_normalized_distance_map(16, 16)
    zone_map = classify_zones(distance_map)
    output_path = tmp_path / "zone_map.png"
    save_zone_map(zone_map, output_path)
    assert output_path.stat().st_size > 0


def test_save_zone_map_accepts_all_zone_values(tmp_path: Path):
    """全 3 ゾーン(0/1/2)が含まれる zone_map を渡しても PNG が生成されること.

    仕様: 値は 0(center), 1(middle), 2(periphery)のいずれか(zones.py classify_zones)
    """
    # 手動で 3 ゾーン全てを含む 3x3 マップを作成する
    zone_map = numpy.array([[0, 0, 1], [0, 1, 2], [1, 2, 2]], dtype=numpy.int8)
    output_path = tmp_path / "zone_map_all_zones.png"
    save_zone_map(zone_map, output_path)
    assert output_path.exists()
    assert output_path.stat().st_size > 0


# ---------------------------------------------------------------------------
# Step 8 追加: save_zone_map のシグネチャ拡張テスト
# ---------------------------------------------------------------------------


def test_save_zone_map_luminance_none_backward_compat_creates_file(tmp_path: Path):
    """luminance=None で呼び出しても例外なく PNG が生成されること(後方互換性).

    仕様: luminance が None の場合は zone_map のみを 3 色で塗り分けるフォールバック動作
         (visualize.py save_zone_map)
    """
    from sm_tias_lightsim.evaluation.zones import classify_zones, compute_normalized_distance_map

    distance_map = compute_normalized_distance_map(32, 32)
    zone_map = classify_zones(distance_map)
    output_path = tmp_path / "zone_map_no_luminance.png"
    save_zone_map(zone_map, output_path, luminance=None)
    assert output_path.exists()
    assert output_path.stat().st_size > 0


def test_save_zone_map_with_luminance_creates_file(tmp_path: Path):
    """luminance を渡すと例外なく PNG が生成されること(新機能).

    仕様: luminance 指定時は背景に hot luminance + ゾーン境界線を重ねる
         (visualize.py save_zone_map)
    """
    from sm_tias_lightsim.evaluation.zones import classify_zones, compute_normalized_distance_map

    luminance = _make_luminance(size=32)
    distance_map = compute_normalized_distance_map(32, 32)
    zone_map = classify_zones(distance_map)
    output_path = tmp_path / "zone_map_with_luminance.png"
    save_zone_map(zone_map, output_path, luminance=luminance)
    assert output_path.exists()
    assert output_path.stat().st_size > 0


def test_save_zone_map_with_luminance_larger_than_without(tmp_path: Path):
    """luminance を渡したときのファイルサイズが luminance=None より大きいこと.

    仕様: luminance 指定時は背景に hot luminance + 境界線が加わるため情報量が増える
         (visualize.py save_zone_map)
    注: 厳密な画像 diff は行わず「ファイルサイズの差分」でのみ確認する.
    """
    from sm_tias_lightsim.evaluation.zones import classify_zones, compute_normalized_distance_map

    luminance = _make_luminance(size=64)
    distance_map = compute_normalized_distance_map(64, 64)
    zone_map = classify_zones(distance_map)

    path_no_lum = tmp_path / "zone_map_no_lum.png"
    path_with_lum = tmp_path / "zone_map_with_lum.png"

    save_zone_map(zone_map, path_no_lum, luminance=None)
    save_zone_map(zone_map, path_with_lum, luminance=luminance)

    size_no_lum = path_no_lum.stat().st_size
    size_with_lum = path_with_lum.stat().st_size

    # 背景輝度+カラーバー+境界線で情報量が増えるため,ファイルサイズも大きくなる
    assert size_with_lum > size_no_lum