Files
csaxs_bec/tests/tests_devices/test_vision_interface.py
T
Mirko HollerandClaude Sonnet 5 6bf4f3e716 feat(vision): add per-camera ROI cropping to VisionInterface
set_roi()/get_roi() (plus a roi constructor argument -- a single tuple for
every configured camera, or a per-camera-name mapping) crop a camera's frame
to (x, y, width, height) before any mode processing, publishing, or scoring
happens. Applied centrally in _get_camera_frame(), the one method both
get_raw_frame() and get_last_processed_image()/refresh() go through, so
SampleVisionMonitor.save_reference()/score() pick it up automatically with
no changes needed there -- both already call get_raw_frame(), never the
camera directly. An out-of-bounds ROI raises VisionInterfaceError rather
than silently clamping.

New vision_toolkit.crop_to_roi() is the underlying pure-function primitive.

Also declares device_manager explicitly in VisionInterface's own __init__,
same bug and fix as SampleVisionMonitor's previous commit -- the device
server never injected it into either class before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9zgK28uPoZWJFkJWV4P1G
2026-09-06 20:14:28 -03:00

228 lines
7.9 KiB
Python

"""Unit tests for the Layer-1 vision interface device (csaxs_bec/devices/vision/vision_interface.py)."""
import numpy as np
import pytest
from bec_server.device_server.tests.utils import DMMock
from csaxs_bec.devices.vision.vision_interface import VisionInterface, VisionInterfaceError
class FakeCamera:
"""Minimal stand-in for a camera device -- only the shared get_last_image() contract,
deliberately not an IDSCamera/AlliedVisionAravisCamera subclass, to prove VisionInterface
doesn't depend on either."""
def __init__(self, image: np.ndarray | None):
self._image = image
def get_last_image(self) -> np.ndarray | None:
return self._image
def _make_interface(camera_names=("cam_a",), images=None, **kwargs) -> VisionInterface:
dm = DMMock()
images = images or {}
for camera_name in camera_names:
dm.devices[camera_name] = FakeCamera(images.get(camera_name))
return VisionInterface(
name="vision_test", camera_names=list(camera_names), device_manager=dm, **kwargs
)
def _rgb_image(size: int = 20) -> np.ndarray:
image = np.zeros((size, size, 3), dtype=np.uint8)
image[5:15, 5:15] = 255
return image
def test_construction_requires_at_least_one_camera_name():
dm = DMMock()
with pytest.raises(VisionInterfaceError):
VisionInterface(name="vision_test", camera_names=[], device_manager=dm)
def test_camera_names_returns_a_copy():
interface = _make_interface(camera_names=["cam_a", "cam_b"])
names = interface.camera_names
names.append("cam_c")
assert interface.camera_names == ["cam_a", "cam_b"]
def test_default_mode_is_raw():
interface = _make_interface()
assert interface.get_mode() == "raw"
def test_set_mode_rejects_unknown_mode():
interface = _make_interface()
with pytest.raises(VisionInterfaceError):
interface.set_mode("sepia")
def test_get_last_processed_image_raw_mode_returns_unmodified_frame():
image = _rgb_image()
interface = _make_interface(images={"cam_a": image})
result = interface.get_last_processed_image()
assert np.array_equal(result, image)
def test_get_last_processed_image_grayscale_mode():
image = _rgb_image()
interface = _make_interface(images={"cam_a": image}, mode="grayscale")
result = interface.get_last_processed_image()
assert result.ndim == 2
assert result.shape == image.shape[:2]
def test_get_last_processed_image_edges_mode():
image = _rgb_image()
interface = _make_interface(images={"cam_a": image}, mode="edges")
result = interface.get_last_processed_image()
assert result.ndim == 2
assert result.sum() > 0
def test_get_last_processed_image_unknown_camera_name_raises():
interface = _make_interface(camera_names=["cam_a"], images={"cam_a": _rgb_image()})
with pytest.raises(VisionInterfaceError):
interface.get_last_processed_image(camera_name="cam_not_configured")
def test_get_last_processed_image_missing_camera_in_device_manager_raises():
dm = DMMock() # cam_a never registered
interface = VisionInterface(name="vision_test", camera_names=["cam_a"], device_manager=dm)
with pytest.raises(VisionInterfaceError):
interface.get_last_processed_image()
def test_get_last_processed_image_no_frame_yet_raises():
interface = _make_interface(images={"cam_a": None})
with pytest.raises(VisionInterfaceError):
interface.get_last_processed_image()
def test_refresh_publishes_to_diagnostic_image_and_returns_it():
image = _rgb_image()
interface = _make_interface(images={"cam_a": image}, mode="grayscale")
result = interface.refresh()
assert result.ndim == 2
published = interface.diagnostic_image.get()
assert np.array_equal(published.data, result)
def test_refresh_second_configured_camera_by_name():
image_a = _rgb_image()
image_b = np.zeros((20, 20, 3), dtype=np.uint8)
interface = _make_interface(
camera_names=["cam_a", "cam_b"], images={"cam_a": image_a, "cam_b": image_b}
)
result = interface.refresh(camera_name="cam_b")
assert np.array_equal(result, image_b)
def test_get_raw_frame_bypasses_active_mode():
image = _rgb_image()
interface = _make_interface(images={"cam_a": image}, mode="edges")
result = interface.get_raw_frame()
assert np.array_equal(result, image)
def test_get_roi_defaults_to_none_for_every_camera():
interface = _make_interface(camera_names=["cam_a", "cam_b"])
assert interface.get_roi("cam_a") is None
assert interface.get_roi() == {}
def test_set_roi_crops_frames_from_get_raw_frame():
image = _rgb_image(size=20)
interface = _make_interface(images={"cam_a": image})
interface.set_roi((5, 5, 8, 6))
result = interface.get_raw_frame()
assert result.shape == (6, 8, 3)
assert np.array_equal(result, image[5:11, 5:13])
def test_set_roi_affects_processed_image_and_refresh():
image = _rgb_image(size=20)
interface = _make_interface(images={"cam_a": image}, mode="grayscale")
interface.set_roi((0, 0, 10, 8))
result = interface.refresh()
assert result.shape == (8, 10)
published = interface.diagnostic_image.get()
assert np.array_equal(published.data, result)
def test_set_roi_none_clears_it():
image = _rgb_image(size=20)
interface = _make_interface(images={"cam_a": image})
interface.set_roi((0, 0, 10, 8))
interface.set_roi(None)
result = interface.get_raw_frame()
assert result.shape == image.shape
def test_set_roi_per_camera_only_affects_that_camera():
image_a = _rgb_image(size=20)
image_b = _rgb_image(size=20)
interface = _make_interface(
camera_names=["cam_a", "cam_b"], images={"cam_a": image_a, "cam_b": image_b}
)
interface.set_roi((0, 0, 10, 8), camera_name="cam_a")
assert interface.get_raw_frame("cam_a").shape == (8, 10, 3)
assert interface.get_raw_frame("cam_b").shape == image_b.shape
assert interface.get_roi() == {"cam_a": (0, 0, 10, 8)}
def test_set_roi_without_camera_name_applies_to_all_configured_cameras():
image_a = _rgb_image(size=20)
image_b = _rgb_image(size=20)
interface = _make_interface(
camera_names=["cam_a", "cam_b"], images={"cam_a": image_a, "cam_b": image_b}
)
interface.set_roi((0, 0, 10, 8))
assert interface.get_raw_frame("cam_a").shape == (8, 10, 3)
assert interface.get_raw_frame("cam_b").shape == (8, 10, 3)
def test_set_roi_unknown_camera_name_raises():
interface = _make_interface(images={"cam_a": _rgb_image()})
with pytest.raises(VisionInterfaceError):
interface.set_roi((0, 0, 5, 5), camera_name="cam_not_configured")
def test_get_raw_frame_out_of_bounds_roi_raises_vision_interface_error():
interface = _make_interface(images={"cam_a": _rgb_image(size=20)})
interface.set_roi((15, 15, 10, 10))
with pytest.raises(VisionInterfaceError):
interface.get_raw_frame()
def test_roi_constructor_argument_single_tuple_applies_to_all_cameras():
image = _rgb_image(size=20)
interface = _make_interface(
camera_names=["cam_a", "cam_b"],
images={"cam_a": image, "cam_b": image},
roi=(0, 0, 10, 8),
)
assert interface.get_raw_frame("cam_a").shape == (8, 10, 3)
assert interface.get_raw_frame("cam_b").shape == (8, 10, 3)
def test_roi_constructor_argument_dict_is_per_camera():
image = _rgb_image(size=20)
interface = _make_interface(
camera_names=["cam_a", "cam_b"],
images={"cam_a": image, "cam_b": image},
roi={"cam_a": (0, 0, 10, 8)},
)
assert interface.get_raw_frame("cam_a").shape == (8, 10, 3)
assert interface.get_raw_frame("cam_b").shape == image.shape
def test_publish_diagnostic_image_makes_an_externally_computed_image_available():
interface = _make_interface(images={"cam_a": _rgb_image()})
overlay = np.full((5, 5), 42, dtype=np.uint8)
interface.publish_diagnostic_image(overlay)
published = interface.diagnostic_image.get()
assert np.array_equal(published.data, overlay)