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