CI for csaxs_bec / test (push) Successful in 2m10s
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
269 lines
9.2 KiB
Python
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()
|