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
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.pyc
.venv/
venv/
*.log
+12
View File
@@ -0,0 +1,12 @@
[
{
"date": "2026-08-04",
"scope": "app",
"macrostructure": "n/a (app/dashboard — no marketing macrostructure)",
"theme": "custom",
"theme_axes": "light / grotesk-sans / cool-cobalt",
"vibe": "Tally-referenced modern-minimal, instrument-panel discipline",
"enrichment": "none",
"brief": "scan_web control panel redesign — hallmark applied to app UI, not a marketing page"
}
]
+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
+102
View File
@@ -0,0 +1,102 @@
# Design — scan_web
Locked design system for the unified scan/record control panel. Every future
view (the M3 Scanning/LIO view, any later addition) reads this file before
shipping new UI — extend it, don't invent a parallel system.
## Genre
modern-minimal (Stripe / Linear / instrument-panel school — SaaS/dashboard trigger)
## Macrostructure family
This is an **app**, not a marketing site — no macrostructure/nav-archetype/
footer-archetype apparatus applies (those are page-shape concepts for
landing pages). The whole app is one page-type:
- App pages (Setup/Control view, Scanning/Live view, any future view):
hairline-bordered panel grid, left-biased control column + right-biased
primary content, sticky toolbar with a segmented view switch. No
enrichment — function carries the page (never add hero imagery/CSS art).
## Theme
Custom — cool-cobalt, referenced against the "Tally" modern-minimal SaaS
example the user pointed at, adapted toward Cobalt's instrument-panel
discipline (hairlines over shadow, mono status readouts) since the app is
an operational dashboard, not a marketing hero.
- `--color-paper` oklch(97.8% 0.005 255)
- `--color-paper-1` oklch(99.3% 0.003 255)
- `--color-ink` oklch(22.0% 0.020 258)
- `--color-ink-2` oklch(38.0% 0.016 257)
- `--color-rule` oklch(85.0% 0.010 255)
- `--color-accent` oklch(52.0% 0.185 256)
- `--color-focus` oklch(48.0% 0.200 256)
- Functional state colours (not brand accent — status semantics):
`--color-success` (149°), `--color-warning` (68°), `--color-danger` (25°)
Full token set: [`frontend/css/tokens.css`](frontend/css/tokens.css).
## Typography
- Display: Geist, weight 650, normal style
- Body: Geist, weight 400 (single-family discipline — the modern-minimal signature)
- Mono/outlier: Geist Mono, weight 500 — carries exactly one role: status
badges, tab labels, meta/hint text, and numeric-readout fields (camera
params). Do not reach for it a third role.
- Korean UI copy falls back per-glyph to "Noto Sans CJK KR" / "Noto Sans KR"
automatically — Geist covers Latin only.
- Fonts are self-hosted at `frontend/vendor/fonts/` (woff2, variable) —
never a Google Fonts CDN link. The field PC/phone may have no internet.
## Spacing
4-point named scale in `tokens.css` (`--space-3xs``--space-2xl`). Always
reference by name.
## Motion
- Easings: `--ease-out` / `--ease-in` / `--ease-in-out`, no bounce/overshoot.
- Modern-minimal default: reveals are OFF — this is a repeatedly-used tool,
not a first-impression marketing page. The only motion is functional:
button press, badge colour transition, tab crossfade, REC pulse dot.
- `prefers-reduced-motion: reduce` collapses everything to ≤150ms opacity
(see `tokens.css`).
## Microinteractions stance
- Silent success (no toast for visible state changes).
- Focus rings appear instantly, never animated in.
- No `transition: all` anywhere — properties are named explicitly.
## Status/health vocabulary — must stay legible in direct sunlight
Every status signal is **icon + text + colour**, never colour alone
(outdoor/colour-blind requirement, not just a Hallmark default):
- `badge-ok` — ✓, success green
- `badge-stale` — !, warning amber
- `badge-down` — ✕, danger red
- `badge-rec` — pulsing dot, danger red
- The ● / state-line idiom (`● 실행 중` / `● 정지`) is carried over from the
original PyQt5 tools on purpose — operators already know it.
## CTA voice
- Primary actions (시동/녹화 시작): pill radius, solid semantic fill
(`--color-success` / `--color-danger`), 44px min-height (touch target floor).
- Secondary/utility actions: `.btn-gray`, `.btn-blue` — same pill shape,
neutral or accent fill.
- E-STOP: outlined danger pill that fills solid on hover/press — visually
distinct from the recording Stop button, since it's a different severity
of action (kills every subsystem at once).
## What pages MUST share
- The token set in `tokens.css` — never an inline OKLCH/hex value.
- The badge/status vocabulary above.
- 44px minimum touch target on every interactive control (phone access is
a hard requirement, not a nice-to-have).
- Hairline-bordered panels, whisper shadow at most — no drop-shadow cards.
## What pages MAY differ on
- Panel layout within a view (the Scanning/LIO view is full-viewport canvas
+ status strip, not the Setup view's panel grid — that's fine, it's a
different *content* need, not a different *system*).
## Related tool: the calibration GUI (`~/dvlc_gui/dvlc_calib_gui.py`)
Native PyQt5, out of scope for a web port (Phase 2, if ever). Its Qt
stylesheet was updated to reuse this same palette (sRGB conversions of the
tokens above) purely for visual consistency across the two tools the
operator switches between — no functional change. See the `STYLESHEET`
constant and `C_*` colour constants near the top of that file.
+217
View File
@@ -0,0 +1,217 @@
# scan_web 운용 가이드 (Phase 1 / M1 — 시동·카메라설정·녹화 통합 웹앱)
> `~/fast_ws` 의 `scan_gui.py` / `scan_gui_dual.py` / `scan_gui_triple.py` (PyQt5, 3개 중복 파일)를
> 대체하는 **하나의 웹앱**이다. 브라우저(이 PC 또는 같은 네트워크의 폰)로 접속해서 쓴다.
> 기존 스캔 GUI 3종은 아직 그대로 두었다 — `~/Desktop/scan_gui*.desktop` 그대로 사용 가능(백업/롤백용).
## 0. 지금 버전이 할 수 있는 것 / 못 하는 것
**Phase 1 - M1(이번 작업)에서 됨:**
- LiDAR + cam1/cam2/cam3 시동/종료 (기존과 동일한 5초 지연 순서)
- 카메라별 노출/게인 설정 (자동노출/밝기/노출상한하한/노출시간/게인) — 적용(`ros2 param set`) / 저장(YAML, 주석 보존)
- rosbag 녹화 시작/종료 (기존 트리플 GUI와 동일 토픽 목록)
- 브라우저 기반 카메라 미리보기 (MJPEG)
- 전체 정지(E-STOP) 버튼
- 라이다/IMU/카메라 상태 배지 — OK/STALE/DOWN + **실측 Hz** (토픽 수신 여부/속도로 추정, 별도 진단 토픽 없음)
- **디스크 여유 공간 표시** (부족하면 경고/위험 색으로 바뀜 — §3 참고)
- **녹화 중 경과 시간 + 실시간 파일 크기**
- **최근 녹화 목록에 실제 길이/용량/메시지 수/토픽 수 표시** (`ros2 bag info`로 확인할 필요 없이 대시보드에서 바로 확인)
- **통합 로그 패널** — 기존 PyQt GUI의 로그창과 동일하게 lidar/camera/recording 프로세스 출력을 `[태그]`로 구분해 실시간으로 봄
- **디자인** — hallmark 스킬로 리디자인 완료 (M4, 순서를 당겨서 먼저 진행)
**아직 안 됨 (다음 단계):**
- GPS/RTK 연동 — **Phase 1 Milestone 2**. 지금 버전은 GPS 시작/정지/상태 표시가 아예 없다.
- 스캐닝 중 LIO(포인트클라우드+궤적) 라이브 뷰 — **Milestone 3**.
**⚠️ 아직 실제 하드웨어로 테스트 안 됨**: 서버 기동/API/프로세스 종료 로직은 소프트웨어 레벨로 확인했지만,
실제로 브라우저에서 "시동 시작"을 눌러 LiDAR/카메라를 구동해본 적은 없다. 아래 §2 순서대로 처음 실행할 때
평소 `scan_gui_triple.py` 켤 때처럼 하드웨어 연결 상태를 확인하고 진행할 것.
---
## 1. 실행 방법
### 방법 A — 바탕화면 아이콘
바탕화면 **"Scan Web (통합)"** 아이콘 실행 → 서버가 안 떠 있으면 자동으로 띄운 뒤, 준비되면 기본 브라우저로
`http://localhost:8000` 이 열린다.
### 방법 B — 수동 실행 (터미널에서 로그 보면서 디버깅할 때)
```bash
/home/gardentech/scan_web/run_scan_web.sh
```
포그라운드로 뜨며 uvicorn 로그가 그대로 보인다. `Ctrl+C` 로 종료(종료 시 실행 중이던 lidar/camera/recording
프로세스도 함께 정리됨 — app.py의 lifespan shutdown 훅).
### 폰에서 접속하려면
같은 Wi-Fi에 연결된 폰 브라우저에서 `http://<이 PC의 LAN IP>:8000` 으로 접속.
```bash
hostname -I # 이 PC의 LAN IP 확인
```
---
## 2. 사용법 (웹 UI)
브라우저를 열면 좌측에 제어 패널, 우측에 카메라 미리보기가 보인다 (기존 PyQt5 GUI와 레이아웃 유사).
1. **시동** — "시동 시작" 클릭 → LiDAR 즉시 실행, 5초 후 cam1/cam2/cam3 자동 실행. 상단 상태 배지에서
`LiDAR OK 9.8Hz` / `IMU OK` / `cam1 OK 14.9Hz` 등으로 바뀌는지 확인. 우측 미리보기에 실시간 영상이 뜨는지 확인.
2. **카메라 설정** — 탭에서 카메라 선택 → 자동노출/밝기/노출상한하한/노출시간/게인 조절 →
- **적용**: 지금 켜진 카메라 노드에 바로 반영(`ros2 param set`), 재시동 전까지만 유효
- **저장**: 카메라 config YAML 파일에 영구 저장 (기존 주석은 그대로 보존됨)
3. **녹화** — 저장 경로(기본 `~/bags`)와 파일 이름 입력 → "…" 버튼으로 폴더 찾아보기 가능 →
"녹화 시작" → 끝나면 "녹화 정지". 저장 위치는 `~/bags/<파일이름>/<파일이름>_0.db3` (기존과 동일).
4. **전체 정지(E-STOP)** — 라이다/카메라/녹화 프로세스를 한번에 즉시 정지. 확인 팝업 있음.
---
## 3. 대시보드에 표시되는 추가 정보
이번에 녹화 화면에 추가된 것들 — 전부 터미널을 열지 않고 브라우저에서 바로 확인 가능:
- **디스크 여유 공간** (녹화 패널, 저장 경로 입력 밑) — `여유 공간: 780.4 GB (/home/gardentech/bags)`
식으로 표시. **10GB 미만이면 주황색(경고), 2GB 미만이면 빨간색(위험)** 으로 바뀐다 — 장시간 스캔 중
디스크가 꽉 차서 bag이 깨지는 상황을 미리 막기 위함. 저장 경로를 바꾸면 그 경로 기준으로 다시 계산됨.
- **녹화 경과 시간 + 실시간 용량** — 녹화 중일 때 "● 녹화 중" 옆에 `03:42 · 1.2 GB` 식으로 표시.
- **최근 녹화 목록** — 이름뿐 아니라 **길이 / 용량 / 총 메시지 수 / 토픽 개수** 까지 바로 보임. 방금
중지한 bag이 아직 `metadata.yaml`을 못 쓴 상태면(정지 직후 잠깐) `(녹화 중 / 미완료)`로 표시됐다가
1~2초 뒤 자동으로 정상 표시로 바뀐다.
- **상태 배지의 실측 Hz** — 예: `LiDAR OK 9.8Hz`. STALE/DOWN일 땐 Hz를 안 붙임(의미 없는 숫자라서).
기준 임계값은 `backend/config.py``HEALTH_THRESHOLDS`에서 조정 가능.
- **로그 패널** (카메라 미리보기 아래, 화면 전체 너비) — lidar/camera/recording 세 프로세스의 출력을
`[lidar]`/`[camera]`/`[recording]` 태그로 구분해서 실시간으로 보여줌. 자동 스크롤 체크박스와
지우기 버튼 있음. 문제 생겼을 때(카메라 안 붙음, launch 실패 등) 원인 파악용 — 기존 PyQt GUI의
로그창과 동일한 역할.
---
## 4. 실기(하드웨어) 테스트 체크리스트
소프트웨어 레벨 검증은 끝났고, 브라우저 화면도 확인됨. 다음은 **실제 LiDAR/카메라를 켜서** 확인해야 할
것들 — 아래 순서대로 체크하면서 진행할 것을 권장.
### 4.1 시동 전
- [ ] LiDAR(MID360s), cam1/cam2/cam3 물리적으로 연결/전원 확인 (기존 `scan_gui_triple.py` 켤 때와 동일)
- [ ] 다른 곳에서 같은 토픽(`/livox/lidar`, `/cam1/image` 등)을 쓰는 프로세스가 남아있지 않은지 확인
(`ps aux | grep -E "ros2 launch|hik_camera|livox"`)
### 4.2 시동
- [ ] "시동 시작" 클릭 → LiDAR 즉시 실행, **5초 후** 카메라 3대 자동 실행되는지(카운트다운 없이 그냥 5초
뒤 배지가 바뀌는지 확인 — 화면에 카운트다운 표시는 아직 없음, PyQt 버전엔 있었음)
- [ ] 상단 배지: `LiDAR OK <Hz>` / `IMU OK <Hz>` / `cam1 OK <Hz>` / `cam2 OK <Hz>` / `cam3 OK <Hz>` 로 전환
- [ ] Hz 숫자가 말이 되는 값인지 (라이다는 보통 ~10Hz 대, 카메라는 설정된 fps 근처) — 기존
`ros2 topic hz`로 알던 값과 비교
- [ ] 우측 카메라 미리보기 3분할 화면에 실제 영상이 뜨는지, 세 대 다 정상인지 (cam3는 180도 회전 장착이라
화면도 거꾸로 나오는 게 정상 — SuperGlue 단계에서만 rotate_camera로 보정됨, 미리보기 자체는 원본)
- [ ] 로그 패널에서 `[lidar]`/`[camera]` 태그로 launch 로그가 흘러나오는지, 에러 없는지
### 4.3 카메라 설정
- [ ] 카메라 탭에서 노출/게인 "적용" 눌렀을 때 실제 영상 밝기가 바뀌는지 (반영까지 약간의 지연 있을 수 있음)
- [ ] "저장" 눌렀을 때 해당 카메라 config YAML이 실제로 갱신되는지, 기존 주석이 안 지워졌는지 확인
(`git diff` 있으면 diff로, 없으면 파일 직접 열어서 확인)
### 4.4 녹화
- [ ] 저장 경로/파일 이름 입력 후 "녹화 시작" → "● 녹화 중" + 경과시간/용량이 올라가는지
- [ ] 녹화 중 디스크 여유 공간 숫자가 (아주 조금씩이라도) 줄어드는지
- [ ] "녹화 정지" 후 "최근 녹화" 목록에 새 항목이 뜨는지, 잠깐의 "(미완료)" 표시 후 정상 정보로 바뀌는지
- [ ] `ros2 bag info`로 직접 확인해서 대시보드에 보이는 길이/용량/메시지 수와 일치하는지 (§5 참고)
### 4.5 종료 / 전체 정지
- [ ] "시동 종료" 눌렀을 때 배지가 전부 DOWN으로, 미리보기가 다시 회색으로 돌아오는지
- [ ] `ps aux | grep -E "ros2 launch|hik_camera|livox"` 로 프로세스가 하나도 안 남았는지 (§6)
- [ ] 녹화 중에 E-STOP을 눌러보고, bag이 깨지지 않고 (거의) 정상 종료되는지 — SIGINT로 정지하므로
rosbag2가 db3를 flush할 시간을 주는지 확인하는 목적
### 4.6 문제 생기면
로그 패널(§3) 또는 `~/scan_web_logs/*.log` 확인 → §8 트러블슈팅 표 참고 → 그래도 안 풀리면 필요한 로그
내용 들고 알려주면 같이 봄.
---
## 5. 녹화 검증 (기존 가이드와 동일한 방법)
```bash
source /opt/ros/humble/setup.bash
ros2 bag info ~/bags/<파일이름>/
```
확인할 토픽: `/livox/lidar`, `/livox/imu`, `/cam1/image`, `/cam1/camera_info`,
`/cam2/image`, `/cam2/camera_info`, `/cam3/image`, `/cam3/camera_info`
(GPS는 M1에 없으므로 `/ublox_driver/receiver_pvt` 는 아직 안 담김 — M2에서 추가 예정).
**참고**: 이제 대시보드 "최근 녹화" 목록에서 같은 정보(길이/용량/토픽별 메시지 수)를 바로 볼 수 있어서,
터미널로 확인하는 건 더 자세히 파고들 때만 필요하다.
실시간으로 토픽 수신 속도 확인하려면 시동 켠 상태에서:
```bash
ros2 topic hz /livox/lidar
ros2 topic hz /cam1/image
```
(또는 상단 상태 배지의 실측 Hz 숫자를 바로 봐도 됨.)
---
## 6. 종료 후 프로세스 정리 확인
시동 종료(또는 E-STOP) 후 `ros2 launch` 하위 프로세스가 안 남았는지 확인:
```bash
ps aux | grep -E "ros2 launch|fastlivo|hik_camera" | grep -v grep
```
아무것도 안 나오면 정상 종료된 것. (내부적으로 기존 `_kill_proc` 로직과 동일하게 프로세스 그룹 전체에
SIGINT → 3초 대기 → 안 죽으면 SIGKILL 하는 방식으로 정리한다.)
---
## 7. 로그 위치
| 항목 | 경로 |
|---|---|
| LiDAR 로그 | `~/scan_web_logs/lidar.log` |
| 카메라 로그 | `~/scan_web_logs/camera.log` |
| 녹화(rosbag) 로그 | `~/scan_web_logs/recording.log` |
| 서버(uvicorn) 로그 — 바탕화면 아이콘으로 실행했을 때만 | `~/scan_web_logs/server.log` |
위 세 개(lidar/camera/recording)는 이제 브라우저의 **로그 패널**(§3)에서 실시간으로도 볼 수 있다 —
터미널을 열 필요 없이 화면 하나에서 확인 가능.
---
## 8. 트러블슈팅
| 증상 | 원인 | 조치 |
|---|---|---|
| 브라우저에서 "초기화 실패" 알림 | 백엔드 서버가 안 떠 있거나 방금 죽음 | 터미널에서 방법 B로 직접 실행해 로그 확인 |
| 시동 눌러도 미리보기 화면이 계속 회색 | 카메라 노드가 5초 지연 후 아직 안 붙었거나, 실제 카메라 미연결 | 브라우저 로그 패널(§3)에서 `[camera]` 태그 확인, `ros2 topic list``/cam1/image` 존재 확인 |
| 상태 배지가 계속 DOWN | 해당 토픽에 메시지가 안 들어옴(프로세스 자체는 떠 있어도) | 하드웨어 연결/드라이버 로그 확인 — 배지는 진단 토픽이 아니라 수신 여부/속도 추정치임 |
| 녹화 시작이 막힘(버튼 비활성) | 시동(LiDAR+카메라)이 먼저 켜져 있어야 함 | "시동 시작" 먼저 실행 |
| 디스크 여유 공간이 주황/빨강 | 저장 경로 볼륨에 공간이 얼마 안 남음 | 오래된 bag 정리하거나 저장 경로를 다른 볼륨으로 변경 |
| 최근 녹화 항목이 계속 "(녹화 중 / 미완료)"로 남음 | 녹화가 비정상 종료돼 `metadata.yaml`이 안 써짐(강제 kill 등) | `ros2 bag info` 로 직접 확인, 필요시 해당 bag 폴더 정리 |
| 포트 8000 이미 사용 중 | 이전 세션의 uvicorn 이 안 죽고 남아있음 | `pkill -f "uvicorn app:app"` 후 재실행 |
---
## 9. 구버전(scan_gui\*, fast_ws)과의 관계
- `~/fast_ws/scan_gui.py` / `scan_gui_dual.py` / `scan_gui_triple.py` 및 해당 `.desktop` 파일은
**삭제하지 않고 그대로 둠** — scan_web 이 실제 필드에서 최소 한 번 이상 문제없이 검증되기 전까지는
기존 GUI로 언제든 되돌아갈 수 있다.
- GPS/RTK(M2), LIO 라이브 뷰(M3)가 끝나고 필드 검증까지 마치면, 기존 `.desktop` 파일들을
`~/Desktop/legacy/` 로 옮기는 것을 고려 (삭제는 그 이후 판단).
- **`fast_ws` 자체는 2026-08-05부로 폐기 방침** (`fast_dual_ws`로 3카메라 확장되기 이전의 구버전
저장소) — Jetson 이관 대상에서도 제외됨 (`~/fast_dual_ws/docs/Jetson-이관가이드.md` 참고).
이에 맞춰 scan_web의 라이다 실행 경로도 `fast_ws`를 거치지 않고 **`lidar2_ws`를 직접 source**
하도록 이미 고쳤다 (`backend/config.py``LIDAR2_WS_SETUP`, `run_scan_web.sh`) — 알고 보니
`fast_ws`는 자체 라이다 드라이버가 없이 `lidar2_ws`를 언더레이로 체이닝만 하던 것이라, 직접
source해도 동작은 완전히 동일하다 (2026-08-05, 실제로 재기동해서 확인함).
---
## 10. 코드 구조 (참고용)
```
~/scan_web/
backend/ FastAPI 서버 (프로세스 관리, ROS2 브리지, 카메라 파라미터, REST/WebSocket API)
frontend/ 브라우저 UI (순수 HTML/CSS/JS, 빌드 과정 없음)
design.md hallmark 리디자인 시스템 (색/폰트/간격/모션 토큰 규칙)
run_scan_web.sh 실행 스크립트 (ROS 오버레이 source + uvicorn 실행)
open_scan_web.sh 바탕화면 아이콘용 — 서버 기동 대기 후 브라우저 자동 오픈
```
전체 설계 배경/마일스톤 계획은 `~/.claude/plans/federated-marinating-sunbeam.md` 참고.
+25
View File
@@ -0,0 +1,25 @@
/* Self-hosted (vendored, not CDN) — the field PC/phone may have no internet.
Both files are variable fonts; one @font-face per family covers every
weight the design uses via the weight axis. */
@font-face {
font-family: "Geist";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/vendor/fonts/Geist-Variable.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: "Geist Mono";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/vendor/fonts/GeistMono-Variable.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
+699
View File
@@ -0,0 +1,699 @@
/* Hallmark · pre-emit critique: P5 H4 E4 S4 R5 V4
* genre: modern-minimal · theme: custom (Tally-referenced) · redesign
* scope: app (single-screen phone HUD over a live scan view) — no marketing
* macrostructure/nav/footer archetypes apply
* anchor hue: cool-cobalt 256 · outdoor-legibility constraint: status never colour-only
*/
html, body { overflow: hidden; height: 100%; margin: 0; }
* { box-sizing: border-box; }
body {
background: var(--color-paper);
color: var(--color-ink-2);
font-family: var(--font-body);
font-weight: 400;
font-size: var(--text-base);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 {
font-family: var(--font-display);
font-weight: 650;
font-style: normal;
letter-spacing: -0.015em;
color: var(--color-ink);
margin: 0;
}
.hidden { display: none !important; }
a { color: var(--color-accent); }
/* ── Focus — instant, never animated, always visible ────────────────────── */
:focus { outline: none; }
:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
/* ── App shell — one fixed viewport, no page scroll ever. The scan view is
the permanent base layer; the HUD floats fixed on top of it; Settings/
Recording/Log are overlays drawn above both, never a navigation away. ── */
#app {
position: fixed;
inset: 0;
overflow: hidden;
}
.scan-view {
position: absolute;
inset: 0;
overflow: hidden;
background: var(--color-ink);
}
#pointcloud-canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
touch-action: none; /* OrbitControls owns touch gestures here, not the browser (no pull-to-refresh/pinch-zoom-page fighting it) */
}
.scan-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-ink-4);
font-family: var(--font-mono);
font-size: var(--text-sm);
text-align: center;
padding: var(--space-2xl);
}
/* ── HUD — fixed, pointer-events pass through the gaps so the scan view
underneath stays interactive (pan/zoom, once M3 adds that). Top/bottom
scrims guarantee legible light text regardless of what's rendered in the
scan view beneath (placeholder today, a point cloud later). ── */
.hud {
position: absolute;
inset: 0;
z-index: var(--z-sticky);
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
}
.hud-top {
pointer-events: auto;
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-2xs);
padding: max(var(--space-xs), env(safe-area-inset-top)) max(var(--space-sm), env(safe-area-inset-right))
var(--space-lg) max(var(--space-sm), env(safe-area-inset-left));
background: linear-gradient(to bottom, oklch(20% 0.02 258 / 0.65), transparent);
}
.hud-bottom {
pointer-events: auto;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-sm);
padding: var(--space-lg) max(var(--space-sm), env(safe-area-inset-right))
max(var(--space-xs), env(safe-area-inset-bottom)) max(var(--space-sm), env(safe-area-inset-left));
background: linear-gradient(to top, oklch(20% 0.02 258 / 0.65), transparent);
}
.hud-startup, .hud-rec, .hud-actions, .hud-top-right {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.status-badges {
display: flex;
gap: var(--space-2xs);
flex-wrap: wrap;
}
/* ── Status badges — icon + text, never colour alone (outdoor/colour-blind safe) ── */
.badge {
display: inline-flex;
align-items: center;
gap: 0.3em;
padding: 0.3em 0.7em;
border-radius: var(--radius-pill);
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0.02em;
line-height: 1.4;
white-space: nowrap;
}
/* Sensor badges (LiDAR/IMU/camN) get a hard fixed width, not shrink-to-fit
— icon glyphs (✓/!/✕) can render at slightly different widths than
monospace text depending on font fallback, so relying on content width
alone still let the box size drift. A fixed width removes that variable
entirely: label sits left, Hz sits right (space-between), and the box
itself never resizes as health/Hz change. Sized generously for the
longest realistic content ("✓ LiDAR STALE 199.9Hz"); flex-shrink:0 stops
the row from squeezing it under width pressure. */
.badge-sensor {
width: 15em;
box-sizing: border-box;
flex-shrink: 0;
justify-content: space-between;
}
.badge-ok { background: var(--color-success); color: var(--color-success-ink); }
.badge-stale { background: var(--color-warning); color: var(--color-warning-ink); }
.badge-down { background: var(--color-danger); color: var(--color-danger-ink); }
.badge-rec { background: var(--color-danger); color: var(--color-danger-ink); }
.badge-ok::before { content: "✓"; }
.badge-stale::before { content: "!"; }
.badge-down::before { content: "✕"; }
.badge-rec::before {
content: "";
width: 7px; height: 7px;
border-radius: 50%;
background: currentColor;
animation: badge-rec-pulse 1.4s var(--ease-in-out) infinite;
}
@keyframes badge-rec-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
@media (prefers-reduced-motion: reduce) {
.badge-rec::before { animation: none; }
}
/* Badge Hz suffix — a measured number, not just a traffic-light state.
status-panel.js NBSP-pads the health word and Hz digits to a fixed
character count so badge width never changes between updates (that's
the real fix); min-width here is just a safety margin against font
metric rounding. */
.badge .badge-hz {
display: inline-block;
min-width: 7ch;
text-align: right;
opacity: 0.85;
font-weight: 500;
}
.hint {
color: var(--color-ink-3);
font-size: var(--text-xs);
margin: var(--space-2xs) 0;
word-break: break-all;
font-family: var(--font-mono);
}
/* ── Buttons — pill, semantic fills, full state coverage ─────────────────
default · hover · focus-visible · active · disabled all styled below. */
.btn-row { display: flex; gap: var(--space-xs); margin: var(--space-sm) 0; }
.btn {
flex: 1;
min-height: 44px; /* touch target floor */
padding: 0 var(--space-md);
border: 1px solid transparent;
border-radius: var(--radius-pill);
font-family: var(--font-body);
font-weight: 600;
font-size: var(--text-sm);
white-space: nowrap;
cursor: pointer;
transition: background-color var(--dur-mid) var(--ease-out),
border-color var(--dur-mid) var(--ease-out),
transform var(--dur-fast) var(--ease-out);
}
.btn:active { transform: translateY(1px); }
.btn:disabled {
background: var(--color-paper-2) !important;
color: var(--color-ink-4) !important;
border-color: var(--color-rule-soft) !important;
cursor: not-allowed;
transform: none;
}
.btn-green {
background: var(--color-success); color: var(--color-success-ink);
}
.btn-green:hover:not(:disabled) { background: oklch(from var(--color-success) calc(l - 0.05) c h); }
.btn-red {
background: var(--color-danger); color: var(--color-danger-ink);
}
.btn-red:hover:not(:disabled) { background: oklch(from var(--color-danger) calc(l - 0.05) c h); }
.btn-blue {
background: var(--color-accent); color: var(--color-accent-ink);
}
.btn-blue:hover:not(:disabled) { background: oklch(from var(--color-accent) calc(l - 0.05) c h); }
.btn-gray {
background: var(--color-paper-2); color: var(--color-ink-2);
border-color: var(--color-rule);
}
.btn-gray:hover:not(:disabled) { background: var(--color-paper-3); }
.btn-small {
flex: 0 0 auto;
width: 44px;
min-height: 44px;
border-radius: var(--radius-sm);
background: var(--color-paper-2);
color: var(--color-ink-2);
border-color: var(--color-rule);
}
.btn-small:hover { background: var(--color-paper-3); }
/* HUD buttons sit inline in a floating strip, not a stacked full-width
.btn-row, so they must not stretch (flex:1) — size to content instead. */
.btn-hud {
flex: none;
min-width: 64px;
box-shadow: var(--shadow-whisper);
}
.btn-icon {
min-width: 44px;
width: 44px;
padding: 0;
font-size: var(--text-md);
}
.btn-estop {
min-height: 52px;
background: var(--color-paper-1);
color: var(--color-danger);
border: 1.5px solid var(--color-danger);
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: var(--text-sm);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
cursor: pointer;
transition: background-color var(--dur-mid) var(--ease-out), color var(--dur-mid) var(--ease-out);
}
.btn-estop:hover {
background: var(--color-danger);
color: var(--color-danger-ink);
}
.btn-estop:active { transform: translateY(1px); }
.btn-estop-hud {
min-height: 44px;
padding: 0 var(--space-md);
font-size: var(--text-xs);
background: oklch(20% 0.02 258 / 0.55);
box-shadow: var(--shadow-whisper);
}
/* ── State lines (● + label — carried over from the field-proven PyQt5 idiom) ──
Default colours assume a light panel; the HUD versions below override for
legibility over the dark top/bottom scrims. */
.state-line {
font-family: var(--font-mono);
font-size: var(--text-sm);
font-weight: 600;
margin-top: var(--space-2xs);
}
.state-line.on { color: var(--color-success); }
.state-line.off { color: var(--color-ink-4); }
.state-line.rec { color: var(--color-danger); }
.hud .state-line {
font-size: var(--text-xs);
text-shadow: 0 1px 3px oklch(0% 0 0 / 0.5);
}
.hud .state-line.off { color: var(--color-paper-3); }
/* ── Disk free space — safety info, not decoration ───────────────────── */
.disk-free {
display: flex;
align-items: center;
gap: 0.4em;
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-ink-3);
}
.disk-free.warning { color: var(--color-warning); font-weight: 600; }
.disk-free.danger { color: var(--color-danger); font-weight: 600; }
/* Small always-on chip in the HUD top strip — same idea as the sensor
badges, just neutral (free space isn't a health traffic light). */
.hud-disk-free {
padding: 0.3em 0.7em;
border-radius: var(--radius-pill);
background: oklch(20% 0.02 258 / 0.55);
color: var(--color-paper-1);
font-weight: 600;
}
.hud-disk-free.warning { color: oklch(78% 0.16 68); }
.hud-disk-free.danger { color: oklch(78% 0.18 25); }
/* ── Live recording meter (elapsed + size) ───────────────────────────── */
.rec-meter {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-ink-3);
font-variant-numeric: tabular-nums;
}
.hud .rec-meter {
color: var(--color-paper-1);
text-shadow: 0 1px 3px oklch(0% 0 0 / 0.5);
}
/* ── Bag list — richer entries (duration / size / topic count) ──────── */
.bag-item { padding: var(--space-xs) 0; border-bottom: 1px solid var(--color-rule-soft); }
.bag-item:last-child { border-bottom: none; }
.bag-item-name {
color: var(--color-ink-2);
font-weight: 600;
display: flex;
align-items: center;
gap: 0.4em;
}
.bag-item-meta {
color: var(--color-ink-4);
font-size: var(--text-xs);
margin-top: 2px;
}
.bag-item-incomplete {
color: var(--color-warning);
font-weight: 600;
}
/* ── Overlays — Settings / Recording / Log all float as a centred sheet
above the scan view + HUD, never a page navigation. Later overlays in
DOM order (log, opened from inside Settings) stack visually above
earlier ones at the same z-index. ── */
.overlay {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-md);
}
.overlay-backdrop {
position: absolute;
inset: 0;
background: oklch(15% 0.02 258 / 0.55);
}
.overlay-sheet {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
max-width: 480px;
max-height: min(85dvh, 720px);
background: var(--color-paper-1);
border: 1px solid var(--color-rule);
border-radius: var(--radius-lg);
box-shadow: 0 8px 32px oklch(15% 0.02 258 / 0.28);
overflow: hidden;
}
.overlay-sheet-small { max-width: 380px; }
.overlay-header {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-md) var(--space-lg);
border-bottom: 1px solid var(--color-rule-soft);
}
.overlay-header h2 { font-size: var(--text-md); }
.btn-close {
flex: none;
width: 36px;
height: 36px;
border: none;
border-radius: var(--radius-sm);
background: var(--color-paper-2);
color: var(--color-ink-2);
font-size: var(--text-sm);
cursor: pointer;
}
.btn-close:hover { background: var(--color-paper-3); }
.overlay-tabs {
flex: none;
display: flex;
gap: var(--space-lg);
padding: 0 var(--space-lg);
border-bottom: 1px solid var(--color-rule);
overflow-x: auto;
}
.overlay-body {
flex: 1;
overflow-y: auto;
padding: var(--space-lg);
}
/* Outer overlay-level tabs (카메라 설정 / 미리보기 / 최근 녹화 / 로그).
Deliberately a different class from .tab-btn/.tab-panel below — those are
reset via a global querySelectorAll by camera-settings.js for the
per-camera tabs, which would otherwise clobber these too. */
.pane-tab-btn {
padding: var(--space-xs) 0;
min-height: 40px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--color-ink-3);
font-family: var(--font-mono);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: color var(--dur-mid) var(--ease-out), border-color var(--dur-mid) var(--ease-out);
}
.pane-tab-btn:hover { color: var(--color-ink); }
.pane-tab-btn.active { color: var(--color-ink); border-bottom-color: var(--color-accent); font-weight: 600; }
.pane-tab-btn-log { margin-left: auto; color: var(--color-accent); }
.pane-tab { display: none; }
.pane-tab.active { display: block; }
/* ── Log overlay — the unified [tag] scrollback the PyQt tools had ──────── */
.log-header-actions { display: flex; align-items: center; gap: var(--space-md); }
.log-autoscroll {
display: flex; align-items: center; gap: var(--space-2xs);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-ink-3);
cursor: pointer;
}
.log-autoscroll input { accent-color: var(--color-accent); cursor: pointer; }
.log-body {
flex: 1;
min-height: 0;
background: var(--color-ink);
padding: var(--space-sm) var(--space-md);
overflow-y: auto;
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
}
.log-line { display: flex; gap: 0.6em; }
.log-line .log-tag {
flex: 0 0 auto;
color: var(--color-ink-4);
text-transform: uppercase;
font-weight: 600;
}
.log-line.tag-lidar .log-tag { color: oklch(70% 0.14 256); }
.log-line.tag-camera .log-tag { color: oklch(72% 0.14 149); }
.log-line.tag-recording .log-tag { color: oklch(72% 0.16 25); }
.log-line.tag-fastlio .log-tag { color: oklch(72% 0.15 320); }
.log-line .log-text { color: var(--color-paper-1); }
/* ── Form fields ──────────────────────────────────────────────────────── */
.field-row { display: flex; align-items: center; gap: var(--space-xs); margin: var(--space-sm) 0; }
.field-row label {
width: 72px; flex: 0 0 72px;
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-ink-3);
}
.field-row input[type=text] {
flex: 1;
min-height: 40px;
padding: 0 var(--space-sm);
background: var(--color-paper-2);
border: 1px solid var(--color-rule);
border-radius: var(--radius-sm);
color: var(--color-ink);
font-family: var(--font-body);
font-size: var(--text-sm);
transition: border-color var(--dur-mid) var(--ease-out), background-color var(--dur-mid) var(--ease-out);
}
.field-row input[type=text]:hover { border-color: var(--color-ink-4); }
.field-row input[type=text]:focus-visible {
background: var(--color-paper-1);
border-color: var(--color-accent);
outline: 2px solid var(--color-focus);
outline-offset: 1px;
}
.field-row input[type=text]::placeholder { color: var(--color-ink-4); }
.browse-panel {
border: 1px solid var(--color-rule);
border-radius: var(--radius-sm);
background: var(--color-paper-2);
max-height: 180px;
overflow-y: auto;
padding: var(--space-2xs);
margin-bottom: var(--space-sm);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.browse-panel .dir-entry {
padding: var(--space-xs);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background-color var(--dur-fast) var(--ease-out);
}
.browse-panel .dir-entry:hover { background: var(--color-paper-3); }
.bag-list {
list-style: none;
padding: 0;
margin: 0;
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-ink-2);
}
.bag-list li {
padding: var(--space-xs) 0;
border-bottom: 1px solid var(--color-rule-soft);
}
.bag-list li:last-child { border-bottom: none; }
/* ── Tabs — underline indicator, crossfade content (never slide).
Per-camera tabs inside the "카메라 설정" pane. ── */
.tabs { display: flex; gap: var(--space-lg); border-bottom: 1px solid var(--color-rule); margin-bottom: var(--space-md); }
.tab-btn {
padding: var(--space-xs) 0;
min-height: 40px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--color-ink-3);
font-family: var(--font-mono);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
transition: color var(--dur-mid) var(--ease-out), border-color var(--dur-mid) var(--ease-out);
}
.tab-btn:hover { color: var(--color-ink); }
.tab-btn.active { color: var(--color-ink); border-bottom-color: var(--color-accent); font-weight: 600; }
.tab-panel { display: none; animation: tab-crossfade var(--dur-mid) var(--ease-out); }
.tab-panel.active { display: block; }
@keyframes tab-crossfade { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.tab-panel { animation: none; }
}
.tab-panel > label:first-child {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--text-sm);
color: var(--color-ink-2);
cursor: pointer;
}
.tab-panel input[type=checkbox] {
width: 18px; height: 18px;
accent-color: var(--color-accent);
cursor: pointer;
}
.cam-field-grid {
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
gap: var(--space-xs) var(--space-sm);
align-items: center;
margin: var(--space-sm) 0;
}
.cam-field-grid label {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-ink-3);
}
.cam-field-grid input[type=number] {
min-height: 36px;
padding: 0 var(--space-xs);
border: 1px solid var(--color-rule);
border-radius: var(--radius-sm);
background: var(--color-paper-2);
color: var(--color-ink);
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: var(--text-sm);
width: 100%;
transition: border-color var(--dur-mid) var(--ease-out);
}
.cam-field-grid input[type=number]:hover { border-color: var(--color-ink-4); }
.cam-field-grid input[type=number]:focus-visible {
border-color: var(--color-accent);
outline: 2px solid var(--color-focus);
outline-offset: 1px;
}
.cam-field-grid input:disabled {
color: var(--color-ink-4);
background: var(--color-paper);
border-color: var(--color-rule-soft);
}
.cam-result {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-ink-3);
min-height: 1.4em;
margin-top: var(--space-2xs);
}
/* ── Camera preview grid ─────────────────────────────────────────────── */
.camera-grid { display: flex; gap: var(--space-md); flex-wrap: wrap; }
.camera-pane { flex: 1; min-width: 260px; }
.camera-pane .cam-title {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-ink-3);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: var(--space-2xs);
}
.camera-pane img {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: contain;
background: var(--color-ink);
border: 1px solid var(--color-rule);
border-radius: var(--radius-md);
display: block;
}
/* ── Mobile / small overlay widths — verified at 320 / 375 / 414 / 768 px
(Hallmark floor). No horizontal scroll, no two-line button labels, tap
targets >= 44px. The app shell itself has no scroll at all (see #app). ── */
@media (max-width: 480px) {
.field-row { flex-wrap: wrap; }
.field-row label { width: 100%; flex-basis: 100%; }
.cam-field-grid { grid-template-columns: 1fr; }
.cam-field-grid label { margin-top: var(--space-2xs); }
}
+96
View File
@@ -0,0 +1,96 @@
/* Hallmark · genre: modern-minimal · theme: custom (Tally-referenced cool-indigo,
* instrument-panel discipline borrowed from Cobalt) · redesign · designed-as-app
* paper-band: light · display-style: grotesk-sans (Geist) · accent-hue: cool-cobalt (256)
*/
:root {
color-scheme: light;
/* ── Paper / ink — cool engineered near-white, never pure #fff/#000 ── */
--color-paper: oklch(97.8% 0.005 255); /* page ground */
--color-paper-1: oklch(99.3% 0.003 255); /* panel / card surface */
--color-paper-2: oklch(95.0% 0.008 255); /* recessed surface (inputs, browse tree) */
--color-paper-3: oklch(91.5% 0.010 255); /* hover/active recessed surface */
--color-ink: oklch(22.0% 0.020 258); /* headings, primary text */
--color-ink-2: oklch(38.0% 0.016 257); /* body text */
--color-ink-3: oklch(48.0% 0.013 257); /* secondary / hint text */
--color-ink-4: oklch(66.0% 0.010 257); /* placeholder / disabled text */
--color-rule: oklch(85.0% 0.010 255); /* visible hairline border */
--color-rule-soft: oklch(90.5% 0.008 255); /* quiet hairline */
/* ── Accent — one cobalt signal, used for focus / links / active state only ── */
--color-accent: oklch(52.0% 0.185 256);
--color-accent-ink: oklch(98.5% 0.004 255);
--color-accent-tint: oklch(93.5% 0.030 256);
--color-focus: oklch(48.0% 0.200 256);
/* ── Functional state colours — status/health semantics, not brand accent ── */
--color-success: oklch(52.0% 0.145 149);
--color-success-ink: oklch(98.5% 0.004 149);
--color-warning: oklch(64.0% 0.165 68);
--color-warning-ink: oklch(20.0% 0.030 68);
--color-danger: oklch(53.0% 0.195 25);
--color-danger-ink: oklch(98.5% 0.004 25);
/* ── Typography — Geist family throughout (single-family discipline is the
modern-minimal signature), Geist Mono as the one outlier for status/data.
Geist covers Latin only; "Noto Sans KR" carries the Korean UI copy —
the browser falls back to it per-glyph automatically. */
--font-display: "Geist", "Noto Sans CJK KR", "Noto Sans KR", ui-sans-serif, system-ui, sans-serif;
--font-body: "Geist", "Noto Sans CJK KR", "Noto Sans KR", ui-sans-serif, system-ui, sans-serif;
--font-mono: "Geist Mono", "Noto Sans CJK KR", "Noto Sans KR", ui-monospace, "SF Mono", Menlo, monospace;
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.8125rem; /* 13px */
--text-base: 0.9375rem; /* 15px */
--text-md: 1.0625rem; /* 17px */
--text-lg: 1.3125rem; /* 21px */
--text-xl: 1.75rem; /* 28px */
--text-2xl: 2.25rem; /* 36px */
/* ── Spacing — 4pt scale ── */
--space-3xs: 0.25rem; /* 4px */
--space-2xs: 0.375rem; /* 6px */
--space-xs: 0.5rem; /* 8px */
--space-sm: 0.75rem; /* 12px */
--space-md: 1rem; /* 16px */
--space-lg: 1.5rem; /* 24px */
--space-xl: 2rem; /* 32px */
--space-2xl: 3rem; /* 48px */
/* ── Radius — tight/technical for surfaces, pill for actions & chips ── */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--radius-pill: 999px;
/* ── Elevation — hairline borders do the work; shadow is a whisper, one only ── */
--shadow-whisper: 0 1px 2px oklch(20% 0.02 258 / 0.06);
/* ── Motion — three named eases, no bounce/overshoot ── */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--dur-fast: 100ms;
--dur-mid: 180ms;
--dur-slow: 280ms;
/* ── Z-index — named scale ── */
--z-base: 1;
--z-raised: 10;
--z-dropdown: 100;
--z-sticky: 200;
--z-modal: 400;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: var(--dur-fast) !important;
scroll-behavior: auto !important;
}
}
+154
View File
@@ -0,0 +1,154 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover">
<title>scan_web</title>
<link rel="stylesheet" href="/css/fonts.css">
<link rel="stylesheet" href="/css/tokens.css">
<link rel="stylesheet" href="/css/style.css">
<script type="importmap">
{
"imports": {
"three": "/vendor/three/three.module.min.js"
}
}
</script>
</head>
<body>
<div id="app">
<!-- Base layer, always full-screen: the live FAST-LIO point cloud. Empty
state shown until the first accumulated points arrive. -->
<section id="view-scan" class="scan-view">
<canvas id="pointcloud-canvas"></canvas>
<div id="scan-empty" class="scan-placeholder">
<p>포인트 없음 — 시동 후 녹화를 시작하면 지도가 쌓입니다.</p>
</div>
</section>
<!-- Fixed HUD — never scrolls, always on top of the scan view. -->
<div class="hud">
<div class="hud-top">
<div id="status-badges" class="status-badges"></div>
<div class="hud-top-right">
<div id="map-points" class="disk-free hud-disk-free"></div>
<div id="disk-free" class="disk-free hud-disk-free"></div>
</div>
</div>
<div class="hud-bottom">
<div class="hud-startup">
<button id="btn-lidar-start" class="btn btn-green btn-hud">시동</button>
<button id="btn-lidar-stop" class="btn btn-red btn-hud">정지</button>
<div id="lidar-state" class="state-line">● 정지</div>
</div>
<div class="hud-rec">
<button id="btn-rec-open" class="btn btn-red btn-hud">● 녹화</button>
<button id="btn-rec-stop" class="btn btn-gray btn-hud hidden">■ 정지</button>
<div id="rec-meter" class="rec-meter hidden"></div>
</div>
<div class="hud-actions">
<button id="btn-settings" class="btn btn-gray btn-hud btn-icon" aria-label="설정"></button>
<button id="btn-estop" class="btn-estop btn-estop-hud">E-STOP</button>
</div>
</div>
</div>
<!-- Settings overlay — camera settings / preview / recent bags, plus the
entry point into the log overlay. Floats over the scan view, closed by
default. -->
<div id="overlay-settings" class="overlay hidden">
<div class="overlay-backdrop" data-close="overlay-settings"></div>
<div class="overlay-sheet">
<div class="overlay-header">
<h2>설정</h2>
<button class="btn-close" data-close="overlay-settings"></button>
</div>
<div class="overlay-tabs">
<button class="pane-tab-btn active" data-pane="pane-camera-settings">카메라 설정</button>
<button class="pane-tab-btn" data-pane="pane-camera-preview">미리보기</button>
<button class="pane-tab-btn" data-pane="pane-lidar-test">라이다 테스트</button>
<button class="pane-tab-btn" data-pane="pane-recent-bags">최근 녹화</button>
<button id="btn-open-log" class="pane-tab-btn pane-tab-btn-log">로그</button>
</div>
<div class="overlay-body">
<div class="pane-tab active" id="pane-camera-settings">
<div class="tabs" id="camera-tabs"></div>
<div id="camera-tab-panels"></div>
</div>
<div class="pane-tab" id="pane-camera-preview">
<div id="camera-grid" class="camera-grid"></div>
</div>
<div class="pane-tab" id="pane-lidar-test">
<p class="hint">녹화 없이 FAST-LIO만 켜서 라이다 포인트 품질을 미리 확인합니다.
시작 후 몇 초간 흔들지 말고 가만히 두세요 — FAST-LIO가 정지 상태의 IMU로 중력 방향을 자동 정렬합니다.</p>
<div class="btn-row">
<button id="btn-fastlio-test-start" class="btn btn-blue">테스트 시작</button>
<button id="btn-fastlio-test-stop" class="btn btn-gray">테스트 정지</button>
</div>
<div id="fastlio-test-state" class="state-line">● 정지</div>
<h3>시점</h3>
<div class="btn-row">
<button id="btn-view-orbit" class="btn btn-blue">자유 시점</button>
<button id="btn-view-lidar" class="btn btn-gray">라이다 시점</button>
</div>
</div>
<div class="pane-tab" id="pane-recent-bags">
<ul id="bag-list" class="bag-list"></ul>
</div>
</div>
</div>
</div>
<!-- Recording modal — just enough to see/set the save path and filename
before confirming; floats over the scan view. -->
<div id="overlay-recording" class="overlay hidden">
<div class="overlay-backdrop" data-close="overlay-recording"></div>
<div class="overlay-sheet overlay-sheet-small">
<div class="overlay-header">
<h2>녹화 시작</h2>
<button class="btn-close" data-close="overlay-recording"></button>
</div>
<div class="overlay-body">
<div class="field-row">
<label>저장 경로</label>
<input type="text" id="rec-save-dir" />
<button id="btn-browse" class="btn btn-small"></button>
</div>
<div id="browse-panel" class="browse-panel hidden"></div>
<div class="field-row">
<label>파일 이름</label>
<input type="text" id="rec-filename" placeholder="scan_001" />
</div>
<div class="btn-row">
<button id="btn-rec-confirm" class="btn btn-red">● 녹화 시작</button>
</div>
<div id="rec-path" class="hint"></div>
</div>
</div>
</div>
<!-- Log overlay — opened from inside Settings, stacks above it. -->
<div id="overlay-log" class="overlay hidden">
<div class="overlay-backdrop" data-close="overlay-log"></div>
<div class="overlay-sheet">
<div class="overlay-header">
<h2>로그</h2>
<div class="log-header-actions">
<label class="log-autoscroll"><input type="checkbox" id="log-autoscroll" checked /> 자동 스크롤</label>
<button id="btn-log-clear" class="btn btn-small btn-gray">지우기</button>
<button class="btn-close" data-close="overlay-log"></button>
</div>
</div>
<div id="log-body" class="log-body"></div>
</div>
</div>
</div>
<script type="module" src="/js/app.js"></script>
</body>
</html>
+132
View File
@@ -0,0 +1,132 @@
import { startStatusSocket } from "/js/status-panel.js";
import { buildCameraGrid, setPreviewActive } from "/js/camera-preview.js";
import { buildCameraTabs } from "/js/camera-settings.js";
import { initRecordingPanel } from "/js/recording-panel.js";
import { initLogPanel } from "/js/log-panel.js";
import { initPointcloudView } from "/js/pointcloud-view.js";
async function getJSON(url, opts) {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`${url}: ${res.status} ${await res.text()}`);
return res.json();
}
const OVERLAY_IDS = ["overlay-settings", "overlay-recording", "overlay-log"];
let pointcloudView = null;
function anyOverlayOpen() {
return OVERLAY_IDS.some((id) => !document.getElementById(id).classList.contains("hidden"));
}
// The point-cloud render loop keeps running (cheap-ish) but there's no
// reason to keep painting frames the operator can't see behind a full-screen
// overlay — pause on open, resume once every overlay is closed.
function syncPointcloudPaused() {
if (pointcloudView) pointcloudView.setPaused(anyOverlayOpen());
}
function openOverlay(id) {
document.getElementById(id).classList.remove("hidden");
syncPointcloudPaused();
}
function closeOverlay(id) {
document.getElementById(id).classList.add("hidden");
syncPointcloudPaused();
}
// Camera MJPEG only needs to stream while its tab is actually visible inside
// the open Settings overlay — otherwise it's wasted phone battery/bandwidth
// behind a closed overlay (same reasoning M3's point-cloud stream will need
// for the scan view itself).
function syncPreviewActive() {
const overlaySettings = document.getElementById("overlay-settings");
const previewPane = document.getElementById("pane-camera-preview");
const active = !overlaySettings.classList.contains("hidden") && previewPane.classList.contains("active");
setPreviewActive(active);
}
function setupOverlays() {
document.querySelectorAll("[data-close]").forEach((el) => {
el.onclick = () => {
closeOverlay(el.dataset.close);
syncPreviewActive();
};
});
document.getElementById("btn-settings").onclick = () => {
openOverlay("overlay-settings");
syncPreviewActive();
};
document.getElementById("btn-rec-open").onclick = () => openOverlay("overlay-recording");
document.getElementById("btn-open-log").onclick = () => openOverlay("overlay-log");
}
function setupPaneTabs() {
const btns = document.querySelectorAll(".pane-tab-btn[data-pane]");
btns.forEach((btn) => {
btn.onclick = () => {
btns.forEach((b) => b.classList.remove("active"));
document.querySelectorAll(".pane-tab").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
document.getElementById(btn.dataset.pane).classList.add("active");
syncPreviewActive();
};
});
}
async function main() {
setupOverlays();
setupPaneTabs();
pointcloudView = initPointcloudView();
const status = await getJSON("/api/status");
await buildCameraGrid(status.cameras);
await buildCameraTabs(status.cameras);
initRecordingPanel(status.default_save_dir);
initLogPanel();
document.getElementById("btn-lidar-start").onclick = async () => {
await getJSON("/api/lidar/start", { method: "POST" });
};
document.getElementById("btn-lidar-stop").onclick = async () => {
await getJSON("/api/lidar/stop", { method: "POST" });
};
document.getElementById("btn-estop").onclick = async () => {
if (!confirm("모든 프로세스(LiDAR/카메라/녹화)를 즉시 정지합니다. 계속할까요?")) return;
await getJSON("/api/system/estop", { method: "POST" });
};
document.getElementById("btn-fastlio-test-start").onclick = async () => {
try {
await getJSON("/api/fastlio/start", { method: "POST" });
} catch (e) {
alert(String(e));
}
};
document.getElementById("btn-fastlio-test-stop").onclick = async () => {
try {
await getJSON("/api/fastlio/stop", { method: "POST" });
} catch (e) {
alert(String(e));
}
};
const btnViewOrbit = document.getElementById("btn-view-orbit");
const btnViewLidar = document.getElementById("btn-view-lidar");
btnViewOrbit.onclick = () => {
pointcloudView.setViewMode("orbit");
btnViewOrbit.className = "btn btn-blue";
btnViewLidar.className = "btn btn-gray";
};
btnViewLidar.onclick = () => {
pointcloudView.setViewMode("lidar");
btnViewLidar.className = "btn btn-blue";
btnViewOrbit.className = "btn btn-gray";
};
startStatusSocket();
}
main().catch((e) => {
console.error(e);
alert("초기화 실패: " + e);
});
+33
View File
@@ -0,0 +1,33 @@
// Builds the MJPEG camera preview grid. One <img> per camera, no client JS
// decoding needed — the browser natively renders multipart MJPEG streams.
export function buildCameraGrid(cameras) {
const grid = document.getElementById("camera-grid");
grid.innerHTML = "";
for (const cam of cameras) {
const pane = document.createElement("div");
pane.className = "camera-pane";
const title = document.createElement("div");
title.className = "cam-title";
title.textContent = `${cam.id} (${cam.topic})`;
pane.appendChild(title);
const img = document.createElement("img");
img.dataset.camId = cam.id;
img.alt = cam.id;
pane.appendChild(img);
grid.appendChild(pane);
}
}
export function setPreviewActive(active) {
document.querySelectorAll("#camera-grid img").forEach((img) => {
if (active) {
if (!img.src) img.src = `/api/camera/${img.dataset.camId}/stream.mjpg?t=${Date.now()}`;
} else {
img.removeAttribute("src");
}
});
}
+148
View File
@@ -0,0 +1,148 @@
// Per-camera exposure/gain settings tabs — mirrors the QTabWidget layout in
// scan_gui_triple.py (auto-exposure toggle, brightness/exp_min/exp_max vs.
// exp_time mutually exclusive groups, gain; Apply = live ros2 param set,
// Save = persist into the base YAML).
async function getJSON(url, opts) {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`${url}: ${res.status} ${await res.text()}`);
return res.json();
}
function field(labelText, id, type, step) {
const wrap = document.createDocumentFragment();
const label = document.createElement("label");
label.textContent = labelText;
label.htmlFor = id;
const input = document.createElement("input");
input.type = type;
input.id = id;
if (step) input.step = step;
wrap.appendChild(label);
wrap.appendChild(input);
return { wrap, input };
}
export async function buildCameraTabs(cameras) {
const tabBar = document.getElementById("camera-tabs");
const panelsRoot = document.getElementById("camera-tab-panels");
tabBar.innerHTML = "";
panelsRoot.innerHTML = "";
for (let i = 0; i < cameras.length; i++) {
const cam = cameras[i];
const tabBtn = document.createElement("button");
tabBtn.className = "tab-btn" + (i === 0 ? " active" : "");
tabBtn.textContent = cam.id;
tabBtn.onclick = () => {
document.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active"));
document.querySelectorAll(".tab-panel").forEach((p) => p.classList.remove("active"));
tabBtn.classList.add("active");
document.getElementById(`cam-panel-${cam.id}`).classList.add("active");
};
tabBar.appendChild(tabBtn);
const panel = document.createElement("div");
panel.className = "tab-panel" + (i === 0 ? " active" : "");
panel.id = `cam-panel-${cam.id}`;
panelsRoot.appendChild(panel);
await buildCameraPanel(panel, cam.id);
}
}
async function buildCameraPanel(panel, camId) {
const values = await getJSON(`/api/camera/${camId}/params`);
const autoLabel = document.createElement("label");
const autoChk = document.createElement("input");
autoChk.type = "checkbox";
autoChk.checked = values.exposure_auto;
autoLabel.appendChild(autoChk);
autoLabel.appendChild(document.createTextNode(" 자동 노출"));
panel.appendChild(autoLabel);
const grid = document.createElement("div");
grid.className = "cam-field-grid";
panel.appendChild(grid);
const fBrightness = field("목표 밝기:", `${camId}-brightness`, "number");
fBrightness.input.min = 0; fBrightness.input.max = 255; fBrightness.input.value = values.exposure_auto_target_brightness;
const fExpMin = field("노출 하한 (us):", `${camId}-exp-min`, "number");
fExpMin.input.min = 10; fExpMin.input.max = 1000000; fExpMin.input.step = 100; fExpMin.input.value = values.exposure_auto_min;
const fExpMax = field("노출 상한 (us):", `${camId}-exp-max`, "number");
fExpMax.input.min = 10; fExpMax.input.max = 1000000; fExpMax.input.step = 1000; fExpMax.input.value = values.exposure_auto_max;
const fExpTime = field("노출 시간 (us):", `${camId}-exp-time`, "number");
fExpTime.input.min = 10; fExpTime.input.max = 1000000; fExpTime.input.value = values.exposure_time;
const fGain = field("게인 (dB):", `${camId}-gain`, "number");
fGain.input.min = 0; fGain.input.max = 16.9; fGain.input.step = 0.5; fGain.input.value = values.gain;
for (const f of [fBrightness, fExpMin, fExpMax, fExpTime, fGain]) grid.appendChild(f.wrap);
const autoWidgets = [fBrightness.input, fExpMin.input, fExpMax.input];
const manualWidgets = [fExpTime.input];
function applyToggleState(checked) {
autoWidgets.forEach((w) => (w.disabled = !checked));
manualWidgets.forEach((w) => (w.disabled = checked));
}
autoChk.onchange = () => applyToggleState(autoChk.checked);
applyToggleState(values.exposure_auto);
const btnRow = document.createElement("div");
btnRow.className = "btn-row";
const btnApply = document.createElement("button");
btnApply.className = "btn btn-blue";
btnApply.textContent = "적용";
const btnSave = document.createElement("button");
btnSave.className = "btn btn-green";
btnSave.textContent = "저장";
btnRow.appendChild(btnApply);
btnRow.appendChild(btnSave);
panel.appendChild(btnRow);
const result = document.createElement("div");
result.className = "cam-result";
panel.appendChild(result);
function collect() {
return {
exposure_auto: autoChk.checked,
exposure_auto_target_brightness: parseInt(fBrightness.input.value, 10),
exposure_auto_min: parseFloat(fExpMin.input.value),
exposure_auto_max: parseFloat(fExpMax.input.value),
exposure_time: parseInt(fExpTime.input.value, 10),
gain: parseFloat(fGain.input.value),
};
}
btnApply.onclick = async () => {
result.textContent = "적용 중…";
try {
await getJSON(`/api/camera/${camId}/params`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(collect()),
});
const r = await getJSON(`/api/camera/${camId}/params/apply`, { method: "POST" });
result.textContent = r.ok ? "적용 완료" : r.results.filter(x => !x.ok).map(x => `${x.name}: ${x.message}`).join(" / ");
result.style.color = r.ok ? "var(--color-success)" : "var(--color-danger)";
} catch (e) {
result.textContent = String(e);
result.style.color = "var(--color-danger)";
}
};
btnSave.onclick = async () => {
result.textContent = "저장 중…";
try {
await getJSON(`/api/camera/${camId}/params`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(collect()),
});
const r = await getJSON(`/api/camera/${camId}/params/save`, { method: "POST" });
result.textContent = `저장됨 → ${r.path}`;
result.style.color = "var(--color-success)";
} catch (e) {
result.textContent = String(e);
result.style.color = "var(--color-danger)";
}
};
}
+21
View File
@@ -0,0 +1,21 @@
export function formatBytes(bytes) {
if (bytes == null) return "";
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export function formatElapsed(seconds) {
if (seconds == null) return "";
const s = Math.floor(seconds);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
const pad = (n) => String(n).padStart(2, "0");
return h > 0 ? `${h}:${pad(m % 60)}:${pad(s % 60)}` : `${pad(m)}:${pad(s % 60)}`;
}
export function formatDuration(seconds) {
if (seconds == null) return "—";
if (seconds < 60) return `${seconds.toFixed(1)}s`;
return formatElapsed(seconds);
}
+45
View File
@@ -0,0 +1,45 @@
// Unified [tag] log scrollback — consumes /ws/logs (backlog once, then new
// lines as they're appended to lidar.log/camera.log/recording.log).
const MAX_RENDERED_LINES = 2000;
export function initLogPanel() {
const body = document.getElementById("log-body");
const autoscrollChk = document.getElementById("log-autoscroll");
const clearBtn = document.getElementById("btn-log-clear");
function appendLine({ tag, line }) {
const row = document.createElement("div");
row.className = `log-line tag-${tag}`;
const tagSpan = document.createElement("span");
tagSpan.className = "log-tag";
tagSpan.textContent = `[${tag}]`;
const textSpan = document.createElement("span");
textSpan.className = "log-text";
textSpan.textContent = line;
row.appendChild(tagSpan);
row.appendChild(textSpan);
body.appendChild(row);
while (body.childElementCount > MAX_RENDERED_LINES) {
body.removeChild(body.firstChild);
}
if (autoscrollChk.checked) {
body.scrollTop = body.scrollHeight;
}
}
clearBtn.onclick = () => { body.innerHTML = ""; };
const proto = location.protocol === "https:" ? "wss:" : "ws:";
function connect() {
const ws = new WebSocket(`${proto}//${location.host}/ws/logs`);
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.backlog) msg.backlog.forEach(appendLine);
if (msg.lines) msg.lines.forEach(appendLine);
};
ws.onclose = () => setTimeout(connect, 1500);
ws.onerror = () => ws.close();
}
connect();
}
+169
View File
@@ -0,0 +1,169 @@
// M3 live scan view — consumes /ws/pointcloud (see backend/pointcloud_codec.py
// for the exact binary layout) and renders into #pointcloud-canvas with
// three.js. One reused BufferGeometry with pre-allocated typed arrays,
// overwritten in place per frame (needsUpdate=true) to avoid GC churn at
// the stream's 3-5Hz.
import * as THREE from "three";
import { OrbitControls } from "/vendor/three/OrbitControls.js";
const MAGIC = "PCF1";
// Must match backend config.py POINTCLOUD_PROFILES max_points — sizes the
// pre-allocated buffers, so it can't just read the count off the wire.
const PROFILE_MAX_POINTS = { desktop: 20000, phone: 7000 };
function pickProfile() {
return window.innerWidth < 768 ? "phone" : "desktop";
}
export function initPointcloudView() {
const canvas = document.getElementById("pointcloud-canvas");
const emptyState = document.getElementById("scan-empty");
const profile = pickProfile();
const maxPoints = PROFILE_MAX_POINTS[profile];
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0e14);
const camera = new THREE.PerspectiveCamera(60, 1, 0.05, 500);
// FAST-LIO's "camera_init" world frame is z-up (LiDAR convention), not
// three.js's default y-up — set the camera's up vector to match so
// OrbitControls orbits around the right axis.
camera.up.set(0, 0, 1);
camera.position.set(0, -8, 5);
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
const grid = new THREE.GridHelper(40, 40, 0x2a3040, 0x1a1f28);
grid.rotation.x = Math.PI / 2; // GridHelper defaults to the XZ plane (y-up); rotate into XY (z-up)
scene.add(grid);
const positions = new Float32Array(maxPoints * 3);
const colors = new Float32Array(maxPoints * 3);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
geometry.setDrawRange(0, 0);
const material = new THREE.PointsMaterial({ size: 0.035, vertexColors: true, sizeAttenuation: true });
scene.add(new THREE.Points(geometry, material));
// Small marker at the current sensor pose (position + heading).
const poseGizmo = new THREE.Mesh(
new THREE.ConeGeometry(0.15, 0.4, 12),
new THREE.MeshBasicMaterial({ color: 0x5b8cff }),
);
poseGizmo.rotation.x = Math.PI / 2; // ConeGeometry points +y by default; point it along +x (forward) instead
poseGizmo.visible = false;
scene.add(poseGizmo);
let hasCentered = false;
let running = true;
let paused = false;
let viewMode = "orbit"; // or "lidar"
const latestPose = { position: new THREE.Vector3(), quaternion: new THREE.Quaternion(), valid: false };
const lookTarget = new THREE.Vector3();
const FORWARD = new THREE.Vector3(1, 0, 0); // body-frame +x — matches the poseGizmo's forward convention below
function resize() {
const w = canvas.clientWidth, h = canvas.clientHeight;
if (w === 0 || h === 0) return;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
new ResizeObserver(resize).observe(canvas);
resize();
function updateCameraForLidarView() {
if (!latestPose.valid) return;
camera.position.copy(latestPose.position);
lookTarget.copy(FORWARD).applyQuaternion(latestPose.quaternion).add(latestPose.position);
camera.up.set(0, 0, 1);
camera.lookAt(lookTarget);
}
function animate() {
if (!running) return;
requestAnimationFrame(animate);
if (paused) return;
if (viewMode === "lidar") {
updateCameraForLidarView();
} else {
controls.update();
}
renderer.render(scene, camera);
}
animate();
function applyFrame(buf) {
const view = new DataView(buf);
if (String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3)) !== MAGIC) return;
let off = 4;
off += 4; // frame_seq — unused client-side for now
off += 8; // timestamp
const poseFlag = view.getUint32(off, true); off += 4;
if (poseFlag) {
const x = view.getFloat32(off, true); const y = view.getFloat32(off + 4, true); const z = view.getFloat32(off + 8, true);
const qx = view.getFloat32(off + 12, true), qy = view.getFloat32(off + 16, true),
qz = view.getFloat32(off + 20, true), qw = view.getFloat32(off + 24, true);
off += 28;
poseGizmo.position.set(x, y, z);
poseGizmo.quaternion.set(qx, qy, qz, qw);
poseGizmo.visible = true;
latestPose.position.set(x, y, z);
latestPose.quaternion.set(qx, qy, qz, qw);
latestPose.valid = true;
if (!hasCentered) {
controls.target.set(x, y, z);
camera.position.set(x, y - 8, z + 5);
hasCentered = true;
}
}
const n = view.getUint32(off, true); off += 4;
const count = Math.min(n, maxPoints);
positions.set(new Float32Array(buf, off, count * 3));
const intensity = new Uint8Array(buf, off + n * 3 * 4, count);
for (let i = 0; i < count; i++) {
const t = intensity[i] / 255;
// Cool-to-warm ramp by intensity — no camera colour to draw from
// (LiDAR-only), so intensity is the only per-point signal worth encoding.
colors[i * 3] = 0.15 + 0.65 * t;
colors[i * 3 + 1] = 0.35 + 0.35 * (1 - Math.abs(t - 0.5) * 2);
colors[i * 3 + 2] = 0.75 - 0.55 * t;
}
geometry.attributes.position.needsUpdate = true;
geometry.attributes.color.needsUpdate = true;
geometry.setDrawRange(0, count);
emptyState.classList.toggle("hidden", count > 0);
}
function connect() {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${proto}//${location.host}/ws/pointcloud?profile=${profile}`);
ws.binaryType = "arraybuffer";
ws.onmessage = (ev) => applyFrame(ev.data);
ws.onclose = () => { if (running) setTimeout(connect, 1500); };
ws.onerror = () => ws.close();
}
connect();
return {
setPaused(p) { paused = p; },
// "orbit" — free OrbitControls camera (default). "lidar" — camera locked
// to the sensor's live position/heading, first-person. Disabling
// OrbitControls in lidar mode (not just skipping controls.update()) stops
// it from silently accumulating drag input it never gets to apply, which
// would otherwise jump the view on switching back to orbit.
setViewMode(mode) {
viewMode = mode;
controls.enabled = mode === "orbit";
},
};
}
+123
View File
@@ -0,0 +1,123 @@
import { formatBytes, formatDuration } from "/js/format.js";
async function getJSON(url, opts) {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`${url}: ${res.status} ${await res.text()}`);
return res.json();
}
export function initRecordingPanel(defaultSaveDir) {
const saveDirInput = document.getElementById("rec-save-dir");
saveDirInput.value = defaultSaveDir;
const browseBtn = document.getElementById("btn-browse");
const browsePanel = document.getElementById("browse-panel");
const filenameInput = document.getElementById("rec-filename");
const confirmBtn = document.getElementById("btn-rec-confirm");
const stopBtn = document.getElementById("btn-rec-stop");
const bagList = document.getElementById("bag-list");
browseBtn.onclick = async () => {
if (!browsePanel.classList.contains("hidden")) {
browsePanel.classList.add("hidden");
return;
}
await renderBrowse(saveDirInput.value || defaultSaveDir);
browsePanel.classList.remove("hidden");
};
async function renderBrowse(path) {
const data = await getJSON(`/api/fs/browse?path=${encodeURIComponent(path)}`);
browsePanel.innerHTML = "";
const cur = document.createElement("div");
cur.className = "dir-entry";
cur.style.fontWeight = "bold";
cur.textContent = `✓ 선택: ${data.path}`;
cur.onclick = () => {
saveDirInput.value = data.path;
browsePanel.classList.add("hidden");
refreshBags();
};
browsePanel.appendChild(cur);
if (data.parent) {
const up = document.createElement("div");
up.className = "dir-entry";
up.textContent = "..";
up.onclick = () => renderBrowse(data.parent);
browsePanel.appendChild(up);
}
for (const d of data.dirs) {
const el = document.createElement("div");
el.className = "dir-entry";
el.textContent = d.name;
el.onclick = () => renderBrowse(d.path);
browsePanel.appendChild(el);
}
}
saveDirInput.addEventListener("change", refreshBags);
confirmBtn.onclick = async () => {
try {
await getJSON("/api/recording/start", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: filenameInput.value, save_dir: saveDirInput.value }),
});
refreshBags();
document.getElementById("overlay-recording").classList.add("hidden");
} catch (e) {
alert(String(e));
}
};
stopBtn.onclick = async () => {
await getJSON("/api/recording/stop", { method: "POST" });
// rosbag2 flushes metadata.yaml on SIGINT — give it a moment before
// reading the bag back, otherwise it still shows as "incomplete".
setTimeout(refreshBags, 1200);
};
function renderBagItem(bag) {
const li = document.createElement("li");
li.className = "bag-item";
const nameRow = document.createElement("div");
nameRow.className = "bag-item-name";
nameRow.textContent = bag.name;
if (!bag.complete) {
const badge = document.createElement("span");
badge.className = "bag-item-incomplete";
badge.textContent = "(녹화 중 / 미완료)";
nameRow.appendChild(badge);
}
li.appendChild(nameRow);
const meta = document.createElement("div");
meta.className = "bag-item-meta";
const parts = [formatDuration(bag.duration_s), formatBytes(bag.size_bytes)];
if (bag.message_count != null) parts.push(`msg ${bag.message_count.toLocaleString()}`);
if (bag.topics && bag.topics.length) parts.push(`topic ${bag.topics.length}`);
meta.textContent = parts.filter(Boolean).join(" · ");
li.appendChild(meta);
return li;
}
// Disk-free is owned entirely by status-panel.js now (HUD strip, driven by
// /ws/status) — this panel only needs the bag list.
async function refreshBags() {
const dir = saveDirInput.value || defaultSaveDir;
try {
const bagsData = await getJSON(`/api/bags?save_dir=${encodeURIComponent(dir)}`);
bagList.innerHTML = "";
for (const bag of bagsData.bags) {
bagList.appendChild(renderBagItem(bag));
}
} catch (e) {
// Directory may not exist yet (new save path) — not an error worth alarming over.
bagList.innerHTML = "";
}
}
refreshBags();
setInterval(refreshBags, 15000);
}
+138
View File
@@ -0,0 +1,138 @@
// Renders the top-bar health badges + startup/recording state lines from /ws/status.
import { formatBytes, formatElapsed } from "/js/format.js";
function badgeClass(health) {
if (health === "OK") return "badge badge-ok";
if (health === "STALE") return "badge badge-stale";
return "badge badge-down";
}
// NBSP padding, not CSS min-width: regular spaces collapse in rendered
// text, so a guessed ch-width can still drift. Padding to a fixed character
// count (monospace font) makes each badge's pixel width identical on every
// tick regardless of health word or Hz digit count — nothing to reflow the
// row on mobile.
function padHealth(health) {
return health.padEnd(5, " ");
}
function formatHz(hz) {
return hz.toFixed(1).padStart(5, " ");
}
function makeBadge(label, health, hz) {
const b = document.createElement("span");
// badge-sensor: fixed CSS width (see style.css) — the NBSP padding above
// keeps internal jitter down, but the box's own size being fixed is what
// actually guarantees the position never moves, regardless of icon glyph
// width or font-metric rounding.
b.className = `${badgeClass(health)} badge-sensor`;
b.textContent = `${label} ${padHealth(health)}`;
const hzSpan = document.createElement("span");
hzSpan.className = "badge-hz";
hzSpan.textContent = health !== "DOWN" && hz > 0
? `${formatHz(hz)}Hz`
: `${" ".repeat(5)}Hz`;
b.appendChild(hzSpan);
return b;
}
export function startStatusSocket(onStatus) {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
let ws;
function connect() {
ws = new WebSocket(`${proto}//${location.host}/ws/status`);
ws.onmessage = (ev) => {
const status = JSON.parse(ev.data);
render(status);
if (onStatus) onStatus(status);
};
ws.onclose = () => setTimeout(connect, 1500);
ws.onerror = () => ws.close();
}
connect();
}
function render(status) {
const badges = document.getElementById("status-badges");
badges.innerHTML = "";
badges.appendChild(makeBadge("LiDAR", status.lidar.health, status.lidar.hz));
badges.appendChild(makeBadge("IMU", status.lidar.imu_health, status.lidar.imu_hz));
badges.appendChild(makeBadge("MAP", status.map.health, status.map.hz));
for (const cam of status.cameras) {
badges.appendChild(makeBadge(cam.id, cam.health, cam.hz));
}
if (status.recording.active) {
const b = document.createElement("span");
b.className = "badge badge-rec";
b.textContent = "REC";
badges.appendChild(b);
}
const lidarState = document.getElementById("lidar-state");
if (lidarState) {
lidarState.textContent = status.lidar.running ? "● 실행 중" : "● 정지";
lidarState.className = "state-line " + (status.lidar.running ? "on" : "off");
}
const recPath = document.getElementById("rec-path");
if (recPath) {
recPath.textContent = status.recording.path ? `저장: ${status.recording.path}` : "";
}
const recMeter = document.getElementById("rec-meter");
if (recMeter) {
if (status.recording.active) {
recMeter.textContent = `${formatElapsed(status.recording.elapsed_s)} · ${formatBytes(status.recording.size_bytes)}`;
recMeter.classList.remove("hidden");
} else {
recMeter.classList.add("hidden");
}
}
// Always-on compact readout in the HUD — the backend already resolves the
// right path (DEFAULT_SAVE_DIR when idle, the live recording target while
// active), so this can just mirror status.disk unconditionally.
const diskEl = document.getElementById("disk-free");
if (diskEl && status.disk) {
const freeGb = status.disk.free_gb;
diskEl.textContent = `여유 ${freeGb.toFixed(1)}GB`;
diskEl.classList.remove("warning", "danger");
if (freeGb < status.disk.low_danger_gb) diskEl.classList.add("danger");
else if (freeGb < status.disk.low_warning_gb) diskEl.classList.add("warning");
}
const mapPointsEl = document.getElementById("map-points");
if (mapPointsEl && status.map) {
mapPointsEl.textContent = status.map.points > 0 ? `${status.map.points.toLocaleString()}pt` : "";
mapPointsEl.classList.toggle("hidden", status.map.points === 0);
}
document.getElementById("btn-lidar-start").disabled = status.lidar.running;
document.getElementById("btn-lidar-stop").disabled = !status.lidar.running;
// The record button swaps for a stop button once recording is live,
// rather than just disabling both — HUD real estate is tight on a phone.
const recOpenBtn = document.getElementById("btn-rec-open");
const recStopBtn = document.getElementById("btn-rec-stop");
recOpenBtn.classList.toggle("hidden", status.recording.active);
recStopBtn.classList.toggle("hidden", !status.recording.active);
recOpenBtn.disabled = !status.lidar.running;
const fastlioTestState = document.getElementById("fastlio-test-state");
const fastlioTestStart = document.getElementById("btn-fastlio-test-start");
const fastlioTestStop = document.getElementById("btn-fastlio-test-stop");
if (fastlioTestState && status.map) {
const running = !!(status.process_status && status.process_status.fastlio && status.process_status.fastlio.running);
fastlioTestState.textContent = running
? `● 실행 중 (${status.map.health} · ${status.map.hz.toFixed(1)}Hz · ${status.map.points.toLocaleString()}pt)`
: "● 정지";
fastlioTestState.className = "state-line " + (running ? "on" : "off");
fastlioTestStart.disabled = !status.lidar.running || running;
// Stopping while a real recording owns fastlio is rejected server-side
// (409) — disable client-side too so the test button can't fire that.
fastlioTestStop.disabled = !running || status.recording.active;
}
}
Binary file not shown.
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
The MIT License
Copyright © 2010-2024 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Desktop-launcher entry point: starts the backend (if not already running)
# and opens the browser once it's actually accepting requests, instead of a
# blind sleep.
set -euo pipefail
URL="http://localhost:8000"
LOG="/home/gardentech/scan_web_logs/server.log"
mkdir -p "$(dirname "$LOG")"
if ! curl -sf "$URL/api/status" >/dev/null 2>&1; then
nohup /home/gardentech/scan_web/run_scan_web.sh >>"$LOG" 2>&1 &
for _ in $(seq 1 60); do
if curl -sf "$URL/api/status" >/dev/null 2>&1; then
break
fi
sleep 0.5
done
fi
xdg-open "$URL" >/dev/null 2>&1 &
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Launches scan_web: sources the ROS overlays the backend's ManagedProcess
# commands need to be able to `ros2 launch`/`ros2 bag record` against, then
# runs the FastAPI app with a single uvicorn worker (required — see
# backend/app.py: the rclpy context + ProcessManager registry live in
# process memory and must not be duplicated across workers).
# NOTE: no `-u` (nounset) — ROS's setup.bash references unset variables
# internally (e.g. AMENT_TRACE_SETUP_FILES) and isn't nounset-safe.
set -eo pipefail
HOME_DIR="/home/gardentech"
source /opt/ros/humble/setup.bash
# fast_ws deprecated (2026-08-05) — it was the pre-3-camera predecessor to
# fast_dual_ws and never had its own livox_ros_driver2 build (only chained
# to lidar2_ws/install as an underlay). Source lidar2_ws directly.
source "${HOME_DIR}/lidar2_ws/install/setup.bash"
source "${HOME_DIR}/camera2_ws/install/setup.bash"
if [ -f "${HOME_DIR}/fast_dual_ws/install/setup.bash" ]; then
source "${HOME_DIR}/fast_dual_ws/install/setup.bash"
fi
# rtk_ws intentionally not sourced yet — GPS/RTK integration is Phase 1
# Milestone 2, unused until then.
cd "${HOME_DIR}/scan_web/backend"
exec python3 -m uvicorn app:app --host 0.0.0.0 --port 8000 --workers 1