f34817d5b6
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.
159 lines
5.7 KiB
Python
159 lines
5.7 KiB
Python
"""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)
|