Initial commit: scan_web integrated recording UI
FastAPI backend + vanilla JS frontend for LiDAR/camera startup, camera settings, and rosbag recording, unifying the previous scan_gui/scan_gui_dual/ scan_gui_triple desktop tools into one web app.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
*.log
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.29
|
||||
websockets>=12.0
|
||||
numpy>=1.22
|
||||
PyYAML>=6.0
|
||||
@@ -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)
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
import camera_params
|
||||
import config
|
||||
|
||||
router = APIRouter(prefix="/api/camera", tags=["camera"])
|
||||
|
||||
|
||||
class ParamValuesIn(BaseModel):
|
||||
exposure_auto: bool
|
||||
exposure_auto_target_brightness: int
|
||||
exposure_auto_min: float
|
||||
exposure_auto_max: float
|
||||
exposure_time: int
|
||||
gain: float
|
||||
|
||||
|
||||
def _get_spec(request: Request, cam_id: str) -> config.CameraSpec:
|
||||
session = request.app.state.session
|
||||
specs = {c.id: c for c in config.camera_specs(session.camera_count)}
|
||||
spec = specs.get(cam_id)
|
||||
if spec is None:
|
||||
raise HTTPException(404, f"unknown camera id '{cam_id}' for camera_count={session.camera_count}")
|
||||
return spec
|
||||
|
||||
|
||||
@router.get("/{cam_id}/params")
|
||||
async def get_params(cam_id: str, request: Request):
|
||||
spec = _get_spec(request, cam_id)
|
||||
session = request.app.state.session
|
||||
values = session.camera_values.get(cam_id) or camera_params.load_defaults(spec)
|
||||
session.camera_values[cam_id] = values
|
||||
return values.as_dict()
|
||||
|
||||
|
||||
@router.post("/{cam_id}/params")
|
||||
async def set_params(cam_id: str, body: ParamValuesIn, request: Request):
|
||||
spec = _get_spec(request, cam_id)
|
||||
session = request.app.state.session
|
||||
values = camera_params.CameraParamValues(**body.model_dump())
|
||||
session.camera_values[cam_id] = values
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{cam_id}/params/apply")
|
||||
async def apply_params(cam_id: str, request: Request):
|
||||
spec = _get_spec(request, cam_id)
|
||||
session = request.app.state.session
|
||||
pm = request.app.state.pm
|
||||
if not pm.is_running("camera"):
|
||||
raise HTTPException(409, "camera is not running")
|
||||
values = session.camera_values.get(cam_id) or camera_params.load_defaults(spec)
|
||||
results = await run_in_threadpool(camera_params.apply_params, spec, values)
|
||||
ok = all(r["ok"] for r in results)
|
||||
return {"ok": ok, "results": results}
|
||||
|
||||
|
||||
@router.post("/{cam_id}/params/save")
|
||||
async def save_params(cam_id: str, request: Request):
|
||||
spec = _get_spec(request, cam_id)
|
||||
session = request.app.state.session
|
||||
values = session.camera_values.get(cam_id) or camera_params.load_defaults(spec)
|
||||
try:
|
||||
await run_in_threadpool(camera_params.save_params, spec, values)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"save failed: {e}")
|
||||
return {"ok": True, "path": spec.base_params_path}
|
||||
|
||||
|
||||
@router.get("/{cam_id}/stream.mjpg")
|
||||
async def stream(cam_id: str, request: Request):
|
||||
_get_spec(request, cam_id) # validate
|
||||
ros = request.app.state.ros
|
||||
return StreamingResponse(
|
||||
ros.mjpeg_generator(cam_id),
|
||||
media_type="multipart/x-mixed-replace; boundary=frame",
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
import bag_metadata
|
||||
import config
|
||||
|
||||
router = APIRouter(tags=["recording"])
|
||||
|
||||
# Directory browsing/recording is restricted to the user's home tree — the
|
||||
# browser has no server-filesystem access, so /api/fs/browse is a small
|
||||
# custom endpoint standing in for a native folder picker.
|
||||
BROWSE_ROOT = Path.home()
|
||||
|
||||
|
||||
def _safe_path(raw: str) -> Path:
|
||||
p = (BROWSE_ROOT / raw).resolve() if not os.path.isabs(raw) else Path(raw).resolve()
|
||||
try:
|
||||
p.relative_to(BROWSE_ROOT.resolve())
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"path must be under {BROWSE_ROOT}")
|
||||
return p
|
||||
|
||||
|
||||
@router.get("/fs/browse")
|
||||
async def browse(path: str = str(BROWSE_ROOT)):
|
||||
p = _safe_path(path)
|
||||
if not p.exists() or not p.is_dir():
|
||||
raise HTTPException(404, "not a directory")
|
||||
entries = []
|
||||
for child in sorted(p.iterdir()):
|
||||
if child.is_dir():
|
||||
entries.append({"name": child.name, "path": str(child)})
|
||||
return {"path": str(p), "parent": str(p.parent) if p != BROWSE_ROOT.resolve() else None, "dirs": entries}
|
||||
|
||||
|
||||
@router.get("/bags")
|
||||
async def list_bags(save_dir: str = config.DEFAULT_SAVE_DIR, limit: int = 10):
|
||||
d = Path(save_dir)
|
||||
if not d.exists():
|
||||
return {"bags": []}
|
||||
bags = []
|
||||
children = sorted(d.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
for child in children:
|
||||
if not child.is_dir():
|
||||
continue
|
||||
if len(bags) >= limit:
|
||||
break
|
||||
meta = bag_metadata.read_bag_metadata(child)
|
||||
bags.append({
|
||||
"name": child.name,
|
||||
"path": str(child),
|
||||
"mtime": child.stat().st_mtime,
|
||||
"duration_s": meta["duration_s"] if meta else None,
|
||||
"message_count": meta["message_count"] if meta else None,
|
||||
"topics": meta["topics"] if meta else [],
|
||||
"size_bytes": meta["size_bytes"] if meta else bag_metadata.dir_size_bytes(child),
|
||||
"complete": meta is not None, # False = still recording, or was killed before flush
|
||||
})
|
||||
return {"bags": bags}
|
||||
|
||||
|
||||
@router.get("/fs/disk-usage")
|
||||
async def disk_usage(path: str = config.DEFAULT_SAVE_DIR):
|
||||
return {"free_gb": round(config.disk_free_gb(path), 2)}
|
||||
|
||||
|
||||
class RecordingStartIn(BaseModel):
|
||||
filename: str
|
||||
save_dir: str = config.DEFAULT_SAVE_DIR
|
||||
|
||||
|
||||
@router.post("/recording/start")
|
||||
async def recording_start(body: RecordingStartIn, request: Request):
|
||||
session = request.app.state.session
|
||||
pm = request.app.state.pm
|
||||
|
||||
if not pm.is_running("lidar") or not pm.is_running("camera"):
|
||||
raise HTTPException(409, "start lidar+camera before recording")
|
||||
if session.recording:
|
||||
raise HTTPException(409, "already recording")
|
||||
if not body.filename.strip():
|
||||
raise HTTPException(400, "filename required")
|
||||
|
||||
save_dir = body.save_dir.strip() or config.DEFAULT_SAVE_DIR
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
bag_path = os.path.join(save_dir, body.filename.strip())
|
||||
topics = config.base_record_topics(session.camera_count)
|
||||
|
||||
# FAST-LIO tracks the scan (LiDAR+IMU-only mapping) for exactly the
|
||||
# recording's lifetime — started alongside the bag, stopped alongside it.
|
||||
# It only needs /livox/lidar + /livox/imu, both already live since
|
||||
# lidar+camera are required (checked above) before recording can start.
|
||||
request.app.state.ros.pointcloud.reset() # fresh map for this recording, not last one's leftovers
|
||||
pm.start("fastlio", config.FASTLIO_LAUNCH_CMD, log_path=config.FASTLIO_LOG_PATH, env=config.fastlio_env())
|
||||
|
||||
cmd = f"{config.ROSBAG_SOURCE} && stdbuf -oL -eL ros2 bag record -o {bag_path} {topics}"
|
||||
pm.start("recording", cmd, log_path=config.RECORDING_LOG_PATH)
|
||||
session.recording = True
|
||||
session.recording_path = bag_path
|
||||
session.recording_started_at = time.monotonic()
|
||||
return {"ok": True, "path": bag_path}
|
||||
|
||||
|
||||
@router.post("/recording/stop")
|
||||
async def recording_stop(request: Request):
|
||||
session = request.app.state.session
|
||||
pm = request.app.state.pm
|
||||
pm.stop("recording")
|
||||
pm.stop("fastlio")
|
||||
session.recording = False
|
||||
session.recording_path = None
|
||||
session.recording_started_at = None
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,212 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
import bag_metadata
|
||||
import camera_params
|
||||
import config
|
||||
from ros_bridge import ROSBridge
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SessionConfig(BaseModel):
|
||||
camera_count: int
|
||||
|
||||
|
||||
@router.post("/session")
|
||||
async def set_session(cfg: SessionConfig, request: Request):
|
||||
if cfg.camera_count not in config.CAMERA_PROFILES:
|
||||
raise HTTPException(400, f"unsupported camera_count, expected one of {sorted(config.CAMERA_PROFILES)}")
|
||||
session = request.app.state.session
|
||||
pm = request.app.state.pm
|
||||
if pm.is_running("lidar") or pm.is_running("camera"):
|
||||
raise HTTPException(409, "stop lidar/camera before changing camera_count")
|
||||
|
||||
session.camera_count = cfg.camera_count
|
||||
# ROSBridge subscriptions are fixed at construction (see ros_bridge.py) —
|
||||
# changing camera_count means tearing down and recreating the bridge.
|
||||
old_ros: ROSBridge = request.app.state.ros
|
||||
old_ros.stop()
|
||||
new_ros = ROSBridge(camera_count=cfg.camera_count)
|
||||
new_ros.start()
|
||||
request.app.state.ros = new_ros
|
||||
return {"ok": True, "camera_count": session.camera_count}
|
||||
|
||||
|
||||
def _camera_launch_cmd(app) -> str:
|
||||
session = app.state.session
|
||||
specs = config.camera_specs(session.camera_count)
|
||||
launch_file = config.camera_launch_file(session.camera_count)
|
||||
args = []
|
||||
for cam in specs:
|
||||
values = session.camera_values.get(cam.id) or camera_params.load_defaults(cam)
|
||||
session.camera_values[cam.id] = values
|
||||
path = camera_params.write_launch_params_yaml(cam, values)
|
||||
args.append(f" {cam.launch_arg}:={path}")
|
||||
return (
|
||||
f"{config.CAMERA_LAUNCH_SOURCE} && "
|
||||
f"stdbuf -oL -eL ros2 launch hik_camera_ros2_driver {launch_file}" + "".join(args)
|
||||
)
|
||||
|
||||
|
||||
async def _auto_start_camera_after_delay(app):
|
||||
await asyncio.sleep(config.CAMERA_DELAY_SEC)
|
||||
pm = app.state.pm
|
||||
if pm.is_running("lidar") and not pm.is_running("camera"):
|
||||
pm.start("camera", _camera_launch_cmd(app), log_path=config.CAMERA_LOG_PATH)
|
||||
|
||||
|
||||
@router.post("/lidar/start")
|
||||
async def lidar_start(request: Request):
|
||||
pm = request.app.state.pm
|
||||
pm.start("lidar", config.LIDAR_LAUNCH_CMD, log_path=config.LIDAR_LOG_PATH)
|
||||
asyncio.create_task(_auto_start_camera_after_delay(request.app))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/lidar/stop")
|
||||
async def lidar_stop(request: Request):
|
||||
pm = request.app.state.pm
|
||||
session = request.app.state.session
|
||||
pm.stop("lidar")
|
||||
pm.stop("camera")
|
||||
# fastlio has no sensor data source once lidar stops — tear it down here
|
||||
# too, whether it's running because a recording is active or because the
|
||||
# operator started it standalone via the settings test panel.
|
||||
pm.stop("fastlio")
|
||||
if session.recording:
|
||||
pm.stop("recording")
|
||||
session.recording = False
|
||||
session.recording_path = None
|
||||
session.recording_started_at = None
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/camera/start")
|
||||
async def camera_start(request: Request):
|
||||
pm = request.app.state.pm
|
||||
if not pm.is_running("lidar"):
|
||||
raise HTTPException(409, "start lidar first")
|
||||
pm.start("camera", _camera_launch_cmd(request.app), log_path=config.CAMERA_LOG_PATH)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/camera/stop")
|
||||
async def camera_stop(request: Request):
|
||||
request.app.state.pm.stop("camera")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/fastlio/start")
|
||||
async def fastlio_start(request: Request):
|
||||
"""Manual FAST-LIO start, independent of recording — lets the operator
|
||||
preview lidar point-cloud quality (rig leveling, coverage) in Settings
|
||||
before committing to an actual recording."""
|
||||
pm = request.app.state.pm
|
||||
if not pm.is_running("lidar"):
|
||||
raise HTTPException(409, "start lidar first")
|
||||
request.app.state.ros.pointcloud.reset()
|
||||
pm.start("fastlio", config.FASTLIO_LAUNCH_CMD, log_path=config.FASTLIO_LOG_PATH, env=config.fastlio_env())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/fastlio/stop")
|
||||
async def fastlio_stop(request: Request):
|
||||
if request.app.state.session.recording:
|
||||
raise HTTPException(409, "recording is using fastlio — stop the recording instead")
|
||||
request.app.state.pm.stop("fastlio")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/system/estop")
|
||||
async def estop(request: Request):
|
||||
request.app.state.pm.stop_all()
|
||||
request.app.state.session.recording = False
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def build_status(app) -> dict:
|
||||
from health import TopicThreshold
|
||||
|
||||
pm = app.state.pm
|
||||
ros: ROSBridge = app.state.ros
|
||||
session = app.state.session
|
||||
specs = config.camera_specs(session.camera_count)
|
||||
|
||||
lidar_running = pm.is_running("lidar")
|
||||
camera_running = pm.is_running("camera")
|
||||
fastlio_running = pm.is_running("fastlio")
|
||||
|
||||
lidar_thr = config.HEALTH_THRESHOLDS["/livox/lidar"]
|
||||
cam_thr = config.HEALTH_THRESHOLDS["camera_image"]
|
||||
map_thr = config.HEALTH_THRESHOLDS["/Odometry"]
|
||||
|
||||
cameras = []
|
||||
for cam in specs:
|
||||
health = ros.health.classify(
|
||||
cam.image_topic, camera_running, TopicThreshold(*cam_thr)
|
||||
)
|
||||
cameras.append({
|
||||
"id": cam.id, "topic": cam.image_topic, "health": health,
|
||||
"hz": ros.health.rate_hz(cam.image_topic),
|
||||
})
|
||||
|
||||
recording_elapsed_s = None
|
||||
recording_size_bytes = None
|
||||
disk_check_dir = config.DEFAULT_SAVE_DIR
|
||||
if session.recording and session.recording_path:
|
||||
if session.recording_started_at is not None:
|
||||
recording_elapsed_s = round(time.monotonic() - session.recording_started_at, 1)
|
||||
recording_size_bytes = bag_metadata.dir_size_bytes(Path(session.recording_path))
|
||||
disk_check_dir = str(Path(session.recording_path).parent)
|
||||
|
||||
lidar_health = ros.health.classify("/livox/lidar", lidar_running, TopicThreshold(*lidar_thr))
|
||||
|
||||
return {
|
||||
"camera_count": session.camera_count,
|
||||
"default_save_dir": config.DEFAULT_SAVE_DIR,
|
||||
"lidar": {
|
||||
"running": lidar_running,
|
||||
"health": lidar_health,
|
||||
# /livox/imu isn't subscribed at all anymore (see ros_bridge.py's
|
||||
# module docstring — its ~200Hz starved the camera topics badly
|
||||
# enough that the operator asked to drop it, 2026-08-06). It
|
||||
# comes off the same livox_ros_driver2 process as the lidar
|
||||
# topic, so lidar's own health is a reasonable stand-in; there's
|
||||
# no real Hz number to report, so imu_hz stays 0 (the HUD badge
|
||||
# just shows the health word with no Hz suffix in that case).
|
||||
"imu_health": lidar_health,
|
||||
"hz": ros.health.rate_hz("/livox/lidar"),
|
||||
"imu_hz": 0.0,
|
||||
},
|
||||
"cameras": cameras,
|
||||
"map": {
|
||||
"health": ros.health.classify(config.ODOMETRY_TOPIC, fastlio_running, TopicThreshold(*map_thr)),
|
||||
"hz": ros.health.rate_hz(config.ODOMETRY_TOPIC),
|
||||
"points": ros.pointcloud.point_count(),
|
||||
},
|
||||
"recording": {
|
||||
"active": session.recording,
|
||||
"path": session.recording_path,
|
||||
"elapsed_s": recording_elapsed_s,
|
||||
"size_bytes": recording_size_bytes,
|
||||
},
|
||||
"disk": {
|
||||
"path": disk_check_dir,
|
||||
"free_gb": round(config.disk_free_gb(disk_check_dir), 2),
|
||||
"low_warning_gb": config.DISK_LOW_WARNING_GB,
|
||||
"low_danger_gb": config.DISK_LOW_DANGER_GB,
|
||||
},
|
||||
"process_status": pm.status(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def status(request: Request):
|
||||
return build_status(request.app)
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
import config
|
||||
from log_tail import LogTailer
|
||||
from routers.system import build_status
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
STATUS_PUSH_HZ = 1.0 # slowed from 2.0 — paired with health.py's wider RATE_WINDOW,
|
||||
# the operator asked for a calmer/more trustworthy number over a
|
||||
# snappier one (2026-08-06).
|
||||
LOG_POLL_HZ = 4.0
|
||||
|
||||
|
||||
@router.websocket("/ws/status")
|
||||
async def ws_status(ws: WebSocket):
|
||||
await ws.accept()
|
||||
period = 1.0 / STATUS_PUSH_HZ
|
||||
try:
|
||||
while True:
|
||||
await ws.send_json(build_status(ws.app))
|
||||
await asyncio.sleep(period)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
|
||||
|
||||
@router.websocket("/ws/pointcloud")
|
||||
async def ws_pointcloud(ws: WebSocket, profile: str = "desktop"):
|
||||
"""M3 live scan view — binary-framed decimated point cloud, see
|
||||
pointcloud_codec.py for the wire format. `profile` picks desktop vs.
|
||||
phone point/Hz budgets rather than trusting client-supplied numbers
|
||||
directly (a client could otherwise ask for an unbounded frame size)."""
|
||||
await ws.accept()
|
||||
prof = config.POINTCLOUD_PROFILES.get(profile, config.POINTCLOUD_PROFILES["desktop"])
|
||||
period = 1.0 / prof["hz"]
|
||||
ros = ws.app.state.ros
|
||||
try:
|
||||
while True:
|
||||
await ws.send_bytes(ros.pointcloud.encode_frame(max_points=prof["max_points"]))
|
||||
await asyncio.sleep(period)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
|
||||
|
||||
@router.websocket("/ws/logs")
|
||||
async def ws_logs(ws: WebSocket):
|
||||
"""Unified log tail — mirrors the old PyQt GUIs' single scrollback pane.
|
||||
Sends recent backlog once on connect, then only newly-appended lines."""
|
||||
await ws.accept()
|
||||
tailer = LogTailer({
|
||||
"lidar": config.LIDAR_LOG_PATH,
|
||||
"camera": config.CAMERA_LOG_PATH,
|
||||
"recording": config.RECORDING_LOG_PATH,
|
||||
"fastlio": config.FASTLIO_LOG_PATH,
|
||||
})
|
||||
backlog = []
|
||||
for tag in ("lidar", "camera", "recording", "fastlio"):
|
||||
backlog += [{"tag": tag, "line": ln} for ln in tailer.read_tail(tag, max_lines=80)]
|
||||
if backlog:
|
||||
await ws.send_json({"backlog": backlog})
|
||||
|
||||
period = 1.0 / LOG_POLL_HZ
|
||||
try:
|
||||
while True:
|
||||
new_lines = [{"tag": tag, "line": line} for tag, line in tailer.read_new()]
|
||||
if new_lines:
|
||||
await ws.send_json({"lines": new_lines})
|
||||
await asyncio.sleep(period)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
@@ -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
@@ -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` 참고.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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)";
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+21
@@ -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.
|
||||
+1523
File diff suppressed because it is too large
Load Diff
+6
File diff suppressed because one or more lines are too long
Executable
+21
@@ -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 &
|
||||
Executable
+26
@@ -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
|
||||
Reference in New Issue
Block a user