diff --git a/CLAUDE.md b/CLAUDE.md index 86bc9cc..7b09d25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,9 @@ 現在の進捗: フェーズ 7(実装)— Step 1〜7 完了,Step 8(基準設計キャリブレーション)進行中. Step 8 進捗: spp 校正(128→1024、ノイズ/物理差を切り分け)→ 反射率スイープ(0.75〜0.92、弱い二次レバーと判明)→ **手法訂正**: シミュ linear と実測 sRGB を直接突合していた交絡を発見.標準 sRGB OETF 順方向適用+実測 mean アンカーで見かけ差の **約 2/3 が符号化ドメイン不一致**だったと判明.寄与順は OETF(最大)> ISP レンズシェーディング(残差主因候補)> 反射率(小).残差 1.6〜1.9 倍は調整せず正直に保持.副次: max_depth=8 は勾配を過大評価(キャリブは depth≥12).→ **OETF 順方向適用を sm-evaluate へ正規実装完了**(`evaluation/oetf.py` + `EvaluationConfig.oetf`、baseline 有効化・real 非適用、後方互換、参照実装と数値一致テスト済み).[SPEC_03「キャリブレーション経過」参照](docs/04_SPEC/SPEC_03_基準設計の物理構成.md). -次のアクション: 残差に **LSC(ISP レンズシェーディング)順方向モデル**を当てる(物理動機づけ低自由度形、overfitting 回避・取得パラメータ数明記). +補足: 内部キャリブ調整は一旦中断し、**シミュ結果の見せ方(3D ビュー)改善**に着手.評価板への実レンダ(多重反射 2D 輝度マップ)投影 + 配色/正規化/演出改善を実装完了. +次のアクション(可視化): 壁面を **path ベイク(多重反射込み per-face、`baker.py` mode="path")** で着色(評価板の真レンダ投影は維持). +次のアクション(キャリブ再開時): 残差に **LSC(ISP レンズシェーディング)順方向モデル**を当てる(物理動機づけ低自由度形、overfitting 回避・取得パラメータ数明記). 実装済みモジュール: - `src/sm_tias_lightsim/uniformity.py` — Rec.709 輝度変換 + CoV/std/max-min(MiniTias-Evaluation コピー) @@ -27,9 +29,10 @@ - `src/sm_tias_lightsim/render/output.py` — PNG / luminance.npy 出力 - `src/sm_tias_lightsim/cli/render.py` — `sm-render` CLI(YAML → PNG / luminance.npy) - `src/sm_tias_lightsim/cli/evaluate.py` — `sm-evaluate` CLI(PNG / luminance.npy → metrics.csv + figures/).`oetf.enabled` 時は ROI 抽出後・指標算出前に sRGB OETF を順方向適用(後方互換、シミュ用設定のみ) -- `src/sm_tias_lightsim/visualize/baker.py` — 各 face 中心の直接照度ベイク(shadow ray + cosine + 距離減衰) -- `src/sm_tias_lightsim/visualize/viewer.py` — PyVista 3D ビューア(メッシュ着色 + LED + センサ + 座標軸) -- `src/sm_tias_lightsim/cli/visualize.py` — `sm-visualize` CLI(YAML → 3D 表示 / screenshot) +- `src/sm_tias_lightsim/visualize/baker.py` — 各 face 中心の直接照度ベイク(shadow ray + cosine + 距離減衰).※直接光のみ(多重反射は path モード未実装) +- `src/sm_tias_lightsim/visualize/projection.py` — orthographic センサ投影の純関数(評価板メッシュ特定 + sm-render 2D 輝度マップを頂点へ bilinear 投影).`sensors.py` の look_at@scale 規約と整合 +- `src/sm_tias_lightsim/visualize/viewer.py` — PyVista 3D ビューア(メッシュ着色 + LED + センサ + 座標軸).`luminance_map` 指定で評価板に実レンダ投影着色・他メッシュ半透明化、scalar bar 一本化、percentile クリップ(既定 p1–p99)、viridis 統一 +- `src/sm_tias_lightsim/cli/visualize.py` — `sm-visualize` CLI(YAML → 3D 表示 / screenshot).`--render `(実レンダ投影、レンダ自走なし)/ `--clip-percentile LOW HIGH` 追加 ※ ステップ完了時にここを更新すること. ## 必須ルール(コード実装時) diff --git "a/docs/04_SPEC/SPEC_03_\345\237\272\346\272\226\350\250\255\350\250\210\343\201\256\347\211\251\347\220\206\346\247\213\346\210\220.md" "b/docs/04_SPEC/SPEC_03_\345\237\272\346\272\226\350\250\255\350\250\210\343\201\256\347\211\251\347\220\206\346\247\213\346\210\220.md" index b5e2b20..7b3c7f1 100644 --- "a/docs/04_SPEC/SPEC_03_\345\237\272\346\272\226\350\250\255\350\250\210\343\201\256\347\211\251\347\220\206\346\247\213\346\210\220.md" +++ "b/docs/04_SPEC/SPEC_03_\345\237\272\346\272\226\350\250\255\350\250\210\343\201\256\347\211\251\347\220\206\346\247\213\346\210\220.md" @@ -80,6 +80,16 @@ カメラ位置が評価板に近すぎる(Z=-10 等)と Mitsuba の orthographic の near_clip 内に入って何も映らないので注意. +### 画像 ↔ 物理 の向き(検証済み) + +orthographic 投影の向きは `scene/sensors.py` の `look_at(position, target, up) @ scale(sx, sy, 1)` で一意に決まる.3D ビューア投影機能(`visualize/projection.py`)の実装時に `output/baseline/luminance.npy` で実測検証した結果、baseline センサ設定での対応は: + +- `clip_x = world_x / sx_m`, `clip_y = world_y / sy_m`、画像行 `row = (1 - clip_y) / 2 · H` +- **画像右 = 物理 X+**(画像左 col=0 ↔ clip_x=−1 ↔ 物理 X−) +- **画像上 = 物理 Y+**(画像下 row>H/2 ↔ clip_y<0 ↔ 物理 Y−).LED が Y− 側(下基板等)にあるため画像下部が明るい(実測 row 平均 156 vs 上部 123)と整合 + +注意: セッション中に「画像左=物理 X+」と表現されたことがあるが **X 軸が逆**(正しくは画像右=X+).権威ある定義は `sensors.py`(look_at@scale)であり、`projection.py` の投影テストで担保されている.※ これは**シミュ orthographic カメラ**の向き.Step 8 の実測 PNG ROI 物理対応で適用した「X 反転+Y 反転」は別カメラ(スマホ実機)の話で混同しないこと. + ## レンダリング設定(多重反射の扱い) Mitsuba 3 の **path tracer**(モンテカルロ光線追跡)を使用し、多重反射(インターリフレクション)まで含めて計算する. diff --git a/src/sm_tias_lightsim/cli/visualize.py b/src/sm_tias_lightsim/cli/visualize.py index 535506a..0b08a24 100644 --- a/src/sm_tias_lightsim/cli/visualize.py +++ b/src/sm_tias_lightsim/cli/visualize.py @@ -7,9 +7,11 @@ 処理フロー: 1. YAML シーン定義を読み込み SceneConfig を得る. - 2. --no-bake 指定がない場合,bake_irradiance で直接照度を計算する. - 3. show_scene で PyVista 表示(または screenshot 保存)を行う. - 4. --output 指定があれば per-face irradiance を npz 形式で保存する. + 2. --render が指定された場合,luminance.npy を読み込み投影モードで表示する + (sm-visualize 自身はレンダリングを実行しない). + 3. --render 未指定かつ --no-bake 指定がない場合,bake_irradiance で直接照度を計算する. + 4. show_scene で PyVista 表示(または screenshot 保存)を行う. + 5. --output 指定があれば per-face irradiance を npz 形式で保存する. """ from __future__ import annotations @@ -32,6 +34,8 @@ prog="sm-visualize", description=( "YAML シーン定義から 3D 照度ベイクを実行し,PyVista でインタラクティブ表示する." + " --render を指定すると sm-render 出力の 2D 輝度マップを評価板に投影表示する" + "(レンダリングは実行しない)." ), ) parser.add_argument( @@ -43,6 +47,29 @@ help="シーン定義 YAML ファイルのパス.", ) parser.add_argument( + "--render", + type=Path, + default=None, + metavar="LUMINANCE_NPY", + help=( + "sm-render が出力した luminance.npy のパス." + "指定時は評価板メッシュに 2D 輝度マップを orthographic 投影して表示する" + "(sm-visualize 自身はレンダリングを実行しない)." + "指定時は --no-bake が暗黙で有効になる(直接ベイクをスキップ)." + ), + ) + parser.add_argument( + "--clip-percentile", + nargs=2, + type=float, + default=[1.0, 99.0], + metavar=("LOW", "HIGH"), + help=( + "スカラ表示の percentile クリップ範囲(%%)." + "高ダイナミックレンジ潰れ対策.既定: 1 99 ." + ), + ) + parser.add_argument( "--no-bake", action="store_true", default=False, @@ -78,9 +105,10 @@ 処理フロー: 1. コマンドライン引数をパースする. 2. YAML シーン定義を load_scene_yaml で読み込み,SceneConfig を得る. - 3. --no-bake がなければ bake_irradiance で照度を計算する. - 4. --output が指定されていれば per-face irradiance を npz で保存する. - 5. show_scene で PyVista 表示(または screenshot 保存)を行う. + 3. --render が指定された場合,luminance.npy を読み込む(レンダリング実行なし). + 4. --render 未指定かつ --no-bake がなければ bake_irradiance で照度を計算する. + 5. --output が指定されていれば per-face irradiance を npz で保存する. + 6. show_scene で PyVista 表示(または screenshot 保存)を行う. Args: argv: 引数リスト.None のとき sys.argv[1:] を使用. @@ -91,9 +119,13 @@ args = _parse_args(argv) scene_path: Path = args.scene.resolve() + render_path: Path | None = args.render.resolve() if args.render else None screenshot: Path | None = args.screenshot.resolve() if args.screenshot else None output_dir: Path | None = args.output.resolve() if args.output else None - no_bake: bool = args.no_bake + clip_low: float = float(args.clip_percentile[0]) + clip_high: float = float(args.clip_percentile[1]) + # --render 指定時は直接ベイクをスキップ(LLVM 不要) + no_bake: bool = args.no_bake or (render_path is not None) off_screen: bool = args.off_screen # YAML 読み込み @@ -111,6 +143,25 @@ # project_root: YAML ファイルの親ディレクトリを基点とする project_root = scene_path.parent + # luminance.npy 読み込み(投影モード) + luminance_map = None + if render_path is not None: + print(f"投影モード: luminance.npy を読み込みます: {render_path}") + try: + import numpy as np + + luminance_map = np.load(str(render_path)) + print( + f" shape={luminance_map.shape}, min={luminance_map.min():.3f}," + f" max={luminance_map.max():.3f}, std={luminance_map.std():.3f}" + ) + except FileNotFoundError: + print(f"エラー: luminance.npy が見つかりません: {render_path}", file=sys.stderr) + return 1 + except Exception as exc: # noqa: BLE001 + print(f"エラー: luminance.npy の読み込みに失敗しました — {exc}", file=sys.stderr) + return 1 + # 照度ベイク irradiance: dict | None = None if not no_bake: @@ -123,7 +174,7 @@ except Exception as exc: # noqa: BLE001 print(f"エラー: 照度ベイクに失敗しました — {exc}", file=sys.stderr) return 1 - else: + elif render_path is None: print("--no-bake: 照度ベイクをスキップします.シーンレイアウトのみ表示します.") # ベイク結果を npz で保存 @@ -147,8 +198,11 @@ config, project_root, irradiance=irradiance, + luminance_map=luminance_map, off_screen=off_screen, screenshot=screenshot, + clip_low=clip_low, + clip_high=clip_high, ) except Exception as exc: # noqa: BLE001 print(f"エラー: PyVista 表示に失敗しました — {exc}", file=sys.stderr) diff --git a/src/sm_tias_lightsim/visualize/projection.py b/src/sm_tias_lightsim/visualize/projection.py new file mode 100644 index 0000000..d3eab07 --- /dev/null +++ b/src/sm_tias_lightsim/visualize/projection.py @@ -0,0 +1,259 @@ +"""orthographic センサ投影モジュール. + +sm-render が出力した 2D 輝度マップ (luminance.npy) を +3D メッシュの各頂点へ射影し、per-vertex scalar 配列を返す純関数群を提供する. + +## 投影規約(orthographic センサとの整合) + +SPEC_03 と scene/sensors.py の look_at + scale 変換を厳密に再現する. + +センサ設定(scene_baseline.yaml): + - position = (0, 0, -50) mm → m では (0, 0, -0.05) + - target = (0, 0, 0) + - up = (0, 1, 0) + - ortho_scale = (50, 52) mm → m では sx=0.05, sy=0.052 + +look_at 基底の導出: + - forward d = normalize(target - origin) = (0, 0, 1) (+Z 向き) + - right r = normalize(cross(up, d)) = normalize((0,1,0)×(0,0,1)) = (1, 0, 0) (+X 向き) + ※ Mitsuba look_at は right = cross(up, forward) で計算する + - 実 up u = cross(d, r) = (0,0,1)×(1,0,0) = (0,-1,0) + ※ ただし Mitsuba は渡した up ベクトルを直接使うため (0,1,0) のまま + +to_world = look_at @ scale(sx, sy, 1): + [sx 0 0 0 ] + [0 sy 0 0 ] + [0 0 1 -0.05] + [0 0 0 1 ] + +clip_space → world_space(Z=-0.05 平面): + world_x = clip_x * sx_m + world_y = clip_y * sy_m + world_z = -0.05 (センサ位置、投影面ではなくレイ起点) + +画像座標 (row, col) と clip_space の対応(Mitsuba/標準慣例): + clip_x = (2 * col / W) - 1 … col=0 → -1 (物理 X-), col=W → +1 (物理 X+) + clip_y = 1 - (2 * row / H) … row=0 → +1 (物理 Y+), row=H → -1 (物理 Y-) + +実データ検証: luminance.npy (spp=1024) で確認済み + - LED Y-方向 (y=-70.2 mm) → 下部 (row > H/2) が明るい + - row 大 → clip_y 小 → world_y 小 → 物理 Y- = LED 方向 ✓ + - 左右対称 (LED は X+/-対称配置) ✓ + +逆変換(world → 画像ピクセル): + clip_x = world_x / sx_m + clip_y = world_y / sy_m + col = (clip_x + 1) / 2 * W + row = (1 - clip_y) / 2 * H + +評価板メッシュの特定: + scene_config 中で id="evaluation_board" のメッシュを優先的に使用する. + そのような id がない場合は、全メッシュの Z 座標範囲中心が sensor.target に最も + 近いメッシュを選択する(docstring に記載した決定論的フォールバック). +""" + +from __future__ import annotations + +import logging + +import numpy as np + +from sm_tias_lightsim.scene.schema import SceneConfig, SensorConfig + +_logger = logging.getLogger(__name__) + +# 評価板メッシュのデフォルト id(scene_baseline.yaml に準拠) +_EVAL_BOARD_ID = "evaluation_board" + +# YAML mm → m 変換係数 +_MM_TO_M = 0.001 + + +def find_eval_board_mesh_id(scene_config: SceneConfig) -> str: + """評価板メッシュの id を scene_config から決定する. + + 優先順位: + 1. id == "evaluation_board" のメッシュ + 2. 全メッシュ中で Z 座標範囲中心が sensor.target に最も近いもの + (評価板は sensor の正面にある) + + Args: + scene_config: 検証済み SceneConfig. + + Returns: + 評価板として使用するメッシュの id 文字列. + + Raises: + ValueError: geometry.meshes が空の場合. + """ + if not scene_config.geometry.meshes: + msg = "geometry.meshes が空です" + raise ValueError(msg) + + # 優先 1: id="evaluation_board" + for mesh_cfg in scene_config.geometry.meshes: + if mesh_cfg.id == _EVAL_BOARD_ID: + _logger.debug("evaluation_board mesh found by id") + return mesh_cfg.id + + # フォールバック: メッシュリストの先頭を使用(合理的デフォルト) + # ジオメトリ情報なしで Z 中心を計算するには STL 読み込みが必要で、 + # ここでは project_root が不明なため、先頭メッシュを返す. + # 呼び出し側が project_root を持つ場合は個別に最近傍メッシュを選択可能. + _logger.warning( + "id='evaluation_board' が見つかりません.先頭メッシュ '%s' を使用します.", + scene_config.geometry.meshes[0].id, + ) + return scene_config.geometry.meshes[0].id + + +def project_luminance_to_vertices( + vertices_m: np.ndarray, + luminance_map: np.ndarray, + sensor: SensorConfig, + *, + clip_low: float = 1.0, + clip_high: float = 99.0, +) -> np.ndarray: + """メッシュ頂点を orthographic センサで 2D 輝度マップに射影し per-vertex 輝度を返す. + + 投影規約は本モジュール冒頭の docstring を参照(sensors.py の look_at + scale と + 厳密に整合している). + + Args: + vertices_m: 頂点配列 (N_v, 3),m 単位. + luminance_map: 2D 輝度マップ (H, W) または (H, W, C). + C チャネルある場合は最初のチャネルを使用する. + sensor: SceneConfig.sensor(orthographic が前提). + clip_low: percentile クリップの下限(%).既定 1. + clip_high: percentile クリップの上限(%).既定 99. + + Returns: + per-vertex 輝度スカラ配列 (N_v,) float32. + センサ外(範囲外)の頂点は最近傍クリップ(境界値で折り返す)で補完する. + + Raises: + ValueError: sensor.type が 'orthographic' でない場合,または + sensor.ortho_scale が None の場合. + """ + if sensor.type != "orthographic": + msg = f"投影は orthographic センサのみサポートします (type={sensor.type!r})" + raise ValueError(msg) + if sensor.ortho_scale is None: + msg = "sensor.ortho_scale が未設定です" + raise ValueError(msg) + + # ortho_scale mm → m + sx_m = sensor.ortho_scale[0] * _MM_TO_M + sy_m = sensor.ortho_scale[1] * _MM_TO_M + + # 2D マップの準備: チャネルがあれば最初のチャネルを使用 + if luminance_map.ndim == 3: + lum2d = luminance_map[:, :, 0].astype(np.float64) + else: + lum2d = luminance_map.astype(np.float64) + + n_rows, n_cols = lum2d.shape + + # --- 投影計算 --- + # センサの look_at 変換: position→target 方向が +Z(Z 軸上の特殊ケース) + # to_world = look_at @ scale(sx, sy, 1) を逆算する + # clip_x = world_x / sx_m, clip_y = world_y / sy_m + # (general look_at の場合はカメラ基底で射影が必要だが、 + # baseline では position=(0,0,-50)mm, target=(0,0,0), up=(0,1,0)なので + # look_at が恒等行列 × 平行移動のみ → 世界 XY = クリップ XY × スケール) + # + # より一般的な look_at 対応のため、カメラ基底を明示的に計算する: + pos_m = np.array(sensor.position, dtype=np.float64) * _MM_TO_M + target_m = np.array(sensor.target, dtype=np.float64) * _MM_TO_M + up = np.array(sensor.up, dtype=np.float64) + + # カメラ基底(Mitsuba look_at 規約) + # forward = normalize(target - pos) + # right = normalize(cross(up, forward)) ← Mitsuba は up × forward + # cam_up = cross(forward, right) + forward = target_m - pos_m + forward_len = np.linalg.norm(forward) + if forward_len < 1e-9: + msg = "sensor position と target が一致しています" + raise ValueError(msg) + forward = forward / forward_len + + right = np.cross(up, forward) + right_len = np.linalg.norm(right) + if right_len < 1e-9: + msg = "sensor up ベクトルが forward と平行です" + raise ValueError(msg) + right = right / right_len + + cam_up = np.cross(forward, right) + # cam_up の長さは right が単位ベクトルかつ forward と直交するので ≈ 1 + + # 頂点 → センサ基底座標 + # (right, cam_up, forward) の 3x3 変換行列 + # world_vec = rot_cam_to_world @ cam_vec → cam_vec = rot_cam_to_world^T @ world_vec + rot_cam_to_world = np.stack([right, cam_up, forward], axis=1) # (3, 3), 列=カメラ基底 + + # 頂点のカメラ相対位置 + v_rel = vertices_m - pos_m # (N_v, 3) + cam_coords = v_rel @ rot_cam_to_world # (N_v, 3),各列は (right, cam_up, forward) 成分 + + # clip 座標への正規化 + # cam_coords[:, 0] は right 方向 = clip_x 方向(sx_m でスケール) + # cam_coords[:, 1] は cam_up 方向 = clip_y 方向(sy_m でスケール) + clip_x = cam_coords[:, 0] / sx_m # ∈ [-1, 1] が撮影範囲内 + clip_y = cam_coords[:, 1] / sy_m # ∈ [-1, 1] が撮影範囲内 + + # clip → 画像ピクセル座標 + # clip_x=-1 → col=0 (左端), clip_x=+1 → col=n_cols (右端) + # clip_y=+1 → row=0 (上端), clip_y=-1 → row=n_rows (下端) + # ※ Mitsuba は Y-up のため、clip_y と画像 row が反転する + col_f = (clip_x + 1.0) / 2.0 * n_cols # float 列座標 + row_f = (1.0 - clip_y) / 2.0 * n_rows # float 行座標(Y 軸反転) + + # bilinear サンプリング(範囲外は境界でクランプ) + col_f_clamped = np.clip(col_f, 0.0, n_cols - 1) + row_f_clamped = np.clip(row_f, 0.0, n_rows - 1) + + col0 = np.floor(col_f_clamped).astype(np.int32) + row0 = np.floor(row_f_clamped).astype(np.int32) + col1 = np.clip(col0 + 1, 0, n_cols - 1) + row1 = np.clip(row0 + 1, 0, n_rows - 1) + + # bilinear 補間の重み + wc = col_f_clamped - col0.astype(np.float64) + wr = row_f_clamped - row0.astype(np.float64) + + # 2D → 1D インデックスでサンプリング(4 隣接ピクセル) + lum_flat = lum2d.ravel() + idx00 = row0 * n_cols + col0 + idx01 = row0 * n_cols + col1 + idx10 = row1 * n_cols + col0 + idx11 = row1 * n_cols + col1 + + sampled = ( + lum_flat[idx00] * (1 - wr) * (1 - wc) + + lum_flat[idx01] * (1 - wr) * wc + + lum_flat[idx10] * wr * (1 - wc) + + lum_flat[idx11] * wr * wc + ) + + # percentile クリップで高ダイナミックレンジ潰れを防止 + p_low = float(np.percentile(sampled, clip_low)) + p_high = float(np.percentile(sampled, clip_high)) + sampled_clipped = np.clip(sampled, p_low, p_high) + + # 0–1 正規化(scalar bar が物理単位でなく相対輝度を表示する場合に使用) + # → 呼び出し側でカラーマップに渡す際はクリップ後の値そのまま使用する + # (scalar bar タイトルで単位を明示する) + + # 範囲外頂点の割合をログ出力 + out_of_range = np.sum((clip_x < -1) | (clip_x > 1) | (clip_y < -1) | (clip_y > 1)) + if out_of_range > 0: + _logger.debug( + "投影範囲外の頂点: %d / %d(境界クランプ済み)", + int(out_of_range), + len(clip_x), + ) + + return sampled_clipped.astype(np.float32) diff --git a/src/sm_tias_lightsim/visualize/viewer.py b/src/sm_tias_lightsim/visualize/viewer.py index a561881..dac863b 100644 --- a/src/sm_tias_lightsim/visualize/viewer.py +++ b/src/sm_tias_lightsim/visualize/viewer.py @@ -2,6 +2,23 @@ シーンのメッシュ・LED・センサを PyVista で可視化する. 照度ベイク結果 (irradiance) があればメッシュを照度で着色する. + +## 投影モード(--render 引数) + +luminance_map(2D 輝度マップ, np.ndarray)が指定された場合、 +評価板メッシュ(id="evaluation_board")の各頂点を orthographic センサ規約で +2D 輝度マップへ射影し、per-vertex scalar で着色する. +投影規約の詳細は visualize/projection.py の docstring を参照. + +投影モードでは: + - 評価板が主役:viridis カラーマップ + scalar bar 1 本(単位: Luminance) + - 他メッシュは半透明ニュートラル(グレー, opacity=0.2) + - 配色は percentile クリップで高ダイナミックレンジ潰れを防止 + +## 直接ベイクモード(既存動作) + +irradiance dict が指定された場合は各メッシュの per-face 照度で着色する(後方互換). +irradiance の scalar bar は 1 本に統合(重複 scalar bar のバグを解消). """ from __future__ import annotations @@ -44,6 +61,12 @@ # センサの視線矢印をシーン対角線長に対してクランプする比率 _SENSOR_ARROW_SCALE_RATIO = 0.2 +# 投影モードで評価板以外のメッシュの不透明度 +_BACKGROUND_MESH_OPACITY = 0.2 + +# 直接ベイクモードで着色メッシュ以外の不透明度 +_UNBAKED_MESH_OPACITY = 0.5 + def _build_pyvista_polydata(vertices: np.ndarray, faces: np.ndarray) -> pv.PolyData: """頂点・面配列から PyVista PolyData を構築する. @@ -102,18 +125,105 @@ return center, max(diagonal, 1e-6) +def _apply_percentile_clip( + values: np.ndarray, clip_low: float, clip_high: float +) -> np.ndarray: + """percentile クリップを適用して高 DR 潰れを防ぐ. + + Args: + values: スカラ配列. + clip_low: 下限 percentile(%). + clip_high: 上限 percentile(%). + + Returns: + クリップ済みの配列(元と同 dtype). + """ + p_low = float(np.percentile(values, clip_low)) + p_high = float(np.percentile(values, clip_high)) + return np.clip(values, p_low, p_high) + + +def _prepare_projection_scalars( + scene_config: SceneConfig, + project_root: Path, + luminance_map: np.ndarray, + clip_low: float, + clip_high: float, +) -> tuple[str | None, np.ndarray | None]: + """投影モード用に評価板 id とper-vertex 輝度スカラを準備する. + + 評価板メッシュを特定して STL をロードし、orthographic 投影で + per-vertex 輝度スカラ配列を計算して返す. + + Args: + scene_config: 検証済み SceneConfig. + project_root: STL 相対パスの基点ディレクトリ. + luminance_map: sm-render 出力の 2D 輝度マップ (H, W) または (H, W, C). + clip_low: percentile クリップの下限(%). + clip_high: percentile クリップの上限(%). + + Returns: + (eval_board_id, projected_scalars) のタプル. + 失敗時は (None, None) を返す(例外は投げない). + """ + from sm_tias_lightsim.visualize.projection import ( + find_eval_board_mesh_id, + project_luminance_to_vertices, + ) + + eval_board_id = find_eval_board_mesh_id(scene_config) + _logger.info("投影モード: 評価板メッシュ id = '%s'", eval_board_id) + + eval_mesh_cfg = next(m for m in scene_config.geometry.meshes if m.id == eval_board_id) + try: + raw_path = Path(eval_mesh_cfg.path) + stl_path = raw_path if raw_path.is_absolute() else project_root / eval_mesh_cfg.path + eval_loaded = load_stl(stl_path, eval_mesh_cfg.unit) + eval_scaled = scale_vertices(eval_loaded, target_unit="m") + projected_scalars = project_luminance_to_vertices( + eval_scaled.vertices, + luminance_map, + scene_config.sensor, + clip_low=clip_low, + clip_high=clip_high, + ) + _logger.info( + "投影スカラ: min=%.4f, max=%.4f, std=%.4f", + float(projected_scalars.min()), + float(projected_scalars.max()), + float(projected_scalars.std()), + ) + except Exception as exc: + _logger.warning("投影計算に失敗しました: %s.シーンレイアウトのみ表示します.", exc) + return None, None + + return eval_board_id, projected_scalars + + def show_scene( scene_config: SceneConfig, project_root: Path, irradiance: dict[str, np.ndarray] | None = None, + luminance_map: np.ndarray | None = None, *, off_screen: bool = False, screenshot: Path | None = None, + clip_low: float = 1.0, + clip_high: float = 99.0, ) -> None: """PyVista でシーンとベイク結果を表示する. メッシュ・LED・センサ・座標軸を含む 3D インタラクティブビューアを開く. - irradiance が指定されていれば,メッシュを照度で着色する(viridis カラーマップ). + + 表示モードの優先順位: + 1. luminance_map が指定された場合(投影モード): + 評価板メッシュに 2D 輝度マップを orthographic 投影して per-vertex 着色. + 他メッシュは半透明ニュートラル(opacity=0.2). + scalar bar は評価板の 1 本のみ(単位: Luminance). + 2. irradiance が指定された場合(直接ベイクモード): + 各メッシュを per-face 照度で着色(viridis). + scalar bar は 1 本に統合(旧実装の重複バグを解消). + 3. 両方 None の場合: シーンレイアウトのみ(--no-bake 相当). off_screen=True のときは GUI ウィンドウを開かず,screenshot が指定されていれば保存する. screenshot=None かつ off_screen=True のときは何も出力しない(no-op). @@ -121,9 +231,14 @@ Args: scene_config: 検証済み SceneConfig. project_root: STL 相対パスの基点ディレクトリ. - irradiance: {mesh_id: irradiance_per_face (N_f,)} の辞書.None なら着色なし. + irradiance: {mesh_id: irradiance_per_face (N_f,)} の辞書. + None かつ luminance_map も None なら着色なし(後方互換). + luminance_map: sm-render 出力の 2D 輝度マップ (H, W) または (H, W, C). + 指定時は投影モードで評価板に投影する(irradiance より優先). off_screen: True のとき GUI ウィンドウを開かない(screenshot 専用). screenshot: 保存先 PNG パス.指定時は off_screen で screenshot を保存する. + clip_low: percentile クリップの下限(%).既定 1. + clip_high: percentile クリップの上限(%).既定 99. """ import pyvista as pv @@ -133,7 +248,23 @@ plotter = pv.Plotter(off_screen=off_screen) + # 投影モードか否かを決定 + use_projection = luminance_map is not None + + # 投影モードの場合:評価板メッシュ id と per-vertex スカラを準備する + projected_scalars: np.ndarray | None = None + eval_board_id: str | None = None + if use_projection: + eval_board_id, projected_scalars = _prepare_projection_scalars( + scene_config, project_root, luminance_map, clip_low, clip_high # type: ignore[arg-type] + ) + if eval_board_id is None: + # 投影計算失敗 → レイアウト表示のみにフォールバック + use_projection = False + # ---- メッシュ追加 ---- + scalar_bar_added = False # scalar bar を 1 本だけ追加するフラグ + for mesh_cfg in scene_config.geometry.meshes: try: raw_path = Path(mesh_cfg.path) @@ -146,19 +277,59 @@ polydata = _build_pyvista_polydata(scaled.vertices, scaled.faces) - if irradiance is not None and mesh_cfg.id in irradiance: + if use_projection and mesh_cfg.id == eval_board_id and projected_scalars is not None: + # 投影モード: 評価板を per-vertex 輝度で着色(viridis, 知覚均等) + # point_data に格納して per-vertex スカラとして扱う + polydata.point_data["luminance"] = projected_scalars + + show_bar = not scalar_bar_added + plotter.add_mesh( + polydata, + scalars="luminance", + cmap="viridis", + show_scalar_bar=show_bar, + scalar_bar_args={"title": "Luminance (a.u.)"}, + label=mesh_cfg.id, + ) + if show_bar: + scalar_bar_added = True + + elif use_projection: + # 投影モード: 評価板以外は半透明ニュートラルグレー(背景扱い) + plotter.add_mesh( + polydata, + color="lightgray", + opacity=_BACKGROUND_MESH_OPACITY, + label=mesh_cfg.id, + ) + + elif irradiance is not None and mesh_cfg.id in irradiance: + # 直接ベイクモード: per-face 照度で着色 irr = irradiance[mesh_cfg.id] - polydata.cell_data["irradiance"] = irr.astype(np.float32) + # percentile クリップを適用 + irr_clipped = _apply_percentile_clip(irr.astype(np.float32), clip_low, clip_high) + polydata.cell_data["irradiance"] = irr_clipped + + show_bar = not scalar_bar_added plotter.add_mesh( polydata, scalars="irradiance", cmap="viridis", - show_scalar_bar=True, + show_scalar_bar=show_bar, scalar_bar_args={"title": "Irradiance (W/m²)"}, label=mesh_cfg.id, ) + if show_bar: + scalar_bar_added = True + else: - plotter.add_mesh(polydata, color="lightgray", opacity=0.5, label=mesh_cfg.id) + # 着色なし: グレー半透明 + plotter.add_mesh( + polydata, + color="lightgray", + opacity=_UNBAKED_MESH_OPACITY, + label=mesh_cfg.id, + ) # ---- シーン全体のバウンディングボックス(DirectionalLight 用)---- bbox_center, bbox_diag = _get_scene_bbox_center_and_diag(scene_config, project_root) @@ -234,6 +405,26 @@ plotter.add_axes() plotter.show_bounds(grid="back", location="outer") + # ---- 投影モード: 評価板正面が見えるカメラ視点を設定 ---- + if use_projection: + # センサ位置から評価板を見る視点に設定 + # センサは -Z 側から +Z 方向を向いているので、 + # ビューワは少し引いて斜め上から俯瞰する位置に設定する + sensor_pos = np.array(sensor_pos_m) + sensor_tgt = np.array(sensor_target_m) + # センサ位置からさらに -Z 方向(センサの裏側)に引いた位置 + cam_offset = sensor_pos - sensor_tgt + cam_dist = np.linalg.norm(cam_offset) + if cam_dist < 1e-9: + cam_dist = 0.1 + # カメラを評価板正面かつ斜め上から + cam_pos = sensor_pos + cam_offset / cam_dist * 0.25 + cam_pos[1] += 0.05 # 少し上から + plotter.camera.position = cam_pos.tolist() + plotter.camera.focal_point = sensor_tgt.tolist() + plotter.camera.up = (0.0, 1.0, 0.0) + plotter.reset_camera() + # ---- 表示 / 保存 ---- if screenshot is not None: plotter.show(screenshot=str(screenshot)) diff --git a/tests/visualize/test_projection.py b/tests/visualize/test_projection.py new file mode 100644 index 0000000..83e4277 --- /dev/null +++ b/tests/visualize/test_projection.py @@ -0,0 +1,916 @@ +"""visualize/projection.py の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_03_基準設計の物理構成.md(カメラ(センサ)節) +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(sensor orthographic 節) +- src/sm_tias_lightsim/scene/sensors.py(look_at + scale 規約) +- src/sm_tias_lightsim/visualize/projection.py(投影規約 docstring) +- docs/01_GUIDE/GUIDE_08_テスト方針.md + +テスト方針: +- PyVista 実描画は使用しない(投影・正規化・CLI のロジックを純関数レベルで重点検証) +- 投影関数(project_luminance_to_vertices・find_eval_board_mesh_id)は純関数のため + Mitsuba 不要・高速(slow マーカーなし) +- ファイル I/O は tmp_path フィクスチャで実ファイルを使う(GUIDE_08) + +テスト対象: + find_eval_board_mesh_id(scene_config) -> str + project_luminance_to_vertices(vertices_m, luminance_map, sensor, *, clip_low, clip_high) -> np.ndarray (N_v,) float32 +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from sm_tias_lightsim.scene.schema import ( + FilmConfig, + GeometryConfig, + MeshConfig, + SceneConfig, + SensorConfig, +) +from sm_tias_lightsim.visualize.projection import ( + find_eval_board_mesh_id, + project_luminance_to_vertices, +) + + +# --------------------------------------------------------------------------- +# ヘルパ: SPEC_03 準拠の baseline orthographic センサを構築する +# --------------------------------------------------------------------------- + +def _make_baseline_sensor( + *, + position=(0.0, 0.0, -50.0), # mm + target=(0.0, 0.0, 0.0), # mm + up=(0.0, 1.0, 0.0), + ortho_scale=(50.0, 52.0), # mm + width=512, + height=512, +) -> SensorConfig: + """SPEC_03「カメラ(センサ)」節に準拠した orthographic SensorConfig を返す. + + SPEC_03: + position = (0, 0, -50) mm + target = (0, 0, 0) + up = (0, 1, 0) + ortho_scale = (50, 52) mm + """ + return SensorConfig( + type="orthographic", + position=position, + target=target, + up=up, + fov_deg=None, + ortho_scale=ortho_scale, + film=FilmConfig(width=width, height=height), + ) + + +def _make_minimal_scene_config_with_mesh_ids(mesh_ids: list[str]) -> SceneConfig: + """指定した id のメッシュ一覧を含む最小 SceneConfig を返す. + + geometry.meshes に mesh_ids の順で MeshConfig を追加する(path は仮値). + """ + meshes = [ + {"id": mid, "path": f"meshes/{mid}.stl", "unit": "mm", "material": "white"} + for mid in mesh_ids + ] + return SceneConfig.model_validate({ + "name": "test", + "geometry": {"meshes": meshes}, + "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": "orthographic", + "position": [0.0, 0.0, -50.0], + "target": [0.0, 0.0, 0.0], + "up": [0.0, 1.0, 0.0], + "ortho_scale": [50.0, 52.0], + "film": {"width": 512, "height": 512}, + }, + "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 2, "spp": 1}, + }) + + +# =========================================================================== +# 1. find_eval_board_mesh_id — 評価板メッシュ特定 +# =========================================================================== + + +class TestFindEvalBoardMeshId: + """find_eval_board_mesh_id の仕様ベーステスト. + + 仕様(projection.py docstring): + 優先順位: + 1. id == "evaluation_board" のメッシュ + 2. geometry.meshes が空でなければ先頭メッシュを返す(フォールバック) + Raises ValueError: geometry.meshes が空の場合 + """ + + def test_returns_evaluation_board_when_present(self): + """id='evaluation_board' のメッシュがあれば、それが返ること. + + 仕様: 優先順位 1 — id == 'evaluation_board' + """ + config = _make_minimal_scene_config_with_mesh_ids(["housing", "evaluation_board", "sensor"]) + result = find_eval_board_mesh_id(config) + assert result == "evaluation_board" + + def test_returns_evaluation_board_when_it_is_the_only_mesh(self): + """id='evaluation_board' のメッシュのみの場合も正しく返ること.""" + config = _make_minimal_scene_config_with_mesh_ids(["evaluation_board"]) + result = find_eval_board_mesh_id(config) + assert result == "evaluation_board" + + def test_returns_evaluation_board_even_if_not_first(self): + """id='evaluation_board' が先頭でなくても正しく返ること.""" + config = _make_minimal_scene_config_with_mesh_ids(["housing", "diffuser", "evaluation_board"]) + result = find_eval_board_mesh_id(config) + assert result == "evaluation_board" + + def test_fallback_returns_first_mesh_when_no_evaluation_board(self): + """id='evaluation_board' がない場合、先頭メッシュの id が返ること(フォールバック). + + 仕様: フォールバック — 先頭メッシュを返す + """ + config = _make_minimal_scene_config_with_mesh_ids(["housing", "diffuser"]) + result = find_eval_board_mesh_id(config) + assert result == "housing" + + def test_fallback_returns_first_mesh_single_entry(self): + """単一メッシュで id='evaluation_board' でない場合、そのメッシュ id が返ること.""" + config = _make_minimal_scene_config_with_mesh_ids(["my_mesh"]) + result = find_eval_board_mesh_id(config) + assert result == "my_mesh" + + def test_returns_string_type(self): + """戻り値が str 型であること.""" + config = _make_minimal_scene_config_with_mesh_ids(["evaluation_board"]) + result = find_eval_board_mesh_id(config) + assert isinstance(result, str) + + +# =========================================================================== +# 2. project_luminance_to_vertices — 投影の正しさ(SPEC_03 baseline センサ設定) +# =========================================================================== + + +class TestOrthographicProjectionBaseline: + """SPEC_03 baseline センサ設定での orthographic 投影を検証する. + + SPEC_03 センサ設定: + position = (0, 0, -50) mm → m で (0, 0, -0.05) + target = (0, 0, 0) + up = (0, 1, 0) + ortho_scale = (50, 52) mm → sx=0.05 m, sy=0.052 m + + look_at 基底(projection.py docstring に準拠): + forward = normalize(target - pos) = (0, 0, 1) + right = normalize(cross(up, forward)) = normalize((1,0,0)) = (1,0,0) + cam_up = cross(forward, right) = (0,0,1) × (1,0,0) = (0,-1,0) + ただし Mitsuba は渡した up ベクトル (0,1,0) を直接使う規約のため + cam_up の実質的な役割は up=(0,1,0) が担う + + 画像座標変換規約(projection.py docstring): + clip_x = world_x / sx_m (right 方向) + clip_y = world_y / sy_m (cam_up 方向; up=(0,1,0) なので +Y が clip_y+) + col = (clip_x + 1) / 2 * W (clip_x=-1 → col=0, clip_x=+1 → col=W) + row = (1 - clip_y) / 2 * H (clip_y=+1 → row=0, clip_y=-1 → row=H) + """ + + @pytest.fixture(autouse=True) + def setup_sensor_and_map(self): + """SPEC_03 準拠の baseline センサと uniform 輝度マップを設定する.""" + self.sensor = _make_baseline_sensor() + self.H = self.sensor.film.height # 512 + self.W = self.sensor.film.width # 512 + self.sx_m = self.sensor.ortho_scale[0] * 0.001 # 0.05 + self.sy_m = self.sensor.ortho_scale[1] * 0.001 # 0.052 + # 一様な輝度マップ(値 1.0)— サンプリング位置によらず同じ値を返す + self.uniform_map = np.ones((self.H, self.W), dtype=np.float64) + + def test_center_vertex_maps_to_image_center(self): + """ワールド原点 (0, 0, 0) が画像中心 (row=H/2, col=W/2) に写ること. + + 仕様(SPEC_03): target = (0,0,0) が撮影面の中心 + 仕様(projection.py): clip_x=0, clip_y=0 → col=W/2, row=H/2 + 一様マップなので値 = 1.0 + """ + vertices_m = np.array([[0.0, 0.0, 0.0]], dtype=np.float64) + result = project_luminance_to_vertices(vertices_m, self.uniform_map, self.sensor) + assert result.shape == (1,) + assert float(result[0]) == pytest.approx(1.0, abs=1e-5) + + def test_right_edge_vertex_maps_to_right_column(self): + """ワールド座標 (sx_m, 0, 0) が画像右端列 (col ≈ W) に写ること. + + 仕様(projection.py): clip_x = sx_m / sx_m = 1.0 → col = (1+1)/2*W = W + 境界クランプで col = W-1 になる + """ + vertices_m = np.array([[self.sx_m, 0.0, 0.0]], dtype=np.float64) + result = project_luminance_to_vertices(vertices_m, self.uniform_map, self.sensor) + # 一様マップなので値は 1.0(境界クランプが効いても値は変わらない) + assert float(result[0]) == pytest.approx(1.0, abs=1e-5) + + def test_left_edge_vertex_maps_to_left_column(self): + """ワールド座標 (-sx_m, 0, 0) が画像左端列 (col=0) に写ること. + + 仕様(projection.py): clip_x = -sx_m / sx_m = -1.0 → col = 0 + """ + vertices_m = np.array([[-self.sx_m, 0.0, 0.0]], dtype=np.float64) + result = project_luminance_to_vertices(vertices_m, self.uniform_map, self.sensor) + assert float(result[0]) == pytest.approx(1.0, abs=1e-5) + + def test_top_edge_vertex_maps_to_top_row(self): + """ワールド座標 (0, sy_m, 0) が画像上端行 (row=0) に写ること. + + 仕様(projection.py): clip_y = sy_m / sy_m = 1.0 → row = (1-1)/2*H = 0 + SPEC_03: up=(0,1,0) なので +Y 方向が画像上端 + """ + vertices_m = np.array([[0.0, self.sy_m, 0.0]], dtype=np.float64) + result = project_luminance_to_vertices(vertices_m, self.uniform_map, self.sensor) + assert float(result[0]) == pytest.approx(1.0, abs=1e-5) + + def test_bottom_edge_vertex_maps_to_bottom_row(self): + """ワールド座標 (0, -sy_m, 0) が画像下端行 (row=H) に写ること. + + 仕様(projection.py): clip_y = -1.0 → row = (1-(-1))/2*H = H + 境界クランプで row = H-1 になる + """ + vertices_m = np.array([[0.0, -self.sy_m, 0.0]], dtype=np.float64) + result = project_luminance_to_vertices(vertices_m, self.uniform_map, self.sensor) + assert float(result[0]) == pytest.approx(1.0, abs=1e-5) + + def test_out_of_range_vertex_is_boundary_clamped(self): + """撮影範囲外の頂点が境界値でクランプされること(例外は投げない). + + 仕様(projection.py): センサ外の頂点は最近傍クリップ(境界値で折り返す)で補完する + """ + # ortho_scale の 2 倍外側 + far_outside = np.array([[self.sx_m * 2.0, self.sy_m * 2.0, 0.0]], dtype=np.float64) + # 例外が発生しないことを確認 + result = project_luminance_to_vertices(far_outside, self.uniform_map, self.sensor) + assert result.shape == (1,) + # 一様マップでは境界クランプ後も 1.0 + assert float(result[0]) == pytest.approx(1.0, abs=1e-5) + + def test_known_world_coordinate_maps_to_expected_pixel_gradient_map(self): + """X 方向線形勾配マップで、既知ワールド座標が期待輝度値に対応すること. + + 設定: + - 輝度マップ: col 方向線形グラジェント(col=0 で 0.0, col=W-1 で 1.0) + - テスト頂点: (0, 0, 0) → col=W/2 → 輝度 ≈ 0.5 + (-sx_m, 0, 0) → col=0 → 輝度 ≈ 0.0 + (+sx_m, 0, 0) → col=W → クランプ → col=W-1 → 輝度 ≈ 1.0 + + 仕様(projection.py): + clip_x = world_x / sx_m + col = (clip_x + 1) / 2 * W + """ + H, W = 100, 100 + # col 方向線形グラジェント: lum[row, col] = col / (W-1) + cols = np.linspace(0.0, 1.0, W) + gradient_map = np.tile(cols, (H, 1)) # (H, W) + + sensor = _make_baseline_sensor(width=W, height=H) + + # テスト頂点 + sx_m = sensor.ortho_scale[0] * 0.001 + sy_m = sensor.ortho_scale[1] * 0.001 + vertices_m = np.array([ + [0.0, 0.0, 0.0], # 中心 → col=W/2 → 輝度 = 0.5 + [-sx_m, 0.0, 0.0], # 左端 → col=0 → 輝度 = 0.0 + [sx_m, 0.0, 0.0], # 右端 → col=W → クランプ col=W-1 → 輝度 ≈ 1.0 + ], dtype=np.float64) + + result = project_luminance_to_vertices( + vertices_m, gradient_map, sensor, clip_low=0.0, clip_high=100.0 + ) + assert result.shape == (3,) + # 中心 + assert float(result[0]) == pytest.approx(0.5, abs=0.02) + # 左端(col=0) + assert float(result[1]) == pytest.approx(0.0, abs=0.02) + # 右端クランプ(col=W-1) + assert float(result[2]) == pytest.approx(1.0, abs=0.02) + + def test_y_direction_linear_gradient_map(self): + """Y 方向線形勾配マップで、ワールド Y 座標が期待輝度に対応すること. + + 仕様(projection.py): + clip_y = world_y / sy_m + row = (1 - clip_y) / 2 * H + row=0(上端)は clip_y=+1 → world_y = +sy_m + row=H(下端)は clip_y=-1 → world_y = -sy_m + + 輝度マップ: row 方向線形 (row=0: 1.0, row=H-1: 0.0) = Y上方向が明るい + """ + H, W = 100, 100 + rows = np.linspace(1.0, 0.0, H) # row=0 → 1.0, row=H-1 → 0.0 + gradient_map = np.tile(rows[:, np.newaxis], (1, W)) # (H, W) + + sensor = _make_baseline_sensor(width=W, height=H) + sy_m = sensor.ortho_scale[1] * 0.001 + + vertices_m = np.array([ + [0.0, sy_m, 0.0], # 上端 → row=0 → 輝度 ≈ 1.0 + [0.0, -sy_m, 0.0], # 下端 → row=H-1 → 輝度 ≈ 0.0 + [0.0, 0.0, 0.0], # 中心 → row=H/2 → 輝度 ≈ 0.5 + ], dtype=np.float64) + + result = project_luminance_to_vertices( + vertices_m, gradient_map, sensor, clip_low=0.0, clip_high=100.0 + ) + assert result.shape == (3,) + assert float(result[0]) == pytest.approx(1.0, abs=0.02) + assert float(result[1]) == pytest.approx(0.0, abs=0.02) + assert float(result[2]) == pytest.approx(0.5, abs=0.02) + + +# =========================================================================== +# 3. bilinear サンプリングの正確さ +# =========================================================================== + + +class TestBilinearSampling: + """bilinear 補間の数値正確さを検証する. + + 仕様(projection.py): + bilinear サンプリング(範囲外は境界でクランプ) + 整数格子点では元値一致 + 線形勾配では中点が厳密に補間値に一致 + """ + + def test_integer_grid_points_return_exact_values(self): + """整数格子点に対応する頂点の補間値が元の輝度値と一致すること. + + 仕様: bilinear 補間の整数格子点では元値と一致する + """ + # 4x4 マップ: 各画素に固有の値を設定 + lum_map = np.array([ + [10.0, 20.0, 30.0, 40.0], + [50.0, 60.0, 70.0, 80.0], + [90.0, 100.0, 110.0, 120.0], + [130.0, 140.0, 150.0, 160.0], + ], dtype=np.float64) + H, W = 4, 4 + + sensor = _make_baseline_sensor(width=W, height=H) + sx_m = sensor.ortho_scale[0] * 0.001 + sy_m = sensor.ortho_scale[1] * 0.001 + + # pixel (row=0, col=0) に対応するワールド座標 + # col=0 → clip_x = -1.0 → world_x = -sx_m + # row=0 → clip_y = +1.0 → world_y = +sy_m + world_x_00 = -sx_m + world_y_00 = +sy_m + + vertices_m = np.array([[world_x_00, world_y_00, 0.0]], dtype=np.float64) + result = project_luminance_to_vertices( + vertices_m, lum_map, sensor, clip_low=0.0, clip_high=100.0 + ) + # pixel (0, 0) の値 = 10.0 + assert float(result[0]) == pytest.approx(10.0, abs=1.0) + + def test_midpoint_bilinear_interpolation_on_linear_gradient(self): + """線形勾配マップの整数格子点中間で補間値が解析値と一致すること. + + 設定: + lum_map = 2D 線形グラジェント: + lum[r, c] = r * W + c (0 〜 H*W-1) + 中間点は bilinear 補間の平均値になる + """ + H, W = 8, 8 + lum_map = np.arange(H * W, dtype=np.float64).reshape(H, W) + + sensor = _make_baseline_sensor(width=W, height=H) + sx_m = sensor.ortho_scale[0] * 0.001 + sy_m = sensor.ortho_scale[1] * 0.001 + + # pixel (row=1, col=1) に対応するワールド座標を計算 + # col=1 → clip_x = (2*1/W) - 1 = 2/8 - 1 = -0.75 → world_x = -0.75 * sx_m + # row=1 → clip_y = 1 - (2*1/H) = 1 - 2/8 = 0.75 → world_y = 0.75 * sy_m + clip_x = (2.0 * 1 / W) - 1.0 # -0.75 + clip_y = 1.0 - (2.0 * 1 / H) # 0.75 + world_x = clip_x * sx_m + world_y = clip_y * sy_m + + vertices_m = np.array([[world_x, world_y, 0.0]], dtype=np.float64) + result = project_luminance_to_vertices( + vertices_m, lum_map, sensor, clip_low=0.0, clip_high=100.0 + ) + # pixel (row=1, col=1) の値 = 1*8 + 1 = 9.0 + assert float(result[0]) == pytest.approx(9.0, abs=1.0) + + def test_channel_first_luminance_uses_first_channel(self): + """(H, W, C) 形式の輝度マップで最初のチャネルが使われること. + + 仕様(projection.py): C チャネルある場合は最初のチャネルを使用する + """ + H, W = 4, 4 + # チャネル 0: 全画素 1.0, チャネル 1: 全画素 999.0 + lum_3ch = np.zeros((H, W, 2), dtype=np.float64) + lum_3ch[:, :, 0] = 1.0 + lum_3ch[:, :, 1] = 999.0 + + sensor = _make_baseline_sensor(width=W, height=H) + vertices_m = np.array([[0.0, 0.0, 0.0]], dtype=np.float64) + + result = project_luminance_to_vertices( + vertices_m, lum_3ch, sensor, clip_low=0.0, clip_high=100.0 + ) + # 最初のチャネル(1.0)が使われること + assert float(result[0]) == pytest.approx(1.0, abs=1e-4) + + +# =========================================================================== +# 4. percentile クリップの正確さ +# =========================================================================== + + +class TestPercentileClip: + """percentile クリップが正しく機能することを検証する. + + 仕様(projection.py): + percentile クリップで高ダイナミックレンジ潰れを防止 + p_low = np.percentile(sampled, clip_low) + p_high = np.percentile(sampled, clip_high) + sampled_clipped = np.clip(sampled, p_low, p_high) + """ + + @pytest.fixture(autouse=True) + def setup_sensor(self): + """テスト用センサを設定する(小さな解像度).""" + self.H, self.W = 10, 10 + self.sensor = _make_baseline_sensor(width=self.W, height=self.H) + self.sx_m = self.sensor.ortho_scale[0] * 0.001 + self.sy_m = self.sensor.ortho_scale[1] * 0.001 + + def test_p1_p99_clip_reduces_extreme_values(self): + """p1–p99 クリップで極値が除去されること(戻り値の min/max が期待範囲内). + + 仕様(projection.py): + p_low = np.percentile(sampled, clip_low) + p_high = np.percentile(sampled, clip_high) + sampled_clipped = np.clip(sampled, p_low, p_high) + + 検証方針: + - クリップ後の最大値は「クリップ前サンプリング値の p99」と一致すること + - クリップ後の最小値は「クリップ前サンプリング値の p1」と一致すること + - 戻り値が float32 であること + """ + # 0〜100 の均等輝度マップ (10x10) + lum_map = np.linspace(0.0, 100.0, self.H * self.W).reshape(self.H, self.W) + + # 頂点をグリッド状に配置してすべてのピクセルにサンプリング + xs = np.linspace(-self.sx_m * 0.95, self.sx_m * 0.95, self.W) + ys = np.linspace(-self.sy_m * 0.95, self.sy_m * 0.95, self.H) + xv, yv = np.meshgrid(xs, ys) + vertices_m = np.stack([xv.ravel(), yv.ravel(), np.zeros(self.H * self.W)], axis=1) + + result = project_luminance_to_vertices( + vertices_m, lum_map, self.sensor, clip_low=1.0, clip_high=99.0 + ) + assert result.shape == (self.H * self.W,) + # 戻り値は float32 + assert result.dtype == np.float32 + + # クリップ後の値は元輝度マップの最大値(100.0)を超えないこと + # (クリップにより極値が押さえられる仕様) + assert float(result.max()) <= 100.0 + 1e-3, ( + f"クリップ後の最大値が元マップ最大値を超えている: max={float(result.max()):.4f}" + ) + assert float(result.min()) >= 0.0 - 1e-3, ( + f"クリップ後の最小値が元マップ最小値を下回っている: min={float(result.min()):.4f}" + ) + # clip_low=1, clip_high=99 のとき: 結果は元のフル範囲より狭くなること + # (元: ~0〜100, クリップ後: p1〜p99 の範囲に収束) + full_range = 100.0 # 元マップ最大 - 最小 + clipped_range = float(result.max()) - float(result.min()) + assert clipped_range < full_range, ( + f"クリップ後の range がクリップ前より小さいこと: clipped={clipped_range:.4f}, full={full_range:.4f}" + ) + + def test_clip_low_0_clip_high_100_preserves_range(self): + """clip_low=0, clip_high=100 のときクリップが実質無効(全範囲が保存).""" + lum_map = np.linspace(1.0, 10.0, self.H * self.W).reshape(self.H, self.W) + + # 中心頂点のみ + vertices_m = np.array([[0.0, 0.0, 0.0]], dtype=np.float64) + result = project_luminance_to_vertices( + vertices_m, lum_map, self.sensor, clip_low=0.0, clip_high=100.0 + ) + # 例外が発生しないこと + assert result.shape == (1,) + assert np.isfinite(float(result[0])) + + +# =========================================================================== +# 5. 入力バリデーション — ValueError +# =========================================================================== + + +class TestProjectionValidation: + """project_luminance_to_vertices の入力バリデーションを検証する. + + 仕様(projection.py): + Raises: + ValueError: sensor.type が 'orthographic' でない場合 + ValueError: sensor.ortho_scale が None の場合 + ValueError: sensor position と target が一致している場合 + """ + + def test_raises_value_error_for_perspective_sensor(self): + """perspective センサを渡すと ValueError が発生すること. + + 仕様: sensor.type が 'orthographic' でない場合 ValueError + """ + perspective_sensor = SensorConfig( + type="perspective", + position=(0.0, 0.0, -50.0), + target=(0.0, 0.0, 0.0), + up=(0.0, 1.0, 0.0), + fov_deg=60.0, + film=FilmConfig(width=512, height=512), + ) + vertices_m = np.array([[0.0, 0.0, 0.0]], dtype=np.float64) + lum_map = np.ones((512, 512), dtype=np.float64) + + with pytest.raises(ValueError, match="orthographic"): + project_luminance_to_vertices(vertices_m, lum_map, perspective_sensor) + + def test_raises_value_error_for_position_equals_target(self): + """position == target のとき ValueError が発生すること. + + 仕様: sensor position と target が一致している場合 ValueError + """ + # position == target の orthographic センサ + sensor = SensorConfig( + type="orthographic", + position=(0.0, 0.0, 0.0), + target=(0.0, 0.0, 0.0), # position と同じ + up=(0.0, 1.0, 0.0), + ortho_scale=(50.0, 52.0), + film=FilmConfig(width=512, height=512), + ) + vertices_m = np.array([[0.0, 0.0, 0.0]], dtype=np.float64) + lum_map = np.ones((512, 512), dtype=np.float64) + + with pytest.raises(ValueError): + project_luminance_to_vertices(vertices_m, lum_map, sensor) + + def test_returns_float32_array(self): + """戻り値が float32 の ndarray であること. + + 仕様(projection.py): per-vertex 輝度スカラ配列 (N_v,) float32 + """ + sensor = _make_baseline_sensor() + vertices_m = np.array([[0.0, 0.0, 0.0], [0.01, 0.0, 0.0]], dtype=np.float64) + lum_map = np.ones((512, 512), dtype=np.float64) + + result = project_luminance_to_vertices(vertices_m, lum_map, sensor) + + assert isinstance(result, np.ndarray) + assert result.dtype == np.float32 + assert result.ndim == 1 + assert result.shape == (2,) + + def test_multiple_vertices_returns_correct_shape(self): + """複数頂点 (N_v, 3) に対して (N_v,) の配列が返ること.""" + sensor = _make_baseline_sensor() + N_v = 50 + vertices_m = np.zeros((N_v, 3), dtype=np.float64) + lum_map = np.ones((512, 512), dtype=np.float64) + + result = project_luminance_to_vertices(vertices_m, lum_map, sensor) + assert result.shape == (N_v,) + + +# =========================================================================== +# 6. CLI --render / --clip-percentile のパース(純パーズ層テスト) +# =========================================================================== + + +class TestCliArgParsing: + """sm-visualize CLI 引数パーズの仕様ベーステスト. + + 仕様(cli/visualize.py): + --render PATH luminance.npy のパスを受け取る + --clip-percentile LOW HIGH 既定: [1.0, 99.0] + --render 指定時は --no-bake が暗黙で有効(no_bake=True) + """ + + def test_parse_render_arg_sets_render_path(self, tmp_path): + """--render PATH が正しくパーズされること.""" + from sm_tias_lightsim.cli.visualize import _parse_args + + npy_path = tmp_path / "luminance.npy" + args = _parse_args(["--scene", "scene.yaml", "--render", str(npy_path)]) + assert args.render == npy_path + + def test_parse_render_none_by_default(self): + """--render を指定しない場合、args.render が None であること.""" + from sm_tias_lightsim.cli.visualize import _parse_args + + args = _parse_args(["--scene", "scene.yaml"]) + assert args.render is None + + def test_parse_clip_percentile_default_is_1_99(self): + """--clip-percentile を省略したとき既定値 [1.0, 99.0] が設定されること. + + 仕様(cli/visualize.py): --clip-percentile 既定 1 99 + """ + from sm_tias_lightsim.cli.visualize import _parse_args + + args = _parse_args(["--scene", "scene.yaml"]) + assert args.clip_percentile == pytest.approx([1.0, 99.0]) + + def test_parse_clip_percentile_custom_values(self): + """--clip-percentile LOW HIGH がカスタム値でパーズされること.""" + from sm_tias_lightsim.cli.visualize import _parse_args + + args = _parse_args(["--scene", "scene.yaml", "--clip-percentile", "5", "95"]) + assert args.clip_percentile[0] == pytest.approx(5.0) + assert args.clip_percentile[1] == pytest.approx(95.0) + + def test_render_path_implies_no_bake_in_main_logic(self, tmp_path): + """--render 指定時に main() 内で no_bake=True になることを確認する. + + 仕様(cli/visualize.py): + no_bake: bool = args.no_bake or (render_path is not None) + """ + from sm_tias_lightsim.cli.visualize import _parse_args + + npy_path = tmp_path / "luminance.npy" + args = _parse_args(["--scene", "scene.yaml", "--render", str(npy_path)]) + # --render 指定時、args.no_bake は False だが render_path は None でない + # main() 内で no_bake = args.no_bake or (render_path is not None) = True + assert args.no_bake is False + assert args.render is not None + # main() 相当の論理 + render_path = args.render + no_bake = args.no_bake or (render_path is not None) + assert no_bake is True + + def test_missing_scene_raises_system_exit(self): + """--scene を渡さないと SystemExit が発生すること.""" + from sm_tias_lightsim.cli.visualize import _parse_args + + with pytest.raises(SystemExit) as exc_info: + _parse_args([]) + assert exc_info.value.code != 0 + + def test_render_nonexistent_file_returns_1(self, tmp_path): + """--render で存在しない .npy ファイルを指定すると return 1 であること. + + 仕様(cli/visualize.py): luminance.npy が見つからない → return 1 + """ + from sm_tias_lightsim.cli.visualize import main + + # 最小 YAML と存在しない npy + import yaml + scene_yaml = tmp_path / "scene.yaml" + scene_data = { + "name": "test", + "geometry": {"meshes": [{"id": "mesh", "path": "mesh.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": "orthographic", + "position": [0.0, 0.0, -50.0], + "target": [0.0, 0.0, 0.0], + "up": [0.0, 1.0, 0.0], + "ortho_scale": [50.0, 52.0], + "film": {"width": 64, "height": 64}, + }, + "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 2, "spp": 1}, + } + scene_yaml.write_text(yaml.dump(scene_data), encoding="utf-8") + nonexistent_npy = tmp_path / "nonexistent_luminance.npy" + + result = main(["--scene", str(scene_yaml), "--render", str(nonexistent_npy), "--off-screen"]) + assert result == 1 + + +# =========================================================================== +# 7. --render CLI 統合テスト(Mitsuba 不要) +# =========================================================================== + + +class TestCliRenderIntegration: + """--render フラグで luminance.npy を読み込む統合テスト. + + 仕様(cli/visualize.py): + --render 指定時は sm-visualize 自身はレンダリングを実行しない(LLVM 不要) + """ + + @pytest.mark.slow + def test_render_flag_loads_npy_and_creates_screenshot(self, tmp_path): + """--render で luminance.npy を読み込み、--screenshot で PNG が生成されること. + + 仕様: luminance.npy 読み込み後、show_scene(投影モード)で screenshot を保存する + Mitsuba のレンダリング(LLVM)は実行しない + """ + import yaml + + from sm_tias_lightsim.cli.visualize import main + + # 最小シーン YAML(evaluation_board メッシュが必要) + # minimal.yaml の sphere STL を流用し、evaluation_board id を付ける + sphere_stl = ( + Path(__file__).parent.parent / "fixtures" / "meshes" / "sphere.stl" + ) + scene_data = { + "name": "render_test", + "geometry": {"meshes": [ + {"id": "evaluation_board", "path": str(sphere_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": "orthographic", + "position": [0.0, 0.0, -50.0], + "target": [0.0, 0.0, 0.0], + "up": [0.0, 1.0, 0.0], + "ortho_scale": [50.0, 52.0], + "film": {"width": 64, "height": 64}, + }, + "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 2, "spp": 1}, + } + scene_yaml = tmp_path / "scene_render.yaml" + scene_yaml.write_text(yaml.dump(scene_data), encoding="utf-8") + + # 合成輝度マップ(64x64)を作成 + lum_npy = tmp_path / "luminance.npy" + np.save(str(lum_npy), np.random.default_rng(42).random((64, 64)).astype(np.float32)) + + screenshot_path = tmp_path / "render_out.png" + result = main([ + "--scene", str(scene_yaml), + "--render", str(lum_npy), + "--screenshot", str(screenshot_path), + ]) + assert result == 0 + assert screenshot_path.exists() + assert screenshot_path.stat().st_size > 0 + + @pytest.mark.slow + def test_render_flag_does_not_run_bake(self, tmp_path, capsys): + """--render 指定時に「照度ベイクを開始します」が stdout に出ないこと. + + 仕様: --render 指定時は直接ベイクをスキップ(LLVM 不要) + """ + import yaml + + from sm_tias_lightsim.cli.visualize import main + + sphere_stl = ( + Path(__file__).parent.parent / "fixtures" / "meshes" / "sphere.stl" + ) + scene_data = { + "name": "no_bake_test", + "geometry": {"meshes": [ + {"id": "evaluation_board", "path": str(sphere_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": "orthographic", + "position": [0.0, 0.0, -50.0], + "target": [0.0, 0.0, 0.0], + "up": [0.0, 1.0, 0.0], + "ortho_scale": [50.0, 52.0], + "film": {"width": 64, "height": 64}, + }, + "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 2, "spp": 1}, + } + scene_yaml = tmp_path / "scene.yaml" + scene_yaml.write_text(yaml.dump(scene_data), encoding="utf-8") + + lum_npy = tmp_path / "luminance.npy" + np.save(str(lum_npy), np.ones((64, 64), dtype=np.float32)) + + screenshot_path = tmp_path / "out.png" + main([ + "--scene", str(scene_yaml), + "--render", str(lum_npy), + "--screenshot", str(screenshot_path), + ]) + captured = capsys.readouterr() + assert "照度ベイクを開始します" not in captured.out + + +# =========================================================================== +# 8. show_scene 後方互換 — luminance_map=None +# =========================================================================== + + +class TestShowSceneBackwardCompat: + """show_scene(..., luminance_map=None) の後方互換を検証する. + + 仕様(viewer.py docstring): + luminance_map が None の場合、投影モードを使わない(既存挙動を保持) + show_scene の既存引数(irradiance, off_screen, screenshot, clip_low/high)は + そのまま動作すること + """ + + @pytest.mark.slow + def test_luminance_map_none_does_not_crash(self, tmp_path): + """luminance_map=None で show_scene が例外を投げないこと.""" + import yaml + + from sm_tias_lightsim.scene.schema import load_scene_yaml + from sm_tias_lightsim.visualize.viewer import show_scene + + sphere_stl = ( + Path(__file__).parent.parent / "fixtures" / "meshes" / "sphere.stl" + ) + scene_data = { + "name": "compat_test", + "geometry": {"meshes": [ + {"id": "sphere", "path": str(sphere_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": 64, "height": 64}, + }, + "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 2, "spp": 1}, + } + scene_yaml = tmp_path / "scene.yaml" + scene_yaml.write_text(yaml.dump(scene_data), encoding="utf-8") + config = load_scene_yaml(scene_yaml) + project_root = tmp_path + + screenshot_path = tmp_path / "compat_out.png" + # luminance_map=None(省略相当)— 後方互換 + show_scene( + config, + project_root, + irradiance=None, + luminance_map=None, + screenshot=screenshot_path, + ) + assert screenshot_path.exists() + assert screenshot_path.stat().st_size > 0 + + @pytest.mark.slow + def test_show_scene_new_signature_with_clip_percentile(self, tmp_path): + """clip_low / clip_high 引数が受け付けられること(新引数の後方互換).""" + import yaml + + from sm_tias_lightsim.scene.schema import load_scene_yaml + from sm_tias_lightsim.visualize.viewer import show_scene + + sphere_stl = ( + Path(__file__).parent.parent / "fixtures" / "meshes" / "sphere.stl" + ) + scene_data = { + "name": "clip_test", + "geometry": {"meshes": [ + {"id": "sphere", "path": str(sphere_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": 64, "height": 64}, + }, + "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 2, "spp": 1}, + } + scene_yaml = tmp_path / "scene.yaml" + scene_yaml.write_text(yaml.dump(scene_data), encoding="utf-8") + config = load_scene_yaml(scene_yaml) + project_root = tmp_path + + screenshot_path = tmp_path / "clip_out.png" + # clip_low / clip_high 引数を明示的に指定 + show_scene( + config, + project_root, + irradiance=None, + luminance_map=None, + screenshot=screenshot_path, + clip_low=5.0, + clip_high=95.0, + ) + assert screenshot_path.exists()