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:
@@ -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",
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user