Initial commit: scan_web integrated recording UI
FastAPI backend + vanilla JS frontend for LiDAR/camera startup, camera settings, and rosbag recording, unifying the previous scan_gui/scan_gui_dual/ scan_gui_triple desktop tools into one web app.
This commit is contained in:
@@ -0,0 +1,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)
|
||||
Reference in New Issue
Block a user