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:
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user