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