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 <noreply@anthropic.com>
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
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
|
||||
Reference in New Issue
Block a user