Experimental/rotation center smear #272

Merged
holler merged 11 commits from experimental/rotation_center_smear into main 2026-07-24 16:17:01 +02:00
16 changed files with 1543 additions and 49 deletions
@@ -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)
@@ -524,6 +524,55 @@ 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:
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
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
# ------------------------------------------------------------------
@@ -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,314 @@ 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):
"""Publish the current composite to the GUI's live view.
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.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,
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.
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.
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
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, "
"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)
self.gui.set_live_view_signal("smear_preview")
self.gui.set_smear_active(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)
last_push = time.time()
time.sleep(poll_interval_s)
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(
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(
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.smear_composites = []
self.smear_clicks = []
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..."
)
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,
"Submit the rotation centre (0 deg).",
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
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.")
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):
"""Move lsamx/lsamy to their currently-configured center if they
aren't already there.
+76 -22
View File
@@ -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,30 @@ 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.
"""
@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"
@@ -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",
@@ -253,6 +253,8 @@ class XRayEye(BECWidget, QWidget):
"set_crosshair_position",
"crosshair_position",
"reset_zoom",
"set_live_view_signal",
"set_smear_active",
]
PLUGIN = True
@@ -269,6 +271,8 @@ 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
@@ -292,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)
@@ -341,24 +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_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)
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())
@@ -757,7 +800,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 +813,65 @@ 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)
@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.
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:
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 == "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).
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):
if self._manual_toggle_blocked_by_queue():
@@ -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,6 +326,21 @@ cam_xeye:
transpose: false
force_monochrome: true
m_n_colormode: 1
# 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
+61 -1
View File
@@ -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",
@@ -51,7 +62,15 @@ 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",
"push_smear_preview",
]
def __init__(
self,
@@ -195,6 +214,47 @@ class IDSCamera(PSIDeviceBase):
if image:
return image.data
def push_preview_image(self, data: np.ndarray) -> None:
"""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
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.
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)
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):
+211 -5
View File
@@ -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
@@ -21,6 +26,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 +47,114 @@ 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 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]],
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 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.
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).
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 = 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 = 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 +205,72 @@ 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,
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
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
)
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(
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,
)
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
@@ -106,8 +278,27 @@ 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_speckle_test_pattern(
self._height,
self._width,
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]
return add_frame_noise(frame, self._noise_std)
@@ -136,6 +327,12 @@ 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,
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:
@@ -154,6 +351,15 @@ 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,
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
+12
View File
@@ -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.
```
````
+168
View File
@@ -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 <user.ptychography.lamni>`) 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.<method>(...)` and
`self.gui.<method>(...)` 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`.
+5 -1
View File
@@ -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. 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 <developer.lamni_smear_architecture>` 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**
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:
@@ -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)
@@ -1,5 +1,6 @@
from unittest import mock
import numpy as np
import pytest
from bec_lib.device import DeviceBase
@@ -365,4 +366,169 @@ 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()
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_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):
align._push_smear_composite(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):
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
# 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")
# "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):
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()
# 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):
"""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"]):
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()
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
+39
View File
@@ -86,3 +86,42 @@ 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)
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)
+249
View File
@@ -0,0 +1,249 @@
"""Unit tests for the simulated IDS camera frame source (_SimIDSBackend /
make_rotating_test_pattern / make_speckle_test_pattern /
SimIDSCamera's optional rotation coupling)."""
import numpy as np
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
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():
"""_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)
ax.stppermm = 1000.0
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=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_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_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"
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)
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