diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index 81d897d..3538ca5 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -556,10 +556,9 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools except KeyboardInterrupt as exc: print("Smear rotation-center calibration interrupted by user.") try: - if not dev.cam_xeye.live_mode_enabled.get(): - dev.cam_xeye.live_mode_enabled.put(True) - except Exception as cam_exc: # pylint: disable=broad-except - logger.warning(f"Failed to restore cam_xeye live mode: {cam_exc}") + aligner.gui.set_live_view_signal("image") + except Exception as gui_exc: # pylint: disable=broad-except + logger.warning(f"Failed to restore XRayEye live view signal: {gui_exc}") try: aligner.gui.hide_crosshair() except Exception as gui_exc: # pylint: disable=broad-except diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py index 35c58bd..dd4eab6 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py @@ -665,41 +665,28 @@ class XrayEyeAlign: # Operator-visual only -- no automatic circle fitting. See # find_rotation_center_smear_experimental() below for the entry point. - def _push_smear_composite( - self, composite: np.ndarray, hold_display_s: float, resume_live: bool = True - ): - """Briefly freeze the GUI's live view on the current composite. + def _push_smear_composite(self, composite: np.ndarray): + """Publish the current composite to the GUI's live view. - live_mode_enabled.put(False) synchronously joins IDSCamera's - background capture thread before returning (see - IDSCamera._stop_live()'s thread.join()), so there is no race - between this push and the next raw frame the live thread would - otherwise publish to the same PreviewSignal/device_preview - channel the GUI is subscribed to. - - dev.cam_xeye.image is a PreviewSignal, which is not remotely - writable from client code (BEC excludes BECMessageSignal - subclasses from the client-side device RPC proxy) -- push via the - push_preview_image() device method instead, which runs - device-server side where self.image is a normal ophyd attribute. - - resume_live: if False, live mode is left off after the push -- - used for the final push at the end of a sweep, so the composite - stays frozen on screen (for the operator to click on) instead of - being immediately overwritten by the next raw live frame. + Pushed via push_smear_preview() to the camera's dedicated + smear_preview channel -- decoupled from the live acquisition + channel (image), so this never needs to touch live_mode_enabled + (the "camera running" toggle): the live thread keeps running and + publishing to `image` throughout, completely unaffected, while the + GUI is instead looking at `smear_preview` (see + gui.set_live_view_signal() in _smear_sweep). Toggling + live_mode_enabled off/on around every push (the previous approach) + is fast in simulation but has real, non-trivial overhead on actual + hardware (starting/stopping the live acquisition thread) -- this + avoids that entirely. """ - dev.cam_xeye.live_mode_enabled.put(False) - dev.cam_xeye.push_preview_image(composite) - time.sleep(hold_display_s) - if resume_live: - dev.cam_xeye.live_mode_enabled.put(True) + dev.cam_xeye.push_smear_preview(composite) def _smear_sweep( self, sweep_deg: float = 360.0, keep_shutter_open: bool = False, display_update_interval_s: float = 1.0, - hold_display_s: float = 0.4, poll_interval_s: float = 0.15, ) -> np.ndarray: """Rotate lsamrot continuously through sweep_deg while accumulating @@ -718,10 +705,17 @@ class XrayEyeAlign: directly, exactly what ScanReport.wait() does internally, so no background thread is needed here. - On return, the composite (not a fresh live frame) stays frozen on - the GUI's live view -- live mode is left off, and the shutter is - closed unless keep_shutter_open -- so the operator clicks on the - exact same image the whole time, with no regrab/flicker. + The GUI is switched to a dedicated preview channel for the + composite (gui.set_live_view_signal("smear_preview")) so it can be + pushed freely without ever touching live_mode_enabled ("camera + running") -- that's just turned on once at the start if not + already, exactly like _live_sweep(), and never toggled off here. + On a successful return the GUI is deliberately LEFT on the + composite (the caller is expected to collect the operator's click + before switching back to the normal channel); on KeyboardInterrupt + it's switched back immediately since there's nothing to click on. + The shutter is closed unless keep_shutter_open, so the operator + isn't exposing the sample while deciding where to click. """ print( f"[rotation-center][smear] EXPERIMENTAL feature: max-projection composite aid, " @@ -730,6 +724,7 @@ class XrayEyeAlign: if not dev.cam_xeye.live_mode_enabled.get(): dev.cam_xeye.live_mode_enabled.put(True) self.gui.on_live_view_enabled(True) + self.gui.set_live_view_signal("smear_preview") dev.fsh.fshopen() self._disable_rt_feedback() @@ -761,23 +756,26 @@ class XrayEyeAlign: composite = np.maximum(composite, frame) last_frame = frame if time.time() - last_push > display_update_interval_s: - self._push_smear_composite(composite, hold_display_s) + self._push_smear_composite(composite) last_push = time.time() time.sleep(poll_interval_s) except KeyboardInterrupt: print("[rotation-center][smear] sweep interrupted -- cancelling in-flight rotation.") report.cancel() - if not dev.cam_xeye.live_mode_enabled.get(): - dev.cam_xeye.live_mode_enabled.put(True) + self.gui.set_live_view_signal("image") raise - self._push_smear_composite(composite, hold_display_s, resume_live=False) + self._push_smear_composite(composite) if not keep_shutter_open: dev.fsh.fshclose() print( f"[rotation-center][smear] sweep done -- {frames_seen} distinct frames over " f"{sweep_deg:.1f} deg, now at {self.get_tomo_angle():.1f} deg" ) + # Note: the GUI stays on the "smear_preview" channel (showing this + # composite) until the caller has collected the operator's click -- + # switching back to "image" is the caller's responsibility (see + # find_rotation_center_smear_experimental). return composite def find_rotation_center_smear_experimental( @@ -849,6 +847,7 @@ class XrayEyeAlign: "Submit the rotation centre (0 deg).", label=f"rotation centre, iter {iteration}", ) + self.gui.set_live_view_signal("image") delta_x, delta_y = self._compute_shift_to_fzp(fzp_x, fzp_y, center_x, center_y) cumulative_shift_x += delta_x cumulative_shift_y += delta_y diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index 3e86305..456e8de 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -253,6 +253,7 @@ class XRayEye(BECWidget, QWidget): "set_crosshair_position", "crosshair_position", "reset_zoom", + "set_live_view_signal", ] PLUGIN = True @@ -269,6 +270,7 @@ class XRayEye(BECWidget, QWidget): def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) + self._live_view_signal = CAMERA[1] self._connected_motor = None self._dap_params_forwarding_connected = False self._queue_busy = False @@ -757,7 +759,7 @@ class XRayEye(BECWidget, QWidget): self.live_preview_toggle.blockSignals(True) if enabled: self.live_preview_toggle.checked = enabled - self.image.image(device=CAMERA[0], signal=CAMERA[1]) + self.image.image(device=CAMERA[0], signal=self._live_view_signal) # Reconnecting the monitor schedules a one-shot view autorange on # the next incoming frame (bec_widgets Image._autorange_on_next_update), # which would silently discard any manual zoom/pan every time live @@ -770,10 +772,28 @@ class XRayEye(BECWidget, QWidget): self.live_preview_toggle.blockSignals(False) return - self.image.disconnect_monitor(CAMERA[0], CAMERA[1]) + self.image.disconnect_monitor(CAMERA[0], self._live_view_signal) self.live_preview_toggle.checked = enabled self.live_preview_toggle.blockSignals(False) + def set_live_view_signal(self, signal: str = CAMERA[1]) -> None: + """Switch which camera signal the live Image view displays. + + Defaults to the normal live-acquisition channel -- callers (e.g. + LamNI's smear rotation-center calibration aid) can switch to an + alternate signal (e.g. "smear_preview") while a special procedure + is running, and should switch back when done. New/freshly-opened + GUI instances always default to the normal channel; nothing + changes here unless a caller explicitly requests it. + """ + if signal == self._live_view_signal: + return + self.image.disconnect_monitor(CAMERA[0], self._live_view_signal) + self._live_view_signal = signal + self.image.image(device=CAMERA[0], signal=signal) + if hasattr(self.image, "_autorange_on_next_update"): + self.image._autorange_on_next_update = False + @SafeSlot(bool) def camera_running_enabled(self, enabled: bool): if self._manual_toggle_blocked_by_queue(): diff --git a/csaxs_bec/devices/ids_cameras/ids_camera.py b/csaxs_bec/devices/ids_cameras/ids_camera.py index e77768f..381e959 100644 --- a/csaxs_bec/devices/ids_cameras/ids_camera.py +++ b/csaxs_bec/devices/ids_cameras/ids_camera.py @@ -35,6 +35,17 @@ class IDSCamera(PSIDeviceBase): num_rotation_90=0, transpose=False, ) + smear_preview = Cpt( + PreviewSignal, + name="smear_preview", + ndim=2, + doc=( + "Secondary preview signal for pushed composite/processed images, " + "decoupled from the live acquisition channel." + ), + num_rotation_90=0, + transpose=False, + ) roi_signal = Cpt( AsyncSignal, name="roi_signal", @@ -58,6 +69,7 @@ class IDSCamera(PSIDeviceBase): "set_rect_roi", "get_last_image", "push_preview_image", + "push_smear_preview", ] def __init__( @@ -231,6 +243,18 @@ class IDSCamera(PSIDeviceBase): data = np.rot90(data, k=-self.image.num_rotation_90, axes=(0, 1)) self.image.put(data) + def push_smear_preview(self, data: np.ndarray) -> None: + """Push a processed/composite image (already display-oriented) to + the secondary preview channel -- decoupled from the live + acquisition channel (`image`), so a caller can publish updates here + (e.g. a growing rotation-center calibration composite) without ever + needing to touch live_mode_enabled to avoid the live thread + overwriting them. smear_preview has no rotation_90/transpose + configured, so no compensation is needed here (unlike + push_preview_image). + """ + self.smear_preview.put(data) + ############## User Interface Methods ############## def on_connected(self): diff --git a/tests/tests_bec_ipython_client/test_x_ray_eye_align.py b/tests/tests_bec_ipython_client/test_x_ray_eye_align.py index 005124d..6d537d3 100644 --- a/tests/tests_bec_ipython_client/test_x_ray_eye_align.py +++ b/tests/tests_bec_ipython_client/test_x_ray_eye_align.py @@ -380,22 +380,18 @@ def _report_with_status_sequence(*statuses): return report -def test_push_smear_composite_duty_cycles_live_mode(bec_client_mock): +def test_push_smear_composite_uses_dedicated_channel(bec_client_mock): client = bec_client_mock align, dev_mock = _make_calibration_align(client) composite = np.zeros((2, 2), dtype=np.uint8) with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): - with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"): - align._push_smear_composite(composite, hold_display_s=0.4) + align._push_smear_composite(composite) - # live view must be turned off before the composite is pushed (so the - # background live-mode thread can't overwrite it) and back on after. - assert [c.args[0] for c in dev_mock.cam_xeye.live_mode_enabled.put.call_args_list] == [ - False, - True, - ] - dev_mock.cam_xeye.push_preview_image.assert_called_once_with(composite) + # pushed via the dedicated smear_preview channel -- live_mode_enabled + # ("camera running") is never touched to publish it. + dev_mock.cam_xeye.push_smear_preview.assert_called_once_with(composite) + dev_mock.cam_xeye.live_mode_enabled.put.assert_not_called() def test_smear_sweep_max_projection(bec_client_mock): @@ -418,6 +414,11 @@ def test_smear_sweep_max_projection(bec_client_mock): # exactly 3 frames were grabbed (get_last_image's side_effect list is # exhausted) -- no extra grabs happened after status went COMPLETED. assert dev_mock.cam_xeye.get_last_image.call_count == 3 + # never toggled off -- that's the whole point of the dedicated channel + dev_mock.cam_xeye.live_mode_enabled.put.assert_not_called() + # GUI switched to the composite channel, and NOT switched back yet -- + # that's the caller's job, after collecting the operator's click. + align.gui.set_live_view_signal.assert_called_once_with("smear_preview") def test_smear_sweep_issues_single_continuous_move(bec_client_mock): @@ -468,4 +469,31 @@ def test_smear_sweep_cancels_report_on_keyboard_interrupt(bec_client_mock): align._smear_sweep() report.cancel.assert_called_once() - dev_mock.cam_xeye.live_mode_enabled.put.assert_any_call(True) \ No newline at end of file + # GUI switched back to the normal live channel since there's nothing to + # click on when interrupted mid-sweep. + align.gui.set_live_view_signal.assert_any_call("image") + + +def test_find_rotation_center_smear_experimental_switches_live_view_signal(bec_client_mock): + """Regression guard: the GUI must stay on the composite channel until + AFTER the operator's click is collected, then switch back -- switching + back inside _smear_sweep itself (before the click) would show the + operator a live raw frame instead of the composite to click on.""" + client = bec_client_mock + align, dev_mock = _make_calibration_align(client) + + dev_mock.omny_xray_gui.xval_x_1.get.return_value = 100.0 + dev_mock.omny_xray_gui.yval_y_1.get.return_value = 0.0 + dev_mock.cam_xeye.get_last_image.return_value = np.zeros((2, 2), dtype=np.uint8) + report = _report_with_status_sequence("RUNNING", "COMPLETED") + + with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): + with mock.patch(f"{XRAY_EYE_ALIGN}.umv"): + with mock.patch(f"{XRAY_EYE_ALIGN}.mv", return_value=report): + with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"): + with mock.patch(f"{XRAY_EYE_ALIGN}.input", side_effect=["y", "y"]): + align.find_rotation_center_smear_experimental() + + calls = [c.args[0] for c in align.gui.set_live_view_signal.call_args_list] + assert calls == ["smear_preview", "image"] + dev_mock.cam_xeye.live_mode_enabled.put.assert_not_called() \ No newline at end of file diff --git a/tests/tests_devices/test_ids_camera.py b/tests/tests_devices/test_ids_camera.py index e64814f..e6897d0 100644 --- a/tests/tests_devices/test_ids_camera.py +++ b/tests/tests_devices/test_ids_camera.py @@ -114,3 +114,14 @@ def test_push_preview_image_compensates_rotation_and_transpose(): result = camera.image.get().data assert np.array_equal(result, display_oriented) + + +def test_push_smear_preview_no_rotation_compensation(ids_camera): + """smear_preview has no rotation_90/transpose configured, so pushed data + passes straight through unmodified -- unlike push_preview_image, no + compensation is needed (or applied).""" + data = np.arange(4, dtype=np.uint8).reshape(2, 2) + ids_camera.push_smear_preview(data) + + result = ids_camera.smear_preview.get().data + assert np.array_equal(result, data)