Newer
Older
SmTIAS-LightSim / tests / render / test_output.py
"""render/output.py の仕様ベーステスト(系統 A:実レンダリングなし).

仕様出典:
- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(render/output 層の責務)
- docs/03_PLAN/PLAN_02_開発ステップ.md(Step 4: render/output.py の役割)

save_png / save_luminance の両関数を NumPy 合成配列で検証する.
Mitsuba を呼ばず,ファイル生成と数値の正確さのみを確認する.
"""

from __future__ import annotations

from pathlib import Path

import numpy
import pytest
from PIL import Image

from sm_tias_lightsim.render.output import save_luminance, save_png
from sm_tias_lightsim.uniformity import REC709_COEFF_B, REC709_COEFF_G, REC709_COEFF_R

# ---------------------------------------------------------------------------
# フィクスチャ
# ---------------------------------------------------------------------------


@pytest.fixture
def hdr_image_4x4() -> numpy.ndarray:
    """4x4 の合成 HDR 画像 (4, 4, 3) float32.

    全チャンネル 0.5 の均一画像.
    """
    return numpy.full((4, 4, 3), 0.5, dtype=numpy.float32)


@pytest.fixture
def hdr_image_bright_4x4() -> numpy.ndarray:
    """4x4 の合成 HDR 画像 (4, 4, 3) float32.

    全チャンネル 2.0(HDR 域,クリップ確認用).
    """
    return numpy.full((4, 4, 3), 2.0, dtype=numpy.float32)


@pytest.fixture
def hdr_image_rgb_4x4() -> numpy.ndarray:
    """4x4 の合成 HDR 画像 (4, 4, 3) float32.

    R=1.0, G=0.5, B=0.25 の純色画像(輝度計算確認用).
    """
    img = numpy.zeros((4, 4, 3), dtype=numpy.float32)
    img[..., 0] = 1.0  # R
    img[..., 1] = 0.5  # G
    img[..., 2] = 0.25  # B
    return img


# ---------------------------------------------------------------------------
# save_png: ファイル生成
# ---------------------------------------------------------------------------


def test_save_png_creates_file(tmp_path: Path, hdr_image_4x4: numpy.ndarray):
    """save_png を呼ぶと PNG ファイルが生成されること.

    仕様: render/output → PNG ファイル出力 (SPEC_01 render/output 責務)
    """
    out_path = tmp_path / "test.png"
    save_png(hdr_image_4x4, out_path)
    assert out_path.exists()


def test_save_png_readable_by_pillow(tmp_path: Path, hdr_image_4x4: numpy.ndarray):
    """生成した PNG が PIL で読み戻せること.

    仕様: PNG として保存 (SPEC_01 render/output 責務)
    """
    out_path = tmp_path / "test.png"
    save_png(hdr_image_4x4, out_path)
    img = Image.open(str(out_path))
    assert img is not None


def test_save_png_output_is_uint8(tmp_path: Path, hdr_image_4x4: numpy.ndarray):
    """生成した PNG の dtype が uint8 であること.

    仕様: uint8 [0, 255] に変換 (output.py docstring)
    """
    out_path = tmp_path / "test.png"
    save_png(hdr_image_4x4, out_path)
    arr = numpy.array(Image.open(str(out_path)))
    assert arr.dtype == numpy.uint8


def test_save_png_size_matches_input(tmp_path: Path, hdr_image_4x4: numpy.ndarray):
    """生成した PNG の縦横サイズが入力配列と一致すること.

    仕様: 入力 (H, W, 3) → PNG (H, W) (output.py docstring)
    """
    out_path = tmp_path / "test.png"
    save_png(hdr_image_4x4, out_path)
    img = Image.open(str(out_path))
    # PIL の size は (width, height) 順
    assert img.size == (4, 4)


# ---------------------------------------------------------------------------
# save_png: gain によるクリップ
# ---------------------------------------------------------------------------


def test_save_png_default_gain_clips_hdr_to_white(
    tmp_path: Path, hdr_image_bright_4x4: numpy.ndarray
):
    """gain=1.0(既定)のとき HDR 値 2.0 が 255 にクリップされること.

    仕様: gain を掛けて [0,1] にクリップ後 uint8 化 (output.py docstring)
    """
    out_path = tmp_path / "bright.png"
    save_png(hdr_image_bright_4x4, out_path, gain=1.0)
    arr = numpy.array(Image.open(str(out_path)))
    assert arr.max() == 255


def test_save_png_half_gain_dims_output(tmp_path: Path, hdr_image_4x4: numpy.ndarray):
    """gain=0.5 のとき出力値が gain=1.0 より暗くなること.

    仕様: gain による露出補正 (output.py docstring)
    """
    out_gain1 = tmp_path / "gain1.png"
    out_gain05 = tmp_path / "gain05.png"
    save_png(hdr_image_4x4, out_gain1, gain=1.0)
    save_png(hdr_image_4x4, out_gain05, gain=0.5)
    arr1 = numpy.array(Image.open(str(out_gain1)))
    arr05 = numpy.array(Image.open(str(out_gain05)))
    assert arr05.mean() < arr1.mean()


