feat(vision): wire find_dark_holes() into VisionInterface + VisionTuning as a "holes" mode

find_dark_holes() (previous commit) had no way to see it live -- only edges/
raw/grayscale/clahe existed as VisionInterface processing modes. Adds "holes":
runs find_dark_holes() over the configured area range and draws every
candidate contour (green) as an overlay, so the notch-detection work from the
last few sessions can actually be watched in the VisionTuning widget instead
of only via one-off scripts.

VisionInterface: new set_hole_area_range()/get_hole_area_range(), constructor
hole_area_range kwarg (default unbounded, matching find_dark_holes()'s own
default -- but see its docstring: an unbounded search also matches any bright
object's own silhouette, so this is meant to be tuned per scene same as
edge_low_threshold/high_threshold already are).

VisionTuning: hole area min/max spin boxes + Apply button, enabled only in
"holes" mode (same greyed-out-when-irrelevant pattern as the edge controls).
max defaults to a large practical stand-in for "unbounded" since QSpinBox
can't hold math.inf.

6 new tests (default/constructor area range, holes-mode overlay drawn vs. not
drawn depending on whether a candidate is in range). Verified end-to-end
against the real get_1 capture (not just synthetic data) before committing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fDXnPuNvm3qUsUb4V4Gfo
This commit is contained in:
Mirko HollerandClaude Sonnet 5 authored and Mirko Holler committed 2026-10-26 14:16:01 +01:00
1 parent 977de26e06
commit b30bbda13f
3 files changed
+123 -14

No files matched your search

