Initial commit: scan_web integrated recording UI

FastAPI backend + vanilla JS frontend for LiDAR/camera startup, camera
settings, and rosbag recording, unifying the previous scan_gui/scan_gui_dual/
scan_gui_triple desktop tools into one web app.
This commit is contained in:
gardentech
2026-08-07 14:26:00 +09:00
commit f34817d5b6
38 changed files with 5430 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.types import Scope
import config
from process_manager import ProcessManager
from ros_bridge import ROSBridge
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger("scan_web.app")
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
class SessionState:
def __init__(self):
self.camera_count = config.DEFAULT_CAMERA_COUNT
self.recording = False
self.recording_path: str | None = None
self.recording_started_at: float | None = None
self.camera_values: dict = {} # cam_id -> camera_params.CameraParamValues
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.pm = ProcessManager()
app.state.session = SessionState()
app.state.ros = ROSBridge(camera_count=app.state.session.camera_count)
app.state.ros.start()
logger.info("scan_web backend started (camera_count=%s)", app.state.session.camera_count)
try:
yield
finally:
logger.info("shutting down — stopping all managed processes")
app.state.pm.stop_all()
app.state.ros.stop()
app = FastAPI(title="scan_web", lifespan=lifespan)
from routers import system, camera, recording, telemetry # noqa: E402 (needs app.state types defined above)
app.include_router(system.router, prefix="/api")
app.include_router(camera.router)
app.include_router(recording.router, prefix="/api")
app.include_router(telemetry.router)
class NoCacheStaticFiles(StaticFiles):
"""Frontend is actively edited during field testing — a browser caching a
stale index.html/app.js/style.css (no Cache-Control was set before) led
to confusing "it's not applying" reports that were actually just the
prior version still running. Static assets here are small and local, so
the no-caching cost is negligible."""
async def get_response(self, path: str, scope: Scope):
response = await super().get_response(path, scope)
response.headers["Cache-Control"] = "no-store"
return response
app.mount("/", NoCacheStaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend")
+55
View File
@@ -0,0 +1,55 @@
"""Read back what ros2 bag already knows about a recording — duration,
message counts, per-topic counts — instead of just listing directory names.
Also a plain on-disk size sum (metadata.yaml doesn't carry a byte size in
the ros2 bag version installed here)."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Optional
import yaml
def dir_size_bytes(path: Path) -> int:
total = 0
try:
for entry in path.iterdir():
if entry.is_file():
total += entry.stat().st_size
except OSError:
pass
return total
def read_bag_metadata(bag_dir: Path) -> Optional[dict]:
"""Returns None if metadata.yaml is missing (e.g. bag was killed mid-write
before rosbag2 flushed it) or unparseable — a real, if incomplete, state
the operator should be able to see rather than a crash."""
meta_path = bag_dir / "metadata.yaml"
if not meta_path.exists():
return None
try:
with open(meta_path, encoding="utf-8") as f:
raw = yaml.safe_load(f)
info = raw["rosbag2_bagfile_information"]
except Exception:
return None
duration_s = info.get("duration", {}).get("nanoseconds", 0) / 1e9
message_count = info.get("message_count", 0)
topics = []
for entry in info.get("topics_with_message_count", []):
tm = entry.get("topic_metadata", {})
topics.append({
"name": tm.get("name", "?"),
"type": tm.get("type", "?"),
"message_count": entry.get("message_count", 0),
})
return {
"duration_s": round(duration_s, 1),
"message_count": message_count,
"topics": topics,
"size_bytes": dir_size_bytes(bag_dir),
}
+158
View File
@@ -0,0 +1,158 @@
"""Camera exposure/gain parameter handling.
Ported from scan_gui_triple.py's per-camera tab logic:
- load defaults by reading the base params YAML (ros__parameters block)
- "apply": live `ros2 param set <node> <name> <value>` per param, checked
(subprocess.run with captured output), reporting structured per-param
success/failure — the triple GUI's more robust approach, preferred here
over the single/dual GUIs' fire-and-forget version.
- "save": regex text-patch of the base YAML, NOT yaml.dump — the source
YAML files carry human-authored comments (e.g. timestamp-calibration
notes) that a full re-dump would destroy.
"""
from __future__ import annotations
import re
import subprocess
from dataclasses import dataclass, asdict
from typing import Optional
import yaml
from config import ROS_SETUP, CAMERA2_WS_SETUP, CameraSpec
@dataclass
class CameraParamValues:
exposure_auto: bool
exposure_auto_target_brightness: int
exposure_auto_min: float
exposure_auto_max: float
exposure_time: int
gain: float
def as_dict(self) -> dict:
return asdict(self)
def load_defaults(cam: CameraSpec) -> CameraParamValues:
p = {}
try:
with open(cam.base_params_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f)
p = cfg[f"/{cam.node_name}"]["ros__parameters"]
except Exception:
pass
return CameraParamValues(
exposure_auto=bool(p.get("exposure_auto", False)),
exposure_auto_target_brightness=int(p.get("exposure_auto_target_brightness", 128)),
exposure_auto_min=float(p.get("exposure_auto_min", 100.0)),
exposure_auto_max=float(p.get("exposure_auto_max", 10000.0)),
exposure_time=int(p.get("exposure_time", 5000)),
gain=float(p.get("gain", 8.0)),
)
def write_launch_params_yaml(cam: CameraSpec, values: CameraParamValues, tmp_prefix: str = "/tmp") -> str:
"""Write a temp YAML (base + overridden values) for passing as a launch arg."""
import tempfile
try:
with open(cam.base_params_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f)
except Exception:
cfg = {}
node_key = f"/{cam.node_name}"
cfg.setdefault(node_key, {}).setdefault("ros__parameters", {})
cfg[node_key]["ros__parameters"].update(values.as_dict())
tf = tempfile.NamedTemporaryFile(
mode="w", suffix=".yaml", prefix=f"{tmp_prefix}/{cam.id}_params_", delete=False
)
yaml.dump(cfg, tf, default_flow_style=False, allow_unicode=True)
tf.close()
return tf.name
def _format_param_value(value) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, float):
return f"{value:.4f}"
return str(value)
def apply_params(cam: CameraSpec, values: CameraParamValues) -> list[dict]:
"""Run `ros2 param set` per param, checked. Returns [{name, ok, message}, ...]."""
node = f"/{cam.node_name}"
src = f"source {ROS_SETUP} && source {CAMERA2_WS_SETUP}"
order = ["exposure_auto"]
if values.exposure_auto:
order += ["exposure_auto_target_brightness", "exposure_auto_min", "exposure_auto_max"]
else:
order += ["exposure_time"]
order += ["gain"]
vals = values.as_dict()
results = []
for name in order:
val_str = _format_param_value(vals[name])
cmd = f"{src} && ros2 param set {node} {name} {val_str}"
try:
proc = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, timeout=10)
except subprocess.TimeoutExpired:
results.append({"name": name, "ok": False, "message": "timeout"})
continue
if proc.returncode != 0:
reason = (proc.stdout.strip() or proc.stderr.strip() or "unknown error")
results.append({"name": name, "ok": False, "message": reason})
else:
results.append({"name": name, "ok": True, "message": "ok"})
return results
def _patch_yaml_value(text: str, key: str, new_value: str) -> tuple[str, bool]:
pattern = re.compile(rf"^([ \t]*){re.escape(key)}:([ \t]*)\S+", re.MULTILINE)
new_text, n = pattern.subn(
lambda m: f"{m.group(1)}{key}:{m.group(2)}{new_value}", text, count=1,
)
return new_text, n > 0
def _insert_missing_yaml_keys(text: str, missing: dict) -> str:
indent = " "
anchor_pos: Optional[int] = None
for anchor in ("exposure_time", "gain"):
m = re.search(rf"^([ \t]*){re.escape(anchor)}:", text, re.MULTILINE)
if m:
indent = m.group(1)
anchor_pos = m.start()
break
block = "".join(f"{indent}{k}: {v}\n" for k, v in missing.items())
if anchor_pos is None:
return text.rstrip("\n") + "\n" + block
return text[:anchor_pos] + block + text[anchor_pos:]
def save_params(cam: CameraSpec, values: CameraParamValues) -> None:
"""Persist values into the base YAML via comment-preserving text patch."""
formatted = {
"exposure_auto": "true" if values.exposure_auto else "false",
"exposure_auto_target_brightness": str(values.exposure_auto_target_brightness),
"exposure_auto_min": f"{values.exposure_auto_min:.1f}",
"exposure_auto_max": f"{values.exposure_auto_max:.1f}",
"exposure_time": str(values.exposure_time),
"gain": f"{values.gain:.1f}",
}
with open(cam.base_params_path, encoding="utf-8") as f:
text = f.read()
missing = {}
for key, val in formatted.items():
text, found = _patch_yaml_value(text, key, val)
if not found:
missing[key] = val
if missing:
text = _insert_missing_yaml_keys(text, missing)
with open(cam.base_params_path, "w", encoding="utf-8") as f:
f.write(text)
+235
View File
@@ -0,0 +1,235 @@
"""Workspace paths, per-camera-count topic/launch config, and tunable thresholds.
Values here are ported 1:1 from the source of truth in ~/fast_ws/scan_gui.py,
scan_gui_dual.py and scan_gui_triple.py — see docstrings in each CameraSpec
list for which file a given camera-count profile was copied from.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
HOME = Path.home()
ROS_SETUP = "/opt/ros/humble/setup.bash"
# fast_ws is deprecated (2026-08-05, user decision) — it never had its own
# livox_ros_driver2 build, it only chained to lidar2_ws/install as an
# underlay (confirmed: COLCON_CURRENT_PREFIX in fast_ws/install/setup.bash
# points at lidar2_ws/install). Source lidar2_ws directly instead.
LIDAR2_WS_SETUP = str(HOME / "lidar2_ws/install/setup.bash")
CAMERA2_WS_SETUP = str(HOME / "camera2_ws/install/setup.bash")
FAST_DUAL_WS_SETUP = str(HOME / "fast_dual_ws/install/setup.bash")
RTK_WS_SETUP = str(HOME / "rtk_ws/install/setup.bash")
# hku-mars/FAST_LIO ROS2 port, LiDAR+IMU-only mapping (no camera) — confirmed
# working 2026-08-06. Its install/setup.bash was built with only
# /opt/ros/humble as an underlay (not chained to lidar2_ws), but it subscribes
# livox_ros_driver2::msg::CustomMsg on /livox/lidar, so lidar2_ws must still
# be sourced first at launch time for that message package to resolve.
FAST_LIO_WS_SETUP = str(HOME / "fast_lio/install/setup.bash")
LIDAR_LAUNCH_CMD = (
f"source {ROS_SETUP} && source {LIDAR2_WS_SETUP} && "
"stdbuf -oL -eL ros2 launch livox_ros_driver2 mid360s_fastlivo_launch.py"
)
CAMERA_LAUNCH_SOURCE = f"source {ROS_SETUP} && source {CAMERA2_WS_SETUP}"
# rviz:=false — this is launched headlessly as a recording-lifecycle
# subprocess, not an interactive session; mapping.launch.py defaults rviz to
# true which would otherwise pop an unwanted GUI window.
FASTLIO_LAUNCH_CMD = (
f"source {ROS_SETUP} && source {LIDAR2_WS_SETUP} && source {FAST_LIO_WS_SETUP} && "
"stdbuf -oL -eL ros2 launch fast_lio mapping.launch.py rviz:=false"
)
def fastlio_env() -> dict:
"""LD_LIBRARY_PATH override for the fastlio subprocess.
run_scan_web.sh sources camera2_ws (for the Hikvision camera driver)
before exec'ing uvicorn, which puts the Hikvision SDK's lib dirs on
LD_LIBRARY_PATH for the whole backend process — and therefore, by
default, every ManagedProcess it spawns too (env = os.environ.copy()).
fastlio_mapping doesn't use the camera SDK at all, but its bundled
libusb-1.0.so.0 is old enough to be missing libusb_set_option, a symbol
PCL's libpcl_io needs — with that copy resolved first, fastlio_mapping
fails to even start (symbol lookup error, confirmed 2026-08-06).
Two separate paths carry this old libusb, not just one — /opt/MVS/lib/*
(the vendor SDK's own install) AND
camera2_ws/install/hik_camera_ros2_driver/lib (which symlinks straight
into the same vendored hikSDK/lib/amd64 copy) — both get filtered here.
"""
bad = ("mvs", "hik_camera", "hiksdk")
parts = os.environ.get("LD_LIBRARY_PATH", "").split(":")
cleaned = ":".join(p for p in parts if p and not any(b in p.lower() for b in bad))
return {"LD_LIBRARY_PATH": cleaned}
CAMERA_DELAY_SEC = 5 # let LiDAR init before starting cameras — ported from scan_gui*.py
LOG_DIR = HOME / "scan_web_logs"
LIDAR_LOG_PATH = LOG_DIR / "lidar.log"
CAMERA_LOG_PATH = LOG_DIR / "camera.log"
RECORDING_LOG_PATH = LOG_DIR / "recording.log"
FASTLIO_LOG_PATH = LOG_DIR / "fastlio.log"
GNSS_LOG_PATH = LOG_DIR / "gnss.log"
DEFAULT_SAVE_DIR = str(HOME / "bags") # confirmed default from scan_gui.py:460
@dataclass(frozen=True)
class CameraSpec:
id: str
image_topic: str
info_topic: str
node_name: str # ROS node name, no leading slash
base_params_path: str
launch_arg: str # launch-file argument name this camera's params file is passed as
HIK_CFG_DIR = HOME / "camera2_ws/src/hik_camera_ros2_driver/config"
# camera_count -> (launch_file, [CameraSpec, ...])
CAMERA_PROFILES: dict[int, tuple[str, list[CameraSpec]]] = {
1: (
"hik_camera_launch.py",
[
CameraSpec(
id="cam1",
image_topic="/camera/image",
info_topic="/camera/camera_info",
node_name="hik_camera_ros2_driver",
base_params_path=str(HIK_CFG_DIR / "camera_params.yaml"),
launch_arg="params_file",
),
],
),
2: (
"hik_camera_dual_launch.py",
[
CameraSpec(
id="cam1",
image_topic="/cam1/image",
info_topic="/cam1/camera_info",
node_name="hik_camera_cam1",
base_params_path=str(HIK_CFG_DIR / "camera_params_cam1.yaml"),
launch_arg="cam1_params_file",
),
CameraSpec(
id="cam2",
image_topic="/cam2/image",
info_topic="/cam2/camera_info",
node_name="hik_camera_cam2",
base_params_path=str(HIK_CFG_DIR / "camera_params_cam2.yaml"),
launch_arg="cam2_params_file",
),
],
),
3: (
"hik_camera_triple_launch.py",
[
CameraSpec(
id="cam1",
image_topic="/cam1/image",
info_topic="/cam1/camera_info",
node_name="hik_camera_cam1",
base_params_path=str(HIK_CFG_DIR / "camera_params_cam1.yaml"),
launch_arg="cam1_params_file",
),
CameraSpec(
id="cam2",
image_topic="/cam2/image",
info_topic="/cam2/camera_info",
node_name="hik_camera_cam2",
base_params_path=str(HIK_CFG_DIR / "camera_params_cam2.yaml"),
launch_arg="cam2_params_file",
),
CameraSpec(
id="cam3",
image_topic="/cam3/image",
info_topic="/cam3/camera_info",
node_name="hik_camera_cam3",
base_params_path=str(HIK_CFG_DIR / "camera_params_cam3.yaml"),
launch_arg="cam3_params_file",
),
],
),
}
DEFAULT_CAMERA_COUNT = 3 # matches the physical rig described by the user (3 cams + MID360s)
def camera_specs(camera_count: int) -> list[CameraSpec]:
if camera_count not in CAMERA_PROFILES:
raise ValueError(f"unsupported camera_count={camera_count}, expected one of {sorted(CAMERA_PROFILES)}")
return CAMERA_PROFILES[camera_count][1]
def camera_launch_file(camera_count: int) -> str:
return CAMERA_PROFILES[camera_count][0]
def base_record_topics(camera_count: int) -> str:
parts = ["/livox/lidar", "/livox/imu"]
for cam in camera_specs(camera_count):
parts += [cam.image_topic, cam.info_topic]
return " ".join(parts)
ROSBAG_SOURCE = f"source {ROS_SETUP} && source {LIDAR2_WS_SETUP} && source {CAMERA2_WS_SETUP}"
# GPS is out of scope for M1 (see plan milestone M2) — topic names kept here only
# as forward-reference documentation, not wired to any process/health logic yet.
GPS_TOPICS_LEGACY = "/gps/fix /gps/nmea"
GPS_TOPICS_RTK = "/ublox_driver/receiver_pvt"
DISK_LOW_WARNING_GB = 10.0 # badge turns warning below this
DISK_LOW_DANGER_GB = 2.0 # badge turns danger below this — a long scan can lose a bag here
def disk_free_gb(path: str) -> float:
"""Free space (GiB) on the filesystem backing `path` (walks up to an
existing ancestor dir first — the save dir itself may not exist yet)."""
import shutil as _shutil
p = Path(path)
while not p.exists() and p != p.parent:
p = p.parent
usage = _shutil.disk_usage(p)
return usage.free / (1024 ** 3)
# ── Health staleness thresholds (seconds) — proposed defaults, tune against
# real observed rates once M1-M3 are running (see plan §Health synthesis). ──
HEALTH_THRESHOLDS = {
"/livox/lidar": (1.0, 3.0),
# No "/livox/imu" entry — not subscribed at all anymore, see
# ros_bridge.py's module docstring. imu_health in /api/status mirrors
# lidar's own classification instead.
"camera_image": (1.5, 4.0), # applied per-camera image topic
# /Odometry, not /aft_mapped_to_init — that was FAST-LIVO2/LOAM-family
# naming, anticipated before the actual M3 backend (hku-mars FAST_LIO
# ROS2 port, confirmed 2026-08-06) was built. Confirmed via
# laserMapping.cpp: pubOdomAftMapped_ publishes nav_msgs/Odometry on
# "/Odometry".
"/Odometry": (1.0, 4.0),
}
# ── M3 live point-cloud view ────────────────────────────────────────────
# FAST-LIO's /cloud_registered is the per-scan (not cumulative) downsampled
# world-frame cloud — scan_web accumulates it server-side (see
# pointcloud_codec.py) so the operator sees full scan coverage, not a
# blinking per-scan cloud.
POINTCLOUD_TOPIC = "/cloud_registered"
ODOMETRY_TOPIC = "/Odometry"
POINTCLOUD_VOXEL_SIZE_M = 0.05 # accumulation dedup grid
POINTCLOUD_MAX_ACCUM_POINTS = 200_000 # hard ceiling; oldest points evicted past this
# Two output profiles selected at WS-connect time (?profile=desktop|phone) —
# phone gets fewer points at a lower rate, reasoned from three.js draw cost
# and WS bandwidth on field WiFi. Both sample from the same accumulated
# buffer, just decimated differently per frame.
POINTCLOUD_PROFILES = {
"desktop": {"max_points": 20_000, "hz": 5.0},
"phone": {"max_points": 7_000, "hz": 3.5},
}
+85
View File
@@ -0,0 +1,85 @@
"""Synthesized topic health (no diagnostics topics exist upstream — see plan).
Classification per topic:
DOWN - backing process not running, OR no message ever received since
process start, OR staleness beyond down_threshold
STALE - running, >=1 message received, staleness in (stale, down]
OK - staleness <= stale_threshold
Also tracks a rolling message rate (Hz) per topic — a measured number is a
more actionable status signal than a traffic light alone (e.g. "cam2 4.1Hz"
tells the operator it's alive but degraded, not just "not OK").
"""
from __future__ import annotations
import threading
import time
from collections import deque
from dataclasses import dataclass
RATE_WINDOW = 150 # samples kept per topic — larger window trades responsiveness for a
# stable number: our own callback dispatch has scheduling jitter (see
# ros_bridge.py's MultiThreadedExecutor/GIL-contention notes), so a short
# window made the displayed Hz visibly jump around between updates even
# when the underlying topic rate was steady. 150 samples spans ~25s for a
# ~6Hz camera down to ~2s for ~80Hz IMU — smooths out that jitter without
# the number going stale-looking on genuinely slow topics.
RATE_STALE_S = 2.0 # if last message older than this, report rate as 0 regardless of window
@dataclass
class TopicThreshold:
stale_s: float
down_s: float
class TopicHealthTracker:
def __init__(self):
self._last_msg_time: dict[str, float] = {}
self._recent_times: dict[str, deque] = {}
self._lock = threading.Lock()
def mark_received(self, topic: str) -> None:
now = time.monotonic()
with self._lock:
self._last_msg_time[topic] = now
dq = self._recent_times.setdefault(topic, deque(maxlen=RATE_WINDOW))
dq.append(now)
def reset(self, topic: str) -> None:
with self._lock:
self._last_msg_time.pop(topic, None)
self._recent_times.pop(topic, None)
def classify(self, topic: str, process_running: bool, thresholds: TopicThreshold) -> str:
if not process_running:
return "DOWN"
with self._lock:
last = self._last_msg_time.get(topic)
if last is None:
return "DOWN"
staleness = time.monotonic() - last
if staleness <= thresholds.stale_s:
return "OK"
if staleness <= thresholds.down_s:
return "STALE"
return "DOWN"
def staleness(self, topic: str) -> float | None:
with self._lock:
last = self._last_msg_time.get(topic)
return None if last is None else time.monotonic() - last
def rate_hz(self, topic: str) -> float:
"""Measured message rate over the last few samples. 0.0 if stale or unseen."""
with self._lock:
last = self._last_msg_time.get(topic)
dq = self._recent_times.get(topic)
if last is None or dq is None or len(dq) < 2:
return 0.0
if time.monotonic() - last > RATE_STALE_S:
return 0.0
span = dq[-1] - dq[0]
if span <= 0:
return 0.0
return round((len(dq) - 1) / span, 1)
+54
View File
@@ -0,0 +1,54 @@
"""Tails the lidar/camera/recording log files for the browser log panel.
The original PyQt5 scan GUIs had a single unified QPlainTextEdit showing all
subprocess stdout with a [tag] prefix — this is the web equivalent. Each
ManagedProcess re-opens its log file in "w" mode on every start (see
process_manager.py), so a tailer must detect truncation (file shrank since
last read) and reset to the top rather than seeking past EOF forever.
"""
from __future__ import annotations
from pathlib import Path
from typing import Iterable
class LogTailer:
def __init__(self, sources: dict[str, Path]):
self._sources = sources
self._offsets: dict[str, int] = {tag: 0 for tag in sources}
def read_tail(self, tag: str, max_lines: int = 200) -> list[str]:
"""Backlog for a fresh websocket connection — last N lines, and
advances this tag's offset to end-of-file so read_new() only
reports genuinely new lines afterwards."""
path = self._sources.get(tag)
if path is None or not path.exists():
return []
try:
with open(path, "r", errors="replace") as f:
lines = f.readlines()
self._offsets[tag] = f.tell()
except OSError:
return []
return [ln.rstrip("\n") for ln in lines[-max_lines:]]
def read_new(self) -> Iterable[tuple[str, str]]:
"""Yields (tag, line) for every line appended since the last call."""
for tag, path in self._sources.items():
if not path.exists():
continue
try:
size = path.stat().st_size
if size < self._offsets.get(tag, 0):
self._offsets[tag] = 0 # file was truncated (process restarted)
with open(path, "r", errors="replace") as f:
f.seek(self._offsets[tag])
new_text = f.read()
self._offsets[tag] = f.tell()
except OSError:
continue
if not new_text:
continue
for line in new_text.splitlines():
if line:
yield tag, line
+145
View File
@@ -0,0 +1,145 @@
"""Accumulates FAST-LIO's per-scan /cloud_registered into a running
world-frame point cloud (voxel-deduped, capped at a hard point ceiling) and
encodes decimated binary frames for the /ws/pointcloud stream.
Binary frame layout (all little-endian, no padding):
4s magic b"PCF1"
I frame_seq
d timestamp (unix seconds)
I pose_flag 1 if a pose follows, else 0 (uint32, not uint8 — keeps
everything after it 4-byte aligned so the client can
construct a Float32Array view directly on the xyz
bytes below instead of copying)
7f pose x, y, z, qx, qy, qz, qw (only present if pose_flag)
I point_count N
Nx3 f4 xyz, world frame, contiguous [x0,y0,z0, x1,y1,z1, ...]
Nx1 u1 intensity, normalized to 0-255
xyz and intensity are separate contiguous arrays (not an interleaved struct)
so both ends can hand them straight to a typed array / numpy view.
"""
from __future__ import annotations
import struct
import threading
import time
from typing import Optional
import numpy as np
MAGIC = b"PCF1"
class PointCloudAccumulator:
def __init__(self, voxel_size: float, max_points: int):
self.voxel_size = voxel_size
self.max_points = max_points
self._lock = threading.Lock()
self._xyz = np.empty((0, 3), dtype=np.float32)
self._intensity = np.empty((0,), dtype=np.float32)
self._keys: list[int] = [] # packed voxel key per row, same order as _xyz
self._key_set: set[int] = set() # membership test for incoming-scan dedup
self._pose: Optional[tuple] = None # (x,y,z,qx,qy,qz,qw)
self._frame_seq = 0
# ── ingest (called from the ROS-side worker thread) ─────────────────
def _pack_keys(self, xyz: np.ndarray) -> np.ndarray:
# Packs a voxel-quantized (ix,iy,iz) into one int64 so it's hashable
# for the Python set below. OFFSET/BASE give each axis +-2^19 voxels
# of range (with the default 5cm voxel that's +-26km), comfortably
# covering FAST-LIO's 100m det_range with headroom to spare, while
# staying well inside int64.
OFFSET = 1 << 19
BASE = 1 << 20
idx = np.floor(xyz / self.voxel_size).astype(np.int64) + OFFSET
return (idx[:, 0] * BASE + idx[:, 1]) * BASE + idx[:, 2]
def add_scan(self, xyz: np.ndarray, intensity: np.ndarray) -> None:
"""xyz: (K,3) float32 world-frame points from one /cloud_registered message."""
if xyz.shape[0] == 0:
return
keys = self._pack_keys(xyz)
# Dedup within this scan first (a downsampled scan can still put
# multiple points in one voxel), then keep only voxels not already
# in the accumulated buffer.
_, first_idx = np.unique(keys, return_index=True)
keys = keys[first_idx]
xyz = xyz[first_idx]
intensity = intensity[first_idx]
with self._lock:
novel = np.fromiter((k not in self._key_set for k in keys), dtype=bool, count=len(keys))
if not novel.any():
return
new_xyz = xyz[novel]
new_intensity = intensity[novel]
new_keys = keys[novel]
self._xyz = np.concatenate([self._xyz, new_xyz])
self._intensity = np.concatenate([self._intensity, new_intensity])
self._keys.extend(int(k) for k in new_keys)
self._key_set.update(int(k) for k in new_keys)
overflow = len(self._keys) - self.max_points
if overflow > 0:
# FIFO eviction — oldest points dropped first once over budget.
evicted, self._keys = self._keys[:overflow], self._keys[overflow:]
self._key_set.difference_update(evicted)
self._xyz = self._xyz[overflow:]
self._intensity = self._intensity[overflow:]
def set_pose(self, x: float, y: float, z: float, qx: float, qy: float, qz: float, qw: float) -> None:
with self._lock:
self._pose = (x, y, z, qx, qy, qz, qw)
def point_count(self) -> int:
with self._lock:
return self._xyz.shape[0]
def reset(self) -> None:
"""Clears the accumulated map — call when a new recording starts so
the previous scan's points don't linger into the next one."""
with self._lock:
self._xyz = np.empty((0, 3), dtype=np.float32)
self._intensity = np.empty((0,), dtype=np.float32)
self._keys = []
self._key_set = set()
self._pose = None
self._frame_seq = 0
# ── output (called from the asyncio WS loop) ─────────────────────────
def encode_frame(self, max_points: int) -> bytes:
with self._lock:
n_total = self._xyz.shape[0]
if n_total <= max_points:
xyz, intensity = self._xyz, self._intensity
else:
# Stride sample, not random — keeps the decimated view stable
# frame-to-frame instead of sparkling with a fresh random
# subset every send.
idx = np.linspace(0, n_total - 1, max_points).astype(np.int64)
xyz, intensity = self._xyz[idx], self._intensity[idx]
pose = self._pose
self._frame_seq += 1
seq = self._frame_seq
return _pack_frame(seq, time.time(), pose, xyz, intensity)
def _pack_frame(seq: int, timestamp: float, pose: Optional[tuple], xyz: np.ndarray, intensity: np.ndarray) -> bytes:
n = xyz.shape[0]
parts = [MAGIC, struct.pack("<Id", seq, timestamp)]
if pose is not None:
parts.append(struct.pack("<I", 1))
parts.append(struct.pack("<7f", *pose))
else:
parts.append(struct.pack("<I", 0))
parts.append(struct.pack("<I", n))
parts.append(np.ascontiguousarray(xyz, dtype="<f4").tobytes())
# Livox reflectivity/intensity isn't guaranteed to sit in 0-255, but in
# practice runs close to it — plain clip+cast is a fine first cut, tune
# once real field values are observed.
intensity_u8 = np.clip(intensity, 0, 255).astype(np.uint8)
parts.append(np.ascontiguousarray(intensity_u8).tobytes())
return b"".join(parts)
+141
View File
@@ -0,0 +1,141 @@
"""Subprocess orchestration for lidar/camera/recording processes.
ManagedProcess.stop() is a near-literal port of the `_kill_proc` pattern used
by scan_gui.py / scan_gui_dual.py / scan_gui_triple.py: SIGINT the whole
process group (so `ros2 launch` subtrees and `ros2 bag record` get a chance
to shut down / flush cleanly), wait, then SIGKILL stragglers. This must not
regress — it's the one piece of logic explicitly called out as load-bearing
in the design plan.
"""
from __future__ import annotations
import logging
import os
import signal
import subprocess
import threading
import time
from pathlib import Path
from typing import Optional
import psutil
logger = logging.getLogger("scan_web.process_manager")
class ManagedProcess:
def __init__(self, name: str, cmd: str, log_path: Optional[Path] = None, env: Optional[dict] = None):
self.name = name
self.cmd = cmd
self.log_path = log_path
self.env = env
self._proc: Optional[subprocess.Popen] = None
self._log_file = None
self._lock = threading.Lock()
self.started_at: Optional[float] = None
def start(self) -> None:
with self._lock:
if self.is_running():
return
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
if self.env:
env.update(self.env)
stdout = subprocess.DEVNULL
stderr = subprocess.DEVNULL
if self.log_path is not None:
self.log_path.parent.mkdir(parents=True, exist_ok=True)
self._log_file = open(self.log_path, "w")
stdout = self._log_file
stderr = subprocess.STDOUT
self._proc = subprocess.Popen(
["bash", "-c", self.cmd],
stdout=stdout, stderr=stderr,
preexec_fn=os.setsid, env=env,
)
self.started_at = time.monotonic()
logger.info("started %s pid=%s", self.name, self._proc.pid)
def is_running(self) -> bool:
return self._proc is not None and self._proc.poll() is None
def pid(self) -> Optional[int]:
return self._proc.pid if self._proc else None
def stop(self, timeout: float = 3.0) -> None:
with self._lock:
proc = self._proc
if proc is None or proc.poll() is not None:
self._cleanup()
return
try:
parent = psutil.Process(proc.pid)
children = parent.children(recursive=True)
except psutil.NoSuchProcess:
self._cleanup()
return
for p in children + [parent]:
try:
p.send_signal(signal.SIGINT)
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
_, alive = psutil.wait_procs(children + [parent], timeout=timeout)
for p in alive:
try:
p.kill()
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
logger.info("stopped %s", self.name)
self._cleanup()
def _cleanup(self) -> None:
self._proc = None
self.started_at = None
if self._log_file:
self._log_file.close()
self._log_file = None
class ProcessManager:
"""App-lifetime singleton (stored on app.state) tracking all managed processes."""
def __init__(self):
self._procs: dict[str, ManagedProcess] = {}
self._lock = threading.Lock()
def start(self, name: str, cmd: str, log_path: Optional[Path] = None, env: Optional[dict] = None) -> ManagedProcess:
with self._lock:
mp = self._procs.get(name)
if mp is None or not mp.is_running():
mp = ManagedProcess(name, cmd, log_path=log_path, env=env)
self._procs[name] = mp
mp.start()
return mp
def stop(self, name: str, timeout: float = 3.0) -> None:
with self._lock:
mp = self._procs.get(name)
if mp is not None:
mp.stop(timeout=timeout)
def is_running(self, name: str) -> bool:
with self._lock:
mp = self._procs.get(name)
return mp is not None and mp.is_running()
def status(self) -> dict[str, dict]:
with self._lock:
items = list(self._procs.items())
return {
name: {"running": mp.is_running(), "pid": mp.pid()}
for name, mp in items
}
def stop_all(self, timeout: float = 3.0) -> None:
with self._lock:
names = list(self._procs.keys())
for name in names:
self.stop(name, timeout=timeout)
+5
View File
@@ -0,0 +1,5 @@
fastapi>=0.110
uvicorn[standard]>=0.29
websockets>=12.0
numpy>=1.22
PyYAML>=6.0
+282
View File
@@ -0,0 +1,282 @@
"""Hosts rclpy inside the FastAPI process.
Mirrors the isolation pattern used by RosSubscriber in scan_gui_triple.py:
a dedicated rclpy.Context + Node, spun in a daemon thread, kept out of the
ASGI event loop entirely. One node, one MultiThreadedExecutor, one shared
ReentrantCallbackGroup for every subscription.
/livox/imu (~200Hz) is deliberately NOT subscribed here — it's ~20x every
other topic's rate, and every way of grouping it alongside slower topics
(shared default group, shared Reentrant group, its own dedicated
Context+Node+thread, split-by-cost with the other cheap raw subscriptions)
measurably starved the slower ones, cameras worst of all — confirmed
2026-08-06 across several configurations, right down to cameras measuring
~0Hz against a real ~10Hz feed even after every other topic's callback was
made cheap (raw=True). The operator decided camera accuracy matters more
than a live IMU Hz reading, so IMU health is now inferred from lidar's
health instead of measured directly (see routers/system.py) — they come off
the same livox_ros_driver2 process, so lidar being alive is already strong
evidence IMU is too. If IMU health/Hz needs to be real again later, give it
back its own dedicated Context+Node+executor+thread (not a shared one) and
expect to spend real GIL budget on it.
Subscriptions cover: camera images (JPEG-encoded for MJPEG serving), the raw
lidar driver topic (health only), and (M3) FAST-LIO's point cloud +
odometry for the live scan view. GPS (M2) is not yet added. All
subscriptions register once at startup, never per start/stop call, so
health.py can tell "process down" apart from "never subscribed".
Cross-thread handoff: ROS callbacks (background thread) write into a
lock-guarded "latest value" dict; HTTP/WS handlers (asyncio loop) read it.
No queues — a slow reader just sees a slightly stale value, never backs up
ROS callback processing.
"""
from __future__ import annotations
import logging
import threading
import time
from typing import Optional
import config
from config import camera_specs
from health import TopicHealthTracker
from pointcloud_codec import PointCloudAccumulator
logger = logging.getLogger("scan_web.ros_bridge")
try:
import numpy as np
import rclpy
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.executors import MultiThreadedExecutor
from rclpy.qos import QoSProfile, QoSReliabilityPolicy, QoSHistoryPolicy
from sensor_msgs.msg import Image, PointCloud2
from nav_msgs.msg import Odometry
from livox_ros_driver2.msg import CustomMsg
from cv_bridge import CvBridge
import sensor_msgs_py.point_cloud2 as pc2
import cv2
ROS_AVAILABLE = True
except ImportError:
ROS_AVAILABLE = False
class ROSBridge:
def __init__(self, camera_count: int):
self.camera_count = camera_count
self.health = TopicHealthTracker()
self._running = False
self._thread: Optional[threading.Thread] = None
self._ctx = None # set in _spin(); stop() shuts it down to unblock executor.spin()
self._bridge = CvBridge() if ROS_AVAILABLE else None
self._frame_lock = threading.Lock()
self._latest_jpeg: dict[str, bytes] = {}
self._raw_lock = threading.Lock()
self._latest_raw: dict[str, object] = {}
self._frame_events: dict[str, threading.Event] = {}
self.pointcloud = PointCloudAccumulator(
config.POINTCLOUD_VOXEL_SIZE_M, config.POINTCLOUD_MAX_ACCUM_POINTS,
)
# ── lifecycle ──────────────────────────────────────────────────────────
def start(self) -> None:
if not ROS_AVAILABLE:
logger.warning("rclpy not available (ROS overlays not sourced?) — camera preview disabled")
return
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._spin, daemon=True, name="ros-bridge")
self._thread.start()
def stop(self) -> None:
self._running = False
if self._ctx is not None:
# Unblocks the executor.spin() call in _spin()'s thread from out
# here — spin() only checks context.ok()/is_shutdown, it doesn't
# know about self._running.
try:
self._ctx.shutdown()
except Exception:
pass
if self._thread:
self._thread.join(timeout=2.0)
self._thread = None
def _spin(self) -> None:
ctx = rclpy.Context()
ctx.init()
self._ctx = ctx
try:
node = rclpy.create_node("scan_web_bridge", context=ctx)
# One shared ReentrantCallbackGroup for every subscription below
# — the node's default MutuallyExclusiveCallbackGroup would
# serialize dispatch across all of them regardless of executor
# type. No /livox/imu here (see module docstring) — with it
# removed, the remaining topics are all roughly the same order
# of magnitude (~10Hz), so they coexist fairly under one
# Reentrant group without needing to be split further.
cbg = ReentrantCallbackGroup()
img_qos = QoSProfile(
reliability=QoSReliabilityPolicy.BEST_EFFORT,
history=QoSHistoryPolicy.KEEP_LAST,
depth=1,
)
for cam in camera_specs(self.camera_count):
node.create_subscription(
Image, cam.image_topic,
lambda msg, c=cam.id, t=cam.image_topic: self._on_image(c, t, msg),
img_qos, callback_group=cbg,
)
# Raw (undeserialized) lidar driver topic — subscribed only for
# staleness/health tracking, not decoded/stored. This callback
# never touches the payload, so raw=True skips deserialization
# cost entirely (mirrors what `ros2 bag record` does
# internally). Distinct from the LIO mapping output
# (/aft_mapped_to_init etc.), which is M3 scope.
sensor_qos = QoSProfile(
reliability=QoSReliabilityPolicy.BEST_EFFORT,
history=QoSHistoryPolicy.KEEP_LAST,
depth=1,
)
node.create_subscription(
CustomMsg, "/livox/lidar",
lambda msg: self.health.mark_received("/livox/lidar"), sensor_qos, callback_group=cbg, raw=True,
)
# M3 — FAST-LIO's live mapping output. /cloud_registered is only
# published while the "fastlio" process is running (tied to the
# recording lifecycle, see routers/recording.py), same as lidar
# is gated on the "lidar" process.
node.create_subscription(
PointCloud2, config.POINTCLOUD_TOPIC, self._on_pointcloud, sensor_qos, callback_group=cbg,
)
node.create_subscription(
Odometry, config.ODOMETRY_TOPIC, self._on_odometry, sensor_qos, callback_group=cbg,
)
# MultiThreadedExecutor (not Single) — spin_once() on either type
# still only pulls ONE ready entity per call, but Single runs its
# callback synchronously inline before the next pull, whereas
# MultiThreaded submits it to a worker pool and returns
# immediately, so the poll loop keeps up with several ~10Hz
# topics instead of falling behind and starving each other.
# num_threads is deliberately just above the subscription count
# (6: 3 cameras + lidar + pointcloud + odometry) rather than
# generously oversized — Python's GIL means more threads doesn't
# mean more parallel throughput, only more context-switch
# overhead once you're past "enough to avoid queueing".
executor = MultiThreadedExecutor(context=ctx, num_threads=6)
executor.add_node(node)
executor.spin()
executor.remove_node(node)
node.destroy_node()
except Exception:
logger.exception("ros bridge spin loop crashed")
finally:
self._ctx = None
try:
ctx.shutdown()
except Exception:
pass
# ── callbacks ──────────────────────────────────────────────────────────
def _on_image(self, cam_id: str, topic: str, msg) -> None:
# Record arrival immediately and hand the deserialized msg off to a
# per-camera worker thread. cv_bridge/JPEG encoding is too slow to
# run inline here — running it directly in the ROS callback would
# tie up that callback's executor thread for the encode's duration,
# needlessly limiting how many camera frames can be in flight
# concurrently.
self.health.mark_received(topic)
with self._raw_lock:
self._latest_raw[cam_id] = msg
ev = self._frame_events.get(cam_id)
if ev is None:
ev = threading.Event()
self._frame_events[cam_id] = ev
threading.Thread(
target=self._encode_loop, args=(cam_id,), daemon=True, name=f"encode-{cam_id}",
).start()
ev.set()
def _encode_loop(self, cam_id: str) -> None:
ev = self._frame_events[cam_id]
while self._running:
if not ev.wait(timeout=0.5):
continue
ev.clear()
with self._raw_lock:
msg = self._latest_raw.get(cam_id)
if msg is None:
continue
try:
frame = self._bridge.imgmsg_to_cv2(msg, desired_encoding="bgr8")
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
if not ok:
continue
with self._frame_lock:
self._latest_jpeg[cam_id] = buf.tobytes()
except Exception:
logger.exception("failed to decode frame for %s", cam_id)
def _on_pointcloud(self, msg) -> None:
# Same reasoning as _on_image: read_points_numpy + voxel-dedup
# accumulation is too slow to run inline on the shared spin thread,
# so hand the raw msg off to a dedicated worker thread.
self.health.mark_received(config.POINTCLOUD_TOPIC)
with self._raw_lock:
self._latest_raw["pointcloud"] = msg
ev = self._frame_events.get("pointcloud")
if ev is None:
ev = threading.Event()
self._frame_events["pointcloud"] = ev
threading.Thread(target=self._pointcloud_loop, daemon=True, name="pointcloud-accum").start()
ev.set()
def _pointcloud_loop(self) -> None:
ev = self._frame_events["pointcloud"]
while self._running:
if not ev.wait(timeout=0.5):
continue
ev.clear()
with self._raw_lock:
msg = self._latest_raw.get("pointcloud")
if msg is None:
continue
try:
arr = pc2.read_points_numpy(msg, field_names=("x", "y", "z", "intensity"), skip_nans=True)
if arr.size == 0:
continue
xyz = np.ascontiguousarray(arr[:, :3], dtype=np.float32)
intensity = np.ascontiguousarray(arr[:, 3], dtype=np.float32)
self.pointcloud.add_scan(xyz, intensity)
except Exception:
logger.exception("failed to accumulate point cloud")
def _on_odometry(self, msg) -> None:
# Cheap (a few float unpacks) — fine to run inline on the spin thread.
self.health.mark_received(config.ODOMETRY_TOPIC)
p = msg.pose.pose.position
q = msg.pose.pose.orientation
self.pointcloud.set_pose(p.x, p.y, p.z, q.x, q.y, q.z, q.w)
# ── readers (called from asyncio handlers) ───────────────────────────────
def latest_jpeg(self, cam_id: str) -> Optional[bytes]:
with self._frame_lock:
return self._latest_jpeg.get(cam_id)
def mjpeg_generator(self, cam_id: str, target_hz: float = 12.0):
"""Blocking generator (run in a threadpool) yielding multipart MJPEG chunks."""
period = 1.0 / target_hz
boundary = b"--frame"
while self._running:
frame = self.latest_jpeg(cam_id)
if frame is not None:
yield (
boundary + b"\r\nContent-Type: image/jpeg\r\nContent-Length: "
+ str(len(frame)).encode() + b"\r\n\r\n" + frame + b"\r\n"
)
time.sleep(period)
View File
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from starlette.concurrency import run_in_threadpool
import camera_params
import config
router = APIRouter(prefix="/api/camera", tags=["camera"])
class ParamValuesIn(BaseModel):
exposure_auto: bool
exposure_auto_target_brightness: int
exposure_auto_min: float
exposure_auto_max: float
exposure_time: int
gain: float
def _get_spec(request: Request, cam_id: str) -> config.CameraSpec:
session = request.app.state.session
specs = {c.id: c for c in config.camera_specs(session.camera_count)}
spec = specs.get(cam_id)
if spec is None:
raise HTTPException(404, f"unknown camera id '{cam_id}' for camera_count={session.camera_count}")
return spec
@router.get("/{cam_id}/params")
async def get_params(cam_id: str, request: Request):
spec = _get_spec(request, cam_id)
session = request.app.state.session
values = session.camera_values.get(cam_id) or camera_params.load_defaults(spec)
session.camera_values[cam_id] = values
return values.as_dict()
@router.post("/{cam_id}/params")
async def set_params(cam_id: str, body: ParamValuesIn, request: Request):
spec = _get_spec(request, cam_id)
session = request.app.state.session
values = camera_params.CameraParamValues(**body.model_dump())
session.camera_values[cam_id] = values
return {"ok": True}
@router.post("/{cam_id}/params/apply")
async def apply_params(cam_id: str, request: Request):
spec = _get_spec(request, cam_id)
session = request.app.state.session
pm = request.app.state.pm
if not pm.is_running("camera"):
raise HTTPException(409, "camera is not running")
values = session.camera_values.get(cam_id) or camera_params.load_defaults(spec)
results = await run_in_threadpool(camera_params.apply_params, spec, values)
ok = all(r["ok"] for r in results)
return {"ok": ok, "results": results}
@router.post("/{cam_id}/params/save")
async def save_params(cam_id: str, request: Request):
spec = _get_spec(request, cam_id)
session = request.app.state.session
values = session.camera_values.get(cam_id) or camera_params.load_defaults(spec)
try:
await run_in_threadpool(camera_params.save_params, spec, values)
except Exception as e:
raise HTTPException(500, f"save failed: {e}")
return {"ok": True, "path": spec.base_params_path}
@router.get("/{cam_id}/stream.mjpg")
async def stream(cam_id: str, request: Request):
_get_spec(request, cam_id) # validate
ros = request.app.state.ros
return StreamingResponse(
ros.mjpeg_generator(cam_id),
media_type="multipart/x-mixed-replace; boundary=frame",
)
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
import os
import time
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
import bag_metadata
import config
router = APIRouter(tags=["recording"])
# Directory browsing/recording is restricted to the user's home tree — the
# browser has no server-filesystem access, so /api/fs/browse is a small
# custom endpoint standing in for a native folder picker.
BROWSE_ROOT = Path.home()
def _safe_path(raw: str) -> Path:
p = (BROWSE_ROOT / raw).resolve() if not os.path.isabs(raw) else Path(raw).resolve()
try:
p.relative_to(BROWSE_ROOT.resolve())
except ValueError:
raise HTTPException(400, f"path must be under {BROWSE_ROOT}")
return p
@router.get("/fs/browse")
async def browse(path: str = str(BROWSE_ROOT)):
p = _safe_path(path)
if not p.exists() or not p.is_dir():
raise HTTPException(404, "not a directory")
entries = []
for child in sorted(p.iterdir()):
if child.is_dir():
entries.append({"name": child.name, "path": str(child)})
return {"path": str(p), "parent": str(p.parent) if p != BROWSE_ROOT.resolve() else None, "dirs": entries}
@router.get("/bags")
async def list_bags(save_dir: str = config.DEFAULT_SAVE_DIR, limit: int = 10):
d = Path(save_dir)
if not d.exists():
return {"bags": []}
bags = []
children = sorted(d.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True)
for child in children:
if not child.is_dir():
continue
if len(bags) >= limit:
break
meta = bag_metadata.read_bag_metadata(child)
bags.append({
"name": child.name,
"path": str(child),
"mtime": child.stat().st_mtime,
"duration_s": meta["duration_s"] if meta else None,
"message_count": meta["message_count"] if meta else None,
"topics": meta["topics"] if meta else [],
"size_bytes": meta["size_bytes"] if meta else bag_metadata.dir_size_bytes(child),
"complete": meta is not None, # False = still recording, or was killed before flush
})
return {"bags": bags}
@router.get("/fs/disk-usage")
async def disk_usage(path: str = config.DEFAULT_SAVE_DIR):
return {"free_gb": round(config.disk_free_gb(path), 2)}
class RecordingStartIn(BaseModel):
filename: str
save_dir: str = config.DEFAULT_SAVE_DIR
@router.post("/recording/start")
async def recording_start(body: RecordingStartIn, request: Request):
session = request.app.state.session
pm = request.app.state.pm
if not pm.is_running("lidar") or not pm.is_running("camera"):
raise HTTPException(409, "start lidar+camera before recording")
if session.recording:
raise HTTPException(409, "already recording")
if not body.filename.strip():
raise HTTPException(400, "filename required")
save_dir = body.save_dir.strip() or config.DEFAULT_SAVE_DIR
os.makedirs(save_dir, exist_ok=True)
bag_path = os.path.join(save_dir, body.filename.strip())
topics = config.base_record_topics(session.camera_count)
# FAST-LIO tracks the scan (LiDAR+IMU-only mapping) for exactly the
# recording's lifetime — started alongside the bag, stopped alongside it.
# It only needs /livox/lidar + /livox/imu, both already live since
# lidar+camera are required (checked above) before recording can start.
request.app.state.ros.pointcloud.reset() # fresh map for this recording, not last one's leftovers
pm.start("fastlio", config.FASTLIO_LAUNCH_CMD, log_path=config.FASTLIO_LOG_PATH, env=config.fastlio_env())
cmd = f"{config.ROSBAG_SOURCE} && stdbuf -oL -eL ros2 bag record -o {bag_path} {topics}"
pm.start("recording", cmd, log_path=config.RECORDING_LOG_PATH)
session.recording = True
session.recording_path = bag_path
session.recording_started_at = time.monotonic()
return {"ok": True, "path": bag_path}
@router.post("/recording/stop")
async def recording_stop(request: Request):
session = request.app.state.session
pm = request.app.state.pm
pm.stop("recording")
pm.stop("fastlio")
session.recording = False
session.recording_path = None
session.recording_started_at = None
return {"ok": True}
+212
View File
@@ -0,0 +1,212 @@
from __future__ import annotations
import asyncio
import time
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
import bag_metadata
import camera_params
import config
from ros_bridge import ROSBridge
router = APIRouter()
class SessionConfig(BaseModel):
camera_count: int
@router.post("/session")
async def set_session(cfg: SessionConfig, request: Request):
if cfg.camera_count not in config.CAMERA_PROFILES:
raise HTTPException(400, f"unsupported camera_count, expected one of {sorted(config.CAMERA_PROFILES)}")
session = request.app.state.session
pm = request.app.state.pm
if pm.is_running("lidar") or pm.is_running("camera"):
raise HTTPException(409, "stop lidar/camera before changing camera_count")
session.camera_count = cfg.camera_count
# ROSBridge subscriptions are fixed at construction (see ros_bridge.py) —
# changing camera_count means tearing down and recreating the bridge.
old_ros: ROSBridge = request.app.state.ros
old_ros.stop()
new_ros = ROSBridge(camera_count=cfg.camera_count)
new_ros.start()
request.app.state.ros = new_ros
return {"ok": True, "camera_count": session.camera_count}
def _camera_launch_cmd(app) -> str:
session = app.state.session
specs = config.camera_specs(session.camera_count)
launch_file = config.camera_launch_file(session.camera_count)
args = []
for cam in specs:
values = session.camera_values.get(cam.id) or camera_params.load_defaults(cam)
session.camera_values[cam.id] = values
path = camera_params.write_launch_params_yaml(cam, values)
args.append(f" {cam.launch_arg}:={path}")
return (
f"{config.CAMERA_LAUNCH_SOURCE} && "
f"stdbuf -oL -eL ros2 launch hik_camera_ros2_driver {launch_file}" + "".join(args)
)
async def _auto_start_camera_after_delay(app):
await asyncio.sleep(config.CAMERA_DELAY_SEC)
pm = app.state.pm
if pm.is_running("lidar") and not pm.is_running("camera"):
pm.start("camera", _camera_launch_cmd(app), log_path=config.CAMERA_LOG_PATH)
@router.post("/lidar/start")
async def lidar_start(request: Request):
pm = request.app.state.pm
pm.start("lidar", config.LIDAR_LAUNCH_CMD, log_path=config.LIDAR_LOG_PATH)
asyncio.create_task(_auto_start_camera_after_delay(request.app))
return {"ok": True}
@router.post("/lidar/stop")
async def lidar_stop(request: Request):
pm = request.app.state.pm
session = request.app.state.session
pm.stop("lidar")
pm.stop("camera")
# fastlio has no sensor data source once lidar stops — tear it down here
# too, whether it's running because a recording is active or because the
# operator started it standalone via the settings test panel.
pm.stop("fastlio")
if session.recording:
pm.stop("recording")
session.recording = False
session.recording_path = None
session.recording_started_at = None
return {"ok": True}
@router.post("/camera/start")
async def camera_start(request: Request):
pm = request.app.state.pm
if not pm.is_running("lidar"):
raise HTTPException(409, "start lidar first")
pm.start("camera", _camera_launch_cmd(request.app), log_path=config.CAMERA_LOG_PATH)
return {"ok": True}
@router.post("/camera/stop")
async def camera_stop(request: Request):
request.app.state.pm.stop("camera")
return {"ok": True}
@router.post("/fastlio/start")
async def fastlio_start(request: Request):
"""Manual FAST-LIO start, independent of recording — lets the operator
preview lidar point-cloud quality (rig leveling, coverage) in Settings
before committing to an actual recording."""
pm = request.app.state.pm
if not pm.is_running("lidar"):
raise HTTPException(409, "start lidar first")
request.app.state.ros.pointcloud.reset()
pm.start("fastlio", config.FASTLIO_LAUNCH_CMD, log_path=config.FASTLIO_LOG_PATH, env=config.fastlio_env())
return {"ok": True}
@router.post("/fastlio/stop")
async def fastlio_stop(request: Request):
if request.app.state.session.recording:
raise HTTPException(409, "recording is using fastlio — stop the recording instead")
request.app.state.pm.stop("fastlio")
return {"ok": True}
@router.post("/system/estop")
async def estop(request: Request):
request.app.state.pm.stop_all()
request.app.state.session.recording = False
return {"ok": True}
def build_status(app) -> dict:
from health import TopicThreshold
pm = app.state.pm
ros: ROSBridge = app.state.ros
session = app.state.session
specs = config.camera_specs(session.camera_count)
lidar_running = pm.is_running("lidar")
camera_running = pm.is_running("camera")
fastlio_running = pm.is_running("fastlio")
lidar_thr = config.HEALTH_THRESHOLDS["/livox/lidar"]
cam_thr = config.HEALTH_THRESHOLDS["camera_image"]
map_thr = config.HEALTH_THRESHOLDS["/Odometry"]
cameras = []
for cam in specs:
health = ros.health.classify(
cam.image_topic, camera_running, TopicThreshold(*cam_thr)
)
cameras.append({
"id": cam.id, "topic": cam.image_topic, "health": health,
"hz": ros.health.rate_hz(cam.image_topic),
})
recording_elapsed_s = None
recording_size_bytes = None
disk_check_dir = config.DEFAULT_SAVE_DIR
if session.recording and session.recording_path:
if session.recording_started_at is not None:
recording_elapsed_s = round(time.monotonic() - session.recording_started_at, 1)
recording_size_bytes = bag_metadata.dir_size_bytes(Path(session.recording_path))
disk_check_dir = str(Path(session.recording_path).parent)
lidar_health = ros.health.classify("/livox/lidar", lidar_running, TopicThreshold(*lidar_thr))
return {
"camera_count": session.camera_count,
"default_save_dir": config.DEFAULT_SAVE_DIR,
"lidar": {
"running": lidar_running,
"health": lidar_health,
# /livox/imu isn't subscribed at all anymore (see ros_bridge.py's
# module docstring — its ~200Hz starved the camera topics badly
# enough that the operator asked to drop it, 2026-08-06). It
# comes off the same livox_ros_driver2 process as the lidar
# topic, so lidar's own health is a reasonable stand-in; there's
# no real Hz number to report, so imu_hz stays 0 (the HUD badge
# just shows the health word with no Hz suffix in that case).
"imu_health": lidar_health,
"hz": ros.health.rate_hz("/livox/lidar"),
"imu_hz": 0.0,
},
"cameras": cameras,
"map": {
"health": ros.health.classify(config.ODOMETRY_TOPIC, fastlio_running, TopicThreshold(*map_thr)),
"hz": ros.health.rate_hz(config.ODOMETRY_TOPIC),
"points": ros.pointcloud.point_count(),
},
"recording": {
"active": session.recording,
"path": session.recording_path,
"elapsed_s": recording_elapsed_s,
"size_bytes": recording_size_bytes,
},
"disk": {
"path": disk_check_dir,
"free_gb": round(config.disk_free_gb(disk_check_dir), 2),
"low_warning_gb": config.DISK_LOW_WARNING_GB,
"low_danger_gb": config.DISK_LOW_DANGER_GB,
},
"process_status": pm.status(),
}
@router.get("/status")
async def status(request: Request):
return build_status(request.app)
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
import asyncio
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
import config
from log_tail import LogTailer
from routers.system import build_status
router = APIRouter()
STATUS_PUSH_HZ = 1.0 # slowed from 2.0 — paired with health.py's wider RATE_WINDOW,
# the operator asked for a calmer/more trustworthy number over a
# snappier one (2026-08-06).
LOG_POLL_HZ = 4.0
@router.websocket("/ws/status")
async def ws_status(ws: WebSocket):
await ws.accept()
period = 1.0 / STATUS_PUSH_HZ
try:
while True:
await ws.send_json(build_status(ws.app))
await asyncio.sleep(period)
except WebSocketDisconnect:
pass
@router.websocket("/ws/pointcloud")
async def ws_pointcloud(ws: WebSocket, profile: str = "desktop"):
"""M3 live scan view — binary-framed decimated point cloud, see
pointcloud_codec.py for the wire format. `profile` picks desktop vs.
phone point/Hz budgets rather than trusting client-supplied numbers
directly (a client could otherwise ask for an unbounded frame size)."""
await ws.accept()
prof = config.POINTCLOUD_PROFILES.get(profile, config.POINTCLOUD_PROFILES["desktop"])
period = 1.0 / prof["hz"]
ros = ws.app.state.ros
try:
while True:
await ws.send_bytes(ros.pointcloud.encode_frame(max_points=prof["max_points"]))
await asyncio.sleep(period)
except WebSocketDisconnect:
pass
@router.websocket("/ws/logs")
async def ws_logs(ws: WebSocket):
"""Unified log tail — mirrors the old PyQt GUIs' single scrollback pane.
Sends recent backlog once on connect, then only newly-appended lines."""
await ws.accept()
tailer = LogTailer({
"lidar": config.LIDAR_LOG_PATH,
"camera": config.CAMERA_LOG_PATH,
"recording": config.RECORDING_LOG_PATH,
"fastlio": config.FASTLIO_LOG_PATH,
})
backlog = []
for tag in ("lidar", "camera", "recording", "fastlio"):
backlog += [{"tag": tag, "line": ln} for ln in tailer.read_tail(tag, max_lines=80)]
if backlog:
await ws.send_json({"backlog": backlog})
period = 1.0 / LOG_POLL_HZ
try:
while True:
new_lines = [{"tag": tag, "line": line} for tag, line in tailer.read_new()]
if new_lines:
await ws.send_json({"lines": new_lines})
await asyncio.sleep(period)
except WebSocketDisconnect:
pass