Newer
Older
SmTIAS-LightSim / src / sm_tias_lightsim / cli / visualize.py
# 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 保存)を行う.
    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-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 を使わない)."
        ),
    )
    # 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 を同時指定したのと等価."
            " メッシュ着色とカラーバーは残す."
        ),
    )
    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)
    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_scale: str = args.bake_scale

    # 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))
                    irradiance = {k: npz[k] for k in npz.files}
                    # 形状・型の簡易チェック
                    for k, v in irradiance.items():
                        if v.ndim != 1:
                            msg = f"キャッシュ '{k}' の配列形状が不正です: ndim={v.ndim}"
                            raise ValueError(msg)
                    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,
                    )
                else:
                    irradiance = bake_irradiance(
                        config,
                        project_root,
                        mode="direct",
                        exclude_mesh_ids=exclude_ids,
                    )

                # path ベイク後にキャッシュを保存する
                if bake_mode == "path" and bake_cache_path is not None and irradiance:
                    try:
                        bake_cache_path.parent.mkdir(parents=True, exist_ok=True)
                        np.savez(str(bake_cache_path), **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_smooth=bake_smooth,
            bake_scale=bake_scale,
            cull_outward=cull_outward,
            hide_sensor_marker=hide_sensor_marker,
            hide_led_markers=hide_led_markers,
        )
    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