Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f805225bfa | |||
| f34817d5b6 |
@@ -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