From d0d3602ed194358402c18cfa2de7e6487b9e7211 Mon Sep 17 00:00:00 2001 From: x01dc Date: Fri, 24 Jul 2026 12:46:28 +0200 Subject: [PATCH 01/11] feat(LamNI): experimental continuous-rotation smear aid for rotation-center calibration Adds an EXPERIMENTAL, operator-only alternative to the extended-sample rotation-center click: instead of judging the center from a single instant of live rotation, accumulate a max-projection composite while lsamrot rotates continuously through a (tunable, default 360 deg) sweep. Off-axis features smear into circular arcs; the common center of curvature is the rotation axis, an easier target to click than one live frame. No automatic circle fitting -- purely a visual aid feeding the same _collect_click mechanism the production feature already uses. Rotation is issued non-blocking via scans.mv() (new local wrapper, sibling of this file's existing blocking umv()) and frames are grabbed via get_last_image() in a single-threaded loop polling ScanReport.status -- the same thing ScanReport.wait() does internally -- so no background thread or device-server change is needed to grab frames while the motor is actively moving. The composite is pushed to the GUI through the camera's existing PreviewSignal (dev.cam_xeye.image), duty-cycling live_mode_enabled off/on around each push so the background 5 Hz live thread can't immediately overwrite it. New entry point: lamni.xrayeye_rotation_center_calibration_smear_experimental(). Purely additive -- does not modify find_rotation_center()/_live_sweep() or the two production entry points. Co-Authored-By: Claude Sonnet 5 --- .../bec_ipython_client/plugins/LamNI/lamni.py | 46 ++++ .../plugins/LamNI/x_ray_eye_align.py | 244 ++++++++++++++++++ .../test_x_ray_eye_align.py | 105 +++++++- 3 files changed, 394 insertions(+), 1 deletion(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index eef0837..81d897d 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -524,6 +524,52 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools logger.warning(f"Failed to disable XRayEye DAP parameter forwarding: {gui_exc}") raise exc + def xrayeye_rotation_center_calibration_smear_experimental( + self, keep_shutter_open: bool = False, sweep_deg: float = 360.0 + ): + """EXPERIMENTAL: extended-sample rotation-center calibration aided by + a continuous-rotation max-projection ("star-trail") composite image, + instead of a single instant of live rotation. + + Off-axis features smear into circular arcs while lsamrot rotates + continuously through sweep_deg; the common center of curvature of + those arcs is the rotation axis -- an easier target to click than + one live frame. This is purely a visual aid for the same manual + click used by xrayeye_rotation_center_calibration_extended(); there + is no automatic circle fitting. + + Args: + keep_shutter_open: If True the shutter is left open between steps + so the sample remains visible in live view. + sweep_deg: angular range of the continuous smear sweep. Defaults + to a full 360 deg (complete arcs), but a smaller range may + already show enough curvature to locate the center. + + Returns: + tuple: (new_lsamx_center, new_lsamy_center) in mm. + """ + aligner = XrayEyeAlignGUI(self.client, self) + try: + return aligner.find_rotation_center_smear_experimental( + keep_shutter_open=keep_shutter_open, sweep_deg=sweep_deg + ) + 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}") + try: + aligner.gui.hide_crosshair() + except Exception as gui_exc: # pylint: disable=broad-except + logger.warning(f"Failed to hide XRayEye alignment crosshair: {gui_exc}") + try: + aligner.gui.set_dap_params_forwarding(False) + except Exception as gui_exc: # pylint: disable=broad-except + logger.warning(f"Failed to disable XRayEye DAP parameter forwarding: {gui_exc}") + raise exc + # ------------------------------------------------------------------ # RT feedback / interferometer helpers # ------------------------------------------------------------------ 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 ff189de..87e946a 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 @@ -21,6 +21,10 @@ def umv(*args): return scans.umv(*args, relative=False) +def mv(*args): + return scans.mv(*args, relative=False) + + if TYPE_CHECKING: from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI @@ -655,6 +659,246 @@ class XrayEyeAlign: print(f"[rotation-center] at {self.get_tomo_angle():.1f} deg") print("[rotation-center] live sweep done") + # --- EXPERIMENTAL: rotation-center smear mode ------------------------- + # Max-projection "star-trail" composite aid for the extended-sample + # rotation-center click, built while lsamrot rotates continuously. + # 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): + """Briefly freeze the GUI's live view on the current composite. + + 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.live_mode_enabled.put(False) + dev.cam_xeye.image.put(composite) + time.sleep(hold_display_s) + dev.cam_xeye.live_mode_enabled.put(True) + + def _smear_sweep( + self, + sweep_deg: float = 360.0, + 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 + a max-projection composite of camera frames grabbed along the way. + + Off-axis features smear into circular arcs as the sample rotates; + the common center of curvature of those arcs is the rotation axis + -- an easier target for the operator to click than a single live + frame. sweep_deg need not be a full 360 -- a smaller arc may + already show enough curvature to locate the center. + + The rotation is issued non-blocking (mv(), the sibling of this + file's own blocking umv() wrapper) so frames can be grabbed via + get_last_image() in a plain single-threaded loop while the move + is still in progress on the server -- ScanReport.status is polled + directly, exactly what ScanReport.wait() does internally, so no + background thread is needed here. + """ + print( + f"[rotation-center][smear] EXPERIMENTAL feature: max-projection composite aid, " + "operator-visual only -- no automatic circle fitting." + ) + if not dev.cam_xeye.live_mode_enabled.get(): + dev.cam_xeye.live_mode_enabled.put(True) + self.gui.on_live_view_enabled(True) + dev.fsh.fshopen() + self._disable_rt_feedback() + + start = self.get_tomo_angle() + target = start + sweep_deg + print( + f"[rotation-center][smear] starting continuous {sweep_deg:.1f} deg sweep: " + f"{start:.1f} -> {target:.1f} deg" + ) + report = mv(self.device_manager.devices.lsamrot, target) + + composite = None + last_frame = None + frames_seen = 0 + last_push = time.time() + try: + while report.status != "COMPLETED": + frame = dev.cam_xeye.get_last_image() + if composite is None: + composite = frame.copy() + elif frame.shape != composite.shape: + print( + f"[rotation-center][smear] skipping frame with mismatched shape " + f"{frame.shape} != {composite.shape}" + ) + else: + if last_frame is None or not np.array_equal(frame, last_frame): + frames_seen += 1 + 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) + 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) + raise + + self._push_smear_composite(composite, hold_display_s) + 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" + ) + return composite + + def find_rotation_center_smear_experimental( + self, keep_shutter_open: bool = False, apply: bool = True, sweep_deg: float = 360.0 + ): + """EXPERIMENTAL: extended-sample rotation-center calibration aided + by a continuous-rotation max-projection composite (see + _smear_sweep()), instead of a single instant of live rotation. + + Otherwise identical in structure to find_rotation_center(sample_type="extended"): + FZP reference click, then iterate sweep -> click -> apply -> verify + -> accept/redo, then the same apply-confirmation/shutter-close + prompts. This is a separate, distinctly-named method -- it does not + modify find_rotation_center() or ROTATION_CENTER_SAMPLE_TYPES. + + Args: + keep_shutter_open: passed through to update_frame(), see align(). + apply: if True (default), ask for confirmation and then persist + the computed values via dev.lsamx/lsamy.update_user_parameter(). + sweep_deg: angular range of the continuous smear sweep. Defaults + to a full 360 deg (complete arcs), but a smaller range may + already show enough curvature to locate the center. + + Returns: + tuple: (new_lsamx_center, new_lsamy_center) in mm. + """ + self.lamni.lamnigui_show_xeyealign() + self.gui.set_dap_params_forwarding(False) + self._reset_init_values() + self.alignment_images = [] + self.movement_buttons_enabled(False, False) + self.gui.enable_submit_button(False) + dev.omny_xray_gui.mvx.set(0) + dev.omny_xray_gui.mvy.set(0) + dev.omny_xray_gui.submit.set(0) + + print( + "[rotation-center][smear] starting find_rotation_center_smear_experimental" + f"(sweep_deg={sweep_deg!r})" + ) + + self.send_message("Getting things ready. Please wait...") + self.lamni.losa_out() + self._ensure_at_configured_center() + self.lamni.lfzp_in(force_feedback_reset=True) + self.update_frame(keep_shutter_open) + fzp_x, fzp_y = self._collect_click(0, "Submit centre of FZP.", label="FZP reference") + + self.send_message("Please wait - moving sample in...") + self.lamni.loptics_out() + self._disable_rt_feedback() + self.tomo_rotate(0) + self.update_frame(keep_shutter_open) + self.gui.set_crosshair_position(fzp_x / self.pixel_calibration, fzp_y / self.pixel_calibration) + self.gui.show_crosshair() + + iteration = 0 + cumulative_shift_x, cumulative_shift_y = 0.0, 0.0 + while True: + iteration += 1 + print(f"[rotation-center][smear] === iteration {iteration} ===") + self.send_message( + "Watch the sample rotate and locate the stationary rotation centre..." + ) + self._smear_sweep(sweep_deg=sweep_deg) + self.update_frame(keep_shutter_open) + self.movement_buttons_enabled(True, True) + center_x, center_y = self._collect_click( + 1, + "Submit the rotation centre (0 deg).", + label=f"rotation centre, iter {iteration}", + ) + 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 + print( + f"[rotation-center][smear] cumulative shift from originally-configured center: " + f"({cumulative_shift_x:.4f}, {cumulative_shift_y:.4f}) mm" + ) + self._apply_rotation_center_shift(cumulative_shift_x, cumulative_shift_y) + + self.send_message("Verifying alignment...") + self._live_sweep([45, 0]) + self.update_frame(keep_shutter_open) + answer = ( + input("Alignment acceptable -- stop here? [Y/n] (n = run another iteration): ") + .strip() + .lower() + ) + if answer in ("", "y", "yes"): + print( + f"[rotation-center][smear] operator accepted alignment after " + f"{iteration} iteration(s)" + ) + break + print("[rotation-center][smear] operator requested another iteration.") + + self.gui.hide_crosshair() + new_lsamx = self.device_manager.devices.lsamx.readback.read()["lsamx"]["value"] + new_lsamy = self.device_manager.devices.lsamy.readback.read()["lsamy"]["value"] + old_lsamx = dev.lsamx.user_parameter.get("center") + old_lsamy = dev.lsamy.user_parameter.get("center") + print( + f"[rotation-center][smear] RESULT: new lsamx_center={new_lsamx:.4f} mm " + f"(was {old_lsamx}), new lsamy_center={new_lsamy:.4f} mm (was {old_lsamy})" + ) + + if apply: + answer = ( + input( + f"Update lsamx/lsamy center user parameters to " + f"({new_lsamx:.4f}, {new_lsamy:.4f})? [Y/n]: " + ) + .strip() + .lower() + ) + if answer in ("", "y", "yes"): + dev.lsamx.update_user_parameter({"center": float(new_lsamx)}) + dev.lsamy.update_user_parameter({"center": float(new_lsamy)}) + print( + f"[rotation-center][smear] lsamx.user_parameter['center'] = " + f"{dev.lsamx.user_parameter.get('center')}, " + f"lsamy.user_parameter['center'] = {dev.lsamy.user_parameter.get('center')}" + ) + else: + print( + "[rotation-center][smear] NOT updated -- config unchanged. Apply manually " + "with dev.lsamx.update_user_parameter({'center': ...}) / same for lsamy." + ) + + if keep_shutter_open: + answer = input("Close the shutter now? [Y/n]: ").strip().lower() + if answer in ("", "y", "yes"): + dev.fsh.fshclose() + self.gui.on_live_view_enabled(False) + print("Shutter closed.") + else: + print("Shutter left open.") + + return (new_lsamx, new_lsamy) + + # --- end EXPERIMENTAL: rotation-center smear mode --------------------- + def _ensure_at_configured_center(self, tol: float = 0.003): """Move lsamx/lsamy to their currently-configured center if they aren't already there. 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 2435dc3..5930066 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 @@ -1,5 +1,6 @@ from unittest import mock +import numpy as np import pytest from bec_lib.device import DeviceBase @@ -365,4 +366,106 @@ def test_find_rotation_center_extended_iterates_until_happy(bec_client_mock): # one lamni_move_to_scan_center pair per iteration -> 2 iterations = 4 calls assert align.scans.lamni_move_to_scan_center.call_count == 4 dev_mock.lsamx.update_user_parameter.assert_called_once() - dev_mock.lsamy.update_user_parameter.assert_called_once() \ No newline at end of file + dev_mock.lsamy.update_user_parameter.assert_called_once() + + +# ---------------------------------------------------------------------- +# EXPERIMENTAL: rotation-center smear mode (_smear_sweep) +# ---------------------------------------------------------------------- + + +def _report_with_status_sequence(*statuses): + report = mock.MagicMock() + type(report).status = mock.PropertyMock(side_effect=list(statuses)) + return report + + +def test_push_smear_composite_duty_cycles_live_mode(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) + + # 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.image.put.assert_called_once_with(composite) + + +def test_smear_sweep_max_projection(bec_client_mock): + client = bec_client_mock + align, dev_mock = _make_calibration_align(client) + + frame_a = np.array([[1, 5], [3, 2]], dtype=np.uint8) + frame_b = np.array([[4, 2], [3, 6]], dtype=np.uint8) + frame_c = np.array([[0, 1], [9, 0]], dtype=np.uint8) + dev_mock.cam_xeye.get_last_image.side_effect = [frame_a, frame_b, frame_c] + report = _report_with_status_sequence("RUNNING", "RUNNING", "RUNNING", "COMPLETED") + + with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): + with mock.patch(f"{XRAY_EYE_ALIGN}.mv", return_value=report): + with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"): + composite = align._smear_sweep() + + expected = np.maximum(np.maximum(frame_a, frame_b), frame_c) + assert np.array_equal(composite, expected) + # 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 + + +def test_smear_sweep_issues_single_continuous_move(bec_client_mock): + client = bec_client_mock + align, dev_mock = _make_calibration_align(client) + dev_mock.cam_xeye.get_last_image.return_value = np.zeros((2, 2), dtype=np.uint8) + report = _report_with_status_sequence("COMPLETED") + + with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): + with mock.patch(f"{XRAY_EYE_ALIGN}.mv", return_value=report) as mv_mock: + with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"): + align._smear_sweep(sweep_deg=90.0) + + # lsamrot readback starts at 0.0 (see _fake_positioner in _make_calibration_align) + mv_mock.assert_called_once_with(align.device_manager.devices.lsamrot, 90.0) + + +def test_smear_sweep_skips_frame_with_mismatched_shape(bec_client_mock, capsys): + client = bec_client_mock + align, dev_mock = _make_calibration_align(client) + + frame_a = np.zeros((2, 2), dtype=np.uint8) + frame_bad = np.zeros((3, 3), dtype=np.uint8) + dev_mock.cam_xeye.get_last_image.side_effect = [frame_a, frame_bad] + report = _report_with_status_sequence("RUNNING", "RUNNING", "COMPLETED") + + with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): + with mock.patch(f"{XRAY_EYE_ALIGN}.mv", return_value=report): + with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"): + composite = align._smear_sweep() + + assert np.array_equal(composite, frame_a) + assert "mismatched shape" in capsys.readouterr().out + + +def test_smear_sweep_cancels_report_on_keyboard_interrupt(bec_client_mock): + client = bec_client_mock + align, dev_mock = _make_calibration_align(client) + dev_mock.cam_xeye.live_mode_enabled.get.return_value = False + dev_mock.cam_xeye.get_last_image.side_effect = KeyboardInterrupt() + report = mock.MagicMock() + type(report).status = mock.PropertyMock(return_value="RUNNING") + + with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): + with mock.patch(f"{XRAY_EYE_ALIGN}.mv", return_value=report): + with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"): + with pytest.raises(KeyboardInterrupt): + 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 -- 2.54.0 From 71b9563abbbbb974c8e10eeec5b078ff6dd929c1 Mon Sep 17 00:00:00 2001 From: x01dc Date: Fri, 24 Jul 2026 12:56:48 +0200 Subject: [PATCH 02/11] docs(LamNI): document experimental smear calibration, fix stale prompt quote Co-Authored-By: Claude Sonnet 5 --- docs/user/ptychography/lamni.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md index 8c9241d..ad9e74c 100644 --- a/docs/user/ptychography/lamni.md +++ b/docs/user/ptychography/lamni.md @@ -27,12 +27,16 @@ The recommended way to (re-)measure `center` for a new sample is the GUI-driven This opens the X-ray eye widget and walks through the calibration interactively. Pick the function based on what's mounted: - `lamni.xrayeye_rotation_center_calibration_isolated()` — for a sparse/isolated particle. You submit its centre position once at 0° and once at 180°; the midpoint of the two is the rotation axis' projected position (this works regardless of the axis tilt). No further confirmation step. -- `lamni.xrayeye_rotation_center_calibration_extended()` — for a non-isolated/textured sample where the rotation centre can be identified visually. The sample sweeps 0° → 180° → 0° with the live view left open so you can watch for the point that doesn't move, then you submit a single click at 0°. The correction is applied, a short verification sweep (0° → 45° → 0°) runs, and you're asked *"Are you happy with this alignment, or shall we do another iteration?"* — answer `n` to refine further. +- `lamni.xrayeye_rotation_center_calibration_extended()` — for a non-isolated/textured sample where the rotation centre can be identified visually. The sample sweeps 0° → 180° → 0° with the live view left open so you can watch for the point that doesn't move, then you submit a single click at 0°. The correction is applied, a short verification sweep (0° → 45° → 0°) runs, and you're asked *"Alignment acceptable — stop here?"* — answer `n` to refine further. At the end you're shown the computed `lsamx_center`/`lsamy_center` and asked to confirm before they're written via `dev.lsamx.update_user_parameter(...)` / `dev.lsamy.update_user_parameter(...)`. Pass `keep_shutter_open=True` if it's hard to relocate the sample between steps, matching `xrayeye_alignment_start()` below. Interrupt with Ctrl-C to abort; the crosshair and DAP forwarding are cleaned up automatically. +**Experimental: continuous-rotation "smear" aid** + +`lamni.xrayeye_rotation_center_calibration_smear_experimental(sweep_deg=360.0, keep_shutter_open=False)` is an **experimental** alternative to `..._extended()` for the same non-isolated-sample case. Instead of judging the rotation centre from a single instant of live rotation, it rotates `lsamrot` continuously through `sweep_deg` (default a full circle) while accumulating a max-projection ("star-trail") composite from the camera: off-axis features smear into circular arcs, and the common center of curvature of those arcs is the rotation axis — usually an easier target to click than one live frame. `sweep_deg` can be reduced below 360 (even below 180) if a shorter arc already shows enough curvature. There is no automatic circle fitting — you still submit the centre by eye, same click mechanism as `..._extended()`, and the same iterate/verify/apply flow follows. Not yet merged into the production branch; try it from `experimental/rotation_center_smear`. + **Manual fallback** To observe the axis of rotation obtain the position of the Fresnel zone plate on the X-ray eye, possibly in the *ueye gui* by: -- 2.54.0 From 6d65a87ae44a899b166e183dcd4fe4ca80eff2e7 Mon Sep 17 00:00:00 2001 From: x01dc Date: Fri, 24 Jul 2026 13:10:08 +0200 Subject: [PATCH 03/11] fix(LamNI): push smear composite via new IDSCamera RPC method dev.cam_xeye.image.put(composite) failed on real hardware -- PreviewSignal is a BECMessageSignal subclass, and BEC hardcodes rpc_access=False for that signal_info, so the client-side device proxy never exposes `image` as an attribute at all (confirmed: this is a structural BEC constraint, not specific to this device -- no existing RPC surface, device or widget, offers a raw-array setter either). Add IDSCamera.push_preview_image(data), a small device-server-side method (alongside the existing get_last_image()) that does self.image.put(data) from within the device-server process, where PreviewSignal is a normal ophyd attribute. _push_smear_composite now calls dev.cam_xeye.push_preview_image(composite) instead of writing to the signal directly. Co-Authored-By: Claude Sonnet 5 --- .../plugins/LamNI/x_ray_eye_align.py | 8 ++++++- csaxs_bec/devices/ids_cameras/ids_camera.py | 23 ++++++++++++++++++- .../test_x_ray_eye_align.py | 2 +- 3 files changed, 30 insertions(+), 3 deletions(-) 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 87e946a..1f26e5a 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 @@ -674,9 +674,15 @@ class XrayEyeAlign: 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. """ dev.cam_xeye.live_mode_enabled.put(False) - dev.cam_xeye.image.put(composite) + dev.cam_xeye.push_preview_image(composite) time.sleep(hold_display_s) dev.cam_xeye.live_mode_enabled.put(True) diff --git a/csaxs_bec/devices/ids_cameras/ids_camera.py b/csaxs_bec/devices/ids_cameras/ids_camera.py index a5bf68e..c853818 100644 --- a/csaxs_bec/devices/ids_cameras/ids_camera.py +++ b/csaxs_bec/devices/ids_cameras/ids_camera.py @@ -51,7 +51,14 @@ class IDSCamera(PSIDeviceBase): kind=Kind.config, ) - USER_ACCESS = ["start_live_mode", "stop_live_mode", "mask", "set_rect_roi", "get_last_image"] + USER_ACCESS = [ + "start_live_mode", + "stop_live_mode", + "mask", + "set_rect_roi", + "get_last_image", + "push_preview_image", + ] def __init__( self, @@ -195,6 +202,20 @@ class IDSCamera(PSIDeviceBase): if image: return image.data + def push_preview_image(self, data: np.ndarray) -> None: + """Push an arbitrary array to this camera's own preview signal. + + self.image is a PreviewSignal, which is deliberately excluded from + the client-side device RPC/signal proxy (rpc_access defaults to + False for BECMessageSignal subclasses, with no override) -- so + client code cannot do dev.cam_xeye.image.put(...) directly. This + method runs device-server side, where self.image is a normal ophyd + attribute, and is the supported way for a client to publish a + custom (e.g. composite/processed) image through the same + device_preview channel the GUI already subscribes to. + """ + self.image.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 5930066..005124d 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 @@ -395,7 +395,7 @@ def test_push_smear_composite_duty_cycles_live_mode(bec_client_mock): False, True, ] - dev_mock.cam_xeye.image.put.assert_called_once_with(composite) + dev_mock.cam_xeye.push_preview_image.assert_called_once_with(composite) def test_smear_sweep_max_projection(bec_client_mock): -- 2.54.0 From ea9f15cde1d19ecff99e30b9cff9f4d4e5ef8587 Mon Sep 17 00:00:00 2001 From: x01dc Date: Fri, 24 Jul 2026 13:36:59 +0200 Subject: [PATCH 04/11] feat(sim): rotating structured pattern for simulated X-ray-eye camera The simulated cam_xeye (SimIDSCamera/_SimIDSBackend) served a single static Gaussian blob, cached at construction -- rotating lsamrot in a simulated session produced no visible motion, so the smear/composite rotation-center calibration aid had nothing to smear into an arc. Add an opt-in sim_rotation_coupling config (mirrors the existing sim_coarse_coupling idiom in sim_lamni.py's measured_positions()): when set, _SimIDSBackend regenerates a fresh frame on every get_image_data() call with an eccentric blob (make_rotating_test_pattern) tracking the coupled axis' live simulated Galil position instead of serving the cached static frame. Wired into LamNI's cam_xeye sim config only (host/port/axis matching lsamrot's own sim config); flomni's cam_xeye and any other SimIDSCamera user are unaffected since the new params default to None (byte-identical output to before, confirmed by test). Co-Authored-By: Claude Sonnet 5 --- .../simulated_omny/simulated_lamni.yaml | 9 ++ csaxs_bec/devices/sim/sim_cameras.py | 101 +++++++++++++++- tests/tests_devices/test_sim_cameras.py | 111 ++++++++++++++++++ 3 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 tests/tests_devices/test_sim_cameras.py diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml index ec81460..d9c61c8 100644 --- a/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml +++ b/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml @@ -326,6 +326,15 @@ cam_xeye: transpose: false force_monochrome: true m_n_colormode: 1 + # Makes the synthetic frame track lsamrot's live simulated angle (an + # eccentric blob orbiting image center) instead of a static blob, so the + # smear/composite rotation-center calibration aid can be exercised in + # sim. host/port/axis match lsamrot's own sim config above. + sim_rotation_coupling: + galil_host: mpc2680.psi.ch + galil_port: 8081 + axis: C + sim_feature_radius_px: 220 enabled: true onFailure: buffer readOnly: false diff --git a/csaxs_bec/devices/sim/sim_cameras.py b/csaxs_bec/devices/sim/sim_cameras.py index f87c017..ca9fc7a 100644 --- a/csaxs_bec/devices/sim/sim_cameras.py +++ b/csaxs_bec/devices/sim/sim_cameras.py @@ -21,6 +21,8 @@ from bec_lib.logger import bec_logger from csaxs_bec.devices.ids_cameras.ids_camera import IDSCamera from csaxs_bec.devices.omny.webcam_viewer import WebcamViewer +from csaxs_bec.devices.sim.sim_galil import SimGalilState +from csaxs_bec.devices.sim.sim_socket import SimStateRegistry logger = bec_logger.logger @@ -40,6 +42,43 @@ def make_test_pattern(height: int, width: int, rgb: bool) -> np.ndarray: return frame +def make_rotating_test_pattern( + height: int, + width: int, + rgb: bool, + angle_deg: float, + radius_px: float, + sigma: float | None = None, + amplitude: float = 180.0, +) -> np.ndarray: + """Create a synthetic camera frame (uint8) with an eccentric gaussian blob + orbiting the image center, for testing rotation-dependent features (e.g. + the smear/composite rotation-center calibration aid) in simulation. + + angle_deg follows the standard math convention (0 = +x/right, increasing + counter-clockwise); since image rows grow downward, the y term is negated + to keep that sense on screen. The fixed crosshair marks image center (the + FZP/beam reference), distinct from the orbiting feature -- unlike + make_test_pattern, this frame is meant to be regenerated fresh per call + as the angle changes, not cached. + """ + yy, xx = np.mgrid[0:height, 0:width] + cy, cx = height / 2.0, width / 2.0 + if sigma is None: + sigma = min(height, width) / 12.0 + angle_rad = np.deg2rad(angle_deg) + fy = cy - radius_px * np.sin(angle_rad) + fx = cx + radius_px * np.cos(angle_rad) + blob = amplitude * np.exp(-(((yy - fy) ** 2) + ((xx - fx) ** 2)) / (2.0 * sigma**2)) + frame = 20.0 + blob + frame[int(cy) - 1 : int(cy) + 2, :] = 230.0 # horizontal crosshair line + frame[:, int(cx) - 1 : int(cx) + 2] = 230.0 # vertical crosshair line + frame = frame.clip(0, 255).astype(np.uint8) + if rgb: + frame = np.repeat(frame[:, :, np.newaxis], 3, axis=2) + return frame + + def add_frame_noise(frame: np.ndarray, noise_std: float) -> np.ndarray: """Return a copy of the frame with gaussian noise, clipped to uint8.""" if noise_std <= 0: @@ -90,15 +129,42 @@ class _SimIDSSensor: class _SimIDSBackend: - """Drop-in replacement for `base_integration.camera.Camera` serving synthetic frames.""" + """Drop-in replacement for `base_integration.camera.Camera` serving synthetic frames. - def __init__(self, width: int, height: int, rgb: bool, noise_std: float = 0.0): + By default serves a single static frame (built once, cached forever), same + as always. If `rotation_coupling` is given (a dict with `galil_host`, + `galil_port`, `axis` -- same shape/idiom as SimRtLamniState's + coarse_coupling), a fresh frame is generated on every get_image_data() + call with an eccentric feature tracking that axis' live simulated angle, + for testing rotation-dependent features (e.g. the smear/composite + rotation-center calibration aid) in simulation. + """ + + def __init__( + self, + width: int, + height: int, + rgb: bool, + noise_std: float = 0.0, + rotation_coupling: dict | None = None, + feature_radius_px: float | None = None, + ): self.cam = _SimIDSSensor(width, height) self.force_monochrome = False self._connected = False self._rgb = rgb self._noise_std = float(noise_std) - self._frame = make_test_pattern(height, width, rgb=rgb) + self._width = width + self._height = height + self._rotation_coupling = rotation_coupling + self._feature_radius_px = ( + float(feature_radius_px) + if feature_radius_px is not None + else min(width, height) / 4.0 + ) + self._frame = None if rotation_coupling is not None else make_test_pattern( + height, width, rgb=rgb + ) def on_connect(self): self._connected = True @@ -106,8 +172,26 @@ class _SimIDSBackend: def on_disconnect(self): self._connected = False + def _current_angle_deg(self) -> float: + coupling = self._rotation_coupling + galil = SimStateRegistry.get( + SimGalilState, coupling["galil_host"], coupling["galil_port"] + ) + axis_num = ord(str(coupling["axis"]).lower()) - 97 + ax = galil.axis(axis_num) + return ax.position() / ax.stppermm + def get_image_data(self) -> np.ndarray: - frame = self._frame + if self._rotation_coupling is None: + frame = self._frame + else: + frame = make_rotating_test_pattern( + self._height, + self._width, + rgb=self._rgb, + angle_deg=self._current_angle_deg(), + radius_px=self._feature_radius_px, + ) if self.force_monochrome and frame.ndim == 3: frame = frame[:, :, 0] return add_frame_noise(frame, self._noise_std) @@ -136,6 +220,8 @@ class SimIDSCamera(IDSCamera): channels: int | None = None, # legacy OMNY config keys, accepted for compatibility sim_shape=(1024, 1280), sim_noise_std=0.0, + sim_rotation_coupling: dict | None = None, + sim_feature_radius_px: float | None = None, **kwargs, ): if camera_ID is not None and not camera_id: @@ -154,6 +240,11 @@ class SimIDSCamera(IDSCamera): **kwargs, ) self.cam = _SimIDSBackend( - int(sim_shape[1]), int(sim_shape[0]), rgb=not force_monochrome, noise_std=sim_noise_std + int(sim_shape[1]), + int(sim_shape[0]), + rgb=not force_monochrome, + noise_std=sim_noise_std, + rotation_coupling=sim_rotation_coupling, + feature_radius_px=sim_feature_radius_px, ) self.cam.force_monochrome = self._force_monochrome diff --git a/tests/tests_devices/test_sim_cameras.py b/tests/tests_devices/test_sim_cameras.py new file mode 100644 index 0000000..9e7f9cc --- /dev/null +++ b/tests/tests_devices/test_sim_cameras.py @@ -0,0 +1,111 @@ +"""Unit tests for the simulated IDS camera frame source (_SimIDSBackend / +make_rotating_test_pattern / SimIDSCamera's optional rotation coupling).""" + +import numpy as np +import pytest + +from csaxs_bec.devices.sim.sim_cameras import ( + SimIDSCamera, + _SimIDSBackend, + make_rotating_test_pattern, + make_test_pattern, +) +from csaxs_bec.devices.sim.sim_galil import SimGalilState +from csaxs_bec.devices.sim.sim_socket import SimStateRegistry + + +@pytest.fixture(autouse=True) +def reset_sim_state(): + """Avoid cross-test leakage of shared (host, port)-keyed simulation state.""" + SimStateRegistry.reset() + yield + SimStateRegistry.reset() + + +def test_sim_ids_backend_default_matches_static_pattern(): + """No rotation_coupling (the default) -> byte-identical to today's static frame. + + Regression guard: this is what keeps flomni's cam_xeye and other existing + SimIDSCamera users completely unaffected by the new opt-in feature. + """ + backend = _SimIDSBackend(width=64, height=48, rgb=True) + expected = make_test_pattern(48, 64, rgb=True) + assert np.array_equal(backend.get_image_data(), expected) + # calling it again returns the exact same cached frame, not a fresh one + assert np.array_equal(backend.get_image_data(), expected) + + +@pytest.mark.parametrize("angle_deg", [30.0, 120.0, 210.0, 300.0]) +def test_make_rotating_test_pattern_blob_position(angle_deg): + height, width = 200, 300 + radius_px = 60.0 + frame = make_rotating_test_pattern( + height, width, rgb=False, angle_deg=angle_deg, radius_px=radius_px + ) + + cy, cx = height / 2.0, width / 2.0 + angle_rad = np.deg2rad(angle_deg) + expected_y = cy - radius_px * np.sin(angle_rad) + expected_x = cx + radius_px * np.cos(angle_rad) + + # mask out the fixed center crosshair so it can't win the argmax + masked = frame.astype(np.int32) + masked[int(cy) - 2 : int(cy) + 3, :] = 0 + masked[:, int(cx) - 2 : int(cx) + 3] = 0 + py, px = np.unravel_index(np.argmax(masked), masked.shape) + + assert abs(py - expected_y) <= 2 + assert abs(px - expected_x) <= 2 + + +def test_sim_ids_backend_rotation_coupling_tracks_live_angle(): + host, port, axis_letter = "test-galil.example", 9000, "C" + galil = SimStateRegistry.get(SimGalilState, host, port) + ax = galil.axis(ord(axis_letter.lower()) - 97) + ax.stppermm = 1000.0 + ax.pos_steps = 0.0 # 0 deg + + coupling = {"galil_host": host, "galil_port": port, "axis": axis_letter} + backend = _SimIDSBackend( + width=200, height=200, rgb=False, rotation_coupling=coupling, feature_radius_px=50.0 + ) + + frame0 = backend.get_image_data() + assert np.array_equal( + frame0, make_rotating_test_pattern(200, 200, rgb=False, angle_deg=0.0, radius_px=50.0) + ) + + # move the simulated axis and confirm the frame is regenerated fresh, not cached + ax.pos_steps = 90.0 * ax.stppermm + frame90 = backend.get_image_data() + assert np.array_equal( + frame90, make_rotating_test_pattern(200, 200, rgb=False, angle_deg=90.0, radius_px=50.0) + ) + assert not np.array_equal(frame0, frame90) + + +def test_sim_ids_camera_threads_rotation_coupling_through(): + """SimIDSCamera's sim_rotation_coupling/sim_feature_radius_px reach _SimIDSBackend.""" + host, port, axis_letter = "test-galil-2.example", 9100, "C" + galil = SimStateRegistry.get(SimGalilState, host, port) + ax = galil.axis(ord(axis_letter.lower()) - 97) + ax.stppermm = 1000.0 + ax.pos_steps = 0.0 + + camera = SimIDSCamera( + name="test_sim_cam", + camera_id=1, + prefix="test:", + scan_info=None, + m_n_colormode=1, + bits_per_pixel=24, + live_mode=False, + sim_shape=(64, 64), + sim_rotation_coupling={"galil_host": host, "galil_port": port, "axis": axis_letter}, + sim_feature_radius_px=20.0, + ) + + frame0 = camera.cam.get_image_data() + ax.pos_steps = 180.0 * ax.stppermm + frame180 = camera.cam.get_image_data() + assert not np.array_equal(frame0, frame180) -- 2.54.0 From 81f6d0ff1e7f98c519b9730bca2084917069052e Mon Sep 17 00:00:00 2001 From: x01dc Date: Fri, 24 Jul 2026 14:02:51 +0200 Subject: [PATCH 05/11] fix(LamNI): freeze composite display, undo double rotation, speckle sim pattern Real-HW-adjacent (sim) testing surfaced three issues with the smear calibration aid: 1. The composite reverted to the plain rotating live view right before the click prompt. find_rotation_center_smear_experimental() called update_frame() after _smear_sweep(), which grabs a fresh raw live frame -- overwriting the composite it just built. Also, _push_smear_composite always resumed live mode, so even without that bug the composite would only stay visible for hold_display_s. Fixed: _push_smear_composite gained resume_live=False for the final push; _smear_sweep now takes keep_shutter_open and closes the shutter itself at the end, so the composite (not a fresh grab) stays frozen on screen for the click. The stray update_frame() call is removed. 2. The composite appeared rotated relative to the live view. get_last_image() frames already have num_rotation_90/transpose applied (PreviewSignal.put() applies it to whatever it stores), so composites built from those frames got the transform applied AGAIN by push_preview_image()'s own self.image.put() call. Fixed: push_preview_image() now undoes the transform first (reverse order, since transpose is applied last forward) so the net effect is a single transform, matching a live frame. 3. Feature request: the sim camera's rotation-coupled pattern was a single eccentric gaussian blob -- a better analog for the isolated/ point-particle case than the extended/textured case this feature actually targets. Added generate_speckle_grains/ make_speckle_test_pattern (sim_cameras.py): a fixed, seeded field of small gaussian "grains" at a range of radii, rotating rigidly together as the live angle changes -- producing several concentric arcs in the composite instead of one, much closer to a real textured sample. New sim_speckle_count/sim_speckle_seed config, defaults requiring no yaml changes. Co-Authored-By: Claude Sonnet 5 --- .../plugins/LamNI/x_ray_eye_align.py | 25 ++++- csaxs_bec/devices/ids_cameras/ids_camera.py | 17 +++- csaxs_bec/devices/sim/sim_cameras.py | 93 ++++++++++++++++++- tests/tests_devices/test_ids_camera.py | 28 ++++++ tests/tests_devices/test_sim_cameras.py | 63 ++++++++++++- 5 files changed, 211 insertions(+), 15 deletions(-) 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 1f26e5a..35c58bd 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,7 +665,9 @@ 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): + 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. live_mode_enabled.put(False) synchronously joins IDSCamera's @@ -680,15 +682,22 @@ class XrayEyeAlign: 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. """ dev.cam_xeye.live_mode_enabled.put(False) dev.cam_xeye.push_preview_image(composite) time.sleep(hold_display_s) - dev.cam_xeye.live_mode_enabled.put(True) + if resume_live: + dev.cam_xeye.live_mode_enabled.put(True) 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, @@ -708,6 +717,11 @@ class XrayEyeAlign: is still in progress on the server -- ScanReport.status is polled 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. """ print( f"[rotation-center][smear] EXPERIMENTAL feature: max-projection composite aid, " @@ -757,7 +771,9 @@ class XrayEyeAlign: dev.cam_xeye.live_mode_enabled.put(True) raise - self._push_smear_composite(composite, hold_display_s) + self._push_smear_composite(composite, hold_display_s, resume_live=False) + 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" @@ -826,8 +842,7 @@ class XrayEyeAlign: self.send_message( "Watch the sample rotate and locate the stationary rotation centre..." ) - self._smear_sweep(sweep_deg=sweep_deg) - self.update_frame(keep_shutter_open) + self._smear_sweep(sweep_deg=sweep_deg, keep_shutter_open=keep_shutter_open) self.movement_buttons_enabled(True, True) center_x, center_y = self._collect_click( 1, diff --git a/csaxs_bec/devices/ids_cameras/ids_camera.py b/csaxs_bec/devices/ids_cameras/ids_camera.py index c853818..e77768f 100644 --- a/csaxs_bec/devices/ids_cameras/ids_camera.py +++ b/csaxs_bec/devices/ids_cameras/ids_camera.py @@ -203,7 +203,9 @@ class IDSCamera(PSIDeviceBase): return image.data def push_preview_image(self, data: np.ndarray) -> None: - """Push an arbitrary array to this camera's own preview signal. + """Push an arbitrary array (already display-oriented, e.g. built + from frames returned by get_last_image()) to this camera's own + preview signal. self.image is a PreviewSignal, which is deliberately excluded from the client-side device RPC/signal proxy (rpc_access defaults to @@ -213,7 +215,20 @@ class IDSCamera(PSIDeviceBase): attribute, and is the supported way for a client to publish a custom (e.g. composite/processed) image through the same device_preview channel the GUI already subscribes to. + + get_last_image() frames already have num_rotation_90/transpose + applied (PreviewSignal.put() applies that transform to whatever it + stores), so publishing data built from those frames straight + through self.image.put() would apply the same transform a second + time. Undo it here first -- in reverse order, since transpose is + applied last in PreviewSignal._process_data() so it must be undone + first -- so the net effect after put() is a single transform, + matching what a live frame looks like. """ + if self.image.transpose: + data = np.swapaxes(data, 0, 1) + if self.image.num_rotation_90: + data = np.rot90(data, k=-self.image.num_rotation_90, axes=(0, 1)) self.image.put(data) ############## User Interface Methods ############## diff --git a/csaxs_bec/devices/sim/sim_cameras.py b/csaxs_bec/devices/sim/sim_cameras.py index ca9fc7a..3776386 100644 --- a/csaxs_bec/devices/sim/sim_cameras.py +++ b/csaxs_bec/devices/sim/sim_cameras.py @@ -12,6 +12,11 @@ The synthetic frame is a test pattern (dark background, centered gaussian blob a crosshair). Per-frame gaussian noise can be enabled via sim_noise_std (counts, default 0 = static frames; noisy frames make live views visibly live but cost bandwidth e.g. over remote desktop). + +Optionally (sim_rotation_coupling), the frame instead tracks another simulated axis' +(e.g. lsamrot's) live angle: a field of speckle "grains" (generate_speckle_grains / +make_speckle_test_pattern) orbits image center rigidly, for testing rotation-dependent +features such as the LamNI smear/composite rotation-center calibration aid. """ from __future__ import annotations @@ -79,6 +84,69 @@ def make_rotating_test_pattern( return frame +def generate_speckle_grains( + n_speckles: int, + radius_range: tuple[float, float], + sigma_range: tuple[float, float], + amplitude_range: tuple[float, float], + seed: int, +) -> list[tuple[float, float, float, float]]: + """Generate a fixed, reproducible set of speckle "grains". + + Each grain is (radius_px, base_angle_deg, sigma, amplitude) -- a small + gaussian dot at a random eccentricity/angle/size/brightness, meant to be + generated ONCE and reused across many frames (only the live rotation + angle changes where each grain is drawn -- see make_speckle_test_pattern + -- so the speckle field rotates rigidly rather than jittering). + """ + rng = np.random.default_rng(seed) + radii = rng.uniform(radius_range[0], radius_range[1], n_speckles) + angles = rng.uniform(0.0, 360.0, n_speckles) + sigmas = rng.uniform(sigma_range[0], sigma_range[1], n_speckles) + amplitudes = rng.uniform(amplitude_range[0], amplitude_range[1], n_speckles) + return list(zip(radii, angles, sigmas, amplitudes)) + + +def make_speckle_test_pattern( + height: int, + width: int, + rgb: bool, + angle_deg: float, + grains: list[tuple[float, float, float, float]], + background: float = 20.0, +) -> np.ndarray: + """Create a synthetic camera frame (uint8) with a field of speckle + "grains" rigidly orbiting the image center, for testing rotation- + dependent features (e.g. the smear/composite rotation-center + calibration aid) against a more realistic, textured/extended-sample-like + pattern than a single gaussian blob. + + Each grain's displayed angular position is its fixed base_angle_deg + (from `grains`, see generate_speckle_grains) plus the live angle_deg -- + i.e. the whole speckle field rotates together as one rigid body, the + same way a real extended sample's texture orbits the true rotation axis. + Grains at different radii trace concentric arcs when composited across + a sweep. Sign convention for angle_deg matches make_rotating_test_pattern + (0 = +x/right, increasing counter-clockwise; y negated since image rows + grow downward). The fixed crosshair marks image center (the FZP/beam + reference), distinct from the orbiting speckle field. + """ + yy, xx = np.mgrid[0:height, 0:width] + cy, cx = height / 2.0, width / 2.0 + frame = np.full((height, width), background, dtype=np.float64) + for radius_px, base_angle_deg, sigma, amplitude in grains: + angle_rad = np.deg2rad(base_angle_deg + angle_deg) + fy = cy - radius_px * np.sin(angle_rad) + fx = cx + radius_px * np.cos(angle_rad) + frame += amplitude * np.exp(-(((yy - fy) ** 2) + ((xx - fx) ** 2)) / (2.0 * sigma**2)) + frame[int(cy) - 1 : int(cy) + 2, :] = 230.0 # horizontal crosshair line + frame[:, int(cx) - 1 : int(cx) + 2] = 230.0 # vertical crosshair line + frame = frame.clip(0, 255).astype(np.uint8) + if rgb: + frame = np.repeat(frame[:, :, np.newaxis], 3, axis=2) + return frame + + def add_frame_noise(frame: np.ndarray, noise_std: float) -> np.ndarray: """Return a copy of the frame with gaussian noise, clipped to uint8.""" if noise_std <= 0: @@ -148,6 +216,8 @@ class _SimIDSBackend: noise_std: float = 0.0, rotation_coupling: dict | None = None, feature_radius_px: float | None = None, + speckle_count: int = 60, + speckle_seed: int = 42, ): self.cam = _SimIDSSensor(width, height) self.force_monochrome = False @@ -162,9 +232,18 @@ class _SimIDSBackend: if feature_radius_px is not None else min(width, height) / 4.0 ) - self._frame = None if rotation_coupling is not None else make_test_pattern( - height, width, rgb=rgb - ) + if rotation_coupling is None: + self._frame = make_test_pattern(height, width, rgb=rgb) + self._speckle_grains = None + else: + self._frame = None + self._speckle_grains = generate_speckle_grains( + n_speckles=speckle_count, + radius_range=(0.3 * self._feature_radius_px, self._feature_radius_px), + sigma_range=(2.0, 5.0), + amplitude_range=(120.0, 200.0), + seed=speckle_seed, + ) def on_connect(self): self._connected = True @@ -185,12 +264,12 @@ class _SimIDSBackend: if self._rotation_coupling is None: frame = self._frame else: - frame = make_rotating_test_pattern( + frame = make_speckle_test_pattern( self._height, self._width, rgb=self._rgb, angle_deg=self._current_angle_deg(), - radius_px=self._feature_radius_px, + grains=self._speckle_grains, ) if self.force_monochrome and frame.ndim == 3: frame = frame[:, :, 0] @@ -222,6 +301,8 @@ class SimIDSCamera(IDSCamera): sim_noise_std=0.0, sim_rotation_coupling: dict | None = None, sim_feature_radius_px: float | None = None, + sim_speckle_count: int = 60, + sim_speckle_seed: int = 42, **kwargs, ): if camera_ID is not None and not camera_id: @@ -246,5 +327,7 @@ class SimIDSCamera(IDSCamera): noise_std=sim_noise_std, rotation_coupling=sim_rotation_coupling, feature_radius_px=sim_feature_radius_px, + speckle_count=sim_speckle_count, + speckle_seed=sim_speckle_seed, ) self.cam.force_monochrome = self._force_monochrome diff --git a/tests/tests_devices/test_ids_camera.py b/tests/tests_devices/test_ids_camera.py index 66466b3..e64814f 100644 --- a/tests/tests_devices/test_ids_camera.py +++ b/tests/tests_devices/test_ids_camera.py @@ -86,3 +86,31 @@ def test_on_destroy(ids_camera): ids_camera.cam.on_disconnect = mock.Mock() ids_camera.on_destroy() ids_camera.cam.on_disconnect.assert_called_once() + + +def test_push_preview_image_compensates_rotation_and_transpose(): + """push_preview_image() is meant for data already display-oriented (e.g. + built from get_last_image() frames, which already have num_rotation_90/ + transpose applied by PreviewSignal.put()). Pushing it through must undo + that transform first so put()'s own transform doesn't apply it twice -- + net effect: what comes back out via .get() matches what went in. + """ + camera = IDSCamera( + name="test_camera_rot", + camera_id=1, + prefix="test:", + scan_info=None, + m_n_colormode=1, + bits_per_pixel=24, + live_mode=False, + num_rotation_90=3, + transpose=True, + ) + camera.cam = mock.Mock() + camera.cam._connected = True + + display_oriented = np.arange(12, dtype=np.uint8).reshape(3, 4) + camera.push_preview_image(display_oriented) + + result = camera.image.get().data + assert np.array_equal(result, display_oriented) diff --git a/tests/tests_devices/test_sim_cameras.py b/tests/tests_devices/test_sim_cameras.py index 9e7f9cc..7f5500a 100644 --- a/tests/tests_devices/test_sim_cameras.py +++ b/tests/tests_devices/test_sim_cameras.py @@ -1,5 +1,6 @@ """Unit tests for the simulated IDS camera frame source (_SimIDSBackend / -make_rotating_test_pattern / SimIDSCamera's optional rotation coupling).""" +make_rotating_test_pattern / make_speckle_test_pattern / +SimIDSCamera's optional rotation coupling).""" import numpy as np import pytest @@ -7,7 +8,9 @@ import pytest from csaxs_bec.devices.sim.sim_cameras import ( SimIDSCamera, _SimIDSBackend, + generate_speckle_grains, make_rotating_test_pattern, + make_speckle_test_pattern, make_test_pattern, ) from csaxs_bec.devices.sim.sim_galil import SimGalilState @@ -59,6 +62,9 @@ def test_make_rotating_test_pattern_blob_position(angle_deg): def test_sim_ids_backend_rotation_coupling_tracks_live_angle(): + """_SimIDSBackend renders a speckle field (its actual default rotation- + coupled pattern) tracking the live simulated angle, regenerated fresh + per call rather than cached.""" host, port, axis_letter = "test-galil.example", 9000, "C" galil = SimStateRegistry.get(SimGalilState, host, port) ax = galil.axis(ord(axis_letter.lower()) - 97) @@ -66,24 +72,73 @@ def test_sim_ids_backend_rotation_coupling_tracks_live_angle(): ax.pos_steps = 0.0 # 0 deg coupling = {"galil_host": host, "galil_port": port, "axis": axis_letter} + radius_px = 50.0 backend = _SimIDSBackend( - width=200, height=200, rgb=False, rotation_coupling=coupling, feature_radius_px=50.0 + width=200, height=200, rgb=False, rotation_coupling=coupling, feature_radius_px=radius_px + ) + # same defaults _SimIDSBackend uses internally (speckle_count=60, speckle_seed=42) + expected_grains = generate_speckle_grains( + n_speckles=60, + radius_range=(0.3 * radius_px, radius_px), + sigma_range=(2.0, 5.0), + amplitude_range=(120.0, 200.0), + seed=42, ) frame0 = backend.get_image_data() assert np.array_equal( - frame0, make_rotating_test_pattern(200, 200, rgb=False, angle_deg=0.0, radius_px=50.0) + frame0, + make_speckle_test_pattern(200, 200, rgb=False, angle_deg=0.0, grains=expected_grains), ) # move the simulated axis and confirm the frame is regenerated fresh, not cached ax.pos_steps = 90.0 * ax.stppermm frame90 = backend.get_image_data() assert np.array_equal( - frame90, make_rotating_test_pattern(200, 200, rgb=False, angle_deg=90.0, radius_px=50.0) + frame90, + make_speckle_test_pattern(200, 200, rgb=False, angle_deg=90.0, grains=expected_grains), ) assert not np.array_equal(frame0, frame90) +def test_generate_speckle_grains_reproducible(): + """Same seed/params -> identical grains, so the speckle field is fixed + across frames (only the live angle changes where each grain is drawn).""" + kwargs = dict( + n_speckles=20, + radius_range=(10.0, 50.0), + sigma_range=(2.0, 4.0), + amplitude_range=(100.0, 200.0), + seed=7, + ) + assert generate_speckle_grains(**kwargs) == generate_speckle_grains(**kwargs) + + +def test_make_speckle_test_pattern_rotates_rigidly(): + """A single grain's rendered peak moves by exactly the change in + angle_deg -- the whole speckle field rotates as one rigid body.""" + height, width = 200, 300 + grains = [(60.0, 40.0, 3.0, 200.0)] # radius_px=60, base_angle_deg=40 (avoids axes) + cy, cx = height / 2.0, width / 2.0 + + def peak_position(angle_deg): + frame = make_speckle_test_pattern( + height, width, rgb=False, angle_deg=angle_deg, grains=grains + ) + masked = frame.astype(np.int32) + masked[int(cy) - 2 : int(cy) + 3, :] = 0 + masked[:, int(cx) - 2 : int(cx) + 3] = 0 + return np.unravel_index(np.argmax(masked), masked.shape) + + for extra_angle in (0.0, 90.0): + py, px = peak_position(extra_angle) + total_angle_rad = np.deg2rad(40.0 + extra_angle) + expected_y = cy - 60.0 * np.sin(total_angle_rad) + expected_x = cx + 60.0 * np.cos(total_angle_rad) + assert abs(py - expected_y) <= 2 + assert abs(px - expected_x) <= 2 + + def test_sim_ids_camera_threads_rotation_coupling_through(): """SimIDSCamera's sim_rotation_coupling/sim_feature_radius_px reach _SimIDSBackend.""" host, port, axis_letter = "test-galil-2.example", 9100, "C" -- 2.54.0 From bcdfc12a88c60d64c191b6e033fd79480b9800ab Mon Sep 17 00:00:00 2001 From: x01dc Date: Fri, 24 Jul 2026 14:24:21 +0200 Subject: [PATCH 06/11] fix(LamNI): decouple smear composite display from live acquisition channel The "camera running" toggle (IDSCamera.live_mode_enabled) was flapping on/off during the smear sweep, and on real hardware that's genuinely slow (starting/stopping the live acquisition thread has real driver overhead, unlike the simulator). Root cause: composite pushes shared the same device_preview channel the live thread continuously publishes to, so live_mode_enabled had to be toggled off around every push to keep the composite from being instantly overwritten. Give the composite its own channel instead: - IDSCamera gains a smear_preview PreviewSignal (no rotation_90/ transpose configured -- composite data is already display-oriented) and push_smear_preview(), mirroring push_preview_image() but fully decoupled from the live channel. - XRayEye gains set_live_view_signal() to switch which camera signal the live Image view displays, defaulting to the normal channel for every fresh GUI instance; on_live_view_enabled() now uses whichever signal is currently selected instead of a hardcoded constant. - _smear_sweep() switches the GUI to "smear_preview" for the sweep and pushes composite updates there -- live_mode_enabled is never touched at all (just ensured on at the start, exactly like _live_sweep()). The GUI is deliberately left on the composite after a successful sweep until the caller has collected the operator's click, then switched back to "image"; on KeyboardInterrupt it's switched back immediately. This removes the resume_live/hold_display_s toggle-avoidance mechanism added in def9bf4, which is no longer needed with a dedicated channel. Co-Authored-By: Claude Sonnet 5 --- .../bec_ipython_client/plugins/LamNI/lamni.py | 7 +- .../plugins/LamNI/x_ray_eye_align.py | 69 +++++++++---------- .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 24 ++++++- csaxs_bec/devices/ids_cameras/ids_camera.py | 24 +++++++ .../test_x_ray_eye_align.py | 50 +++++++++++--- tests/tests_devices/test_ids_camera.py | 11 +++ 6 files changed, 133 insertions(+), 52 deletions(-) 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) -- 2.54.0 From 207be425dc3192f4f94646c2ae15a48f833441b3 Mon Sep 17 00:00:00 2001 From: x01dc Date: Fri, 24 Jul 2026 14:24:30 +0200 Subject: [PATCH 07/11] feat(sim): randomize rotation-axis offset, drop synthetic crosshair The simulated speckle pattern rotated exactly around image center -- the same point the FZP crosshair sits at -- so the calibration was trivially already correct in sim, never exercising the actual find-and-correct-a-miscalibration path. Add an opt-in axis_offset_range_px/axis_offset_seed: when set, the "true" rotation axis the speckle field orbits is offset from image center by a random amount (fresh each device construction unless a seed is given, so each sim session gets a new miscalibration to find). Wired into LamNI's cam_xeye sim config (sim_axis_offset_range_px: 40, no seed -- random each session); default (0.0) preserves prior behavior exactly for any other SimIDSCamera user. Also drop the synthetic crosshair lines baked into make_speckle_test_pattern's pixel data -- a real camera image has no such marking; the GUI already draws its own crosshair overlay (TargetCrosshair) on top of the live image. Co-Authored-By: Claude Sonnet 5 --- .../simulated_omny/simulated_lamni.yaml | 16 ++-- csaxs_bec/devices/sim/sim_cameras.py | 46 ++++++++-- tests/tests_devices/test_sim_cameras.py | 83 +++++++++++++++++++ 3 files changed, 133 insertions(+), 12 deletions(-) diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml index d9c61c8..87ff533 100644 --- a/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml +++ b/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml @@ -136,7 +136,7 @@ lsamrot: sign: 1 sim_stppermm: 50154.32099 sim_encpermm: 36000 - sim_velocity: 48 #4x than hw + sim_velocity: 5 # 48 #4x than hw sim_initial_position: 0 enabled: true onFailure: buffer @@ -326,15 +326,21 @@ cam_xeye: transpose: false force_monochrome: true m_n_colormode: 1 - # Makes the synthetic frame track lsamrot's live simulated angle (an - # eccentric blob orbiting image center) instead of a static blob, so the - # smear/composite rotation-center calibration aid can be exercised in - # sim. host/port/axis match lsamrot's own sim config above. + # Makes the synthetic frame track lsamrot's live simulated angle (a + # speckle field orbiting the rotation axis) instead of a static blob, so + # the smear/composite rotation-center calibration aid can be exercised + # in sim. host/port/axis match lsamrot's own sim config above. sim_rotation_coupling: galil_host: mpc2680.psi.ch galil_port: 8081 axis: C sim_feature_radius_px: 220 + # The "true" rotation axis is randomly offset from image center by up to + # this many px (a fresh random offset each session -- sim_axis_offset_seed + # not set) so the calibration procedure has an actual miscalibration to + # find, instead of the axis trivially already coinciding with the FZP + # crosshair. + sim_axis_offset_range_px: 40 enabled: true onFailure: buffer readOnly: false diff --git a/csaxs_bec/devices/sim/sim_cameras.py b/csaxs_bec/devices/sim/sim_cameras.py index 3776386..a3af84b 100644 --- a/csaxs_bec/devices/sim/sim_cameras.py +++ b/csaxs_bec/devices/sim/sim_cameras.py @@ -113,10 +113,11 @@ def make_speckle_test_pattern( rgb: bool, angle_deg: float, grains: list[tuple[float, float, float, float]], + axis_offset: tuple[float, float] = (0.0, 0.0), background: float = 20.0, ) -> np.ndarray: """Create a synthetic camera frame (uint8) with a field of speckle - "grains" rigidly orbiting the image center, for testing rotation- + "grains" rigidly orbiting a rotation axis, for testing rotation- dependent features (e.g. the smear/composite rotation-center calibration aid) against a more realistic, textured/extended-sample-like pattern than a single gaussian blob. @@ -128,19 +129,26 @@ def make_speckle_test_pattern( Grains at different radii trace concentric arcs when composited across a sweep. Sign convention for angle_deg matches make_rotating_test_pattern (0 = +x/right, increasing counter-clockwise; y negated since image rows - grow downward). The fixed crosshair marks image center (the FZP/beam - reference), distinct from the orbiting speckle field. + grow downward). + + axis_offset (dx, dy in pixels) shifts the rotation axis the grains orbit + away from image center -- i.e. a non-zero offset means the "true" + rotation axis is NOT at image center (where the GUI's crosshair overlay + currently sits), exactly the miscalibration the real calibration + procedure is meant to find and correct. Defaults to (0, 0) (axis == + image center, no offset to find). No crosshair is drawn into the frame + itself -- a real camera image has no such marking; the GUI draws its + own crosshair overlay (TargetCrosshair) on top of the live image. """ yy, xx = np.mgrid[0:height, 0:width] cy, cx = height / 2.0, width / 2.0 + axis_cy, axis_cx = cy + axis_offset[1], cx + axis_offset[0] frame = np.full((height, width), background, dtype=np.float64) for radius_px, base_angle_deg, sigma, amplitude in grains: angle_rad = np.deg2rad(base_angle_deg + angle_deg) - fy = cy - radius_px * np.sin(angle_rad) - fx = cx + radius_px * np.cos(angle_rad) + fy = axis_cy - radius_px * np.sin(angle_rad) + fx = axis_cx + radius_px * np.cos(angle_rad) frame += amplitude * np.exp(-(((yy - fy) ** 2) + ((xx - fx) ** 2)) / (2.0 * sigma**2)) - frame[int(cy) - 1 : int(cy) + 2, :] = 230.0 # horizontal crosshair line - frame[:, int(cx) - 1 : int(cx) + 2] = 230.0 # vertical crosshair line frame = frame.clip(0, 255).astype(np.uint8) if rgb: frame = np.repeat(frame[:, :, np.newaxis], 3, axis=2) @@ -218,6 +226,8 @@ class _SimIDSBackend: feature_radius_px: float | None = None, speckle_count: int = 60, speckle_seed: int = 42, + axis_offset_range_px: float = 0.0, + axis_offset_seed: int | None = None, ): self.cam = _SimIDSSensor(width, height) self.force_monochrome = False @@ -235,6 +245,7 @@ class _SimIDSBackend: if rotation_coupling is None: self._frame = make_test_pattern(height, width, rgb=rgb) self._speckle_grains = None + self._axis_offset = (0.0, 0.0) else: self._frame = None self._speckle_grains = generate_speckle_grains( @@ -244,6 +255,22 @@ class _SimIDSBackend: amplitude_range=(120.0, 200.0), seed=speckle_seed, ) + if axis_offset_range_px > 0: + # axis_offset_seed=None (the default) means a genuinely + # fresh random offset every time the device is constructed + # (e.g. each BEC session restart) -- a new "true" rotation + # axis for the operator to find each time, unless a fixed + # seed is given for reproducible testing. + rng = np.random.default_rng(axis_offset_seed) + self._axis_offset = tuple( + rng.uniform(-axis_offset_range_px, axis_offset_range_px, 2) + ) + logger.info( + f"SimIDSCamera: simulated rotation axis offset from image center = " + f"{self._axis_offset} px (ground truth for testing convergence)" + ) + else: + self._axis_offset = (0.0, 0.0) def on_connect(self): self._connected = True @@ -270,6 +297,7 @@ class _SimIDSBackend: rgb=self._rgb, angle_deg=self._current_angle_deg(), grains=self._speckle_grains, + axis_offset=self._axis_offset, ) if self.force_monochrome and frame.ndim == 3: frame = frame[:, :, 0] @@ -303,6 +331,8 @@ class SimIDSCamera(IDSCamera): sim_feature_radius_px: float | None = None, sim_speckle_count: int = 60, sim_speckle_seed: int = 42, + sim_axis_offset_range_px: float = 0.0, + sim_axis_offset_seed: int | None = None, **kwargs, ): if camera_ID is not None and not camera_id: @@ -329,5 +359,7 @@ class SimIDSCamera(IDSCamera): feature_radius_px=sim_feature_radius_px, speckle_count=sim_speckle_count, speckle_seed=sim_speckle_seed, + axis_offset_range_px=sim_axis_offset_range_px, + axis_offset_seed=sim_axis_offset_seed, ) self.cam.force_monochrome = self._force_monochrome diff --git a/tests/tests_devices/test_sim_cameras.py b/tests/tests_devices/test_sim_cameras.py index 7f5500a..0553cb7 100644 --- a/tests/tests_devices/test_sim_cameras.py +++ b/tests/tests_devices/test_sim_cameras.py @@ -164,3 +164,86 @@ def test_sim_ids_camera_threads_rotation_coupling_through(): ax.pos_steps = 180.0 * ax.stppermm frame180 = camera.cam.get_image_data() assert not np.array_equal(frame0, frame180) + + +def test_sim_ids_backend_axis_offset_defaults_to_zero(): + """Without an explicit axis_offset_range_px, the rotation axis stays at + image center -- unchanged behavior for existing rotation_coupling users.""" + host, port, axis_letter = "test-galil-4.example", 9300, "C" + galil = SimStateRegistry.get(SimGalilState, host, port) + ax = galil.axis(ord(axis_letter.lower()) - 97) + ax.stppermm = 1000.0 + ax.pos_steps = 0.0 + + coupling = {"galil_host": host, "galil_port": port, "axis": axis_letter} + backend = _SimIDSBackend(width=200, height=200, rgb=False, rotation_coupling=coupling) + assert backend._axis_offset == (0.0, 0.0) + + +def test_sim_ids_backend_axis_offset_reproducible_with_seed(): + """A given axis_offset_seed always produces the same offset, and the + rendered frame matches make_speckle_test_pattern called directly with + that same offset.""" + host, port, axis_letter = "test-galil-3.example", 9200, "C" + galil = SimStateRegistry.get(SimGalilState, host, port) + ax = galil.axis(ord(axis_letter.lower()) - 97) + ax.stppermm = 1000.0 + ax.pos_steps = 0.0 + + coupling = {"galil_host": host, "galil_port": port, "axis": axis_letter} + radius_px = 50.0 + backend = _SimIDSBackend( + width=200, + height=200, + rgb=False, + rotation_coupling=coupling, + feature_radius_px=radius_px, + axis_offset_range_px=30.0, + axis_offset_seed=99, + ) + assert backend._axis_offset != (0.0, 0.0) + assert all(abs(v) <= 30.0 for v in backend._axis_offset) + + # same seed -> same offset, reconstructed independently + backend2 = _SimIDSBackend( + width=200, + height=200, + rgb=False, + rotation_coupling=coupling, + feature_radius_px=radius_px, + axis_offset_range_px=30.0, + axis_offset_seed=99, + ) + assert backend._axis_offset == backend2._axis_offset + + expected_grains = generate_speckle_grains( + n_speckles=60, + radius_range=(0.3 * radius_px, radius_px), + sigma_range=(2.0, 5.0), + amplitude_range=(120.0, 200.0), + seed=42, + ) + frame = backend.get_image_data() + expected = make_speckle_test_pattern( + 200, + 200, + rgb=False, + angle_deg=0.0, + grains=expected_grains, + axis_offset=backend._axis_offset, + ) + assert np.array_equal(frame, expected) + + +def test_make_speckle_test_pattern_axis_offset_shifts_rotation_center(): + """axis_offset shifts where the grains orbit, away from image center.""" + height, width = 200, 300 + grains = [(0.0, 0.0, 3.0, 200.0)] # radius=0 -> grain sits exactly on the axis + axis_offset = (20.0, -10.0) + frame = make_speckle_test_pattern( + height, width, rgb=False, angle_deg=0.0, grains=grains, axis_offset=axis_offset + ) + cy, cx = height / 2.0, width / 2.0 + py, px = np.unravel_index(np.argmax(frame), frame.shape) + assert abs(py - (cy + axis_offset[1])) <= 2 + assert abs(px - (cx + axis_offset[0])) <= 2 -- 2.54.0 From e2533d286fc343b7bc444a99188e5c5bbef97608 Mon Sep 17 00:00:00 2001 From: x01dc Date: Fri, 24 Jul 2026 14:44:18 +0200 Subject: [PATCH 08/11] chore(bec_widgets): regenerate plugin RPC client stubs (bw-generate-cli) csaxs_bec/bec_widgets/widgets/client.py is a checked-in generated file (via `bw-generate-cli --target csaxs_bec`) containing the client-side RPC stub for each plugin widget's USER_ACCESS surface -- BECGuiClient resolves widget classes by name against this exact file, not via a dynamic import of the real widget class, so it must be regenerated whenever a plugin widget's USER_ACCESS changes. Picks up XRayEye.set_live_view_signal (added for the LamNI smear calibration aid) plus other plugin widgets that had drifted out of sync already (SAXSWidget.move_to_selected_row, a SampleStorageWidget/ TomoParamsWidget docstring update) -- unrelated to this session's work but caught by the same regeneration pass. Co-Authored-By: Claude Sonnet 5 --- csaxs_bec/bec_widgets/widgets/client.py | 87 ++++++++++++++----- .../bec_widgets/widgets/designer_plugins.py | 4 +- 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/csaxs_bec/bec_widgets/widgets/client.py b/csaxs_bec/bec_widgets/widgets/client.py index 381fef2..30b6768 100644 --- a/csaxs_bec/bec_widgets/widgets/client.py +++ b/csaxs_bec/bec_widgets/widgets/client.py @@ -4,6 +4,7 @@ from __future__ import annotations from bec_lib.logger import bec_logger + from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout logger = bec_logger.logger @@ -13,8 +14,8 @@ logger = bec_logger.logger _Widgets = { "ConsoleButtonsWidget": "ConsoleButtonsWidget", - "SampleStorageWidget": "SampleStorageWidget", "SAXSWidget": "SAXSWidget", + "SampleStorageWidget": "SampleStorageWidget", "SlitControlWidget": "SlitControlWidget", "TomoParamsWidget": "TomoParamsWidget", "XRayEye": "XRayEye", @@ -62,25 +63,6 @@ class ConsoleButtonsWidget(RPCBase): """ -class SampleStorageWidget(RPCBase): - """View and correct the FlOMNI sample-storage records.""" - - _IMPORT_MODULE = "csaxs_bec.bec_widgets.widgets.sample_storage.sample_storage" - - @rpc_call - def refresh(self) -> "None": - """ - Re-read all slots (one bulk device round-trip) and update only the - cells whose state actually changed. - - Repainting every cell on every 2 s poll — even when nothing changed, - which is almost always the case for a sample magazine — was needless - work on the GUI thread. We diff against the last-seen state and only - call set_state() on cells that differ, so a steady-state poll does no - UI work at all. - """ - - class SAXSWidget(RPCBase): """Widget for preparing SAXS measurement tables from an image ROI.""" @@ -156,6 +138,31 @@ class SAXSWidget(RPCBase): None """ + @rpc_call + def move_to_selected_row(self, index: "int") -> "None": + """ + None + """ + + +class SampleStorageWidget(RPCBase): + """View and correct the FlOMNI sample-storage records.""" + + _IMPORT_MODULE = "csaxs_bec.bec_widgets.widgets.sample_storage.sample_storage" + + @rpc_call + def refresh(self) -> "None": + """ + Re-read all slots (one bulk device round-trip) and update only the + cells whose state actually changed. + + Repainting every cell on every 2 s poll — even when nothing changed, + which is almost always the case for a sample magazine — was needless + work on the GUI thread. We diff against the last-seen state and only + call set_state() on cells that differ, so a steady-state poll does no + UI work at all. + """ + class SlitControlWidget(RPCBase): """Interactive GUI for cSAXS slit center and size control.""" @@ -201,7 +208,7 @@ class SlitControlWidget(RPCBase): class TomoParamsWidget(RPCBase): - """Interactive GUI for FlOMNI tomo scan parameter editing and queue management.""" + """Interactive GUI for tomo scan parameter editing and queue management, for""" _IMPORT_MODULE = "csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params" @@ -214,7 +221,8 @@ class TomoParamsWidget(RPCBase): @rpc_call def enter_edit_mode(self) -> "None": """ - Unlock all parameter fields for editing. + Unlock all parameter fields for editing, populated from the live + backend. """ @rpc_call @@ -227,6 +235,13 @@ class TomoParamsWidget(RPCBase): def submit_params(self) -> "None": """ Validate, write to global vars, and re-lock fields. + + Hard-blocked while the beamline is busy (plan/AI_docs + TOMO_QUEUE_COMMAND_JOBS_PLAN.md section 6.1's "must be blocked" rule, + made a real block rather than a soft confirm now that there's a safe + alternative for the "I want to prepare a parameter set while a scan + is running" case: add_edited_to_queue() below, which never touches + these live global vars). """ @rpc_call @@ -235,6 +250,21 @@ class TomoParamsWidget(RPCBase): Open queue dialog and trigger Add (also accessible via RPC). """ + @rpc_call + def add_edited_to_queue(self) -> "None": + """ + Package the current (possibly unsubmitted) form fields as a new + tomo-queue job and re-lock the fields, without ever touching the + live param global vars. + + This is the safe alternative to Submit while the beamline is busy: + it writes only to the ``tomo_queue`` var (see + TOMO_QUEUE_COMMAND_JOBS_PLAN.md section 6.1's two-write-paths rule), + so it's always allowed, whether or not a scan is running -- unlike + the CLI's ``tomo_queue_add()``, which snapshots the *live* vars and + would silently ignore an in-progress edit. + """ + class XRayEye(RPCBase): _IMPORT_MODULE = "csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye" @@ -392,6 +422,19 @@ class XRayEye(RPCBase): on_live_view_enabled). """ + @rpc_call + def set_live_view_signal(self, signal: "str" = "image") -> "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. + """ + class XRayEye2DControl(RPCBase): _IMPORT_MODULE = "csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye" diff --git a/csaxs_bec/bec_widgets/widgets/designer_plugins.py b/csaxs_bec/bec_widgets/widgets/designer_plugins.py index 63226c3..b40f400 100644 --- a/csaxs_bec/bec_widgets/widgets/designer_plugins.py +++ b/csaxs_bec/bec_widgets/widgets/designer_plugins.py @@ -9,11 +9,11 @@ designer_plugins = { "csaxs_bec.bec_widgets.widgets.console_buttons.console_buttons", "ConsoleButtonsWidget", ), + "SAXSWidget": ("csaxs_bec.bec_widgets.widgets.saxs_widget.saxs_widget", "SAXSWidget"), "SampleStorageWidget": ( "csaxs_bec.bec_widgets.widgets.sample_storage.sample_storage", "SampleStorageWidget", ), - "SAXSWidget": ("csaxs_bec.bec_widgets.widgets.saxs_widget.saxs_widget", "SAXSWidget"), "SlitControlWidget": ( "csaxs_bec.bec_widgets.widgets.slit_control.slit_control", "SlitControlWidget", @@ -27,8 +27,8 @@ designer_plugins = { widget_icons = { "ConsoleButtonsWidget": "widgets", - "SampleStorageWidget": "widgets", "SAXSWidget": "table_chart", + "SampleStorageWidget": "widgets", "SlitControlWidget": "widgets", "TomoParamsWidget": "widgets", "XRayEye": "widgets", -- 2.54.0 From 831e75675c847790bfcf7be0ae291eed349b9b6c Mon Sep 17 00:00:00 2001 From: x01dc Date: Fri, 24 Jul 2026 14:48:29 +0200 Subject: [PATCH 09/11] docs(LamNI): document smear aid architecture and camera/GUI interfaces Add docs/developer/lamni_smear_architecture.md covering the three-tier device/ipython-client/GUI architecture this feature exercises: the camera's two PreviewSignal channels (image vs smear_preview) and why composite pushes needed their own channel, XRayEye.set_live_view_signal and exactly when the caller must switch it back, the sweep algorithm (non-blocking mv() + ScanReport.status polling + max-projection), and the bw-generate-cli regeneration step required whenever a plugin widget's USER_ACCESS changes (the cause of this session's confusing AttributeError even after a full client/GUI restart). Also expand the user-facing description in lamni.md with a note on the live-updating composite display and a cross-reference to the new developer page. Co-Authored-By: Claude Sonnet 5 --- docs/developer/developer.md | 12 ++ docs/developer/lamni_smear_architecture.md | 168 +++++++++++++++++++++ docs/user/ptychography/lamni.md | 2 +- 3 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 docs/developer/lamni_smear_architecture.md diff --git a/docs/developer/developer.md b/docs/developer/developer.md index d97f29d..664c3d3 100644 --- a/docs/developer/developer.md +++ b/docs/developer/developer.md @@ -9,6 +9,7 @@ hidden: true --- editing_docs +lamni_smear_architecture ``` @@ -30,4 +31,15 @@ editing_docs Conventions for writing these MyST/Sphinx docs and how changes go live on Read the Docs. ``` +```{grid-item-card} +:link: developer.lamni_smear_architecture +:link-type: ref +:text-align: center +:class-item: index-card + +## LamNI smear aid architecture + +Device/ipython-client/GUI widget interfaces for the smear rotation-center calibration aid, and the `bw-generate-cli` regeneration pitfall. +``` + ```` diff --git a/docs/developer/lamni_smear_architecture.md b/docs/developer/lamni_smear_architecture.md new file mode 100644 index 0000000..008b678 --- /dev/null +++ b/docs/developer/lamni_smear_architecture.md @@ -0,0 +1,168 @@ +(developer.lamni_smear_architecture)= + +# LamNI smear rotation-center aid: architecture notes + +The experimental continuous-rotation "smear" calibration aid (see +{ref}`the user-facing description `) touches all +three layers of a typical BEC GUI feature — a **device**, an **ipython-client +plugin**, and a **GUI widget** — running as three separate processes talking +over BEC's Redis-backed message bus. It's a useful worked example for how +those layers connect, and it surfaced a couple of non-obvious pitfalls worth +documenting so the next person doesn't have to re-discover them. + +## The three tiers + +Three separate processes, connected over BEC's Redis-backed message bus: + +```text +ipython client (XrayEyeAlign, LamNI) + │ + ├── device RPC ──────► IDSCamera / SimIDSCamera (device server process) + │ dev.cam_xeye.push_smear_preview(...) │ + │ │ image / smear_preview + │ │ PreviewSignal (pub/sub) + │ ▼ + └── widget RPC ──────► XRayEye widget (GUI server process) + self.gui.set_live_view_signal(...) +``` + +- **`IDSCamera`** (`csaxs_bec/devices/ids_cameras/ids_camera.py`, simulated + counterpart `csaxs_bec/devices/sim/sim_cameras.py`) runs in the device + server process and owns the actual camera hardware (or its simulation). +- **`XrayEyeAlign`** / **`LamNI`** (`csaxs_bec/bec_ipython_client/plugins/LamNI/`) + run in the operator's ipython client process and orchestrate the + calibration procedure — rotating the sample, grabbing frames, driving the + GUI. +- **`XRayEye`** (`csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py`) is a + Qt widget running in its own GUI server process, displaying the live image + and collecting the operator's click. + +Devices and widgets each expose a `USER_ACCESS` list of methods callable +remotely; the ipython client reaches them as `dev.cam_xeye.(...)` and +`self.gui.(...)` respectively. Image data itself flows over a +separate publish/subscribe channel (`PreviewSignal`), not through those RPC +calls. + +## Camera interface: two preview channels + +`IDSCamera` exposes two `PreviewSignal` components: + +- **`image`** — the live-acquisition channel. A background thread + (`_live_mode_loop`) continuously grabs frames and `put()`s them here at + ~5 Hz while `live_mode_enabled` is `True`. Configured per-device with + `num_rotation_90`/`transpose` (e.g. `num_rotation_90=3` for `cam_xeye`), + applied automatically inside `PreviewSignal.put()`. +- **`smear_preview`** — a second, independent channel, added for this + feature, with **no** rotation/transpose configured. Nothing else writes to + it, so publishing here never races with the live thread. + +`PreviewSignal` (and every `BECMessageSignal` subclass) has `rpc_access` +hardcoded off, so it is **not** reachable as a plain attribute from client +code — `dev.cam_xeye.image.put(...)` raises `AttributeError` even though the +signal exists on the real device. The supported pattern is a plain device +method that does the `.put()` server-side, where the signal *is* a normal +ophyd attribute: + +- `get_last_image()` — read side; returns `self.image.get().data`, i.e. + already display-oriented (rotation/transpose already applied by whichever + `.put()` call stored it). +- `push_preview_image(data)` — write side for `image`. Since data built from + `get_last_image()` frames has already been transformed once, this method + undoes the transform first so `self.image.put()`'s own transform doesn't + apply it a second time (compensation is reverse-order: transpose first, + then rotate by `-num_rotation_90`). +- `push_smear_preview(data)` — write side for `smear_preview`. No + compensation needed or applied, since that channel has no transform + configured. + +**Why two channels, not one:** the first working version pushed composites +through `push_preview_image` (the `image` channel), which meant briefly +setting `live_mode_enabled = False` around every push so the live thread +couldn't immediately overwrite the composite, then setting it back to `True` +afterward. That's instant in simulation, but on real hardware toggling +`live_mode_enabled` starts/stops the actual acquisition thread and has real, +non-trivial latency — repeated dozens of times over a sweep, this was the +dominant cost and showed up as the GUI's "camera running" indicator visibly +flapping. Giving the composite its own channel means `live_mode_enabled` is +never toggled during a sweep at all — it's set to `True` once at the start +(if not already), exactly like the production `_live_sweep()`, and left +alone. + +## GUI interface: switching the displayed channel + +`XRayEye.set_live_view_signal(signal="image")` switches which +`(device, signal)` pair the widget's `Image` view is subscribed to (via the +same `image()`/`disconnect_monitor()` calls `on_live_view_enabled()` already +used, now parameterized instead of hardcoded to `"image"`). Every freshly +constructed `XRayEye` defaults to `"image"`, so opening the GUI or running +any other alignment routine is unaffected unless something explicitly calls +`set_live_view_signal("smear_preview")`. + +The smear sweep (`XrayEyeAlign._smear_sweep`) switches to `"smear_preview"` +at the start and pushes composite updates there as the rotation +progresses — but **deliberately does not switch back to `"image"` itself** +on a successful return. The composite has to stay on screen until the +operator's click has actually been collected +(`find_rotation_center_smear_experimental` switches back right after +`_collect_click()` returns); switching back inside `_smear_sweep()` would +show the operator a live raw frame instead of the composite at exactly the +moment they need to click on it. On `KeyboardInterrupt` mid-sweep there's +nothing to click on, so that path switches back to `"image"` immediately — +both inside `_smear_sweep()`'s own handler and, as a second safety net, in +`lamni.py`'s top-level `except KeyboardInterrupt` in case the interrupt +lands after the sweep but before the click completes. + +## How the sweep itself works + +`_smear_sweep()` rotates continuously and accumulates frames without +stepping the motor: + +1. `scans.mv(dev.lsamrot, target)` (the file's `mv()` wrapper, sibling of the + existing blocking `umv()`) issues the rotation **non-blocking** — it + returns a `ScanReport` immediately rather than waiting for the move to + finish. +2. A plain loop polls `report.status` (exactly what `ScanReport.wait()` does + internally) and, while it isn't `"COMPLETED"`, grabs a frame via + `get_last_image()` and folds it into a running `np.maximum` composite — + all single-threaded, since the rotation is progressing concurrently on + the server regardless of what the client does between polls. +3. Every `display_update_interval_s`, the current composite is pushed to + `smear_preview` so the operator watches it grow; off-axis features trace + arcs as the sample turns, and the common center of curvature of those + arcs is the rotation axis. +4. Once the move completes, a final push leaves the finished composite + displayed, the shutter is closed (unless `keep_shutter_open`), and the + operator submits a click — reusing the exact same `_collect_click()` / + `_compute_shift_to_fzp()` / `_apply_rotation_center_shift()` machinery and + cumulative-shift accounting as the production `..._extended()` path. + +## Pitfall: regenerate the RPC client stub after changing `USER_ACCESS` + +:::{important} +Whenever you add or rename a method in a plugin widget's `USER_ACCESS` list +(e.g. `XRayEye.USER_ACCESS` in `x_ray_eye.py`), you must run: + +```bash +bw-generate-cli --target csaxs_bec +``` + +and commit the resulting changes to `csaxs_bec/bec_widgets/widgets/client.py` +(and `designer_plugins.py`). Skipping this step does **not** raise an error +at import time or widget-construction time — it fails much later, and +confusingly, as an `AttributeError` raised from deep inside +`bec_widgets`'s RPC plumbing, even after fully restarting both the ipython +client and the GUI server process. +::: + +The reason: `csaxs_bec/bec_widgets/widgets/client.py` is a **checked-in +generated file** containing one RPC stub class per plugin widget, mirroring +its `USER_ACCESS` at the time `bw-generate-cli` was last run. The ipython +client's `BECGuiClient._add_widget()` resolves a widget's class by +**name lookup against that generated module** +(`getattr(client, state["widget_class"], None)`) — not by dynamically +importing the real widget class — so a stale generated file means the new +method genuinely doesn't exist anywhere the client can find it, no matter +how fresh the actual `XRayEye` class on disk is. This is exactly the same +kind of generated-stub step core `bec_widgets` widgets also need (their +generated file lives in the `bec_widgets` package itself); plugin widgets +just have their own copy living inside `csaxs_bec`. diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md index ad9e74c..569c935 100644 --- a/docs/user/ptychography/lamni.md +++ b/docs/user/ptychography/lamni.md @@ -35,7 +35,7 @@ Pass `keep_shutter_open=True` if it's hard to relocate the sample between steps, **Experimental: continuous-rotation "smear" aid** -`lamni.xrayeye_rotation_center_calibration_smear_experimental(sweep_deg=360.0, keep_shutter_open=False)` is an **experimental** alternative to `..._extended()` for the same non-isolated-sample case. Instead of judging the rotation centre from a single instant of live rotation, it rotates `lsamrot` continuously through `sweep_deg` (default a full circle) while accumulating a max-projection ("star-trail") composite from the camera: off-axis features smear into circular arcs, and the common center of curvature of those arcs is the rotation axis — usually an easier target to click than one live frame. `sweep_deg` can be reduced below 360 (even below 180) if a shorter arc already shows enough curvature. There is no automatic circle fitting — you still submit the centre by eye, same click mechanism as `..._extended()`, and the same iterate/verify/apply flow follows. Not yet merged into the production branch; try it from `experimental/rotation_center_smear`. +`lamni.xrayeye_rotation_center_calibration_smear_experimental(sweep_deg=360.0, keep_shutter_open=False)` is an **experimental** alternative to `..._extended()` for the same non-isolated-sample case. Instead of judging the rotation centre from a single instant of live rotation, it rotates `lsamrot` continuously through `sweep_deg` (default a full circle) while accumulating a max-projection ("star-trail") composite from the camera: off-axis features smear into circular arcs, and the common center of curvature of those arcs is the rotation axis — usually an easier target to click than one live frame. The composite builds up live on screen as the sweep progresses (pushed to its own preview channel, so the "camera running" indicator stays steady throughout — see {ref}`the developer notes ` for why that matters) and stays frozen once the sweep ends, until you submit your click. `sweep_deg` can be reduced below 360 (even below 180) if a shorter arc already shows enough curvature. There is no automatic circle fitting — you still submit the centre by eye, same click mechanism as `..._extended()`, and the same iterate/verify/apply flow follows. Not yet merged into the production branch; try it from `experimental/rotation_center_smear`. **Manual fallback** -- 2.54.0 From 39e81130afef93614cb5898cc6b3c9a8ba0d1713 Mon Sep 17 00:00:00 2001 From: x01dc Date: Fri, 24 Jul 2026 15:23:17 +0200 Subject: [PATCH 10/11] fix(LamNI): reset stale live-view channel, add GUI status/toggle switches A Ctrl-C interrupting an in-flight RPC call (e.g. mid set_live_view_signal) could leave the XRayEye widget stuck showing the smear-preview channel afterward -- and since lamnigui_show_xeyealign() reuses the existing xeyegui widget across calls rather than recreating it, that stale state would silently carry into the *next* alignment run too (including production align()/find_rotation_center(), since on_live_view_enabled() now respects whichever channel is selected). Fix: lamnigui_show_xeyealign() unconditionally resets both set_live_view_signal("image") and set_smear_active(False) every time it's called -- both are cheap no-ops if already in the default state, and this is the single shared entry point for every alignment procedure, so one fix covers all of them. Also add two GUI-facing controls to XRayEye, in a new row below the shutter/camera-running toggles: - "Smear preview channel" -- an interactive toggle mirroring set_live_view_signal(), so the operator has a GUI-only recovery path independent of the ipython client (e.g. if it's hung/disconnected). - "Smear integrating" -- a read-only status indicator (disabled ToggleSwitch) reflecting whether _smear_sweep() is currently accumulating a composite, driven entirely by the automation. set_live_view_signal() keeps the interactive toggle's visual state in sync regardless of who changed the channel (automation or operator) -- single source of truth. _smear_sweep() brackets accumulation with set_smear_active(True)/(False), turning it off as soon as the sweep ends (independent of the channel switch, which stays on the composite until the operator's click is collected). Both new XRayEye methods required regenerating csaxs_bec/bec_widgets/widgets/client.py via bw-generate-cli --target csaxs_bec. Co-Authored-By: Claude Sonnet 5 --- .../plugins/LamNI/gui_tools.py | 8 +++ .../bec_ipython_client/plugins/LamNI/lamni.py | 4 ++ .../plugins/LamNI/x_ray_eye_align.py | 7 +++ csaxs_bec/bec_widgets/widgets/client.py | 11 ++++ .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 55 ++++++++++++++++--- .../test_gui_tools.py | 54 ++++++++++++++++++ .../test_x_ray_eye_align.py | 4 ++ 7 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 tests/tests_bec_ipython_client/test_gui_tools.py diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py index e5efa97..f625a26 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py @@ -89,6 +89,14 @@ class LamniGuiTools: self.xeyegui = self.gui.lamni.new( "XRayEye", object_name="xrayeye", timeout=self.GUI_RPC_TIMEOUT ) + # An existing xeyegui widget is reused across calls, so a Ctrl-C + # that interrupted a prior run mid-RPC (e.g. mid set_live_view_signal) + # could otherwise leave it stuck on the smear-preview channel/ + # indicator for every alignment procedure that follows. Reset + # unconditionally on every open -- both calls are cheap no-ops if + # already in the default state. + self.xeyegui.set_live_view_signal("image") + self.xeyegui.set_smear_active(False) # start live if not dev.cam_xeye.live_mode_enabled.get(): dev.cam_xeye.live_mode_enabled.put(True) diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index 3538ca5..f20f8bd 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -559,6 +559,10 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools 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.set_smear_active(False) + except Exception as gui_exc: # pylint: disable=broad-except + logger.warning(f"Failed to reset XRayEye smear-active indicator: {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 dd4eab6..d319def 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 @@ -710,6 +710,10 @@ class XrayEyeAlign: 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. + gui.set_smear_active(True/False) brackets the accumulation itself, + driving a read-only "integrating" indicator in the GUI -- turned + off as soon as the sweep ends (accumulation genuinely stops then), + independent of the channel switch below. 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 @@ -725,6 +729,7 @@ class XrayEyeAlign: dev.cam_xeye.live_mode_enabled.put(True) self.gui.on_live_view_enabled(True) self.gui.set_live_view_signal("smear_preview") + self.gui.set_smear_active(True) dev.fsh.fshopen() self._disable_rt_feedback() @@ -762,10 +767,12 @@ class XrayEyeAlign: except KeyboardInterrupt: print("[rotation-center][smear] sweep interrupted -- cancelling in-flight rotation.") report.cancel() + self.gui.set_smear_active(False) self.gui.set_live_view_signal("image") raise self._push_smear_composite(composite) + self.gui.set_smear_active(False) if not keep_shutter_open: dev.fsh.fshclose() print( diff --git a/csaxs_bec/bec_widgets/widgets/client.py b/csaxs_bec/bec_widgets/widgets/client.py index 30b6768..00522c6 100644 --- a/csaxs_bec/bec_widgets/widgets/client.py +++ b/csaxs_bec/bec_widgets/widgets/client.py @@ -435,6 +435,17 @@ class XRayEye(RPCBase): changes here unless a caller explicitly requests it. """ + @rpc_call + def set_smear_active(self, active: "bool") -> "None": + """ + Update the read-only "smear integrating" indicator. + + Purely a status display -- driven by the smear rotation-center + calibration aid while it's accumulating a composite; not directly + togglable by the operator (see smear_active_toggle, disabled in + _init_ui()). + """ + class XRayEye2DControl(RPCBase): _IMPORT_MODULE = "csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye" 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 456e8de..4373a78 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 @@ -254,6 +254,7 @@ class XRayEye(BECWidget, QWidget): "crosshair_position", "reset_zoom", "set_live_view_signal", + "set_smear_active", ] PLUGIN = True @@ -362,6 +363,27 @@ class XRayEye(BECWidget, QWidget): switch_row.addWidget(self.camera_running_toggle, 0, Qt.AlignmentFlag.AlignVCenter) self.control_panel_layout.addWidget(self.switch_row_widget) + # Smear rotation-center calibration aid: which channel is displayed, + # and whether it's currently accumulating a composite. + self.smear_row_widget = QWidget(parent=self) + smear_row = QHBoxLayout(self.smear_row_widget) + smear_row.setContentsMargins(0, 0, 0, 0) + smear_row.setSpacing(8) + smear_row.addStretch() + self.smear_active_label = QLabel("Smear integrating", parent=self) + self.smear_active_toggle = ToggleSwitch(parent=self) + self.smear_active_toggle.checked = False + self.smear_active_toggle.setEnabled(False) # read-only status, not operator-togglable + self.smear_preview_label = QLabel("Smear preview channel", parent=self) + self.smear_preview_toggle = ToggleSwitch(parent=self) + self.smear_preview_toggle.checked = False + self.smear_preview_toggle.enabled.connect(self.toggle_smear_preview_channel) + smear_row.addWidget(self.smear_active_label, 0, Qt.AlignmentFlag.AlignVCenter) + smear_row.addWidget(self.smear_active_toggle, 0, Qt.AlignmentFlag.AlignVCenter) + smear_row.addWidget(self.smear_preview_label, 0, Qt.AlignmentFlag.AlignVCenter) + smear_row.addWidget(self.smear_preview_toggle, 0, Qt.AlignmentFlag.AlignVCenter) + self.control_panel_layout.addWidget(self.smear_row_widget) + # separator self.control_panel_layout.addWidget(self._create_separator()) @@ -786,13 +808,32 @@ class XRayEye(BECWidget, QWidget): 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 + if signal != self._live_view_signal: + 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 + # Keep the toggle's visual state in sync regardless of whether this + # call actually changed anything, and regardless of who called it + # (the toggle itself, or the ipython client's automation). + self.smear_preview_toggle.blockSignals(True) + self.smear_preview_toggle.checked = self._live_view_signal != CAMERA[1] + self.smear_preview_toggle.blockSignals(False) + + def set_smear_active(self, active: bool) -> None: + """Update the read-only "smear integrating" indicator. + + Purely a status display -- driven by the smear rotation-center + calibration aid while it's accumulating a composite; not directly + togglable by the operator (see smear_active_toggle, disabled in + _init_ui()). + """ + self.smear_active_toggle.checked = active + + @SafeSlot(bool) + def toggle_smear_preview_channel(self, enabled: bool): + self.set_live_view_signal("smear_preview" if enabled else CAMERA[1]) @SafeSlot(bool) def camera_running_enabled(self, enabled: bool): diff --git a/tests/tests_bec_ipython_client/test_gui_tools.py b/tests/tests_bec_ipython_client/test_gui_tools.py new file mode 100644 index 0000000..c29a9c8 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_gui_tools.py @@ -0,0 +1,54 @@ +"""Unit tests for LamniGuiTools.lamnigui_show_xeyealign()'s live-view reset.""" + +from unittest import mock + +from csaxs_bec.bec_ipython_client.plugins.LamNI.gui_tools import LamniGuiTools + +# pylint: disable=protected-access + +GUI_TOOLS = "csaxs_bec.bec_ipython_client.plugins.LamNI.gui_tools" + + +def _make_gui_tools(): + tools = LamniGuiTools() + client = mock.MagicMock() + tools.set_client(client) + return tools, client + + +def test_lamnigui_show_xeyealign_resets_live_view_on_reuse(): + """A previously-interrupted run (e.g. Ctrl-C mid set_live_view_signal) + could leave xeyegui stuck on the smear channel/indicator; since the + widget is reused (not recreated) across calls, opening the alignment + view must reset both every time, not just on first creation.""" + tools, client = _make_gui_tools() + tools.xeyegui = mock.MagicMock() + tools.xeyegui._is_deleted.return_value = False + client.gui.windows = {"lamni": mock.MagicMock()} + + dev_mock = mock.MagicMock() + dev_mock.cam_xeye.live_mode_enabled.get.return_value = True + + with mock.patch(f"{GUI_TOOLS}.dev", dev_mock, create=True): + tools.lamnigui_show_xeyealign() + + tools.xeyegui.set_live_view_signal.assert_called_once_with("image") + tools.xeyegui.set_smear_active.assert_called_once_with(False) + + +def test_lamnigui_show_xeyealign_resets_on_fresh_widget_too(): + """Even a freshly-created widget gets the reset call -- cheap/no-op, + and keeps the behavior uniform regardless of prior state.""" + tools, client = _make_gui_tools() + client.gui.windows = {"lamni": mock.MagicMock()} + new_widget = mock.MagicMock() + client.gui.lamni.new.return_value = new_widget + + dev_mock = mock.MagicMock() + dev_mock.cam_xeye.live_mode_enabled.get.return_value = True + + with mock.patch(f"{GUI_TOOLS}.dev", dev_mock, create=True): + tools.lamnigui_show_xeyealign() + + new_widget.set_live_view_signal.assert_called_once_with("image") + new_widget.set_smear_active.assert_called_once_with(False) 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 6d537d3..1b8c35f 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 @@ -419,6 +419,9 @@ def test_smear_sweep_max_projection(bec_client_mock): # 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") + # "integrating" indicator on for the sweep, off again once it's done -- + # unlike the channel, this doesn't wait for the click. + assert [c.args[0] for c in align.gui.set_smear_active.call_args_list] == [True, False] def test_smear_sweep_issues_single_continuous_move(bec_client_mock): @@ -472,6 +475,7 @@ def test_smear_sweep_cancels_report_on_keyboard_interrupt(bec_client_mock): # 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") + align.gui.set_smear_active.assert_any_call(False) def test_find_rotation_center_smear_experimental_switches_live_view_signal(bec_client_mock): -- 2.54.0 From 83f9a6bb5912570a24815777bff67ef52bcac421 Mon Sep 17 00:00:00 2001 From: x01dc Date: Fri, 24 Jul 2026 15:44:10 +0200 Subject: [PATCH 11/11] fix(LamNI): show cached composite on manual channel switch, align toggle grid, archive smear runs Three follow-ups from real-hardware testing of the smear aid: 1. Switching the "Smear preview channel" toggle off then back on showed the live camera feed, not the previous composite. Root cause: Image.image()'s connect_slot subscription only delivers stream entries published *after* connecting (bec_lib's default register(from_start=False, newest_only=False) bookmarks the then-current last entry and only forwards newer ones) -- so nothing re-renders on reconnect until the next sweep pushes a new frame. Added a permanent, independent subscription (newest_only=True) that keeps the last smear_preview payload cached regardless of which channel is currently displayed; set_live_view_signal() now force- renders that cache via main_image.set_data() when switching back to "smear_preview", instead of waiting for a push that may never come. Naturally "resets" once a new sweep starts pushing fresh composites. 2. The new switch row wasn't column-aligned with the existing shutter/camera-running row (each used an independent QHBoxLayout sized by its own labels' widths). Merged both rows into one QGridLayout with right-aligned label columns, so all four toggles line up regardless of label text length. Shutter now pairs with Smear integrating, Camera running with Smear preview channel. 3. find_rotation_center_smear_experimental() now archives each run to a timestamped HDF5 file (~/data/raw/logs/xrayeye_smear_calibration/), mirroring align()'s existing _save_alignment_data() pattern: the FZP reference, one composite image + one clicked centre per iteration, and the final result -- collecting data towards eventually automating center detection from the composite offline. Co-Authored-By: Claude Sonnet 5 --- .../plugins/LamNI/x_ray_eye_align.py | 43 ++++++++- .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 93 +++++++++++++------ .../test_x_ray_eye_align.py | 35 ++++++- 3 files changed, 140 insertions(+), 31 deletions(-) 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 d319def..9abe225 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 @@ -813,6 +813,8 @@ class XrayEyeAlign: self.gui.set_dap_params_forwarding(False) self._reset_init_values() self.alignment_images = [] + self.smear_composites = [] + self.smear_clicks = [] self.movement_buttons_enabled(False, False) self.gui.enable_submit_button(False) dev.omny_xray_gui.mvx.set(0) @@ -847,7 +849,7 @@ class XrayEyeAlign: self.send_message( "Watch the sample rotate and locate the stationary rotation centre..." ) - self._smear_sweep(sweep_deg=sweep_deg, keep_shutter_open=keep_shutter_open) + composite = self._smear_sweep(sweep_deg=sweep_deg, keep_shutter_open=keep_shutter_open) self.movement_buttons_enabled(True, True) center_x, center_y = self._collect_click( 1, @@ -855,6 +857,8 @@ class XrayEyeAlign: label=f"rotation centre, iter {iteration}", ) self.gui.set_live_view_signal("image") + self.smear_composites.append(composite) + self.smear_clicks.append((center_x, center_y)) 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 @@ -922,8 +926,45 @@ class XrayEyeAlign: else: print("Shutter left open.") + timestamp = time.strftime("%Y%m%d_%H%M%S") + file_h5 = ( + f"~/data/raw/logs/xrayeye_smear_calibration/xrayeye_smear_calibration_{timestamp}.h5" + ) + self._save_smear_calibration_data( + file_h5, + fzp_xy=(fzp_x, fzp_y), + new_center=(new_lsamx, new_lsamy), + sweep_deg=sweep_deg, + ) + return (new_lsamx, new_lsamy) + def _save_smear_calibration_data( + self, + file_path: str, + fzp_xy: tuple[float, float], + new_center: tuple[float, float], + sweep_deg: float, + ): + """Archive the raw record of a smear calibration run: one composite + image and one clicked centre position per iteration, plus the FZP + reference and final result -- so the data can be collected across + runs for offline analysis (e.g. towards automating center detection + from the composite instead of relying on an operator click). + """ + expanded = os.path.expanduser(file_path) + os.makedirs(os.path.dirname(expanded), exist_ok=True) + with h5py.File(expanded, "w") as f: + f.create_dataset("fzp_reference_mm", data=np.array(fzp_xy)) + f.create_dataset("smear_composites", data=np.array(self.smear_composites)) + ds = f.create_dataset( + "clicked_centers_mm", data=np.array(self.smear_clicks, dtype=float) + ) + ds.attrs["columns"] = ["center_x_mm", "center_y_mm"] + f.create_dataset("new_center_mm", data=np.array(new_center)) + f.attrs["sweep_deg"] = sweep_deg + print(f"[rotation-center][smear] saved calibration record to {expanded}") + # --- end EXPERIMENTAL: rotation-center smear mode --------------------- def _ensure_at_configured_center(self, tol: float = 0.003): 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 4373a78..fb5c4ba 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 @@ -272,6 +272,7 @@ class XRayEye(BECWidget, QWidget): def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) self._live_view_signal = CAMERA[1] + self._last_smear_composite = None self._connected_motor = None self._dap_params_forwarding_connected = False self._queue_busy = False @@ -295,6 +296,20 @@ class XRayEye(BECWidget, QWidget): self.bec_dispatcher.connect_slot( self.on_queue_status_update, MessageEndpoints.scan_queue_status() ) + # Separate, permanent subscription (independent of whichever channel + # self.image is currently displaying) so the last smear composite is + # always cached -- newest_only=True delivers the current retained + # value immediately, then every future one. The "main" monitor + # subscription (image()/connect_slot with no from_start/newest_only) + # only delivers messages published *after* it connects, so switching + # back to "smear_preview" via set_live_view_signal would otherwise + # show nothing new until the next sweep, i.e. whatever was already + # on screen (whichever channel was displayed just before switching). + self.bec_dispatcher.connect_slot( + self._on_smear_preview_cached_update, + MessageEndpoints.device_preview(CAMERA[0], "smear_preview"), + newest_only=True, + ) self.connect_motors() self.resize(800, 600) @@ -344,45 +359,49 @@ class XRayEye(BECWidget, QWidget): header_row.addWidget(self.live_preview_toggle, 0, Qt.AlignmentFlag.AlignVCenter) self.control_panel_layout.addLayout(header_row) - self.switch_row_widget = QWidget(parent=self) - switch_row = QHBoxLayout(self.switch_row_widget) - switch_row.setContentsMargins(0, 0, 0, 0) - switch_row.setSpacing(8) - switch_row.addStretch() - self.camera_running_label = QLabel("Camera running", parent=self) - self.camera_running_toggle = ToggleSwitch(parent=self) - # self.camera_running_toggle.checked = False - self.camera_running_toggle.enabled.connect(self.camera_running_enabled) + # Shutter/camera-running (row 0) and the smear aid's own status + # switches (row 1) share one grid so the toggle columns actually + # line up regardless of each row's label text width -- two + # independent QHBoxLayouts (the previous approach) each size + # themselves from their own labels' widths, so corresponding + # switches in different rows land at different x-positions. + self.switch_grid_widget = QWidget(parent=self) + switch_grid = QGridLayout(self.switch_grid_widget) + switch_grid.setContentsMargins(0, 0, 0, 0) + switch_grid.setHorizontalSpacing(8) + switch_grid.setVerticalSpacing(4) + switch_grid.setColumnStretch(0, 1) # pushes both rows to the right + self.shutter_label = QLabel("Shutter open", parent=self) self.shutter_toggle = ToggleSwitch(parent=self) # self.shutter_toggle.checked = False self.shutter_toggle.enabled.connect(self.opening_shutter) - switch_row.addWidget(self.shutter_label, 0, Qt.AlignmentFlag.AlignVCenter) - switch_row.addWidget(self.shutter_toggle, 0, Qt.AlignmentFlag.AlignVCenter) - switch_row.addWidget(self.camera_running_label, 0, Qt.AlignmentFlag.AlignVCenter) - switch_row.addWidget(self.camera_running_toggle, 0, Qt.AlignmentFlag.AlignVCenter) - self.control_panel_layout.addWidget(self.switch_row_widget) + self.camera_running_label = QLabel("Camera running", parent=self) + self.camera_running_toggle = ToggleSwitch(parent=self) + # self.camera_running_toggle.checked = False + self.camera_running_toggle.enabled.connect(self.camera_running_enabled) # Smear rotation-center calibration aid: which channel is displayed, # and whether it's currently accumulating a composite. - self.smear_row_widget = QWidget(parent=self) - smear_row = QHBoxLayout(self.smear_row_widget) - smear_row.setContentsMargins(0, 0, 0, 0) - smear_row.setSpacing(8) - smear_row.addStretch() - self.smear_active_label = QLabel("Smear integrating", parent=self) - self.smear_active_toggle = ToggleSwitch(parent=self) - self.smear_active_toggle.checked = False - self.smear_active_toggle.setEnabled(False) # read-only status, not operator-togglable self.smear_preview_label = QLabel("Smear preview channel", parent=self) self.smear_preview_toggle = ToggleSwitch(parent=self) self.smear_preview_toggle.checked = False self.smear_preview_toggle.enabled.connect(self.toggle_smear_preview_channel) - smear_row.addWidget(self.smear_active_label, 0, Qt.AlignmentFlag.AlignVCenter) - smear_row.addWidget(self.smear_active_toggle, 0, Qt.AlignmentFlag.AlignVCenter) - smear_row.addWidget(self.smear_preview_label, 0, Qt.AlignmentFlag.AlignVCenter) - smear_row.addWidget(self.smear_preview_toggle, 0, Qt.AlignmentFlag.AlignVCenter) - self.control_panel_layout.addWidget(self.smear_row_widget) + self.smear_active_label = QLabel("Smear integrating", parent=self) + self.smear_active_toggle = ToggleSwitch(parent=self) + self.smear_active_toggle.checked = False + self.smear_active_toggle.setEnabled(False) # read-only status, not operator-togglable + + _right_vcenter = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + switch_grid.addWidget(self.shutter_label, 0, 1, _right_vcenter) + switch_grid.addWidget(self.shutter_toggle, 0, 2, Qt.AlignmentFlag.AlignVCenter) + switch_grid.addWidget(self.camera_running_label, 0, 3, _right_vcenter) + switch_grid.addWidget(self.camera_running_toggle, 0, 4, Qt.AlignmentFlag.AlignVCenter) + switch_grid.addWidget(self.smear_active_label, 1, 1, _right_vcenter) + switch_grid.addWidget(self.smear_active_toggle, 1, 2, Qt.AlignmentFlag.AlignVCenter) + switch_grid.addWidget(self.smear_preview_label, 1, 3, _right_vcenter) + switch_grid.addWidget(self.smear_preview_toggle, 1, 4, Qt.AlignmentFlag.AlignVCenter) + self.control_panel_layout.addWidget(self.switch_grid_widget) # separator self.control_panel_layout.addWidget(self._create_separator()) @@ -798,6 +817,16 @@ class XRayEye(BECWidget, QWidget): self.live_preview_toggle.checked = enabled self.live_preview_toggle.blockSignals(False) + @SafeSlot(dict, dict) + def _on_smear_preview_cached_update(self, msg: dict, metadata: dict): + """Keep the last-known smear composite cached regardless of which + channel self.image is currently displaying -- see the permanent + subscription set up in __init__ and the note in + set_live_view_signal() about why this is needed.""" + data = self.image._get_payload_data(msg) # pylint: disable=protected-access + if data is not None: + self._last_smear_composite = data + def set_live_view_signal(self, signal: str = CAMERA[1]) -> None: """Switch which camera signal the live Image view displays. @@ -814,6 +843,14 @@ class XRayEye(BECWidget, QWidget): self.image.image(device=CAMERA[0], signal=signal) if hasattr(self.image, "_autorange_on_next_update"): self.image._autorange_on_next_update = False + if signal == "smear_preview" and self._last_smear_composite is not None: + # image()/connect_slot only delivers messages published + # *after* connecting (see _on_smear_preview_cached_update's + # docstring) -- force an immediate repaint with the last + # known composite instead of waiting for a new sweep that + # may never come, so switching back shows the previous + # result, not whatever the live channel last rendered. + self.image.main_image.set_data(self._last_smear_composite) # Keep the toggle's visual state in sync regardless of whether this # call actually changed anything, and regardless of who called it # (the toggle itself, or the ipython client's automation). 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 1b8c35f..4aeace9 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 @@ -496,8 +496,39 @@ def test_find_rotation_center_smear_experimental_switches_live_view_signal(bec_c 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() + with mock.patch.object(align, "_save_smear_calibration_data"): + 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 + dev_mock.cam_xeye.live_mode_enabled.put.assert_not_called() + + +def test_find_rotation_center_smear_experimental_saves_composites_and_clicks(bec_client_mock): + """Composites and clicked centres from every iteration must be archived + for offline analysis -- confirms the data reaching _save_smear_calibration_data, + without touching the real filesystem.""" + 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 + frame = np.zeros((2, 2), dtype=np.uint8) + dev_mock.cam_xeye.get_last_image.return_value = frame + 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"]): + with mock.patch.object(align, "_save_smear_calibration_data") as save_mock: + align.find_rotation_center_smear_experimental(sweep_deg=45.0) + + assert len(align.smear_composites) == 1 + assert np.array_equal(align.smear_composites[0], frame) + assert align.smear_clicks == [(0.1, 0.0)] + save_mock.assert_called_once() + _, kwargs = save_mock.call_args + assert kwargs["fzp_xy"] == (0.0, 0.0) + assert kwargs["sweep_deg"] == 45.0 \ No newline at end of file -- 2.54.0