2428 lines
113 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# SLAM_GUI.py
from __future__ import annotations
import json
import os
import sys
import signal
import time
from pathlib import Path
import numpy as np
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QVBoxLayout, QHBoxLayout,
QLabel, QLineEdit, QPushButton, QFileDialog, QMessageBox,
QFrame, QSplitter, QCheckBox, QGraphicsDropShadowEffect, QScrollArea
)
from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal
import pyqtgraph.opengl as gl
from SLAM_engine import SlamEngineClient, load_slam_config
def apply_card_shadow(widget: QFrame):
shadow = QGraphicsDropShadowEffect(widget)
shadow.setBlurRadius(22)
shadow.setOffset(0, 6)
shadow.setColor(Qt.GlobalColor.black)
widget.setGraphicsEffect(shadow)
def pill_button(bg: str, fg: str = "white") -> str:
return f"""
QPushButton {{
background-color: {bg};
color: {fg};
font-weight: 800;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px;
padding: 10px 12px;
}}
QPushButton:hover {{ border: 1px solid rgba(255,255,255,0.18); }}
QPushButton:disabled {{
background-color: rgba(255,255,255,0.08);
color: rgba(255,255,255,0.35);
border: 1px solid rgba(255,255,255,0.05);
}}
"""
def card_style() -> str:
return """
QFrame#Card {
background-color: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 14px;
}
QLabel#CardTitle { color: rgba(255,255,255,0.75); font-weight: 800; }
QLineEdit {
background-color: rgba(0,0,0,0.30);
border: 1px solid rgba(255,255,255,0.10);
border-radius: 10px;
padding: 10px 10px;
color: white;
font-weight: 600;
}
QLineEdit:disabled {
color: rgba(255,255,255,0.40);
background-color: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.05);
}
QCheckBox { color: white; font-weight: 700; spacing: 10px; }
QCheckBox:disabled { color: rgba(255,255,255,0.40); }
"""
class SLAMGLViewWidget(gl.GLViewWidget):
mapClicked = pyqtSignal(float, float, float)
mapPickDrag = pyqtSignal(float, float, float, bool)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.pick_mode = False
self.pick_plane_z = 0.0
self._pick_dragging = False
self.pick_points = None
self.pick_screen_max_px = 140.0
@staticmethod
def _qmat_to_np_candidates(m) -> list[np.ndarray]:
try:
data = np.array(m.copyDataTo(), dtype=np.float64)
if data.size != 16:
return [np.eye(4, dtype=np.float64)]
mats = [
data.reshape((4, 4), order="F"),
data.reshape((4, 4), order="C"),
]
out: list[np.ndarray] = []
for mm in mats:
if np.isfinite(mm).all():
out.append(np.asarray(mm, dtype=np.float64))
return out if out else [np.eye(4, dtype=np.float64)]
except Exception:
return [np.eye(4, dtype=np.float64)]
def _screen_to_world_on_plane(self, sx: float, sy: float, plane_z: float = 0.0):
w = max(2, int(self.width()))
h = max(2, int(self.height()))
x_ndc = (2.0 * float(sx) / float(w)) - 1.0
y_ndc = 1.0 - (2.0 * float(sy) / float(h))
try:
cam = self.cameraPosition()
ctr = self.opts["center"]
cam_p = np.asarray([float(cam.x()), float(cam.y()), float(cam.z())], dtype=np.float64)
ctr_p = np.asarray([float(ctr.x()), float(ctr.y()), float(ctr.z())], dtype=np.float64)
fwd = ctr_p - cam_p
nf = float(np.linalg.norm(fwd))
if nf < 1e-9:
raise ValueError("degenerate camera forward")
fwd = fwd / nf
up_world = np.asarray([0.0, 0.0, 1.0], dtype=np.float64)
right = np.cross(fwd, up_world)
nr = float(np.linalg.norm(right))
if nr < 1e-6:
up_world = np.asarray([0.0, 1.0, 0.0], dtype=np.float64)
right = np.cross(fwd, up_world)
nr = float(np.linalg.norm(right))
if nr < 1e-9:
raise ValueError("degenerate camera right")
right = right / nr
up = np.cross(right, fwd)
nu = float(np.linalg.norm(up))
if nu < 1e-9:
raise ValueError("degenerate camera up")
up = up / nu
fov_rad = float(np.deg2rad(float(self.opts.get("fov", 60.0))))
sx_scale = float(np.tan(0.5 * fov_rad))
sy_scale = sx_scale * (float(h) / float(w))
ray = fwd + (x_ndc * sx_scale * right) + (y_ndc * sy_scale * up)
nray = float(np.linalg.norm(ray))
if nray < 1e-9:
raise ValueError("degenerate pick ray")
ray = ray / nray
if abs(float(ray[2])) < 1e-9:
return None
t = (float(plane_z) - float(cam_p[2])) / float(ray[2])
if t < 0.0:
return None
pt = cam_p + (t * ray)
if np.isfinite(pt).all():
return np.asarray(pt, dtype=np.float64)
except Exception:
pass
# Fallback: matrix unproject solver
viewport = self.getViewport()
region = viewport
proj_cands = self._qmat_to_np_candidates(self.projectionMatrix(region, viewport))
view_cands = self._qmat_to_np_candidates(self.viewMatrix())
near = np.array([x_ndc, y_ndc, -1.0, 1.0], dtype=np.float64)
far = np.array([x_ndc, y_ndc, 1.0, 1.0], dtype=np.float64)
best_pt = None
best_err = 1e18
for proj in proj_cands:
for view in view_cands:
for m in (proj @ view, view @ proj):
try:
inv = np.linalg.inv(m)
except Exception:
continue
p0 = inv @ near
p1 = inv @ far
if abs(float(p0[3])) < 1e-9 or abs(float(p1[3])) < 1e-9:
continue
p0 = p0[:3] / p0[3]
p1 = p1[:3] / p1[3]
ray = p1 - p0
if abs(float(ray[2])) < 1e-9:
continue
t = (float(plane_z) - float(p0[2])) / float(ray[2])
if t < 0.0:
continue
pt = p0 + (t * ray)
if not np.isfinite(pt).all():
continue
clip = m @ np.array([float(pt[0]), float(pt[1]), float(pt[2]), 1.0], dtype=np.float64)
if abs(float(clip[3])) < 1e-9:
continue
ndc = clip[:3] / clip[3]
sx2 = ((float(ndc[0]) + 1.0) * 0.5) * float(w)
sy2 = ((1.0 - float(ndc[1])) * 0.5) * float(h)
err = (sx2 - float(sx)) ** 2 + (sy2 - float(sy)) ** 2
if err < best_err:
best_err = float(err)
best_pt = np.asarray(pt, dtype=np.float64)
return best_pt
def _screen_pick_from_points(self, sx: float, sy: float):
pts = self.pick_points
if pts is None:
return None
arr = np.asarray(pts, dtype=np.float64)
if arr.ndim != 2 or arr.shape[1] != 3 or len(arr) == 0:
return None
w = max(2, int(self.width()))
h = max(2, int(self.height()))
try:
cam = self.cameraPosition()
ctr = self.opts["center"]
cam_p = np.asarray([float(cam.x()), float(cam.y()), float(cam.z())], dtype=np.float64)
ctr_p = np.asarray([float(ctr.x()), float(ctr.y()), float(ctr.z())], dtype=np.float64)
fwd = ctr_p - cam_p
nf = float(np.linalg.norm(fwd))
if nf < 1e-9:
return None
fwd = fwd / nf
up_world = np.asarray([0.0, 0.0, 1.0], dtype=np.float64)
right = np.cross(fwd, up_world)
nr = float(np.linalg.norm(right))
if nr < 1e-6:
up_world = np.asarray([0.0, 1.0, 0.0], dtype=np.float64)
right = np.cross(fwd, up_world)
nr = float(np.linalg.norm(right))
if nr < 1e-9:
return None
right = right / nr
up = np.cross(right, fwd)
nu = float(np.linalg.norm(up))
if nu < 1e-9:
return None
up = up / nu
fov_rad = float(np.deg2rad(float(self.opts.get("fov", 60.0))))
sx_scale = float(np.tan(0.5 * fov_rad))
sy_scale = sx_scale * (float(h) / float(w))
rel = arr - cam_p.reshape((1, 3))
zc = rel @ fwd
keep = zc > 1e-6
if not np.any(keep):
return None
rel = rel[keep]
zc = zc[keep]
pts_k = arr[keep]
xc = rel @ right
yc = rel @ up
x_ndc = xc / (zc * sx_scale)
y_ndc = yc / (zc * sy_scale)
sxv = ((x_ndc + 1.0) * 0.5) * float(w)
syv = ((1.0 - y_ndc) * 0.5) * float(h)
d2 = (sxv - float(sx)) ** 2 + (syv - float(sy)) ** 2
idx = int(np.argmin(d2))
if float(d2[idx]) > float(self.pick_screen_max_px) * float(self.pick_screen_max_px):
return None
p = pts_k[idx]
if np.isfinite(p).all():
return p.astype(np.float64)
return None
except Exception:
return None
def mousePressEvent(self, ev):
lpos = ev.position() if hasattr(ev, "position") else ev.localPos()
self.mousePos = lpos
if self.pick_mode and ev.button() == Qt.MouseButton.LeftButton:
pt = self._screen_pick_from_points(float(lpos.x()), float(lpos.y()))
if pt is None:
pt = self._screen_to_world_on_plane(float(lpos.x()), float(lpos.y()), float(self.pick_plane_z))
if pt is not None:
self._pick_dragging = False
self.mapPickDrag.emit(float(pt[0]), float(pt[1]), float(pt[2]), True)
self.mapClicked.emit(float(pt[0]), float(pt[1]), float(pt[2]))
ev.accept()
return
super().mousePressEvent(ev)
def mouseMoveEvent(self, ev):
lpos = ev.position() if hasattr(ev, "position") else ev.localPos()
if not hasattr(self, "mousePos"):
self.mousePos = lpos
diff = lpos - self.mousePos
self.mousePos = lpos
buttons = ev.buttons()
mods = ev.modifiers()
if self.pick_mode and self._pick_dragging and (buttons & Qt.MouseButton.LeftButton):
pt = self._screen_to_world_on_plane(float(lpos.x()), float(lpos.y()), float(self.pick_plane_z))
if pt is not None:
self.mapPickDrag.emit(float(pt[0]), float(pt[1]), float(pt[2]), False)
ev.accept()
return
# Keep default left-drag behavior.
if buttons & Qt.MouseButton.LeftButton:
if mods & Qt.KeyboardModifier.ControlModifier:
self.pan(diff.x(), diff.y(), 0, relative="view")
else:
self.orbit(-diff.x(), diff.y())
return
# Add right-drag panning so touchpads/mice without middle-click can move map.
if buttons & Qt.MouseButton.RightButton:
if mods & Qt.KeyboardModifier.ControlModifier:
self.pan(diff.x(), 0, diff.y(), relative="view-upright")
else:
self.pan(diff.x(), diff.y(), 0, relative="view-upright")
return
# Keep default middle-drag panning behavior.
if buttons & Qt.MouseButton.MiddleButton:
if mods & Qt.KeyboardModifier.ControlModifier:
self.pan(diff.x(), 0, diff.y(), relative="view-upright")
else:
self.pan(diff.x(), diff.y(), 0, relative="view-upright")
def mouseReleaseEvent(self, ev):
lpos = ev.position() if hasattr(ev, "position") else ev.localPos()
if self.pick_mode and ev.button() == Qt.MouseButton.LeftButton and self._pick_dragging:
self._pick_dragging = False
pt = self._screen_to_world_on_plane(float(lpos.x()), float(lpos.y()), float(self.pick_plane_z))
if pt is not None:
self.mapPickDrag.emit(float(pt[0]), float(pt[1]), float(pt[2]), True)
self.mapClicked.emit(float(pt[0]), float(pt[1]), float(pt[2]))
ev.accept()
return
super().mouseReleaseEvent(ev)
class RefLoaderThread(QThread):
loaded = pyqtSignal(object, object, str) # pts, colors, filename
failed = pyqtSignal(str)
def __init__(self, filename: str, display_voxel: float, color_mode: str = "GRAY"):
super().__init__()
self.filename = filename
self.display_voxel = float(display_voxel)
self.color_mode = str(color_mode).upper().strip()
@staticmethod
def _height_colors_fast(pts: np.ndarray, alpha: float = 0.40) -> np.ndarray:
if pts is None or len(pts) == 0:
return np.zeros((0, 4), dtype=np.float32)
z = pts[:, 2]
zmin = float(np.min(z))
zmax = float(np.max(z))
span = max(1e-6, zmax - zmin)
norm = np.clip((z - zmin) / span, 0.0, 1.0).astype(np.float32)
colors = np.zeros((len(pts), 4), dtype=np.float32)
colors[:, 0] = norm
colors[:, 1] = 1.0 - np.abs(norm - 0.5) * 2.0
colors[:, 2] = 1.0 - norm
colors[:, 3] = alpha
return colors
def run(self):
try:
import open3d as o3d
pcd = o3d.io.read_point_cloud(self.filename)
if self.display_voxel > 0:
pcd = pcd.voxel_down_sample(self.display_voxel)
pts = np.asarray(pcd.points).astype(np.float32)
if len(pts) < 2:
raise RuntimeError("Ref map is empty.")
mode = self.color_mode if self.color_mode in ("GRAY", "HEIGHT", "ORIGINAL") else "GRAY"
colors = np.zeros((len(pts), 4), dtype=np.float32)
colors[:, 3] = 0.40
if mode == "HEIGHT":
colors = self._height_colors_fast(pts, alpha=0.40)
elif mode == "ORIGINAL":
src = np.asarray(pcd.colors)
if src is not None and len(src) == len(pts) and len(src) > 0:
src_rgb = np.asarray(src, dtype=np.float32)
colors[:, :3] = np.clip(src_rgb[:, :3], 0.0, 1.0)
else:
colors[:, :3] = 0.7
else:
colors[:, :3] = 0.7
self.loaded.emit(pts, colors, self.filename)
except Exception as e:
self.failed.emit(str(e))
class UltimateDashboard(QMainWindow):
def __init__(self):
super().__init__()
self.cfg = load_slam_config()
title = self.cfg["app"]["title"]
accent = self.cfg["gui"]["colors"]["accent"]
maps_dir_cfg = str(self.cfg["app"]["maps_dir"])
maps_dir_path = Path(maps_dir_cfg).expanduser()
if not maps_dir_path.is_absolute():
cfg_path = os.environ.get("SLAM_CONFIG", "").strip()
base_dir = Path(cfg_path).expanduser().resolve().parent if cfg_path else Path(__file__).resolve().parent
maps_dir_path = (base_dir / maps_dir_path).resolve()
maps_dir = str(maps_dir_path)
self.setWindowTitle(title)
self.resize(1600, 900)
Path(maps_dir).mkdir(parents=True, exist_ok=True)
self._self_check_popup_shown = False
self.client = SlamEngineClient()
self.client.start_process()
self.show_self_check_popup(self.client.get_self_check(), source="engine")
self.save_armed = False
self.ref_map_path = None
self._ref_thread = None
self.ref_color_mode = "GRAY"
self.density_mode = "MEDIUM"
self.min_stable_points = int(self.cfg["map"]["min_points_to_save"])
self.loop_closure_enabled = bool(self.cfg.get("loop_closure", {}).get("enabled", False))
self.loc_machine_enabled = bool(self.cfg.get("state_machine", {}).get("enabled", True))
self.submap_mode_enabled = bool(self.cfg.get("submap_mapping", {}).get("enabled", True))
self.autosave_enabled = bool(self.cfg.get("autosave", {}).get("enabled", False))
self.autosave_interval_sec = float(self.cfg.get("autosave", {}).get("interval_sec", 90.0))
self.nav_cfg = dict(self.cfg.get("navigation_export", {}))
self.map_quality_cfg = dict(self.cfg.get("map_quality", {}))
self._last_error_popup_text = ""
self._last_error_popup_t = 0.0
self._error_popup_cooldown_sec = 2.0
self._worker_dead_notified = False
self._last_saved_popup_text = ""
self._last_saved_popup_t = 0.0
self._saved_popup_cooldown_sec = 1.0
self.current_stable_points = 0
self.worker_mode = "IDLE"
self.is_connected = False
self.recording_enabled = False
self.recording_frames = 0
self.recording_dropped = 0
self.workflow_profile = "BALANCED"
self.workflow_selected = False
self.last_replay_report = None
self.replay_baseline_path = str(Path(maps_dir) / "SLAM_replay_baseline.json")
self.approx_pick_armed = False
self.approx_guess_z = float(self.cfg.get("localization", {}).get("approx_guess_default_z_m", 0.0))
self.last_loc_confidence = 0.0
self.last_loc_match_ratio = 0.0
self.require_start_pick = True
self.start_pick_ready = False
self.start_pick_xyz = None
self._pick_help_shown_for_ref = None
self.ref_pick_points = None
self.ref_pick_snap_radius_m = float(
self.cfg.get("localization", {}).get("pick_snap_radius_m", 1.2)
)
self.ref_pick_max_points = int(
self.cfg.get("localization", {}).get("pick_snap_max_points", 60000)
)
root = QSplitter(Qt.Orientation.Horizontal)
self.setCentralWidget(root)
# Sidebar
sidebar = QFrame()
sidebar.setMinimumWidth(320)
sidebar.setMaximumWidth(760)
sidebar.setStyleSheet("background-color: rgba(0,0,0,0.35);")
side_layout = QVBoxLayout(sidebar)
side_layout.setContentsMargins(18, 18, 18, 18)
side_layout.setSpacing(14)
side_scroll = QScrollArea()
side_scroll.setWidgetResizable(True)
side_scroll.setFrameShape(QFrame.Shape.NoFrame)
side_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
side_scroll.setWidget(sidebar)
side_scroll.setMinimumWidth(330)
side_scroll.setMaximumWidth(780)
lbl = QLabel("SLAM COMMANDER")
lbl.setStyleSheet(f"color:{accent}; font-size:20px; font-weight:900;")
side_layout.addWidget(lbl)
# Network card
net_card = QFrame(); net_card.setObjectName("Card"); net_card.setStyleSheet(card_style()); apply_card_shadow(net_card)
net_layout = QVBoxLayout(net_card); net_layout.setContentsMargins(14,14,14,14); net_layout.setSpacing(10)
net_title = QLabel("NETWORK SETTINGS"); net_title.setObjectName("CardTitle"); net_layout.addWidget(net_title)
self.txt_iface = QLineEdit(self.cfg["network"]["default_interface"])
self.txt_ip = QLineEdit(self.cfg["network"]["default_host_ip"])
net_layout.addWidget(self.txt_iface); net_layout.addWidget(self.txt_ip)
row1 = QHBoxLayout()
self.btn_connect = QPushButton("🔌 CONNECT"); self.btn_connect.setStyleSheet(pill_button("#1677ff")); self.btn_connect.clicked.connect(self.on_connect)
self.btn_connect.setEnabled(False)
self.btn_connect.setToolTip("Choose a workflow first.")
self.btn_start = QPushButton("▶ START"); self.btn_start.setStyleSheet(pill_button("#21a453")); self.btn_start.clicked.connect(self.on_start)
self.btn_localize_only = QPushButton("🎯 LOCALIZE-ONLY")
self.btn_localize_only.setStyleSheet(pill_button("#7a4cff"))
self.btn_localize_only.clicked.connect(self.on_start_localize_only)
row1.addWidget(self.btn_connect); row1.addWidget(self.btn_start); row1.addWidget(self.btn_localize_only); net_layout.addLayout(row1)
row2 = QHBoxLayout()
self.btn_pause = QPushButton("⏸ PAUSE"); self.btn_pause.setStyleSheet(pill_button("#c78a12", fg="black")); self.btn_pause.clicked.connect(self.on_pause)
self.btn_stop = QPushButton("⛔ STOP"); self.btn_stop.setStyleSheet(pill_button("#d0302f")); self.btn_stop.clicked.connect(self.on_stop)
row2.addWidget(self.btn_pause); row2.addWidget(self.btn_stop); net_layout.addLayout(row2)
self.btn_reset = QPushButton("🧹 RESET (NO SAVE)"); self.btn_reset.setStyleSheet(pill_button("#3a3a3a")); self.btn_reset.clicked.connect(self.on_reset)
net_layout.addWidget(self.btn_reset)
side_layout.addWidget(net_card)
# Workflow card
wf_card = QFrame(); wf_card.setObjectName("Card"); wf_card.setStyleSheet(card_style()); apply_card_shadow(wf_card)
wf_layout = QVBoxLayout(wf_card); wf_layout.setContentsMargins(14,14,14,14); wf_layout.setSpacing(10)
wf_title = QLabel("WORKFLOW (BEFORE RUN)"); wf_title.setObjectName("CardTitle"); wf_layout.addWidget(wf_title)
self.btn_wf_map_new = QPushButton("🧭 MAP NEW PLACE")
self.btn_wf_map_new.setStyleSheet(pill_button("#2b2b2b"))
self.btn_wf_map_new.clicked.connect(self.on_workflow_map_new)
wf_layout.addWidget(self.btn_wf_map_new)
self.btn_wf_extend = QPushButton("📌 EXTEND SAVED MAP")
self.btn_wf_extend.setStyleSheet(pill_button("#2b2b2b"))
self.btn_wf_extend.clicked.connect(self.on_workflow_extend_map)
wf_layout.addWidget(self.btn_wf_extend)
self.btn_wf_localize = QPushButton("📍 NAVIGATE IN MAPPED PLACE")
self.btn_wf_localize.setStyleSheet(pill_button("#2b2b2b"))
self.btn_wf_localize.clicked.connect(self.on_workflow_localize_existing)
wf_layout.addWidget(self.btn_wf_localize)
self.btn_wf_live_nav = QPushButton("🤖 LIVE NAV WITH MAP")
self.btn_wf_live_nav.setStyleSheet(pill_button("#2b2b2b"))
self.btn_wf_live_nav.clicked.connect(self.on_workflow_live_nav)
wf_layout.addWidget(self.btn_wf_live_nav)
self.btn_wf_live_nav_nomap = QPushButton("🛰 LIVE NAV (NO MAP)")
self.btn_wf_live_nav_nomap.setStyleSheet(pill_button("#2b2b2b"))
self.btn_wf_live_nav_nomap.clicked.connect(self.on_workflow_live_nav_nomap)
wf_layout.addWidget(self.btn_wf_live_nav_nomap)
self.btn_wf_quick = QPushButton("🧪 QUICK DEMO")
self.btn_wf_quick.setStyleSheet(pill_button("#2b2b2b"))
self.btn_wf_quick.clicked.connect(self.on_workflow_quick_demo)
wf_layout.addWidget(self.btn_wf_quick)
self.lbl_workflow = QLabel("Workflow: NOT_SELECTED")
self.lbl_workflow.setStyleSheet("color:white; font-family:monospace; font-weight:800;")
wf_layout.addWidget(self.lbl_workflow)
side_layout.addWidget(wf_card)
# Map card
map_card = QFrame(); map_card.setObjectName("Card"); map_card.setStyleSheet(card_style()); apply_card_shadow(map_card)
map_layout = QVBoxLayout(map_card); map_layout.setContentsMargins(14,14,14,14); map_layout.setSpacing(10)
map_title = QLabel("MAP CONTROLS"); map_title.setObjectName("CardTitle"); map_layout.addWidget(map_title)
self.txt_name = QLineEdit("Work")
map_layout.addWidget(self.txt_name)
self.chk_save = QCheckBox("✅ SAVE ARMED (WILL SAVE ON STOP)")
self.chk_save.stateChanged.connect(self.on_toggle_save)
map_layout.addWidget(self.chk_save)
self.btn_save_now = QPushButton("💾 SAVE NOW")
self.btn_save_now.setStyleSheet(pill_button("#2b2b2b"))
self.btn_save_now.clicked.connect(self.on_save_now)
map_layout.addWidget(self.btn_save_now)
auto_title = QLabel("AUTOSAVE")
auto_title.setObjectName("CardTitle")
map_layout.addWidget(auto_title)
auto_row = QHBoxLayout()
self.chk_autosave = QCheckBox("ON")
self.chk_autosave.setChecked(self.autosave_enabled)
self.txt_autosave_sec = QLineEdit(str(int(self.autosave_interval_sec)))
self.txt_autosave_sec.setMaximumWidth(90)
self.btn_apply_autosave = QPushButton("APPLY")
self.btn_apply_autosave.setStyleSheet(pill_button("#2b2b2b"))
self.btn_apply_autosave.clicked.connect(self.on_apply_autosave)
auto_row.addWidget(self.chk_autosave)
auto_row.addWidget(self.txt_autosave_sec)
auto_row.addWidget(self.btn_apply_autosave)
map_layout.addLayout(auto_row)
rec_title = QLabel("RECORD / REPLAY")
rec_title.setObjectName("CardTitle")
map_layout.addWidget(rec_title)
rec_row1 = QHBoxLayout()
self.btn_record_start = QPushButton("⏺ START REC")
self.btn_record_start.setStyleSheet(pill_button("#2b2b2b"))
self.btn_record_start.clicked.connect(self.on_record_start)
self.btn_record_stop = QPushButton("⏹ STOP REC")
self.btn_record_stop.setStyleSheet(pill_button("#2b2b2b"))
self.btn_record_stop.clicked.connect(self.on_record_stop)
rec_row1.addWidget(self.btn_record_start)
rec_row1.addWidget(self.btn_record_stop)
map_layout.addLayout(rec_row1)
rec_row2 = QHBoxLayout()
self.btn_replay_run = QPushButton("▶ RUN REPLAY")
self.btn_replay_run.setStyleSheet(pill_button("#2b2b2b"))
self.btn_replay_run.clicked.connect(self.on_replay_run)
self.btn_replay_set_base = QPushButton("📌 SET BASELINE")
self.btn_replay_set_base.setStyleSheet(pill_button("#2b2b2b"))
self.btn_replay_set_base.clicked.connect(self.on_replay_set_baseline)
rec_row2.addWidget(self.btn_replay_run)
rec_row2.addWidget(self.btn_replay_set_base)
map_layout.addLayout(rec_row2)
min_pts_title = QLabel("MIN STABLE POINTS")
min_pts_title.setObjectName("CardTitle")
map_layout.addWidget(min_pts_title)
min_pts_row = QHBoxLayout()
self.txt_min_stable = QLineEdit(str(self.min_stable_points))
self.txt_min_stable.returnPressed.connect(self.on_apply_min_stable_points)
self.btn_apply_min_stable = QPushButton("APPLY")
self.btn_apply_min_stable.setStyleSheet(pill_button("#2b2b2b"))
self.btn_apply_min_stable.clicked.connect(self.on_apply_min_stable_points)
min_pts_row.addWidget(self.txt_min_stable)
min_pts_row.addWidget(self.btn_apply_min_stable)
map_layout.addLayout(min_pts_row)
self.btn_export_nav = QPushButton("🧭 EXPORT NAV")
self.btn_export_nav.setStyleSheet(pill_button("#2b2b2b"))
self.btn_export_nav.clicked.connect(self.on_export_nav)
map_layout.addWidget(self.btn_export_nav)
nav_title = QLabel("NAV TUNING")
nav_title.setObjectName("CardTitle")
map_layout.addWidget(nav_title)
nav_row1 = QHBoxLayout()
self.txt_nav_zmin = QLineEdit(str(self.nav_cfg.get("z_min_m", -0.4)))
self.txt_nav_zmax = QLineEdit(str(self.nav_cfg.get("z_max_m", 1.2)))
self.txt_nav_zmin.setPlaceholderText("z min")
self.txt_nav_zmax.setPlaceholderText("z max")
nav_row1.addWidget(self.txt_nav_zmin)
nav_row1.addWidget(self.txt_nav_zmax)
map_layout.addLayout(nav_row1)
nav_row2 = QHBoxLayout()
self.txt_nav_res = QLineEdit(str(self.nav_cfg.get("resolution_m", 0.05)))
self.txt_nav_inf = QLineEdit(str(self.nav_cfg.get("inflation_radius_m", 0.2)))
self.txt_nav_res.setPlaceholderText("res")
self.txt_nav_inf.setPlaceholderText("inflate")
self.btn_apply_nav = QPushButton("APPLY NAV")
self.btn_apply_nav.setStyleSheet(pill_button("#2b2b2b"))
self.btn_apply_nav.clicked.connect(self.on_apply_nav_config)
nav_row2.addWidget(self.txt_nav_res)
nav_row2.addWidget(self.txt_nav_inf)
nav_row2.addWidget(self.btn_apply_nav)
map_layout.addLayout(nav_row2)
goal_row = QHBoxLayout()
self.txt_goal_x = QLineEdit("0.0")
self.txt_goal_y = QLineEdit("0.0")
self.txt_goal_x.setPlaceholderText("goal x")
self.txt_goal_y.setPlaceholderText("goal y")
self.btn_goal_apply = QPushButton("SET GOAL")
self.btn_goal_apply.setStyleSheet(pill_button("#2b2b2b"))
self.btn_goal_apply.clicked.connect(self.on_set_nav_goal)
self.btn_goal_clear = QPushButton("CLEAR")
self.btn_goal_clear.setStyleSheet(pill_button("#2b2b2b"))
self.btn_goal_clear.clicked.connect(self.on_clear_nav_goal)
goal_row.addWidget(self.txt_goal_x)
goal_row.addWidget(self.txt_goal_y)
goal_row.addWidget(self.btn_goal_apply)
goal_row.addWidget(self.btn_goal_clear)
map_layout.addLayout(goal_row)
mission_row1 = QHBoxLayout()
self.txt_mission = QLineEdit("1.0,0.0; 2.0,0.0")
self.txt_mission.setPlaceholderText("x,y; x,y; ...")
self.btn_mission_start = QPushButton("MISSION START")
self.btn_mission_start.setStyleSheet(pill_button("#2b2b2b"))
self.btn_mission_start.clicked.connect(self.on_mission_start)
mission_row1.addWidget(self.txt_mission)
mission_row1.addWidget(self.btn_mission_start)
map_layout.addLayout(mission_row1)
mission_row2 = QHBoxLayout()
self.btn_mission_pause = QPushButton("PAUSE M")
self.btn_mission_pause.setStyleSheet(pill_button("#2b2b2b"))
self.btn_mission_pause.clicked.connect(lambda: self._send_mission_cmd("pause"))
self.btn_mission_resume = QPushButton("RESUME M")
self.btn_mission_resume.setStyleSheet(pill_button("#2b2b2b"))
self.btn_mission_resume.clicked.connect(lambda: self._send_mission_cmd("resume"))
self.btn_mission_stop = QPushButton("STOP M")
self.btn_mission_stop.setStyleSheet(pill_button("#2b2b2b"))
self.btn_mission_stop.clicked.connect(lambda: self._send_mission_cmd("stop"))
mission_row2.addWidget(self.btn_mission_pause)
mission_row2.addWidget(self.btn_mission_resume)
mission_row2.addWidget(self.btn_mission_stop)
map_layout.addLayout(mission_row2)
den_title = QLabel("POINT DENSITY")
den_title.setObjectName("CardTitle")
map_layout.addWidget(den_title)
den_row = QHBoxLayout()
self.btn_den_low = QPushButton("LOW")
self.btn_den_med = QPushButton("MEDIUM")
self.btn_den_high = QPushButton("HIGH")
self.btn_den_low.clicked.connect(lambda: self.set_density_mode("LOW"))
self.btn_den_med.clicked.connect(lambda: self.set_density_mode("MEDIUM"))
self.btn_den_high.clicked.connect(lambda: self.set_density_mode("HIGH"))
den_row.addWidget(self.btn_den_low)
den_row.addWidget(self.btn_den_med)
den_row.addWidget(self.btn_den_high)
map_layout.addLayout(den_row)
self.refresh_density_buttons()
self.chk_loop_closure = QCheckBox("🔁 LOOP CLOSURE SAFE")
self.chk_loop_closure.setChecked(self.loop_closure_enabled)
self.chk_loop_closure.stateChanged.connect(self.on_toggle_loop_closure)
map_layout.addWidget(self.chk_loop_closure)
self.chk_loc_machine = QCheckBox("🧠 LOC STATE MACHINE")
self.chk_loc_machine.setChecked(self.loc_machine_enabled)
self.chk_loc_machine.stateChanged.connect(self.on_toggle_loc_machine)
map_layout.addWidget(self.chk_loc_machine)
self.chk_submap_mode = QCheckBox("🗺️ SUBMAP (LOCAL+GLOBAL)")
self.chk_submap_mode.setChecked(self.submap_mode_enabled)
self.chk_submap_mode.stateChanged.connect(self.on_toggle_submap_mode)
map_layout.addWidget(self.chk_submap_mode)
quality_title = QLabel("MAP QUALITY")
quality_title.setObjectName("CardTitle")
map_layout.addWidget(quality_title)
q_row1 = QHBoxLayout()
self.txt_near_range = QLineEdit(str(self.map_quality_cfg.get("near_min_range_m", 0.15)))
self.chk_outlier = QCheckBox("Outlier")
self.chk_outlier.setChecked(bool(self.map_quality_cfg.get("outlier_filter_enabled", False)))
q_row1.addWidget(self.txt_near_range)
q_row1.addWidget(self.chk_outlier)
map_layout.addLayout(q_row1)
q_row2 = QHBoxLayout()
self.chk_clip_z = QCheckBox("Clip Z")
self.chk_clip_z.setChecked(bool(self.map_quality_cfg.get("world_z_clip_enabled", False)))
self.txt_world_zmin = QLineEdit(str(self.map_quality_cfg.get("world_z_min_m", -2.0)))
self.txt_world_zmax = QLineEdit(str(self.map_quality_cfg.get("world_z_max_m", 3.0)))
q_row2.addWidget(self.chk_clip_z)
q_row2.addWidget(self.txt_world_zmin)
q_row2.addWidget(self.txt_world_zmax)
map_layout.addLayout(q_row2)
q_row3 = QHBoxLayout()
self.txt_outlier_voxel = QLineEdit(str(self.map_quality_cfg.get("outlier_voxel_m", 0.12)))
self.txt_outlier_min = QLineEdit(str(self.map_quality_cfg.get("outlier_min_points", 2)))
self.btn_apply_quality = QPushButton("APPLY Q")
self.btn_apply_quality.setStyleSheet(pill_button("#2b2b2b"))
self.btn_apply_quality.clicked.connect(self.on_apply_map_quality)
q_row3.addWidget(self.txt_outlier_voxel)
q_row3.addWidget(self.txt_outlier_min)
q_row3.addWidget(self.btn_apply_quality)
map_layout.addLayout(q_row3)
side_layout.addWidget(map_card)
# Localization card
loc_card = QFrame(); loc_card.setObjectName("Card"); loc_card.setStyleSheet(card_style()); apply_card_shadow(loc_card)
loc_layout = QVBoxLayout(loc_card); loc_layout.setContentsMargins(14,14,14,14); loc_layout.setSpacing(10)
loc_title = QLabel("LOCALIZATION"); loc_title.setObjectName("CardTitle"); loc_layout.addWidget(loc_title)
rowl = QHBoxLayout()
self.btn_load_ref = QPushButton("🗺️ LOAD REF MAP"); self.btn_load_ref.setStyleSheet(pill_button("#2b2b2b")); self.btn_load_ref.clicked.connect(self.on_load_ref)
self.btn_localize = QPushButton("📍 LOCALIZE NOW"); self.btn_localize.setStyleSheet(pill_button("#2b2b2b")); self.btn_localize.clicked.connect(self.on_localize)
rowl.addWidget(self.btn_load_ref); rowl.addWidget(self.btn_localize)
loc_layout.addLayout(rowl)
self.btn_clear_ref = QPushButton("🧽 CLEAR REF"); self.btn_clear_ref.setStyleSheet(pill_button("#2b2b2b")); self.btn_clear_ref.clicked.connect(self.on_clear_ref)
loc_layout.addWidget(self.btn_clear_ref)
ref_color_title = QLabel("REF MAP COLOR")
ref_color_title.setObjectName("CardTitle")
loc_layout.addWidget(ref_color_title)
ref_color_row = QHBoxLayout()
self.btn_ref_gray = QPushButton("GRAY")
self.btn_ref_height = QPushButton("HEIGHT")
self.btn_ref_orig = QPushButton("ORIGINAL")
self.btn_ref_gray.clicked.connect(lambda: self.set_ref_color_mode("GRAY"))
self.btn_ref_height.clicked.connect(lambda: self.set_ref_color_mode("HEIGHT"))
self.btn_ref_orig.clicked.connect(lambda: self.set_ref_color_mode("ORIGINAL"))
ref_color_row.addWidget(self.btn_ref_gray)
ref_color_row.addWidget(self.btn_ref_height)
ref_color_row.addWidget(self.btn_ref_orig)
loc_layout.addLayout(ref_color_row)
self.refresh_ref_color_buttons()
approx_title = QLabel("START POINTER")
approx_title.setObjectName("CardTitle")
loc_layout.addWidget(approx_title)
approx_row1 = QHBoxLayout()
self.txt_approx_z = QLineEdit(f"{self.approx_guess_z:.2f}")
self.txt_approx_z.setPlaceholderText("z")
self.txt_approx_z.setVisible(False)
self.btn_pick_approx = QPushButton("🧭 CLICK START POINTER")
self.btn_pick_approx.setStyleSheet(pill_button("#2b2b2b"))
self.btn_pick_approx.clicked.connect(self.on_set_approx_pick)
approx_row1.addWidget(self.btn_pick_approx)
loc_layout.addLayout(approx_row1)
self.btn_clear_approx = QPushButton("✖ CLEAR APPROX")
self.btn_clear_approx.setStyleSheet(pill_button("#2b2b2b"))
self.btn_clear_approx.clicked.connect(self.on_clear_approx)
loc_layout.addWidget(self.btn_clear_approx)
self.chk_require_start_pick = QCheckBox("Require start-point pick before START")
self.chk_require_start_pick.setChecked(True)
self.chk_require_start_pick.stateChanged.connect(self.on_toggle_require_start_pick)
loc_layout.addWidget(self.chk_require_start_pick)
self.lbl_pick_status = QLabel("Start pick: pending")
self.lbl_pick_status.setStyleSheet("color:#ffcc00; font-family:monospace; font-weight:800;")
loc_layout.addWidget(self.lbl_pick_status)
side_layout.addWidget(loc_card)
perf_card = QFrame(); perf_card.setObjectName("Card"); perf_card.setStyleSheet(card_style()); apply_card_shadow(perf_card)
perf_layout = QVBoxLayout(perf_card); perf_layout.setContentsMargins(14,14,14,14); perf_layout.setSpacing(6)
perf_title = QLabel("PERFORMANCE"); perf_title.setObjectName("CardTitle"); perf_layout.addWidget(perf_title)
self.lbl_perf_fps = QLabel("FPS in/out: 0.0 / 0.0")
self.lbl_perf_fps.setStyleSheet("color:white; font-family:monospace; font-weight:700;")
self.lbl_perf_icp = QLabel("ICP: 0.0 ms CPU: 0.0%")
self.lbl_perf_icp.setStyleSheet("color:white; font-family:monospace; font-weight:700;")
self.lbl_perf_queue = QLabel("Queue lag: 0 Growth: 0.0 pts/s")
self.lbl_perf_queue.setStyleSheet("color:white; font-family:monospace; font-weight:700;")
self.lbl_mode = QLabel("Mode: IDLE Rec: OFF")
self.lbl_mode.setStyleSheet("color:white; font-family:monospace; font-weight:700;")
self.lbl_nav = QLabel("Nav: goal none cmd 0.00/0.00")
self.lbl_nav.setStyleSheet("color:white; font-family:monospace; font-weight:700;")
self.lbl_loc_match = QLabel("Match: n/a")
self.lbl_loc_match.setStyleSheet("color:white; font-family:monospace; font-weight:700;")
perf_layout.addWidget(self.lbl_perf_fps)
perf_layout.addWidget(self.lbl_perf_icp)
perf_layout.addWidget(self.lbl_perf_queue)
perf_layout.addWidget(self.lbl_mode)
perf_layout.addWidget(self.lbl_nav)
perf_layout.addWidget(self.lbl_loc_match)
side_layout.addWidget(perf_card)
side_layout.addStretch(1)
# Status labels
self.lbl_status = QLabel("Status: IDLE")
self.lbl_status.setStyleSheet(f"color:{accent}; font-family:monospace; font-weight:900;")
self.lbl_points = QLabel("Stable points: 0")
self.lbl_points.setStyleSheet("color:white; font-family:monospace; font-weight:800;")
self.lbl_pose = QLabel("Pos: 0.00, 0.00, 0.00")
self.lbl_pose.setStyleSheet("color:white; font-family:monospace; font-weight:800;")
self.lbl_loc = QLabel("Localization: NO_REF")
self.lbl_loc.setStyleSheet("color:white; font-family:monospace; font-weight:800;")
self.lbl_loc_state = QLabel("Loc health: TRACKING")
self.lbl_loc_state.setStyleSheet("color:white; font-family:monospace; font-weight:800;")
self.lbl_density = QLabel(f"Density: {self.density_mode}")
self.lbl_density.setStyleSheet("color:white; font-family:monospace; font-weight:800;")
self.lbl_min_stable = QLabel(f"Min stable: {self.min_stable_points}")
self.lbl_min_stable.setStyleSheet("color:white; font-family:monospace; font-weight:800;")
side_layout.addWidget(self.lbl_status)
side_layout.addWidget(self.lbl_points)
side_layout.addWidget(self.lbl_pose)
side_layout.addWidget(self.lbl_loc)
side_layout.addWidget(self.lbl_loc_state)
side_layout.addWidget(self.lbl_density)
side_layout.addWidget(self.lbl_min_stable)
root.addWidget(side_scroll)
# 3D view
self.view = SLAMGLViewWidget()
self.view.opts["distance"] = float(self.cfg["gui"]["view"]["distance"])
self.view.setBackgroundColor("#0a0c10")
root.addWidget(self.view)
root.setChildrenCollapsible(False)
root.setHandleWidth(10)
root.setStretchFactor(0, 0)
root.setStretchFactor(1, 1)
root.setSizes([380, 1220])
grid = gl.GLGridItem()
grid.setSize(x=float(self.cfg["gui"]["view"]["grid_size"]), y=float(self.cfg["gui"]["view"]["grid_size"]), z=0)
self.view.addItem(grid)
self.axis = gl.GLAxisItem()
self.axis.setSize(2,2,2)
self.view.addItem(self.axis)
self.scatter = gl.GLScatterPlotItem(pos=np.zeros((1,3),dtype=np.float32), size=2)
self.scatter.setGLOptions("opaque")
self.view.addItem(self.scatter)
# Reference map overlay (shown after user loads a map)
self.ref_scatter = gl.GLScatterPlotItem(pos=np.zeros((1,3),dtype=np.float32), size=1.2)
self.ref_scatter.setGLOptions("translucent")
self.view.addItem(self.ref_scatter)
self.ref_match_scatter = gl.GLScatterPlotItem(pos=np.zeros((1,3),dtype=np.float32), size=1.4)
self.ref_match_scatter.setGLOptions("translucent")
self.view.addItem(self.ref_match_scatter)
self.start_ptr_scatter = gl.GLScatterPlotItem(pos=np.zeros((1,3),dtype=np.float32), size=1.8)
self.start_ptr_scatter.setGLOptions("translucent")
self.view.addItem(self.start_ptr_scatter)
self.view.mapPickDrag.connect(self.on_view_map_pick_drag)
self.timer = QTimer()
self.timer.timeout.connect(self.update_loop)
self.timer.start(50)
self.max_draw = int(self.cfg["gui"]["render"]["max_draw_points"])
self.max_ref_draw = max(50000, self.max_draw // 2)
self.decimate_step = int(self.cfg["gui"]["render"]["decimate_step"])
self.maps_dir = maps_dir
self.ref_display_voxel = float(self.cfg["localization"]["ref_display_voxel"])
# Workflow UI control groups: non-relevant controls are disabled/greyed.
self._workflow_widget_groups = {
"runtime": [
self.txt_min_stable, self.btn_apply_min_stable,
self.btn_den_low, self.btn_den_med, self.btn_den_high,
],
"mapping": [
self.txt_name, self.chk_save, self.btn_save_now,
self.chk_autosave, self.txt_autosave_sec, self.btn_apply_autosave,
self.chk_loop_closure,
self.txt_near_range, self.chk_outlier, self.chk_clip_z,
self.txt_world_zmin, self.txt_world_zmax,
self.txt_outlier_voxel, self.txt_outlier_min, self.btn_apply_quality,
],
"recording": [
self.btn_record_start, self.btn_record_stop,
self.btn_replay_run, self.btn_replay_set_base,
],
"localization": [
self.btn_localize_only, self.btn_load_ref, self.btn_localize, self.btn_clear_ref,
self.chk_loc_machine,
self.btn_ref_gray, self.btn_ref_height, self.btn_ref_orig,
self.txt_approx_z, self.btn_pick_approx, self.btn_clear_approx,
self.chk_require_start_pick, self.lbl_pick_status,
],
"submap_nav": [
self.chk_submap_mode,
],
"nav_export": [
self.btn_export_nav, self.txt_nav_zmin, self.txt_nav_zmax,
self.txt_nav_res, self.txt_nav_inf, self.btn_apply_nav,
],
"nav_live": [
self.txt_goal_x, self.txt_goal_y, self.btn_goal_apply, self.btn_goal_clear,
self.txt_mission, self.btn_mission_start,
self.btn_mission_pause, self.btn_mission_resume, self.btn_mission_stop,
],
}
self._workflow_managed_widgets = []
seen = set()
for group_widgets in self._workflow_widget_groups.values():
for widget in group_widgets:
k = id(widget)
if k in seen:
continue
seen.add(k)
self._workflow_managed_widgets.append(widget)
self._apply_workflow_ui_rules(self.workflow_profile, update_label=True)
self._refresh_pick_status()
self._hide_start_pointer_visual()
def set_status(self, text: str, color: str):
self.lbl_status.setText(text)
self.lbl_status.setStyleSheet(f"color:{color}; font-family:monospace; font-weight:900;")
def show_error_popup(self, message: str):
msg = str(message).strip() or "Unknown error"
now = time.monotonic()
if msg == self._last_error_popup_text and (now - self._last_error_popup_t) < self._error_popup_cooldown_sec:
return
self._last_error_popup_text = msg
self._last_error_popup_t = now
QMessageBox.critical(self, "SLAM Error", msg)
def show_saved_popup(self, message: str):
msg = str(message).strip()
if not msg:
return
now = time.monotonic()
if msg == self._last_saved_popup_text and (now - self._last_saved_popup_t) < self._saved_popup_cooldown_sec:
return
self._last_saved_popup_text = msg
self._last_saved_popup_t = now
QMessageBox.information(self, "Map Saved", msg)
def show_self_check_popup(self, report: dict | None, source: str = "startup"):
if self._self_check_popup_shown and source != "worker":
return
rep = report or {}
errs = list(rep.get("errors", []) or [])
warns = list(rep.get("warnings", []) or [])
info = list(rep.get("info", []) or [])
if not errs and not warns:
if source == "engine":
self._self_check_popup_shown = True
return
msg_lines = []
if errs:
msg_lines.append("Errors:")
msg_lines.extend(f"- {x}" for x in errs)
if warns:
msg_lines.append("Warnings:")
msg_lines.extend(f"- {x}" for x in warns)
if info:
msg_lines.append("Info:")
msg_lines.extend(f"- {x}" for x in info[:3])
text = "\n".join(msg_lines)
if errs:
QMessageBox.critical(self, "SLAM Startup Check", text)
else:
QMessageBox.warning(self, "SLAM Startup Check", text)
if source == "engine":
self._self_check_popup_shown = True
@staticmethod
def _read_float(line: QLineEdit, default: float) -> float:
txt = line.text().strip()
try:
return float(txt)
except Exception:
line.setText(str(default))
return float(default)
def request_map_export_if_armed(self) -> bool:
if not self.save_armed:
return False
base = self.txt_name.text().strip() or "map_robot"
self.client.export_map(base)
return True
def _read_min_stable_input(self, show_error: bool = False):
txt = self.txt_min_stable.text().strip()
try:
value = int(txt)
except Exception:
if show_error:
self.show_error_popup("Minimum stable points must be an integer.")
return None
return int(np.clip(value, 50, 5_000_000))
def _apply_min_stable_value(self, value: int, set_status_msg: bool = True):
self.min_stable_points = int(value)
self.txt_min_stable.setText(str(self.min_stable_points))
self.lbl_min_stable.setText(f"Min stable: {self.min_stable_points}")
try:
self.client.set_min_stable_points(self.min_stable_points)
except Exception:
pass
if set_status_msg:
self.set_status(f"Min stable set: {self.min_stable_points}", "#ffcc00")
def ensure_min_stable_synced(self, show_error: bool = False, set_status_msg: bool = False) -> bool:
value = self._read_min_stable_input(show_error=show_error)
if value is None:
return False
if int(value) != int(self.min_stable_points):
self._apply_min_stable_value(int(value), set_status_msg=set_status_msg)
return True
def _set_widgets_enabled(self, widgets, enabled: bool):
for w in widgets:
if w is None:
continue
w.setEnabled(bool(enabled))
def _apply_workflow_ui_rules(self, profile: str | None = None, update_label: bool = False):
p = str(profile if profile is not None else self.workflow_profile).upper().strip()
if p not in ("MAP_NEW", "EXTEND_MAP", "LOCALIZE_MAP", "LIVE_NAV_MAP", "LIVE_NAV_NO_MAP", "QUICK_DEMO", "BALANCED"):
p = "BALANCED"
self.workflow_profile = p
if update_label:
if self.workflow_selected:
self.lbl_workflow.setText(f"Workflow: {self.workflow_profile}")
else:
self.lbl_workflow.setText("Workflow: NOT_SELECTED")
# Start from fully enabled, then gray-out non-relevant controls per workflow.
self._set_widgets_enabled(self._workflow_managed_widgets, True)
# Submap LOCAL+GLOBAL is intentionally limited to map-based localization/nav workflows.
self._set_widgets_enabled(
self._workflow_widget_groups["submap_nav"],
p in ("LOCALIZE_MAP", "LIVE_NAV_MAP"),
)
if p == "MAP_NEW":
self._set_widgets_enabled(self._workflow_widget_groups["localization"], False)
self._set_widgets_enabled(self._workflow_widget_groups["nav_live"], False)
elif p == "EXTEND_MAP":
# Extending an existing map needs localization (to anchor incoming
# scans to the loaded frame) AND mapping (to add the new points).
self._set_widgets_enabled(self._workflow_widget_groups["nav_live"], False)
elif p == "LOCALIZE_MAP":
self._set_widgets_enabled(self._workflow_widget_groups["mapping"], False)
self._set_widgets_enabled(self._workflow_widget_groups["recording"], False)
self._set_widgets_enabled(self._workflow_widget_groups["nav_export"], False)
self._set_widgets_enabled(self._workflow_widget_groups["nav_live"], False)
elif p == "LIVE_NAV_MAP":
self._set_widgets_enabled(self._workflow_widget_groups["mapping"], False)
self._set_widgets_enabled(self._workflow_widget_groups["recording"], False)
elif p == "LIVE_NAV_NO_MAP":
self._set_widgets_enabled(self._workflow_widget_groups["localization"], False)
elif p == "QUICK_DEMO":
# Quick demo keeps most options active, but submap remains map-workflow only.
pass
else:
# BALANCED keeps broad controls active; submap remains map-workflow only.
pass
if p not in ("LOCALIZE_MAP", "LIVE_NAV_MAP") and self.approx_pick_armed:
self._set_approx_pick_mode(False)
self._refresh_pick_status()
def apply_runtime_settings(self):
try:
self.client.set_density(self.density_mode)
except Exception:
pass
if not self.ensure_min_stable_synced(show_error=False, set_status_msg=False):
try:
self.client.set_min_stable_points(self.min_stable_points)
except Exception:
pass
try:
self.client.set_loop_closure(bool(self.loop_closure_enabled))
except Exception:
pass
try:
self.client.set_loc_state_machine(bool(self.loc_machine_enabled))
except Exception:
pass
try:
self.client.set_submap_mode(
bool(self.submap_mode_enabled),
{"apply_profiles": ["LOCALIZE_MAP", "LIVE_NAV_MAP"]},
)
except Exception:
pass
try:
self.client.set_stability_profile(str(self.workflow_profile))
except Exception:
pass
self.on_apply_autosave(set_status_msg=False, quiet=True)
self.on_apply_nav_config(set_status_msg=False, quiet=True)
self.on_apply_map_quality(set_status_msg=False, quiet=True)
def _apply_workflow_profile(self, profile: str, set_status_msg: bool = True):
p = str(profile).upper().strip()
self.workflow_selected = True
self._apply_workflow_ui_rules(p, update_label=True)
self.btn_connect.setEnabled(True)
self.btn_connect.setToolTip("")
try:
self.client.set_stability_profile(self.workflow_profile)
except Exception:
pass
if set_status_msg:
self.set_status(f"Workflow selected: {self.workflow_profile}", "#ffcc00")
def _pick_and_load_ref_map(self) -> bool:
fname, _ = QFileDialog.getOpenFileName(self, "Load Reference Map", self.maps_dir, "Point Clouds (*.ply *.pcd)")
if not fname:
return False
self.ref_map_path = fname
self.start_pick_ready = False
self.start_pick_xyz = None
self._pick_help_shown_for_ref = None
self._refresh_pick_status()
self.client.load_ref_map(fname)
self.lbl_loc.setText(f"Localization: ref={Path(fname).name}")
self._start_ref_loader(fname, show_status=False)
return True
def _workflow_requires_ref_map(self) -> bool:
return str(self.workflow_profile).upper().strip() in ("LOCALIZE_MAP", "LIVE_NAV_MAP")
def on_workflow_map_new(self):
self._apply_workflow_profile("MAP_NEW", set_status_msg=False)
self._set_approx_pick_mode(False)
self.start_pick_ready = False
self.start_pick_xyz = None
self._refresh_pick_status()
self.set_density_mode("MEDIUM")
# Keep loop closure off by default for stability; user can enable manually later.
self.chk_loop_closure.setChecked(False)
self.chk_submap_mode.setChecked(False)
# Mapping-new should run in pure SLAM frame (no stale reference alignment).
if self.ref_map_path:
self.on_clear_ref()
self.set_status("Workflow MAP_NEW ready: CONNECT -> START mapping (loop closure OFF by default).", "#ffcc00")
def on_workflow_extend_map(self):
"""Extend an existing saved map: load .ply, seed stable map, continue mapping."""
fname, _ = QFileDialog.getOpenFileName(
self, "Extend Saved Map", self.maps_dir, "Point Clouds (*.ply *.pcd)"
)
if not fname:
return
self._apply_workflow_profile("EXTEND_MAP", set_status_msg=False)
self._set_approx_pick_mode(False)
self.start_pick_ready = False
self.start_pick_xyz = None
self._refresh_pick_status()
self.set_density_mode("MEDIUM")
# Loop closure on by default for extend — drift accumulated during the
# original scan plus drift in the new segment both benefit from it.
self.chk_loop_closure.setChecked(True)
self.chk_submap_mode.setChecked(False)
# Wire ref_map_path so the GUI shows the loaded map in the viewer.
self.ref_map_path = fname
self.lbl_loc.setText(f"Localization: ref={Path(fname).name}")
self._start_ref_loader(fname, show_status=False)
# Send the extend command (also sets ref internally and seeds stable/filter).
try:
self.client.load_for_extend(fname)
except Exception as e:
self.show_error_popup(f"Extend load failed: {e}")
return
self.set_status(
f"Workflow EXTEND_MAP ready (loaded {Path(fname).name}): CONNECT -> START.",
"#ffcc00",
)
QMessageBox.information(
self,
"Extend Saved Map",
"1) Click CONNECT\n2) Click START\n\n"
"New scans will be added to the loaded map. Save as usual on STOP — "
"the merged map is written to a new file (auto-incremented name).",
)
def on_workflow_localize_existing(self):
self._apply_workflow_profile("LOCALIZE_MAP", set_status_msg=False)
self._set_approx_pick_mode(False)
self.start_pick_ready = False
self.start_pick_xyz = None
self._refresh_pick_status()
self.set_density_mode("MEDIUM")
self.chk_loop_closure.setChecked(False)
self.chk_loc_machine.setChecked(True)
# Reference-anchored default: keep uploaded map static, use submap only if user enables it.
self.chk_submap_mode.setChecked(False)
self.set_status(
"Workflow LOCALIZE_MAP ready: LOAD REF -> click start pointer -> CONNECT -> START.",
"#ffcc00",
)
QMessageBox.information(
self,
"Navigate In Mapped Place",
"1) Click LOAD REF MAP\n2) Click CLICK START POINTER, then click current location on map\n3) Click CONNECT\n4) Click START\n\nLocalization will start automatically from your picked start location.",
)
def on_workflow_live_nav(self):
self._apply_workflow_profile("LIVE_NAV_MAP", set_status_msg=False)
self._set_approx_pick_mode(False)
self.start_pick_ready = False
self.start_pick_xyz = None
self._refresh_pick_status()
self.set_density_mode("HIGH")
self.chk_loop_closure.setChecked(False)
self.chk_loc_machine.setChecked(True)
# Reference-anchored default: keep uploaded map static, use submap only if user enables it.
self.chk_submap_mode.setChecked(False)
try:
self.client.set_nav_runtime_cfg({"enabled": True, "dynamic_decay_sec": 1.6, "dynamic_min_hits": 2})
except Exception:
pass
self.set_status(
"Workflow LIVE_NAV_MAP ready: LOAD REF -> click start pointer -> CONNECT -> START.",
"#ffcc00",
)
QMessageBox.information(
self,
"Live Nav With Map",
"1) Click LOAD REF MAP\n2) Click CLICK START POINTER, then click current location on map\n3) Click CONNECT\n4) Click START\n\nLocalization will start automatically from your picked start location.",
)
def on_workflow_live_nav_nomap(self):
self._apply_workflow_profile("LIVE_NAV_NO_MAP", set_status_msg=False)
self._set_approx_pick_mode(False)
self.start_pick_ready = False
self.start_pick_xyz = None
self._refresh_pick_status()
self.set_density_mode("HIGH")
self.chk_loop_closure.setChecked(False)
self.chk_submap_mode.setChecked(False)
if self.ref_map_path:
# Force no-reference mode for live mapless navigation.
self.on_clear_ref()
try:
self.client.set_nav_runtime_cfg({"enabled": True, "dynamic_decay_sec": 1.4, "dynamic_min_hits": 2})
except Exception:
pass
self.set_status("Workflow LIVE_NAV_NO_MAP ready: CONNECT -> START -> set GOAL/MISSION.", "#ffcc00")
def on_workflow_quick_demo(self):
self._apply_workflow_profile("QUICK_DEMO", set_status_msg=False)
self._set_approx_pick_mode(False)
self.start_pick_ready = False
self.start_pick_xyz = None
self._refresh_pick_status()
self.set_density_mode("HIGH")
self.set_status("Workflow QUICK_DEMO ready (fast build, lower persistence).", "#ffcc00")
# actions
def on_connect(self):
if not self.workflow_selected:
self.show_error_popup("Select a workflow first, then CONNECT.")
self.set_status("Status: select workflow first", "#ffcc00")
return
if self._workflow_requires_ref_map() and not self.ref_map_path:
self.show_error_popup("Load a reference map first for this workflow.")
self.set_status("Status: load ref map first", "#ffcc00")
return
# Update config-derived IP at runtime by environment is not needed; just re-create engine process
ip = self.txt_ip.text().strip()
if not ip:
self.set_status("Status: IP required", "#ffcc00")
return
# write into process config by setting env and recreating client config file? keep behavior: just connect
# engine reads config ip; for runtime typed IP, we pass command CONNECT and worker uses eng_cfg.host_ip.
# easiest: set env override and restart client
self.client.stop_process()
# update env config override is not a numeric param; its user input.
# We rebuild a new client, then mutate host_ip for this session:
self.client = SlamEngineClient()
self.client.eng_cfg.host_ip = ip
self.client.start_process()
self.show_self_check_popup(self.client.get_self_check(), source="engine")
self._worker_dead_notified = False
self.worker_mode = "IDLE"
self.is_connected = False
self.recording_enabled = False
self.recording_frames = 0
self.recording_dropped = 0
self.lbl_mode.setText(f"Mode: {self.worker_mode} Rec: {'ON' if self.recording_enabled else 'OFF'}")
self.client.connect()
self.apply_runtime_settings()
if self._workflow_requires_ref_map() and self.ref_map_path:
self.client.load_ref_map(self.ref_map_path)
if self.start_pick_ready and isinstance(self.start_pick_xyz, tuple) and len(self.start_pick_xyz) == 3:
px, py, pz = self.start_pick_xyz
try:
self.client.set_approx_pose(float(px), float(py), float(pz))
except Exception:
pass
else:
self._prompt_pick_before_start_if_needed()
self.set_status("Status: CONNECTING...", self.cfg["gui"]["colors"]["accent"])
def on_start(self):
if not self.is_connected:
self.show_error_popup("Click CONNECT first.")
return
self._set_approx_pick_mode(False)
self.apply_runtime_settings()
if self._workflow_requires_ref_map():
if not self.ref_map_path:
self.show_error_popup("Load a reference map first, then CONNECT and START.")
self.set_status("Status: load ref map first", "#ffcc00")
return
if self.require_start_pick and not self.start_pick_ready:
self.show_error_popup("Set approximate start location on the map first.")
self._prompt_pick_before_start_if_needed()
self.set_status("Status: click map to set start location", "#ffcc00")
return
if self.start_pick_ready and isinstance(self.start_pick_xyz, tuple) and len(self.start_pick_xyz) == 3:
px, py, pz = self.start_pick_xyz
try:
self.client.set_approx_pose(float(px), float(py), float(pz))
except Exception:
pass
self.client.start_localize_only()
self.worker_mode = "LOCALIZE_ONLY"
self.lbl_mode.setText(f"Mode: {self.worker_mode} Rec: {'ON' if self.recording_enabled else 'OFF'}")
self.set_status("Status: LOCALIZE_ONLY (AUTO LOCALIZING FROM LIVE SCAN)", "#ffcc00")
return
self.client.start_mapping()
self.worker_mode = "MAPPING"
self.lbl_mode.setText(f"Mode: {self.worker_mode} Rec: {'ON' if self.recording_enabled else 'OFF'}")
self.set_status("Status: MAPPING", self.cfg["gui"]["colors"]["accent"])
def on_start_localize_only(self):
if not self.ref_map_path:
self.show_error_popup("Load a reference map first for LOCALIZE-ONLY mode.")
return
if not self.is_connected:
self.show_error_popup("Click CONNECT first.")
return
if self.require_start_pick and not self.start_pick_ready:
self.show_error_popup("Set approximate start location on the map first.")
self._prompt_pick_before_start_if_needed()
return
self.apply_runtime_settings()
if self.start_pick_ready and isinstance(self.start_pick_xyz, tuple) and len(self.start_pick_xyz) == 3:
px, py, pz = self.start_pick_xyz
try:
self.client.set_approx_pose(float(px), float(py), float(pz))
except Exception:
pass
self.client.start_localize_only()
self.worker_mode = "LOCALIZE_ONLY"
self.lbl_mode.setText(f"Mode: {self.worker_mode} Rec: {'ON' if self.recording_enabled else 'OFF'}")
self.set_status("Status: LOCALIZE_ONLY", "#ffcc00")
def on_pause(self):
self.client.pause_mapping()
self.worker_mode = "PAUSED"
self.lbl_mode.setText(f"Mode: {self.worker_mode} Rec: {'ON' if self.recording_enabled else 'OFF'}")
self.set_status("Status: PAUSED", "#ffcc00")
def on_stop(self):
if not self.ensure_min_stable_synced(show_error=True, set_status_msg=False):
return
self.client.stop_mapping()
self.worker_mode = "STOPPED"
self.lbl_mode.setText(f"Mode: {self.worker_mode} Rec: {'ON' if self.recording_enabled else 'OFF'}")
if self.request_map_export_if_armed():
self.set_status("Status: STOPPED (SAVING MAP...)", "#ffcc00")
else:
self.set_status("Status: STOPPED", "white")
def on_record_start(self):
base = self.txt_name.text().strip() or "slam_recording"
self.client.record_start(base)
self.recording_enabled = True
self.lbl_mode.setText(f"Mode: {self.worker_mode} Rec: ON")
self.set_status("Recording started.", "#ffcc00")
def on_record_stop(self):
base = self.txt_name.text().strip() or "slam_recording"
self.client.record_stop(True, base)
self.recording_enabled = False
self.lbl_mode.setText(f"Mode: {self.worker_mode} Rec: OFF")
self.set_status("Recording stopped.", "#ffcc00")
def on_replay_run(self):
fname, _ = QFileDialog.getOpenFileName(self, "Run Replay", self.maps_dir, "Replay Files (*.npz)")
if not fname:
return
try:
from SLAM_Replay import load_replay_npz, run_replay, compare_reports
frames, poses = load_replay_npz(fname)
report = run_replay(frames, poses=poses, export_nav=False)
self.last_replay_report = report
out = {"report": report}
passed = None
base_path = Path(self.replay_baseline_path)
if base_path.exists():
baseline = json.loads(base_path.read_text(encoding="utf-8"))
base_rep = baseline.get("report", baseline) if isinstance(baseline, dict) else {}
out["comparison"] = compare_reports(report, base_rep, tol_ratio=0.15)
passed = bool(out["comparison"].get("passed", False))
msg = json.dumps(out, indent=2)
QMessageBox.information(self, "Replay Report", msg)
if passed is None:
self.set_status("Replay complete (no baseline).", self.cfg["gui"]["colors"]["accent"])
elif passed:
self.set_status("Replay regression: PASS", self.cfg["gui"]["colors"]["accent"])
else:
self.set_status("Replay regression: FAIL", "#ff4444")
except Exception as e:
self.show_error_popup(f"Replay failed: {e}")
def on_replay_set_baseline(self):
if not isinstance(self.last_replay_report, dict):
self.show_error_popup("Run replay first, then set baseline.")
return
try:
base = {"report": self.last_replay_report}
Path(self.replay_baseline_path).write_text(json.dumps(base, indent=2), encoding="utf-8")
self.set_status(f"Baseline saved: {Path(self.replay_baseline_path).name}", "#ffcc00")
except Exception as e:
self.show_error_popup(f"Failed to write baseline: {e}")
def on_save_now(self):
if not self.ensure_min_stable_synced(show_error=True, set_status_msg=False):
return
base = self.txt_name.text().strip() or "map_robot"
self.client.export_map(base)
self.set_status("Saving map now...", "#ffcc00")
def on_reset(self):
self.save_armed = False
self.chk_save.blockSignals(True); self.chk_save.setChecked(False); self.chk_save.blockSignals(False)
self.client.reset_mapping()
self.worker_mode = "IDLE"
self.recording_enabled = False
self.recording_frames = 0
self.recording_dropped = 0
self.lbl_mode.setText("Mode: IDLE Rec: OFF")
self.scatter.setData(pos=np.zeros((1,3),dtype=np.float32))
self.set_status("Status: RESET", "#ffcc00")
self.lbl_points.setText("Stable points: 0")
self.current_stable_points = 0
def on_toggle_save(self, state: int):
self.save_armed = bool(state)
self.set_status("Save armed" if self.save_armed else "Save disarmed",
self.cfg["gui"]["colors"]["accent"] if self.save_armed else "white")
def on_apply_autosave(self, _checked: bool = False, set_status_msg: bool = True, quiet: bool = False):
sec = self._read_float(self.txt_autosave_sec, self.autosave_interval_sec)
sec = max(5.0, sec)
self.autosave_interval_sec = sec
self.autosave_enabled = bool(self.chk_autosave.isChecked())
self.txt_autosave_sec.setText(str(int(sec)))
base = self.txt_name.text().strip() or "autosave_map"
try:
self.client.set_autosave(self.autosave_enabled, self.autosave_interval_sec, base)
except Exception:
if not quiet:
self.show_error_popup("Failed to send autosave settings to worker.")
return
if set_status_msg:
mode = "ON" if self.autosave_enabled else "OFF"
self.set_status(f"Autosave {mode} ({int(self.autosave_interval_sec)}s)", "#ffcc00")
def on_export_nav(self):
if not self.ensure_min_stable_synced(show_error=True, set_status_msg=False):
return
self.on_apply_nav_config(set_status_msg=False, quiet=True)
base = self.txt_name.text().strip() or "map_robot"
self.client.export_nav(base)
self.set_status("Exporting nav map...", "#ffcc00")
def on_apply_min_stable_points(self):
value = self._read_min_stable_input(show_error=True)
if value is None:
return
self._apply_min_stable_value(int(value), set_status_msg=True)
def on_toggle_loop_closure(self, state: int):
self.loop_closure_enabled = bool(state)
try:
self.client.set_loop_closure(self.loop_closure_enabled)
except Exception:
pass
self.set_status(
"Loop closure SAFE ON" if self.loop_closure_enabled else "Loop closure OFF",
"#ffcc00",
)
def on_toggle_loc_machine(self, state: int):
self.loc_machine_enabled = bool(state)
try:
self.client.set_loc_state_machine(self.loc_machine_enabled)
except Exception:
pass
self.set_status(
"Localization state machine ON" if self.loc_machine_enabled else "Localization state machine OFF",
"#ffcc00",
)
def on_toggle_submap_mode(self, state: int):
self.submap_mode_enabled = bool(state)
try:
self.client.set_submap_mode(
self.submap_mode_enabled,
{"apply_profiles": ["LOCALIZE_MAP", "LIVE_NAV_MAP"]},
)
except Exception:
pass
self.set_status(
"Submap LOCAL+GLOBAL ON" if self.submap_mode_enabled else "Submap LOCAL+GLOBAL OFF",
"#ffcc00",
)
def on_apply_nav_config(self, _checked: bool = False, set_status_msg: bool = True, quiet: bool = False):
zmin = self._read_float(self.txt_nav_zmin, float(self.nav_cfg.get("z_min_m", -0.4)))
zmax = self._read_float(self.txt_nav_zmax, float(self.nav_cfg.get("z_max_m", 1.2)))
res = max(0.01, self._read_float(self.txt_nav_res, float(self.nav_cfg.get("resolution_m", 0.05))))
inf = max(0.0, self._read_float(self.txt_nav_inf, float(self.nav_cfg.get("inflation_radius_m", 0.2))))
patch = {
"z_min_m": zmin,
"z_max_m": zmax,
"resolution_m": res,
"inflation_radius_m": inf,
"min_points": int(self.min_stable_points),
}
self.nav_cfg.update(patch)
try:
self.client.set_nav_export_cfg(patch)
except Exception:
if not quiet:
self.show_error_popup("Failed to send nav tuning to worker.")
return
if set_status_msg:
self.set_status("Nav tuning applied.", "#ffcc00")
def on_set_nav_goal(self):
x = self._read_float(self.txt_goal_x, 0.0)
y = self._read_float(self.txt_goal_y, 0.0)
try:
self.client.set_nav_goal(x, y)
self.set_status(f"Nav goal set ({x:.2f}, {y:.2f})", "#ffcc00")
except Exception:
self.show_error_popup("Failed to send nav goal.")
def on_clear_nav_goal(self):
try:
self.client.clear_nav_goal()
self.set_status("Nav goal cleared.", "white")
except Exception:
self.show_error_popup("Failed to clear nav goal.")
def on_mission_start(self):
raw = self.txt_mission.text().strip()
if not raw:
self.show_error_popup("Mission waypoints are empty.")
return
waypoints = []
try:
chunks = [x.strip() for x in raw.split(";") if x.strip()]
for ch in chunks:
parts = [p.strip() for p in ch.split(",")]
if len(parts) < 2:
continue
waypoints.append({"x": float(parts[0]), "y": float(parts[1])})
except Exception:
self.show_error_popup("Mission format should be: x,y; x,y; ...")
return
if not waypoints:
self.show_error_popup("No valid mission waypoints parsed.")
return
try:
self.client.mission_start(waypoints)
self.set_status(f"Mission started ({len(waypoints)} wp)", "#ffcc00")
except Exception:
self.show_error_popup("Failed to start mission.")
def _send_mission_cmd(self, mode: str):
m = str(mode).lower().strip()
try:
if m == "pause":
self.client.mission_pause()
self.set_status("Mission paused.", "#ffcc00")
elif m == "resume":
self.client.mission_resume()
self.set_status("Mission resumed.", "#ffcc00")
elif m == "stop":
self.client.mission_stop()
self.set_status("Mission stopped.", "white")
except Exception:
self.show_error_popup("Failed to send mission command.")
def on_apply_map_quality(self, _checked: bool = False, set_status_msg: bool = True, quiet: bool = False):
patch = {
"enabled": True,
"near_min_range_m": max(0.0, self._read_float(self.txt_near_range, 0.15)),
"world_z_clip_enabled": bool(self.chk_clip_z.isChecked()),
"world_z_min_m": self._read_float(self.txt_world_zmin, -2.0),
"world_z_max_m": self._read_float(self.txt_world_zmax, 3.0),
"outlier_filter_enabled": bool(self.chk_outlier.isChecked()),
"outlier_voxel_m": max(0.02, self._read_float(self.txt_outlier_voxel, 0.12)),
"outlier_min_points": max(1, int(self._read_float(self.txt_outlier_min, 2))),
}
self.map_quality_cfg.update(patch)
try:
self.client.set_map_quality_cfg(patch)
except Exception:
if not quiet:
self.show_error_popup("Failed to send map-quality settings to worker.")
return
if set_status_msg:
self.set_status("Map-quality filter applied.", "#ffcc00")
def refresh_density_buttons(self):
for mode, btn in (
("LOW", self.btn_den_low),
("MEDIUM", self.btn_den_med),
("HIGH", self.btn_den_high),
):
if mode == self.density_mode:
btn.setStyleSheet(pill_button("#0f8f4b"))
else:
btn.setStyleSheet(pill_button("#2b2b2b"))
def set_density_mode(self, mode: str):
self.density_mode = str(mode).upper().strip()
if self.density_mode not in ("LOW", "MEDIUM", "HIGH"):
self.density_mode = "MEDIUM"
self.refresh_density_buttons()
self.lbl_density.setText(f"Density: {self.density_mode}")
try:
self.client.set_density(self.density_mode)
except Exception:
pass
self.set_status(f"Density set: {self.density_mode}", "#ffcc00")
def refresh_ref_color_buttons(self):
for mode, btn in (
("GRAY", self.btn_ref_gray),
("HEIGHT", self.btn_ref_height),
("ORIGINAL", self.btn_ref_orig),
):
if mode == self.ref_color_mode:
btn.setStyleSheet(pill_button("#0f8f4b"))
else:
btn.setStyleSheet(pill_button("#2b2b2b"))
def _start_ref_loader(self, filename: str, show_status: bool = True):
if self._ref_thread is not None and self._ref_thread.isRunning():
self._ref_thread.quit()
self._ref_thread.wait(200)
self._ref_thread = RefLoaderThread(
filename,
display_voxel=self.ref_display_voxel,
color_mode=self.ref_color_mode,
)
self._ref_thread.loaded.connect(self.on_ref_loaded)
self._ref_thread.failed.connect(lambda e: QMessageBox.critical(self, "REF load failed", e))
self._ref_thread.start()
if show_status:
self.set_status(f"Ref loading ({self.ref_color_mode})...", "#ffcc00")
def set_ref_color_mode(self, mode: str):
m = str(mode).upper().strip()
if m not in ("GRAY", "HEIGHT", "ORIGINAL"):
m = "GRAY"
self.ref_color_mode = m
self.refresh_ref_color_buttons()
if self.ref_map_path:
self._start_ref_loader(self.ref_map_path, show_status=False)
self.set_status(f"Ref color: {self.ref_color_mode}", "#ffcc00")
def on_load_ref(self):
if not self._pick_and_load_ref_map():
return
self.set_status("Ref loaded.", "#ffcc00")
def on_ref_loaded(self, pts, cols, filename: str):
draw_pts = pts
draw_cols = cols
if len(draw_pts) > self.max_ref_draw:
step = max(2, len(draw_pts) // self.max_ref_draw)
draw_pts = draw_pts[::step]
if draw_cols is not None and len(draw_cols) == len(pts):
draw_cols = draw_cols[::step]
if draw_cols is not None:
self.ref_scatter.setData(pos=draw_pts, color=draw_cols, size=1.2)
else:
self.ref_scatter.setData(pos=draw_pts, size=1.2)
try:
pick_pts = np.asarray(pts, dtype=np.float32)
if pick_pts.ndim == 2 and pick_pts.shape[1] == 3 and len(pick_pts) > 0:
max_n = max(2000, int(self.ref_pick_max_points))
if len(pick_pts) > max_n:
step = max(1, int(np.ceil(float(len(pick_pts)) / float(max_n))))
pick_pts = pick_pts[::step][:max_n]
self.ref_pick_points = np.asarray(pick_pts, dtype=np.float32)
self.view.pick_points = self.ref_pick_points
else:
self.ref_pick_points = None
self.view.pick_points = None
except Exception:
self.ref_pick_points = None
self.view.pick_points = None
# Auto-localize only when stable map is ready; otherwise avoid error popups.
if self.worker_mode == "LOCALIZE_ONLY":
self.client.start_localize_only()
self.set_status("Ref shown. Localize-only running...", "#ffcc00")
elif self._workflow_requires_ref_map():
if self.require_start_pick and not self.start_pick_ready:
self._set_approx_pick_mode(True)
if self._pick_help_shown_for_ref != str(filename):
self._pick_help_shown_for_ref = str(filename)
QMessageBox.information(
self,
"Set Start Location",
"Reference map loaded.\n\nClick CLICK START POINTER, then click your current robot location on the map, then press START.",
)
self.set_status("Ref shown. Click start pointer, then click map to set start location.", "#ffcc00")
else:
self.set_status("Ref shown. Click CONNECT then START (auto localize).", "#ffcc00")
elif int(self.current_stable_points) >= int(self.min_stable_points):
self.client.localize_now()
self.set_status("Ref shown. Localizing...", "#ffcc00")
else:
self.set_status(
f"Ref shown. Need stable points {self.current_stable_points}/{self.min_stable_points} before localize.",
"#ffcc00",
)
self.lbl_loc.setText(
f"Localization: ref={Path(filename).name} ({len(pts)} pts, {self.ref_color_mode})"
)
self._refresh_pick_status()
def on_localize(self):
if not self.ref_map_path:
self.show_error_popup("Load a reference map first.")
return
if not self._workflow_requires_ref_map() and self.worker_mode != "LOCALIZE_ONLY":
if not self.ensure_min_stable_synced(show_error=True, set_status_msg=False):
return
self.client.localize_now()
self.set_status("Localization forced...", "#ffcc00")
def _refresh_pick_status(self):
if self.start_pick_ready and isinstance(self.start_pick_xyz, tuple) and len(self.start_pick_xyz) == 3:
x, y, z = self.start_pick_xyz
self.lbl_pick_status.setText(
f"Start pick: set ({self._fmt_coord(x)}, {self._fmt_coord(y)}, {self._fmt_coord(z)})"
)
self.lbl_pick_status.setStyleSheet("color:#00ff88; font-family:monospace; font-weight:800;")
else:
txt = "Start pick: optional (not required)" if not self.require_start_pick else "Start pick: pending"
self.lbl_pick_status.setText(txt)
self.lbl_pick_status.setStyleSheet("color:#ffcc00; font-family:monospace; font-weight:800;")
@staticmethod
def _fmt_coord(v: float, ndigits: int = 3) -> str:
vv = float(v)
if abs(vv) < (10 ** (-(ndigits + 1))):
vv = 0.0
return f"{vv:.{int(ndigits)}f}"
def _hide_start_pointer_visual(self):
self.start_ptr_scatter.setData(
pos=np.zeros((1, 3), dtype=np.float32),
color=np.zeros((1, 4), dtype=np.float32),
size=1.0,
)
def _set_start_pointer_visual(self, x: float, y: float, z: float):
pos = np.asarray([[float(x), float(y), float(z)]], dtype=np.float32)
col = np.asarray([[1.00, 0.82, 0.08, 0.96]], dtype=np.float32)
self.start_ptr_scatter.setData(pos=pos, color=col, size=19.0)
def _snap_pick_to_ref(self, x: float, y: float, z: float) -> tuple[float, float, float]:
pts = self.ref_pick_points
if pts is None:
return float(x), float(y), float(z)
arr = np.asarray(pts, dtype=np.float32)
if arr.ndim != 2 or arr.shape[1] != 3 or len(arr) == 0:
return float(x), float(y), float(z)
q = np.asarray([float(x), float(y)], dtype=np.float32)
rel = arr[:, :2] - q.reshape((1, 2))
d2 = np.einsum("ij,ij->i", rel, rel)
idx = int(np.argmin(d2))
if float(d2[idx]) <= float(self.ref_pick_snap_radius_m) * float(self.ref_pick_snap_radius_m):
p = arr[idx]
return float(p[0]), float(p[1]), float(p[2])
return float(x), float(y), float(z)
def on_toggle_require_start_pick(self, state: int):
self.require_start_pick = bool(state)
if not self.require_start_pick:
self._set_approx_pick_mode(False)
elif self._workflow_requires_ref_map() and self.ref_map_path and (not self.start_pick_ready):
self._set_approx_pick_mode(True)
self._refresh_pick_status()
self.set_status(
"Start-point pick required." if self.require_start_pick else "Start-point pick optional.",
"#ffcc00",
)
def _prompt_pick_before_start_if_needed(self):
if (not self._workflow_requires_ref_map()) or (not self.require_start_pick):
return
if self.start_pick_ready:
return
self._set_approx_pick_mode(True)
QMessageBox.information(
self,
"Set Start Location",
"Before START, click CLICK START POINTER, then click the reference map at robot current location.",
)
def _set_approx_pick_mode(self, armed: bool):
self.approx_pick_armed = bool(armed)
self.view.pick_mode = bool(armed)
if bool(armed):
self.btn_pick_approx.setStyleSheet(pill_button("#0f8f4b"))
self.btn_pick_approx.setText("🧭 CLICK START POINTER (ON)")
self.set_status("Click on map to set start pointer. Click again to move it. Press button to stop.", "#ffcc00")
else:
self.btn_pick_approx.setStyleSheet(pill_button("#2b2b2b"))
self.btn_pick_approx.setText("🧭 CLICK START POINTER")
def on_set_approx_pick(self):
if not self.ref_map_path:
self.show_error_popup("Load a reference map first.")
return
if self.approx_pick_armed:
self._set_approx_pick_mode(False)
self.set_status("Start pointer pick disabled.", "#ffcc00")
return
self.view.pick_plane_z = float(self.approx_guess_z)
self._set_approx_pick_mode(True)
if self.start_pick_ready and isinstance(self.start_pick_xyz, tuple) and len(self.start_pick_xyz) == 3:
sx, sy, _ = self.start_pick_xyz
self._set_start_pointer_visual(float(sx), float(sy), float(self.approx_guess_z))
def on_clear_approx(self):
self._set_approx_pick_mode(False)
self.start_pick_ready = False
self.start_pick_xyz = None
try:
self.client.clear_approx_pose()
except Exception:
pass
self._refresh_pick_status()
self._hide_start_pointer_visual()
self.set_status("Approximate position cleared.", "#ffcc00")
def on_view_map_pick_drag(self, x: float, y: float, z: float, final: bool):
if not self.approx_pick_armed:
return
if not self.ref_map_path:
return
z_guess = float(z) if np.isfinite(float(z)) else float(self.approx_guess_z)
sx, sy, sz = self._snap_pick_to_ref(float(x), float(y), float(z_guess))
self.approx_guess_z = float(sz)
self.txt_approx_z.setText(f"{self.approx_guess_z:.2f}")
self._set_start_pointer_visual(float(sx), float(sy), float(sz))
self.start_pick_ready = True
self.start_pick_xyz = (float(sx), float(sy), float(sz))
self._refresh_pick_status()
if not bool(final):
return
try:
self.client.set_approx_pose(float(sx), float(sy), float(sz))
self.set_status(
f"Start pointer set at ({self._fmt_coord(sx)}, {self._fmt_coord(sy)}, {self._fmt_coord(sz)}). Click map again to adjust.",
"#ffcc00",
)
except Exception:
self.show_error_popup("Failed to send approximate position to worker.")
def on_clear_ref(self):
if self._ref_thread is not None and self._ref_thread.isRunning():
self._ref_thread.quit()
self._ref_thread.wait(200)
self.ref_map_path = None
self.ref_pick_points = None
self.view.pick_points = None
self._set_approx_pick_mode(False)
self.start_pick_ready = False
self.start_pick_xyz = None
self._pick_help_shown_for_ref = None
self.client.clear_ref()
try:
self.client.clear_approx_pose()
except Exception:
pass
self.ref_scatter.setData(
pos=np.zeros((1, 3), dtype=np.float32),
color=np.zeros((1, 4), dtype=np.float32),
size=1.0,
)
self.ref_match_scatter.setData(
pos=np.zeros((1, 3), dtype=np.float32),
color=np.zeros((1, 4), dtype=np.float32),
size=1.0,
)
self.lbl_loc.setText("Localization: NO_REF")
self._refresh_pick_status()
self.lbl_loc_match.setText("Match: n/a")
self.set_status("Ref cleared.", "white")
def update_loop(self):
# handle status msgs
try:
while True:
level, msg = self.client.status_q.get_nowait()
if level == "ERROR":
self.set_status(f"ERR: {msg}", "#ff4444")
if "CONNECT" in str(msg).upper() or "LIVOX" in str(msg).upper():
self.is_connected = False
self.show_error_popup(str(msg))
elif level == "WARN":
self.set_status(f"WARN: {msg}", "#ffcc00")
elif level == "INFO" and isinstance(msg, str):
up = msg.upper()
if "CONNECTED TO LIVOX" in up:
self.is_connected = True
self.set_status("Status: CONNECTED", self.cfg["gui"]["colors"]["accent"])
elif "MAPPING STARTED" in up:
self.set_status("Status: MAPPING", self.cfg["gui"]["colors"]["accent"])
elif "LOCALIZE_ONLY STARTED" in up:
self.set_status("Status: LOCALIZE_ONLY", "#ffcc00")
elif "MAPPING PAUSED" in up:
self.set_status("Status: PAUSED", "#ffcc00")
elif "MAPPING STOPPED" in up:
self.set_status("Status: STOPPED", "white")
elif "SAVED:" in up:
saved_path = str(msg).split("SAVED:", 1)[1].strip() if "SAVED:" in str(msg) else ""
if saved_path:
self.set_status(f"Status: MAP SAVED ({Path(saved_path).name})", self.cfg["gui"]["colors"]["accent"])
self.show_saved_popup(f"Map saved:\n{saved_path}")
else:
self.set_status("Status: MAP SAVED", self.cfg["gui"]["colors"]["accent"])
elif isinstance(msg, dict) and "NAV_EXPORTED" in msg:
nav = msg.get("NAV_EXPORTED", {})
pgm = str(nav.get("pgm", ""))
self.set_status("Status: NAV EXPORTED", self.cfg["gui"]["colors"]["accent"])
if pgm:
QMessageBox.information(self, "Navigation Export", f"Saved navigation files:\n{pgm}")
elif isinstance(msg, dict) and "SESSION" in msg:
sess = msg.get("SESSION", {})
if bool(sess.get("prior_loaded", False)):
ref = str(sess.get("ref", "reference"))
self.set_status(f"Session prior loaded: {ref}", "#ffcc00")
elif isinstance(msg, dict) and "SELF_CHECK" in msg:
rep = msg.get("SELF_CHECK", {})
src = str(msg.get("source", "worker"))
self.show_self_check_popup(rep if isinstance(rep, dict) else {}, source=src)
elif isinstance(msg, dict) and "LOOP" in msg:
loop = msg.get("LOOP", {})
if bool(loop.get("optimized", False)):
self.set_status("Status: LOOP CLOSED", self.cfg["gui"]["colors"]["accent"])
rej = loop.get("rejected", {})
if isinstance(rej, dict) and rej:
tr = float(rej.get("translation_m", 0.0))
ang = float(rej.get("rotation_deg", 0.0))
self.set_status(f"Loop reject safe guard ({tr:.2f}m, {ang:.1f}deg)", "#ffcc00")
elif isinstance(msg, dict) and "LOOP_MODE" in msg:
info = msg.get("LOOP_MODE", {})
enabled = bool(info.get("enabled", self.loop_closure_enabled))
self.loop_closure_enabled = enabled
self.chk_loop_closure.blockSignals(True)
self.chk_loop_closure.setChecked(enabled)
self.chk_loop_closure.blockSignals(False)
elif isinstance(msg, dict) and "LOC_MACHINE" in msg:
info = msg.get("LOC_MACHINE", {})
enabled = bool(info.get("enabled", self.loc_machine_enabled))
self.loc_machine_enabled = enabled
self.chk_loc_machine.blockSignals(True)
self.chk_loc_machine.setChecked(enabled)
self.chk_loc_machine.blockSignals(False)
elif isinstance(msg, dict) and "SUBMAP_MODE" in msg:
info = msg.get("SUBMAP_MODE", {})
enabled = bool(info.get("enabled", self.submap_mode_enabled))
self.submap_mode_enabled = enabled
self.chk_submap_mode.blockSignals(True)
self.chk_submap_mode.setChecked(enabled)
self.chk_submap_mode.blockSignals(False)
elif isinstance(msg, dict) and "LOC_STATE" in msg:
loc_info = msg.get("LOC_STATE", {})
state = str(loc_info.get("state", "TRACKING")).upper().strip()
enabled = bool(loc_info.get("enabled", self.loc_machine_enabled))
self.loc_machine_enabled = enabled
self.chk_loc_machine.blockSignals(True)
self.chk_loc_machine.setChecked(enabled)
self.chk_loc_machine.blockSignals(False)
self.lbl_loc_state.setText(f"Loc health: {state}")
if state == "LOST":
self.set_status("Status: LOST (RECOVERING)", "#ff4444")
elif state == "RECOVERY":
self.set_status("Status: RECOVERY (AUTO RELOCALIZE)", "#ffcc00")
elif state == "DEGRADED":
self.set_status("Status: DEGRADED", "#ffcc00")
elif isinstance(msg, dict) and "DENSITY" in msg:
den = msg.get("DENSITY", {})
mode = str(den.get("mode", self.density_mode)).upper().strip()
stride = int(den.get("stride", 1))
self.density_mode = mode if mode in ("LOW", "MEDIUM", "HIGH") else self.density_mode
self.refresh_density_buttons()
self.lbl_density.setText(f"Density: {self.density_mode} (stride {stride})")
elif isinstance(msg, dict) and "MIN_STABLE_POINTS" in msg:
info = msg.get("MIN_STABLE_POINTS", {})
try:
value = int(info.get("value", self.min_stable_points))
except Exception:
value = self.min_stable_points
self.min_stable_points = value
self.txt_min_stable.setText(str(value))
self.lbl_min_stable.setText(f"Min stable: {value}")
elif isinstance(msg, dict) and "AUTOSAVE" in msg:
info = msg.get("AUTOSAVE", {})
self.autosave_enabled = bool(info.get("enabled", self.autosave_enabled))
self.autosave_interval_sec = float(info.get("interval_sec", self.autosave_interval_sec))
self.chk_autosave.blockSignals(True)
self.chk_autosave.setChecked(self.autosave_enabled)
self.chk_autosave.blockSignals(False)
self.txt_autosave_sec.setText(str(int(self.autosave_interval_sec)))
elif isinstance(msg, dict) and "AUTOSAVED" in msg:
auto = msg.get("AUTOSAVED", {})
path = str(auto.get("path", ""))
pts = int(auto.get("points", 0))
if path:
self.set_status(f"Autosaved: {Path(path).name} ({pts} pts)", self.cfg["gui"]["colors"]["accent"])
elif isinstance(msg, dict) and "MODE" in msg:
info = msg.get("MODE", {})
self.worker_mode = str(info.get("mode", self.worker_mode)).upper().strip()
self.lbl_mode.setText(
f"Mode: {self.worker_mode} Rec: {'ON' if self.recording_enabled else 'OFF'}"
)
elif isinstance(msg, dict) and "RECORDING" in msg:
info = msg.get("RECORDING", {})
self.recording_enabled = bool(info.get("enabled", self.recording_enabled))
self.recording_frames = int(info.get("frames", self.recording_frames))
self.recording_dropped = int(info.get("dropped", self.recording_dropped))
self.lbl_mode.setText(
f"Mode: {self.worker_mode} Rec: {'ON' if self.recording_enabled else 'OFF'}"
)
elif isinstance(msg, dict) and "RECORD_SAVED" in msg:
rec = msg.get("RECORD_SAVED", {})
path = str(rec.get("path", ""))
frames = int(rec.get("frames", 0))
dropped = int(rec.get("dropped", 0))
if path:
self.set_status(f"Recording saved ({frames} frames)", self.cfg["gui"]["colors"]["accent"])
QMessageBox.information(
self,
"Recording Saved",
f"Saved replay file:\n{path}\n\nFrames: {frames}\nDropped: {dropped}",
)
elif isinstance(msg, dict) and "NAV_CONFIG" in msg:
info = msg.get("NAV_CONFIG", {})
self.txt_nav_zmin.setText(str(info.get("z_min_m", self.txt_nav_zmin.text())))
self.txt_nav_zmax.setText(str(info.get("z_max_m", self.txt_nav_zmax.text())))
self.txt_nav_res.setText(str(info.get("resolution_m", self.txt_nav_res.text())))
self.txt_nav_inf.setText(str(info.get("inflation_radius_m", self.txt_nav_inf.text())))
elif isinstance(msg, dict) and "NAV_GOAL" in msg:
info = msg.get("NAV_GOAL", None)
if isinstance(info, dict):
self.set_status(
f"Nav goal set: ({float(info.get('x', 0.0)):.2f}, {float(info.get('y', 0.0)):.2f})",
"#ffcc00",
)
else:
self.set_status("Nav goal cleared.", "white")
elif isinstance(msg, dict) and "MISSION" in msg:
info = msg.get("MISSION", {})
if bool(info.get("completed", False)):
self.set_status("Mission completed.", self.cfg["gui"]["colors"]["accent"])
elif isinstance(msg, dict) and "MAP_QUALITY" in msg:
info = msg.get("MAP_QUALITY", {})
self.txt_near_range.setText(str(info.get("near_min_range_m", self.txt_near_range.text())))
self.chk_outlier.setChecked(bool(info.get("outlier_filter_enabled", self.chk_outlier.isChecked())))
self.chk_clip_z.setChecked(bool(info.get("world_z_clip_enabled", self.chk_clip_z.isChecked())))
self.txt_world_zmin.setText(str(info.get("world_z_min_m", self.txt_world_zmin.text())))
self.txt_world_zmax.setText(str(info.get("world_z_max_m", self.txt_world_zmax.text())))
self.txt_outlier_voxel.setText(str(info.get("outlier_voxel_m", self.txt_outlier_voxel.text())))
self.txt_outlier_min.setText(str(info.get("outlier_min_points", self.txt_outlier_min.text())))
elif isinstance(msg, dict) and "FILTER_TUNING" in msg:
info = msg.get("FILTER_TUNING", {})
prof = str(info.get("profile", self.workflow_profile)).upper().strip()
self._apply_workflow_ui_rules(prof, update_label=True)
elif isinstance(msg, dict) and "WORKFLOW_PROFILE" in msg:
info = msg.get("WORKFLOW_PROFILE", {})
prof = str(info.get("name", self.workflow_profile)).upper().strip()
self._apply_workflow_ui_rules(prof, update_label=True)
elif isinstance(msg, dict) and "LOCALIZE" in msg:
loc = msg["LOCALIZE"]
fit = float(loc.get("fitness", 0.0))
rmse = float(loc.get("rmse", 0.0))
conf = float(loc.get("confidence", 0.0))
self.last_loc_confidence = float(conf)
ok = bool(loc.get("accepted", False))
src = str(loc.get("source", "")).upper().strip()
state = str(loc.get("state", self.lbl_loc_state.text().split(":")[-1].strip())).upper().strip()
bi = float(loc.get("inlier_bidir", 0.0))
prefix = f"{src} " if src else ""
self.lbl_loc.setText(
f"Localization: {prefix}fit={fit:.3f} rmse={rmse:.3f} bi={bi:.2f} conf={conf:.2f} {'OK' if ok else 'NO'}"
)
self.lbl_loc_state.setText(f"Loc health: {state}")
if ok:
self.set_status(f"Status: LOCALIZED ({src or 'RUN'})", self.cfg["gui"]["colors"]["accent"])
else:
self.set_status(f"Status: LOCALIZE WEAK ({src or 'RUN'})", "#ffcc00")
elif isinstance(msg, dict) and "APPROX_POSE" in msg:
info = msg.get("APPROX_POSE", {})
if bool(info.get("set", False)):
ax = float(info.get("x", 0.0))
ay = float(info.get("y", 0.0))
az = float(info.get("z", self.approx_guess_z))
self.approx_guess_z = az
self.txt_approx_z.setText(f"{az:.2f}")
self.start_pick_ready = True
self.start_pick_xyz = (ax, ay, az)
self._set_start_pointer_visual(ax, ay, az)
self._refresh_pick_status()
self.set_status(
f"Approx pose armed at ({self._fmt_coord(ax)}, {self._fmt_coord(ay)}, {self._fmt_coord(az)})",
"#ffcc00",
)
else:
self.start_pick_ready = False
self.start_pick_xyz = None
self._hide_start_pointer_visual()
self._refresh_pick_status()
self.set_status("Approx pose cleared.", "#ffcc00")
except Exception:
pass
if self.client.proc is not None and not self.client.proc.is_alive():
if not self._worker_dead_notified:
self._worker_dead_notified = True
code = self.client.proc.exitcode
code_txt = "unknown" if code is None else str(code)
self.worker_mode = "DEAD"
self.is_connected = False
self.lbl_mode.setText(f"Mode: {self.worker_mode} Rec: OFF")
self.set_status(f"ERR: Worker stopped (exit {code_txt})", "#ff4444")
if int(code or 0) == -11:
# Native crash fallback: keep advanced loop closure off on next restart.
was_loop_on = bool(self.loop_closure_enabled)
self.loop_closure_enabled = False
self.chk_loop_closure.blockSignals(True)
self.chk_loop_closure.setChecked(False)
self.chk_loop_closure.blockSignals(False)
extra = "Advanced loop closure was disabled for safety.\n" if was_loop_on else ""
self.show_error_popup(
"SLAM worker process stopped (exit code -11).\n"
"Native ICP/localization path crashed.\n"
f"{extra}"
"Click CONNECT to restart."
)
else:
self.show_error_popup(f"SLAM worker process stopped (exit code {code_txt}). Click CONNECT to restart.")
else:
self._worker_dead_notified = False
latest = None
try:
while True:
latest = self.client.data_q.get_nowait()
except Exception:
pass
if not latest:
return
tag, payload = latest
if tag != "FRAME":
return
pts = payload.get("stable_points", None)
cols = payload.get("stable_colors", None)
pose = payload.get("pose", None)
perf = payload.get("perf", {}) if isinstance(payload, dict) else {}
map_lock = payload.get("map_lock", {}) if isinstance(payload, dict) else {}
continuity = payload.get("continuity", {}) if isinstance(payload, dict) else {}
submap = payload.get("submap", {}) if isinstance(payload, dict) else {}
mode = str(payload.get("mode", self.worker_mode)).upper().strip() if isinstance(payload, dict) else self.worker_mode
nav = payload.get("nav", {}) if isinstance(payload, dict) else {}
safety = payload.get("safety", {}) if isinstance(payload, dict) else {}
rec = payload.get("recording", {}) if isinstance(payload, dict) else {}
wf = str(payload.get("workflow_profile", self.workflow_profile)).upper().strip() if isinstance(payload, dict) else self.workflow_profile
ref_match_points = payload.get("ref_match_points", None) if isinstance(payload, dict) else None
ref_match_colors = payload.get("ref_match_colors", None) if isinstance(payload, dict) else None
ref_match_stats = payload.get("ref_match", {}) if isinstance(payload, dict) else {}
self.last_loc_confidence = float(payload.get("loc_confidence", self.last_loc_confidence)) if isinstance(payload, dict) else self.last_loc_confidence
if pts is not None and len(pts) > 1:
draw_pts = pts
draw_cols = cols
if len(draw_pts) > self.max_draw:
step = max(2, len(draw_pts) // self.max_draw)
if self.decimate_step > 1:
step = max(step, self.decimate_step)
draw_pts = draw_pts[::step]
if draw_cols is not None and len(draw_cols) == len(pts):
draw_cols = draw_cols[::step]
if draw_cols is not None:
self.scatter.setData(pos=draw_pts, color=draw_cols)
else:
self.scatter.setData(pos=draw_pts)
self.lbl_points.setText(f"Stable points: {len(pts)}")
self.current_stable_points = int(len(pts))
if ref_match_points is not None:
try:
mpts = np.asarray(ref_match_points, dtype=np.float32)
if mpts.ndim == 2 and mpts.shape[1] == 3 and len(mpts) > 1:
mcols = None
if ref_match_colors is not None:
mcols_arr = np.asarray(ref_match_colors, dtype=np.float32)
if mcols_arr.ndim == 2 and mcols_arr.shape[1] == 4 and len(mcols_arr) == len(mpts):
mcols = mcols_arr
if len(mpts) > self.max_ref_draw:
mstep = max(2, len(mpts) // self.max_ref_draw)
mpts = mpts[::mstep]
if mcols is not None:
mcols = mcols[::mstep]
if mcols is not None:
self.ref_match_scatter.setData(pos=mpts, color=mcols, size=1.5)
else:
self.ref_match_scatter.setData(pos=mpts, size=1.5)
else:
self.ref_match_scatter.setData(
pos=np.zeros((1, 3), dtype=np.float32),
color=np.zeros((1, 4), dtype=np.float32),
size=1.0,
)
except Exception:
pass
elif str(mode).upper().strip() != "LOCALIZE_ONLY":
self.ref_match_scatter.setData(
pos=np.zeros((1, 3), dtype=np.float32),
color=np.zeros((1, 4), dtype=np.float32),
size=1.0,
)
if pose is not None:
try:
x, y, z = float(pose[0, 3]), float(pose[1, 3]), float(pose[2, 3])
self.lbl_pose.setText(f"Pos: {x:.2f}, {y:.2f}, {z:.2f}")
self.axis.resetTransform()
self.axis.translate(x, y, z)
except Exception:
pass
if isinstance(perf, dict) and perf:
try:
in_fps = float(perf.get("input_fps", 0.0))
out_fps = float(perf.get("publish_fps", 0.0))
icp_ms = float(perf.get("icp_ms", 0.0))
cpu_pct = float(perf.get("cpu_percent", 0.0))
qlag = int(perf.get("queue_lag", 0))
growth = float(perf.get("stable_growth_per_sec", 0.0))
freeze_count = int(map_lock.get("freeze_count", 0)) if isinstance(map_lock, dict) else 0
c_rej_total = int(continuity.get("reject_count", 0)) if isinstance(continuity, dict) else 0
c_rej_streak = int(continuity.get("reject_streak", 0)) if isinstance(continuity, dict) else 0
self.lbl_perf_fps.setText(f"FPS in/out: {in_fps:.1f} / {out_fps:.1f}")
self.lbl_perf_icp.setText(f"ICP: {icp_ms:.1f} ms CPU: {cpu_pct:.1f}%")
hold_txt = f" Hold: {freeze_count}" if freeze_count > 0 else ""
self.lbl_perf_queue.setText(
f"Queue lag: {qlag} Growth: {growth:.1f} pts/s{hold_txt} Cont: {c_rej_streak}/{c_rej_total}"
)
except Exception:
pass
if isinstance(rec, dict):
self.recording_enabled = bool(rec.get("enabled", self.recording_enabled))
self.recording_frames = int(rec.get("frames", self.recording_frames))
self.recording_dropped = int(rec.get("dropped", self.recording_dropped))
if mode:
self.worker_mode = mode
if wf:
self._apply_workflow_ui_rules(wf, update_label=True)
mode_suffix = ""
if isinstance(map_lock, dict) and bool(map_lock.get("freeze_active", False)):
mode_suffix = " [ROTATE HOLD]"
submap_suffix = ""
if isinstance(submap, dict):
if bool(submap.get("enabled", False)) and bool(submap.get("active", False)):
lg = int(submap.get("local_points", 0))
gg = int(submap.get("global_points", 0))
submap_suffix = f" Submap L/G:{lg}/{gg}"
elif bool(submap.get("enabled", False)):
submap_suffix = " Submap ON"
self.lbl_mode.setText(
f"Mode: {self.worker_mode} Rec: {'ON' if self.recording_enabled else 'OFF'} ({self.recording_frames}){mode_suffix}{submap_suffix}"
)
if isinstance(nav, dict):
goal = nav.get("goal", None)
cmd = nav.get("cmd", {}) if isinstance(nav.get("cmd", {}), dict) else {}
lin = float(cmd.get("linear_mps", 0.0))
ang = float(cmd.get("angular_rps", 0.0))
if goal and isinstance(goal, (list, tuple)) and len(goal) >= 2:
gx = float(goal[0]); gy = float(goal[1])
self.lbl_nav.setText(f"Nav: goal {gx:.2f},{gy:.2f} cmd {lin:.2f}/{ang:.2f}")
else:
self.lbl_nav.setText(f"Nav: goal none cmd {lin:.2f}/{ang:.2f}")
if isinstance(ref_match_stats, dict) and ref_match_stats:
matched_ratio = float(ref_match_stats.get("matched_ratio", self.last_loc_match_ratio))
self.last_loc_match_ratio = matched_ratio
matched = int(ref_match_stats.get("matched", 0))
near = int(ref_match_stats.get("near", 0))
unmatched = int(ref_match_stats.get("unmatched", 0))
confirmed = int(ref_match_stats.get("confirmed", 0))
self.lbl_loc_match.setText(
f"Match: {matched_ratio * 100.0:.0f}% C:{confirmed} M:{matched} N:{near} U:{unmatched} Conf:{self.last_loc_confidence:.2f}"
)
elif not self._workflow_requires_ref_map():
self.lbl_loc_match.setText("Match: n/a")
if isinstance(safety, dict) and bool(safety.get("emergency", False)):
reasons = safety.get("reasons", [])
rs = ",".join(str(x) for x in reasons[:2]) if isinstance(reasons, list) else "safety"
self.set_status(f"SAFETY STOP: {rs}", "#ff4444")
def closeEvent(self, event):
try:
if self.recording_enabled:
self.client.record_stop(True, self.txt_name.text().strip() or "slam_recording")
# If save is armed, request one final export before shutting down worker.
self.client.stop_mapping()
if self.request_map_export_if_armed():
end_t = time.monotonic() + 1.2
while time.monotonic() < end_t:
QApplication.processEvents()
time.sleep(0.05)
self.client.stop_process()
except Exception:
pass
event.accept()
def main():
signal.signal(signal.SIGINT, signal.SIG_DFL)
app = QApplication(sys.argv)
app.setStyle("Fusion")
w = UltimateDashboard()
w.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()