Newer
Older
SmTIAS-LightSim / tests / scene / test_audit_normals.py
"""シェーディング法線アーティファクト(TEST_12)の再発防止テスト.

仕様出典:
- docs/06_TEST/TEST_12_シェーディング法線アーティファクトの発見と修正.md
- docs/06_TEST/TEST_13_物理モデル健全性の一括監査.md

TEST_12 のバグは「法線のない PLY に対して Mitsuba がスムーズ頂点法線を自動生成し,
エッジ上にしか頂点を持たない巨大三角形の平面が約 47 度傾いた法線で描画される」もので,
エラーも警告も出さずに CoV の 6 割超を占める系統誤差を生んだ.

`scene/builder.py` は PLY shape に `face_normals: True` を渡してこれを防いでいるが,
既存テストはその**キーの存在**しか検証していない.本モジュールは実際に Mitsuba へ
シーンを読み込み,レイを飛ばして**シェーディング法線が幾何法線と一致することを実測**する.

負のコントロール(`test_..._detects_smooth_normal_contamination`)で,
`face_normals` を外すと本テストが実際に大きな逸脱を検出することも確認している.
検出できない検査は再発防止にならないため,この対照実験を必ず維持すること.
"""

from __future__ import annotations

import shutil
from pathlib import Path

import numpy as np
import pytest
import trimesh
import yaml

from sm_tias_lightsim.scene.audit import audit_scene_normals
from sm_tias_lightsim.scene.builder import build_scene
from sm_tias_lightsim.scene.schema import load_scene_yaml

# 箱メッシュは 12 枚の巨大三角形からなり,全 8 頂点が法線の異なる 3 面に共有される.
# TEST_12 の評価板(裏面 26 枚の巨大三角形・内部頂点ゼロ)と同じ病理を最小構成で再現する.
_BOX_SIZE_MM = 100.0

# 空洞内部の観測点(m 単位).箱の中心付近から全方向にレイを飛ばす.
_INTERIOR_ORIGINS = np.array([[0.0, 0.0, 0.0], [0.01, 0.0, 0.0], [0.0, 0.01, -0.01]], dtype=float)

# 面法線が使われていれば逸脱は浮動小数点誤差のみ(実測 0.05 度未満)
_FACE_NORMAL_TOLERANCE_DEG = 0.5


def _build_box_cavity_scene(tmp_path: Path):
    """内向き法線の箱(空洞)だけからなる最小シーンを構築する.

    Returns:
        (scene_dict, tempdir) のタプル.呼び出し側が tempdir を削除する.
    """
    box = trimesh.creation.box(extents=[_BOX_SIZE_MM] * 3)
    # 空洞の内壁として使うため面を反転し,法線を内側へ向ける
    box.faces = box.faces[:, ::-1]
    stl_path = tmp_path / "box.stl"
    box.export(str(stl_path))

    cfg = {
        "name": "box_cavity",
        "description": "シェーディング法線の再発防止テスト用の最小空洞",
        "geometry": {
            "meshes": [{"id": "box", "path": str(stl_path), "unit": "mm", "material": "wall"}]
        },
        "materials": {"wall": {"type": "diffuse", "reflectance": [0.85, 0.85, 0.85]}},
        "lights": [
            {
                "id": "src",
                "type": "point",
                "position": [0.0, 0.0, 0.0],
                "intensity": [1.0, 1.0, 1.0],
                "profile": "isotropic",
            }
        ],
        "sensor": {
            "type": "perspective",
            "position": [0.0, 0.0, -10.0],
            "target": [0.0, 0.0, 0.0],
            "up": [0.0, 1.0, 0.0],
            "fov_deg": 30.0,
            "film": {"width": 8, "height": 8, "rfilter": "box"},
        },
        "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 64, "spp": 1},
    }
    yaml_path = tmp_path / "scene.yaml"
    yaml_path.write_text(yaml.safe_dump(cfg, allow_unicode=True), encoding="utf-8")

    return build_scene(load_scene_yaml(yaml_path), project_root=tmp_path)


def _audit(scene_dict) -> object:
    """scene dict を読み込んで法線監査を実行する."""
    import mitsuba as mi

    scene = mi.load_dict(scene_dict)
    return audit_scene_normals(scene, _INTERIOR_ORIGINS, rays_per_origin=200, seed=0)


def test_shading_normals_match_geometric_normals(tmp_path):
    """builder が構築したシーンでシェーディング法線が幾何法線と一致すること.

    仕様: face_normals=True によりスムーズ頂点法線の自動生成を無効化する(TEST_12 の修正).
    """
    scene_dict, tempdir = _build_box_cavity_scene(tmp_path)
    try:
        result = _audit(scene_dict)
    finally:
        shutil.rmtree(tempdir, ignore_errors=True)

    assert result.hits > 0, "レイが 1 本も命中していない.テストシーンが不正"
    assert result.max_shading_normal_deviation_deg < _FACE_NORMAL_TOLERANCE_DEG