def test_save_png_high_gain_clips_to_255(tmp_path: Path, hdr_image_4x4: numpy.ndarray):
    """gain が大きいとき出力値が 255 にクリップされること.

    仕様: [0,1] にクリップ (output.py docstring)
    """
    out_path = tmp_path / "high_gain.png"
    save_png(hdr_image_4x4, out_path, gain=10.0)
    arr = numpy.array(Image.open(str(out_path)))
    assert arr.max() == 255


# ---------------------------------------------------------------------------
# save_luminance: ファイル生成
# ---------------------------------------------------------------------------


def test_save_luminance_creates_npy_file(tmp_path: Path, hdr_image_4x4: numpy.ndarray):
    """.npy ファイルが生成されること.

    仕様: render/output → luminance.npy 出力 (SPEC_01 render/output 責務)
    """
    out_path = tmp_path / "luminance.npy"
    save_luminance(hdr_image_4x4, out_path)
    assert out_path.exists()


def test_save_luminance_loadable_by_numpy(tmp_path: Path, hdr_image_4x4: numpy.ndarray):
    """numpy.load で読み戻せること.

    仕様: luminance.npy (SPEC_01 render/output 責務)
    """
    out_path = tmp_path / "luminance.npy"
    save_luminance(hdr_image_4x4, out_path)
    arr = numpy.load(str(out_path))
    assert arr is not None


def test_save_luminance_output_dtype_is_float64(tmp_path: Path, hdr_image_4x4: numpy.ndarray):
    """出力 .npy の dtype が float64 であること.

    仕様: float64 でHDR値域をそのまま保持 (output.py docstring)
    """
    out_path = tmp_path / "luminance.npy"
    save_luminance(hdr_image_4x4, out_path)
    arr = numpy.load(str(out_path))
    assert arr.dtype == numpy.float64


def test_save_luminance_shape_is_hw(tmp_path: Path, hdr_image_4x4: numpy.ndarray):
    """出力配列の shape が (H, W) であること(チャンネル次元なし).

    仕様: Rec.709 で輝度スカラに変換 (output.py docstring)
    """
    out_path = tmp_path / "luminance.npy"
    save_luminance(hdr_image_4x4, out_path)
    arr = numpy.load(str(out_path))
    assert arr.shape == (4, 4)


# ---------------------------------------------------------------------------
# save_luminance: Rec.709 係数で輝度計算
# ---------------------------------------------------------------------------


def test_save_luminance_uses_rec709_coefficients(tmp_path: Path, hdr_image_rgb_4x4: numpy.ndarray):
    """出力輝度が Rec.709 係数を使って計算されていること.

    仕様: uniformity.py の Rec.709 係数を再利用 (output.py docstring)
    期待値: 0.2126 * 1.0 + 0.7152 * 0.5 + 0.0722 * 0.25
    """
    out_path = tmp_path / "luminance.npy"
    save_luminance(hdr_image_rgb_4x4, out_path)
    arr = numpy.load(str(out_path))
    expected = REC709_COEFF_R * 1.0 + REC709_COEFF_G * 0.5 + REC709_COEFF_B * 0.25
    assert arr.mean() == pytest.approx(expected, rel=1e-5)


def test_save_luminance_does_not_clip_hdr_values(tmp_path: Path):
    """HDR 値域(> 1.0)がクリップされずに保持されること.

    仕様: clip は行わない (output.py docstring)
    """
    img = numpy.full((2, 2, 3), 5.0, dtype=numpy.float32)
    out_path = tmp_path / "hdr_luminance.npy"
    save_luminance(img, out_path)
    arr = numpy.load(str(out_path))
    # 全チャンネル 5.0 → 輝度 = (R+G+B 係数合計) * 5.0 ≈ 5.0
    assert arr.mean() > 1.0


def test_save_luminance_uniform_image_has_single_value(
    tmp_path: Path, hdr_image_4x4: numpy.ndarray
):
    """全画素が同じ値の場合,輝度マップも均一になること.

    仕様: Rec.709 線形変換 (output.py docstring)
    """
    out_path = tmp_path / "uniform_luminance.npy"
    save_luminance(hdr_image_4x4, out_path)
    arr = numpy.load(str(out_path))
    # 全チャンネル 0.5 → 輝度 = 0.5 * (R + G + B 係数の合計) ≒ 0.5 * 1.0 = 0.5
    expected = 0.5 * (REC709_COEFF_R + REC709_COEFF_G + REC709_COEFF_B)
    assert numpy.allclose(arr, expected, rtol=1e-5)