feat(sim): randomize rotation-axis offset, drop synthetic crosshair

The simulated speckle pattern rotated exactly around image center --
the same point the FZP crosshair sits at -- so the calibration was
trivially already correct in sim, never exercising the actual
find-and-correct-a-miscalibration path.

Add an opt-in axis_offset_range_px/axis_offset_seed: when set, the
"true" rotation axis the speckle field orbits is offset from image
center by a random amount (fresh each device construction unless a
seed is given, so each sim session gets a new miscalibration to find).
Wired into LamNI's cam_xeye sim config (sim_axis_offset_range_px: 40,
no seed -- random each session); default (0.0) preserves prior
behavior exactly for any other SimIDSCamera user.

Also drop the synthetic crosshair lines baked into
make_speckle_test_pattern's pixel data -- a real camera image has no
such marking; the GUI already draws its own crosshair overlay
(TargetCrosshair) on top of the live image.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
x01dc
2026-07-24 14:24:30 +02:00
co-authored by Claude Sonnet 5
parent 931df76d84
commit 9039a9c37d
3 changed files with 133 additions and 12 deletions
@@ -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
deviceTags:
- lamni
@@ -324,15 +324,21 @@ cam_xeye:
transpose: false
force_monochrome: true
m_n_colormode: 1
# Makes the synthetic frame track lsamrot's live simulated angle (an
# eccentric blob orbiting image center) 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.
# 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
+39 -7
View File
@@ -113,10 +113,11 @@ def make_speckle_test_pattern(
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 the image center, for testing rotation-
"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.
@@ -128,19 +129,26 @@ def make_speckle_test_pattern(
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.
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 = cy - radius_px * np.sin(angle_rad)
fx = cx + radius_px * np.cos(angle_rad)
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[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)
@@ -218,6 +226,8 @@ class _SimIDSBackend:
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
@@ -235,6 +245,7 @@ class _SimIDSBackend:
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(
@@ -244,6 +255,22 @@ class _SimIDSBackend:
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
@@ -270,6 +297,7 @@ class _SimIDSBackend:
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]
@@ -303,6 +331,8 @@ class SimIDSCamera(IDSCamera):
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:
@@ -329,5 +359,7 @@ class SimIDSCamera(IDSCamera):
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
+83
View File
@@ -164,3 +164,86 @@ def test_sim_ids_camera_threads_rotation_coupling_through():
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