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
This commit is contained in:
Mirko Holler
2026-09-06 20:14:28 -03:00
co-authored by Claude Sonnet 5
parent d52e3c5364
commit 6bf4f3e716
4 changed files with 218 additions and 6 deletions
+76 -6
View File
@@ -2,9 +2,9 @@
Layer 1 of ``AI_docs/MACHINE_VISION_AUTOCONFIRM_PLAN.md``: wraps one or more source cameras,
referenced by configured name rather than by class, and republishes a processed view of the
latest frame -- raw / grayscale / edge-detected / CLAHE-normalized, using the Layer-0
:mod:`vision_toolkit` -- through its own ``diagnostic_image`` signal, independent of the source
camera's own live-acquisition channel.
latest frame -- raw / grayscale / edge-detected / CLAHE-normalized, optionally cropped first to a
per-camera region of interest, using the Layer-0 :mod:`vision_toolkit` -- through its own
``diagnostic_image`` signal, independent of the source camera's own live-acquisition channel.
"""
from __future__ import annotations
@@ -20,10 +20,11 @@ from ophyd_devices.utils.bec_signals import PreviewSignal
from csaxs_bec.devices.vision import vision_toolkit as vt
if TYPE_CHECKING:
from bec_lib.devicemanager import ScanInfo
from bec_lib.devicemanager import DeviceManagerBase, ScanInfo
VisionMode = Literal["raw", "grayscale", "edges", "clahe"]
_VALID_MODES: tuple[VisionMode, ...] = ("raw", "grayscale", "edges", "clahe")
RoiType = tuple[int, int, int, int]
class VisionInterfaceError(Exception):
@@ -59,6 +60,8 @@ class VisionInterface(PSIDeviceBase):
"camera_names",
"set_mode",
"get_mode",
"set_roi",
"get_roi",
"get_last_processed_image",
"get_raw_frame",
"publish_diagnostic_image",
@@ -72,7 +75,9 @@ class VisionInterface(PSIDeviceBase):
camera_names: list[str],
prefix: str = "",
scan_info: "ScanInfo | None" = None,
device_manager: "DeviceManagerBase | None" = None,
mode: VisionMode = "raw",
roi: "RoiType | dict[str, RoiType] | None" = None,
**kwargs,
):
"""Initialize the vision interface.
@@ -86,13 +91,30 @@ class VisionInterface(PSIDeviceBase):
single-camera setup is just a length-1 list.
prefix (str): Prefix for the device.
scan_info (ScanInfo | None): Scan information for the device.
device_manager (DeviceManagerBase | None): Device manager, needed to resolve
``camera_names`` at call time -- must be declared explicitly here (not just
inherited via ``**kwargs``) since the device server only injects it into classes
whose own ``__init__`` signature names it.
mode (VisionMode): Initial processing mode.
roi (RoiType | dict[str, RoiType] | None): Region of interest to crop from each
camera's frame before any further processing -- see :meth:`set_roi`. Either a
single ``(x, y, width, height)`` applied to every configured camera, a
``{camera_name: (x, y, width, height)}`` mapping for per-camera ROIs, or ``None``
(the default) to use each camera's full frame.
"""
super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs)
super().__init__(
name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs
)
if not camera_names:
raise VisionInterfaceError(f"{name}: camera_names must be a non-empty list.")
self._camera_names = list(camera_names)
self._rois: dict[str, RoiType] = {}
self.set_mode(mode)
if isinstance(roi, dict):
for camera_name, camera_roi in roi.items():
self.set_roi(camera_roi, camera_name=camera_name)
elif roi is not None:
self.set_roi(roi)
@property
def camera_names(self) -> list[str]:
@@ -108,12 +130,54 @@ class VisionInterface(PSIDeviceBase):
def get_mode(self) -> str:
return self.mode_signal.get()
def _resolve_camera(self, camera_name: str):
def _check_camera_name(self, camera_name: str) -> None:
if camera_name not in self._camera_names:
raise VisionInterfaceError(
f"{self.name}: '{camera_name}' is not one of this device's configured cameras"
f" {self._camera_names}."
)
def set_roi(self, roi: "RoiType | None", camera_name: str | None = None) -> None:
"""Set (or clear) the region of interest cropped from a camera's frame before any further
processing -- affects :meth:`get_raw_frame`/:meth:`get_last_processed_image`/:meth:`refresh`,
and therefore also what ``SampleVisionMonitor.save_reference()``/``score()`` see, uniformly
(both go through this interface's frame-fetching, not the camera directly).
Args:
roi (RoiType | None): ``(x, y, width, height)`` in pixels, top-left origin -- matching
the convention OpenCV and ``bec_widgets``' image ROI widgets use
(``RectangularROI.get_coordinates()``), so coordinates picked interactively in a
GUI can be passed straight through. ``None`` clears the ROI, going back to the
camera's full frame. Not validated against the camera's actual resolution until the
next frame is pulled -- the camera may not have produced one yet at configuration
time.
camera_name (str | None): Which configured camera to set this for. ``None`` (the
default) applies it to every camera configured on this interface.
"""
targets = [camera_name] if camera_name is not None else self._camera_names
for target in targets:
self._check_camera_name(target)
for target in targets:
if roi is None:
self._rois.pop(target, None)
else:
self._rois[target] = tuple(roi)
def get_roi(self, camera_name: str | None = None) -> "RoiType | dict[str, RoiType] | None":
"""Current ROI configuration.
Args:
camera_name (str | None): If given, that camera's ROI (``None`` if it has none set).
If omitted, the full ``{camera_name: roi}`` mapping of every camera that currently
has one set.
"""
if camera_name is None:
return dict(self._rois)
self._check_camera_name(camera_name)
return self._rois.get(camera_name)
def _resolve_camera(self, camera_name: str):
self._check_camera_name(camera_name)
if self.device_manager is None or not hasattr(self.device_manager, "devices"):
raise VisionInterfaceError(f"{self.name}: device manager is not available.")
camera = self.device_manager.devices.get(camera_name, None)
@@ -129,6 +193,12 @@ class VisionInterface(PSIDeviceBase):
image = camera.get_last_image()
if image is None:
raise VisionInterfaceError(f"{self.name}: camera '{camera_name}' has no image yet.")
roi = self._rois.get(camera_name)
if roi is not None:
try:
image = vt.crop_to_roi(image, roi)
except ValueError as exc:
raise VisionInterfaceError(f"{self.name}: {exc}") from exc
return image
def _process(self, image: np.ndarray, mode: str) -> np.ndarray:
@@ -115,6 +115,30 @@ def make_square_test_pattern(
return image
def crop_to_roi(image: np.ndarray, roi: tuple[int, int, int, int]) -> np.ndarray:
"""Crop ``image`` to a rectangular region of interest.
Args:
image: Array to crop (grayscale or multi-channel; only the first two axes are cropped).
roi: ``(x, y, width, height)`` in pixels, top-left origin -- the same convention OpenCV
and ``bec_widgets``' image ROI widgets use (``RectangularROI.get_coordinates()``), so
coordinates picked interactively in a GUI can be passed straight through.
Raises:
ValueError: if width/height aren't positive, or the ROI falls even partially outside
``image``'s bounds -- silently clamping would make a caller believe it's always
comparing the same region when the configured ROI no longer fits the camera's actual
frame size.
"""
x, y, width, height = roi
if width <= 0 or height <= 0:
raise ValueError(f"ROI width/height must be positive, got {roi}.")
image_height, image_width = image.shape[:2]
if x < 0 or y < 0 or x + width > image_width or y + height > image_height:
raise ValueError(f"ROI {roi} is out of bounds for image of shape {image.shape[:2]}.")
return image[y : y + height, x : x + width]
def to_grayscale(image: np.ndarray) -> np.ndarray:
"""Convert an image to single-channel grayscale.
@@ -127,6 +127,98 @@ def test_get_raw_frame_bypasses_active_mode():
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)
@@ -20,6 +20,32 @@ def test_make_square_test_pattern_shape_and_offset():
assert not np.array_equal(centered, shifted)
def test_crop_to_roi_returns_expected_subregion():
image = np.zeros((20, 20), dtype=np.uint8)
image[5:10, 3:8] = 42
cropped = vt.crop_to_roi(image, (3, 5, 5, 5))
assert cropped.shape == (5, 5)
assert np.all(cropped == 42)
def test_crop_to_roi_preserves_channels():
image = _square_image(size=20, square_side=6)
cropped = vt.crop_to_roi(image, (0, 0, 10, 8))
assert cropped.shape == (8, 10, 3)
def test_crop_to_roi_rejects_non_positive_size():
image = np.zeros((10, 10), dtype=np.uint8)
with pytest.raises(ValueError):
vt.crop_to_roi(image, (0, 0, 0, 5))
def test_crop_to_roi_rejects_out_of_bounds():
image = np.zeros((10, 10), dtype=np.uint8)
with pytest.raises(ValueError):
vt.crop_to_roi(image, (5, 5, 10, 10))
def test_to_grayscale_passes_through_2d():
gray = np.zeros((10, 10), dtype=np.uint8)
assert vt.to_grayscale(gray) is gray