Files
csaxs_bec/tests/tests_devices/test_ids_camera.py
T
x01dcandClaude Sonnet 5 bcdfc12a88 fix(LamNI): decouple smear composite display from live acquisition channel
The "camera running" toggle (IDSCamera.live_mode_enabled) was flapping
on/off during the smear sweep, and on real hardware that's genuinely
slow (starting/stopping the live acquisition thread has real driver
overhead, unlike the simulator). Root cause: composite pushes shared
the same device_preview channel the live thread continuously publishes
to, so live_mode_enabled had to be toggled off around every push to
keep the composite from being instantly overwritten.

Give the composite its own channel instead:
- IDSCamera gains a smear_preview PreviewSignal (no rotation_90/
  transpose configured -- composite data is already display-oriented)
  and push_smear_preview(), mirroring push_preview_image() but fully
  decoupled from the live channel.
- XRayEye gains set_live_view_signal() to switch which camera signal
  the live Image view displays, defaulting to the normal channel for
  every fresh GUI instance; on_live_view_enabled() now uses whichever
  signal is currently selected instead of a hardcoded constant.
- _smear_sweep() switches the GUI to "smear_preview" for the sweep and
  pushes composite updates there -- live_mode_enabled is never touched
  at all (just ensured on at the start, exactly like _live_sweep()).
  The GUI is deliberately left on the composite after a successful
  sweep until the caller has collected the operator's click, then
  switched back to "image"; on KeyboardInterrupt it's switched back
  immediately.

This removes the resume_live/hold_display_s toggle-avoidance mechanism
added in def9bf4, which is no longer needed with a dedicated channel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 16:08:01 +02:00

128 lines
4.3 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)
def test_push_smear_preview_no_rotation_compensation(ids_camera):
"""smear_preview has no rotation_90/transpose configured, so pushed data
passes straight through unmodified -- unlike push_preview_image, no
compensation is needed (or applied)."""
data = np.arange(4, dtype=np.uint8).reshape(2, 2)
ids_camera.push_smear_preview(data)
result = ids_camera.smear_preview.get().data
assert np.array_equal(result, data)