Files
csaxs_bec/tests/tests_devices/test_ids_camera.py
T
x01dcandClaude Sonnet 5 8cf95196b7 fix(ids-cameras): snap pixel clock to a supported value; pulse auto-gain
Pixel clock: get_pixel_clock_range()'s (min, max, increment) describes
a linear range, but IS_PIXELCLOCK_CMD_SET doesn't actually accept
every value in it -- confirmed on hardware, 49/60/73 MHz all failed on
camera 41. Added Camera.get_pixel_clock_list() (IS_PIXELCLOCK_CMD_GET_
NUMBER + _GET_LIST, the SDK's authoritative source for what's settable)
and IDSCamera.get_pixel_clock_list(); IDSCamera.set_pixel_clock() now
snaps to the nearest supported value before writing, instead of
passing the raw requested value straight to the driver.

Auto gain: HW testing found continuous auto-gain has nothing further
to adjust once correctly exposed, and takes gain out of manual
control. on_connected() now pulses auto_gain_enabled on then off
(_AUTO_GAIN_SETTLE_S = 0.5s in between, for the already-running
continuous capture to feed it a few frames), instead of leaving it
enabled like auto_exposure_enabled -- still does the one-time gain
correction, but leaves gain fixed under manual control afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYQTHuSxSaLCijvayoRkX5
2026-09-15 08:31:51 +02:00

268 lines
10 KiB
Python

