Files
gardentech f34817d5b6 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.
2026-08-07 14:26:00 +09:00

283 lines
13 KiB
Python

"""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)