From 9299d813adf780efb5f6e6dbad69b8e84bf6eb3e Mon Sep 17 00:00:00 2001 From: x01dc Date: Fri, 24 Jul 2026 15:44:10 +0200 Subject: [PATCH] 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