fix(LamNI): freeze composite display, undo double rotation, speckle sim pattern

Real-HW-adjacent (sim) testing surfaced three issues with the smear
calibration aid:

1. The composite reverted to the plain rotating live view right before
   the click prompt. find_rotation_center_smear_experimental() called
   update_frame() after _smear_sweep(), which grabs a fresh raw live
   frame -- overwriting the composite it just built. Also,
   _push_smear_composite always resumed live mode, so even without that
   bug the composite would only stay visible for hold_display_s.
   Fixed: _push_smear_composite gained resume_live=False for the final
   push; _smear_sweep now takes keep_shutter_open and closes the
   shutter itself at the end, so the composite (not a fresh grab) stays
   frozen on screen for the click. The stray update_frame() call is
   removed.

2. The composite appeared rotated relative to the live view.
   get_last_image() frames already have num_rotation_90/transpose
   applied (PreviewSignal.put() applies it to whatever it stores), so
   composites built from those frames got the transform applied AGAIN
   by push_preview_image()'s own self.image.put() call. Fixed:
   push_preview_image() now undoes the transform first (reverse order,
   since transpose is applied last forward) so the net effect is a
   single transform, matching a live frame.

3. Feature request: the sim camera's rotation-coupled pattern was a
   single eccentric gaussian blob -- a better analog for the isolated/
   point-particle case than the extended/textured case this feature
   actually targets. Added generate_speckle_grains/
   make_speckle_test_pattern (sim_cameras.py): a fixed, seeded field of
   small gaussian "grains" at a range of radii, rotating rigidly
   together as the live angle changes -- producing several concentric
   arcs in the composite instead of one, much closer to a real textured
   sample. New sim_speckle_count/sim_speckle_seed config, defaults
   requiring no yaml changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
