new_gui Phase 2: camera ML overlays, controls/screenshot, gonio/beamline video, preconditions
- camera: ML detection boxes+polygons (per-class colours), state-coloured beam centre marker, focus readout - camera controls in tab strip: gain/exposure (samcam_settings), autofocus, screenshot-to-DB - Gonio/Beamline tabs wired to axis IP video via VideoThread - precondition gate (ring/shutter/hutch + 1h snooze) on scans; hutch-PSS mount block; sample-missing modal Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b288c23d1f
commit
5b833bf9eb
@@ -40,6 +40,14 @@ Backend contract is documented in the `new-gui-backend-paths` memory.
|
||||
- **Baton** chip ← `baton_status_changed`.
|
||||
- **Sample list** ← `spreadsheet` → Sample Changer + Library.
|
||||
- **Camera** ← `PredictionSubscriber` (live SAMCAM image, fps, target point).
|
||||
ML detection overlays (boxes + convex-hull polygons, per-class colours), a
|
||||
state-coloured beam-centre marker (`geom.beam_location_pxl`), and a focus
|
||||
readout (`focus_measure`). Camera controls (⚙ gain/exposure → `samcam_settings`,
|
||||
AF → `autofocus`, ◉ → `send_screenshot_db`) live in the tab strip.
|
||||
- **Gonio / Beamline tabs** ← axis IP video via `VideoThread` (`frame_ready`).
|
||||
- **Preconditions**: data-collection actions are gated by a ring/shutter/hutch
|
||||
warning (1-hour snooze); mounting is hard-blocked by the hutch PSS;
|
||||
`sample_missing` shows a modal (suppressed during automation).
|
||||
- **Mount / Unmount** → `mount(SampleShortInfo)` / `unmount()`.
|
||||
- **Motors** → `set_omega_rel`, `move_smargon`, `zoom`, `front_light`, `back_light`,
|
||||
and a staff-only **Energy** control → `change_energy` (keV).
|
||||
|
||||
@@ -83,14 +83,15 @@ Start→`automated_scan`).
|
||||
|
||||
## Proposed phased plan
|
||||
|
||||
**Phase 1 — core operator parity (HIGH value):** D (completed-scan feedback +
|
||||
screening), C (raster results heatmap+table), E (XRF spectrum), K (live
|
||||
automation progress) + the pausable protocol queue. Makes day-to-day collection
|
||||
fully usable.
|
||||
**Phase 1 — core operator parity (HIGH value):** ✅ DONE. D (completed-scan
|
||||
feedback + screening), C (raster results heatmap+table), E (XRF spectrum), K
|
||||
(live automation progress) + the pausable protocol queue. (Deferred: dose/
|
||||
temperature readouts in screening.)
|
||||
|
||||
**Phase 2 — camera & feedback:** A (ML overlays, beam centre, screenshot,
|
||||
**Phase 2 — camera & feedback:** ✅ DONE. A (ML overlays, beam centre, screenshot,
|
||||
focus), B (gonio/beamline video), I (alert banner + precondition dialogs +
|
||||
sample-missing).
|
||||
sample-missing). Still deferred from A: smargon bookmarks (Ctrl-click), helical
|
||||
markers.
|
||||
|
||||
**Phase 3 — staff tooling:** G (beam/detector physics), J (pgroups + sessions),
|
||||
H (recovery + local-contact admin), developer help.
|
||||
|
||||
@@ -57,6 +57,29 @@ def _beamline_defaults():
|
||||
)
|
||||
|
||||
|
||||
def _camera_defaults():
|
||||
"""Resolve (gonio_addr, beamline_addr, gonio_id) for the Gonio/Beamline tabs."""
|
||||
match mx_beamline():
|
||||
case MXBeamline.X06DA:
|
||||
return (
|
||||
cfg_get("gui.cameras.gonio_camera_url", "axis-accc8ed2972e.psi.ch"),
|
||||
cfg_get("gui.cameras.beamline_camera_url", "x06da-axis-1.psi.ch"),
|
||||
int(cfg_get("gui.cameras.gonio_camera_id", 3)),
|
||||
)
|
||||
case MXBeamline.X10SA:
|
||||
return (
|
||||
cfg_get("gui.cameras.gonio_camera_url", "axis-accc8ea5e463.psi.ch"),
|
||||
cfg_get("gui.cameras.beamline_camera_url", "axis-accc8eb02488.psi.ch"),
|
||||
int(cfg_get("gui.cameras.gonio_camera_id", 1)),
|
||||
)
|
||||
case _:
|
||||
return (
|
||||
cfg_get("gui.cameras.gonio_camera_url", ""),
|
||||
cfg_get("gui.cameras.beamline_camera_url", ""),
|
||||
int(cfg_get("gui.cameras.gonio_camera_id", 1)),
|
||||
)
|
||||
|
||||
|
||||
def _clean(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
@@ -114,7 +137,12 @@ def main() -> None:
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
win = MainWindow(base_url=base_url, token=token, pred_zmq_addr=pred_zmq)
|
||||
gonio_addr, beamline_addr, gonio_id = _camera_defaults()
|
||||
win = MainWindow(
|
||||
base_url=base_url, token=token, pred_zmq_addr=pred_zmq,
|
||||
gonio_cam_addr=_clean(gonio_addr), beamline_cam_addr=_clean(beamline_addr),
|
||||
gonio_cam_id=gonio_id,
|
||||
)
|
||||
win.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QTimer, Slot
|
||||
from PySide6.QtWidgets import (
|
||||
QMessageBox,
|
||||
QStackedWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
@@ -24,6 +25,7 @@ from aare.gui.new_gui.status_bar import StatusBar
|
||||
from aare.gui.new_gui.theme import LIGHT, build_qss
|
||||
from aare.gui.new_gui.top_bar import TopBar
|
||||
from aare.gui.new_gui.widgets.alert_banner import AlertBanner
|
||||
from aare.gui.new_gui.widgets import preconditions
|
||||
|
||||
logger = setup_logger("aareGUI.new")
|
||||
|
||||
@@ -49,10 +51,15 @@ def _collect_defaults() -> dict:
|
||||
|
||||
|
||||
class MainWindow(QWidget):
|
||||
def __init__(self, base_url, token, pred_zmq_addr=None, parent=None):
|
||||
def __init__(self, base_url, token, pred_zmq_addr=None,
|
||||
gonio_cam_addr=None, beamline_cam_addr=None, gonio_cam_id=1,
|
||||
parent=None):
|
||||
super().__init__(parent)
|
||||
self._base_url = base_url
|
||||
self._token = token
|
||||
self._gonio_cam_addr = gonio_cam_addr
|
||||
self._beamline_cam_addr = beamline_cam_addr
|
||||
self._gonio_cam_id = gonio_cam_id
|
||||
self._palette = LIGHT
|
||||
self._defaults = _collect_defaults()
|
||||
|
||||
@@ -123,6 +130,28 @@ class MainWindow(QWidget):
|
||||
logger.warning("Could not start camera stream: %s", exc)
|
||||
self.camera_thread = None
|
||||
|
||||
# axis IP video for Gonio / Beamline tabs
|
||||
self.gonio_thread = self._start_video(
|
||||
self._gonio_cam_addr, self.manual.gonio_view, camera=self._gonio_cam_id)
|
||||
self.beamline_thread = self._start_video(
|
||||
self._beamline_cam_addr, self.manual.beamline_view, camera=1)
|
||||
|
||||
def _start_video(self, addr, view, camera=1):
|
||||
if not addr:
|
||||
return None
|
||||
try:
|
||||
from aare.gui.threads.axis_video_thread import VideoThread
|
||||
|
||||
thread = VideoThread(ip=addr, camera=camera)
|
||||
thread.frame_ready.connect(view.update_frame)
|
||||
if hasattr(thread, "error_occurred"):
|
||||
thread.error_occurred.connect(view.set_error)
|
||||
thread.start()
|
||||
return thread
|
||||
except Exception as exc: # pragma: no cover - hardware/runtime path
|
||||
logger.warning("Could not start video (%s): %s", addr, exc)
|
||||
return None
|
||||
|
||||
# -------------------------------------------------------------- wiring
|
||||
def _wire(self) -> None:
|
||||
s = self.state
|
||||
@@ -159,13 +188,22 @@ class MainWindow(QWidget):
|
||||
# manual intents -> DAQ
|
||||
self.manual.center_loop_requested.connect(self.daq.center_loop)
|
||||
self.manual.center_at_point_requested.connect(self._on_center_at_point)
|
||||
# Non-auto raster: smargon_top_left is computed from the drawn grid.
|
||||
self.manual.raster_scan_requested.connect(self.daq.raster_scan)
|
||||
self.manual.rotation_scan_requested.connect(self.daq.standard_scan)
|
||||
# Scans are precondition-gated (ring/shutter/hutch) at the boundary.
|
||||
self.manual.raster_scan_requested.connect(self._do_raster)
|
||||
self.manual.rotation_scan_requested.connect(self._do_rotation)
|
||||
self.manual.smart_params_requested.connect(self._on_smart_params)
|
||||
self.manual.xrf_scan_requested.connect(self.daq.fluorimeter_spectrum)
|
||||
self.manual.xrf_scan_requested.connect(self._do_xrf)
|
||||
self.manual.raster_goto_requested.connect(self.daq.move_smargon)
|
||||
self.manual.status_message.connect(self._note)
|
||||
|
||||
# camera controls
|
||||
cc = self.manual.camera_controls
|
||||
if hasattr(self.daq, "samcam_settings"):
|
||||
cc.samcam_changed.connect(self.daq.samcam_settings)
|
||||
if hasattr(self.daq, "autofocus"):
|
||||
cc.autofocus_requested.connect(self.daq.autofocus)
|
||||
if hasattr(self.daq, "send_screenshot_db"):
|
||||
cc.screenshot_requested.connect(self.daq.send_screenshot_db)
|
||||
if hasattr(self.daq, "raster_scan_completed"):
|
||||
self.daq.raster_scan_completed.connect(self.manual.on_raster_completed)
|
||||
|
||||
@@ -210,7 +248,7 @@ class MainWindow(QWidget):
|
||||
if hasattr(self.daq, "auth_error"):
|
||||
self.daq.auth_error.connect(self._on_auth_error)
|
||||
if hasattr(self.daq, "sample_missing"):
|
||||
self.daq.sample_missing.connect(lambda msg: self._note(msg, error=True))
|
||||
self.daq.sample_missing.connect(self._on_sample_missing)
|
||||
if hasattr(self.daq, "polled_devices_status"):
|
||||
self.daq.polled_devices_status.connect(self._on_device_status)
|
||||
if hasattr(self.daq, "detector_error"):
|
||||
@@ -239,6 +277,12 @@ class MainWindow(QWidget):
|
||||
self.camera_thread.fps_measure.connect(cam.update_fps)
|
||||
if hasattr(self.camera_thread, "target_point"):
|
||||
self.camera_thread.target_point.connect(cam.update_target_point)
|
||||
if hasattr(self.camera_thread, "prediction"):
|
||||
self.camera_thread.prediction.connect(cam.update_predictions)
|
||||
if hasattr(self.camera_thread, "focus_measure"):
|
||||
self.camera_thread.focus_measure.connect(cam.update_focus)
|
||||
if hasattr(self.camera_thread, "enable_focus_measurement"):
|
||||
self.camera_thread.enable_focus_measurement(True)
|
||||
if hasattr(self.camera_thread, "update_daq_status"):
|
||||
self.daq.update.connect(self.camera_thread.update_daq_status)
|
||||
|
||||
@@ -260,10 +304,47 @@ class MainWindow(QWidget):
|
||||
self.manual.changer.set_samples(samples)
|
||||
self.automation.set_samples(samples)
|
||||
|
||||
# ---- precondition-gated actions ----
|
||||
def _precondition_ok(self) -> bool:
|
||||
bl = getattr(self.manual.last_status, "bl", None)
|
||||
if bl is None:
|
||||
return True
|
||||
shutter = getattr(bl, "exp_shutter_open", None)
|
||||
if shutter is None:
|
||||
shutter = getattr(bl, "shutter_open", None)
|
||||
return preconditions.precondition_check(
|
||||
self,
|
||||
ring_current=getattr(bl, "ring_current_mA", None),
|
||||
shutter_open=shutter,
|
||||
door_prohibited=getattr(bl, "pss_prohibited", None),
|
||||
)
|
||||
|
||||
def _do_raster(self, req) -> None:
|
||||
if self._precondition_ok():
|
||||
self.daq.raster_scan(req)
|
||||
|
||||
def _do_rotation(self, req) -> None:
|
||||
if self._precondition_ok():
|
||||
self.daq.standard_scan(req)
|
||||
|
||||
def _do_xrf(self, req) -> None:
|
||||
if self._precondition_ok():
|
||||
self.daq.fluorimeter_spectrum(req)
|
||||
|
||||
@Slot(object, bool)
|
||||
def _on_mount_requested(self, sample, reference: bool = False) -> None:
|
||||
reason = preconditions.hutch_blocks_mount(self.manual.last_status)
|
||||
if reason is not None:
|
||||
QMessageBox.critical(self, "Mounting blocked", reason)
|
||||
self.alert.show_message(reason, "error")
|
||||
return
|
||||
self.daq.mount(sample, reference)
|
||||
|
||||
def _on_sample_missing(self, msg: str) -> None:
|
||||
self.alert.show_message(msg, "error")
|
||||
if not self._auto_running: # don't interrupt automation with a modal
|
||||
QMessageBox.warning(self, "No sample", msg)
|
||||
|
||||
@Slot(object)
|
||||
def _on_reference_tools(self, payload) -> None:
|
||||
tools = getattr(payload, "s", payload)
|
||||
@@ -449,6 +530,8 @@ class MainWindow(QWidget):
|
||||
"""Automate-parameters (simple strategy): forward seeds, then collect."""
|
||||
from aare.gui.new_gui import requests
|
||||
|
||||
if not self._precondition_ok():
|
||||
return
|
||||
if hasattr(self.daq, "smart_params"):
|
||||
self.daq.smart_params(sp)
|
||||
self.daq.standard_scan(requests.rotation_from_simple(sp))
|
||||
@@ -553,9 +636,12 @@ class MainWindow(QWidget):
|
||||
self.daq.cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if self.camera_thread is not None and hasattr(self.camera_thread, "stop"):
|
||||
self.camera_thread.stop()
|
||||
except Exception:
|
||||
pass
|
||||
for thread in (getattr(self, "camera_thread", None),
|
||||
getattr(self, "gonio_thread", None),
|
||||
getattr(self, "beamline_thread", None)):
|
||||
try:
|
||||
if thread is not None and hasattr(thread, "stop"):
|
||||
thread.stop()
|
||||
except Exception:
|
||||
pass
|
||||
super().closeEvent(event)
|
||||
|
||||
@@ -16,12 +16,14 @@ from aare.gui.new_gui import requests
|
||||
from aare.gui.new_gui.state import AppState
|
||||
from aare.gui.new_gui.theme import CAMERA_TABS_H, Palette
|
||||
from aare.gui.new_gui.widgets.camera import CameraViewport
|
||||
from aare.gui.new_gui.widgets.camera_controls import CameraControls
|
||||
from aare.gui.new_gui.widgets.motors_panel import MotorsPanel
|
||||
from aare.gui.new_gui.widgets.pipeline_panel import PipelinePanel
|
||||
from aare.gui.new_gui.widgets.heatmap import build_heatmap_qimage, metric_value
|
||||
from aare.gui.new_gui.widgets.raster_grids_dialog import RasterGridsDialog
|
||||
from aare.gui.new_gui.widgets.sample_changer import SampleChangerPanel, sample_id
|
||||
from aare.gui.new_gui.widgets.spectrum_view import SpectrumView
|
||||
from aare.gui.new_gui.widgets.video_view import VideoView
|
||||
|
||||
|
||||
class ManualView(QWidget):
|
||||
@@ -62,9 +64,13 @@ class ManualView(QWidget):
|
||||
cl.setSpacing(0)
|
||||
self.camera = CameraViewport(palette)
|
||||
self.spectrum = SpectrumView(palette)
|
||||
self.gonio_view = VideoView(palette, "Gonio")
|
||||
self.beamline_view = VideoView(palette, "Beamline")
|
||||
self.center_stack = QStackedWidget()
|
||||
self.center_stack.addWidget(self.camera) # index 0
|
||||
self.center_stack.addWidget(self.spectrum) # index 1
|
||||
self.center_stack.addWidget(self.camera) # index 0
|
||||
self.center_stack.addWidget(self.spectrum) # index 1
|
||||
self.center_stack.addWidget(self.gonio_view) # index 2
|
||||
self.center_stack.addWidget(self.beamline_view) # index 3
|
||||
cl.addWidget(self._build_camera_tabs())
|
||||
cl.addWidget(self.center_stack, 1)
|
||||
|
||||
@@ -95,7 +101,7 @@ class ManualView(QWidget):
|
||||
self._tabs: dict[str, QPushButton] = {}
|
||||
# (label, center-stack index) — Gonio/Beamline still show the camera (Phase 2)
|
||||
for i, (name, idx) in enumerate(
|
||||
(("Sample camera", 0), ("Gonio", 0), ("Beamline", 0), ("XRF spectrum", 1))
|
||||
(("Sample camera", 0), ("Gonio", 2), ("Beamline", 3), ("XRF spectrum", 1))
|
||||
):
|
||||
tab = QPushButton(name)
|
||||
tab.setObjectName("CamTab")
|
||||
@@ -108,6 +114,8 @@ class ManualView(QWidget):
|
||||
self._tabs[name] = tab
|
||||
lay.addWidget(tab)
|
||||
lay.addStretch(1)
|
||||
self.camera_controls = CameraControls(self._p)
|
||||
lay.addWidget(self.camera_controls)
|
||||
return bar
|
||||
|
||||
def show_spectrum(self) -> None:
|
||||
@@ -337,3 +345,10 @@ class ManualView(QWidget):
|
||||
bl = getattr(s, "bl", None)
|
||||
if bl is not None and getattr(bl, "zoom", None) is not None:
|
||||
self.camera.set_zoom(bl.zoom)
|
||||
if bl is not None:
|
||||
self.camera.set_beam_state(
|
||||
getattr(bl, "shutter_open", False), getattr(s, "busy", False))
|
||||
geom = getattr(s, "geom", None)
|
||||
beam = getattr(geom, "beam_location_pxl", None) if geom else None
|
||||
if beam is not None:
|
||||
self.camera.set_beam_center(beam.x, beam.y)
|
||||
|
||||
@@ -28,6 +28,12 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from aare.gui.new_gui.theme import FONT_MONO_FALLBACK, Palette
|
||||
|
||||
# Per-class detection colours (match the existing GUI).
|
||||
_DET_COLORS = {
|
||||
"pin": "#ff5555", "loop_all": "#3fb950", "loop_face": "#d29922",
|
||||
"crystal": "#4d8bff", "needle": "#d24bff", "ice": "#33d6e0",
|
||||
}
|
||||
|
||||
|
||||
class CameraViewport(QWidget):
|
||||
"""Camera image + overlays. Modes: 'empty' | 'picking' | 'mounted'."""
|
||||
@@ -49,6 +55,13 @@ class CameraViewport(QWidget):
|
||||
self._zoom: float = 2.0
|
||||
self._mode = "empty"
|
||||
self._target_point: QPoint | None = None # image-pixel coords
|
||||
self._beam_px: QPoint | None = None # beam centre, image-pixel coords
|
||||
self._beam_shutter = False
|
||||
self._beam_busy = False
|
||||
self._focus: float | None = None
|
||||
self._detections: list = [] # raw boxes from prediction
|
||||
self._det_shape = None # [h, w] of the inference image
|
||||
self._show_detections = True
|
||||
|
||||
# Raster grid-draw state
|
||||
self._grid_draw_enabled = False
|
||||
@@ -233,6 +246,30 @@ class CameraViewport(QWidget):
|
||||
self._target_point = None
|
||||
self.update()
|
||||
|
||||
def set_beam_center(self, x: float, y: float) -> None:
|
||||
self._beam_px = QPoint(int(x), int(y))
|
||||
self.update()
|
||||
|
||||
def set_beam_state(self, shutter_open: bool, busy: bool) -> None:
|
||||
self._beam_shutter = bool(shutter_open)
|
||||
self._beam_busy = bool(busy)
|
||||
self.update()
|
||||
|
||||
def update_focus(self, value: float) -> None:
|
||||
self._focus = value
|
||||
self.update()
|
||||
|
||||
def update_predictions(self, payload: dict) -> None:
|
||||
if not isinstance(payload, dict):
|
||||
return
|
||||
self._detections = payload.get("boxes", []) or []
|
||||
self._det_shape = payload.get("shape")
|
||||
self.update()
|
||||
|
||||
def set_detections_visible(self, visible: bool) -> None:
|
||||
self._show_detections = visible
|
||||
self.update()
|
||||
|
||||
# -------------------------------------------------- coordinate mapping
|
||||
def _widget_to_image(self, pt: QPoint) -> QPoint:
|
||||
if self._draw_rect.width() <= 0 or self._draw_rect.height() <= 0:
|
||||
@@ -349,15 +386,82 @@ class CameraViewport(QWidget):
|
||||
if self._mode == "mounted":
|
||||
self._paint_mounted_overlays(painter, r)
|
||||
|
||||
if self._show_detections and self._mode == "mounted":
|
||||
self._paint_detections(painter)
|
||||
|
||||
self._paint_grid(painter)
|
||||
|
||||
def _paint_detections(self, painter: QPainter) -> None:
|
||||
if not self._detections or self._pixmap is None or self._pixmap.isNull():
|
||||
return
|
||||
# Map detection-image coords -> our image coords (handles differing inference res).
|
||||
det_w = det_h = None
|
||||
if self._det_shape and len(self._det_shape) >= 2:
|
||||
det_h, det_w = float(self._det_shape[0]), float(self._det_shape[1])
|
||||
sx = self._img_w / det_w if det_w else 1.0
|
||||
sy = self._img_h / det_h if det_h else 1.0
|
||||
f = QFont()
|
||||
f.setPixelSize(10)
|
||||
painter.setFont(f)
|
||||
for d in self._detections:
|
||||
try:
|
||||
x1, y1 = float(d["x1"]) * sx, float(d["y1"]) * sy
|
||||
x2, y2 = float(d["x2"]) * sx, float(d["y2"]) * sy
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
label = str(d.get("label", ""))
|
||||
color = QColor(_DET_COLORS.get(label, "#cccccc"))
|
||||
tl = self._image_to_widget(QPoint(int(x1), int(y1)))
|
||||
br = self._image_to_widget(QPoint(int(x2), int(y2)))
|
||||
rect = QRect(tl, br).normalized()
|
||||
painter.setBrush(Qt.NoBrush)
|
||||
painter.setPen(QPen(color, 2))
|
||||
painter.drawRect(rect)
|
||||
# polygon (points are relative to the box origin in detection coords)
|
||||
poly = d.get("poly")
|
||||
if poly:
|
||||
from PySide6.QtGui import QPolygon
|
||||
pts = []
|
||||
for px, py in poly:
|
||||
ix = int((float(px) + float(d["x1"])) * sx)
|
||||
iy = int((float(py) + float(d["y1"])) * sy)
|
||||
pts.append(self._image_to_widget(QPoint(ix, iy)))
|
||||
if len(pts) >= 3:
|
||||
faint = QColor(color)
|
||||
faint.setAlpha(150)
|
||||
painter.setPen(QPen(faint, 1))
|
||||
painter.drawPolygon(QPolygon(pts))
|
||||
conf = d.get("conf")
|
||||
tag = f"{label} {conf:.2f}" if isinstance(conf, (int, float)) else label
|
||||
painter.setPen(color)
|
||||
painter.drawText(rect.left(), rect.top() - 3, tag)
|
||||
|
||||
def _paint_crosshair(self, painter: QPainter, r: QRect) -> None:
|
||||
pen = QPen(QColor(255, 255, 255, 46))
|
||||
pen.setWidth(1)
|
||||
painter.setPen(pen)
|
||||
cx, cy = r.width() // 2, r.height() // 2
|
||||
painter.drawLine(cx, cy - 21, cx, cy + 21)
|
||||
painter.drawLine(cx - 21, cy, cx + 21, cy)
|
||||
# Beam-centre crosshair at the real beam pixel when known, else viewport centre.
|
||||
if self._beam_px is not None and self._pixmap is not None \
|
||||
and not self._pixmap.isNull():
|
||||
c = self._image_to_widget(self._beam_px)
|
||||
# colour by state: shutter open = green, busy = red, idle = orange
|
||||
if self._beam_busy:
|
||||
col = QColor(255, 0, 0)
|
||||
elif self._beam_shutter:
|
||||
col = QColor(0, 200, 0)
|
||||
else:
|
||||
col = QColor(245, 121, 0)
|
||||
pen = QPen(col)
|
||||
pen.setWidth(2)
|
||||
painter.setPen(pen)
|
||||
painter.drawLine(c.x(), c.y() - 14, c.x(), c.y() + 14)
|
||||
painter.drawLine(c.x() - 14, c.y(), c.x() + 14, c.y())
|
||||
painter.setBrush(Qt.NoBrush)
|
||||
painter.drawEllipse(c, 7, 7)
|
||||
else:
|
||||
pen = QPen(QColor(255, 255, 255, 46))
|
||||
pen.setWidth(1)
|
||||
painter.setPen(pen)
|
||||
cx, cy = r.width() // 2, r.height() // 2
|
||||
painter.drawLine(cx, cy - 21, cx, cy + 21)
|
||||
painter.drawLine(cx - 21, cy, cx + 21, cy)
|
||||
|
||||
def _paint_overlay_text(self, painter: QPainter, r: QRect) -> None:
|
||||
painter.setPen(QColor("#867f96"))
|
||||
@@ -366,6 +470,8 @@ class CameraViewport(QWidget):
|
||||
painter.setFont(f)
|
||||
fps = "-" if self._fps != self._fps else f"{self._fps:.0f}" # NaN-safe
|
||||
text = f"SAMCAM · {fps} fps · {self._zoom:.1f}×"
|
||||
if self._focus is not None and self._focus == self._focus: # NaN-safe
|
||||
text += f" · focus {self._focus:.0f}"
|
||||
painter.drawText(QRect(0, 8, r.width() - 14, 16),
|
||||
Qt.AlignRight | Qt.AlignTop, text)
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Compact sample-camera controls: settings (gain/exposure), autofocus, screenshot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from aare.common.models import AutofocusSettings, SampleCameraSettings
|
||||
from aare.gui.new_gui.theme import Palette
|
||||
|
||||
|
||||
class CameraControls(QWidget):
|
||||
"""Lives in the camera tab strip. Emits backend-ready models."""
|
||||
|
||||
samcam_changed = Signal(object) # SampleCameraSettings
|
||||
autofocus_requested = Signal(object) # AutofocusSettings
|
||||
screenshot_requested = Signal(str, str) # filename, message
|
||||
|
||||
def __init__(self, palette: Palette, parent=None):
|
||||
super().__init__(parent)
|
||||
self._p = palette
|
||||
lay = QHBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.setSpacing(6)
|
||||
for text, tip, slot in (
|
||||
("⚙", "Camera settings (gain / exposure)", self._open_settings),
|
||||
("AF", "Autofocus", self._on_autofocus),
|
||||
("◉", "Screenshot to database", self._open_screenshot),
|
||||
):
|
||||
b = QPushButton(text)
|
||||
b.setCursor(Qt.PointingHandCursor)
|
||||
b.setToolTip(tip)
|
||||
b.setFixedHeight(26)
|
||||
b.setStyleSheet(
|
||||
f"QPushButton {{ background:{palette.surface};"
|
||||
f" border:1px solid {palette.border_control}; border-radius:6px;"
|
||||
f" padding:3px 10px; font-size:12px; color:{palette.text_secondary}; }}"
|
||||
)
|
||||
b.clicked.connect(slot)
|
||||
lay.addWidget(b)
|
||||
|
||||
def _open_settings(self) -> None:
|
||||
dlg = QDialog(self)
|
||||
dlg.setWindowTitle("Camera settings")
|
||||
form = QFormLayout(dlg)
|
||||
gain = QDoubleSpinBox()
|
||||
gain.setRange(0, 1000)
|
||||
gain.setValue(1.0)
|
||||
exposure = QDoubleSpinBox()
|
||||
exposure.setRange(0.0, 1.0)
|
||||
exposure.setDecimals(4)
|
||||
exposure.setSingleStep(0.001)
|
||||
exposure.setValue(0.02)
|
||||
form.addRow("Gain", gain)
|
||||
form.addRow("Exposure (s)", exposure)
|
||||
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
form.addRow(bb)
|
||||
bb.accepted.connect(dlg.accept)
|
||||
bb.rejected.connect(dlg.reject)
|
||||
if dlg.exec() == QDialog.Accepted:
|
||||
self.samcam_changed.emit(
|
||||
SampleCameraSettings(gain=gain.value(), exposure=exposure.value())
|
||||
)
|
||||
|
||||
def _on_autofocus(self) -> None:
|
||||
self.autofocus_requested.emit(
|
||||
AutofocusSettings(center_x_pxl=None, center_y_pxl=None,
|
||||
radius_pxl=30, z_range_um=2000, z_steps=10)
|
||||
)
|
||||
|
||||
def _open_screenshot(self) -> None:
|
||||
dlg = QDialog(self)
|
||||
dlg.setWindowTitle("Screenshot to database")
|
||||
form = QFormLayout(dlg)
|
||||
fn = QLineEdit()
|
||||
fn.setPlaceholderText("optional")
|
||||
msg = QLineEdit()
|
||||
msg.setPlaceholderText("optional")
|
||||
form.addRow("Filename", fn)
|
||||
form.addRow("Message", msg)
|
||||
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
form.addRow(bb)
|
||||
bb.accepted.connect(dlg.accept)
|
||||
bb.rejected.connect(dlg.reject)
|
||||
if dlg.exec() == QDialog.Accepted:
|
||||
self.screenshot_requested.emit(fn.text(), msg.text())
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Pre-action safety checks: beamline preconditions + hutch-PSS mount block.
|
||||
|
||||
Mirrors the existing GUI's message_box: before a data-collection action we warn
|
||||
if the ring current is low, the experiment shutter is closed, or the hutch isn't
|
||||
in the prohibited (searched/closed) state — with a 1-hour snooze. Mounting is
|
||||
hard-blocked by the hutch PSS. Only *explicitly* bad values raise a warning, so
|
||||
missing/unknown fields (e.g. SIMULATED) don't nag.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from PySide6.QtWidgets import QCheckBox, QMessageBox
|
||||
|
||||
LOW_CURRENT_THRESHOLD = 100.0 # mA
|
||||
SNOOZE_SECONDS = 3600.0
|
||||
|
||||
_snooze_until = 0.0
|
||||
|
||||
|
||||
def _snoozed() -> bool:
|
||||
return time.time() < _snooze_until
|
||||
|
||||
|
||||
def _snooze() -> None:
|
||||
global _snooze_until
|
||||
_snooze_until = time.time() + SNOOZE_SECONDS
|
||||
|
||||
|
||||
def precondition_problems(ring_current, shutter_open, door_prohibited) -> list[str]:
|
||||
problems: list[str] = []
|
||||
if ring_current is not None and ring_current < LOW_CURRENT_THRESHOLD:
|
||||
problems.append(f"Ring current is low: {round(ring_current, 2)} mA.")
|
||||
if shutter_open is False:
|
||||
problems.append("Experiment safety shutter is closed.")
|
||||
if door_prohibited is False:
|
||||
problems.append("Hutch is not searched/closed (door safety not active).")
|
||||
return problems
|
||||
|
||||
|
||||
def precondition_check(parent, *, ring_current, shutter_open, door_prohibited) -> bool:
|
||||
"""Return True to proceed. Shows a Yes/No warning if there are problems."""
|
||||
if _snoozed():
|
||||
return True
|
||||
problems = precondition_problems(ring_current, shutter_open, door_prohibited)
|
||||
if not problems:
|
||||
return True
|
||||
box = QMessageBox(parent)
|
||||
box.setIcon(QMessageBox.Icon.Warning)
|
||||
box.setWindowTitle("Beamline not ready")
|
||||
box.setText("\n".join(problems) + "\n\nDo you wish to continue?")
|
||||
box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
snooze_cb = QCheckBox("Don't ask again for 1 hour")
|
||||
box.setCheckBox(snooze_cb)
|
||||
proceed = box.exec() == QMessageBox.StandardButton.Yes
|
||||
if proceed and snooze_cb.isChecked():
|
||||
_snooze()
|
||||
return proceed
|
||||
|
||||
|
||||
def hutch_blocks_mount(status) -> str | None:
|
||||
"""Reason the hutch PSS blocks a mount, or None if OK."""
|
||||
bl = getattr(status, "bl", None)
|
||||
if bl is None:
|
||||
return None
|
||||
if getattr(bl, "pss_prohibited", None) is False:
|
||||
return ("Door safety is not active: close the hutch doors and complete "
|
||||
"the safety search before mounting.")
|
||||
if getattr(bl, "pss_alarm", False):
|
||||
return "The hutch safety alarm is active. Mounting is blocked until it clears."
|
||||
return None
|
||||
@@ -0,0 +1,47 @@
|
||||
"""A simple axis-camera video view: paints the latest QImage, aspect-fit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Slot
|
||||
from PySide6.QtGui import QColor, QImage, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from aare.gui.new_gui.theme import Palette
|
||||
|
||||
|
||||
class VideoView(QWidget):
|
||||
"""Displays frames from a VideoThread (frame_ready: QImage)."""
|
||||
|
||||
def __init__(self, palette: Palette, label: str = "Camera", parent=None):
|
||||
super().__init__(parent)
|
||||
self._p = palette
|
||||
self._label = label
|
||||
self._pixmap: QPixmap | None = None
|
||||
self._error: str | None = None
|
||||
self.setMinimumHeight(220)
|
||||
|
||||
@Slot(QImage)
|
||||
def update_frame(self, image: QImage) -> None:
|
||||
if image is not None and not image.isNull():
|
||||
self._pixmap = QPixmap.fromImage(image)
|
||||
self._error = None
|
||||
self.update()
|
||||
|
||||
@Slot(str)
|
||||
def set_error(self, message: str) -> None:
|
||||
self._error = message
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event): # noqa: N802
|
||||
painter = QPainter(self)
|
||||
r = self.rect()
|
||||
painter.fillRect(r, QColor(self._p.cam_stop2))
|
||||
if self._pixmap is not None and not self._pixmap.isNull():
|
||||
scaled = self._pixmap.scaled(
|
||||
r.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
painter.drawPixmap((r.width() - scaled.width()) // 2,
|
||||
(r.height() - scaled.height()) // 2, scaled)
|
||||
else:
|
||||
painter.setPen(QColor("#867f96"))
|
||||
painter.drawText(r, Qt.AlignCenter,
|
||||
self._error or f"{self._label} — no feed")
|
||||
Reference in New Issue
Block a user