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