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
# 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.
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
push_preview_image() device method instead, which runs
device-server side where self.image is a normal ophyd attribute.
resume_live: if False, live mode is left off after the push --
used for the final push at the end of a sweep, so the composite
stays frozen on screen (for the operator to click on) instead of
being immediately overwritten by the next raw live frame.
"""
dev.cam_xeye.live_mode_enabled.put(False)
dev.cam_xeye.push_preview_image(composite)
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(
self,
sweep_deg: float = 360.0,
keep_shutter_open: bool = False,
display_update_interval_s: float = 1.0,
hold_display_s: float = 0.4,
poll_interval_s: float = 0.15,
@@ -708,6 +717,11 @@ class XrayEyeAlign:
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.
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(
f"[rotation-center][smear] EXPERIMENTAL feature: max-projection composite aid, "
@@ -757,7 +771,9 @@ class XrayEyeAlign:
dev.cam_xeye.live_mode_enabled.put(True)
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(
f"[rotation-center][smear] sweep done -- {frames_seen} distinct frames over "
f"{sweep_deg:.1f} deg, now at {self.get_tomo_angle():.1f} deg"
@@ -826,8 +842,7 @@ class XrayEyeAlign:
self.send_message(
"Watch the sample rotate and locate the stationary rotation centre..."
)
self._smear_sweep(sweep_deg=sweep_deg)
self.update_frame(keep_shutter_open)
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,
+16 -1
View File
@@ -203,7 +203,9 @@ class IDSCamera(PSIDeviceBase):
return image.data
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
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
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)
############## 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
= 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
@@ -79,6 +84,69 @@ def make_rotating_test_pattern(
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:
"""Return a copy of the frame with gaussian noise, clipped to uint8."""
if noise_std <= 0:
@@ -148,6 +216,8 @@ class _SimIDSBackend:
noise_std: float = 0.0,
rotation_coupling: dict | None = None,
feature_radius_px: float | None = None,
speckle_count: int = 60,
speckle_seed: int = 42,
):
self.cam = _SimIDSSensor(width, height)
self.force_monochrome = False
@@ -162,9 +232,18 @@ class _SimIDSBackend:
if feature_radius_px is not None
else min(width, height) / 4.0
)
self._frame = None if rotation_coupling is not None else make_test_pattern(
height, width, rgb=rgb
)
if rotation_coupling is None:
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):
self._connected = True
@@ -185,12 +264,12 @@ class _SimIDSBackend:
if self._rotation_coupling is None:
frame = self._frame
else:
frame = make_rotating_test_pattern(
frame = make_speckle_test_pattern(
self._height,
self._width,
rgb=self._rgb,
angle_deg=self._current_angle_deg(),
radius_px=self._feature_radius_px,
grains=self._speckle_grains,
)
if self.force_monochrome and frame.ndim == 3:
frame = frame[:, :, 0]
@@ -222,6 +301,8 @@ class SimIDSCamera(IDSCamera):
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,
**kwargs,
):
if camera_ID is not None and not camera_id:
@@ -246,5 +327,7 @@ class SimIDSCamera(IDSCamera):
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,
)
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.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)
+59 -4
View File
@@ -1,5 +1,6 @@
"""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 pytest
@@ -7,7 +8,9 @@ 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
@@ -59,6 +62,9 @@ def test_make_rotating_test_pattern_blob_position(angle_deg):
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)
@@ -66,24 +72,73 @@ def test_sim_ids_backend_rotation_coupling_tracks_live_angle():
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=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()
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
ax.pos_steps = 90.0 * ax.stppermm
frame90 = backend.get_image_data()
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)
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"