new_gui: live smargon-trace dialog + camera fast-scaling (lag fix)

- replace CSV-polling smargon trace with a live QtCharts dialog (deltaX/Y/Z +
  distance from a reference, fed by the status stream); launch from camera strip
- camera live feed uses fast (nearest) scaling instead of smooth — smooth
  rescaling every frame caused lag at low frame rates / long exposures

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
appleb_m
2026-06-24 22:27:42 +02:00
co-authored by Claude Opus 4.8
parent 49dfb40eff
commit 53aeabd99e
5 changed files with 135 additions and 3 deletions
+5 -2
View File
@@ -44,8 +44,11 @@ Backend contract is documented in the `new-gui-backend-paths` memory.
(new — smargon-z move), staff-only **ABR** (GMX/GMY/GMZ + save/goto) under
SMARGON. Energy lives in the staff Tools dialog (not the motors panel).
- **Camera exp/gain** are inline in the camera tab strip (Exp/Gain → `samcam_settings`).
- **Prediction metrics** dialog (per-class counts, confidence, FPS) from the
prediction stream (launched from the camera strip). (smargon-trace still deferred.)
- **Prediction metrics** dialog (per-class counts, confidence, FPS) and a **live
smargon-trace** dialog (ΔX/ΔY/ΔZ + distance from a reference, fed by the status
stream — no CSV) from the camera strip.
- Live camera uses fast (nearest) scaling to avoid lag at low frame rates / long
exposures.
- **Polish (Phase 4)**: keyboard shortcuts (Ctrl+1/2 mode, Alt+1-4 camera tabs,
Ctrl+T tools, F1 help, Ctrl+Q quit); window-geometry + mode persistence
(QSettings); a **filename builder** (prefix + auto-incrementing run number) that
+9
View File
@@ -217,6 +217,7 @@ class MainWindow(QWidget):
cc.face_detect_clicked.connect(self._open_face_detection)
cc.stability_clicked.connect(self._open_stability)
cc.metrics_clicked.connect(self._open_metrics)
cc.trace_clicked.connect(self._open_trace)
if hasattr(self.daq, "raster_scan_completed"):
self.daq.raster_scan_completed.connect(self.manual.on_raster_completed)
@@ -414,6 +415,14 @@ class MainWindow(QWidget):
self._stability_dialog.show()
self._stability_dialog.raise_()
def _open_trace(self) -> None:
from aare.gui.new_gui.widgets.smargon_trace_dialog import SmargonTraceDialog
if getattr(self, "_trace_dialog", None) is None:
self._trace_dialog = SmargonTraceDialog(self._palette, self)
self.daq.update.connect(self._trace_dialog.update_daq_status)
self._trace_dialog.show()
self._trace_dialog.raise_()
def _open_metrics(self) -> None:
from aare.gui.new_gui.widgets.prediction_metrics_dialog import (
PredictionMetricsDialog,
+4 -1
View File
@@ -362,8 +362,11 @@ class CameraViewport(QWidget):
r = self.rect()
if self._pixmap is not None and not self._pixmap.isNull():
# Fast (nearest) scaling for the live feed — smooth scaling every
# frame is the main source of perceived camera lag, especially at
# low frame rates / long exposures.
scaled = self._pixmap.scaled(
r.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation
r.size(), Qt.KeepAspectRatio, Qt.FastTransformation
)
x = (r.width() - scaled.width()) // 2
y = (r.height() - scaled.height()) // 2
@@ -27,6 +27,7 @@ class CameraControls(QWidget):
face_detect_clicked = Signal()
stability_clicked = Signal()
metrics_clicked = Signal()
trace_clicked = Signal()
def __init__(self, palette: Palette, parent=None):
super().__init__(parent)
@@ -62,6 +63,7 @@ class CameraControls(QWidget):
("⊙ Face", "Face detection", self.face_detect_clicked.emit),
("∿ Stability", "Target stability", self.stability_clicked.emit),
("◧ Metrics", "Prediction metrics", self.metrics_clicked.emit),
("⤳ Trace", "Smargon trace", self.trace_clicked.emit),
):
b = QPushButton(text)
b.setCursor(Qt.PointingHandCursor)
@@ -0,0 +1,115 @@
"""Live smargon trace — plots sample-holder drift from a reference over time.
Driven by the status stream (no CSV file polling): captures a reference sh_mm,
then plots ΔX/ΔY/ΔZ (and total distance) vs time with QtCharts.
"""
from __future__ import annotations
import math
import time
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QValueAxis
from PySide6.QtCore import Qt
from PySide6.QtGui import QPainter
from PySide6.QtWidgets import (
QDialog,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
)
from aare.gui.new_gui.theme import build_qss
class SmargonTraceDialog(QDialog):
def __init__(self, palette, parent=None):
super().__init__(parent)
self.setWindowTitle("Smargon trace")
self.setModal(False)
self.resize(640, 440)
self.setStyleSheet(build_qss(palette))
self._ref = None
self._t0 = time.monotonic()
self._last = 0.0
self._maxpts = 600
lay = QVBoxLayout(self)
top = QHBoxLayout()
self._stat = QLabel("Waiting for smargon position…")
self._stat.setStyleSheet(f"color:{palette.text_muted};")
top.addWidget(self._stat, 1)
ref = QPushButton("Set reference"); ref.clicked.connect(self._set_ref)
clr = QPushButton("Clear"); clr.clicked.connect(self._clear)
for b in (ref, clr):
b.setCursor(Qt.PointingHandCursor)
top.addWidget(ref); top.addWidget(clr)
lay.addLayout(top)
self._sx = QLineSeries(); self._sx.setName("ΔX")
self._sy = QLineSeries(); self._sy.setName("ΔY")
self._sz = QLineSeries(); self._sz.setName("ΔZ")
self._sd = QLineSeries(); self._sd.setName("Distance")
self._chart = QChart()
for s in (self._sx, self._sy, self._sz, self._sd):
self._chart.addSeries(s)
self._ax = QValueAxis(); self._ax.setTitleText("Time [s]")
self._ay = QValueAxis(); self._ay.setTitleText("Δ [mm]")
self._chart.addAxis(self._ax, Qt.AlignBottom)
self._chart.addAxis(self._ay, Qt.AlignLeft)
for s in (self._sx, self._sy, self._sz, self._sd):
s.attachAxis(self._ax); s.attachAxis(self._ay)
view = QChartView(self._chart); view.setRenderHint(QPainter.Antialiasing)
lay.addWidget(view, 1)
def _set_ref(self) -> None:
self._ref = None # re-captured on next status
def _clear(self) -> None:
for s in (self._sx, self._sy, self._sz, self._sd):
s.clear()
self._ref = None
self._t0 = time.monotonic()
def update_daq_status(self, s) -> None:
if not self.isVisible():
return
now = time.monotonic()
if now - self._last < 0.2:
return
self._last = now
geom = getattr(s, "geom", None)
smg = getattr(geom, "smargon", None) if geom else None
sh = getattr(smg, "sh_mm", None) if smg else None
if sh is None:
return
cur = (float(sh.x), float(sh.y), float(sh.z))
if self._ref is None:
self._ref = cur
dx, dy, dz = (cur[0] - self._ref[0], cur[1] - self._ref[1], cur[2] - self._ref[2])
dist = math.sqrt(dx * dx + dy * dy + dz * dz)
t = now - self._t0
for series, val in ((self._sx, dx), (self._sy, dy), (self._sz, dz),
(self._sd, dist)):
series.append(t, val)
if series.count() > self._maxpts:
series.remove(0)
self._stat.setText(
f"ΔX {dx:+.4f} ΔY {dy:+.4f} ΔZ {dz:+.4f} · dist {dist:.4f} mm")
self._rescale()
def _rescale(self) -> None:
pts = self._sd.pointsVector() if hasattr(self._sd, "pointsVector") else self._sd.points()
if not pts:
return
xs = [p.x() for p in pts]
self._ax.setRange(min(xs), max(xs) + 0.01)
vals = []
for series in (self._sx, self._sy, self._sz, self._sd):
sp = series.points()
vals.extend(p.y() for p in sp)
if vals:
lo, hi = min(vals), max(vals)
pad = max(0.001, (hi - lo) * 0.1)
self._ay.setRange(lo - pad, hi + pad)