"""render/runner.py の ensure_variant / seed 受け渡しの仕様ベーステスト.
仕様出典:
- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(render/runner 層の責務: variant 設定と mi.render)
- docs/04_SPEC/SPEC_02_シーン定義仕様.md「render」セクション(seed を含むレンダ設定)
- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」
(Mitsuba の variant はプロセス内で 1 度しか設定できない.変えるにはプロセスを作り直す)
検証する仕様:
- ensure_variant(variant) は同一 variant で何度呼んでも例外にならない(2 回目以降は無操作)
- 既に別の variant が設定済みなら警告ログを出して続行する(例外にしない)
- スイープがレンダ前にレイキャストを目的の variant で走らせられるよう,
render_scene から独立して呼べる
- render_scene は config.render.seed を mi.render に渡す
テスト方針(testing.md):
- variant はプロセス内で 1 度しか設定できないため,グローバル状態はフィクスチャで戻す
- 実 Mitsuba を使う経路は scalar_rgb(tests/conftest.py が設定済みの variant)で確認し,
「別 variant 要求時の警告」「seed の受け渡し」はモックで確認する
"""
from __future__ import annotations
import logging
from pathlib import Path
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
import sm_tias_lightsim.render.runner as runner_module
from sm_tias_lightsim.scene.schema import SceneConfig
@pytest.fixture(autouse=True)
def reset_runner_variant():
"""各テスト前後に runner._current_variant をリセットする(テスト間の干渉防止)."""
original = runner_module._current_variant
runner_module._current_variant = None
yield
runner_module._current_variant = original
def _make_scene_config(variant: str = "scalar_rgb", seed: int | None = None) -> SceneConfig:
"""最小限の SceneConfig(実 STL 不要 — build_scene をモックするため)."""
render: dict = {"variant": variant, "integrator": "path", "max_depth": 2, "spp": 1}
if seed is not None:
render["seed"] = seed
data = {
"name": "test_ensure_variant",
"geometry": {
"meshes": [{"id": "dummy", "path": "dummy.stl", "unit": "mm", "material": "white"}]
},
"materials": {"white": {"type": "diffuse", "reflectance": 0.9}},
"lights": [
{
"id": "led",
"type": "point",
"position": [0.0, 0.0, 100.0],
"intensity": 1.0,
"profile": "lambertian",
}
],
"sensor": {
"type": "perspective",
"position": [0.0, 0.0, 200.0],
"target": [0.0, 0.0, 0.0],
"up": [0.0, 1.0, 0.0],
"fov_deg": 60.0,
"film": {"width": 4, "height": 4},
},
"render": render,
}
return SceneConfig.model_validate(data)
# ---------------------------------------------------------------------------
# ensure_variant: 実 Mitsuba(scalar_rgb)での振る舞い
# ---------------------------------------------------------------------------
def test_ensure_variant_is_idempotent_for_same_variant():
"""同じ variant で 2 回呼んでも例外にならないこと(2 回目以降は無操作).
仕様: Mitsuba の set_variant はプロセス内 1 度だけ.スイープは条件ごとに
ensure_variant を呼ぶので,2 回目以降が失敗してはならない.
"""
runner_module.ensure_variant("scalar_rgb")
runner_module.ensure_variant("scalar_rgb")
import mitsuba as mi
assert mi.variant() == "scalar_rgb"
def test_ensure_variant_records_current_variant():
"""1 度目の呼び出しで内部状態(_current_variant)が記録されること."""
assert runner_module._current_variant is None
runner_module.ensure_variant("scalar_rgb")
assert runner_module._current_variant == "scalar_rgb"
def test_ensure_variant_does_not_call_set_variant_twice():
"""2 回目の呼び出しでは mi.set_variant を呼ばないこと."""
dummy_mi = MagicMock()
dummy_mi.variant.return_value = "scalar_rgb"
with (
patch("sm_tias_lightsim.render.runner.log_variant_status"),
patch.dict("sys.modules", {"mitsuba": dummy_mi}),
):
runner_module.ensure_variant("scalar_rgb")
runner_module.ensure_variant("scalar_rgb")
assert dummy_mi.set_variant.call_count == 1
# ---------------------------------------------------------------------------
# ensure_variant: 異なる variant 要求時
# ---------------------------------------------------------------------------
def test_ensure_variant_warns_when_different_variant_requested(caplog):
"""別の variant を要求すると警告ログを出して続行すること(例外にしない).
仕様: 同一プロセスで variant は切り替えられない.要求は無視し,
プロセスを作り直すよう促す警告を出す.
"""
dummy_mi = MagicMock()
dummy_mi.variant.return_value = "scalar_rgb"
with (
patch("sm_tias_lightsim.render.runner.log_variant_status"),
patch.dict("sys.modules", {"mitsuba": dummy_mi}),
):
runner_module.ensure_variant("scalar_rgb")
with caplog.at_level(logging.WARNING, logger="sm_tias_lightsim.render.runner"):
runner_module.ensure_variant("llvm_ad_rgb")
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert warnings, "別 variant 要求時に WARNING ログが出ること"
assert dummy_mi.set_variant.call_count == 1, "2 度目の set_variant は呼ばないこと"
def test_ensure_variant_keeps_first_variant_after_conflicting_request():
"""別 variant を要求しても最初に設定した variant が維持されること."""
dummy_mi = MagicMock()
dummy_mi.variant.return_value = "scalar_rgb"
with (
patch("sm_tias_lightsim.render.runner.log_variant_status"),
patch.dict("sys.modules", {"mitsuba": dummy_mi}),
):
runner_module.ensure_variant("scalar_rgb")
runner_module.ensure_variant("cuda_ad_rgb")
assert runner_module._current_variant == "scalar_rgb"
def test_ensure_variant_calls_ensure_drjit_libllvm_path_for_llvm_variant():
"""llvm_* 要求時は set_variant 前に DRJIT_LIBLLVM_PATH を探索すること."""
dummy_mi = MagicMock()
dummy_mi.variant.return_value = "llvm_ad_rgb"
with (
patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path") as mock_ensure,
patch("sm_tias_lightsim.render.runner.log_variant_status"),
patch.dict("sys.modules", {"mitsuba": dummy_mi}),
):
runner_module.ensure_variant("llvm_ad_rgb")
mock_ensure.assert_called_once()
def test_ensure_variant_skips_llvm_path_lookup_for_scalar_variant():
"""scalar_* 要求時は DRJIT_LIBLLVM_PATH の探索を行わないこと."""
dummy_mi = MagicMock()
dummy_mi.variant.return_value = "scalar_rgb"
with (
patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path") as mock_ensure,
patch("sm_tias_lightsim.render.runner.log_variant_status"),
patch.dict("sys.modules", {"mitsuba": dummy_mi}),
):
runner_module.ensure_variant("scalar_rgb")
mock_ensure.assert_not_called()
# ---------------------------------------------------------------------------
# render_scene から ensure_variant を通じた variant 設定
# ---------------------------------------------------------------------------
def _render_with_mock(config: SceneConfig) -> MagicMock:
"""build_scene / mitsuba をモックして render_scene を呼び,mitsuba モックを返す."""
dummy_image = np.zeros((4, 4, 3), dtype=np.float32)
dummy_mi = MagicMock()
dummy_mi.variant.return_value = config.render.variant
# numpy.array() でそのまま配列になるよう実配列を返す(mi.render の戻り値の代役)
dummy_mi.render.return_value = dummy_image
with (
patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"),
patch(
"sm_tias_lightsim.render.runner.build_scene",
return_value=({"type": "scene"}, "/tmp/fake"),
),
patch("sm_tias_lightsim.render.runner.log_variant_status"),
patch.dict("sys.modules", {"mitsuba": dummy_mi}),
patch("shutil.rmtree"),
):
runner_module.render_scene(config, project_root=Path("/tmp"))
return dummy_mi
def test_render_scene_sets_variant_from_config():
"""render_scene が config.render.variant で variant を設定すること."""
_render_with_mock(_make_scene_config(variant="scalar_rgb"))
assert runner_module._current_variant == "scalar_rgb"
def test_render_scene_passes_seed_to_mi_render():
"""render_scene が config.render.seed を mi.render に渡すこと.
仕様: 同一条件の再レンダで別のノイズ実現を得るため,seed はレンダに伝わる必要がある.
"""
dummy_mi = _render_with_mock(_make_scene_config(variant="scalar_rgb", seed=3))
_, kwargs = dummy_mi.render.call_args
assert kwargs["seed"] == 3
def test_render_scene_passes_default_seed_zero():
"""seed 未指定(既定 0)でも mi.render に seed=0 が渡ること."""
dummy_mi = _render_with_mock(_make_scene_config(variant="scalar_rgb"))
_, kwargs = dummy_mi.render.call_args
assert kwargs["seed"] == 0
def test_render_scene_passes_spp_and_seed_together():
"""spp と seed の両方が mi.render に渡ること(片方の追加で他方が落ちない)."""
dummy_mi = _render_with_mock(_make_scene_config(variant="scalar_rgb", seed=5))
_, kwargs = dummy_mi.render.call_args
assert kwargs["spp"] == 1
assert kwargs["seed"] == 5
@pytest.mark.slow
def test_different_seeds_give_different_images(sphere_stl_in_cwd: Path, minimal_scene_yaml: str):
"""seed を変えると実レンダの画素が変わること(ノイズの実現が変わる).
仕様: 再現性チェック(同一条件の再レンダ)で seed を振ってノイズフロアを測るため,
seed が結果に効いていなければならない.
"""
import yaml
data = yaml.safe_load(minimal_scene_yaml)
data["render"]["variant"] = "scalar_rgb"
data["render"]["spp"] = 4
data["render"]["seed"] = 0
img_a = runner_module.render_scene(
SceneConfig.model_validate(data), project_root=sphere_stl_in_cwd.parent
)
data["render"]["seed"] = 1
img_b = runner_module.render_scene(
SceneConfig.model_validate(data), project_root=sphere_stl_in_cwd.parent
)
assert img_a.shape == img_b.shape
assert not np.array_equal(img_a, img_b), "seed が違えば画素は一致しないこと"