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