"""Unit tests for the IDS Camera device."""
import threading
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
camera.cam.get_exposure_range = mock.Mock(return_value=(0.1, 1000.0, 0.1))
camera.cam.get_pixel_clock = mock.Mock(return_value=20)
camera.cam.get_pixel_clock_range = mock.Mock(return_value=(5, 40, 1))
camera.cam.get_pixel_clock_list = mock.Mock(return_value=[5, 10, 20, 40])
# on_connected() pulses auto_gain_enabled on then off with a real sleep
# in between (see IDSCamera._AUTO_GAIN_SETTLE_S) -- skip the wait in
# tests, only hardware needs the settle time.
camera._AUTO_GAIN_SETTLE_S = 0
yield camera
camera.stop_live_mode()
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_get_live_fps_none_before_frames(ids_camera):
"""No frames pushed yet -> get_live_fps() returns None."""
assert ids_camera.get_live_fps() is None
def test_get_live_fps_computes_rate(ids_camera):
"""get_live_fps() computes rate from the recorded push timestamps."""
ids_camera._live_frame_times.extend([0.0, 0.2, 0.4, 0.6, 0.8])
assert ids_camera.get_live_fps() == pytest.approx(5.0)
def test_live_mode_loop_records_frame_times(ids_camera):
"""_live_mode_loop() appends one timestamp per pushed frame."""
ids_camera.cam.get_image_data = mock.Mock(return_value=np.zeros((2, 2), dtype=np.uint8))
stop_event = threading.Event()
calls = {"n": 0}
def fake_wait(timeout):
calls["n"] += 1
if calls["n"] >= 3:
stop_event.set()
return False
stop_event.wait = fake_wait
ids_camera._live_mode_loop(stop_event)
assert len(ids_camera._live_frame_times) == 3
assert ids_camera.get_live_fps() is not None
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_get_set_exposure_time(ids_camera):
ids_camera.set_exposure_time(1234.5)
assert ids_camera.cam.exposure_time == 1234.5
assert ids_camera.get_exposure_time() == 1234.5
def test_set_auto_exposure_enabled(ids_camera):
ids_camera.set_auto_exposure_enabled(False)
ids_camera.cam.set_auto_shutter.assert_called_once_with(False)
def test_set_auto_gain_enabled(ids_camera):
ids_camera.set_auto_gain_enabled(False)
ids_camera.cam.set_auto_gain.assert_called_once_with(False)
def test_on_connected_seeds_exposure_time(ids_camera):
ids_camera.cam.on_connect = mock.Mock()
ids_camera.cam.exposure_time = 4200.0
ids_camera.cam.get_exposure_range = mock.Mock(return_value=(0.1, 1000.0, 0.1))
ids_camera.on_connected()
assert ids_camera.get_exposure_time() == 4200.0
def test_on_connected_seeds_exposure_time_range(ids_camera):
ids_camera.cam.on_connect = mock.Mock()
ids_camera.cam.get_exposure_range = mock.Mock(return_value=(0.05, 500.0, 0.05))
ids_camera.on_connected()
assert ids_camera.get_exposure_time_range() == (0.05, 500.0)
def test_auto_gain_hardware_failure_is_logged_not_raised(ids_camera):
"""A driver-level failure inside the subscribe callback must not
propagate -- see _on_auto_gain_enabled_changed()'s docstring. This also
covers the case that motivated it: the old set_auto_gain() passed a
c_int where the SDK expects c_double, which the real driver rejected."""
ids_camera.cam.set_auto_gain = mock.Mock(side_effect=RuntimeError("driver rejected value"))
ids_camera.set_auto_gain_enabled(False) # must not raise
ids_camera.cam.set_auto_gain.assert_called_once_with(False)
def test_auto_exposure_hardware_failure_is_logged_not_raised(ids_camera):
ids_camera.cam.set_auto_shutter = mock.Mock(side_effect=RuntimeError("driver rejected value"))
ids_camera.set_auto_exposure_enabled(False) # must not raise
ids_camera.cam.set_auto_shutter.assert_called_once_with(False)
def test_on_connected_applies_auto_exposure_default_to_hardware(ids_camera):
"""auto_exposure_enabled defaults to True but, unlike
live_mode_enabled/exposure_time, was never actually applied to hardware
at connect -- meaning a fresh session left the camera in whatever
is_ResetToDefault() leaves it in (observed: auto off) regardless of the
Signal's declared default. on_connected() must now round-trip it through
its subscribe callback so the real SDK call is issued."""
ids_camera.cam.on_connect = mock.Mock()
ids_camera.on_connected()
ids_camera.cam.set_auto_shutter.assert_called_once_with(True)
def test_on_connected_pulses_auto_gain_on_then_off(ids_camera):
"""auto_gain_enabled gets pulsed on then off at connect (a one-time gain
correction), unlike auto_exposure_enabled which stays on -- HW testing
found continuous auto-gain has nothing useful left to do once correctly
exposed, and it should end up under the operator's manual control."""
ids_camera.cam.on_connect = mock.Mock()
ids_camera.on_connected()
ids_camera.cam.set_auto_gain.assert_has_calls([mock.call(True), mock.call(False)])
assert ids_camera.auto_gain_enabled.get() is False
def test_on_connected_seeds_pixel_clock(ids_camera):
ids_camera.cam.on_connect = mock.Mock()
ids_camera.cam.get_pixel_clock = mock.Mock(return_value=30)
ids_camera.cam.get_pixel_clock_range = mock.Mock(return_value=(5, 45, 1))
ids_camera.on_connected()
assert ids_camera.get_pixel_clock() == 30
assert ids_camera.get_pixel_clock_range() == (5, 45)
def test_set_pixel_clock_reseeds_exposure_range(ids_camera):
ids_camera.cam.set_pixel_clock = mock.Mock()
ids_camera.cam.get_exposure_range = mock.Mock(return_value=(0.2, 200.0, 0.1))
ids_camera.set_pixel_clock(10)
ids_camera.cam.set_pixel_clock.assert_called_once_with(10)
assert ids_camera.get_exposure_time_range() == (0.2, 200.0)
def test_set_pixel_clock_snaps_to_nearest_supported_value(ids_camera):
"""Confirmed on hardware: not every value between get_pixel_clock_range()'s
min/max is actually accepted (49, 60, 73 MHz were all rejected on camera
41) -- set_pixel_clock() must snap to the nearest value in
get_pixel_clock_list() instead of passing the raw slider value through."""
ids_camera.cam.get_pixel_clock_list = mock.Mock(return_value=[5, 10, 20, 40])
ids_camera.cam.set_pixel_clock = mock.Mock()
ids_camera.cam.get_exposure_range = mock.Mock(return_value=(0.1, 100.0, 0.1))
ids_camera.set_pixel_clock(37) # nearest supported value is 40
ids_camera.cam.set_pixel_clock.assert_called_once_with(40)
assert ids_camera.get_pixel_clock() == 40
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)