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:
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()``
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user