# pyright: reportCallIssue=none
# Mitsuba 3 は variant ベースの動的属性解決を行うため Pylance/Pyright が
# mi.* を object 型と推論し reportCallIssue を誤検知する(GUIDE_06 の方針).
"""sm-visualize コマンドラインエントリポイント.
YAML シーン定義を読み込み,3D 照度ベイクと PyVista インタラクティブビューアを提供する.
処理フロー:
1. YAML シーン定義を読み込み SceneConfig を得る.
2. --render が指定された場合,luminance.npy を読み込む
(sm-visualize 自身はレンダリングを実行しない).
3. --render 未指定かつ --no-bake 指定がない場合,bake_irradiance でベイクを計算する.
既定(render 無し): **全メッシュ(評価板含む)**を path / direct ベイクで統一着色.
--render 指定時(任意): 評価板はテクスチャ投影・壁は path ベイク(ハイブリッドモード).
--render + --bake-mode path: 評価板=投影・壁=ベイク着色(ハイブリッド).
--render + --bake-mode direct: 投影のみ(direct ベイクはしない、従来どおり).
4. --bake-cache 指定時はキャッシュ npz を読み書きする(path ベイクのみ有効).
キャッシュが存在すれば Mitsuba 起動なしで即時読込,なければベイク後に npz 保存.
5. show_scene で PyVista 表示(または screenshot 保存)を行う.
--style slide: 発表スライド用クリーン体裁(背景純白・座標軸/bounds 非表示・
アンチエイリアス・黒文字スカラバー・センサマーカー非表示).
--cmap NAME: カラーマップ名(未指定時は style に応じて default=viridis / slide=hot).
6. --output 指定があれば per-face irradiance を npz 形式で保存する.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""コマンドライン引数をパースして返す.
Args:
argv: 引数リスト.None のとき sys.argv[1:] を使用.
Returns:
パース済み Namespace.
"""
parser = argparse.ArgumentParser(
prog="sm-visualize",
description=(
"YAML シーン定義から 3D 照度ベイクを実行し,PyVista でインタラクティブ表示する."
" --render を指定すると sm-render 出力の 2D 輝度マップを評価板に投影表示する"
"(レンダリングは実行しない)."
),
)
parser.add_argument(
"--scene",
"-s",
required=True,
type=Path,
metavar="YAML_PATH",
help="シーン定義 YAML ファイルのパス.",
)
parser.add_argument(
"--render",
type=Path,
default=None,
metavar="LUMINANCE_NPY",
help=(
"sm-render が出力した luminance.npy のパス(任意オプション)."
"指定時は評価板メッシュに 2D 輝度マップを orthographic 投影して表示する"
"(sm-visualize 自身はレンダリングを実行しない)."
"未指定(既定): 全メッシュ(評価板含む)を bake_irradiance で統一着色する."
"--render + --bake-mode path: ハイブリッドモード(評価板=投影・壁=ベイク)."
"--render + --bake-mode direct: 投影のみ(direct ベイクはスキップ)."
),
)
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,
help="照度ベイクをスキップしてシーンレイアウトのみ表示する.",
)
parser.add_argument(
"--bake-mode",
choices=["direct", "path"],
default="direct",
metavar="MODE",
help=(
"照度ベイクのモード."
"'direct'(既定): shadow ray による直接照度のみ(後方互換)."
"'path': コサイン重点サンプリング + Mitsuba path tracer による多重反射込み照度."
" scalar_rgb variant を使用(LLVM / DRJIT_LIBLLVM_PATH 不要)."
" --render 未指定時(既定): 全メッシュ(評価板含む)をベイクで統一着色する."
" --render + path: ハイブリッドモード(評価板=投影・壁=ベイク)."
" --render + direct: direct ベイクはスキップし投影のみ(従来どおり)."
),
)
parser.add_argument(
"--bake-samples",
type=int,
default=64,
metavar="N",
help=(
"--bake-mode path 時の face あたりサンプル数."
"大きいほど低ノイズ・高コスト.既定: 64."
"高速確認は 16 程度,本番は 256 以上推奨."
"--bake-mode direct では無視される."
),
)
parser.add_argument(
"--bake-cache",
type=Path,
default=None,
metavar="NPZ_PATH",
help=(
"path ベイク結果のキャッシュ npz ファイルパス(任意)."
" 指定パスが存在すれば npz を読み込んでベイクをスキップ(Mitsuba 起動なし・即時)."
" 存在しなければ path ベイクを実行し結果を npz に保存して次回以降キャッシュを利用."
" --bake-mode path のときのみ有効(direct では無視)."
),
)
parser.add_argument(
"--screenshot",
type=Path,
default=None,
metavar="PNG_PATH",
help="スクリーンショット保存先 PNG パス.指定時はウィンドウを開かず off_screen で保存する",
)
parser.add_argument(
"--output",
"-o",
type=Path,
default=None,
metavar="OUTPUT_DIR",
help="ベイク結果(per-face irradiance)を npz 形式で保存するディレクトリ(任意).",
)
parser.add_argument(
"--off-screen",
action="store_true",
default=False,
help="ウィンドウを開かない(screenshot 専用モード).",
)
parser.add_argument(
"--opacity",
type=float,
default=0.5,
metavar="O",
help=(
"ベイク着色モードのメッシュ不透明度(0=透明〜1=不透明、既定 0.5)."
" 1.0 指定で完全不透明になる."
" --render 投影モードには影響しない."
),
)
parser.add_argument(
"--hide-below",
type=float,
default=0.0,
metavar="VALUE",
help=(
"ベイク着色で irradiance がこの値以下の面を非表示にする"
"(既定 0.0=光が当たっていない面を描かない)."
" 負値指定で全面表示(ベイク着色の全 face を描画)."
" 投影モード・ベイク計算ロジックには影響しない."
),
)
parser.add_argument(
"--bake-smooth",
type=int,
default=4,
metavar="N",
help=(
"ベイク着色メッシュのスカラ Laplacian 平滑化イテレーション数(既定 4)."
" N>0 のとき cell↔point_data 変換を N 回往復してノイズを拡散・抑制する"
"(per-face モンテカルロノイズによる三角形モザイクを軽減)."
" N=0 で平滑化なし(cell_data → point_data 変換のみ行い Gouraud 補間は有効)."
" 負値は 0 にクランプ."
" 投影モード・ベイク計算ロジックには影響しない."
),
)
parser.add_argument(
"--bake-smooth-radius",
type=float,
default=None,
metavar="MM",
help=(
"ベイク着色を物理半径 MM mm のガウス重み付き平均で平滑化する(既定: なし)."
" 三角形スケールの MC ノイズを消し,半径より大きい照明構造は保つ."
" --bake-denoise の後・--bake-smooth の前に適用する."
" 投影モード・ベイク計算ロジックには影響しない."
),
)
parser.add_argument(
"--bake-denoise",
type=int,
default=0,
metavar="N",
help=(
"ベイク着色のメディアンデノイズ反復回数(既定 0=なし)."
" MC ノイズ(firefly)をエッジを保ったまま除去する."
" --bake-smooth の拡散平滑化より先に適用される."
" 投影モード・ベイク計算ロジックには影響しない."
),
)
parser.add_argument(
"--bake-scale",
choices=["linear", "log"],
default="log",
metavar="SCALE",
help=(
"ベイク着色の輝度正規化."
"'log'(既定): log10 変換後に共有 clim / percentile を算出する."
" 高ダイナミックレンジ潰れを防ぎ壁面にコントラストが付く."
"'linear': 従来どおり(後方互換)."
" 評価板の実レンダ投影には影響しない."
),
)
parser.add_argument(
"--bake-max-depth",
type=int,
default=16,
metavar="N",
help=(
"path ベイクの ray 最大反射回数(render 設定から独立、既定 16)."
" 大きいほど多重反射の均一化を正確に積分する"
"(SPEC_03: depth≥12 推奨.depth=8 は後段多重反射を取りこぼし勾配を過大評価)."
" --bake-mode direct では無視される(direct ベイクは path integrator を使わない)."
),
)
parser.add_argument(
"--bake-subdivide",
type=float,
default=None,
metavar="MM",
help=(
"ベイク前に三角形の最大辺長を指定 mm 以下まで均一分割する(既定: 分割なし)."
" 巨大三角形のブロック感を解消する."
" --bake-cache 使用時はキャッシュに設定値を記録し,次回以降の読み込み時に"
" 設定が変わっていれば警告を出して再ベイクする."
),
)
# BooleanOptionalAction が Windows CP932 環境で --help 表示時に ↔ を出力し
# UnicodeEncodeError になる問題を回避するため、store_true / store_false で実装する.
_cull_group = parser.add_mutually_exclusive_group()
_cull_group.add_argument(
"--cull-outward",
action="store_true",
default=True,
dest="cull_outward",
help=(
"外向き面(シーン外を向く ≈0 の外殻)を描画しない."
" 暗殻の濁り/見かけ段差を解消する.既定有効."
" --render の評価板テクスチャには影響しない(壁ベイク側にのみ適用)."
),
)
_cull_group.add_argument(
"--no-cull-outward",
action="store_false",
dest="cull_outward",
help="外向き面を含む全面を描画する(後方互換).",
)
parser.add_argument(
"--no-sensor-marker",
action="store_true",
default=False,
help=(
"センサマーカー(青い球+視線矢印)のみ非表示にする(デモ撮影用)."
" LED マーカーは残す.メッシュ着色とカラーバーは影響しない."
),
)
parser.add_argument(
"--no-led-markers",
action="store_true",
default=False,
help=(
"LED マーカー(黄色球・AreaLight plane・矢印等)のみ非表示にする(デモ撮影用)."
" センサマーカーは残す.メッシュ着色とカラーバーは影響しない."
),
)
parser.add_argument(
"--no-markers",
action="store_true",
default=False,
help=(
"LED/センサ両方のマーカー・矢印を非表示にするショートカット(デモ撮影用)."
" --no-sensor-marker --no-led-markers を同時指定したのと等価."
" メッシュ着色とカラーバーは残す."
),
)
parser.add_argument(
"--style",
choices=["default", "slide"],
default="default",
metavar="STYLE",
help=(
"表示スタイルプリセット."
"'default'(既定): 現行表示そのまま(後方互換)."
"'slide': 発表スライド用クリーン体裁(背景純白・座標軸/bounds 非表示・"
"アンチエイリアス有効・scalar bar 黒文字・センサマーカー非表示)."
" screenshot 保存(--screenshot / --off-screen)にも反映される."
),
)
parser.add_argument(
"--cmap",
default=None,
metavar="NAME",
help=(
"カラーマップ名(matplotlib 互換)."
"未指定(既定): style に応じて自動決定する"
"(default → viridis、slide → hot)."
"明示指定時は全スタイルでそのカラーマップを使用する"
"(壁ベイク・評価板テクスチャ・スカラバーの配色が一致する)."
"不正な名前は warning を出して style 既定にフォールバックする."
),
)
parser.add_argument(
"--backface-cull",
action="store_true",
default=False,
help=(
"カメラに背を向けた面を描画しない(背面カリング)."
" --opacity 1.0 と併用すると,手前の壁が自動的に隠れて内部が混色なしで見える"
"(スライド/デモ向き)."
" 既定 OFF(後方互換)."
),
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
"""sm-visualize コマンドのメインエントリポイント.
処理フロー:
1. コマンドライン引数をパースする.
2. YAML シーン定義を load_scene_yaml で読み込み,SceneConfig を得る.
3. --render が指定された場合,luminance.npy を読み込む(レンダリング実行なし).
4. --no-bake がなければ bake_irradiance で照度を計算する.
--render 未指定(既定): 全メッシュ(評価板含む)を統一ベイク着色.
--render + path: ハイブリッド(評価板=投影・壁=ベイク;評価板をベイクから除外).
--render + direct: ベイクしない(投影のみ、従来どおり).
5. --output が指定されていれば per-face irradiance を npz で保存する.
6. show_scene で PyVista 表示(または screenshot 保存)を行う.
Args:
argv: 引数リスト.None のとき sys.argv[1:] を使用.
Returns:
終了コード(成功時 0,失敗時 1).
"""
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
clip_low: float = float(args.clip_percentile[0])
clip_high: float = float(args.clip_percentile[1])
bake_mode: str = args.bake_mode # "direct" または "path"
bake_samples: int = int(args.bake_samples)
bake_cache_path: Path | None = args.bake_cache.resolve() if args.bake_cache else None
bake_max_depth: int = int(args.bake_max_depth)
# --bake-subdivide は mm 単位で受け取り、baker / viewer へは m 系で渡す(_MM_TO_M 規約)
bake_subdivide_m: float | None = (
float(args.bake_subdivide) * 0.001 if args.bake_subdivide is not None else None
)
# --bake-smooth-radius は mm 単位で受け取り viewer へは m 単位で渡す(_MM_TO_M 規約)
bake_smooth_radius_m: float | None = (
float(args.bake_smooth_radius) * 0.001 if args.bake_smooth_radius is not None else None
)
cull_outward: bool = bool(args.cull_outward)
# --no-markers は --no-sensor-marker / --no-led-markers 両方の互換ショートカット
hide_sensor_marker: bool = bool(args.no_sensor_marker) or bool(args.no_markers)
hide_led_markers: bool = bool(args.no_led_markers) or bool(args.no_markers)
# モード判定:
# render 無し(既定) → 全メッシュ統一ベイク(評価板も含む)
# --render + --bake-mode path → ハイブリッドモード(評価板=投影・壁=ベイク)
# --render + --bake-mode direct → ベイクしない(投影のみ、従来どおり)
is_hybrid = (render_path is not None) and (bake_mode == "path")
no_bake: bool = args.no_bake or (render_path is not None and not is_hybrid)
off_screen: bool = args.off_screen
bake_opacity: float = float(args.opacity)
hide_below: float = float(args.hide_below)
bake_smooth: int = int(args.bake_smooth)
bake_denoise: int = int(args.bake_denoise)
bake_scale: str = args.bake_scale
style: str = args.style # "default" または "slide"
cmap: str | None = args.cmap # None のとき style に応じて自動決定
backface_cull: bool = bool(args.backface_cull)
# YAML 読み込み
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
# 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:
import numpy as np
if bake_mode == "direct":
if is_hybrid:
mode_label = "直接照度(shadow ray)- ハイブリッド(評価板除外)"
else:
mode_label = "直接照度(shadow ray)- 全メッシュ統一"
else:
if is_hybrid:
mode_label = (
f"path ベイク(ハイブリッドモード・壁のみ,"
f" samples={bake_samples}, max_depth={bake_max_depth})"
)
else:
mode_label = (
f"path ベイク(多重反射込み・全メッシュ統一,"
f" samples={bake_samples}, max_depth={bake_max_depth})"
)
print(f"照度ベイクを開始します({mode_label})...")
if bake_mode == "path":
print(" path モードは計算が重いため時間がかかります.")
if is_hybrid:
print(
" ハイブリッドモード(--render 指定): "
"評価板メッシュはベイクから除外します(投影で着色)."
)
else:
print(
" 全メッシュ統一ベイク(--render 未指定): "
"評価板を含むすべてのメッシュをベイクします."
)
# ハイブリッドモード時のみ評価板メッシュをベイクから除外する
# render 未指定(全面統一ベイク)時は評価板を除外しない
exclude_ids: set[str] = set()
if is_hybrid:
from sm_tias_lightsim.visualize.projection import find_eval_board_mesh_id
eval_board_id_for_bake = find_eval_board_mesh_id(config)
exclude_ids = {eval_board_id_for_bake}
print(
f" 評価板メッシュ '{eval_board_id_for_bake}' を"
"ベイクから除外します(投影着色のため)."
)
# キャッシュ対応(path モードのみ)
cache_loaded = False
if bake_mode == "path" and bake_cache_path is not None:
if bake_cache_path.exists():
print(f"キャッシュを読み込みます(ベイク再計算なし): {bake_cache_path}")
try:
npz = np.load(str(bake_cache_path), allow_pickle=False)
irradiance = {k: npz[k] for k in npz.files if k != "_subdivide_setting"}
# 形状・型の簡易チェック
for k, v in irradiance.items():
if v.ndim != 1:
msg = f"キャッシュ '{k}' の配列形状が不正です: ndim={v.ndim}"
raise ValueError(msg)
# subdivide_max_edge 設定の整合チェック
# キャッシュに _subdivide_setting が無い場合は「分割なし」とみなす
if "_subdivide_setting" in npz.files:
cached_subdivide_m = float(npz["_subdivide_setting"])
else:
cached_subdivide_m = float("nan") # 旧キャッシュ: 分割なし扱い
_cached_none = np.isnan(cached_subdivide_m)
_current_none = bake_subdivide_m is None
_mismatch = (_cached_none != _current_none) or (
not _cached_none
and not _current_none
and abs(cached_subdivide_m - bake_subdivide_m) > 1e-9 # type: ignore[operator]
)
if _mismatch:
_cached_str = (
"なし" if _cached_none else f"{cached_subdivide_m * 1000:.1f} mm"
)
_current_str = (
"なし"
if bake_subdivide_m is None
else f"{bake_subdivide_m * 1000:.1f} mm"
)
print(
f"警告: キャッシュの subdivide 設定({_cached_str})と"
f" 現在の指定({_current_str})が不一致です."
" キャッシュを無視して再ベイクします.",
file=sys.stderr,
)
irradiance = None
cache_loaded = False
else:
cache_loaded = True
print(f"キャッシュ読み込み完了.メッシュ数={len(irradiance)}")
for mesh_id, irr in irradiance.items():
print(
f" {mesh_id}: shape={irr.shape} min={irr.min():.4e} "
f"max={irr.max():.4e}"
)
except Exception as exc: # noqa: BLE001
print(
"警告: キャッシュの読み込みに失敗しました"
f"(再計算にフォールバック)— {exc}",
file=sys.stderr,
)
irradiance = None
cache_loaded = False
if not cache_loaded:
try:
from sm_tias_lightsim.visualize.baker import bake_irradiance
if bake_mode == "path":
irradiance = bake_irradiance(
config,
project_root,
mode="path",
samples=bake_samples,
exclude_mesh_ids=exclude_ids,
max_depth=bake_max_depth,
subdivide_max_edge=bake_subdivide_m,
)
else:
irradiance = bake_irradiance(
config,
project_root,
mode="direct",
exclude_mesh_ids=exclude_ids,
subdivide_max_edge=bake_subdivide_m,
)
# path ベイク後にキャッシュを保存する
# _subdivide_setting キーに設定値を記録して次回読み込み時の整合チェックに使う
if bake_mode == "path" and bake_cache_path is not None and irradiance:
try:
bake_cache_path.parent.mkdir(parents=True, exist_ok=True)
# subdivide 設定を scalar float 配列として保存
# None(分割なし)の場合は nan で保存する(後方互換のため旧キャッシュは
# _subdivide_setting キーなし=分割なしとして読み込まれる)
_subdivide_setting_val = (
bake_subdivide_m if bake_subdivide_m is not None else float("nan")
)
np.savez(
str(bake_cache_path),
_subdivide_setting=np.float64(_subdivide_setting_val),
**irradiance,
)
print(f"ベイクキャッシュを保存しました: {bake_cache_path}")
except Exception as exc: # noqa: BLE001
print(
f"警告: キャッシュの保存に失敗しました — {exc}",
file=sys.stderr,
)
except Exception as exc: # noqa: BLE001
print(f"エラー: 照度ベイクに失敗しました — {exc}", file=sys.stderr)
return 1
if irradiance:
all_vals = np.concatenate([v for v in irradiance.values() if len(v) > 0])
print(
f"照度ベイク完了.全メッシュ: min={all_vals.min():.4e} "
f"max={all_vals.max():.4e} std={all_vals.std():.4e}"
)
for mesh_id, irr in irradiance.items():
print(
f" {mesh_id}: min={irr.min():.4e} max={irr.max():.4e} "
f"std={irr.std():.4e} nonzero={int((irr > 0).sum())}/{len(irr)}"
)
else:
print("照度ベイク完了(結果なし).")
elif render_path is None:
print("--no-bake: 照度ベイクをスキップします.シーンレイアウトのみ表示します.")
# ベイク結果を npz で保存
if irradiance is not None and output_dir is not None:
try:
output_dir.mkdir(parents=True, exist_ok=True)
import numpy as np
npz_path = output_dir / f"{config.name}_irradiance.npz"
np.savez(npz_path, **irradiance)
print(f"照度データ保存: {npz_path}")
except Exception as exc: # noqa: BLE001
print(f"エラー: 照度データの保存に失敗しました — {exc}", file=sys.stderr)
return 1
# PyVista 表示
try:
from sm_tias_lightsim.visualize.viewer import show_scene
show_scene(
config,
project_root,
irradiance=irradiance,
luminance_map=luminance_map,
off_screen=off_screen,
screenshot=screenshot,
clip_low=clip_low,
clip_high=clip_high,
bake_opacity=bake_opacity,
hide_below=hide_below,
bake_denoise=bake_denoise,
bake_smooth=bake_smooth,
bake_scale=bake_scale,
cull_outward=cull_outward,
hide_sensor_marker=hide_sensor_marker,
hide_led_markers=hide_led_markers,
style=style,
cmap=cmap,
subdivide_max_edge=bake_subdivide_m,
bake_smooth_radius=bake_smooth_radius_m,
backface_cull=backface_cull,
)
except Exception as exc: # noqa: BLE001
print(f"エラー: PyVista 表示に失敗しました — {exc}", file=sys.stderr)
return 1
if screenshot is not None:
print(f"スクリーンショット保存: {screenshot}")
return 0