x01dc
2026-07-24 14:02:51 +02:00
co-authored by Claude Sonnet 5
parent 819f4c322d
commit def9bf4bfc
5 changed files with 211 additions and 15 deletions
@@ -665,7 +665,9 @@ class XrayEyeAlign:
# Operator-visual only -- no automatic circle fitting. See # Operator-visual only -- no automatic circle fitting. See
# find_rotation_center_smear_experimental() below for the entry point. # find_rotation_center_smear_experimental() below for the entry point.
def _push_smear_composite(self, composite: np.ndarray, hold_display_s: float): def _push_smear_composite(
self, composite: np.ndarray, hold_display_s: float, resume_live: bool = True
):
"""Briefly freeze the GUI's live view on the current composite. """Briefly freeze the GUI's live view on the current composite.
live_mode_enabled.put(False) synchronously joins IDSCamera's live_mode_enabled.put(False) synchronously joins IDSCamera's
@@ -680,15 +682,22 @@ class XrayEyeAlign:
subclasses from the client-side device RPC proxy) -- push via the subclasses from the client-side device RPC proxy) -- push via the
push_preview_image() device method instead, which runs push_preview_image() device method instead, which runs
device-server side where self.image is a normal ophyd attribute. device-server side where self.image is a normal ophyd attribute.
resume_live: if False, live mode is left off after the push --
used for the final push at the end of a sweep, so the composite
stays frozen on screen (for the operator to click on) instead of
being immediately overwritten by the next raw live frame.
""" """
dev.cam_xeye.live_mode_enabled.put(False) dev.cam_xeye.live_mode_enabled.put(False)
dev.cam_xeye.push_preview_image(composite) dev.cam_xeye.push_preview_image(composite)
time.sleep(hold_display_s) time.sleep(hold_display_s)
dev.cam_xeye.live_mode_enabled.put(True) if resume_live:
dev.cam_xeye.live_mode_enabled.put(True)
def _smear_sweep( def _smear_sweep(
self, self,
sweep_deg: float = 360.0, sweep_deg: float = 360.0,
keep_shutter_open: bool = False,
display_update_interval_s: float = 1.0, display_update_interval_s: float = 1.0,
hold_display_s: float = 0.4, hold_display_s: float = 0.4,
poll_interval_s: float = 0.15, poll_interval_s: float = 0.15,
@@ -708,6 +717,11 @@ class XrayEyeAlign:
is still in progress on the server -- ScanReport.status is polled is still in progress on the server -- ScanReport.status is polled
directly, exactly what ScanReport.wait() does internally, so no directly, exactly what ScanReport.wait() does internally, so no
background thread is needed here. background thread is needed here.
On return, the composite (not a fresh live frame) stays frozen on
the GUI's live view -- live mode is left off, and the shutter is
closed unless keep_shutter_open -- so the operator clicks on the
exact same image the whole time, with no regrab/flicker.
""" """
print( print(
f"[rotation-center][smear] EXPERIMENTAL feature: max-projection composite aid, " f"[rotation-center][smear] EXPERIMENTAL feature: max-projection composite aid, "
@@ -757,7 +771,9 @@ class XrayEyeAlign:
dev.cam_xeye.live_mode_enabled.put(True) dev.cam_xeye.live_mode_enabled.put(True)
raise raise
self._push_smear_composite(composite, hold_display_s) self._push_smear_composite(composite, hold_display_s, resume_live=False)
if not keep_shutter_open:
dev.fsh.fshclose()
print( print(
f"[rotation-center][smear] sweep done -- {frames_seen} distinct frames over " f"[rotation-center][smear] sweep done -- {frames_seen} distinct frames over "
f"{sweep_deg:.1f} deg, now at {self.get_tomo_angle():.1f} deg" f"{sweep_deg:.1f} deg, now at {self.get_tomo_angle():.1f} deg"
@@ -826,8 +842,7 @@ class XrayEyeAlign:
self.send_message( self.send_message(
"Watch the sample rotate and locate the stationary rotation centre..." "Watch the sample rotate and locate the stationary rotation centre..."
) )
self._smear_sweep(sweep_deg=sweep_deg) self._smear_sweep(sweep_deg=sweep_deg, keep_shutter_open=keep_shutter_open)
self.update_frame(keep_shutter_open)
self.movement_buttons_enabled(True, True) self.movement_buttons_enabled(True, True)
center_x, center_y = self._collect_click( center_x, center_y = self._collect_click(
1, 1,
+16 -1
View File
@@ -203,7 +203,9 @@ class IDSCamera(PSIDeviceBase):
return image.data return image.data
def push_preview_image(self, data: np.ndarray) -> None: def push_preview_image(self, data: np.ndarray) -> None:
"""Push an arbitrary array to this camera's own preview signal. """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 self.image is a PreviewSignal, which is deliberately excluded from
the client-side device RPC/signal proxy (rpc_access defaults to the client-side device RPC/signal proxy (rpc_access defaults to
@@ -213,7 +215,20 @@ class IDSCamera(PSIDeviceBase):
attribute, and is the supported way for a client to publish a attribute, and is the supported way for a client to publish a
custom (e.g. composite/processed) image through the same custom (e.g. composite/processed) image through the same
device_preview channel the GUI already subscribes to. 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) self.image.put(data)
############## User Interface Methods ############## ############## User Interface Methods ##############
+88 -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 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 = static frames; noisy frames make live views visibly live but cost bandwidth e.g. over
remote desktop). 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 from __future__ import annotations
@@ -79,6 +84,69 @@ def make_rotating_test_pattern(
return frame 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]],
background: float = 20.0,
) -> np.ndarray:
"""Create a synthetic camera frame (uint8) with a field of speckle
"grains" rigidly orbiting the image center, 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). The fixed crosshair marks image center (the FZP/beam
reference), distinct from the orbiting speckle field.
"""
yy, xx = np.mgrid[0:height, 0:width]
cy, cx = height / 2.0, width / 2.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 = cy - radius_px * np.sin(angle_rad)
fx = cx + radius_px * np.cos(angle_rad)
frame += amplitude * np.exp(-(((yy - fy) ** 2) + ((xx - fx) ** 2)) / (2.0 * sigma**2))
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 add_frame_noise(frame: np.ndarray, noise_std: float) -> np.ndarray: def add_frame_noise(frame: np.ndarray, noise_std: float) -> np.ndarray:
"""Return a copy of the frame with gaussian noise, clipped to uint8.""" """Return a copy of the frame with gaussian noise, clipped to uint8."""
if noise_std <= 0: if noise_std <= 0:
@@ -148,6 +216,8 @@ class _SimIDSBackend:
noise_std: float = 0.0, noise_std: float = 0.0,
rotation_coupling: dict | None = None, rotation_coupling: dict | None = None,
feature_radius_px: float | None = None, feature_radius_px: float | None = None,
speckle_count: int = 60,
speckle_seed: int = 42,
): ):
self.cam = _SimIDSSensor(width, height) self.cam = _SimIDSSensor(width, height)
self.force_monochrome = False self.force_monochrome = False
@@ -162,9 +232,18 @@ class _SimIDSBackend:
if feature_radius_px is not None if feature_radius_px is not None
else min(width, height) / 4.0 else min(width, height) / 4.0
) )
self._frame = None if rotation_coupling is not None else make_test_pattern( if rotation_coupling is None:
height, width, rgb=rgb self._frame = make_test_pattern(height, width, rgb=rgb)
) self._speckle_grains = None
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,
)
def on_connect(self): def on_connect(self):
self._connected = True self._connected = True
@@ -185,12 +264,12 @@ class _SimIDSBackend:
if self._rotation_coupling is None: if self._rotation_coupling is None:
frame = self._frame frame = self._frame
else: else:
frame = make_rotating_test_pattern( frame = make_speckle_test_pattern(
self._height, self._height,
self._width, self._width,
rgb=self._rgb, rgb=self._rgb,
angle_deg=self._current_angle_deg(), angle_deg=self._current_angle_deg(),
radius_px=self._feature_radius_px, grains=self._speckle_grains,
) )
if self.force_monochrome and frame.ndim == 3: if self.force_monochrome and frame.ndim == 3:
frame = frame[:, :, 0] frame = frame[:, :, 0]
@@ -222,6 +301,8 @@ class SimIDSCamera(IDSCamera):
sim_noise_std=0.0, sim_noise_std=0.0,
sim_rotation_coupling: dict | None = None, sim_rotation_coupling: dict | None = None,
sim_feature_radius_px: float | None = None, sim_feature_radius_px: float | None = None,
sim_speckle_count: int = 60,
sim_speckle_seed: int = 42,
**kwargs, **kwargs,
): ):
if camera_ID is not None and not camera_id: if camera_ID is not None and not camera_id:
@@ -246,5 +327,7 @@ class SimIDSCamera(IDSCamera):
noise_std=sim_noise_std, noise_std=sim_noise_std,
rotation_coupling=sim_rotation_coupling, rotation_coupling=sim_rotation_coupling,
feature_radius_px=sim_feature_radius_px, feature_radius_px=sim_feature_radius_px,
speckle_count=sim_speckle_count,
speckle_seed=sim_speckle_seed,
) )
self.cam.force_monochrome = self._force_monochrome self.cam.force_monochrome = self._force_monochrome
+28
View File
@@ -86,3 +86,31 @@ def test_on_destroy(ids_camera):
ids_camera.cam.on_disconnect = mock.Mock() ids_camera.cam.on_disconnect = mock.Mock()
ids_camera.on_destroy() ids_camera.on_destroy()
ids_camera.cam.on_disconnect.assert_called_once() 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)
+59 -4
View File
@@ -1,5 +1,6 @@
"""Unit tests for the simulated IDS camera frame source (_SimIDSBackend / """Unit tests for the simulated IDS camera frame source (_SimIDSBackend /
make_rotating_test_pattern / SimIDSCamera's optional rotation coupling).""" make_rotating_test_pattern / make_speckle_test_pattern /
SimIDSCamera's optional rotation coupling)."""
import numpy as np import numpy as np
import pytest import pytest
@@ -7,7 +8,9 @@ import pytest
from csaxs_bec.devices.sim.sim_cameras import ( from csaxs_bec.devices.sim.sim_cameras import (
SimIDSCamera, SimIDSCamera,
_SimIDSBackend, _SimIDSBackend,
generate_speckle_grains,
make_rotating_test_pattern, make_rotating_test_pattern,
make_speckle_test_pattern,
make_test_pattern, make_test_pattern,
) )
from csaxs_bec.devices.sim.sim_galil import SimGalilState from csaxs_bec.devices.sim.sim_galil import SimGalilState
@@ -59,6 +62,9 @@ def test_make_rotating_test_pattern_blob_position(angle_deg):
def test_sim_ids_backend_rotation_coupling_tracks_live_angle(): 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" host, port, axis_letter = "test-galil.example", 9000, "C"
galil = SimStateRegistry.get(SimGalilState, host, port) galil = SimStateRegistry.get(SimGalilState, host, port)
ax = galil.axis(ord(axis_letter.lower()) - 97) ax = galil.axis(ord(axis_letter.lower()) - 97)
@@ -66,24 +72,73 @@ def test_sim_ids_backend_rotation_coupling_tracks_live_angle():
ax.pos_steps = 0.0 # 0 deg ax.pos_steps = 0.0 # 0 deg
coupling = {"galil_host": host, "galil_port": port, "axis": axis_letter} coupling = {"galil_host": host, "galil_port": port, "axis": axis_letter}
radius_px = 50.0
backend = _SimIDSBackend( backend = _SimIDSBackend(
width=200, height=200, rgb=False, rotation_coupling=coupling, feature_radius_px=50.0 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() frame0 = backend.get_image_data()
assert np.array_equal( assert np.array_equal(
frame0, make_rotating_test_pattern(200, 200, rgb=False, angle_deg=0.0, radius_px=50.0) 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 # move the simulated axis and confirm the frame is regenerated fresh, not cached
ax.pos_steps = 90.0 * ax.stppermm ax.pos_steps = 90.0 * ax.stppermm
frame90 = backend.get_image_data() frame90 = backend.get_image_data()
assert np.array_equal( assert np.array_equal(
frame90, make_rotating_test_pattern(200, 200, rgb=False, angle_deg=90.0, radius_px=50.0) frame90,
make_speckle_test_pattern(200, 200, rgb=False, angle_deg=90.0, grains=expected_grains),
) )
assert not np.array_equal(frame0, frame90) 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(): def test_sim_ids_camera_threads_rotation_coupling_through():
"""SimIDSCamera's sim_rotation_coupling/sim_feature_radius_px reach _SimIDSBackend.""" """SimIDSCamera's sim_rotation_coupling/sim_feature_radius_px reach _SimIDSBackend."""
host, port, axis_letter = "test-galil-2.example", 9100, "C" host, port, axis_letter = "test-galil-2.example", 9100, "C"