Skip to content

Senss Hop Lidar Camera

Viser-rendered nadir camera mp4 for the SENSS hop (LiDAR FOV / footprint).

Dedicated FPV scene: lit DEM terrain only — no trajectory trail, vehicle markers, or LiDAR point cloud. Camera uses the same square angular FOV as the body-fixed LiDAR (footprint_m at ref_range_m).

Output is 8"×8" at 300 dpi (2400×2400), 30 fps.

Run::

python examples/animations/senss_hop_lidar_camera.py

Requires ffmpeg on PATH. A headless Chromium client is started via Playwright when available; otherwise open the printed viser URL in a browser.

File: examples/animations/senss_hop_lidar_camera.py

from __future__ import annotations

import importlib
import os
import pickle
import sys
import threading
import time
import webbrowser
from pathlib import Path

import numpy as np
from PIL import Image

current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(os.path.dirname(current_dir))
sys.path.append(project_root)

hop = importlib.import_module("examples.rocket.senss.6DoF_pdg_stc_senss_hop")
from examples.animations._render import render_animation_to_video  # noqa: E402
from examples.plotting_viser import AnimatedServerHandle  # noqa: E402
from examples.rocket.senss._hop_lidar_overlay import (  # noqa: E402
    LIDAR_FOOTPRINT_M,
    LIDAR_REF_RANGE_M,
    lidar_half_fov_rad,
)
from openscvx.plotting.viser import create_server  # noqa: E402

OUTPUT_PATH = os.path.join(current_dir, "mp4", "senss_hop_lidar_camera.mp4")
CACHE_PATH = os.path.join(current_dir, "mp4", ".senss_hop_lidar_camera_result.pkl")

# 8" × 8" at 300 dpi
DPI = 300
WIDTH = 2400
HEIGHT = 2400
FPS = 30
PROPAGATION_HZ = FPS
CRF = 14
PRESET = "slow"

# Same native DEM as the hop example.
RENDER_DEM_GRID = 3938

# Identity body→sensor: hop FPV looks along sensor −Z = body −Z (nadir).
R_SB_NADIR = np.eye(3, dtype=np.float64)


def _prepare_render_dem(grid_n: int) -> None:
    """Reload hop DEM assets at ``grid_n`` (native size preferred)."""
    hop.DEM_GRID = int(grid_n)
    img = Image.open(hop._DEM_PATH)
    raw = np.array(img, dtype=np.uint16)
    lo, hi = float(raw.min()), float(raw.max())
    if raw.shape[0] == hop.DEM_GRID and raw.shape[1] == hop.DEM_GRID:
        arr = raw.astype(np.float32)
    else:
        arr = np.array(
            img.resize((hop.DEM_GRID, hop.DEM_GRID), Image.Resampling.LANCZOS),
            dtype=np.float32,
        )
    hop._dem_norm = (arr - lo) / max(hi - lo, 1.0)
    hop._dem_center_i = hop._dem_center_j = (hop.DEM_GRID - 1) // 2
    hop._dem_center_norm = float(hop._dem_norm[hop._dem_center_i, hop._dem_center_j])
    hop._terrain_faces = hop._make_terrain_faces()


def _open_viser_client(url: str) -> None:
    """Connect a browser client so ``get_render`` can capture frames."""

    def _playwright_connect() -> bool:
        try:
            from playwright.sync_api import sync_playwright
        except ImportError:
            return False

        def _run() -> None:
            with sync_playwright() as p:
                try:
                    browser = p.chromium.launch(
                        channel="chrome",
                        headless=True,
                        args=["--ignore-gpu-blocklist", "--enable-webgl"],
                    )
                except Exception:
                    browser = p.chromium.launch(
                        headless=True,
                        args=["--ignore-gpu-blocklist", "--enable-webgl"],
                    )
                page = browser.new_page(
                    viewport={"width": WIDTH + 64, "height": HEIGHT + 64},
                    device_scale_factor=1,
                )
                page.goto(url, wait_until="networkidle")
                time.sleep(8.0)
                while True:
                    time.sleep(1.0)

        threading.Thread(target=_run, daemon=True).start()
        return True

    if _playwright_connect():
        print(f"[hop-cam] Chromium connecting to {url}")
        return
    print(f"[hop-cam] opening {url} in the system browser")
    try:
        webbrowser.open(url)
    except Exception as exc:  # pragma: no cover
        print(f"[hop-cam] could not auto-open browser ({exc}); open the URL manually")


