From 53aeabd99e68334c658daed1a2d6ef3e23a8f2d5 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Wed, 24 Jun 2026 22:27:42 +0200 Subject: [PATCH] new_gui: live smargon-trace dialog + camera fast-scaling (lag fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/aare/gui/new_gui/README.md | 7 +- src/aare/gui/new_gui/main_window.py | 9 ++ src/aare/gui/new_gui/widgets/camera.py | 5 +- .../gui/new_gui/widgets/camera_controls.py | 2 + .../new_gui/widgets/smargon_trace_dialog.py | 115 ++++++++++++++++++ 5 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 src/aare/gui/new_gui/widgets/smargon_trace_dialog.py diff --git a/src/aare/gui/new_gui/README.md b/src/aare/gui/new_gui/README.md index 7b0dc11d..c4210699 100644 --- a/src/aare/gui/new_gui/README.md +++ b/src/aare/gui/new_gui/README.md @@ -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 diff --git a/src/aare/gui/new_gui/main_window.py b/src/aare/gui/new_gui/main_window.py index 98a79ce7..010a26ce 100644 --- a/src/aare/gui/new_gui/main_window.py +++ b/src/aare/gui/new_gui/main_window.py @@ -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, diff --git a/src/aare/gui/new_gui/widgets/camera.py b/src/aare/gui/new_gui/widgets/camera.py index d9147ecd..ef2426b4 100644 --- a/src/aare/gui/new_gui/widgets/camera.py +++ b/src/aare/gui/new_gui/widgets/camera.py @@ -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 diff --git a/src/aare/gui/new_gui/widgets/camera_controls.py b/src/aare/gui/new_gui/widgets/camera_controls.py index 55ae0a2f..692ed0d9 100644 --- a/src/aare/gui/new_gui/widgets/camera_controls.py +++ b/src/aare/gui/new_gui/widgets/camera_controls.py @@ -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) diff --git a/src/aare/gui/new_gui/widgets/smargon_trace_dialog.py b/src/aare/gui/new_gui/widgets/smargon_trace_dialog.py new file mode 100644 index 00000000..a32eaeaf --- /dev/null +++ b/src/aare/gui/new_gui/widgets/smargon_trace_dialog.py @@ -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)