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 <noreply@anthropic.com>
This commit is contained in:
x01dc
2026-07-24 16:08:01 +02:00
co-authored by Claude Sonnet 5
parent 81f6d0ff1e
commit bcdfc12a88
6 changed files with 133 additions and 52 deletions
@@ -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
@@ -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
@@ -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():
@@ -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):
@@ -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)
# 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()
+11
View File
@@ -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)