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>
117 lines
3.9 KiB
Python
117 lines
3.9 KiB
Python
"""Unit tests for the IDS Camera device."""
|
|
|
|
from unittest import mock
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from csaxs_bec.devices.ids_cameras.ids_camera import IDSCamera
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def ids_camera():
|
|
"""Fixture for creating an instance of the IDSCamera."""
|
|
camera = IDSCamera(
|
|
name="test_camera",
|
|
camera_id=1,
|
|
prefix="test:",
|
|
scan_info=None,
|
|
m_n_colormode=1,
|
|
bits_per_pixel=24,
|
|
live_mode=False,
|
|
)
|
|
# Mock camera connection and attributes
|
|
camera.cam = mock.Mock()
|
|
camera.cam._connected = True
|
|
camera.cam.cam = mock.Mock()
|
|
camera.cam.cam.width.value = 2
|
|
camera.cam.cam.height.value = 2
|
|
yield camera
|
|
|
|
|
|
def test_mask_setter_getter(ids_camera):
|
|
"""Test the mask setter and getter methods."""
|
|
mask = np.zeros((2, 2), dtype=np.uint8)
|
|
mask[0, 0] = 1
|
|
ids_camera.mask = mask
|
|
assert np.array_equal(ids_camera.mask, mask)
|
|
|
|
|
|
def test_mask_setter_invalid_shape(ids_camera):
|
|
"""Test the mask setter with an invalid shape."""
|
|
with pytest.raises(ValueError):
|
|
ids_camera.mask = np.zeros((3, 3), dtype=np.uint8) # Exceeds mocked camera dimensions
|
|
|
|
|
|
def test_on_connected_sets_mask_and_live_mode(ids_camera):
|
|
"""Test the on_connected method to ensure it sets the mask and live mode."""
|
|
ids_camera.cam.on_connect = mock.Mock()
|
|
ids_camera.on_connected()
|
|
ids_camera.cam.on_connect.assert_called_once()
|
|
expected_mask = np.ones((2, 2), dtype=np.uint8)
|
|
assert np.array_equal(ids_camera.mask, expected_mask)
|
|
|
|
|
|
def test_on_trigger_roi_signal(ids_camera):
|
|
"""Test the on_trigger method to ensure it processes the ROI signal correctly."""
|
|
ids_camera.start_live_mode()
|
|
test_image = np.array([[2, 4], [6, 8]])
|
|
test_mask = np.array([[1, 0], [0, 1]], dtype=np.uint8)
|
|
ids_camera.mask = test_mask
|
|
mock_image = mock.Mock()
|
|
mock_image.data = test_image
|
|
ids_camera.image.get = mock.Mock(return_value=mock_image)
|
|
ids_camera.roi_signal.put = mock.Mock(side_effect=ids_camera.roi_signal.put)
|
|
ids_camera.on_trigger()
|
|
expected_value = (2 * 1 + 4 * 0 + 6 * 0 + 8 * 1) / (np.sum(test_mask) * 1)
|
|
result = ids_camera.roi_signal.get()
|
|
assert np.isclose(
|
|
result.content["signals"][ids_camera.roi_signal.name]["value"], expected_value, atol=1e-6
|
|
)
|
|
|
|
|
|
def test_get_last_image(ids_camera):
|
|
"""Test the get_last_image method to ensure it returns the last captured image."""
|
|
test_image = np.array([[1, 2], [3, 4]], dtype=np.uint8)
|
|
mock_image = mock.Mock()
|
|
mock_image.data = test_image
|
|
ids_camera.image.get = mock.Mock(return_value=mock_image)
|
|
|
|
result = ids_camera.get_last_image()
|
|
assert np.array_equal(result, test_image)
|
|
|
|
|
|
def test_on_destroy(ids_camera):
|
|
"""Test the on_destroy method to ensure it cleans up resources."""
|
|
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)
|