diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml index 9932938..566ebeb 100644 --- a/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml +++ b/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml @@ -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 diff --git a/csaxs_bec/devices/sim/sim_cameras.py b/csaxs_bec/devices/sim/sim_cameras.py index 3776386..a3af84b 100644 --- a/csaxs_bec/devices/sim/sim_cameras.py +++ b/csaxs_bec/devices/sim/sim_cameras.py @@ -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 diff --git a/tests/tests_devices/test_sim_cameras.py b/tests/tests_devices/test_sim_cameras.py index 7f5500a..0553cb7 100644 --- a/tests/tests_devices/test_sim_cameras.py +++ b/tests/tests_devices/test_sim_cameras.py @@ -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