diff --git a/csaxs_bec/bec_widgets/widgets/vision_tuning/vision_tuning.py b/csaxs_bec/bec_widgets/widgets/vision_tuning/vision_tuning.py index 7d8b7e09..b3222be6 100644 --- a/csaxs_bec/bec_widgets/widgets/vision_tuning/vision_tuning.py +++ b/csaxs_bec/bec_widgets/widgets/vision_tuning/vision_tuning.py @@ -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()`` diff --git a/csaxs_bec/devices/vision/vision_interface.py b/csaxs_bec/devices/vision/vision_interface.py index 1df6c2c4..e5f98a23 100644 --- a/csaxs_bec/devices/vision/vision_interface.py +++ b/csaxs_bec/devices/vision/vision_interface.py @@ -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: diff --git a/tests/tests_devices/test_vision_interface.py b/tests/tests_devices/test_vision_interface.py index 0b0c15de..bff699ce 100644 --- a/tests/tests_devices/test_vision_interface.py +++ b/tests/tests_devices/test_vision_interface.py @@ -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)