@@ -3,9 +3,11 @@
Deliberately small -- modeled on ``z_ConsoleButtonsWidget``'s "yesno" simplicity, not on the full
``OMNY_XRayEye`` alignment GUI: pick a ``VisionInterface`` device and one of its cameras, flip
through processing modes, set/clear an ROI, and watch the live ``diagnostic_image`` result. No
``SampleVisionMonitor`` (scoring) controls here -- that is a different device/concern and would
roughly double the knob count; a follow-up panel can add it later if wanted.
through processing modes (including ``"holes"`` -- ``vision_toolkit.find_dark_holes()``'s live
diagnostic view, drawing every candidate dark-hole contour in the configured area range), set/clear
an ROI, and watch the live ``diagnostic_image`` result. No ``SampleVisionMonitor`` (scoring)
controls here -- that is a different device/concern and would roughly double the knob count; a
follow-up panel can add it later if wanted.
"""
from __future__ import annotations
@@ -60,6 +62,10 @@ class VisionTuning(BECWidget, QWidget):
PLUGIN = True
USER_ACCESS: list[str] = []
# Stands in for "unbounded" on the hole-area-max spin box (QSpinBox can't hold math.inf) --
# far above any real image's total contour area, so it behaves as unbounded in practice.
_HOLE_AREA_MAX = 5_000_000
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
self.get_bec_shortcuts()
@@ -132,6 +138,24 @@ class VisionTuning(BECWidget, QWidget):
edge_row.addWidget(self.apply_edge_button)
root.addLayout(edge_row)
# Dark-hole tuning -- only meaningful in "holes" mode. find_dark_holes() always needs an
# area range scoped to the expected feature (a gripper notch, say) -- an unbounded search
# also matches any bright object's own silhouette, see its docstring. _HOLE_AREA_MAX below
# stands in for "unbounded" on the max spin box (QSpinBox can't hold math.inf); any real
# image's total contour area is far below it anyway.
holes_row = QHBoxLayout()
self.hole_min = QSpinBox(parent=self)
self.hole_max = QSpinBox(parent=self)
self.hole_min.setRange(0, self._HOLE_AREA_MAX)
self.hole_max.setRange(0, self._HOLE_AREA_MAX)
self.hole_max.setValue(self._HOLE_AREA_MAX)
for label_text, spin_box in (("hole area min", self.hole_min), ("max", self.hole_max)):
holes_row.addWidget(QLabel(label_text, parent=self))
holes_row.addWidget(spin_box)
self.apply_holes_button = QPushButton("Apply Hole Range", parent=self)
holes_row.addWidget(self.apply_holes_button)
root.addLayout(holes_row)
control_row = QHBoxLayout()
self.refresh_button = QPushButton("Refresh", parent=self)
self.live_checkbox = QCheckBox("Live", parent=self)
@@ -156,9 +180,10 @@ class VisionTuning(BECWidget, QWidget):
self.apply_roi_button.clicked.connect(self._on_apply_roi)
self.clear_roi_button.clicked.connect(self._on_clear_roi)
self.apply_edge_button.clicked.connect(self._on_apply_edge_params)
self.apply_holes_button.clicked.connect(self._on_apply_holes_params)
self.refresh_button.clicked.connect(self._on_refresh_clicked)
self.live_checkbox.toggled.connect(self._on_live_toggled)
self._update_edge_controls_enabled(self.mode_combo.currentText())
self._update_mode_controls_enabled(self.mode_combo.currentText())
def _populate_devices(self):
"""List every configured ``VisionInterface`` instance, once, at widget construction --
@@ -212,6 +237,13 @@ class VisionTuning(BECWidget, QWidget):
spin_box.blockSignals(True)
spin_box.setValue(edge_params[key])
spin_box.blockSignals(False)
hole_min, hole_max = interface.get_hole_area_range()
self.hole_min.blockSignals(True)
self.hole_min.setValue(int(min(hole_min, self._HOLE_AREA_MAX)))
self.hole_min.blockSignals(False)
self.hole_max.blockSignals(True)
self.hole_max.setValue(int(min(hole_max, self._HOLE_AREA_MAX)))
self.hole_max.blockSignals(False)
if camera_names:
self._on_camera_changed(camera_names[0])
else:
@@ -241,20 +273,23 @@ class VisionTuning(BECWidget, QWidget):
@SafeSlot(str)
def _on_mode_changed(self, mode: str):
self._update_edge_controls_enabled(mode)
self._update_mode_controls_enabled(mode)
interface = self._current_interface()
if interface is None or not mode:
return
interface.set_mode(mode)
self._on_refresh_clicked()
def _update_edge_controls_enabled(self, mode: str):
"""Edge threshold/blur only affect "edges" mode -- grey them out otherwise rather than
def _update_mode_controls_enabled(self, mode: str):
"""Edge/hole controls only affect their own mode -- grey them out otherwise rather than
leaving controls that currently do nothing clickable (same reasoning as
z_ConsoleButtonsWidget greying out Yes/No with no message to respond to)."""
enabled = mode == "edges"
edges_enabled = mode == "edges"
for widget in (self.edge_low, self.edge_high, self.edge_blur, self.apply_edge_button):
widget.setEnabled(enabled)
widget.setEnabled(edges_enabled)
holes_enabled = mode == "holes"
for widget in (self.hole_min, self.hole_max, self.apply_holes_button):
widget.setEnabled(holes_enabled)
@SafeSlot()
def _on_apply_roi(self):
@@ -287,6 +322,18 @@ class VisionTuning(BECWidget, QWidget):
return
self._on_refresh_clicked()
@SafeSlot()
def _on_apply_holes_params(self):
interface = self._current_interface()
if interface is None:
return
try:
interface.set_hole_area_range(self.hole_min.value(), self.hole_max.value())
except Exception as exc:
self._set_status(f"set_hole_area_range failed: {exc}")
return
self._on_refresh_clicked()
@SafeSlot()
def _on_refresh_clicked(self):
"""Pull+process+publish once. Never triggers a live camera acquisition -- ``refresh()``
+34 -5
View File
@@ -2,13 +2,15 @@
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, 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.
latest frame -- raw / grayscale / edge-detected / CLAHE-normalized / dark-hole-overlay, 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
import math
from typing import TYPE_CHECKING, Literal
import numpy as np
@@ -22,8 +24,8 @@ from csaxs_bec.devices.vision import vision_toolkit as vt
if TYPE_CHECKING:
from bec_lib.devicemanager import DeviceManagerBase, ScanInfo
VisionMode = Literal["raw", "grayscale", "edges", "clahe"]
_VALID_MODES: tuple[VisionMode, ...] = ("raw", "grayscale", "edges", "clahe")
VisionMode = Literal["raw", "grayscale", "edges", "clahe", "holes"]
_VALID_MODES: tuple[VisionMode, ...] = ("raw", "grayscale", "edges", "clahe", "holes")
RoiType = tuple[int, int, int, int]
@@ -64,6 +66,8 @@ class VisionInterface(PSIDeviceBase):
"get_roi",
"set_edge_params",
"get_edge_params",
"set_hole_area_range",
"get_hole_area_range",
"get_last_processed_image",
"get_raw_frame",
"publish_diagnostic_image",
@@ -83,6 +87,7 @@ class VisionInterface(PSIDeviceBase):
edge_low_threshold: int = 50,
edge_high_threshold: int = 150,
edge_blur_ksize: int = 0,
hole_area_range: "tuple[float, float]" = (0.0, math.inf),
**kwargs,
):
"""Initialize the vision interface.
@@ -110,6 +115,8 @@ class VisionInterface(PSIDeviceBase):
:meth:`set_edge_params`.
edge_high_threshold (int): Initial Canny high threshold for ``"edges"`` mode.
edge_blur_ksize (int): Initial pre-Canny Gaussian blur kernel size (``0`` = no blur).
hole_area_range (tuple[float, float]): Initial area range for ``"holes"`` mode -- see
:meth:`set_hole_area_range`.
"""
super().__init__(
name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs
@@ -120,6 +127,7 @@ class VisionInterface(PSIDeviceBase):
self._rois: dict[str, RoiType] = {}
self.set_mode(mode)
self.set_edge_params(edge_low_threshold, edge_high_threshold, edge_blur_ksize)
self.set_hole_area_range(*hole_area_range)
if isinstance(roi, dict):
for camera_name, camera_roi in roi.items():
self.set_roi(camera_roi, camera_name=camera_name)
@@ -224,6 +232,21 @@ class VisionInterface(PSIDeviceBase):
"blur_ksize": self._edge_blur_ksize,
}
def set_hole_area_range(self, min_area: float, max_area: float) -> None:
"""Set the area range used by ``"holes"`` mode (see :meth:`set_mode`) to search for --
:func:`vision_toolkit.find_dark_holes`'s ``area_range``, always required there rather than
left unbounded, since an unbounded search also matches any bright object's own silhouette
(see that function's docstring). Pick this from whatever's physically plausible for the
feature being searched for (e.g. a gripper notch's known approximate size) -- there is no
generally-correct default.
"""
self._hole_area_min = min_area
self._hole_area_max = max_area
def get_hole_area_range(self) -> tuple[float, float]:
"""Current ``"holes"``-mode area range -- see :meth:`set_hole_area_range`."""
return (self._hole_area_min, self._hole_area_max)
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"):
@@ -260,6 +283,12 @@ class VisionInterface(PSIDeviceBase):
)
if mode == "clahe":
return vt.apply_clahe(image)
if mode == "holes":
holes = vt.find_dark_holes(image, (self._hole_area_min, self._hole_area_max))
overlay = image
for hole in holes:
overlay = vt.draw_contour_overlay(overlay, hole, color=(0, 255, 0))
return overlay
raise VisionInterfaceError(f"{self.name}: unknown mode '{mode}'.")
def get_last_processed_image(self, camera_name: str | None = None) -> np.ndarray:
@@ -1,5 +1,7 @@
"""Unit tests for the Layer-1 vision interface device (csaxs_bec/devices/vision/vision_interface.py)."""
import math
import numpy as np
import pytest
from bec_server.device_server.tests.utils import DMMock
@@ -266,3 +268,34 @@ def test_set_edge_params_affects_edges_mode_output():
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()
def _rgb_image_with_hole(size: int = 20) -> np.ndarray:
image = _rgb_image(size=size)
image[9:11, 9:11] = 0 # small dark hole (area ~4) punched into the bright square
return image
def test_default_hole_area_range():
interface = _make_interface()
assert interface.get_hole_area_range() == (0.0, math.inf)
def test_hole_area_range_constructor_argument():
interface = _make_interface(hole_area_range=(2, 10))
assert interface.get_hole_area_range() == (2, 10)
def test_holes_mode_draws_overlay_when_a_hole_is_in_range():
image = _rgb_image_with_hole()
interface = _make_interface(images={"cam_a": image}, mode="holes", hole_area_range=(1, 20))
result = interface.get_last_processed_image()
assert result.ndim == 3 # a color overlay, not the raw/edge/grayscale 2D forms
assert not np.array_equal(result, image)
def test_holes_mode_no_match_in_range_returns_image_unmodified():
image = _rgb_image_with_hole()
interface = _make_interface(images={"cam_a": image}, mode="holes", hole_area_range=(1000, 2000))
result = interface.get_last_processed_image()
assert np.array_equal(result, image)