Experimental/rotation center smear #272
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
```
|
||||
|
||||
````
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user