diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py index 1f26e5a..35c58bd 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py @@ -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, diff --git a/csaxs_bec/devices/ids_cameras/ids_camera.py b/csaxs_bec/devices/ids_cameras/ids_camera.py index c853818..e77768f 100644 --- a/csaxs_bec/devices/ids_cameras/ids_camera.py +++ b/csaxs_bec/devices/ids_cameras/ids_camera.py @@ -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 ############## diff --git a/csaxs_bec/devices/sim/sim_cameras.py b/csaxs_bec/devices/sim/sim_cameras.py index ca9fc7a..3776386 100644 --- a/csaxs_bec/devices/sim/sim_cameras.py +++ b/csaxs_bec/devices/sim/sim_cameras.py @@ -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 diff --git a/tests/tests_devices/test_ids_camera.py b/tests/tests_devices/test_ids_camera.py index 66466b3..e64814f 100644 --- a/tests/tests_devices/test_ids_camera.py +++ b/tests/tests_devices/test_ids_camera.py @@ -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) diff --git a/tests/tests_devices/test_sim_cameras.py b/tests/tests_devices/test_sim_cameras.py index 9e7f9cc..7f5500a 100644 --- a/tests/tests_devices/test_sim_cameras.py +++ b/tests/tests_devices/test_sim_cameras.py @@ -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"