diff --git a/tests/cli/test_evaluate_no_figures.py b/tests/cli/test_evaluate_no_figures.py new file mode 100644 index 0000000..df6c701 --- /dev/null +++ b/tests/cli/test_evaluate_no_figures.py @@ -0,0 +1,145 @@ +"""sm-evaluate の --no-figures オプションの仕様ベーステスト(統合テスト). + +仕様出典: +- docs/04_SPEC/SPEC_04_評価仕様.md「出力ファイル仕様」(既定は metrics.csv + figures/ 4 種) +- docs/02_ENV/ENV_04_開発コマンド.md(sm-evaluate は PNG / luminance.npy を評価し + metrics.csv + figures/ を出力する) +- 実装サマリー: --no-figures を付けると figures/ を作らず描画もせず, + サマリーの figures/ 行も出さない(指標だけ欲しいスイープ用) + +検証する仕様: +- --no-figures なしは従来どおり(後方互換): metrics.csv + figures/ 4 種 +- --no-figures ありは metrics.csv のみ.終了コードは 0 +- どちらでも metrics.csv の内容は同一(描画は指標に影響しない) + +テスト方針(testing.md): subprocess ではなく main() を直接呼ぶ.tmp_path で実ファイルを使う. +""" + +from __future__ import annotations + +import matplotlib # noqa: I001 + +# headless 環境でも動くよう Agg バックエンドを pyplot より前に設定する +matplotlib.use("Agg") + +from pathlib import Path + +import numpy +import pytest + +from sm_tias_lightsim.cli.evaluate import main + +_FIGURE_FILES = ("luminance_map.png", "histogram.png", "radial_profile.png", "zone_map.png") + + +def _make_inputs(tmp_path: Path, seed: int = 0) -> tuple[Path, Path]: + """合成輝度 .npy と評価 YAML を書き出してパスを返す(seed 固定).""" + rng = numpy.random.RandomState(seed) + luminance = numpy.full((128, 128), 100.0) + rng.normal(0, 5, (128, 128)) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = tmp_path / "eval_config.yaml" + config_path.write_text( + """\ +roi: + type: image_pixel + x: 16 + y: 16 + width: 96 + height: 96 +zones: + enabled: true + boundaries: [0.33, 0.66] +metrics: + cov: true + std: true + max_min: true + radial_profile: true +""", + encoding="utf-8", + ) + return npy_path, config_path + + +def _run(npy: Path, config: Path, out_dir: Path, *extra: str) -> int: + return main( + ["--image", str(npy), "--config", str(config), "--output", str(out_dir), *extra] + ) + + +def test_no_figures_returns_0(tmp_path: Path): + """--no-figures 付きでも正常終了(0)すること.""" + npy, config = _make_inputs(tmp_path, seed=0) + assert _run(npy, config, tmp_path / "out", "--no-figures") == 0 + + +def test_no_figures_creates_metrics_csv(tmp_path: Path): + """--no-figures でも metrics.csv は出力されること.""" + npy, config = _make_inputs(tmp_path, seed=1) + out_dir = tmp_path / "out" + _run(npy, config, out_dir, "--no-figures") + assert (out_dir / "metrics.csv").is_file() + + +def test_no_figures_does_not_create_figures_dir(tmp_path: Path): + """--no-figures のとき figures/ ディレクトリが作られないこと.""" + npy, config = _make_inputs(tmp_path, seed=2) + out_dir = tmp_path / "out" + _run(npy, config, out_dir, "--no-figures") + assert not (out_dir / "figures").exists() + + +def test_no_figures_writes_no_png(tmp_path: Path): + """--no-figures のとき PNG が 1 つも出力されないこと.""" + npy, config = _make_inputs(tmp_path, seed=3) + out_dir = tmp_path / "out" + _run(npy, config, out_dir, "--no-figures") + assert list(out_dir.rglob("*.png")) == [] + + +def test_no_figures_summary_has_no_figures_line(tmp_path: Path, capsys): + """--no-figures のとき標準出力のサマリーに figures/ 行が出ないこと.""" + npy, config = _make_inputs(tmp_path, seed=4) + _run(npy, config, tmp_path / "out", "--no-figures") + out = capsys.readouterr().out + assert "=== 均一性評価サマリー ===" in out + assert "metrics.csv" in out + assert "figures/" not in out + + +def test_default_still_creates_figures(tmp_path: Path): + """--no-figures を付けなければ従来どおり figures/ 4 種が出ること(後方互換).""" + npy, config = _make_inputs(tmp_path, seed=5) + out_dir = tmp_path / "out" + assert _run(npy, config, out_dir) == 0 + for name in _FIGURE_FILES: + assert (out_dir / "figures" / name).is_file(), name + + +def test_metrics_csv_identical_with_and_without_figures(tmp_path: Path): + """--no-figures の有無で metrics.csv の内容が一致すること.""" + npy, config = _make_inputs(tmp_path, seed=6) + with_dir = tmp_path / "with" + without_dir = tmp_path / "without" + _run(npy, config, with_dir) + _run(npy, config, without_dir, "--no-figures") + assert (with_dir / "metrics.csv").read_text(encoding="utf-8") == ( + without_dir / "metrics.csv" + ).read_text(encoding="utf-8") + + +def test_help_mentions_no_figures(capsys): + """--help に --no-figures が説明されていること.""" + with pytest.raises(SystemExit) as exc: + main(["--help"]) + assert exc.value.code == 0 + assert "--no-figures" in capsys.readouterr().out + + +def test_no_figures_error_path_still_returns_1(tmp_path: Path, capsys): + """--no-figures 付きでも異常時は「エラー: 」を stderr に出して 1 を返すこと.""" + _, config = _make_inputs(tmp_path, seed=7) + result = _run(tmp_path / "missing.npy", config, tmp_path / "out", "--no-figures") + assert result == 1 + assert "エラー: " in capsys.readouterr().err diff --git a/tests/cli/test_evaluate_radial_min_max.py b/tests/cli/test_evaluate_radial_min_max.py index 155b511..fc01ef0 100644 --- a/tests/cli/test_evaluate_radial_min_max.py +++ b/tests/cli/test_evaluate_radial_min_max.py @@ -3,7 +3,8 @@ 仕様出典: - docs/04_SPEC/SPEC_04_評価仕様.md(動径指標・評価パイプライン) - docs/04_SPEC/SPEC_02_シーン定義仕様.md(evaluation/metrics セクション) -- src/sm_tias_lightsim/cli/evaluate.py(_run_radial_min_max 実装) +- src/sm_tias_lightsim/evaluation/pipeline.py(_run_radial_min_max 実装.cli/evaluate.py は + 引数パースと例外→終了コード変換だけを担い,同名を再エクスポートしている) - src/sm_tias_lightsim/evaluation/radial.py(calc_radial_min_max_ratio 実装) テスト方針: @@ -11,6 +12,8 @@ - ファイル I/O はモックせず tmp_path フィクスチャで実ファイルを使う (testing.md) - 乱数を使う場合は seed を必ず固定する (testing.md) - monkeypatch で calc_radial_profile の呼び出し有無を検証する + (差し替え先は実体のある evaluation.pipeline.cli.evaluate の再エクスポートを + 差し替えてもパイプライン内の参照には伝播しない) 注意: matplotlib の Agg バックエンドは evaluate.py 内部で設定される. @@ -219,7 +222,7 @@ metrics_cfg = MetricsConfig(radial_min_max=True) profile = calc_radial_profile(roi_luminance) - with patch("sm_tias_lightsim.cli.evaluate.calc_radial_profile") as mock_calc: + with patch("sm_tias_lightsim.evaluation.pipeline.calc_radial_profile") as mock_calc: _run_radial_min_max(roi_luminance, metrics_cfg, profile) mock_calc.assert_not_called() @@ -258,7 +261,7 @@ real_profile = calc_radial_profile(roi_luminance) with patch( - "sm_tias_lightsim.cli.evaluate.calc_radial_profile", + "sm_tias_lightsim.evaluation.pipeline.calc_radial_profile", return_value=real_profile, ) as mock_calc: result = _run_radial_min_max(roi_luminance, metrics_cfg, None) @@ -592,7 +595,7 @@ output_dir = tmp_path / "output" with patch( - "sm_tias_lightsim.cli.evaluate._run_radial_min_max", + "sm_tias_lightsim.evaluation.pipeline._run_radial_min_max", side_effect=RuntimeError("テスト用エラー"), ): result = _run_main(npy_path, config_path, output_dir) @@ -614,7 +617,7 @@ output_dir = tmp_path / "output" with patch( - "sm_tias_lightsim.cli.evaluate._run_radial_min_max", + "sm_tias_lightsim.evaluation.pipeline._run_radial_min_max", side_effect=RuntimeError("テスト用エラー"), ): main(["--image", str(npy_path), "--config", str(config_path), "--output", str(output_dir)]) @@ -637,7 +640,7 @@ output_dir = tmp_path / "output" with patch( - "sm_tias_lightsim.cli.evaluate.calc_radial_min_max_ratio", + "sm_tias_lightsim.evaluation.pipeline.calc_radial_min_max_ratio", side_effect=ValueError("テスト用内部エラー"), ): result = _run_main(npy_path, config_path, output_dir) diff --git a/tests/cli/test_render_jit_init.py b/tests/cli/test_render_jit_init.py index 01e6ce7..57c323e 100644 --- a/tests/cli/test_render_jit_init.py +++ b/tests/cli/test_render_jit_init.py @@ -7,7 +7,8 @@ テスト対象: - E1: cli/render.py がモジュール import の段階で mitsuba を import しないこと - E1 (動作): llvm_* variant 設定時に ensure_drjit_libllvm_path を呼んでから - render_scene を import すること + render_scene を import すること(処理本体が render/pipeline.py の + render_yaml へ移ったため,順序の検証対象もそちら) テスト方針: - AST レベルで import 文の存在をチェック(モジュールレベルの mitsuba import 不在を検証) @@ -24,9 +25,9 @@ # E1: cli/render.py がモジュール import 時点で mitsuba を import しないこと # --------------------------------------------------------------------------- -_CLI_RENDER_PATH = ( - Path(__file__).parent.parent.parent / "src" / "sm_tias_lightsim" / "cli" / "render.py" -) +_SRC = Path(__file__).parent.parent.parent / "src" / "sm_tias_lightsim" +_CLI_RENDER_PATH = _SRC / "cli" / "render.py" +_PIPELINE_PATH = _SRC / "render" / "pipeline.py" class TestCliRenderImportOrder: @@ -131,55 +132,107 @@ "仕様: モジュールレベルは標準ライブラリのみ(Mitsuba / 内部モジュールは遅延 import)" ) - def test_ensure_drjit_libllvm_path_is_called_in_main_before_render_scene(self) -> None: - """E1 (動作): main() の llvm_* 分岐で ensure_drjit_libllvm_path を - render_scene より前に呼んでいること(AST による検証). + def test_main_delegates_rendering_to_render_yaml(self) -> None: + """E1: main() は処理本体を render_yaml に委譲していること. 仕様: - runner.py の import(build_scene → sensors.py / lights.py → mitsuba)より先に呼ぶ. - ensure_drjit_libllvm_path は llvm_* variant のとき main() 内で先行呼び出しされる. + cli/render.py は引数のパースと例外 → 終了コード変換だけを担い, + 「YAML 読込 → variant 解決 → レンダ → 保存」は render/pipeline.py が行う. + JIT 初期化(ensure_drjit_libllvm_path)の順序保証もそちらへ移っている. """ src = open(_CLI_RENDER_PATH, encoding="utf-8").read() tree = ast.parse(src) - # main 関数の AST を取得 main_func = next( (n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef) and n.name == "main"), None, ) assert main_func is not None, "main() 関数が cli/render.py に存在すること" - # main 関数内で ensure_drjit_libllvm_path と render_scene の呼び出し順を確認 - # (AST の lineno ベースで順序を検証) - ensure_calls: list[int] = [] - render_calls: list[int] = [] - - for node in ast.walk(main_func): - if isinstance(node, ast.Call): - # ensure_drjit_libllvm_path の呼び出し - if isinstance(node.func, ast.Name) and node.func.id == "ensure_drjit_libllvm_path": - ensure_calls.append(node.lineno) - elif ( - isinstance(node.func, ast.Attribute) - and node.func.attr == "ensure_drjit_libllvm_path" - ): - ensure_calls.append(node.lineno) - # render_scene の呼び出し - if isinstance(node.func, ast.Name) and node.func.id == "render_scene": - render_calls.append(node.lineno) - elif isinstance(node.func, ast.Attribute) and node.func.attr == "render_scene": - render_calls.append(node.lineno) - - # ensure_drjit_libllvm_path は main() 内に存在すること - assert ensure_calls, ( - "main() 内に ensure_drjit_libllvm_path の呼び出しが存在すること\n" - "仕様: llvm_* variant のとき render_scene より前に呼ぶ" + called = { + node.func.id + for node in ast.walk(main_func) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + } + assert "render_yaml" in called, ( + "main() は render_yaml を呼ぶこと(レンダ処理は render/pipeline.py に集約する)" ) - # render_scene は main() 内に存在すること - assert render_calls, "main() 内に render_scene の呼び出しが存在すること" - # ensure が render より前の行にあること +class TestRenderPipelineImportOrder: + """E1: render/pipeline.py の import 順が仕様通りであることを検証する. + + 仕様: + render_yaml() は llvm_* variant のとき ensure_drjit_libllvm_path を + render_scene(= mitsuba import chain)より前に呼ぶ. + そのためにモジュールレベルでは mitsuba 依存モジュールを import しない. + """ + + def test_no_top_level_mitsuba_or_runner_import_in_pipeline(self) -> None: + """E1: render/pipeline.py のモジュールレベルに mitsuba / runner の import が無いこと. + + 仕様: + モジュールレベルで import すると DRJIT_LIBLLVM_PATH 設定より前に + LLVM 初期化が走ってしまう(cli/render.py と同じ遅延 import 方針). + """ + src = open(_PIPELINE_PATH, encoding="utf-8").read() + tree = ast.parse(src) + + offenders: list[str] = [] + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name.startswith("mitsuba"): + offenders.append(alias.name) + elif isinstance(node, ast.ImportFrom) and node.module: + if node.module.startswith("mitsuba") or "runner" in node.module: + offenders.append(node.module) + + assert not offenders, ( + f"render/pipeline.py のモジュールレベルに遅延すべき import がある: {offenders}." + "仕様: mitsuba 依存 import はすべて render_yaml() 内で行う" + ) + + def test_ensure_drjit_libllvm_path_is_called_before_render_scene(self) -> None: + """E1 (動作): render_yaml() の llvm_* 分岐で ensure_drjit_libllvm_path を + render_scene より前に呼んでいること(AST による検証). + + 仕様: + runner.py の import(build_scene → sensors.py / lights.py → mitsuba)より先に呼ぶ. + """ + src = open(_PIPELINE_PATH, encoding="utf-8").read() + tree = ast.parse(src) + + func = next( + ( + n + for n in ast.walk(tree) + if isinstance(n, ast.FunctionDef) and n.name == "render_yaml" + ), + None, + ) + assert func is not None, "render_yaml() 関数が render/pipeline.py に存在すること" + + ensure_calls: list[int] = [] + render_calls: list[int] = [] + for node in ast.walk(func): + if not isinstance(node, ast.Call): + continue + name = None + if isinstance(node.func, ast.Name): + name = node.func.id + elif isinstance(node.func, ast.Attribute): + name = node.func.attr + if name == "ensure_drjit_libllvm_path": + ensure_calls.append(node.lineno) + elif name == "render_scene": + render_calls.append(node.lineno) + + assert ensure_calls, ( + "render_yaml() 内に ensure_drjit_libllvm_path の呼び出しが存在すること." + "仕様: llvm_* variant のとき render_scene より前に呼ぶ" + ) + assert render_calls, "render_yaml() 内に render_scene の呼び出しが存在すること" assert min(ensure_calls) < min(render_calls), ( f"ensure_drjit_libllvm_path (line {min(ensure_calls)}) は " f"render_scene (line {min(render_calls)}) より前に呼ばれること" diff --git a/tests/evaluation/test_pipeline.py b/tests/evaluation/test_pipeline.py new file mode 100644 index 0000000..89cdee2 --- /dev/null +++ b/tests/evaluation/test_pipeline.py @@ -0,0 +1,296 @@ +"""evaluation/pipeline.py(evaluate_image)の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_04_評価仕様.md + 「出力ファイル仕様」: /metrics.csv と /figures/{luminance_map, histogram, + radial_profile, zone_map}.png + 「metrics.csv カラム定義」: cov, std, max_min_ratio, mean, max, min, + center_*, middle_*, periphery_*, center_periphery_ratio, gradient_magnitude +- docs/04_SPEC/SPEC_02_シーン定義仕様.md「evaluation」セクション(ROI / zones / metrics) +- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(evaluate パイプラインの責務分離) + +検証する仕様: +- evaluate_image() が CLI と同じ評価を行い,指標の辞書を返す +- save_figures=False のとき figures/ を作らず描画もしない(指標は変わらない) +- 段階ごとの失敗は EvaluationStepError として,どの段階かが分かる文言で送出される + (CLI はこれを「エラー: …」+ 終了コード 1 に変換するだけ) + +テスト方針(testing.md): +- ファイル I/O はモックせず tmp_path で実ファイルを使う +- 合成輝度マップは seed を固定する +""" + +from __future__ import annotations + +import matplotlib # noqa: I001 + +# headless 環境でも動くよう Agg バックエンドを pyplot より前に設定する +matplotlib.use("Agg") + +from pathlib import Path + +import numpy +import pytest + +from sm_tias_lightsim.evaluation.pipeline import EvaluationStepError, evaluate_image + +# SPEC_04「metrics.csv カラム定義(ヘッダ順)」の必須列 +_BASE_COLUMNS = ("cov", "std", "max_min_ratio", "mean", "max", "min") +_ZONE_COLUMNS = ( + "center_mean", + "center_std", + "middle_mean", + "middle_std", + "periphery_mean", + "periphery_std", + "center_periphery_ratio", + "gradient_magnitude", +) +# SPEC_04「可視化規約」の 4 種 +_FIGURE_FILES = ("luminance_map.png", "histogram.png", "radial_profile.png", "zone_map.png") + + +def _make_luminance_array(seed: int = 0, size: int = 128) -> numpy.ndarray: + """ゼロを含まない合成輝度配列を返す(seed 固定).""" + rng = numpy.random.RandomState(seed) + return numpy.full((size, size), 100.0) + rng.normal(0, 5, (size, size)) + + +def _write_npy(tmp_path: Path, seed: int = 0, size: int = 128) -> Path: + path = tmp_path / "luminance.npy" + numpy.save(path, _make_luminance_array(seed=seed, size=size)) + return path + + +def _write_eval_yaml( + tmp_path: Path, + x: int = 16, + y: int = 16, + width: int = 96, + height: int = 96, + zones_enabled: bool = True, + radial_profile: bool = True, +) -> Path: + """評価専用 YAML(トップレベルが EvaluationConfig 互換)を書き出す.""" + content = f"""\ +roi: + type: image_pixel + x: {x} + y: {y} + width: {width} + height: {height} +zones: + enabled: {"true" if zones_enabled else "false"} + boundaries: [0.33, 0.66] +metrics: + cov: true + std: true + max_min: true + radial_profile: {"true" if radial_profile else "false"} +""" + path = tmp_path / "eval_config.yaml" + path.write_text(content, encoding="utf-8") + return path + + +# --------------------------------------------------------------------------- +# 正常系: 既定(save_figures=True) +# --------------------------------------------------------------------------- + + +def test_returns_metrics_dict(tmp_path: Path): + """evaluate_image が指標の辞書を返すこと. + + 仕様: SPEC_04「metrics.csv カラム定義」の基本 6 指標を含む. + """ + metrics = evaluate_image( + _write_npy(tmp_path, seed=0), _write_eval_yaml(tmp_path), tmp_path / "out" + ) + assert isinstance(metrics, dict) + for key in _BASE_COLUMNS: + assert key in metrics, key + assert isinstance(metrics[key], float) + + +def test_returned_metrics_are_physically_consistent(tmp_path: Path): + """返された指標が互いに整合すること(min <= mean <= max・CoV = std/mean).""" + metrics = evaluate_image( + _write_npy(tmp_path, seed=1), _write_eval_yaml(tmp_path), tmp_path / "out" + ) + assert metrics["min"] <= metrics["mean"] <= metrics["max"] + assert metrics["cov"] == pytest.approx(metrics["std"] / metrics["mean"], rel=1e-9) + assert metrics["max_min_ratio"] == pytest.approx(metrics["max"] / metrics["min"], rel=1e-9) + + +def test_zone_metrics_included_when_zones_enabled(tmp_path: Path): + """zones.enabled のときゾーン指標が辞書に含まれること(SPEC_04 3 ゾーン分析).""" + metrics = evaluate_image( + _write_npy(tmp_path, seed=2), _write_eval_yaml(tmp_path), tmp_path / "out" + ) + for key in _ZONE_COLUMNS: + assert key in metrics, key + + +def test_creates_metrics_csv(tmp_path: Path): + """metrics.csv が出力ディレクトリ直下に作られること(SPEC_04 出力ファイル仕様).""" + out_dir = tmp_path / "out" + evaluate_image(_write_npy(tmp_path, seed=3), _write_eval_yaml(tmp_path), out_dir) + assert (out_dir / "metrics.csv").is_file() + + +def test_metrics_csv_matches_returned_dict(tmp_path: Path): + """metrics.csv の内容が戻り値の辞書と一致すること.""" + import csv + + out_dir = tmp_path / "out" + metrics = evaluate_image(_write_npy(tmp_path, seed=4), _write_eval_yaml(tmp_path), out_dir) + with (out_dir / "metrics.csv").open(encoding="utf-8") as fh: + row = next(csv.DictReader(fh)) + assert set(row) == set(metrics) + for key, value in row.items(): + assert float(value) == pytest.approx(metrics[key], rel=1e-6) + + +def test_creates_four_figures_by_default(tmp_path: Path): + """既定(save_figures 省略)で figures/ に 4 種の PNG が出ること(SPEC_04 可視化規約).""" + out_dir = tmp_path / "out" + evaluate_image(_write_npy(tmp_path, seed=5), _write_eval_yaml(tmp_path), out_dir) + for name in _FIGURE_FILES: + assert (out_dir / "figures" / name).is_file(), name + + +def test_creates_deep_nested_output_dir(tmp_path: Path): + """出力ディレクトリが親ごと作られること.""" + out_dir = tmp_path / "a" / "b" / "c" + evaluate_image(_write_npy(tmp_path, seed=6), _write_eval_yaml(tmp_path), out_dir) + assert (out_dir / "metrics.csv").is_file() + + +# --------------------------------------------------------------------------- +# save_figures=False(スイープ用の図なしモード) +# --------------------------------------------------------------------------- + + +def test_no_figures_dir_when_save_figures_false(tmp_path: Path): + """save_figures=False のとき figures/ が作られないこと.""" + out_dir = tmp_path / "out" + evaluate_image( + _write_npy(tmp_path, seed=7), _write_eval_yaml(tmp_path), out_dir, save_figures=False + ) + assert not (out_dir / "figures").exists() + + +def test_metrics_csv_still_created_when_save_figures_false(tmp_path: Path): + """save_figures=False でも metrics.csv は出ること(指標だけ欲しい用途).""" + out_dir = tmp_path / "out" + evaluate_image( + _write_npy(tmp_path, seed=8), _write_eval_yaml(tmp_path), out_dir, save_figures=False + ) + assert (out_dir / "metrics.csv").is_file() + + +def test_metrics_identical_with_and_without_figures(tmp_path: Path): + """図の有無で指標が変わらないこと(描画は評価結果に影響しない).""" + npy = _write_npy(tmp_path, seed=9) + config = _write_eval_yaml(tmp_path) + with_figs = evaluate_image(npy, config, tmp_path / "with", save_figures=True) + without_figs = evaluate_image(npy, config, tmp_path / "without", save_figures=False) + assert set(with_figs) == set(without_figs) + for key in with_figs: + assert with_figs[key] == pytest.approx(without_figs[key], rel=1e-12) + + +def test_summary_omits_figures_line_when_save_figures_false(tmp_path: Path, capsys): + """save_figures=False のときサマリーに figures/ 行を出さないこと.""" + evaluate_image( + _write_npy(tmp_path, seed=10), + _write_eval_yaml(tmp_path), + tmp_path / "out", + save_figures=False, + ) + out = capsys.readouterr().out + assert "metrics.csv" in out + assert "figures/" not in out + + +def test_summary_includes_figures_line_by_default(tmp_path: Path, capsys): + """既定ではサマリーに figures/ 行が出ること(従来どおり).""" + evaluate_image(_write_npy(tmp_path, seed=11), _write_eval_yaml(tmp_path), tmp_path / "out") + assert "figures/" in capsys.readouterr().out + + +def test_no_figure_files_written_anywhere_when_disabled(tmp_path: Path): + """save_figures=False のとき出力ディレクトリ配下に PNG が 1 つも無いこと.""" + out_dir = tmp_path / "out" + evaluate_image( + _write_npy(tmp_path, seed=12), _write_eval_yaml(tmp_path), out_dir, save_figures=False + ) + assert list(out_dir.rglob("*.png")) == [] + + +# --------------------------------------------------------------------------- +# 異常系: 段階別の EvaluationStepError +# --------------------------------------------------------------------------- + + +def test_missing_image_raises_step_error(tmp_path: Path): + """画像が無いとき EvaluationStepError になること(見つからない旨を含む).""" + with pytest.raises(EvaluationStepError) as exc: + evaluate_image(tmp_path / "nope.npy", _write_eval_yaml(tmp_path), tmp_path / "out") + assert "画像ファイルが見つかりません" in str(exc.value) + + +def test_broken_image_raises_load_step_error(tmp_path: Path): + """壊れた .npy は「画像の読み込みに失敗しました」段階の例外になること.""" + bad = tmp_path / "broken.npy" + bad.write_bytes(b"not a npy file") + with pytest.raises(EvaluationStepError) as exc: + evaluate_image(bad, _write_eval_yaml(tmp_path), tmp_path / "out") + assert "画像の読み込みに失敗しました" in str(exc.value) + + +def test_missing_config_raises_step_error(tmp_path: Path): + """設定 YAML が無いとき EvaluationStepError になること.""" + with pytest.raises(EvaluationStepError) as exc: + evaluate_image(_write_npy(tmp_path, seed=13), tmp_path / "nope.yaml", tmp_path / "out") + assert "設定 YAML ファイルが見つかりません" in str(exc.value) + + +def test_invalid_config_raises_config_step_error(tmp_path: Path): + """ROI 欠落の YAML は「設定 YAML の読み込みに失敗しました」段階の例外になること.""" + config = tmp_path / "bad.yaml" + config.write_text("zones:\n enabled: true\n", encoding="utf-8") + with pytest.raises(EvaluationStepError) as exc: + evaluate_image(_write_npy(tmp_path, seed=14), config, tmp_path / "out") + assert "設定 YAML の読み込みに失敗しました" in str(exc.value) + + +def test_roi_out_of_bounds_raises_roi_step_error(tmp_path: Path): + """ROI が画像外のとき「ROI 抽出に失敗しました」段階の例外になること.""" + config = _write_eval_yaml(tmp_path, x=0, y=0, width=9999, height=9999) + with pytest.raises(EvaluationStepError) as exc: + evaluate_image(_write_npy(tmp_path, seed=15), config, tmp_path / "out") + assert "ROI 抽出に失敗しました" in str(exc.value) + + +def test_zero_luminance_raises_metrics_step_error(tmp_path: Path): + """全ゼロ画像は「均一性指標の算出に失敗しました」段階の例外になること.""" + npy = tmp_path / "zeros.npy" + numpy.save(npy, numpy.zeros((128, 128), dtype=numpy.float64)) + with pytest.raises(EvaluationStepError) as exc: + evaluate_image(npy, _write_eval_yaml(tmp_path), tmp_path / "out") + assert "均一性指標の算出に失敗しました" in str(exc.value) + + +def test_step_error_is_runtime_error_subclass(): + """EvaluationStepError は RuntimeError 派生であること(呼び出し側の捕捉粒度).""" + assert issubclass(EvaluationStepError, RuntimeError) + + +def test_no_output_written_when_roi_fails(tmp_path: Path): + """ROI 抽出で失敗したとき metrics.csv は作られないこと(中途半端な出力を残さない).""" + out_dir = tmp_path / "out" + config = _write_eval_yaml(tmp_path, x=0, y=0, width=9999, height=9999) + with pytest.raises(EvaluationStepError): + evaluate_image(_write_npy(tmp_path, seed=16), config, out_dir) + assert not (out_dir / "metrics.csv").exists() diff --git a/tests/render/test_pipeline.py b/tests/render/test_pipeline.py new file mode 100644 index 0000000..75727b9 --- /dev/null +++ b/tests/render/test_pipeline.py @@ -0,0 +1,245 @@ +"""render/pipeline.py(render_yaml)の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(render パイプラインの責務.CLI は薄い層) +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(シーン YAML スキーマ・render セクション) +- docs/02_ENV/ENV_04_開発コマンド.md + (sm-render は PNG / luminance.npy を出力.variant は --variant > SM_RENDER_VARIANT > YAML) + +検証する仕様: +- render_yaml(scene_yaml, output_dir, variant) が render.png と luminance.npy を書き, + そのパスを (png, npy) の順で返す +- variant の優先順位は 引数 > 環境変数 SM_RENDER_VARIANT > YAML の render.variant で, + 上書き時だけ「variant を上書き: A → B」を表示する(YAML ファイルは書き換えない) +- 失敗は段階別の文言を持つ RenderStepError になる(CLI が「エラー: …」に変換する) + +テスト方針(testing.md): +- 軽量テストでは render_scene を差し替えて「どの variant / どの config で呼ばれたか」を見る + (variant はプロセス内で 1 度しか設定できず,実レンダでは切り替えを検証できないため) +- 実レンダを伴う end-to-end は @pytest.mark.slow に 1 本置く +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest +import yaml + +from sm_tias_lightsim.render.pipeline import RenderStepError, render_yaml +from sm_tias_lightsim.scene.schema import load_scene_yaml + +MINIMAL_YAML = Path(__file__).parent.parent / "fixtures" / "minimal.yaml" +YAML_VARIANT = "llvm_ad_rgb" # minimal.yaml の render.variant + + +@pytest.fixture +def render_calls(monkeypatch): + """render_scene を差し替え,渡された SceneConfig を記録する(実レンダはしない).""" + import sm_tias_lightsim.render.runner as runner + import sm_tias_lightsim.runtime.llvm_init as llvm_init + + calls: list[dict] = [] + + def fake_render_scene(config, project_root=None): + calls.append({"config": config, "project_root": project_root}) + return np.full((4, 4, 3), 0.5, dtype=np.float32) + + monkeypatch.setattr(runner, "render_scene", fake_render_scene) + monkeypatch.setattr(llvm_init, "ensure_drjit_libllvm_path", lambda *a, **k: None) + monkeypatch.delenv("SM_RENDER_VARIANT", raising=False) + return calls + + +# --------------------------------------------------------------------------- +# 出力ファイル +# --------------------------------------------------------------------------- + + +def test_returns_png_and_npy_paths(tmp_path, render_calls): + """戻り値が (render.png, luminance.npy) のパスで,実ファイルが存在すること.""" + out_dir = tmp_path / "out" + png_path, npy_path = render_yaml(MINIMAL_YAML, out_dir) + assert png_path == out_dir / "render.png" + assert npy_path == out_dir / "luminance.npy" + assert png_path.is_file() + assert npy_path.is_file() + + +def test_luminance_npy_is_loadable_2d_or_3d(tmp_path, render_calls): + """luminance.npy が numpy で読める配列であること(評価側の入力になる).""" + _, npy_path = render_yaml(MINIMAL_YAML, tmp_path / "out") + data = np.load(npy_path) + assert data.ndim in (2, 3) + assert data.shape[0] == 4 + assert data.shape[1] == 4 + + +def test_creates_deep_nested_output_dir(tmp_path, render_calls): + """出力ディレクトリが親ごと作られること.""" + out_dir = tmp_path / "a" / "b" / "c" + png_path, _ = render_yaml(MINIMAL_YAML, out_dir) + assert png_path.is_file() + + +def test_project_root_is_yaml_parent(tmp_path, render_calls): + """STL 相対パスの基点が YAML のあるディレクトリになること.""" + render_yaml(MINIMAL_YAML, tmp_path / "out") + assert render_calls[0]["project_root"] == MINIMAL_YAML.resolve().parent + + +def test_accepts_relative_paths(tmp_path, render_calls, monkeypatch): + """相対パスで渡しても解決されて出力できること.""" + monkeypatch.chdir(MINIMAL_YAML.parent) + png_path, npy_path = render_yaml(Path("minimal.yaml"), tmp_path / "out") + assert png_path.is_file() + assert npy_path.is_file() + + +# --------------------------------------------------------------------------- +# variant の解決(優先順位) +# --------------------------------------------------------------------------- + + +def test_yaml_variant_used_without_override(tmp_path, render_calls, capsys): + """引数・環境変数が無ければ YAML の variant を使い,上書き表示も出さないこと.""" + render_yaml(MINIMAL_YAML, tmp_path / "out") + assert render_calls[0]["config"].render.variant == YAML_VARIANT + assert "variant を上書き" not in capsys.readouterr().out + + +def test_env_overrides_yaml(tmp_path, render_calls, capsys, monkeypatch): + """環境変数 SM_RENDER_VARIANT が YAML より優先されること.""" + monkeypatch.setenv("SM_RENDER_VARIANT", "scalar_rgb") + render_yaml(MINIMAL_YAML, tmp_path / "out") + assert render_calls[0]["config"].render.variant == "scalar_rgb" + assert f"variant を上書き: {YAML_VARIANT} → scalar_rgb" in capsys.readouterr().out + + +def test_argument_overrides_yaml(tmp_path, render_calls, capsys): + """variant 引数が YAML より優先されること.""" + render_yaml(MINIMAL_YAML, tmp_path / "out", "cuda_ad_rgb") + assert render_calls[0]["config"].render.variant == "cuda_ad_rgb" + assert f"variant を上書き: {YAML_VARIANT} → cuda_ad_rgb" in capsys.readouterr().out + + +def test_argument_overrides_env(tmp_path, render_calls, capsys, monkeypatch): + """variant 引数が環境変数より優先されること.""" + monkeypatch.setenv("SM_RENDER_VARIANT", "scalar_rgb") + render_yaml(MINIMAL_YAML, tmp_path / "out", "cuda_ad_rgb") + assert render_calls[0]["config"].render.variant == "cuda_ad_rgb" + + +def test_same_variant_is_not_reported_as_override(tmp_path, render_calls, capsys): + """YAML と同じ variant を指定しても「上書き」とは表示しないこと.""" + render_yaml(MINIMAL_YAML, tmp_path / "out", YAML_VARIANT) + assert "variant を上書き" not in capsys.readouterr().out + + +def test_override_changes_only_variant(tmp_path, render_calls): + """上書きするのは variant だけで,他の設定・シーン内容は YAML のままであること.""" + render_yaml(MINIMAL_YAML, tmp_path / "out", "cuda_ad_rgb") + original = load_scene_yaml(MINIMAL_YAML) + config = render_calls[0]["config"] + assert config.render.model_dump(exclude={"variant"}) == original.render.model_dump( + exclude={"variant"} + ) + assert config.model_dump(exclude={"render"}) == original.model_dump(exclude={"render"}) + + +def test_yaml_file_is_not_modified(tmp_path, render_calls, monkeypatch): + """variant を上書きしても YAML ファイル自体は書き換えないこと.""" + before = MINIMAL_YAML.read_bytes() + monkeypatch.setenv("SM_RENDER_VARIANT", "scalar_rgb") + render_yaml(MINIMAL_YAML, tmp_path / "out", "cuda_ad_rgb") + assert MINIMAL_YAML.read_bytes() == before + + +def test_seed_from_yaml_reaches_render_scene(tmp_path, render_calls): + """YAML の render.seed がそのままレンダに渡ること(再レンダのノイズ実現の切り替え).""" + data = yaml.safe_load(MINIMAL_YAML.read_text(encoding="utf-8")) + data["render"]["seed"] = 4 + # render_scene は差し替え済みなので STL の実在は問われない(YAML の中身だけを見る) + scene_yaml = tmp_path / "scene_with_seed.yaml" + scene_yaml.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8") + render_yaml(scene_yaml, tmp_path / "out") + assert render_calls[0]["config"].render.seed == 4 + + +def test_seed_survives_variant_override(tmp_path, render_calls): + """variant を上書きしても seed が既定 0 のまま保たれること.""" + render_yaml(MINIMAL_YAML, tmp_path / "out", "cuda_ad_rgb") + assert render_calls[0]["config"].render.seed == 0 + + +# --------------------------------------------------------------------------- +# 異常系: 段階別の RenderStepError +# --------------------------------------------------------------------------- + + +def test_missing_yaml_raises_step_error(tmp_path, render_calls): + """シーン YAML が無いとき RenderStepError になること.""" + with pytest.raises(RenderStepError) as exc: + render_yaml(tmp_path / "nope.yaml", tmp_path / "out") + assert "見つかりません" in str(exc.value) + + +def test_invalid_yaml_raises_load_step_error(tmp_path, render_calls): + """必須フィールド欠落の YAML は「シーン YAML の読み込みに失敗しました」になること.""" + data = yaml.safe_load(MINIMAL_YAML.read_text(encoding="utf-8")) + del data["name"] # 必須フィールドを落とす(SPEC_02 トップレベル構造) + broken = tmp_path / "broken.yaml" + broken.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8") + with pytest.raises(RenderStepError) as exc: + render_yaml(broken, tmp_path / "out") + assert "シーン YAML の読み込みに失敗しました" in str(exc.value) + + +def test_render_failure_raises_render_step_error(tmp_path, monkeypatch): + """レンダ中の例外は「レンダリングに失敗しました」段階の例外になること.""" + import sm_tias_lightsim.render.runner as runner + import sm_tias_lightsim.runtime.llvm_init as llvm_init + + monkeypatch.delenv("SM_RENDER_VARIANT", raising=False) + monkeypatch.setattr(llvm_init, "ensure_drjit_libllvm_path", lambda *a, **k: None) + + def boom(config, project_root=None): + raise RuntimeError("テスト用レンダ失敗") + + monkeypatch.setattr(runner, "render_scene", boom) + with pytest.raises(RenderStepError) as exc: + render_yaml(MINIMAL_YAML, tmp_path / "out") + assert "レンダリングに失敗しました" in str(exc.value) + assert "テスト用レンダ失敗" in str(exc.value) + + +def test_step_error_is_runtime_error_subclass(): + """RenderStepError は RuntimeError 派生であること.""" + assert issubclass(RenderStepError, RuntimeError) + + +def test_no_output_written_when_load_fails(tmp_path, render_calls): + """YAML 読み込みで失敗したときは出力ファイルを作らないこと.""" + out_dir = tmp_path / "out" + with pytest.raises(RenderStepError): + render_yaml(tmp_path / "nope.yaml", out_dir) + assert not (out_dir / "render.png").exists() + + +# --------------------------------------------------------------------------- +# end-to-end(実レンダ) +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_end_to_end_render_writes_files(tmp_path, monkeypatch, capsys): + """実レンダで render.png と luminance.npy が出力されること(scalar_rgb).""" + monkeypatch.delenv("SM_RENDER_VARIANT", raising=False) + monkeypatch.delenv("SM_DRJIT_THREADS", raising=False) + png_path, npy_path = render_yaml(MINIMAL_YAML, tmp_path / "out", "scalar_rgb") + assert png_path.is_file() + assert npy_path.is_file() + luminance = np.load(npy_path) + assert luminance.shape[:2] == (64, 64) + assert float(np.nanmean(luminance)) > 0.0 diff --git a/tests/render/test_runner_ensure_variant.py b/tests/render/test_runner_ensure_variant.py new file mode 100644 index 0000000..d5d5cf4 --- /dev/null +++ b/tests/render/test_runner_ensure_variant.py @@ -0,0 +1,260 @@ +"""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 が違えば画素は一致しないこと" diff --git a/tests/scene/test_schema_render_seed.py b/tests/scene/test_schema_render_seed.py new file mode 100644 index 0000000..e8b03b8 --- /dev/null +++ b/tests/scene/test_schema_render_seed.py @@ -0,0 +1,97 @@ +"""render.seed(レンダの乱数シード)の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_02_シーン定義仕様.md「render」セクション(レンダ設定の YAML スキーマ. + seed は本実装で追加された新フィールドで,仕様書側は Phase 4 の更新対象) +- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」 + (同じ YAML・同じ seed でも読み込み直すと画素は変わるが,指標はほぼ一致する. + ノイズの実現を意図的に変えたいときに seed を振る) + +検証する仕様: +- seed は省略可能(既定 0)=既存 YAML が書き換え無しで読める(後方互換) +- seed は 0 以上の整数のみ(負値は ValidationError) +- YAML に書いた値がそのまま SceneConfig に載る +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from pydantic import ValidationError + +from sm_tias_lightsim.scene.schema import RenderConfig, SceneConfig, load_scene_yaml + +MINIMAL_YAML = Path(__file__).parent.parent / "fixtures" / "minimal.yaml" + + +# --------------------------------------------------------------------------- +# 既定値・後方互換 +# --------------------------------------------------------------------------- + + +def test_seed_defaults_to_zero(): + """seed を省略すると 0 になること(既定値).""" + assert RenderConfig().seed == 0 + + +def test_existing_yaml_without_seed_is_still_valid(): + """seed を書いていない既存 YAML がそのまま読め,seed=0 になること(後方互換).""" + config = load_scene_yaml(MINIMAL_YAML) + assert config.render.seed == 0 + + +def test_seed_omission_does_not_change_other_render_fields(): + """seed の追加で他の render 設定の既定値が変わっていないこと.""" + render = RenderConfig() + assert render.variant == "llvm_ad_rgb" + assert render.integrator == "path" + assert render.max_depth == 8 + assert render.spp == 256 + + +# --------------------------------------------------------------------------- +# 正常系: 値の指定 +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("seed", [0, 1, 2, 42, 12345]) +def test_seed_accepts_non_negative_integers(seed): + """0 以上の整数はそのまま保持されること.""" + assert RenderConfig(seed=seed).seed == seed + + +def test_seed_from_yaml_is_loaded(tmp_path: Path): + """YAML の render.seed が SceneConfig に反映されること.""" + data = yaml.safe_load(MINIMAL_YAML.read_text(encoding="utf-8")) + data["render"]["seed"] = 7 + # メッシュの相対パスを保つため,YAML はフィクスチャと同じディレクトリ基準で解決する + yaml_path = tmp_path / "scene_with_seed.yaml" + yaml_path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8") + config = SceneConfig.model_validate(yaml.safe_load(yaml_path.read_text(encoding="utf-8"))) + assert config.render.seed == 7 + + +# --------------------------------------------------------------------------- +# 異常系 +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("seed", [-1, -42]) +def test_negative_seed_raises_validation_error(seed): + """負の seed は ValidationError になること(ge=0).""" + with pytest.raises(ValidationError): + RenderConfig(seed=seed) + + +def test_non_integer_seed_raises_validation_error(): + """整数に解釈できない seed は ValidationError になること.""" + with pytest.raises(ValidationError): + RenderConfig(seed="abc") + + +def test_float_seed_with_fraction_raises_validation_error(): + """小数の seed は ValidationError になること(整数フィールド).""" + with pytest.raises(ValidationError): + RenderConfig(seed=1.5) diff --git a/tests/scripts/test_sweep_led_placement.py b/tests/scripts/test_sweep_led_placement.py new file mode 100644 index 0000000..c952595 --- /dev/null +++ b/tests/scripts/test_sweep_led_placement.py @@ -0,0 +1,414 @@ +"""scripts/sweep_led_placement.py(照明配置スイープ)の仕様ベーステスト. + +仕様出典: +- docs/06_TEST/TEST_17_オフ軸LED配置の影響とサイズ依存.md(オフ軸配置の定量.本スイープの前段) +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(シーン YAML スキーマ・render.seed) +- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」 + (_tmp YAML のメッシュパスは YAML からの相対パスでなければサーバで読めない. + 同じ seed で読み直すと指標がほぼ一致するので,再現性チェックには seed を振る) +- スクリプト冒頭の条件設計(位置 θ グリッド / 向き / 個数 / 再現性の 4 グループ) + +検証する仕様: +- conditions() が設計どおりの条件集合(198 条件・名前が一意・重要な順)を返す +- --list で条件名の一覧だけを表示して終了する(レンダしない) +- prepare_condition() が条件ごとのシーン YAML を作り,再レンダ条件(rep>1)は + 幾何を共有したまま render.seed = rep-1 の別 YAML になる +- YAML に書くメッシュパスは YAML からの相対パス(POSIX 区切り・ドライブ無し) + +テスト方針(testing.md): +- sm-render / sm-evaluate は呼ばない(レンダはしない).幾何生成と YAML だけを見る +- TMP_DIR / MESH_DIR を tmp_path に差し替える + (tests/scripts/test_sweep_relative_mesh_paths.py と同じ流儀) +- 筐体メッシュ生成は球で十数秒かかるので,生成を伴うテストは立方体条件のみを使い, + モジュールスコープのフィクスチャで 1 度だけ生成する +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path, PurePosixPath, PureWindowsPath +from types import ModuleType +from unittest import mock + +import pytest +import yaml + +_ROOT = Path(__file__).resolve().parents[2] +_SCRIPTS = _ROOT / "scripts" + +# 再現性グループで使う立方体条件(スクリプトの SIZE_PAIRS[REPEAT_PAIR_INDEX] と対応) +_CUBE_SIZE = 234.0 + + +def _load(name: str) -> ModuleType: + """scripts/.py を import する(既に読み込み済みならそれを使う).""" + path = (_SCRIPTS / f"{name}.py").resolve() + cached = sys.modules.get(name) + if cached is not None and Path(getattr(cached, "__file__", "")).resolve() == path: + return cached + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def sweep() -> ModuleType: + return _load("sweep_led_placement") + + +@pytest.fixture(autouse=True) +def _same_drive(tmp_path): + if tmp_path.resolve().drive.upper() != _ROOT.drive.upper(): + pytest.skip("tmp_path とリポジトリが別ドライブのため相対パスで表せない環境") + + +# --------------------------------------------------------------------------- +# 条件集合 +# --------------------------------------------------------------------------- + + +def test_conditions_total_count(sweep): + """全条件数が 198 であること. + + 仕様: 位置 2 形状 × 4 サイズ × 13θ = 104,個数 2 × 4 × (3 + 2) = 40, + 向き 2 × 4 × 3θ × 3 向き = 72 のうち θ=60/90/120 の板向き 1 灯 24 条件は + 位置グループと重複するので 48,再現性 2 形状 × 3 回 = 6.合計 198. + """ + assert len(sweep.conditions()) == 198 + + +def test_condition_names_are_unique(sweep): + """条件名が一意であること(出力ディレクトリ名として使われるため).""" + names = [c.name for c in sweep.conditions()] + assert len(names) == len(set(names)) + + +def test_conditions_cover_all_groups(sweep): + """4 つのグループがすべて含まれること.""" + groups = {c.group for c in sweep.conditions()} + assert groups == set(sweep.GROUPS) + + +def test_group_counts_match_design(sweep): + """グループごとの条件数が設計どおりであること.""" + from collections import Counter + + counts = Counter(c.group for c in sweep.conditions()) + assert counts["position"] == 104 + assert counts["count"] == 40 + assert counts["direction"] == 48 + assert counts["repeat"] == 6 + + +def test_shapes_are_balanced(sweep): + """球と立方体が同数であること(同内面積ペアでの比較が目的).""" + conds = sweep.conditions() + assert sum(c.shape == "sphere" for c in conds) == sum(c.shape == "cube" for c in conds) + + +def test_position_group_comes_first(sweep): + """主実験(位置)が先頭に並ぶこと(途中で止めても主実験が揃うようにする).""" + groups = [c.group for c in sweep.conditions()] + assert groups[0] == "position" + assert set(groups[:104]) == {"position"} + + +def test_repeat_conditions_share_base_name_and_differ_by_rep(sweep): + """再現性条件は同じ base_name で rep だけが違うこと(幾何は同一).""" + repeats = [c for c in sweep.conditions() if c.group == "repeat"] + assert repeats, "再現性グループの条件が存在すること" + for cond in repeats: + assert cond.rep > 1 + assert cond.name == f"{cond.base_name}_rep{cond.rep}" + + +def test_position_conditions_use_rep_one(sweep): + """再現性以外の条件は rep=1(名前に rep が付かない)であること.""" + for cond in sweep.conditions(): + if cond.group != "repeat": + assert cond.rep == 1 + assert cond.name == cond.base_name + + +def test_azimuths_are_evenly_spaced(sweep): + """多灯条件の方位角が等間隔で,1 灯目が +X 側であること.""" + cond = sweep.Condition("count", "cube", _CUBE_SIZE, 90.0, "board", 4) + assert cond.azimuths_deg == [0.0, 90.0, 180.0, 270.0] + + +# --------------------------------------------------------------------------- +# --list(レンダせず一覧のみ) +# --------------------------------------------------------------------------- + + +def test_list_prints_all_condition_names(sweep, monkeypatch, capsys): + """--list が全条件名と件数を表示して 0 を返すこと(レンダしない).""" + monkeypatch.setattr(sys, "argv", ["sweep_led_placement.py", "--list"]) + assert sweep.main() == 0 + out = capsys.readouterr().out + assert "198 条件" in out + assert out.count("\n") == 199 # 条件名 198 行 + 件数 1 行 + + +def test_list_respects_shape_and_group_filters(sweep, monkeypatch, capsys): + """--shapes / --groups で条件を絞れること.""" + monkeypatch.setattr( + sys, + "argv", + ["sweep_led_placement.py", "--list", "--shapes", "cube", "--groups", "position"], + ) + assert sweep.main() == 0 + lines = [ln for ln in capsys.readouterr().out.splitlines() if ln] + names = lines[:-1] + assert lines[-1] == "52 条件" # 4 サイズ × 13θ + assert all(name.startswith("cube_") for name in names) + + +# --------------------------------------------------------------------------- +# prepare_condition(幾何生成 + シーン YAML) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def prepared(sweep, tmp_path_factory) -> dict[str, tuple[Path, dict]]: + """立方体条件の rep1 / rep2 / rep3 を 1 度だけ生成する. + + 筐体メッシュは同じファイルを共有するので,2 件目以降の生成は速い. + """ + base = tmp_path_factory.mktemp("led_placement") + if base.resolve().drive.upper() != _ROOT.drive.upper(): + pytest.skip("tmp_path とリポジトリが別ドライブのため相対パスで表せない環境") + conds = [ + sweep.Condition("repeat", "cube", _CUBE_SIZE, 90.0, "board", 1, rep) + for rep in (1, 2, 3) + ] + with ( + mock.patch.object(sweep, "TMP_DIR", base), + mock.patch.object(sweep, "MESH_DIR", base / "meshes"), + ): + return {cond.name: sweep.prepare_condition(cond) for cond in conds} + + +def _load_yaml(path: Path) -> dict: + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def test_prepare_writes_scene_yaml_named_after_condition(sweep, prepared): + """条件ごとに scene_.yaml が作られること.""" + for name, (scene_yaml, _) in prepared.items(): + assert scene_yaml.name == f"scene_{name}.yaml" + assert scene_yaml.is_file() + + +def test_repeat_conditions_get_separate_yaml_files(sweep, prepared): + """再レンダ条件(rep>1)は元の条件とは別の YAML になること.""" + paths = {str(scene_yaml) for scene_yaml, _ in prepared.values()} + assert len(paths) == len(prepared) + + +def test_seed_equals_rep_minus_one(sweep, prepared): + """render.seed が rep-1 になること. + + 仕様: seed が同じだとシーンを読み直しても指標が 4 桁まで一致してノイズフロアにならない + (ENV_04「研究室サーバでの実行」の実測).再レンダは seed を振って別実現にする. + """ + for name, (scene_yaml, _) in prepared.items(): + rep = int(name.split("_rep")[1]) if "_rep" in name else 1 + assert _load_yaml(scene_yaml)["render"]["seed"] == rep - 1, name + + +def test_first_rep_uses_default_seed_zero(sweep, prepared): + """rep=1(通常条件)の seed が 0 であること(既定値と同じ).""" + scene_yaml, _ = prepared["cube_L234_th090_board_n1"] + assert _load_yaml(scene_yaml)["render"]["seed"] == 0 + + +def test_repeat_yaml_differs_only_in_seed_and_name(sweep, prepared): + """再レンダ条件の YAML は seed・name・description 以外が元の条件と同一であること. + + 仕様: 幾何(筐体・LED・バッフル)は共有し,変えるのは乱数シードだけ. + """ + base = _load_yaml(prepared["cube_L234_th090_board_n1"][0]) + rep2 = _load_yaml(prepared["cube_L234_th090_board_n1_rep2"][0]) + for key in ("name", "description"): + base.pop(key, None) + rep2.pop(key, None) + assert base["render"].pop("seed") == 0 + assert rep2["render"].pop("seed") == 1 + assert base == rep2 + + +def test_scene_yaml_is_valid_scene_config(sweep, prepared): + """生成した YAML が SceneConfig として検証を通ること(SPEC_02 スキーマ).""" + from sm_tias_lightsim.scene.schema import SceneConfig + + scene_yaml, _ = prepared["cube_L234_th090_board_n1"] + config = SceneConfig.model_validate(_load_yaml(scene_yaml)) + assert config.render.seed == 0 + + +def test_mesh_paths_are_relative_to_yaml(sweep, prepared): + """メッシュパスが YAML からの相対パス(POSIX 区切り・ドライブ無し)であること. + + 仕様: Windows 絶対パスの YAML は研究室サーバで読めない(ENV_04 注意). + """ + from sm_tias_lightsim.remote.files import PushEntry, find_yaml_with_windows_paths + + scene_yaml, _ = prepared["cube_L234_th090_board_n1"] + meshes = _load_yaml(scene_yaml)["geometry"]["meshes"] + assert meshes, "メッシュが 1 つ以上書かれていること" + for mesh in meshes: + path = mesh["path"] + assert "\\" not in path, f"{mesh['id']}: POSIX 区切りでない: {path}" + assert not PurePosixPath(path).is_absolute(), f"{mesh['id']}: 絶対パス: {path}" + assert not PureWindowsPath(path).drive, f"{mesh['id']}: ドライブ付き: {path}" + assert (scene_yaml.parent / path).resolve().is_file(), f"{mesh['id']}: 実体が無い: {path}" + + entry = PushEntry( + path=scene_yaml, arcname=f"configs/_tmp_led_placement/{scene_yaml.name}", size=0 + ) + assert find_yaml_with_windows_paths([entry]) == [] + + +def test_housing_mesh_is_shared_between_repeats(sweep, prepared): + """再レンダ条件が同じ筐体メッシュを参照すること(幾何を作り直さない).""" + base_yaml, _ = prepared["cube_L234_th090_board_n1"] + rep_yaml, _ = prepared["cube_L234_th090_board_n1_rep2"] + + def housing(path: Path) -> Path: + meshes = _load_yaml(path)["geometry"]["meshes"] + entry = next(m for m in meshes if m["id"] == "cube_housing") + return (path.parent / entry["path"]).resolve() + + assert housing(base_yaml) == housing(rep_yaml) + + +def test_baffled_condition_records_baffle(sweep, prepared): + """板の前方に発光面がある条件はバッフルを生成し,記録に残すこと.""" + _, record = prepared["cube_L234_th090_board_n1"] + assert record["baffled"] == 1 + assert record["baffle_radius_mm"] > 0.0 + + +# --------------------------------------------------------------------------- +# variant の解決 +# --------------------------------------------------------------------------- + + +def test_render_variant_prefers_env(sweep, monkeypatch): + """SM_RENDER_VARIANT がテンプレート YAML の variant より優先されること.""" + monkeypatch.setattr(sweep, "_render_variant", None) + monkeypatch.setenv("SM_RENDER_VARIANT", "cuda_ad_rgb") + assert sweep.render_variant() == "cuda_ad_rgb" + + +def test_render_variant_falls_back_to_template(sweep, monkeypatch): + """環境変数が無ければテンプレート YAML の render.variant を使うこと.""" + monkeypatch.setattr(sweep, "_render_variant", None) + monkeypatch.delenv("SM_RENDER_VARIANT", raising=False) + template = yaml.safe_load(sweep.TEMPLATE_YAML.read_text(encoding="utf-8")) + assert sweep.render_variant() == template["render"]["variant"] + + +def test_render_variant_is_cached(sweep, monkeypatch): + """variant は 1 度だけ解決して使い回すこと(条件ごとの YAML 読み直しを避ける).""" + monkeypatch.setattr(sweep, "_render_variant", None) + monkeypatch.setenv("SM_RENDER_VARIANT", "cuda_ad_rgb") + first = sweep.render_variant() + monkeypatch.setenv("SM_RENDER_VARIANT", "scalar_rgb") + assert sweep.render_variant() == first + + +# --------------------------------------------------------------------------- +# run_job(レンダ + 評価をプロセス内で実行する) +# --------------------------------------------------------------------------- + + +def _fake_metrics_csv(path: Path) -> None: + """read_metrics_csv が読む列を持つ metrics.csv を書く.""" + columns = [ + "cov", "center_periphery_ratio", "gradient_magnitude", "radial_min_max_ratio", + "angular_ratio", "pca_eccentricity", "intensity_centroid_offset_norm", + "max_min_ratio", "intensity_centroid_x_norm", "intensity_centroid_y_norm", + ] + header = ",".join(columns) + values = ",".join("0.5" for _ in columns) + path.write_text(f"{header}\n{values}\n", encoding="utf-8") + + +def test_run_job_reuses_existing_row_json(sweep, tmp_path, monkeypatch): + """既に row.json がある条件は再レンダせず,その内容をそのまま返すこと. + + 仕様: スイープは途中で止めて再開できる(既存結果の再利用). + """ + def _must_not_render(*args, **kwargs): + raise AssertionError("既存結果があるときはレンダしないこと") + + monkeypatch.setattr(sweep, "OUTPUT_BASE", tmp_path / "output") + monkeypatch.setattr(sweep, "render_yaml", _must_not_render) + monkeypatch.setattr(sweep, "ensure_render_variant", _must_not_render) + + cond = sweep.Condition("position", "cube", _CUBE_SIZE, 90.0, "board", 1) + out_dir = tmp_path / "output" / cond.name + out_dir.mkdir(parents=True) + (out_dir / "row.json").write_text( + json.dumps({"name": cond.name, "cov": 0.01}), encoding="utf-8" + ) + + row, _ = sweep.run_job(cond, tmp_path / "scene.yaml", {"baffled": 0}) + assert row == {"name": cond.name, "cov": 0.01} + + +def test_run_job_renders_and_evaluates_without_figures(sweep, tmp_path, monkeypatch): + """レンダ → 評価をプロセス内で呼び,評価は figures 無しで行うこと. + + 仕様: スイープでは図が不要なので save_figures=False で描画時間を省く. + """ + import numpy as np + + calls: dict = {} + + def fake_render_yaml(scene_yaml, out_dir, variant): + calls["render"] = {"scene": scene_yaml, "out_dir": out_dir, "variant": variant} + np.save(Path(out_dir) / "luminance.npy", np.full((512, 512), 100.0)) + return Path(out_dir) / "render.png", Path(out_dir) / "luminance.npy" + + def fake_evaluate_image(image, config, out_dir, save_figures=True): + calls["evaluate"] = {"image": image, "config": config, "save_figures": save_figures} + _fake_metrics_csv(Path(out_dir) / "metrics.csv") + return {} + + monkeypatch.setattr(sweep, "OUTPUT_BASE", tmp_path / "output") + monkeypatch.setattr(sweep, "render_yaml", fake_render_yaml) + monkeypatch.setattr(sweep, "evaluate_image", fake_evaluate_image) + monkeypatch.setattr(sweep, "ensure_render_variant", lambda: "scalar_rgb") + + cond = sweep.Condition("position", "cube", _CUBE_SIZE, 90.0, "board", 1) + row, _ = sweep.run_job(cond, tmp_path / "scene.yaml", {"baffled": 0}) + + assert calls["evaluate"]["save_figures"] is False + assert calls["render"]["variant"] == "scalar_rgb" + assert row["render_variant"] == "scalar_rgb" + assert row["name"] == cond.name + assert (tmp_path / "output" / cond.name / "row.json").is_file() + + +def test_run_job_returns_none_on_failure(sweep, tmp_path, monkeypatch): + """1 条件が失敗しても例外を投げず None を返すこと(スイープ全体を止めない).""" + def boom(*args, **kwargs): + raise RuntimeError("テスト用レンダ失敗") + + monkeypatch.setattr(sweep, "OUTPUT_BASE", tmp_path / "output") + monkeypatch.setattr(sweep, "render_yaml", boom) + monkeypatch.setattr(sweep, "ensure_render_variant", lambda: "scalar_rgb") + + cond = sweep.Condition("position", "cube", _CUBE_SIZE, 90.0, "board", 1) + row, messages = sweep.run_job(cond, tmp_path / "scene.yaml", {"baffled": 0}) + assert row is None + assert any("失敗" in m for m in messages) + assert not (tmp_path / "output" / cond.name / "row.json").exists()