"""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)