def _create_nadir_camera_server(traj_time: np.ndarray, pos_vis: np.ndarray) -> AnimatedServerHandle:
    """DEM-only viser scene for the body-fixed nadir camera (no traj / LiDAR)."""
    # Cover launch→apex→land under the narrow nadir FOV. Illumination unchanged.
    hop.TERRAIN_HALF_EXTENT_M = 320.0
    hop.DEM_POS_X_M = 95.0
    hop.DEM_POS_Y_M = 0.0
    hop.DEM_SCALE_X = 1.0
    hop.DEM_SCALE_Y = 1.0

    server = create_server(pos_vis, dark_mode=True, show_grid=False)
    server.gui.configure_theme(
        dark_mode=True,
        control_layout="collapsible",
        control_width="small",
        show_logo=False,
        show_share_button=False,
        titlebar_content=None,
    )
    try:
        server.scene.remove_by_name("/origin")
    except Exception:
        pass

    hop._add_dem_to_server(server, fov_slider=False)
    return AnimatedServerHandle(
        server=server,
        traj_time=np.asarray(traj_time, dtype=np.float64).flatten(),
        update_callbacks=[],
    )


if __name__ == "__main__":
    reuse = os.environ.get("SENSS_HOP_CAM_REUSE", "1") != "0"
    result = None
    if reuse and os.path.isfile(CACHE_PATH):
        try:
            with open(CACHE_PATH, "rb") as f:
                result = pickle.load(f)
            print(f"[hop-cam] reusing cached solve: {CACHE_PATH}")
        except Exception as exc:
            print(f"[hop-cam] cache load failed ({exc}); re-solving")
            result = None

    if result is None:
        problem = hop.problem
        problem.settings.dev.debug = True
        problem.settings.prp.dt = 1.0 / PROPAGATION_HZ
        problem.initialize()
        problem.solve()
        result = problem.post_process()
        Path(CACHE_PATH).parent.mkdir(parents=True, exist_ok=True)
        with open(CACHE_PATH, "wb") as f:
            pickle.dump(result, f)
        print(f"[hop-cam] cached solve -> {CACHE_PATH}")

    hop.prepare_for_viser(result)

    pos = np.asarray(result.trajectory["position"], dtype=np.float64)
    attitude = np.asarray(result.trajectory["attitude"], dtype=np.float64)
    t = np.asarray(result.trajectory["time"], dtype=np.float64).flatten()

    fov_deg = float(np.degrees(2.0 * lidar_half_fov_rad(LIDAR_FOOTPRINT_M, LIDAR_REF_RANGE_M)))
    print(
        f"[hop-cam] {len(t)} samples, T={t[-1]:.2f}s  ·  "
        f"nadir camera FOV={fov_deg:.2f}° "
        f"(LiDAR footprint={LIDAR_FOOTPRINT_M:g} m @ {LIDAR_REF_RANGE_M:g} m)"
    )
    print(f"[hop-cam] capture {WIDTH}x{HEIGHT} @ {DPI} dpi")

    _prepare_render_dem(RENDER_DEM_GRID)
    handle = _create_nadir_camera_server(t, pos)

    def camera_pose_fn(frame_idx: int):
        cam_pos, cam_wxyz = hop._los_sensor_fpv_pose(
            pos[frame_idx],
            attitude[frame_idx],
            R_SB_NADIR,
            roll_deg=0.0,
        )
        return cam_pos, cam_wxyz, None

    port = handle.server.get_port()
    url = f"http://localhost:{port}"
    _open_viser_client(url)

    out = render_animation_to_video(
        handle,
        Path(OUTPUT_PATH),
        camera_pose_fn,
        width=WIDTH,
        height=HEIGHT,
        fps=FPS,
        crf=CRF,
        preset=PRESET,
        background_color=(0, 0, 0),
        fov_deg=fov_deg,
        settle_s=0.05,
        progress_every=30,
    )
    print(f"[hop-cam] wrote {out}  ({WIDTH}x{HEIGHT} @ {DPI} dpi, {FPS} fps)")