def test_audit_detects_smooth_normal_contamination(tmp_path):
    """face_normals を外すと監査が大きな法線逸脱を検出すること(負のコントロール).

    この対照実験が通らなくなったら,本テスト群は TEST_12 の再発を検出できていない.
    """
    scene_dict, tempdir = _build_box_cavity_scene(tmp_path)
    try:
        del scene_dict["mesh_box"]["face_normals"]
        result = _audit(scene_dict)
    finally:
        shutil.rmtree(tempdir, ignore_errors=True)

    # 箱のエッジ頂点における平均化法線は面法線から大きく傾く(TEST_12 では約 47 度)
    assert result.max_shading_normal_deviation_deg > 10.0


def test_no_backface_hits_from_cavity_interior(tmp_path):
    """空洞内部から見て裏向きの面が無いこと.

    裏向きの面は片面 BSDF では無警告で真っ黒になる(twosided ラップで救済済みだが,
    裏返り自体はメッシュのモデリング誤りの兆候なので検出する).
    """
    scene_dict, tempdir = _build_box_cavity_scene(tmp_path)
    try:
        result = _audit(scene_dict)
    finally:
        shutil.rmtree(tempdir, ignore_errors=True)

    assert result.backface_hits == 0


def test_audit_detects_flipped_mesh(tmp_path):
    """メッシュを裏返すと監査が裏面ヒットを検出すること(負のコントロール)."""
    box = trimesh.creation.box(extents=[_BOX_SIZE_MM] * 3)
    # 反転しない=法線が外向きのまま=空洞内部から見ると全て裏面
    stl_path = tmp_path / "box_outward.stl"
    box.export(str(stl_path))

    cfg = {
        "name": "box_outward",
        "description": "裏返りメッシュの検出テスト",
        "geometry": {
            "meshes": [{"id": "box", "path": str(stl_path), "unit": "mm", "material": "wall"}]
        },
        "materials": {"wall": {"type": "diffuse", "reflectance": [0.85, 0.85, 0.85]}},
        "lights": [
            {
                "id": "src",
                "type": "point",
                "position": [0.0, 0.0, 0.0],
                "intensity": [1.0, 1.0, 1.0],
                "profile": "isotropic",
            }
        ],
        "sensor": {
            "type": "perspective",
            "position": [0.0, 0.0, -10.0],
            "target": [0.0, 0.0, 0.0],
            "up": [0.0, 1.0, 0.0],
            "fov_deg": 30.0,
            "film": {"width": 8, "height": 8, "rfilter": "box"},
        },
        "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 64, "spp": 1},
    }
    yaml_path = tmp_path / "scene.yaml"
    yaml_path.write_text(yaml.safe_dump(cfg, allow_unicode=True), encoding="utf-8")

    scene_dict, tempdir = build_scene(load_scene_yaml(yaml_path), project_root=tmp_path)
    try:
        result = _audit(scene_dict)
    finally:
        shutil.rmtree(tempdir, ignore_errors=True)

    assert result.backface_hits == result.hits


@pytest.mark.slow
def test_baseline_scene_has_clean_normals():
    """実運用シーン(configs/scene_baseline.yaml)の法線が健全であること.

    合成メッシュではなく実際の設計 STL 8 個を読み込んで検証する.
    STL の差し替えや builder の変更で法線規約が崩れた場合にここで検出する.
    """
    project_root = Path(__file__).resolve().parents[2] / "configs"
    scene_yaml = project_root / "scene_baseline.yaml"
    if not scene_yaml.exists():
        pytest.skip("基準設計シーンが存在しない環境ではスキップする")

    scene_dict, tempdir = build_scene(load_scene_yaml(scene_yaml), project_root=project_root)
    try:
        # 空洞内部(LED の z=-66mm と評価板の z=-4.5mm の間)に観測点を配置する
        origins = np.array(
            [
                [0.0, 0.0, -0.050],
                [0.0, 0.0, -0.030],
                [0.020, 0.020, -0.040],
                [-0.020, -0.020, -0.040],
            ],
            dtype=float,
        )
        result = audit_scene_normals(scene_dict_to_scene(scene_dict), origins, 300, seed=0)
    finally:
        shutil.rmtree(tempdir, ignore_errors=True)

    assert result.hits > 0
    assert result.max_shading_normal_deviation_deg < _FACE_NORMAL_TOLERANCE_DEG
    assert result.backface_hits == 0


def scene_dict_to_scene(scene_dict):
    """scene dict を Mitsuba シーンに読み込む(テスト用ヘルパ)."""
    import mitsuba as mi

    return mi.load_dict(scene_dict)