GUI: added protection against smargon and raster moves while camera is off

This commit is contained in:
2026-04-20 15:21:43 +02:00
parent 0bbe070776
commit a0aff79675
3 changed files with 158 additions and 9 deletions
+33 -1
View File
@@ -82,6 +82,8 @@ class MainWindow(QMainWindow):
self.__base_url = base_url
self.__token = token
self.__mounting = False
self.__samcam_feed_banner_active = False
self.__samcam_feed_banner_message = "Sample camera feed unavailable"
self._dev_help_dialog = None
self._beamline_recovery_dialog = None
self._controls_help_dialog = None
@@ -388,8 +390,13 @@ class MainWindow(QMainWindow):
self.camera_thread.start()
self.camera_thread.focus_measure.connect(self.status_bar.update_sharpness)
self.camera_thread.fps_measure.connect(self.status_bar.update_samcam_fps)
self.camera_thread.camera_availability_changed.connect(self.sample_camera.set_camera_available)
self.camera_thread.camera_availability_changed.connect(self._on_sample_camera_availability_changed)
self.camera_thread.camera_error.connect(self._on_sample_camera_error)
else:
self.camera_thread = None
self.sample_camera.set_camera_available(False)
self._show_samcam_feed_banner("Sample camera feed unavailable: no stream configured")
# Prediction subscriber thread
if pred_zmq_addr is not None:
@@ -499,7 +506,8 @@ class MainWindow(QMainWindow):
self.daq.update.connect(self.sample_camera.update_daq_status)
self.daq.update.connect(self.tell_samples.update_daq_status)
self.daq.update.connect(self.ref_tools_panel.update_daq_status)
self.daq.update.connect(self.camera_thread.update_daq_status)
if self.camera_thread is not None:
self.daq.update.connect(self.camera_thread.update_daq_status)
if self.__decoded_token.staff:
self.daq.update.connect(self.beamline.beam_size.update_daq_status)
@@ -612,6 +620,30 @@ class MainWindow(QMainWindow):
age_s = time.monotonic() - self._last_pred_image_ts
self._pred_is_preferred = age_s <= self._pred_preferred_timeout_s
@Slot(bool)
def _on_sample_camera_availability_changed(self, available: bool) -> None:
if available:
self._clear_samcam_feed_banner()
return
self._show_samcam_feed_banner("Sample camera feed unavailable")
@Slot(str)
def _on_sample_camera_error(self, message: str) -> None:
logger.warning(message)
self._show_samcam_feed_banner(message or "Sample camera feed unavailable")
def _show_samcam_feed_banner(self, message: str) -> None:
self.__samcam_feed_banner_message = message
self.alert_banner_secondary.show_message(message, True)
self.__samcam_feed_banner_active = True
def _clear_samcam_feed_banner(self) -> None:
if not self.__samcam_feed_banner_active:
return
self.alert_banner_secondary.clear_message()
self.__samcam_feed_banner_active = False
def start_text_tutorial(self) -> None:
self.tutorial_manager.start("manual_workflow_demo")
+30 -6
View File
@@ -15,6 +15,8 @@ class SampleCameraThread(QThread):
camera_image = Signal(QPixmap)
focus_measure = Signal(float) # Emits Laplacian variance (higher = sharper)
fps_measure = Signal(float)
camera_availability_changed = Signal(bool)
camera_error = Signal(str)
def __init__(self, zmq_url: str, parent=None):
super().__init__(parent)
@@ -38,6 +40,21 @@ class SampleCameraThread(QThread):
self.__last_frame_time = None
self.__no_frame_timeout_s = 5.0
self.__camera_available = False
self.__last_camera_error: str | None = None
def __set_camera_available(self, available: bool, error: str | None = None) -> None:
if available != self.__camera_available:
self.__camera_available = available
self.camera_availability_changed.emit(available)
if error is not None and error != self.__last_camera_error:
self.__last_camera_error = error
self.camera_error.emit(error)
if available:
self.__last_camera_error = None
@Slot(DAQStatusModel)
def update_daq_status(self, s: DAQStatusModel):
self.__beam_x = s.geom.beam_location_pxl.x
@@ -89,7 +106,7 @@ class SampleCameraThread(QThread):
encoded = np.frombuffer(data, dtype=np.uint8)
bgr = cv2.imdecode(encoded, cv2.IMREAD_COLOR)
if bgr is None:
print("Failed to decode JPEG image from zmq stream")
self.__set_camera_available(False, "Sample camera feed unavailable: failed to decode JPEG frame")
continue
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
elif "shape" in header:
@@ -97,9 +114,11 @@ class SampleCameraThread(QThread):
raw = np.frombuffer(data, np.uint8).reshape((h, w))
rgb = cv2.cvtColor(raw, cv2.COLOR_BAYER_GB2RGB)
else:
print(f"Unsupported header in zmq stream: {header}")
self.__set_camera_available(False, f"Sample camera feed unavailable: unsupported frame header {header}")
continue
self.__set_camera_available(True)
if self.__measure_focus:
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
if self.__focus_mask is None or self.__focus_mask.shape != gray.shape:
@@ -114,19 +133,24 @@ class SampleCameraThread(QThread):
qimage = QImage(rgb.data, rgb.shape[1], rgb.shape[0], QImage.Format.Format_RGB888).copy()
self.camera_image.emit(QPixmap.fromImage(qimage))
else:
print("NO HEADER in zmq stream")
self.__set_camera_available(False, "Sample camera feed unavailable: no frame header in zmq stream")
except zmq.Again: # Timeout occurred
now = time.perf_counter()
elapsed = now - self.__fps_window_start
if elapsed >= self.__fps_emit_period_s:
last = self.__fps_frame_count
no_frames_long = (last is None) or ((now - last) >= self.__no_frame_timeout_s)
no_frames_long = (
self.__last_frame_time is None
or (now - self.__last_frame_time) >= self.__no_frame_timeout_s
)
self.fps_measure.emit(float("nan") if no_frames_long else 0.0)
self.__fps_window_start = now
self.__fps_frame_count = 0
if no_frames_long:
self.__set_camera_available(False, "Sample camera feed unavailable")
continue # Check self.running again
except Exception as e:
print(f"Error in sample camera thread {e}")
self.__set_camera_available(False, f"Sample camera feed unavailable: {e}")
def stop(self):
# Signal the thread to stop
+95 -2
View File
@@ -72,6 +72,7 @@ class SampleCameraImageLabel(QGraphicsView):
self.__session_state: SessionsStateEnum | None = None
self.__sam_cam = SampleCameraSettings(exposure=0.1, gain=100.0)
self.__is_daq_busy = False
self.__camera_available = True
self.__geom = geom
self.__bookmarks: SmargonBookmarkList = SmargonBookmarkList()
@@ -91,6 +92,7 @@ class SampleCameraImageLabel(QGraphicsView):
self.setScene(self.scene)
self.setMouseTracking(True)
self.__update_camera_interaction_feedback()
if default_image is not None:
pixmap = QPixmap(default_image)
@@ -125,6 +127,30 @@ class SampleCameraImageLabel(QGraphicsView):
self.__detections = [] # list of dicts from publisher
self.__det_shape = None # shape from payload [h,w] so we can scale
def __update_camera_interaction_feedback(self) -> None:
if self.__camera_available:
self.viewport().setCursor(Qt.CursorShape.ArrowCursor)
self.setToolTip("")
else:
self.viewport().setCursor(Qt.CursorShape.ForbiddenCursor)
self.setToolTip("Sample camera feed unavailable")
def __show_camera_unavailable_tooltip(self, event, action: str = "Sample camera interaction") -> None:
QToolTip.showText(
self.mapToGlobal(event.pos()),
f"{action} disabled: sample camera feed unavailable",
self,
)
def __camera_interaction_enabled(self) -> bool:
return self.__camera_available
@Slot(bool)
def set_camera_available(self, available: bool):
self.__camera_available = available
self.__update_camera_interaction_feedback()
self.update()
@Slot(dict)
def update_detections(self, payload: dict):
# payload: { 'time', 'frame_id', 'shape':[h,w], 'boxes':[{'x1',...,'label','conf'}] }
@@ -227,6 +253,42 @@ class SampleCameraImageLabel(QGraphicsView):
painter.restore()
def __draw_camera_unavailable_overlay(self, painter: QPainter):
if self.__camera_available:
return
painter.save()
painter.resetTransform()
font = QFont()
font.setPointSize(22)
font.setBold(True)
painter.setFont(font)
text = "Sample camera feed unavailable"
fm = QFontMetrics(font)
text_rect = fm.boundingRect(text)
padding = 16
position_x = 50
position_y = 120
bg_rect = QRect(
position_x - padding,
position_y - padding,
text_rect.width() + 2 * padding,
text_rect.height() + 2 * padding,
)
painter.setPen(QPen(QColor(255, 255, 255, 220), 2))
painter.setBrush(QColor(180, 60, 0, 180))
painter.drawRoundedRect(bg_rect, 10, 10)
painter.setPen(QPen(QColor(255, 255, 255)))
painter.drawText(QPoint(position_x, position_y + fm.ascent()), text)
painter.restore()
def drawForeground(self, painter, rect):
self.__draw_ml_bounding_box(painter)
self.__draw_beam_center(painter)
@@ -234,6 +296,7 @@ class SampleCameraImageLabel(QGraphicsView):
self.__draw_helical(painter)
self.__draw_busy_overlay(painter)
self.__draw_session_overlay(painter)
self.__draw_camera_unavailable_overlay(painter)
self.__draw_detections(painter, rect)
def resizeEvent(self, event):
@@ -241,6 +304,12 @@ class SampleCameraImageLabel(QGraphicsView):
self.__scaling()
def mousePressEvent(self, event):
if not self.__camera_interaction_enabled():
if event.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.RightButton):
self.__show_camera_unavailable_tooltip(event)
event.accept()
return
self.start_point = self.mapToScene(event.pos())
match self.__state:
@@ -272,6 +341,9 @@ class SampleCameraImageLabel(QGraphicsView):
self.__raster_mgr.resize_active_grid(self.end_point)
def mouseMoveEvent(self, event):
if not self.__camera_interaction_enabled():
return
mouse_pos = self.mapToScene(event.pos())
if self.__state == SampleCameraImageState.IDLE:
@@ -299,12 +371,24 @@ class SampleCameraImageLabel(QGraphicsView):
self.update()
def __on_raster_timer_timeout(self):
if self.__pending_load_pos is not None:
self.load_image.emit(self.__pending_load_pos)
self.__pending_load_pos = None
return
if self.__pending_load_pos is not None:
self.load_image.emit(self.__pending_load_pos)
self.__pending_load_pos = None
self.raster_timer.start(self.raster_timer_interval)
def mouseReleaseEvent(self, event):
if not self.__camera_interaction_enabled():
self.click_timer.stop()
if self.__state != SampleCameraImageState.BEAM_MARKING:
self.__state = SampleCameraImageState.IDLE
event.accept()
return
if event.button() == Qt.MouseButton.LeftButton:
if self.click_timer.isActive():
self.click_timer.stop()
@@ -323,6 +407,9 @@ class SampleCameraImageLabel(QGraphicsView):
self.__state = SampleCameraImageState.IDLE
def __right_click_menu(self, event):
if not self.__camera_interaction_enabled():
self.__show_camera_unavailable_tooltip(event)
return
menu = QMenu(self)
@@ -373,7 +460,6 @@ class SampleCameraImageLabel(QGraphicsView):
elif action == grab_with_overlay_action:
self.__screenshot_with_dialog(overlay=True)
elif action == autofocus_action:
c = self.mapToScene(event.pos())
self.autofocus.emit(AutofocusSettings(center_x_pxl=None, center_y_pxl=None,
radius_pxl=30,
z_range_um=2000,
@@ -471,6 +557,11 @@ class SampleCameraImageLabel(QGraphicsView):
def __left_single_click(self, event):
if not self.__camera_interaction_enabled():
logger.info("Ignoring click because sample camera feed is unavailable")
self.__show_camera_unavailable_tooltip(event, "Point-on-click")
return
match self.__state:
case SampleCameraImageState.BEAM_MARKING:
if event.button() == Qt.MouseButton.LeftButton and event.modifiers() & Qt.KeyboardModifier.ShiftModifier:
@@ -521,6 +612,8 @@ class SampleCameraImageLabel(QGraphicsView):
'loop_all': QColor('green'),
'loop_face': QColor('yellow'),
'crystal': QColor('blue'),
'needle': QColor('magenta'),
'ice': QColor('cyan'),
}
for det in self.__detections:
@@ -666,7 +759,7 @@ class SampleCameraImageLabel(QGraphicsView):
mouse_scene_pos = self.mapToScene(mouse_view_pos)
if event.key() == Qt.Key.Key_Shift:
if self.__state == SampleCameraImageState.IDLE:
if self.__state == SampleCameraImageState.IDLE and self.__camera_interaction_enabled():
if not self.raster_timer.isActive():
self.load_image.emit(mouse_scene_pos)
self.raster_timer.start(self.raster_timer_interval)