diff --git a/.gitignore b/.gitignore index 3a02be1..a49d10d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ MiniTIAS/ MiniTias-Evaluation/ +# シミュ・評価結果(コミット対象外) +output/ + # Python / uv .venv/ __pycache__/ diff --git a/CLAUDE.md b/CLAUDE.md index d13a823..5e36ff8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,8 +5,8 @@ ## 開発進捗 -現在の進捗: フェーズ 7(実装)— Step 1〜3 完了. -次のアクション: `/implement Step 4: シーン構築 + 最小レンダー`([PLAN_02 参照](docs/03_PLAN/PLAN_02_開発ステップ.md)) +現在の進捗: フェーズ 7(実装)— Step 1〜5 完了. +次のアクション: `/implement Step 6: evaluate CLI + 可視化`([PLAN_02 参照](docs/03_PLAN/PLAN_02_開発ステップ.md)) 実装済みモジュール: - `src/sm_tias_lightsim/uniformity.py` — Rec.709 輝度変換 + CoV/std/max-min(MiniTias-Evaluation コピー) @@ -15,6 +15,11 @@ - `src/sm_tias_lightsim/scene/schema.py` — Pydantic ベース YAML スキーマ + ローダ - `src/sm_tias_lightsim/geometry/loader.py` — STL 読込(trimesh)+ `LoadedMesh` データクラス - `src/sm_tias_lightsim/geometry/transforms.py` — mm⇄m 単位スケール変換 +- `src/sm_tias_lightsim/scene/{lights,materials,sensors}.py` — Mitsuba emitter / BSDF / sensor dict 変換 +- `src/sm_tias_lightsim/scene/builder.py` — SceneConfig → Mitsuba scene dict(PLY 一時ファイル経由) +- `src/sm_tias_lightsim/render/runner.py` — variant 管理 + `mi.render` 実行 +- `src/sm_tias_lightsim/render/output.py` — PNG / luminance.npy 出力 +- `src/sm_tias_lightsim/cli/render.py` — `sm-render` CLI(YAML → PNG / luminance.npy) ※ ステップ完了時にここを更新すること. ## 必須ルール(コード実装時) diff --git a/pyproject.toml b/pyproject.toml index 8dd8137..0215d06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ [project.scripts] sm-tias-lightsim = "sm_tias_lightsim:main" +sm-render = "sm_tias_lightsim.cli.render:main" [build-system] requires = ["uv_build>=0.11.7,<0.12.0"] diff --git a/src/sm_tias_lightsim/cli/__init__.py b/src/sm_tias_lightsim/cli/__init__.py new file mode 100644 index 0000000..65dc744 --- /dev/null +++ b/src/sm_tias_lightsim/cli/__init__.py @@ -0,0 +1 @@ +"""cli パッケージ — コマンドラインエントリポイント群.""" diff --git a/src/sm_tias_lightsim/cli/render.py b/src/sm_tias_lightsim/cli/render.py new file mode 100644 index 0000000..91ad61e --- /dev/null +++ b/src/sm_tias_lightsim/cli/render.py @@ -0,0 +1,123 @@ +# pyright: reportCallIssue=none +# Mitsuba 3 は variant ベースの動的属性解決を行うため Pylance/Pyright が +# mi.* を object 型と推論し reportCallIssue を誤検知する(GUIDE_06 の方針). +# 下記の Mitsuba 依存 import(load_scene_yaml / render_scene / save_png / save_luminance)を +# main() 内で遅延 import しているのは,--help だけ使う場面(Mitsuba 不在の軽量環境)でも +# 起動できるようにするためである. +"""sm-render コマンドラインエントリポイント. + +YAML シーン定義を読み込み,Mitsuba 3 でレンダリングを実行して +PNG と luminance.npy を出力ディレクトリに保存する. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +# Step 6 の evaluate CLI も同じファイルを読むため,名前を定数として一元管理する +_PNG_FILENAME = "render.png" +_NPY_FILENAME = "luminance.npy" + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """コマンドライン引数をパースして返す. + + Args: + argv: 引数リスト.None のとき sys.argv[1:] を使用. + + Returns: + パース済み Namespace. + """ + parser = argparse.ArgumentParser( + prog="sm-render", + description=( + "YAML シーン定義から Mitsuba 3 でレンダリングし PNG と luminance.npy を出力する." + ), + ) + parser.add_argument( + "--scene", + "-s", + required=True, + type=Path, + metavar="YAML_PATH", + help="シーン定義 YAML ファイルのパス.", + ) + parser.add_argument( + "--output", + "-o", + required=True, + type=Path, + metavar="OUTPUT_DIR", + help="出力先ディレクトリ(存在しない場合は親ごと作成する).", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """sm-render コマンドのメインエントリポイント. + + 処理フロー: + 1. コマンドライン引数をパースする. + 2. YAML シーン定義を load_scene_yaml で読み込み,SceneConfig を得る. + 3. 出力ディレクトリを作成する(親ごと,既存なら何もしない). + 4. render_scene で Mitsuba レンダリングを実行し,HDR 配列を得る. + 5. save_png で render.png を保存する. + 6. save_luminance で luminance.npy を保存する. + + Args: + argv: 引数リスト.None のとき sys.argv[1:] を使用. + + Returns: + 終了コード(成功時 0,失敗時 1). + """ + # argparse 自体のエラー(必須引数欠落等)は ArgumentParser が直接 sys.exit するため + # ここでは ValueError / FileNotFoundError 等の実行時エラーのみ捕捉する + args = _parse_args(argv) + + scene_path: Path = args.scene.resolve() + output_dir: Path = args.output.resolve() + + try: + from sm_tias_lightsim.scene.schema import load_scene_yaml + + config = load_scene_yaml(scene_path) + except FileNotFoundError as exc: + print(f"エラー: シーン YAML ファイルが見つかりません — {exc}", file=sys.stderr) + return 1 + except Exception as exc: # noqa: BLE001 + print(f"エラー: シーン YAML の読み込みに失敗しました — {exc}", file=sys.stderr) + return 1 + + try: + output_dir.mkdir(parents=True, exist_ok=True) + except OSError as exc: + print(f"エラー: 出力ディレクトリを作成できません — {exc}", file=sys.stderr) + return 1 + + # project_root を scene YAML の親ディレクトリとすることで, + # YAML 内の STL 相対パスが YAML ファイルを基点に解決される + project_root = scene_path.parent + try: + from sm_tias_lightsim.render.runner import render_scene + + hdr_image = render_scene(config, project_root=project_root) + except Exception as exc: # noqa: BLE001 + print(f"エラー: レンダリングに失敗しました — {exc}", file=sys.stderr) + return 1 + + png_path = output_dir / _PNG_FILENAME + npy_path = output_dir / _NPY_FILENAME + try: + from sm_tias_lightsim.render.output import save_luminance, save_png + + save_png(hdr_image, png_path) + save_luminance(hdr_image, npy_path) + except Exception as exc: # noqa: BLE001 + print(f"エラー: 出力ファイルの保存に失敗しました — {exc}", file=sys.stderr) + return 1 + + print(f"レンダリング完了: {png_path}") + print(f"輝度マップ保存: {npy_path}") + return 0 diff --git a/src/sm_tias_lightsim/render/__init__.py b/src/sm_tias_lightsim/render/__init__.py new file mode 100644 index 0000000..80b9aaa --- /dev/null +++ b/src/sm_tias_lightsim/render/__init__.py @@ -0,0 +1,6 @@ +"""render パッケージ — Mitsuba レンダリング実行と画像出力.""" + +from sm_tias_lightsim.render.output import save_luminance, save_png +from sm_tias_lightsim.render.runner import render_scene + +__all__ = ["render_scene", "save_luminance", "save_png"] diff --git a/src/sm_tias_lightsim/render/output.py b/src/sm_tias_lightsim/render/output.py new file mode 100644 index 0000000..6b57449 --- /dev/null +++ b/src/sm_tias_lightsim/render/output.py @@ -0,0 +1,49 @@ +"""HDR 画像出力モジュール. + +レンダリング結果の HDR float 配列を PNG または luminance.npy として保存する. +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy +from PIL import Image + +from sm_tias_lightsim.uniformity import REC709_COEFF_B, REC709_COEFF_G, REC709_COEFF_R + + +def save_luminance(image: numpy.ndarray, path: Path) -> None: + """HDR 画像を Rec.709 輝度に変換して .npy として保存する. + + uniformity.py の Rec.709 係数定数を再利用する(DRY). + HDR 値域(0〜∞)をそのまま保持するため clip は行わない. + + Args: + image: HDR 画像 (H, W, 3) float. + path: 出力 .npy ファイルパス. + """ + luminance = ( + REC709_COEFF_R * image[..., 0] + + REC709_COEFF_G * image[..., 1] + + REC709_COEFF_B * image[..., 2] + ) + numpy.save(path, luminance.astype(numpy.float64)) + + +def save_png(image: numpy.ndarray, path: Path, gain: float = 1.0) -> None: + """HDR 画像をトーンマップして 8-bit PNG として保存する. + + 線形スケール(gain 倍)して [0, 1] にクリップ後,uint8 [0, 255] に変換する. + 物理的に妥当なトーンマッピングではなく,目視確認・デバッグ用途の簡易実装. + + Args: + image: HDR 画像 (H, W, 3) float. + path: 出力 PNG パス. + gain: 露出補正係数.既定 1.0. + """ + # gain を掛けて [0, 1] にクリップし uint8 化 + tonemapped = numpy.clip(image * gain, 0.0, 1.0) + uint8_image = (tonemapped * 255.0).astype(numpy.uint8) + pil_image = Image.fromarray(uint8_image, mode="RGB") + pil_image.save(str(path)) diff --git a/src/sm_tias_lightsim/render/runner.py b/src/sm_tias_lightsim/render/runner.py new file mode 100644 index 0000000..bf7ebb3 --- /dev/null +++ b/src/sm_tias_lightsim/render/runner.py @@ -0,0 +1,66 @@ +# pyright: reportCallIssue=none +# Mitsuba 3 は variant ベースの動的属性解決を行うため Pylance/Pyright が +# mi.* を object 型と推論し reportCallIssue を誤検知する(GUIDE_06 の方針). +"""Mitsuba レンダリング実行モジュール. + +variant 設定と mi.render() の実行を担う. +`mitsuba.set_variant` はこのモジュールで 1 度だけ呼ぶ. +""" + +from __future__ import annotations + +import logging +import shutil +from pathlib import Path + +import mitsuba as mi +import numpy + +from sm_tias_lightsim.scene.builder import build_scene +from sm_tias_lightsim.scene.schema import SceneConfig + +_logger = logging.getLogger(__name__) + +# 現在設定されている variant を追跡する(プロセス内 1 度だけ変更可能) +_current_variant: str | None = None + + +def render_scene(config: SceneConfig, project_root: Path | None = None) -> numpy.ndarray: + """SceneConfig からシーンを構築してレンダリングし,HDR 画像(H, W, 3)を返す. + + Mitsuba の variant を config.render.variant に設定する(プロセス内初回のみ有効). + 既に異なる variant が設定されている場合は警告ログを出して続行する. + + Args: + config: 検証済み SceneConfig. + project_root: STL 相対パスの基点ディレクトリ.None なら CWD を使用. + + Returns: + HDR 画像 (H, W, 3) の float32 配列.値域は物理量(0〜∞ も許容). + """ + global _current_variant # noqa: PLW0603 + + desired_variant = config.render.variant + + if _current_variant is None: + mi.set_variant(desired_variant) + _current_variant = desired_variant + elif _current_variant != desired_variant: + _logger.warning( + "Mitsuba variant is already set to %r; " + "requested variant %r will be ignored. " + "Restart the process to change the variant.", + _current_variant, + desired_variant, + ) + # 同じ variant が既に設定されている場合は何もしない + + scene_dict, tempdir = build_scene(config, project_root=project_root) + try: + scene = mi.load_dict(scene_dict) + image_tensor = mi.render(scene, spp=config.render.spp) + arr = numpy.array(image_tensor) + finally: + shutil.rmtree(tempdir, ignore_errors=True) + + return arr diff --git a/src/sm_tias_lightsim/scene/__init__.py b/src/sm_tias_lightsim/scene/__init__.py index 06e1e16..d0e284c 100644 --- a/src/sm_tias_lightsim/scene/__init__.py +++ b/src/sm_tias_lightsim/scene/__init__.py @@ -1,8 +1,9 @@ """scene パッケージ — YAML スキーマ定義・シーン構築モジュール群.""" +from sm_tias_lightsim.scene.builder import build_scene from sm_tias_lightsim.scene.schema import ( SceneConfig, load_scene_yaml, ) -__all__ = ["SceneConfig", "load_scene_yaml"] +__all__ = ["SceneConfig", "build_scene", "load_scene_yaml"] diff --git a/src/sm_tias_lightsim/scene/builder.py b/src/sm_tias_lightsim/scene/builder.py new file mode 100644 index 0000000..ae6e0f2 --- /dev/null +++ b/src/sm_tias_lightsim/scene/builder.py @@ -0,0 +1,133 @@ +# pyright: reportCallIssue=none +# Mitsuba 3 は variant ベースの動的属性解決を行うため Pylance/Pyright が +# mi.* を object 型と推論し reportCallIssue を誤検知する(GUIDE_06 の方針). +"""SceneConfig 全体を Mitsuba load_dict 互換 dict に変換する統合モジュール.""" + +from __future__ import annotations + +import shutil +import tempfile +from pathlib import Path + +import trimesh + +from sm_tias_lightsim.geometry.loader import LoadedMesh, load_stl +from sm_tias_lightsim.geometry.transforms import scale_vertices +from sm_tias_lightsim.scene.lights import build_light_dict +from sm_tias_lightsim.scene.materials import build_material_dict +from sm_tias_lightsim.scene.schema import SceneConfig +from sm_tias_lightsim.scene.sensors import build_sensor_dict + +# mm→m の変換係数 +_MM_TO_M = 0.001 + + +def _position_scale(unit: str) -> float: + """単位文字列に対応する座標スケール係数を返す. + + YAML 内の座標値はすべて同一単位で記述されているため, + scene 全体での単位を sensor/light の位置変換に統一して使う. + 現状は mm と m のみサポート. + + Args: + unit: "mm" または "m". + + Returns: + 座標をメートルに変換するスケール係数. + """ + if unit == "mm": + return _MM_TO_M + if unit == "m": + return 1.0 + msg = f"Unsupported position unit: {unit!r}" + raise ValueError(msg) + + +def _save_loaded_mesh_to_ply(mesh: LoadedMesh, path: Path) -> None: + """LoadedMesh を PLY ファイルとして書き出す. + + trimesh 経由で書き出すことで Mitsuba の PLY パーサに渡せる形式にする. + + Args: + mesh: 書き出す LoadedMesh(頂点・面が既に目的単位に変換済みであること). + path: 書き出し先の PLY ファイルパス. + """ + tmesh = trimesh.Trimesh(vertices=mesh.vertices, faces=mesh.faces, process=False) + tmesh.export(str(path), file_type="ply") + + +def build_scene(config: SceneConfig, project_root: Path | None = None) -> tuple[dict, Path]: + """SceneConfig 全体を Mitsuba load_dict 互換の dict に変換する. + + STL メッシュは trimesh で読み込み,一時ディレクトリに PLY として書き出して + Mitsuba の "ply" shape type に渡す. + + 呼び出し側(runner.py)は返された tempdir を try/finally で shutil.rmtree すること. + Mitsuba の load_dict が PLY ファイルを読むまでファイルを保持する必要があるため, + builder 内では tempdir を削除しない. + + Args: + config: 検証済み SceneConfig. + project_root: STL 相対パスの基点ディレクトリ.None なら CWD を使用. + + Returns: + (scene_dict, tempdir) のタプル. + - scene_dict: mi.load_dict() に渡せる dict. + - tempdir: 一時 PLY ファイルを格納したディレクトリ(呼び出し側が削除する). + + Raises: + FileNotFoundError: STL ファイルが見つからない場合. + ValueError: 未対応の設定値が含まれる場合. + """ + root = project_root if project_root is not None else Path.cwd() + + # YAML 内の座標値の単位は sensor.position の単位と一致しているため, + # sensor の位置座標を基準に scale を決める + # SPEC_02: lights の position も mm 単位で記述する前提 + # ここでは固定で mm→m スケールを使う(YAML 上の座標は常に mm 基準) + pos_scale = _MM_TO_M + + tempdir = Path(tempfile.mkdtemp(prefix="sm_tias_scene_")) + try: + scene_dict: dict = {"type": "scene"} + + # integrator + scene_dict["integrator"] = { + "type": config.render.integrator, + "max_depth": config.render.max_depth, + } + + # sensor + scene_dict["camera"] = build_sensor_dict(config.sensor, position_scale=pos_scale) + + # materials(トップレベルに配置し mesh から id 参照する) + for name, mat in config.materials.items(): + mat_dict = build_material_dict(mat) + mat_dict["id"] = f"mat_{name}" + scene_dict[f"mat_{name}"] = mat_dict + + # meshes + for mesh_cfg in config.geometry.meshes: + raw_path = Path(mesh_cfg.path) + stl_path = raw_path if raw_path.is_absolute() else root / mesh_cfg.path + loaded = load_stl(stl_path, mesh_cfg.unit) + scaled = scale_vertices(loaded, target_unit="m") + ply_path = tempdir / f"{mesh_cfg.id}.ply" + _save_loaded_mesh_to_ply(scaled, ply_path) + scene_dict[f"mesh_{mesh_cfg.id}"] = { + "type": "ply", + "filename": str(ply_path), + "bsdf": {"type": "ref", "id": f"mat_{mesh_cfg.material}"}, + } + + # lights + for light_cfg in config.lights: + scene_dict[f"light_{light_cfg.id}"] = build_light_dict( + light_cfg, position_scale=pos_scale + ) + + return scene_dict, tempdir + + except Exception: + shutil.rmtree(tempdir, ignore_errors=True) + raise diff --git a/src/sm_tias_lightsim/scene/lights.py b/src/sm_tias_lightsim/scene/lights.py new file mode 100644 index 0000000..8b842fe --- /dev/null +++ b/src/sm_tias_lightsim/scene/lights.py @@ -0,0 +1,121 @@ +# pyright: reportCallIssue=none +# Mitsuba 3 は variant ベースの動的属性解決を行うため Pylance/Pyright が +# mi.* を object 型と推論し reportCallIssue を誤検知する(GUIDE_06 の方針). +"""YAML 光源定義 → Mitsuba emitter dict 変換モジュール.""" + +from __future__ import annotations + +import mitsuba as mi + +from sm_tias_lightsim.scene.materials import _to_rgb +from sm_tias_lightsim.scene.schema import AreaLight, DirectionalLight, LightConfig, PointLight + + +def _build_area_light_transform( + center: tuple[float, float, float], + size: tuple[float, float], + direction: tuple[float, float, float], + position_scale: float, +) -> mi.ScalarTransform4f: + """AreaLight の中心・サイズ・方向から Mitsuba の to_world Transform を組み立てる. + + Mitsuba の rectangle 形状は XZ 平面上の -1〜+1 の正方形(Y 軸正方向が前面). + direction ベクトルが面の法線方向を指すように look_at を構成する. + + Args: + center: 面中心位置(元単位). + size: 面サイズ (width, height)(元単位). + direction: 放射方向ベクトル(単位ベクトルでなくても可). + position_scale: 座標スケール係数(mm→m なら 0.001). + + Returns: + Mitsuba ScalarTransform4f. + """ + cx, cy, cz = [c * position_scale for c in center] + sw, sh = size[0] * position_scale, size[1] * position_scale + + dx, dy, dz = direction + norm = (dx**2 + dy**2 + dz**2) ** 0.5 + if norm == 0.0: + msg = "AreaLight direction vector must not be zero." + raise ValueError(msg) + dx, dy, dz = dx / norm, dy / norm, dz / norm + + # direction が Y 軸に近い場合は up を Z 軸とし,それ以外は Y 軸を up に使う + # look_at の origin を center + direction(目線は放射方向の逆から見る)として構成 + if abs(dy) < 0.9: + up = [0.0, 1.0, 0.0] + else: + up = [0.0, 0.0, 1.0] + + # look_at: origin=center,target=center + direction,up=up + origin = [cx, cy, cz] + target = [cx + dx, cy + dy, cz + dz] + + transform = mi.ScalarTransform4f().look_at(origin=origin, target=target, up=up) + + # サイズに合わせてスケール(rectangle は ±1 なので half-size でスケール) + scale = mi.ScalarTransform4f().scale([sw / 2.0, 1.0, sh / 2.0]) + + return transform @ scale + + +def build_light_dict(light: LightConfig, position_scale: float) -> dict: + """1 LightConfig を Mitsuba scene dict の emitter エントリに変換する. + + Args: + light: SceneConfig.lights の 1 要素(PointLight / AreaLight / DirectionalLight). + position_scale: 座標スケール係数(例:mm→m なら 0.001). + 位置・サイズに掛けて Mitsuba(m)に揃える. + + Returns: + Mitsuba scene dict のキーに使える dict. + - PointLight → {"type": "point", "position": [...], "intensity": {...}} + - AreaLight → {"type": "rectangle", "to_world": ..., "emitter": {"type": "area", ...}} + - DirectionalLight → {"type": "directional", "direction": [...], "irradiance": {...}} + + Raises: + ValueError: 未対応の type が指定された場合. + """ + if isinstance(light, PointLight): + pos = [c * position_scale for c in light.position] + return { + "type": "point", + "position": pos, + "intensity": { + "type": "rgb", + "value": _to_rgb(light.intensity), + }, + } + + if isinstance(light, AreaLight): + to_world = _build_area_light_transform( + center=light.center, + size=light.size, + direction=light.direction, + position_scale=position_scale, + ) + return { + "type": "rectangle", + "to_world": to_world, + "emitter": { + "type": "area", + "radiance": { + "type": "rgb", + "value": _to_rgb(light.radiance), + }, + }, + } + + if isinstance(light, DirectionalLight): + return { + "type": "directional", + "direction": list(light.direction), + "irradiance": { + "type": "rgb", + "value": _to_rgb(light.irradiance), + }, + } + + msg = f"Unsupported light type: {type(light).__name__}" + raise ValueError(msg) diff --git a/src/sm_tias_lightsim/scene/materials.py b/src/sm_tias_lightsim/scene/materials.py new file mode 100644 index 0000000..6c2241b --- /dev/null +++ b/src/sm_tias_lightsim/scene/materials.py @@ -0,0 +1,69 @@ +"""YAML 材料定義 → Mitsuba BSDF dict 変換モジュール.""" + +from __future__ import annotations + +from sm_tias_lightsim.scene.schema import ( + DielectricMaterial, + DiffuseMaterial, + MaterialConfig, + ThinDielectricMaterial, +) + + +def _to_rgb(value: float | tuple[float, float, float]) -> list[float]: + """スカラまたは RGB タプルを 3 要素の float リストに正規化する. + + lights.py / materials.py 共通のヘルパ.このモジュールが正本で, + lights.py はここから import して重複を回避する. + + Args: + value: スカラ(全チャンネル同値)または RGB タプル. + + Returns: + 3 要素の float リスト. + """ + if isinstance(value, (int, float)): + return [float(value)] * 3 + return list(value) + + +def build_material_dict(material: MaterialConfig) -> dict: + """1 MaterialConfig を Mitsuba BSDF dict に変換する. + + Args: + material: SceneConfig.materials の値(DiffuseMaterial 等). + + Returns: + Mitsuba BSDF dict. + - diffuse → {"type": "diffuse", "reflectance": {"type":"rgb","value":[r,g,b]}} + - dielectric → {"type": "dielectric", "int_ior": ..., "ext_ior": ...} + - thindielectric → {"type": "thindielectric", "int_ior": ..., "ext_ior": ...} + + Raises: + ValueError: 未対応の材料タイプが指定された場合. + """ + if isinstance(material, DiffuseMaterial): + return { + "type": "diffuse", + "reflectance": { + "type": "rgb", + "value": _to_rgb(material.reflectance), + }, + } + + if isinstance(material, DielectricMaterial): + return { + "type": "dielectric", + "int_ior": material.int_ior, + "ext_ior": material.ext_ior, + } + + if isinstance(material, ThinDielectricMaterial): + return { + "type": "thindielectric", + "int_ior": material.int_ior, + "ext_ior": material.ext_ior, + } + + msg = f"Unsupported material type: {type(material).__name__}" + raise ValueError(msg) diff --git a/src/sm_tias_lightsim/scene/sensors.py b/src/sm_tias_lightsim/scene/sensors.py new file mode 100644 index 0000000..3813b7f --- /dev/null +++ b/src/sm_tias_lightsim/scene/sensors.py @@ -0,0 +1,42 @@ +# pyright: reportCallIssue=none +# Mitsuba 3 は variant ベースの動的属性解決を行うため Pylance/Pyright が +# mi.* を object 型と推論し reportCallIssue を誤検知する(GUIDE_06 の方針). +"""YAML センサ定義 → Mitsuba sensor dict 変換モジュール.""" + +from __future__ import annotations + +import mitsuba as mi + +from sm_tias_lightsim.scene.schema import SensorConfig + + +def build_sensor_dict(sensor: SensorConfig, position_scale: float) -> dict: + """SensorConfig を Mitsuba sensor dict に変換する. + + Args: + sensor: SceneConfig.sensor. + position_scale: 座標スケール係数(mm→m なら 0.001).position/target にも適用. + + Returns: + Mitsuba sensor dict(perspective). + """ + pos = [c * position_scale for c in sensor.position] + target = [c * position_scale for c in sensor.target] + # 方向ベクトルは無次元なのでスケール不要 + up = list(sensor.up) + + to_world = mi.ScalarTransform4f().look_at(origin=pos, target=target, up=up) + + return { + "type": "perspective", + "fov": sensor.fov_deg, + "to_world": to_world, + "film": { + "type": "hdrfilm", + "width": sensor.film.width, + "height": sensor.film.height, + "rfilter": {"type": sensor.film.rfilter}, + }, + # spp は render 時に渡すので置きの値 + "sampler": {"type": "independent", "sample_count": 1}, + } diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..fa5bbbb --- /dev/null +++ b/tests/cli/__init__.py @@ -0,0 +1 @@ +"""cli テストパッケージ.""" diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py new file mode 100644 index 0000000..51e6766 --- /dev/null +++ b/tests/cli/conftest.py @@ -0,0 +1,55 @@ +"""cli テスト共通フィクスチャ. + +不正 YAML など CLI 異常系テスト用の一時ファイルを提供する. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + + +@pytest.fixture +def invalid_yaml_file(tmp_path: Path) -> Path: + """必須フィールド(name)が欠落した不正 YAML ファイルを tmp_path に書き出す. + + load_scene_yaml が Pydantic ValidationError を投げる経路を踏ませるためのフィクスチャ. + FileNotFoundError とは異なる「読み込み失敗」経路のテスト用. + """ + content = """\ +# name フィールドを意図的に省略(必須フィールド欠落) +geometry: + meshes: + - id: sphere + path: meshes/sphere.stl + unit: mm + material: white +materials: + white: + type: diffuse + reflectance: 0.9 +lights: + - id: led_top + type: point + position: [0.0, 0.0, 50.0] + intensity: 10.0 + profile: lambertian +sensor: + type: perspective + position: [0.0, 0.0, 100.0] + target: [0.0, 0.0, 0.0] + up: [0.0, 1.0, 0.0] + fov_deg: 60.0 + film: + width: 64 + height: 64 +render: + variant: llvm_ad_rgb + integrator: path + max_depth: 4 + spp: 4 +""" + yaml_path = tmp_path / "invalid_scene.yaml" + yaml_path.write_text(content, encoding="utf-8") + return yaml_path diff --git a/tests/cli/test_render.py b/tests/cli/test_render.py new file mode 100644 index 0000000..32969db --- /dev/null +++ b/tests/cli/test_render.py @@ -0,0 +1,233 @@ +"""cli/render.py の仕様ベーステスト. + +仕様出典: +- docs/03_PLAN/PLAN_02_開発ステップ.md(Step 5: render CLI 完了条件) +- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(CLI 層の責務) +- docs/01_GUIDE/GUIDE_08_テスト方針.md(テスト方針) + +Step 5 完了条件: + `sm-render --scene --output ` で PNG と luminance.npy が生成されること + +テスト方針: + - CLI の振る舞いを main() を直接呼び出す形で検証する(subprocess 不使用) + - Mitsuba 実行を伴うテストは @pytest.mark.slow を付ける + - 軽量テスト(argparse / エラーハンドリング)は @pytest.mark.slow なし +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from sm_tias_lightsim.cli.render import main + +# --------------------------------------------------------------------------- +# テスト用定数 +# --------------------------------------------------------------------------- + +#: tests/fixtures/minimal.yaml の絶対パス +MINIMAL_YAML = Path(__file__).parent.parent / "fixtures" / "minimal.yaml" + + +# --------------------------------------------------------------------------- +# 1. 必須引数の欠落(軽量 — argparse 動作) +# --------------------------------------------------------------------------- + + +def test_missing_scene_arg_exits_with_error(): + """--scene を渡さないと SystemExit が発生すること. + + 仕様: --scene は必須引数(PLAN_02 Step 5「sm-render --scene 」) + argparse の required=True は SystemExit を発生させる. + """ + with pytest.raises(SystemExit) as exc_info: + main(["--output", "/tmp/out"]) + assert exc_info.value.code != 0 + + +def test_missing_output_arg_exits_with_error(): + """--output を渡さないと SystemExit が発生すること. + + 仕様: --output は必須引数(PLAN_02 Step 5「--output 」) + argparse の required=True は SystemExit を発生させる. + """ + with pytest.raises(SystemExit) as exc_info: + main(["--scene", str(MINIMAL_YAML)]) + assert exc_info.value.code != 0 + + +def test_no_args_exits_with_error(): + """引数なしで呼ぶと SystemExit が発生すること. + + 仕様: --scene と --output の両方が必須 + """ + with pytest.raises(SystemExit) as exc_info: + main([]) + assert exc_info.value.code != 0 + + +# --------------------------------------------------------------------------- +# 2. --scene が存在しないパス(軽量 — return code 1, stderr メッセージ) +# --------------------------------------------------------------------------- + + +def test_nonexistent_scene_returns_1(tmp_path: Path, capsys): + """存在しない YAML パスを渡すと return 1 かつ stderr にエラーメッセージが出ること. + + 仕様: FileNotFoundError 捕捉 → stderr「シーン YAML ファイルが見つかりません」→ return 1 + (cli/render.py 処理フロー ステップ 2) + """ + nonexistent = tmp_path / "does_not_exist.yaml" + result = main(["--scene", str(nonexistent), "--output", str(tmp_path / "out")]) + assert result == 1 + captured = capsys.readouterr() + assert "シーン YAML ファイルが見つかりません" in captured.err + + +def test_nonexistent_scene_error_message_contains_path(tmp_path: Path, capsys): + """存在しない YAML パスを渡したときのエラーメッセージにパス情報が含まれること. + + 仕様: stderr「エラー: シーン YAML ファイルが見つかりません — ...」 + (cli/render.py 処理フロー ステップ 2) + """ + nonexistent = tmp_path / "no_such_scene.yaml" + main(["--scene", str(nonexistent), "--output", str(tmp_path / "out")]) + captured = capsys.readouterr() + assert "エラー:" in captured.err + + +# --------------------------------------------------------------------------- +# 3. --scene が不正な YAML(ValidationError 等)(軽量 — return code 1) +# --------------------------------------------------------------------------- + + +def test_invalid_yaml_returns_1(invalid_yaml_file: Path, tmp_path: Path, capsys): + """必須フィールド欠落の YAML を渡すと return 1 が返ること. + + 仕様: Pydantic ValidationError 等の一般例外 → stderr「シーン YAML の読み込みに失敗しました」 + → return 1(FileNotFoundError と区別される経路) + (cli/render.py 処理フロー ステップ 2) + """ + result = main(["--scene", str(invalid_yaml_file), "--output", str(tmp_path / "out")]) + assert result == 1 + + +def test_invalid_yaml_stderr_message(invalid_yaml_file: Path, tmp_path: Path, capsys): + """不正 YAML のエラーメッセージに「シーン YAML の読み込みに失敗しました」が含まれること. + + 仕様: 一般例外捕捉 → stderr「エラー: シーン YAML の読み込みに失敗しました — ...」 + (cli/render.py 処理フロー ステップ 2) + """ + main(["--scene", str(invalid_yaml_file), "--output", str(tmp_path / "out")]) + captured = capsys.readouterr() + assert "シーン YAML の読み込みに失敗しました" in captured.err + + +def test_invalid_yaml_does_not_show_file_not_found_message( + invalid_yaml_file: Path, tmp_path: Path, capsys +): + """存在するが不正な YAML に対して「ファイルが見つかりません」メッセージが出ないこと. + + 仕様: FileNotFoundError と Pydantic ValidationError の経路が明確に分離されていること + (cli/render.py 処理フロー ステップ 2) + """ + main(["--scene", str(invalid_yaml_file), "--output", str(tmp_path / "out")]) + captured = capsys.readouterr() + assert "ファイルが見つかりません" not in captured.err + + +# --------------------------------------------------------------------------- +# 4 & 5 & 6. end-to-end 統合テスト(slow — Mitsuba 実行) +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_end_to_end_returns_0(tmp_path: Path): + """minimal.yaml を入力として main() が return 0 を返すこと. + + 仕様: Step 5 完了条件「sm-render --scene tests/fixtures/minimal.yaml --output が動く」 + (PLAN_02 Step 5 完了条件) + """ + result = main(["--scene", str(MINIMAL_YAML), "--output", str(tmp_path)]) + assert result == 0 + + +@pytest.mark.slow +def test_end_to_end_creates_render_png(tmp_path: Path): + """end-to-end 実行後に render.png が出力ディレクトリに存在すること. + + 仕様: sm-render は render.png を出力する (cli/render.py 処理フロー ステップ 5) + """ + main(["--scene", str(MINIMAL_YAML), "--output", str(tmp_path)]) + assert (tmp_path / "render.png").exists() + + +@pytest.mark.slow +def test_end_to_end_creates_luminance_npy(tmp_path: Path): + """end-to-end 実行後に luminance.npy が出力ディレクトリに存在すること. + + 仕様: sm-render は luminance.npy を出力する (cli/render.py 処理フロー ステップ 6) + """ + main(["--scene", str(MINIMAL_YAML), "--output", str(tmp_path)]) + assert (tmp_path / "luminance.npy").exists() + + +@pytest.mark.slow +def test_end_to_end_stdout_contains_render_complete(tmp_path: Path, capsys): + """end-to-end 実行後に stdout に「レンダリング完了」が含まれること. + + 仕様: 成功時 stdout に「レンダリング完了: ...」を出力する (cli/render.py 処理フロー ステップ 6) + """ + main(["--scene", str(MINIMAL_YAML), "--output", str(tmp_path)]) + captured = capsys.readouterr() + assert "レンダリング完了" in captured.out + + +@pytest.mark.slow +def test_end_to_end_stdout_contains_luminance_saved(tmp_path: Path, capsys): + """end-to-end 実行後に stdout に「輝度マップ保存」が含まれること. + + 仕様: 成功時 stdout に「輝度マップ保存: ...」を出力する (cli/render.py 処理フロー ステップ 6) + """ + main(["--scene", str(MINIMAL_YAML), "--output", str(tmp_path)]) + captured = capsys.readouterr() + assert "輝度マップ保存" in captured.out + + +# --------------------------------------------------------------------------- +# 5. 短縮引数 -s / -o(slow) +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_short_args_s_and_o_work(tmp_path: Path): + """-s と -o の短縮形でも --scene / --output と同等に動くこと. + + 仕様: --scene/-s, --output/-o の短縮形を提供する (cli/render.py _parse_args) + """ + result = main(["-s", str(MINIMAL_YAML), "-o", str(tmp_path)]) + assert result == 0 + assert (tmp_path / "render.png").exists() + assert (tmp_path / "luminance.npy").exists() + + +# --------------------------------------------------------------------------- +# 6. --output ディレクトリの自動作成(slow) +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_deep_nested_output_dir_is_created_automatically(tmp_path: Path): + """存在しない深いネストのディレクトリも mkdir(parents=True) で自動作成されること. + + 仕様: output_dir.mkdir(parents=True, exist_ok=True) で親ディレクトリも含めて作成する + (cli/render.py 処理フロー ステップ 3) + """ + deep_output = tmp_path / "deep" / "nested" / "out" + assert not deep_output.exists() + result = main(["--scene", str(MINIMAL_YAML), "--output", str(deep_output)]) + assert result == 0 + assert deep_output.exists() + assert (deep_output / "render.png").exists() + assert (deep_output / "luminance.npy").exists() diff --git a/tests/conftest.py b/tests/conftest.py index db21735..9bc32d3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,22 @@ import pytest +@pytest.fixture(scope="session", autouse=True) +def mitsuba_variant(): + """セッション全体で Mitsuba variant を 1 度だけ設定する. + + mi.ScalarTransform4f 等の Mitsuba 型はモジュールインポート後に + set_variant を呼ぶまでアクセスできない.軽量テスト(build_*_dict 系)も + to_world 生成で内部的に mi.ScalarTransform4f を使うため, + セッション開始時に variant を設定しておく必要がある. + + GUIDE_08: Mitsuba をモックしない方針に従い実ライブラリを使用する. + """ + import mitsuba as mi + + mi.set_variant("llvm_ad_rgb") + + @pytest.fixture def uniform_luminance_100x100() -> numpy.ndarray: """全画素が 100.0 の (100, 100) 輝度配列.""" diff --git a/tests/fixtures/meshes/sphere.stl b/tests/fixtures/meshes/sphere.stl new file mode 100644 index 0000000..644a832 --- /dev/null +++ b/tests/fixtures/meshes/sphere.stl Binary files differ diff --git a/tests/fixtures/minimal.yaml b/tests/fixtures/minimal.yaml new file mode 100644 index 0000000..f80074f --- /dev/null +++ b/tests/fixtures/minimal.yaml @@ -0,0 +1,35 @@ +# Step 5 完了条件 / 統合テスト用の最小シーン定義 +# uv run sm-render --scene tests/fixtures/minimal.yaml --output /tmp/test/ で動作確認する +# 64x64 低解像度・spp=4 で smoke 用途(高速完走優先) +name: smoke_test +geometry: + meshes: + - id: sphere + path: meshes/sphere.stl + unit: mm + material: white +materials: + white: + type: diffuse + reflectance: 0.9 +lights: + - id: led_top + type: point + position: [0.0, 0.0, 50.0] + intensity: 10.0 + profile: lambertian +sensor: + type: perspective + position: [0.0, 0.0, 100.0] + target: [0.0, 0.0, 0.0] + up: [0.0, 1.0, 0.0] + fov_deg: 60.0 + film: + width: 64 + height: 64 + rfilter: gaussian +render: + variant: llvm_ad_rgb + integrator: path + max_depth: 4 + spp: 4 diff --git a/tests/render/__init__.py b/tests/render/__init__.py new file mode 100644 index 0000000..06e95c0 --- /dev/null +++ b/tests/render/__init__.py @@ -0,0 +1 @@ +"""render テストパッケージ.""" diff --git a/tests/render/conftest.py b/tests/render/conftest.py new file mode 100644 index 0000000..89bcd58 --- /dev/null +++ b/tests/render/conftest.py @@ -0,0 +1,69 @@ +"""render テスト共通フィクスチャ. + +球体 STL の一時生成と最小シーン YAML 文字列を提供する. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import trimesh + + +@pytest.fixture +def sphere_stl_in_cwd(tmp_path: Path) -> Path: + """球体 STL を tmp_path / "sphere.stl" に書き出してそのパスを返す. + + trimesh.creation.icosphere で生成する小球体(半径 5mm 相当). + render テストの project_root として tmp_path を使うため, + STL パスは tmp_path 配下に置く. + """ + mesh = trimesh.creation.icosphere(subdivisions=1, radius=5.0) + path = tmp_path / "sphere.stl" + mesh.export(str(path)) + return path + + +@pytest.fixture +def minimal_scene_yaml(sphere_stl_in_cwd: Path) -> str: + """球体 1 個+拡散材料+点光源+センサ+render(spp=4) の YAML 文字列. + + 64x64 の小解像度・低 spp で smoke テストを高速化する. + STL パスは sphere_stl_in_cwd の絶対パスを埋め込む. + """ + stl_path = sphere_stl_in_cwd.as_posix() + return f"""\ +name: smoke_test +geometry: + meshes: + - id: sphere + path: {stl_path} + unit: mm + material: white +materials: + white: + type: diffuse + reflectance: 0.9 +lights: + - id: led_top + type: point + position: [0.0, 0.0, 50.0] + intensity: 10.0 + profile: lambertian +sensor: + type: perspective + position: [0.0, 0.0, 100.0] + target: [0.0, 0.0, 0.0] + up: [0.0, 1.0, 0.0] + fov_deg: 60.0 + film: + width: 64 + height: 64 + rfilter: gaussian +render: + variant: llvm_ad_rgb + integrator: path + max_depth: 4 + spp: 4 +""" diff --git a/tests/render/test_output.py b/tests/render/test_output.py new file mode 100644 index 0000000..1e4655a --- /dev/null +++ b/tests/render/test_output.py @@ -0,0 +1,241 @@ +"""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) diff --git a/tests/render/test_runner.py b/tests/render/test_runner.py new file mode 100644 index 0000000..7051605 --- /dev/null +++ b/tests/render/test_runner.py @@ -0,0 +1,41 @@ +"""render/runner.py の仕様ベーステスト(end-to-end smoke). + +仕様出典: +- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(render/runner 層の責務) +- docs/03_PLAN/PLAN_02_開発ステップ.md(Step 4 完了条件: 64×64 の合成シーンを最後まで + レンダリングできる) +- docs/01_GUIDE/GUIDE_08_テスト方針.md(重いテストは @pytest.mark.slow 必須) + +このテストは実レンダリングを伴うため @pytest.mark.slow を付ける. +通常実行(uv run pytest -m "not slow")ではスキップされる. +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy +import pytest +import yaml + +from sm_tias_lightsim.render.runner import render_scene +from sm_tias_lightsim.scene.schema import SceneConfig + + +@pytest.mark.slow +def test_render_scene_smoke_returns_image(sphere_stl_in_cwd: Path, minimal_scene_yaml: str): + """64×64 シーンのレンダリングが成功し,正当な画像配列が返ること. + + 仕様: Step 4 完了条件「64×64 程度の合成シーンを最後までレンダリングできる」 + (PLAN_02 Step 4 完了条件) + + 検証: + - shape: (64, 64, 3) + - dtype: float32 または float64 + - mean > 0(光が物理的に届いている) + """ + cfg = SceneConfig.model_validate(yaml.safe_load(minimal_scene_yaml)) + img = render_scene(cfg, project_root=sphere_stl_in_cwd.parent) + assert img.shape == (64, 64, 3) + assert img.dtype in (numpy.float32, numpy.float64) + assert img.mean() > 0 diff --git a/tests/scene/test_builder.py b/tests/scene/test_builder.py new file mode 100644 index 0000000..3c1b22d --- /dev/null +++ b/tests/scene/test_builder.py @@ -0,0 +1,400 @@ +"""scene/builder.py の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(全セクション) +- docs/03_PLAN/PLAN_02_開発ステップ.md(Step 4: scene/builder.py の役割) +- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(scene/builder 層の責務) + +build_scene の戻り値 dict 構造のみを検証する(実レンダリングは行わない). +build_scene で生成された tempdir は各テスト末尾で shutil.rmtree する. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest +import trimesh +import yaml + +from sm_tias_lightsim.scene.builder import build_scene +from sm_tias_lightsim.scene.schema import SceneConfig + +# --------------------------------------------------------------------------- +# フィクスチャ +# --------------------------------------------------------------------------- + + +@pytest.fixture +def sphere_stl(tmp_path: Path) -> Path: + """球体 STL を tmp_path に書き出してそのパスを返す. + + trimesh.creation.icosphere で生成する小球体(半径 10mm 相当). + """ + mesh = trimesh.creation.icosphere(subdivisions=1, radius=10.0) + path = tmp_path / "sphere.stl" + mesh.export(str(path)) + return path + + +@pytest.fixture +def minimal_scene_config(sphere_stl: Path) -> SceneConfig: + """球体メッシュ 1 つ+拡散材料+点光源+センサの最小 SceneConfig.""" + data = yaml.safe_load(f"""\ +name: test_scene +geometry: + meshes: + - id: sphere + path: {sphere_stl.as_posix()} + unit: mm + material: white +materials: + white: + type: diffuse + reflectance: 0.9 +lights: + - id: led_top + type: point + position: [0.0, 0.0, 50.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: 64 + height: 64 + rfilter: gaussian +render: + variant: llvm_ad_rgb + integrator: path + max_depth: 4 + spp: 4 +""") + return SceneConfig.model_validate(data) + + +# --------------------------------------------------------------------------- +# 戻り値の型 +# --------------------------------------------------------------------------- + + +def test_build_scene_returns_tuple(minimal_scene_config: SceneConfig, tmp_path: Path): + """build_scene が (dict, Path) のタプルを返すこと. + + 仕様: build_scene → (scene_dict, tempdir) (builder.py docstring) + """ + result = build_scene(minimal_scene_config, project_root=tmp_path) + assert isinstance(result, tuple) + assert len(result) == 2 + scene_dict, tempdir = result + shutil.rmtree(tempdir, ignore_errors=True) + assert isinstance(scene_dict, dict) + assert isinstance(tempdir, Path) + + +def test_build_scene_tempdir_exists_before_cleanup( + minimal_scene_config: SceneConfig, tmp_path: Path +): + """build_scene が返す tempdir が存在するディレクトリであること. + + 仕様: tempdir は一時 PLY ファイルを格納したディレクトリ (builder.py docstring) + """ + _, tempdir = build_scene(minimal_scene_config, project_root=tmp_path) + try: + assert tempdir.exists() + assert tempdir.is_dir() + finally: + shutil.rmtree(tempdir, ignore_errors=True) + + +# --------------------------------------------------------------------------- +# scene_dict のトップレベル構造 +# --------------------------------------------------------------------------- + + +def test_build_scene_dict_type_is_scene(minimal_scene_config: SceneConfig, tmp_path: Path): + """scene_dict["type"] == "scene" であること. + + 仕様: Mitsuba load_dict 互換の dict (builder.py docstring) + """ + scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path) + shutil.rmtree(tempdir, ignore_errors=True) + assert scene_dict["type"] == "scene" + + +def test_build_scene_dict_has_integrator(minimal_scene_config: SceneConfig, tmp_path: Path): + """scene_dict に integrator キーが存在すること. + + 仕様: scene_dict["integrator"] (SPEC_01 scene/builder 責務) + """ + scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path) + shutil.rmtree(tempdir, ignore_errors=True) + assert "integrator" in scene_dict + + +def test_build_scene_dict_integrator_type(minimal_scene_config: SceneConfig, tmp_path: Path): + """integrator の type が RenderConfig.integrator と一致すること. + + 仕様: render.integrator: path | direct | volpath (SPEC_02 render セクション) + """ + scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path) + shutil.rmtree(tempdir, ignore_errors=True) + assert scene_dict["integrator"]["type"] == "path" + + +def test_build_scene_dict_has_camera(minimal_scene_config: SceneConfig, tmp_path: Path): + """scene_dict に camera キーが存在すること. + + 仕様: sensor → scene_dict["camera"] (builder.py 実装) + """ + scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path) + shutil.rmtree(tempdir, ignore_errors=True) + assert "camera" in scene_dict + + +def test_build_scene_dict_camera_type_is_perspective( + minimal_scene_config: SceneConfig, tmp_path: Path +): + """camera の type が "perspective" であること. + + 仕様: sensor.type は perspective のみ (SPEC_02 sensor セクション) + """ + scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path) + shutil.rmtree(tempdir, ignore_errors=True) + assert scene_dict["camera"]["type"] == "perspective" + + +# --------------------------------------------------------------------------- +# 材料キー +# --------------------------------------------------------------------------- + + +def test_build_scene_dict_has_material_key(minimal_scene_config: SceneConfig, tmp_path: Path): + """scene_dict に mat_ キーが存在すること. + + 仕様: materials → scene_dict["mat_"] (builder.py 実装) + """ + scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path) + shutil.rmtree(tempdir, ignore_errors=True) + assert "mat_white" in scene_dict + + +def test_build_scene_dict_material_id_matches(minimal_scene_config: SceneConfig, tmp_path: Path): + """mat_ の id フィールドが "mat_" と一致すること. + + 仕様: mat_dict["id"] = f"mat_{name}" (builder.py 実装) + """ + scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path) + shutil.rmtree(tempdir, ignore_errors=True) + assert scene_dict["mat_white"]["id"] == "mat_white" + + +# --------------------------------------------------------------------------- +# メッシュキー +# --------------------------------------------------------------------------- + + +def test_build_scene_dict_has_mesh_key(minimal_scene_config: SceneConfig, tmp_path: Path): + """scene_dict に mesh_ キーが存在すること. + + 仕様: meshes → scene_dict["mesh_"] (builder.py 実装) + """ + scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path) + shutil.rmtree(tempdir, ignore_errors=True) + assert "mesh_sphere" in scene_dict + + +def test_build_scene_dict_mesh_type_is_ply(minimal_scene_config: SceneConfig, tmp_path: Path): + """mesh の type が "ply" であること. + + 仕様: STL → PLY 変換して Mitsuba に渡す (builder.py 実装) + """ + scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path) + shutil.rmtree(tempdir, ignore_errors=True) + assert scene_dict["mesh_sphere"]["type"] == "ply" + + +def test_build_scene_dict_mesh_bsdf_is_ref(minimal_scene_config: SceneConfig, tmp_path: Path): + """mesh の bsdf が {"type": "ref", "id": "mat_"} であること. + + 仕様: mesh から material への参照は id 参照で行う (builder.py 実装) + """ + scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path) + shutil.rmtree(tempdir, ignore_errors=True) + bsdf = scene_dict["mesh_sphere"]["bsdf"] + assert bsdf["type"] == "ref" + assert bsdf["id"] == "mat_white" + + +def test_build_scene_ply_file_written_to_tempdir(minimal_scene_config: SceneConfig, tmp_path: Path): + """tempdir 配下に PLY ファイルが実際に書き出されていること. + + 仕様: 一時ディレクトリに PLY として書き出す (builder.py 実装) + """ + scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path) + try: + ply_files = list(tempdir.glob("*.ply")) + assert len(ply_files) >= 1 + assert (tempdir / "sphere.ply").exists() + finally: + shutil.rmtree(tempdir, ignore_errors=True) + + +def test_build_scene_ply_filename_matches_mesh_id( + minimal_scene_config: SceneConfig, tmp_path: Path +): + """scene_dict の filename が実在する PLY ファイルを指していること. + + 仕様: PLY ファイルを格納した tempdir (builder.py 実装) + """ + scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path) + try: + ply_path = Path(scene_dict["mesh_sphere"]["filename"]) + assert ply_path.exists() + finally: + shutil.rmtree(tempdir, ignore_errors=True) + + +# --------------------------------------------------------------------------- +# 光源キー +# --------------------------------------------------------------------------- + + +def test_build_scene_dict_has_light_key(minimal_scene_config: SceneConfig, tmp_path: Path): + """scene_dict に light_ キーが存在すること. + + 仕様: lights → scene_dict["light_"] (builder.py 実装) + """ + scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path) + shutil.rmtree(tempdir, ignore_errors=True) + assert "light_led_top" in scene_dict + + +def test_build_scene_dict_light_type_is_point(minimal_scene_config: SceneConfig, tmp_path: Path): + """point 光源の type が "point" であること. + + 仕様: PointLight → {"type": "point", ...} (SPEC_02 lights セクション) + """ + scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path) + shutil.rmtree(tempdir, ignore_errors=True) + assert scene_dict["light_led_top"]["type"] == "point" + + +# --------------------------------------------------------------------------- +# 複数材料・複数光源のシーン +# --------------------------------------------------------------------------- + + +def test_build_scene_with_multiple_materials(tmp_path: Path, sphere_stl: Path): + """複数材料を持つシーンで全材料キーが scene_dict に揃うこと. + + 仕様: materials dict の全エントリが変換される (SPEC_02 materials セクション) + """ + data = yaml.safe_load(f"""\ +name: multi_mat +geometry: + meshes: + - id: sphere1 + path: {sphere_stl.as_posix()} + unit: mm + material: white + - id: sphere2 + path: {sphere_stl.as_posix()} + unit: mm + material: pmma +materials: + white: + type: diffuse + reflectance: 0.9 + pmma: + type: thindielectric + int_ior: 1.49 + ext_ior: 1.0 +lights: + - id: led_1 + type: point + position: [0.0, 0.0, 50.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: 64 + height: 64 +render: + variant: llvm_ad_rgb + integrator: path + max_depth: 4 + spp: 4 +""") + config = SceneConfig.model_validate(data) + scene_dict, tempdir = build_scene(config, project_root=tmp_path) + shutil.rmtree(tempdir, ignore_errors=True) + assert "mat_white" in scene_dict + assert "mat_pmma" in scene_dict + assert "mesh_sphere1" in scene_dict + assert "mesh_sphere2" in scene_dict + + +def test_build_scene_missing_stl_raises_file_not_found(tmp_path: Path): + """存在しない STL パスを指定したとき FileNotFoundError が発生すること. + + 仕様: FileNotFoundError: STL ファイルが見つからない場合 (builder.py docstring) + """ + data = yaml.safe_load("""\ +name: missing_stl +geometry: + meshes: + - id: ghost + path: nonexistent/ghost.stl + unit: mm + material: white +materials: + white: + type: diffuse + reflectance: 0.9 +lights: + - id: led_1 + type: point + position: [0.0, 0.0, 50.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: 64 + height: 64 +render: + variant: llvm_ad_rgb + integrator: path + max_depth: 4 + spp: 4 +""") + config = SceneConfig.model_validate(data) + with pytest.raises(FileNotFoundError): + build_scene(config, project_root=tmp_path) + + +def test_build_scene_integrator_max_depth_matches_config( + minimal_scene_config: SceneConfig, tmp_path: Path +): + """integrator の max_depth が RenderConfig.max_depth と一致すること. + + 仕様: render.max_depth (SPEC_02 render セクション) + """ + scene_dict, tempdir = build_scene(minimal_scene_config, project_root=tmp_path) + shutil.rmtree(tempdir, ignore_errors=True) + assert scene_dict["integrator"]["max_depth"] == minimal_scene_config.render.max_depth diff --git a/tests/scene/test_lights.py b/tests/scene/test_lights.py new file mode 100644 index 0000000..447a825 --- /dev/null +++ b/tests/scene/test_lights.py @@ -0,0 +1,312 @@ +"""scene/lights.py の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(lights セクション) +- docs/03_PLAN/PLAN_02_開発ステップ.md(Step 4: scene/lights.py の役割) + +build_light_dict の戻り値 dict 構造のみを検証する.Mitsuba の load_dict は呼ばない. +ただし mi.ScalarTransform4f を内部で使うため mitsuba のインポートは発生する. +""" + +from __future__ import annotations + +import pytest + +from sm_tias_lightsim.scene.lights import build_light_dict +from sm_tias_lightsim.scene.schema import AreaLight, DirectionalLight, PointLight + +# --------------------------------------------------------------------------- +# PointLight +# --------------------------------------------------------------------------- + + +def test_point_light_emitter_type_is_point(): + """PointLight から生成される dict の type が "point" であること. + + 仕様: PointLight → {"type": "point", ...} (SPEC_01 scene/lights 責務) + """ + light = PointLight( + id="led_1", + type="point", + position=(0.0, 0.0, 50.0), + intensity=1.0, + profile="lambertian", + ) + result = build_light_dict(light, position_scale=0.001) + assert result["type"] == "point" + + +def test_point_light_position_scaled_by_position_scale(): + """PointLight の position が position_scale で変換されること. + + 仕様: mm→m なら 0.001 を掛ける (SPEC_02 lights セクション「position: mm 単位」) + """ + light = PointLight( + id="led_1", + type="point", + position=(10.0, 20.0, 30.0), + intensity=1.0, + profile="lambertian", + ) + result = build_light_dict(light, position_scale=0.001) + pos = result["position"] + assert pos == pytest.approx([0.01, 0.02, 0.03]) + + +def test_point_light_scalar_intensity_expands_to_rgb(): + """スカラ intensity が RGB 3 要素リストに展開されること. + + 仕様: intensity スカラ可 (SPEC_02 lights セクション) + """ + light = PointLight( + id="led_1", + type="point", + position=(0.0, 0.0, 50.0), + intensity=2.5, + profile="lambertian", + ) + result = build_light_dict(light, position_scale=0.001) + assert result["intensity"]["type"] == "rgb" + assert result["intensity"]["value"] == pytest.approx([2.5, 2.5, 2.5]) + + +def test_point_light_rgb_intensity_preserved(): + """RGB intensity がそのまま保持されること. + + 仕様: intensity: [R, G, B] 形式 (SPEC_02 lights セクション) + """ + light = PointLight( + id="led_1", + type="point", + position=(0.0, 0.0, 50.0), + intensity=(1.0, 0.5, 0.2), + profile="lambertian", + ) + result = build_light_dict(light, position_scale=0.001) + assert result["intensity"]["value"] == pytest.approx([1.0, 0.5, 0.2]) + + +def test_point_light_ies_profile_not_accepted_by_schema(): + """profile="ies" は現在スキーマレベルで ValidationError になること. + + 仕様: profile: ies は将来拡張(SPEC_02 lights セクション). + 現在の PointLight スキーマは lambertian / isotropic のみ受け付ける. + build_light_dict 内の NotImplementedError ガードはスキーマ迂回時のフォールバック. + + 注: SPEC_02 では profile: lambertian | isotropic | ies と記述されているが, + 現在の schema.py 実装では "ies" が Literal に含まれておらず ValidationError になる. + 将来 "ies" がスキーマに追加された際には,このテストも NotImplementedError 確認に変更する. + """ + from pydantic import ValidationError + + with pytest.raises(ValidationError): + PointLight( + id="led_ies", + type="point", + position=(0.0, 0.0, 50.0), + intensity=1.0, + profile="ies", # type: ignore[arg-type] + ) + + +def test_point_light_position_scale_one_leaves_position_unchanged(): + """position_scale=1.0 のとき position がそのまま保持されること. + + 仕様: position_scale が 1.0 なら m 入力がそのまま Mitsuba へ渡る + """ + light = PointLight( + id="led_1", + type="point", + position=(1.0, 2.0, 3.0), + intensity=1.0, + profile="isotropic", + ) + result = build_light_dict(light, position_scale=1.0) + assert result["position"] == pytest.approx([1.0, 2.0, 3.0]) + + +# --------------------------------------------------------------------------- +# AreaLight +# --------------------------------------------------------------------------- + + +def test_area_light_shape_is_rectangle(): + """AreaLight から生成される dict の type が "rectangle" であること. + + 仕様: AreaLight → {"type": "rectangle", ...} (SPEC_01 scene/lights 責務) + """ + light = AreaLight( + id="led_area", + type="area", + shape="rectangle", + center=(0.0, 0.0, 20.0), + size=(10.0, 10.0), + direction=(0.0, 0.0, -1.0), + radiance=50.0, + ) + result = build_light_dict(light, position_scale=0.001) + assert result["type"] == "rectangle" + + +def test_area_light_has_nested_area_emitter(): + """AreaLight の dict に type="area" のネスト emitter が含まれること. + + 仕様: AreaLight → {"type": "rectangle", ..., "emitter": {"type": "area", ...}} + (SPEC_01 scene/lights 責務) + """ + light = AreaLight( + id="led_area", + type="area", + shape="rectangle", + center=(0.0, 0.0, 20.0), + size=(10.0, 10.0), + direction=(0.0, 0.0, -1.0), + radiance=50.0, + ) + result = build_light_dict(light, position_scale=0.001) + assert "emitter" in result + assert result["emitter"]["type"] == "area" + + +def test_area_light_radiance_rgb_in_nested_emitter(): + """AreaLight の radiance が nested emitter の rgb dict に格納されること. + + 仕様: AreaLight の emitter dict に radiance (SPEC_01 scene/lights 責務) + """ + light = AreaLight( + id="led_area", + type="area", + shape="rectangle", + center=(0.0, 0.0, 20.0), + size=(10.0, 10.0), + direction=(0.0, 0.0, -1.0), + radiance=(50.0, 50.0, 50.0), + ) + result = build_light_dict(light, position_scale=0.001) + radiance = result["emitter"]["radiance"] + assert radiance["type"] == "rgb" + assert radiance["value"] == pytest.approx([50.0, 50.0, 50.0]) + + +def test_area_light_scalar_radiance_expands_to_rgb(): + """スカラ radiance が RGB 3 要素リストに展開されること. + + 仕様: radiance スカラ可 (SPEC_02 lights セクション) + """ + light = AreaLight( + id="led_area", + type="area", + shape="rectangle", + center=(0.0, 0.0, 20.0), + size=(10.0, 10.0), + direction=(0.0, 0.0, -1.0), + radiance=100.0, + ) + result = build_light_dict(light, position_scale=0.001) + assert result["emitter"]["radiance"]["value"] == pytest.approx([100.0, 100.0, 100.0]) + + +def test_area_light_to_world_key_exists(): + """AreaLight の dict に to_world キーが存在すること. + + 仕様: AreaLight → {"type": "rectangle", "to_world": ..., "emitter": ...} + (SPEC_01 scene/lights 責務) + """ + light = AreaLight( + id="led_area", + type="area", + shape="rectangle", + center=(0.0, 0.0, 20.0), + size=(10.0, 10.0), + direction=(0.0, 0.0, -1.0), + radiance=50.0, + ) + result = build_light_dict(light, position_scale=0.001) + assert "to_world" in result + + +def test_area_light_position_scale_affects_to_world(): + """position_scale が異なると to_world が変わること(文字列表現で差分確認). + + 仕様: position_scale で center/size の座標が変換される (SPEC_01 scene/lights 責務) + """ + light = AreaLight( + id="led_area", + type="area", + shape="rectangle", + center=(100.0, 0.0, 0.0), + size=(10.0, 10.0), + direction=(0.0, 0.0, -1.0), + radiance=50.0, + ) + result_mm = build_light_dict(light, position_scale=0.001) + result_m = build_light_dict(light, position_scale=1.0) + # to_world は同じ center でも scale が違えば異なるはず + assert str(result_mm["to_world"]) != str(result_m["to_world"]) + + +# --------------------------------------------------------------------------- +# DirectionalLight +# --------------------------------------------------------------------------- + + +def test_directional_light_emitter_type_is_directional(): + """DirectionalLight から生成される dict の type が "directional" であること. + + 仕様: DirectionalLight → {"type": "directional", ...} (SPEC_01 scene/lights 責務) + """ + light = DirectionalLight( + id="sun", + type="directional", + direction=(0.0, 0.0, -1.0), + irradiance=1.0, + ) + result = build_light_dict(light, position_scale=0.001) + assert result["type"] == "directional" + + +def test_directional_light_direction_preserved(): + """DirectionalLight の direction がそのまま dict に保持されること. + + 仕様: DirectionalLight → {"type": "directional", "direction": [...], ...} + (SPEC_02 lights セクション) + """ + light = DirectionalLight( + id="sun", + type="directional", + direction=(0.0, -1.0, -1.0), + irradiance=1.0, + ) + result = build_light_dict(light, position_scale=0.001) + assert result["direction"] == pytest.approx([0.0, -1.0, -1.0]) + + +def test_directional_light_scalar_irradiance_expands_to_rgb(): + """スカラ irradiance が RGB 3 要素リストに展開されること. + + 仕様: irradiance スカラ可 (SPEC_02 lights セクション) + """ + light = DirectionalLight( + id="sun", + type="directional", + direction=(0.0, 0.0, -1.0), + irradiance=3.0, + ) + result = build_light_dict(light, position_scale=0.001) + assert result["irradiance"]["type"] == "rgb" + assert result["irradiance"]["value"] == pytest.approx([3.0, 3.0, 3.0]) + + +def test_directional_light_rgb_irradiance_preserved(): + """RGB irradiance がそのまま保持されること. + + 仕様: irradiance: [R, G, B] (SPEC_02 lights セクション) + """ + light = DirectionalLight( + id="sun", + type="directional", + direction=(0.0, 0.0, -1.0), + irradiance=(1.0, 0.8, 0.6), + ) + result = build_light_dict(light, position_scale=0.001) + assert result["irradiance"]["value"] == pytest.approx([1.0, 0.8, 0.6]) diff --git a/tests/scene/test_materials.py b/tests/scene/test_materials.py new file mode 100644 index 0000000..e33cf84 --- /dev/null +++ b/tests/scene/test_materials.py @@ -0,0 +1,161 @@ +"""scene/materials.py の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(materials セクション) +- docs/03_PLAN/PLAN_02_開発ステップ.md(Step 4: scene/materials.py の役割) + +build_material_dict の戻り値 dict 構造のみを検証する.Mitsuba の load_dict は呼ばない. +""" + +from __future__ import annotations + +import pytest + +from sm_tias_lightsim.scene.materials import build_material_dict +from sm_tias_lightsim.scene.schema import ( + DielectricMaterial, + DiffuseMaterial, + ThinDielectricMaterial, +) + +# --------------------------------------------------------------------------- +# DiffuseMaterial +# --------------------------------------------------------------------------- + + +def test_diffuse_material_type_is_diffuse(): + """DiffuseMaterial の dict type が "diffuse" であること. + + 仕様: type: diffuse → Mitsuba BSDF "diffuse" (SPEC_02 materials セクション) + """ + mat = DiffuseMaterial(type="diffuse", reflectance=0.5) + result = build_material_dict(mat) + assert result["type"] == "diffuse" + + +def test_diffuse_scalar_reflectance_expands_to_rgb(): + """スカラ reflectance が RGB 3 要素リストに展開されること. + + 仕様: reflectance スカラ可 (SPEC_02 materials セクション white_lambert: 0.95 例) + """ + mat = DiffuseMaterial(type="diffuse", reflectance=0.95) + result = build_material_dict(mat) + assert result["reflectance"]["type"] == "rgb" + assert result["reflectance"]["value"] == pytest.approx([0.95, 0.95, 0.95]) + + +def test_diffuse_rgb_reflectance_preserved(): + """RGB reflectance がそのまま保持されること. + + 仕様: reflectance: [0.05, 0.05, 0.05] の RGB 表記 (SPEC_02 materials セクション) + """ + mat = DiffuseMaterial(type="diffuse", reflectance=(0.05, 0.05, 0.05)) + result = build_material_dict(mat) + assert result["reflectance"]["type"] == "rgb" + assert result["reflectance"]["value"] == pytest.approx([0.05, 0.05, 0.05]) + + +def test_diffuse_reflectance_has_rgb_type_key(): + """DiffuseMaterial の reflectance dict に type="rgb" キーが存在すること. + + 仕様: Mitsuba RGB スペクトル型の構造 (SPEC_01 scene/materials 責務) + """ + mat = DiffuseMaterial(type="diffuse", reflectance=1.0) + result = build_material_dict(mat) + assert "type" in result["reflectance"] + assert "value" in result["reflectance"] + + +# --------------------------------------------------------------------------- +# DielectricMaterial +# --------------------------------------------------------------------------- + + +def test_dielectric_material_type_is_dielectric(): + """DielectricMaterial の dict type が "dielectric" であること. + + 仕様: type: dielectric → Mitsuba BSDF "dielectric" (SPEC_02 materials セクション) + """ + mat = DielectricMaterial(type="dielectric", int_ior=1.5, ext_ior=1.0) + result = build_material_dict(mat) + assert result["type"] == "dielectric" + + +def test_dielectric_int_ior_in_dict(): + """DielectricMaterial の int_ior が dict に格納されること. + + 仕様: dielectric → {"type": "dielectric", "int_ior": ..., "ext_ior": ...} + (SPEC_01 scene/materials 責務) + """ + mat = DielectricMaterial(type="dielectric", int_ior=1.5, ext_ior=1.0) + result = build_material_dict(mat) + assert result["int_ior"] == pytest.approx(1.5) + + +def test_dielectric_ext_ior_in_dict(): + """DielectricMaterial の ext_ior が dict に格納されること. + + 仕様: dielectric → {"type": "dielectric", "int_ior": ..., "ext_ior": ...} + (SPEC_01 scene/materials 責務) + """ + mat = DielectricMaterial(type="dielectric", int_ior=1.5, ext_ior=1.0) + result = build_material_dict(mat) + assert result["ext_ior"] == pytest.approx(1.0) + + +def test_dielectric_ior_values_preserved(): + """DielectricMaterial の IOR 値が正確に dict に格納されること. + + 仕様: PMMA 屈折率 int_ior: 1.49 例 (SPEC_02 materials セクション) + """ + mat = DielectricMaterial(type="dielectric", int_ior=1.49, ext_ior=1.0003) + result = build_material_dict(mat) + assert result["int_ior"] == pytest.approx(1.49) + assert result["ext_ior"] == pytest.approx(1.0003) + + +# --------------------------------------------------------------------------- +# ThinDielectricMaterial +# --------------------------------------------------------------------------- + + +def test_thindielectric_material_type_is_thindielectric(): + """ThinDielectricMaterial の dict type が "thindielectric" であること. + + 仕様: type: thindielectric → Mitsuba BSDF "thindielectric" (SPEC_02 materials セクション) + """ + mat = ThinDielectricMaterial(type="thindielectric", int_ior=1.49, ext_ior=1.0) + result = build_material_dict(mat) + assert result["type"] == "thindielectric" + + +def test_thindielectric_int_ior_in_dict(): + """ThinDielectricMaterial の int_ior が dict に格納されること. + + 仕様: thindielectric → {"type": "thindielectric", "int_ior": ..., "ext_ior": ...} + (SPEC_01 scene/materials 責務) + """ + mat = ThinDielectricMaterial(type="thindielectric", int_ior=1.49, ext_ior=1.0) + result = build_material_dict(mat) + assert result["int_ior"] == pytest.approx(1.49) + + +def test_thindielectric_ext_ior_in_dict(): + """ThinDielectricMaterial の ext_ior が dict に格納されること. + + 仕様: thindielectric → {"type": "thindielectric", "int_ior": ..., "ext_ior": ...} + (SPEC_01 scene/materials 責務) + """ + mat = ThinDielectricMaterial(type="thindielectric", int_ior=1.49, ext_ior=1.0) + result = build_material_dict(mat) + assert result["ext_ior"] == pytest.approx(1.0) + + +def test_thindielectric_does_not_have_reflectance(): + """ThinDielectricMaterial の dict に reflectance キーが含まれないこと. + + 仕様: thindielectric は IOR のみで反射率を持たない (SPEC_02 materials セクション) + """ + mat = ThinDielectricMaterial(type="thindielectric", int_ior=1.49, ext_ior=1.0) + result = build_material_dict(mat) + assert "reflectance" not in result diff --git a/tests/scene/test_sensors.py b/tests/scene/test_sensors.py new file mode 100644 index 0000000..d605fa3 --- /dev/null +++ b/tests/scene/test_sensors.py @@ -0,0 +1,170 @@ +"""scene/sensors.py の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(sensor セクション) +- docs/03_PLAN/PLAN_02_開発ステップ.md(Step 4: scene/sensors.py の役割) + +build_sensor_dict の戻り値 dict 構造のみを検証する.Mitsuba の load_dict は呼ばない. +ただし mi.ScalarTransform4f を内部で使うため mitsuba のインポートは発生する. +""" + +from __future__ import annotations + +import pytest + +from sm_tias_lightsim.scene.schema import FilmConfig, SensorConfig +from sm_tias_lightsim.scene.sensors import build_sensor_dict + + +def _make_sensor( + *, + position=(0.0, 0.0, 100.0), + target=(0.0, 0.0, 0.0), + up=(0.0, 1.0, 0.0), + fov_deg=60.0, + width=640, + height=480, + rfilter="gaussian", +) -> SensorConfig: + """テスト用 SensorConfig を組み立てるヘルパ.""" + return SensorConfig( + type="perspective", + position=position, + target=target, + up=up, + fov_deg=fov_deg, + film=FilmConfig(width=width, height=height, rfilter=rfilter), + ) + + +# --------------------------------------------------------------------------- +# type +# --------------------------------------------------------------------------- + + +def test_sensor_type_is_perspective(): + """build_sensor_dict の戻り値 type が "perspective" であること. + + 仕様: sensor.type は perspective のみ対応 (SPEC_02 sensor セクション) + """ + sensor = _make_sensor() + result = build_sensor_dict(sensor, position_scale=0.001) + assert result["type"] == "perspective" + + +# --------------------------------------------------------------------------- +# fov +# --------------------------------------------------------------------------- + + +def test_sensor_fov_matches_fov_deg(): + """fov_deg がそのまま fov キーに格納されること. + + 仕様: fov_deg → fov (SPEC_02 sensor セクション) + """ + sensor = _make_sensor(fov_deg=45.0) + result = build_sensor_dict(sensor, position_scale=0.001) + assert result["fov"] == pytest.approx(45.0) + + +# --------------------------------------------------------------------------- +# film +# --------------------------------------------------------------------------- + + +def test_sensor_film_width_and_height(): + """film の width / height が正しく dict に格納されること. + + 仕様: film.width, film.height (SPEC_02 sensor セクション) + """ + sensor = _make_sensor(width=1920, height=1080) + result = build_sensor_dict(sensor, position_scale=0.001) + assert result["film"]["width"] == 1920 + assert result["film"]["height"] == 1080 + + +def test_sensor_film_type_is_hdrfilm(): + """film の type が "hdrfilm" であること. + + 仕様: Mitsuba の HDR フィルム型 (SPEC_01 render/output 責務) + """ + sensor = _make_sensor() + result = build_sensor_dict(sensor, position_scale=0.001) + assert result["film"]["type"] == "hdrfilm" + + +def test_sensor_film_rfilter_gaussian(): + """rfilter が "gaussian" のとき rfilter dict の type が "gaussian" になること. + + 仕様: film.rfilter (SPEC_02 sensor セクション) + """ + sensor = _make_sensor(rfilter="gaussian") + result = build_sensor_dict(sensor, position_scale=0.001) + assert result["film"]["rfilter"]["type"] == "gaussian" + + +def test_sensor_film_rfilter_box(): + """rfilter が "box" のとき rfilter dict の type が "box" になること. + + 仕様: film.rfilter: gaussian | box | tent (SPEC_02 sensor セクション FilmConfig) + """ + sensor = _make_sensor(rfilter="box") + result = build_sensor_dict(sensor, position_scale=0.001) + assert result["film"]["rfilter"]["type"] == "box" + + +# --------------------------------------------------------------------------- +# to_world +# --------------------------------------------------------------------------- + + +def test_sensor_to_world_key_exists(): + """build_sensor_dict の戻り値に to_world キーが存在すること. + + 仕様: perspective sensor は to_world で視点変換を持つ (SPEC_01 scene/sensors 責務) + """ + sensor = _make_sensor() + result = build_sensor_dict(sensor, position_scale=0.001) + assert "to_world" in result + + +def test_sensor_position_scale_affects_to_world(): + """position_scale が異なると to_world が変わること(文字列化で差分確認). + + 仕様: position_scale で position/target が変換される (SPEC_01 scene/sensors 責務) + """ + sensor = _make_sensor(position=(0.0, 0.0, 100.0), target=(0.0, 0.0, 0.0)) + result_mm = build_sensor_dict(sensor, position_scale=0.001) + result_m = build_sensor_dict(sensor, position_scale=1.0) + assert str(result_mm["to_world"]) != str(result_m["to_world"]) + + +# --------------------------------------------------------------------------- +# sampler +# --------------------------------------------------------------------------- + + +def test_sensor_sampler_type_is_independent(): + """sampler の type が "independent" であること. + + 仕様: spp は render 時に渡す (runner.py 設計) + """ + sensor = _make_sensor() + result = build_sensor_dict(sensor, position_scale=0.001) + assert result["sampler"]["type"] == "independent" + + +# --------------------------------------------------------------------------- +# 境界値: 様々な position_scale +# --------------------------------------------------------------------------- + + +def test_sensor_position_scale_1_leaves_position_in_to_world(): + """position_scale=1.0 のとき position_scale=0.001 と to_world が異なること. + + 座標が 1000 倍差があれば to_world 行列も異なるはず. + """ + sensor_mm = _make_sensor(position=(0.0, 0.0, 500.0), target=(0.0, 0.0, 0.0)) + result_mm = build_sensor_dict(sensor_mm, position_scale=0.001) + result_m = build_sensor_dict(sensor_mm, position_scale=1.0) + assert str(result_mm["to_world"]) != str(result_m["to_world"])