Files
csaxs_bec/tests/tests_devices/test_sample_vision_monitor.py
T
x01dcandClaude Sonnet 5 4420087c83
CI for csaxs_bec / test (push) Successful in 2m49s
fix(vision): correct a misdiagnosis, make registration + position config
The previous commit's "translation is currently invisible" finding was
wrong about the cause: verified directly that contour_similarity_score()
(Hu-moment shape matching) is unconditionally translation/rotation/scale-
invariant regardless of registration -- scoring completely unregistered
frames gives the same result as registered ones for a pure translation.
warp_mode never controlled that; it only affects registration/
rasterization quality for a genuine scale/rotation difference (small,
now verified effect).

Per feedback: OMNY genuinely wants position ignored, but this should be
configurable for other future consumers, not hardcoded. Since the shape
score can't be made position-sensitive, added a separate, independent
signal instead -- vision_toolkit.centroid_distance() (always measured on
raw, unregistered contours, since registering first would remove the
displacement it's meant to detect) and SampleVisionMonitor's
position_tolerance_px (None by default, matching OMNY's actual need;
runtime-adjustable via set/get_position_tolerance_px()). warp_mode is
now itself configurable too (WARP_MODES: none/translation/euclidean/
affine, friendly-string YAML-representable via resolve_warp_mode()) --
legitimate on its own merits, just not a fix for the translation
non-issue.

Corrected the one test that had encoded the wrong diagnosis
(asserted warp_mode="none" would make translation "count" -- it
doesn't), and rewrote the howto's affected section to match. Also fixed
one arithmetic error caught while re-verifying a howto example
(offset=(20,0) is a distance of 20px, not 20*sqrt(2)).

15 new/changed tests (6 vision_toolkit: resolve_warp_mode, none-mode
registration skip, corrected shape-invariance test, centroid/position
tests; 5 SampleVisionMonitor: warp_mode get/set, position_tolerance_px
behavior). Full suite: 371 passed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
2026-09-06 14:09:57 +02:00

214 lines
7.8 KiB
Python

"""Unit tests for the Layer-2 sample vision monitor device
(csaxs_bec/devices/vision/sample_vision_monitor.py)."""
import numpy as np
import pytest
from bec_server.device_server.tests.utils import DMMock
from csaxs_bec.devices.vision import vision_toolkit as vt
from csaxs_bec.devices.vision.sample_vision_monitor import (
SampleVisionMonitor,
SampleVisionMonitorError,
)
from csaxs_bec.devices.vision.vision_interface import VisionInterface
class FakeCamera:
"""Minimal stand-in for a camera device -- only get_last_image(), settable per test."""
def __init__(self, image: np.ndarray | None):
self.image = image
def get_last_image(self) -> np.ndarray | None:
return self.image
def _square_image(size: int = 60, offset: int = 0) -> np.ndarray:
return vt.make_square_test_pattern(size=size, square_side=20, offset=(offset, offset))
def _make_monitor(
camera_names=("cam_a", "cam_b"), images=None, **monitor_kwargs
) -> SampleVisionMonitor:
dm = DMMock()
images = images or {}
cameras = {name: FakeCamera(images.get(name)) for name in camera_names}
for name, camera in cameras.items():
dm.devices[name] = camera
interface = VisionInterface(
name="vision_interface_test", camera_names=list(camera_names), device_manager=dm
)
dm.devices["vision_interface_test"] = interface
monitor = SampleVisionMonitor(
name="vision_monitor_test",
vision_interface_name="vision_interface_test",
device_manager=dm,
**monitor_kwargs,
)
dm.devices["vision_monitor_test"] = monitor
monitor._cameras = cameras # convenience handle for tests to mutate live frames
return monitor
def test_missing_vision_interface_raises():
dm = DMMock()
monitor = SampleVisionMonitor(
name="vision_monitor_test", vision_interface_name="does_not_exist", device_manager=dm
)
with pytest.raises(SampleVisionMonitorError):
monitor.score("some_tag")
def test_score_with_no_saved_references_is_infinite_but_does_not_raise():
monitor = _make_monitor(images={"cam_a": _square_image(), "cam_b": _square_image()})
result = monitor.score("put_mount_start_1")
assert result["overall_score"] == float("inf")
for camera_summary in result["per_camera"].values():
assert camera_summary["score"] == float("inf")
assert camera_summary["reference_count"] == 0
def test_save_reference_then_score_against_identical_frame_scores_low():
image = _square_image()
monitor = _make_monitor(images={"cam_a": image, "cam_b": image})
monitor.save_reference("put_mount_start_1")
result = monitor.score("put_mount_start_1")
assert result["overall_score"] < float("inf")
for camera_summary in result["per_camera"].values():
assert camera_summary["reference_count"] == 1
assert camera_summary["score"] == pytest.approx(0.0, abs=1e-3)
def test_score_flags_a_camera_that_no_longer_matches_its_reference():
matching_image = _square_image()
monitor = _make_monitor(images={"cam_a": matching_image, "cam_b": matching_image})
monitor.save_reference("put_mount_start_1")
# cam_b's live view changes to something with no square at all.
monitor._cameras["cam_b"].image = np.zeros((60, 60, 3), dtype=np.uint8)
result = monitor.score("put_mount_start_1")
assert result["worst_camera"] == "cam_b"
assert result["per_camera"]["cam_b"]["score"] > result["per_camera"]["cam_a"]["score"]
assert result["overall_score"] == result["per_camera"]["cam_b"]["score"]
def test_score_publishes_a_diagnostic_overlay_for_the_worst_camera():
image = _square_image()
monitor = _make_monitor(images={"cam_a": image, "cam_b": image})
monitor.save_reference("put_mount_start_1")
monitor.score("put_mount_start_1")
interface = monitor._resolve_vision_interface()
published = interface.diagnostic_image.get()
assert published.data.shape[:2] == image.shape[:2]
def test_reference_tags_and_clear_reference():
monitor = _make_monitor(images={"cam_a": _square_image(), "cam_b": _square_image()})
assert monitor.reference_tags() == []
monitor.save_reference("tag_a")
monitor.save_reference("tag_b")
assert set(monitor.reference_tags()) == {"tag_a", "tag_b"}
monitor.clear_reference("tag_a")
assert monitor.reference_tags() == ["tag_b"]
# Clearing an unknown tag is a no-op, not an error.
monitor.clear_reference("does_not_exist")
def test_save_reference_accumulates_multiple_references_per_tag():
monitor = _make_monitor(images={"cam_a": _square_image(), "cam_b": _square_image()})
monitor.save_reference("tag_a")
monitor.save_reference("tag_a")
result = monitor.score("tag_a")
assert result["per_camera"]["cam_a"]["reference_count"] == 2
def test_default_warp_mode_is_euclidean():
monitor = _make_monitor()
assert monitor.get_warp_mode() == "euclidean"
def test_constructor_accepts_a_custom_warp_mode():
monitor = _make_monitor(warp_mode="affine")
assert monitor.get_warp_mode() == "affine"
def test_set_warp_mode_rejects_unknown_name():
monitor = _make_monitor()
with pytest.raises(ValueError):
monitor.set_warp_mode("sepia")
# A rejected change must not have taken effect.
assert monitor.get_warp_mode() == "euclidean"
def test_warp_mode_affects_scoring_of_a_zoomed_reference():
# Same shape, different scale (simulating a slightly different camera zoom) -- euclidean mode
# (translation+rotation only) cannot compensate for this, affine mode can.
current = vt.make_square_test_pattern(size=100, square_side=20, offset=(0, 0))
zoomed_reference = vt.make_square_test_pattern(size=100, square_side=26, offset=(0, 0))
monitor = _make_monitor(images={"cam_a": current, "cam_b": current}, warp_mode="euclidean")
monitor._cameras["cam_a"].image = zoomed_reference
monitor._cameras["cam_b"].image = zoomed_reference
monitor.save_reference("zoom_tag")
for camera in monitor._cameras.values():
camera.image = current
euclidean_score = monitor.score("zoom_tag")["overall_score"]
monitor.set_warp_mode("affine")
affine_score = monitor.score("zoom_tag")["overall_score"]
assert affine_score < euclidean_score
def test_position_not_checked_by_default():
image = _square_image()
monitor = _make_monitor(images={"cam_a": image, "cam_b": image})
monitor.save_reference("tag_a")
result = monitor.score("tag_a")
assert "position_distance_px" not in result["per_camera"]["cam_a"]
def test_position_tolerance_flags_a_camera_that_has_moved_too_far():
reference_image = _square_image(offset=0)
monitor = _make_monitor(
images={"cam_a": reference_image, "cam_b": reference_image}, position_tolerance_px=5.0
)
monitor.save_reference("tag_a")
# cam_b's live frame is the same shape, just shifted well past the tolerance -- the shape
# score alone (still euclidean by default) would call this a match; position tolerance must
# catch it independently.
monitor._cameras["cam_b"].image = _square_image(offset=15)
result = monitor.score("tag_a")
assert result["per_camera"]["cam_a"]["position_within_tolerance"] is True
assert result["per_camera"]["cam_b"]["position_within_tolerance"] is False
assert result["per_camera"]["cam_b"]["position_distance_px"] > 5.0
# The shape score itself must still report a match -- position and shape are independent axes.
assert result["per_camera"]["cam_b"]["score"] == pytest.approx(0.0, abs=1e-3)
def test_get_position_tolerance_px_default_is_none():
monitor = _make_monitor()
assert monitor.get_position_tolerance_px() is None
def test_set_position_tolerance_px_updates_at_runtime():
monitor = _make_monitor()
monitor.set_position_tolerance_px(2.5)
assert monitor.get_position_tolerance_px() == 2.5
monitor.set_position_tolerance_px(None)
assert monitor.get_position_tolerance_px() is None