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
228 lines
7.9 KiB
Python
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)
|