Files
csaxs_bec/tests/tests_devices/test_vision_interface.py
T
Mirko HollerandClaude Sonnet 5 b48aaa6ade
CI for csaxs_bec / test (push) Successful in 2m10s
feat(vision): make edge detection tunable (thresholds + pre-Canny blur)
Real captures (a photo of the gripper/sample gap against a CNC-machined metal
background, and the existing flomni reference_images/ set) show dense machining
scratches registering as Canny edges as strongly as the actual object boundary,
at any threshold -- verified on one real capture: low=50/high=150 finds ~203k
edge pixels, low=150/high=250 still ~76k, both dominated by texture rather than
the gripper. Raising thresholds alone can't separate "faint real edge" from
"strong but irrelevant texture" when both survive at the same gradient
magnitude.

vision_toolkit.detect_edges() gains a blur_ksize parameter (Gaussian blur before
Canny, 0 = off) for exactly this: blurring suppresses fine texture while leaving
the coarser real edge mostly intact. On the same capture, adding blur_ksize=9 on
top of low=150/high=250 drops the edge count to ~5k; blur_ksize=15 to ~1.8k.

VisionInterface exposes this as live device state (set_edge_params()/
get_edge_params(), used by "edges" mode), and VisionTuning gains edge low/high/
blur spin boxes (enabled only in "edges" mode) next to the existing mode/ROI
controls, so tuning is visual and interactive rather than a blind parameter
sweep. VISION_PIPELINE_TESTING_HOWTO.md's manual edge-inspection section is
extended accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQpiwJcJZJKGo6Pzozfsi1
2026-09-07 07:47:21 -03:00

269 lines
9.2 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)
def test_default_edge_params():
interface = _make_interface()
assert interface.get_edge_params() == {
"low_threshold": 50,
"high_threshold": 150,
"blur_ksize": 0,
}
def test_edge_params_constructor_arguments():
interface = _make_interface(
edge_low_threshold=10, edge_high_threshold=200, edge_blur_ksize=5
)
assert interface.get_edge_params() == {
"low_threshold": 10,
"high_threshold": 200,
"blur_ksize": 5,
}
def test_set_edge_params_rejects_even_positive_blur_ksize():
interface = _make_interface()
with pytest.raises(VisionInterfaceError):
interface.set_edge_params(50, 150, blur_ksize=4)
def test_set_edge_params_zero_blur_ksize_is_allowed():
interface = _make_interface()
interface.set_edge_params(50, 150, blur_ksize=0)
assert interface.get_edge_params()["blur_ksize"] == 0
def test_set_edge_params_affects_edges_mode_output():
image = _rgb_image()
interface = _make_interface(images={"cam_a": image}, mode="edges")
default_result = interface.get_last_processed_image()
interface.set_edge_params(250, 255) # thresholds high enough to suppress the square's edges
strict_result = interface.get_last_processed_image()
assert strict_result.sum() <= default_result.sum()