From 3b9064f91f94b7f4ce2269dedfb10700a31a385d Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 11 Feb 2026 11:20:01 +0100 Subject: [PATCH 01/30] Enhance Aravis backend device discovery & rebind Add device enumeration and rebinding utilities to the Aravis backend: implement quick_ping, discover_devices, rebind_settings, _arv_snapshot_devices and _safe_str to allow probing and best-effort rebinds without opening cameras. Update open() to record and refresh device identity (device_id, physical id, vendor/model/serial, label) into CameraSettings properties and compute a higher-quality label from the opened camera. Change namespace key usage from camera_id to device_id and import CameraSettings and DetectedCamera to support the new APIs. --- dlclivegui/cameras/backends/aravis_backend.py | 283 +++++++++++++++++- 1 file changed, 282 insertions(+), 1 deletion(-) diff --git a/dlclivegui/cameras/backends/aravis_backend.py b/dlclivegui/cameras/backends/aravis_backend.py index a0d9ce3..b437c3c 100644 --- a/dlclivegui/cameras/backends/aravis_backend.py +++ b/dlclivegui/cameras/backends/aravis_backend.py @@ -10,7 +10,9 @@ import cv2 import numpy as np +from ...config import CameraSettings from ..base import CameraBackend, SupportLevel, register_backend +from ..factory import DetectedCamera LOG = logging.getLogger(__name__) @@ -40,7 +42,7 @@ def __init__(self, settings): if not isinstance(ns, dict): ns = {} - self._camera_id: str | None = ns.get("camera_id") or props.get("camera_id") + self._camera_id: str | None = ns.get("device_id") or props.get("device_id") self._pixel_format: str = ns.get("pixel_format") or props.get("pixel_format", "Mono8") self._timeout: int = int(ns.get("timeout", props.get("timeout", 2_000_000))) self._n_buffers: int = int(ns.get("n_buffers", props.get("n_buffers", 10))) @@ -103,6 +105,153 @@ def get_device_count(cls) -> int: except Exception: return -1 + @classmethod + def quick_ping(cls, index: int, *_args, **_kwargs) -> bool: + """ + Cheap presence test for CameraFactory probing. + Uses update_device_list() then bounds-check. + """ + if not ARAVIS_AVAILABLE: + return False + try: + Aravis.update_device_list() + n = int(Aravis.get_n_devices() or 0) + return 0 <= int(index) < n + except Exception: + return False + + @classmethod + def discover_devices(cls, max_devices: int = 10, should_cancel=None, progress_cb=None): + if not ARAVIS_AVAILABLE: + return [] + + # Refresh list once; indices may change after update_device_list() + Aravis.update_device_list() + + snap = cls._arv_snapshot_devices(limit=max_devices) + + cams: list[DetectedCamera] = [] + for d in snap: + if should_cancel and should_cancel(): + break + if progress_cb: + progress_cb(f"Found {d['label']}") + + path = d.get("physical_id") or d.get("address") + + cams.append( + DetectedCamera( + index=int(d["index"]), + label=str(d["label"]), + device_id=d.get("device_id"), + path=path, + ) + ) + return cams + + @classmethod + def rebind_settings(cls, settings: CameraSettings) -> CameraSettings: + """ + Best-effort quick rebind using only Aravis enumeration APIs (no camera open). + Indices may change after Aravis.update_device_list(). + """ + if not ARAVIS_AVAILABLE: + return settings + + props = settings.properties if isinstance(settings.properties, dict) else {} + ns = props.get(cls.OPTIONS_KEY, {}) if isinstance(props.get(cls.OPTIONS_KEY), dict) else {} + + # Stored identifiers (some may be missing) + stored_device_id = cls._safe_str( + ns.get("device_id") or props.get("device_id") or ns.get("camera_id") or props.get("camera_id") + ) + stored_physical = cls._safe_str( + ns.get("device_physical_id") or ns.get("device_path") or props.get("device_path") + ) + stored_vendor = cls._safe_str(ns.get("device_vendor")) + stored_model = cls._safe_str(ns.get("device_model")) + stored_serial = cls._safe_str(ns.get("device_serial_nbr") or ns.get("device_serial")) + stored_name = cls._safe_str(ns.get("device_name")) + + # Nothing to rebind with + if not any( + [stored_device_id, stored_physical, (stored_vendor and stored_model and stored_serial), stored_name] + ): + return settings + + try: + Aravis.update_device_list() # must be called before get_device_* + snap = cls._arv_snapshot_devices(limit=None) + + # 1) device_id exact match (fast) + chosen = None + if stored_device_id: + for d in snap: + if d.get("device_id") == stored_device_id: + chosen = d + break + + # 2) physical_id exact match + if chosen is None and stored_physical: + for d in snap: + if d.get("physical_id") == stored_physical or d.get("address") == stored_physical: + chosen = d + break + + # 3) vendor/model/serial exact triple match + if chosen is None and stored_vendor and stored_model and stored_serial: + for d in snap: + if (d.get("vendor"), d.get("model"), d.get("serial")) == ( + stored_vendor, + stored_model, + stored_serial, + ): + chosen = d + break + + # 4) name substring match against computed label + if chosen is None and stored_name: + needle = stored_name.lower() + for d in snap: + label = (d.get("label") or "").lower() + if needle and needle in label: + chosen = d + break + + # 5) fallback to current index if still plausible + if chosen is None: + idx = int(getattr(settings, "index", 0) or 0) + if 0 <= idx < len(snap): + chosen = snap[idx] + else: + return settings + + # Apply new index + settings.index = int(chosen["index"]) + + # Refresh namespace fields (keeps GUI stable identity fresh) + if isinstance(settings.properties, dict): + out = settings.properties.setdefault(cls.OPTIONS_KEY, {}) + if isinstance(out, dict): + out["device_id"] = chosen.get("device_id") + out["device_physical_id"] = chosen.get("physical_id") + out["device_vendor"] = chosen.get("vendor") + out["device_model"] = chosen.get("model") + out["device_serial_nbr"] = chosen.get("serial") + out["device_protocol"] = chosen.get("protocol") + out["device_address"] = chosen.get("address") + out["device_name"] = chosen.get("label") # computed label (no open) + + # also keep 'device_path' aligned with physical id for GUI fallback + if chosen.get("physical_id"): + out["device_path"] = chosen.get("physical_id") + + return settings + + except Exception: + # Never hard-fail creation just because rebinding couldn't happen + return settings + def open(self) -> None: if not ARAVIS_AVAILABLE: raise RuntimeError("Aravis library not available") @@ -120,11 +269,68 @@ def open(self) -> None: raise RuntimeError(f"Camera index {index} out of range for {n_devices} Aravis device(s)") camera_id = Aravis.get_device_id(index) self._camera = Aravis.Camera.new(camera_id) + self._camera_id = self._safe_str(camera_id) if self._camera is None: raise RuntimeError("Failed to open Aravis camera") + # --- Refresh identity and align index (best-effort, no heavy open needed) --- + try: + snap = self._arv_snapshot_devices(limit=None) + + opened_id = self._camera_id + if opened_id is None: + # Opened by index + try: + opened_id = self._safe_str(Aravis.get_device_id(int(self.settings.index))) + except Exception: + opened_id = None + + chosen = None + if opened_id: + for d in snap: + if d.get("device_id") == opened_id: + chosen = d + break + + # If we found it, align settings.index and refresh identity cache + if chosen: + self.settings.index = int(chosen["index"]) + if isinstance(self.settings.properties, dict): + ns = self.settings.properties.setdefault(self.OPTIONS_KEY, {}) + if isinstance(ns, dict): + ns["device_id"] = chosen.get("device_id") + ns["device_physical_id"] = chosen.get("physical_id") + ns["device_vendor"] = chosen.get("vendor") + ns["device_model"] = chosen.get("model") + ns["device_serial_nbr"] = chosen.get("serial") + ns["device_protocol"] = chosen.get("protocol") + ns["device_address"] = chosen.get("address") + ns["device_path"] = chosen.get("physical_id") or chosen.get("address") + else: + if isinstance(self.settings.properties, dict): + ns = self.settings.properties.setdefault(self.OPTIONS_KEY, {}) + if isinstance(ns, dict): + ns["device_id"] = opened_id + except Exception: + pass + + # Compute higher-quality label from the opened camera object self._device_label = self._resolve_device_label() + # Always populate minimal identity into backend namespace for GUI + if isinstance(self.settings.properties, dict): + ns = self.settings.properties.setdefault(self.OPTIONS_KEY, {}) + if isinstance(ns, dict): + # Always write a device_id after a successful open + try: + if self._camera_id: + ns["device_id"] = self._camera_id + else: + ns["device_id"] = self._safe_str(Aravis.get_device_id(int(self.settings.index))) + except Exception: + pass + if self._device_label: + ns["device_name"] = self._device_label self._configure_pixel_format() self._configure_resolution() @@ -261,6 +467,81 @@ def device_name(self) -> str: # ------------------------------------------------------------------ # Configuration helpers # ------------------------------------------------------------------ + @staticmethod + def _safe_str(x) -> str | None: + try: + if x is None: + return None + s = str(x).strip() + return s if s else None + except Exception: + return None + + @classmethod + def _arv_snapshot_devices(cls, limit: int | None = None) -> list[dict]: + """ + Fast snapshot of the current Aravis device list without opening cameras. + Requires Aravis.update_device_list() before calling. + """ + n = int(Aravis.get_n_devices() or 0) # valid until next update_device_list() + if limit is not None: + n = min(n, int(limit)) + + devices: list[dict] = [] + for i in range(n): + try: + dev_id = cls._safe_str(Aravis.get_device_id(i)) + except Exception: + dev_id = None + + try: + physical = cls._safe_str(Aravis.get_device_physical_id(i)) + except Exception: + physical = None + try: + vendor = cls._safe_str(Aravis.get_device_vendor(i)) + except Exception: + vendor = None + try: + model = cls._safe_str(Aravis.get_device_model(i)) + except Exception: + model = None + try: + serial = cls._safe_str(Aravis.get_device_serial_nbr(i)) + except Exception: + serial = None + try: + protocol = cls._safe_str(Aravis.get_device_protocol(i)) + except Exception: + protocol = None + try: + address = cls._safe_str(Aravis.get_device_address(i)) + except Exception: + address = None + + # Construct a stable-ish human label without opening the camera + label_parts = [p for p in (vendor, model) if p] + label = " ".join(label_parts) if label_parts else None + if serial: + label = f"{label} ({serial})" if label else f"({serial})" + if not label: + label = dev_id or f"Aravis #{i}" + + devices.append( + { + "index": int(i), + "device_id": dev_id, + "physical_id": physical, + "vendor": vendor, + "model": model, + "serial": serial, + "protocol": protocol, + "address": address, + "label": label, + } + ) + return devices + def _get_requested_resolution_or_none(self) -> tuple[int, int] | None: """ Return (w, h) if user explicitly requested a resolution. From ab3cfc677b6a31e5ec285a7e5af014e4ca6b7014 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 11 Feb 2026 11:29:59 +0100 Subject: [PATCH 02/30] Add fake camera SDKs and contract tests Introduce comprehensive test scaffolding for camera backends: add fake SDK implementations and patching fixtures in tests/cameras/backends/conftest.py (FakeAravis, FakePylon, FakeHarvester and helpers to monkeypatch aravis/pypylon/harvesters). Add backend-agnostic contract tests in tests/cameras/backends/test_generic_contracts.py to validate backend capability shapes, availability reporting, safe discovery, create/close semantics, optional accelerator warnings, and a GUI identity helper. These changes enable running backend contract tests in CI without real SDKs and help ensure consistent backend behavior. --- tests/cameras/backends/conftest.py | 455 ++++++++++++++++++ .../backends/test_generic_contracts.py | 214 ++++++++ 2 files changed, 669 insertions(+) create mode 100644 tests/cameras/backends/test_generic_contracts.py diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index d140d4a..225b0bc 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -1,6 +1,7 @@ # tests/cameras/backends/conftest.py import importlib import os +from types import SimpleNamespace import pytest @@ -118,3 +119,457 @@ def force_pypylon_unavailable(monkeypatch): monkeypatch.setattr(bas, "PYPYLON_AVAILABLE", False, raising=False) monkeypatch.setattr(bas, "pylon", None, raising=False) yield + + +# ----------------------------------------------------------------------------- +# Fake Aravis SDK (module-like) + fixtures +# ----------------------------------------------------------------------------- + + +class FakeAravis: + """Minimal fake Aravis module used for SDK-less unit/contract tests.""" + + class BufferStatus: + SUCCESS = "SUCCESS" + ERROR = "ERROR" + + PIXEL_FORMAT_MONO_8 = "MONO8" + PIXEL_FORMAT_MONO_12 = "MONO12" + PIXEL_FORMAT_MONO_16 = "MONO16" + PIXEL_FORMAT_RGB_8_PACKED = "RGB8" + PIXEL_FORMAT_BGR_8_PACKED = "BGR8" + + class Auto: + OFF = "OFF" + + # Mutable "device list" that tests can override + devices = ["dev0"] + + @classmethod + def update_device_list(cls): + pass + + @classmethod + def get_n_devices(cls) -> int: + return len(cls.devices) + + @classmethod + def get_device_id(cls, index: int) -> str: + return cls.devices[index] + + # Optional metadata used by snapshot/rebind logic (safe defaults) + @classmethod + def get_device_physical_id(cls, index: int) -> str: + return f"PHYS-{cls.devices[index]}" + + @classmethod + def get_device_vendor(cls, index: int) -> str: + return "FakeVendor" + + @classmethod + def get_device_model(cls, index: int) -> str: + return "FakeModel" + + @classmethod + def get_device_serial_nbr(cls, index: int) -> str: + return "12345" + + @classmethod + def get_device_protocol(cls, index: int) -> str: + return "FakeProtocol" + + @classmethod + def get_device_address(cls, index: int) -> str: + return f"ADDR-{index}" + + class Camera: + def __init__(self, device_id="dev0"): + self.device_id = device_id + self.pixel_format = None + self._exposure = 0.0 + self._gain = 0.0 + self._fps = 0.0 + self.payload = 100 + self.stream = None # should be a FakeAravisStream + + self._features_int = {"Width": 1920, "Height": 1080} + self._features_float = {"AcquisitionFrameRate": 30.0} + + @classmethod + def new(cls, device_id): + return cls(device_id) + + # GenICam-like int/float access + def set_integer(self, name: str, value: int): + self._features_int[name] = int(value) + + def get_integer(self, name: str) -> int: + return int(self._features_int[name]) + + def set_float(self, name: str, value: float): + self._features_float[name] = float(value) + + def get_float(self, name: str) -> float: + return float(self._features_float[name]) + + # Pixel format + def set_pixel_format(self, fmt): + self.pixel_format = fmt + + def set_pixel_format_from_string(self, s): + self.pixel_format = s + + # Exposure + def set_exposure_time_auto(self, mode): + pass + + def set_exposure_time(self, v): + self._exposure = float(v) + + def get_exposure_time(self): + return float(self._exposure) + + # Gain + def set_gain_auto(self, mode): + pass + + def set_gain(self, v): + self._gain = float(v) + + def get_gain(self): + return float(self._gain) + + # FPS + def set_frame_rate(self, v): + self._fps = float(v) + self._features_float["AcquisitionFrameRate"] = float(v) + + def get_frame_rate(self): + return float(self._fps) + + # Metadata + def get_model_name(self): + return "FakeModel" + + def get_vendor_name(self): + return "FakeVendor" + + def get_device_serial_number(self): + return "12345" + + # Streaming + def get_payload(self): + return int(self.payload) + + def create_stream(self, *_): + return self.stream + + def start_acquisition(self): + pass + + def stop_acquisition(self): + pass + + class Buffer: + def __init__(self, data, w, h, fmt, status="SUCCESS"): + self._data = data + self._w = w + self._h = h + self._fmt = fmt + self._status = status + + @classmethod + def new_allocate(cls, size): + # Placeholder buffer object for open() buffer queue + return object() + + def get_status(self): + return self._status + + def get_data(self): + return self._data + + def get_image_width(self): + return self._w + + def get_image_height(self): + return self._h + + def get_image_pixel_format(self): + return self._fmt + + +class FakeAravisStream: + def __init__(self, buffers): + self._buffers = list(buffers) + self.pushed = 0 + + def timeout_pop_buffer(self, timeout): + return self._buffers.pop(0) if self._buffers else None + + def try_pop_buffer(self): + return self._buffers.pop(0) if self._buffers else None + + def push_buffer(self, buf): + self.pushed += 1 + + +@pytest.fixture() +def fake_aravis_module(): + """ + Returns the FakeAravis 'module' and resets its mutable state for isolation. + Tests may mutate FakeAravis.devices safely. + """ + FakeAravis.devices = ["dev0"] + return FakeAravis + + +@pytest.fixture() +def patch_aravis_sdk(monkeypatch, fake_aravis_module): + """ + Patch the Aravis backend module so it behaves as if the SDK is installed, + but uses our FakeAravis implementation. + + Usage: + def test_something(patch_aravis_sdk): + ... # aravis backend sees ARAVIS_AVAILABLE=True and Aravis=FakeAravis + """ + import dlclivegui.cameras.backends.aravis_backend as ar + + monkeypatch.setattr(ar, "ARAVIS_AVAILABLE", True, raising=False) + monkeypatch.setattr(ar, "Aravis", fake_aravis_module, raising=False) + return fake_aravis_module + + +@pytest.fixture() +def fake_aravis_stream(): + """ + Small helper fixture to create a FakeAravisStream with a list of buffers. + """ + + def _make(buffers): + return FakeAravisStream(buffers) + + return _make + + +# ----------------------------------------------------------------------------- +# Fake Basler / pypylon SDK (module-like) + fixtures +# ----------------------------------------------------------------------------- + + +class FakePylon: + """Minimal fake for 'from pypylon import pylon' usage in basler_backend.""" + + # Constants used by Basler backend + GrabStrategy_LatestImageOnly = 1 + TimeoutHandling_ThrowException = 1 + PixelType_BGR8packed = 0x02180014 # arbitrary token + OutputBitAlignment_MsbAligned = 1 + + class _Feature: + def __init__(self, value=0): + self._value = value + + def SetValue(self, v): + self._value = v + + def GetValue(self): + return self._value + + class _DeviceInfo: + def __init__(self, serial: str): + self._serial = serial + + def GetSerialNumber(self): + return self._serial + + class _Device: + def __init__(self, info): + self.info = info + + class TlFactory: + _instance = None + + def __init__(self): + self._devices = [FakePylon._DeviceInfo("FAKE-BASLER-0")] + + @classmethod + def GetInstance(cls): + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def EnumerateDevices(self): + return list(self._devices) + + def CreateDevice(self, device_info): + return FakePylon._Device(device_info) + + class _GrabResult: + def __init__(self, ok=True, array=None): + self._ok = ok + self._array = array + + def GrabSucceeded(self): + return bool(self._ok) + + def Release(self): + return None + + class InstantCamera: + def __init__(self, device): + self._device = device + self._open = False + self._grabbing = False + + # Feature nodes the backend uses + self.ExposureTime = FakePylon._Feature(1000.0) + self.Gain = FakePylon._Feature(0.0) + self.Width = FakePylon._Feature(1920) + self.Height = FakePylon._Feature(1080) + + self.AcquisitionFrameRateEnable = FakePylon._Feature(False) + self.AcquisitionFrameRate = FakePylon._Feature(30.0) + + def Open(self): + self._open = True + + def Close(self): + self._open = False + + def IsOpen(self): + return bool(self._open) + + def StartGrabbing(self, *_args, **_kwargs): + self._grabbing = True + + def StopGrabbing(self): + self._grabbing = False + + def IsGrabbing(self): + return bool(self._grabbing) + + def RetrieveResult(self, *_args, **_kwargs): + # Always succeed with a small dummy image (BGR) + import numpy as np + + frame = np.zeros((10, 10, 3), dtype=np.uint8) + return FakePylon._GrabResult(ok=True, array=frame) + + class _ConvertedImage: + def __init__(self, array): + self._array = array + + def GetArray(self): + return self._array + + class ImageFormatConverter: + def __init__(self): + self.OutputPixelFormat = None + self.OutputBitAlignment = None + + def Convert(self, grab_result): + return FakePylon._ConvertedImage(grab_result._array) + + +@pytest.fixture() +def fake_pylon_module(): + """ + Returns the FakePylon 'module' and resets singleton devices for isolation. + """ + # reset singleton factory so devices list resets per test + FakePylon.TlFactory._instance = None + return FakePylon + + +@pytest.fixture() +def patch_basler_sdk(monkeypatch, fake_pylon_module): + """ + Patch Basler backend to behave as if pypylon is installed, using FakePylon. + """ + import dlclivegui.cameras.backends.basler_backend as bb + + monkeypatch.setattr(bb, "pylon", fake_pylon_module, raising=False) + return fake_pylon_module + + +# ----------------------------------------------------------------------------- +# Fake GenTL / harvesters SDK (module-like) + fixtures +# ----------------------------------------------------------------------------- + + +class FakeHarvesterTimeoutError(TimeoutError): + pass + + +class FakeHarvester: + """ + Minimal fake for 'from harvesters.core import Harvester' usage. + + Enough for: + - is_available() + - get_device_count() flow (Harvester() -> add_file -> update -> device_info_list -> reset) + """ + + def __init__(self): + self.device_info_list = [] + self._files = [] + + def add_file(self, file_path: str): + self._files.append(str(file_path)) + + def update(self): + # Expose at least one device info entry + self.device_info_list = [SimpleNamespace(serial_number="FAKE-GENTL-0")] + + def reset(self): + self.device_info_list = [] + self._files = [] + + # Optional: creation methods referenced by GenTL backend (only needed if you test open()) + def create(self, *args, **kwargs): + raise RuntimeError("FakeHarvester.create() not implemented for open-path tests") + + def create_image_acquirer(self, *args, **kwargs): + raise RuntimeError("FakeHarvester.create_image_acquirer() not implemented for open-path tests") + + +@pytest.fixture() +def fake_harvester_class(): + """Provides FakeHarvester class (not an instance) for patching gentl backend.""" + return FakeHarvester + + +@pytest.fixture() +def patch_gentl_sdk(monkeypatch, fake_harvester_class): + """ + Patch GenTL backend to behave as if harvesters is installed, using FakeHarvester. + """ + import dlclivegui.cameras.backends.gentl_backend as gb + + monkeypatch.setattr(gb, "Harvester", fake_harvester_class, raising=False) + monkeypatch.setattr(gb, "HarvesterTimeoutError", FakeHarvesterTimeoutError, raising=False) + return fake_harvester_class + + +# ----------------------------------------------------------------------------- +# Generic patcher mapping fixture for test_generic_contracts.py +# ----------------------------------------------------------------------------- +@pytest.fixture() +def backend_sdk_patchers(patch_aravis_sdk, patch_basler_sdk, patch_gentl_sdk): + """ + Mapping from backend name -> patcher callable (best-effort SDK stubs). + + This fixture intentionally reuses existing per-backend patch fixtures + to avoid duplication. Patch side effects occur when this fixture is + requested (because patch_aravis_sdk is injected). + """ + return { + # Calling it is harmless; patching already applied by fixture injection. + "aravis": (lambda: patch_aravis_sdk), + "basler": (lambda: patch_basler_sdk), + "gentl": (lambda: patch_gentl_sdk), + # No patch needed: OpenCV is assumed present + # "opencv": None, + } diff --git a/tests/cameras/backends/test_generic_contracts.py b/tests/cameras/backends/test_generic_contracts.py new file mode 100644 index 0000000..1c8d770 --- /dev/null +++ b/tests/cameras/backends/test_generic_contracts.py @@ -0,0 +1,214 @@ +""" +Backend-agnostic contract tests for camera backends. + +Hard failures: +- backend registry / factory / discovery calls must not crash +- capabilities must be well-formed +- if backend is available, create() must return a usable backend object +- close() must be idempotent + +Soft signals (warnings): +- missing quick_ping / discover_devices / rebind_settings (helps future dev work) +- capability claims that do not match provided methods +""" + +from __future__ import annotations + +import warnings +from typing import Any + +import pytest + +from dlclivegui.cameras.factory import CameraFactory, DetectedCamera +from dlclivegui.config import CameraSettings + + +def _try_import_gui_apply_identity(): + try: + from dlclivegui.gui.camera_config_dialog import _apply_detected_identity # type: ignore + + return _apply_detected_identity + except Exception: + return None + + +def _minimal_settings(backend: str, index: int = 0, *, properties: dict[str, Any] | None = None) -> CameraSettings: + return CameraSettings( + name=f"ContractTest-{backend}", + backend=backend, + index=index, + properties=properties or {}, + enabled=True, + width=0, + height=0, + fps=0.0, + exposure=0, + gain=0.0, + rotation=0, + crop_x0=0, + crop_y0=0, + crop_x1=0, + crop_y1=0, + ) + + +@pytest.fixture(scope="module") +def all_registered_backends() -> list[str]: + return list(CameraFactory.backend_names()) + + +@pytest.mark.unit +def test_all_registered_backends_have_well_formed_capabilities(all_registered_backends): + for name in all_registered_backends: + caps = CameraFactory.backend_capabilities(name) + assert isinstance(caps, dict), f"{name}: capabilities must be a dict" + assert all(isinstance(k, str) for k in caps.keys()), f"{name}: capability keys must be str" + assert all(hasattr(v, "value") for v in caps.values()), f"{name}: capability values must be enum-like" + + +@pytest.mark.unit +def test_available_backends_map_is_well_formed(): + availability = CameraFactory.available_backends() + assert isinstance(availability, dict) + assert all(isinstance(k, str) for k in availability.keys()) + assert all(isinstance(v, bool) for v in availability.values()) + + +@pytest.mark.unit +def test_detect_cameras_is_safe_for_all_backends(all_registered_backends): + """ + Must never crash; it should return [] for unavailable SDKs. + """ + for name in all_registered_backends: + cams = CameraFactory.detect_cameras(name, max_devices=2) + assert isinstance(cams, list), f"{name}: detect_cameras must return a list" + for c in cams: + assert hasattr(c, "index") and hasattr(c, "label"), f"{name}: detected items must have index/label" + assert isinstance(c.index, int) + assert isinstance(c.label, str) + + +@pytest.mark.unit +def test_optional_accelerators_warn_if_missing(all_registered_backends): + """ + Non-failing warnings to encourage implementers to add helpful fast paths. + """ + for name in all_registered_backends: + # Resolve backend class indirectly by trying to create minimal settings only if available. + # We can still warn based on capabilities + expected methods. + caps = CameraFactory.backend_capabilities(name) + + # Determine if stable identity / discovery are claimed + stable_claim = getattr(caps.get("stable_identity", None), "value", None) + disco_claim = getattr(caps.get("device_discovery", None), "value", None) + + # Check method presence on backend class (best-effort) + try: + # This is internal-ish, but it’s the most direct way to inspect class methods. + backend_cls = CameraFactory._resolve_backend(name) # type: ignore[attr-defined] + except Exception: + # If backend can't resolve, that's a real issue, but it will be caught elsewhere. + continue + + missing = [] + if not hasattr(backend_cls, "quick_ping"): + missing.append("quick_ping") + if not hasattr(backend_cls, "discover_devices"): + missing.append("discover_devices") + if not hasattr(backend_cls, "rebind_settings"): + missing.append("rebind_settings") + + # Soft warnings: missing accelerators + if missing: + warnings.warn( + f"[backend-contract] {name}: missing optional accelerators: {', '.join(missing)}", + UserWarning, + stacklevel=2, + ) + + # Soft warnings: claimed capability but method missing + if stable_claim in ("supported", "best_effort") and not hasattr(backend_cls, "rebind_settings"): + warnings.warn( + f"[backend-contract] {name}: capabilities claim stable_identity={stable_claim} " + f"but rebind_settings() is missing", + UserWarning, + stacklevel=2, + ) + if disco_claim in ("supported", "best_effort") and not hasattr(backend_cls, "discover_devices"): + warnings.warn( + f"[backend-contract] {name}: capabilities claim device_discovery={disco_claim} " + f"but discover_devices() is missing", + UserWarning, + stacklevel=2, + ) + + +@pytest.mark.unit +def test_create_contract_for_available_backends(all_registered_backends, backend_sdk_patchers): + """ + If a backend is available (possibly after applying a fake SDK patch), create() should work. + If not available, create() should fail cleanly (factory raises). + """ + CameraFactory.available_backends() + + for name in all_registered_backends: + # Apply optional SDK stub/patch if provided (keeps this test file backend-agnostic) + patcher = backend_sdk_patchers.get(name) + if patcher: + patcher() + + availability = CameraFactory.available_backends() + is_avail = availability.get(name, False) + + settings = _minimal_settings(name, index=0) + + if not is_avail: + # Must fail cleanly if unavailable + with pytest.raises(RuntimeError): + CameraFactory.create(settings) + continue + + # Available -> must create successfully + be = CameraFactory.create(settings) + + # Minimal base contract + assert hasattr(be, "open") + assert hasattr(be, "read") + assert hasattr(be, "close") + assert callable(be.device_name) + + # close() should be idempotent even if never opened + be.close() + be.close() + + +@pytest.mark.unit +def test_gui_identity_helper_is_backend_agnostic(all_registered_backends): + """ + This checks the GUI helper itself is backend-agnostic, not that each backend populates identity. + We only validate that applying a DetectedCamera results in namespaced properties. + """ + apply_identity = _try_import_gui_apply_identity() + if apply_identity is None: + pytest.skip("GUI helpers not importable (PySide6 likely missing in test env).") + + for backend in all_registered_backends: + cam = _minimal_settings(backend, index=0, properties={}) + detected = DetectedCamera( + index=0, + label=f"Label-{backend}", + device_id=f"device-{backend}", + vid=0x1234, + pid=0x5678, + path=f"path-{backend}", + backend_hint=None, + ) + + apply_identity(cam, detected, backend) + + assert isinstance(cam.properties, dict) + assert backend in cam.properties + ns = cam.properties[backend] + assert isinstance(ns, dict) + assert ns.get("device_id") == detected.device_id + assert ns.get("device_name") == detected.label From 582afd417ef1624d76025133c53ed09e28a27273 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 11 Feb 2026 11:44:36 +0100 Subject: [PATCH 03/30] Enhance fake GenTL/Aravis test fixtures Make test backend mocks more robust for SDK-less unit tests: add numpy import and a stricter Aravis availability check (require gi and Aravis typelib), expose HARVESTERS_AVAILABLE, and refine force_pypylon_unavailable to set pylon=None. Replace the simple FakeHarvester with a richer fake GenTL implementation (device info adapter, node/node_map, image acquirer, payload/components, timeout exception) that supports create()/create_image_acquirer(), start/stop/fetch and realistic buffer payloads. Patch GenTLCameraBackend to avoid CTI file searching during tests. Also remove pytest.mark.integration markers from many aravis tests so they run as unit tests. --- tests/cameras/backends/conftest.py | 292 ++++++++++++++++-- tests/cameras/backends/test_aravis_backend.py | 17 - 2 files changed, 268 insertions(+), 41 deletions(-) diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index 225b0bc..5789a42 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -1,8 +1,8 @@ # tests/cameras/backends/conftest.py import importlib import os -from types import SimpleNamespace +import numpy as np import pytest @@ -17,8 +17,25 @@ def _has_module(name: str) -> bool: return False -ARAVIS_AVAILABLE = _has_module("gi") # Aravis via GObject introspection -PYPYLON_AVAILABLE = _has_module("pypylon") # Basler pypylon SDK +def _has_aravis_gi() -> bool: + """ + GI can exist without the Aravis typelib. Be representative: + check that gi.repository.Aravis is importable and versionable. + """ + try: + import gi # type: ignore + + gi.require_version("Aravis", "0.8") + from gi.repository import Aravis # noqa: F401 + + return True + except Exception: + return False + + +ARAVIS_AVAILABLE = _has_aravis_gi() +PYPYLON_AVAILABLE = _has_module("pypylon") +HARVESTERS_AVAILABLE = _has_module("harvesters") # ----------------------------- @@ -109,14 +126,14 @@ def force_aravis_unavailable(monkeypatch): def force_pypylon_unavailable(monkeypatch): """ Force Basler/pypylon to be unavailable for error-path testing. + Basler backend availability is based on 'pylon is not None'. """ try: import dlclivegui.cameras.backends.basler_backend as bas except Exception: - # If the module doesn't exist in your tree, ignore. yield return - monkeypatch.setattr(bas, "PYPYLON_AVAILABLE", False, raising=False) + monkeypatch.setattr(bas, "pylon", None, raising=False) yield @@ -124,8 +141,6 @@ def force_pypylon_unavailable(monkeypatch): # ----------------------------------------------------------------------------- # Fake Aravis SDK (module-like) + fixtures # ----------------------------------------------------------------------------- - - class FakeAravis: """Minimal fake Aravis module used for SDK-less unit/contract tests.""" @@ -495,61 +510,290 @@ def patch_basler_sdk(monkeypatch, fake_pylon_module): # ----------------------------------------------------------------------------- -# Fake GenTL / harvesters SDK (module-like) + fixtures +# Fake GenTL / harvesters SDK (open/read/close capable) + fixtures # ----------------------------------------------------------------------------- -class FakeHarvesterTimeoutError(TimeoutError): +class FakeGenTLTimeoutException(TimeoutError): + """ + Representative timeout: Harvesters often surfaces GenTL TimeoutException semantics. + """ + pass +class _DeviceInfoAdapter: + """ + Make device_info_list entries behave whether they're dict-like or object-like. + """ + + def __init__(self, payload): + self._payload = payload + + def get(self, key, default=None): + if isinstance(self._payload, dict): + return self._payload.get(key, default) + return getattr(self._payload, key, default) + + @property + def serial_number(self): + return self.get("serial_number", "") + + @property + def vendor(self): + return self.get("vendor", "") + + @property + def model(self): + return self.get("model", "") + + @property + def display_name(self): + return self.get("display_name", "") + + +class _FakeNode: + """ + Minimal GenICam-style node with .value and optional constraints. + Harvesters exposes nodes as objects; your backend uses: + - node.value + - node.min / node.max / node.inc (for Width/Height) + - PixelFormat.symbolics (for allowed formats) + """ + + def __init__(self, value=None, *, min=None, max=None, inc=1, symbolics=None): + self.value = value + self.min = min + self.max = max + self.inc = inc + self.symbolics = symbolics or [] + + +class _FakeNodeMap: + """Provides attribute access for nodes used by GenTLCameraBackend.""" + + def __init__(self, *, width=1920, height=1080, fps=30.0, exposure=10000.0, gain=0.0, pixel_format="Mono8"): + # Identification / label fields your _resolve_device_label() tries + self.DeviceModelName = _FakeNode("FakeGenTLModel") + self.DeviceSerialNumber = _FakeNode("FAKE-GENTL-0") + self.DeviceDisplayName = _FakeNode("FakeGenTLDisplay") + + # Format + acquisition nodes + self.PixelFormat = _FakeNode( + pixel_format, + symbolics=["Mono8", "Mono16", "RGB8", "BGR8"], + ) + + # Width/Height with constraints for increment alignment logic + self.Width = _FakeNode(int(width), min=64, max=4096, inc=2) + self.Height = _FakeNode(int(height), min=64, max=4096, inc=2) + + # FPS related nodes (backend may set AcquisitionFrameRate) + self.AcquisitionFrameRateEnable = _FakeNode(True) + self.AcquisitionFrameRate = _FakeNode(float(fps)) + # backend tries ResultingFrameRate for actual FPS; provide it + self.ResultingFrameRate = _FakeNode(float(fps)) + + # Exposure/Gain + self.ExposureAuto = _FakeNode("Off") + self.ExposureTime = _FakeNode(float(exposure)) + self.GainAuto = _FakeNode("Off") + self.Gain = _FakeNode(float(gain)) + + +class _FakeRemoteDevice: + def __init__(self, node_map: _FakeNodeMap): + self.node_map = node_map + + +class _FakeComponent: + def __init__(self, width: int, height: int, channels: int, dtype=np.uint8): + self.width = int(width) + self.height = int(height) + self._channels = int(channels) + self._dtype = dtype + + # Create a deterministic image payload + n = self.width * self.height * self._channels + if dtype == np.uint8: + arr = (np.arange(n) % 255).astype(np.uint8) + else: + # e.g., uint16 + arr = (np.arange(n) % 65535).astype(np.uint16) + + # Harvesters often exposes component.data as a buffer-like object; + # your backend does np.asarray(component.data) and may fall back to frombuffer(bytes(...)). + # A numpy array works fine for both. + self.data = arr + + +class _FakePayload: + def __init__(self, component: _FakeComponent): + self.components = [component] + + +class _FakeFetchedBufferCtx: + """ + Context manager returned by FakeImageAcquirer.fetch(). + Must provide .payload with components. + """ + + def __init__(self, payload: _FakePayload): + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + +class FakeImageAcquirer: + """ + Minimal Harvesters image acquirer: + - remote_device.node_map + - start()/stop()/destroy() + - fetch(timeout=...) -> context manager + - node_map shortcut (your backend uses self._acquirer.node_map in read()) + """ + + def __init__(self, *, serial="FAKE-GENTL-0", width=1920, height=1080, pixel_format="Mono8"): + self.serial = serial + self._started = False + self._destroyed = False + + # Node map used by open() and read() + self.remote_device = _FakeRemoteDevice(_FakeNodeMap(width=width, height=height, pixel_format=pixel_format)) + self.node_map = self.remote_device.node_map + + # Simple FIFO of frames (buffers) + self._queue: list[_FakePayload] = [] + self._populate_default_frames() + + def _populate_default_frames(self): + # Make one frame available by default + pf = str(self.node_map.PixelFormat.value or "Mono8") + if pf in ("RGB8", "BGR8"): + channels = 3 + dtype = np.uint8 + elif pf == "Mono16": + channels = 1 + dtype = np.uint16 + else: + channels = 1 + dtype = np.uint8 + + comp = _FakeComponent(self.node_map.Width.value, self.node_map.Height.value, channels, dtype=dtype) + self._queue.append(_FakePayload(comp)) + + def start(self): + self._started = True + + def stop(self): + self._started = False + + def destroy(self): + self._destroyed = True + + def fetch(self, timeout: float = 2.0): + if not self._started: + raise FakeGenTLTimeoutException("Acquirer not started") + + if not self._queue: + raise FakeGenTLTimeoutException(f"Timeout after {timeout}s") + + payload = self._queue.pop(0) + return _FakeFetchedBufferCtx(payload) + + class FakeHarvester: """ - Minimal fake for 'from harvesters.core import Harvester' usage. + Minimal fake for 'from harvesters.core import Harvester' supporting: + - add_file/update/reset + - device_info_list for enumeration + - create()/create_image_acquirer() returning FakeImageAcquirer - Enough for: - - is_available() - - get_device_count() flow (Harvester() -> add_file -> update -> device_info_list -> reset) + This enables GenTLCameraBackend.open/read/close paths. """ def __init__(self): self.device_info_list = [] self._files = [] + self._acquirers: list[FakeImageAcquirer] = [] def add_file(self, file_path: str): self._files.append(str(file_path)) def update(self): - # Expose at least one device info entry - self.device_info_list = [SimpleNamespace(serial_number="FAKE-GENTL-0")] + # Harvesters tutorial output shows dict-like device entries. + self.device_info_list = [ + { + "display_name": "TLSimuMono (FAKE-GENTL-0)", + "model": "FakeGenTLModel", + "vendor": "FakeVendor", + "serial_number": "FAKE-GENTL-0", + "id_": "FakeDeviceId", + "tl_type": "Custom", + "user_defined_name": "Center", + "version": "1.0.0", + } + ] def reset(self): + # "release" resources self.device_info_list = [] self._files = [] + self._acquirers = [] + + def create(self, selector=None, index: int | None = None, *args, **kwargs): + serial = None - # Optional: creation methods referenced by GenTL backend (only needed if you test open()) - def create(self, *args, **kwargs): - raise RuntimeError("FakeHarvester.create() not implemented for open-path tests") + # Selector dict commonly used: {"serial_number": "..."} [1](https://github.com/genicam/harvesters/issues/454) + if isinstance(selector, dict): + serial = selector.get("serial_number") + + if serial is None and index is None: + index = 0 + + if not self.device_info_list: + self.update() + + if serial is None: + if index is None: + index = 0 + if index < 0 or index >= len(self.device_info_list): + raise RuntimeError("Index out of range") + info = _DeviceInfoAdapter(self.device_info_list[index]) + serial = info.serial_number or "FAKE-GENTL-0" + + acq = FakeImageAcquirer(serial=serial) + self._acquirers.append(acq) + return acq def create_image_acquirer(self, *args, **kwargs): - raise RuntimeError("FakeHarvester.create_image_acquirer() not implemented for open-path tests") + # Alias used by some Harvesters versions; just delegate to create() + return self.create(*args, **kwargs) @pytest.fixture() def fake_harvester_class(): - """Provides FakeHarvester class (not an instance) for patching gentl backend.""" + """Provides FakeHarvester class for patching GenTL backend.""" return FakeHarvester @pytest.fixture() def patch_gentl_sdk(monkeypatch, fake_harvester_class): - """ - Patch GenTL backend to behave as if harvesters is installed, using FakeHarvester. - """ import dlclivegui.cameras.backends.gentl_backend as gb monkeypatch.setattr(gb, "Harvester", fake_harvester_class, raising=False) - monkeypatch.setattr(gb, "HarvesterTimeoutError", FakeHarvesterTimeoutError, raising=False) + monkeypatch.setattr(gb, "HarvesterTimeoutError", FakeGenTLTimeoutException, raising=False) + + # Prevent CTI searching from blocking open/get_device_count + monkeypatch.setattr(gb.GenTLCameraBackend, "_find_cti_file", lambda self: "dummy.cti", raising=False) + monkeypatch.setattr( + gb.GenTLCameraBackend, "_search_cti_file", staticmethod(lambda patterns: "dummy.cti"), raising=False + ) + return fake_harvester_class diff --git a/tests/cameras/backends/test_aravis_backend.py b/tests/cameras/backends/test_aravis_backend.py index 719da89..797fd11 100644 --- a/tests/cameras/backends/test_aravis_backend.py +++ b/tests/cameras/backends/test_aravis_backend.py @@ -242,14 +242,12 @@ def make_backend(settings, buffers): @pytest.mark.unit -@pytest.mark.integration def test_device_name(): be, cam, s = make_backend(Settings(), []) assert be.device_name() == "FakeVendor FakeModel (12345)" @pytest.mark.unit -@pytest.mark.integration def test_read_mono8(): w, h = 4, 3 data = (np.arange(w * h) % 256).astype(np.uint8).tobytes() @@ -268,7 +266,6 @@ def test_read_mono8(): @pytest.mark.unit -@pytest.mark.integration def test_read_rgb8_converts_to_bgr(): w, h = 2, 1 # RGB: red=[255,0,0], green=[0,255,0] @@ -286,7 +283,6 @@ def test_read_rgb8_converts_to_bgr(): @pytest.mark.unit -@pytest.mark.integration def test_read_bgr8_passthrough(): w, h = 2, 1 data = np.array([10, 20, 30, 40, 50, 60], dtype=np.uint8).tobytes() @@ -301,7 +297,6 @@ def test_read_bgr8_passthrough(): @pytest.mark.unit -@pytest.mark.integration def test_read_mono16_scaling(): w, h = 3, 1 raw = np.array([0, 32768, 65535], dtype=np.uint16) @@ -320,7 +315,6 @@ def test_read_mono16_scaling(): @pytest.mark.unit -@pytest.mark.integration def test_read_unknown_format_fallback_to_mono8(): w, h = 2, 2 data = (np.arange(w * h) % 256).astype(np.uint8).tobytes() @@ -336,7 +330,6 @@ def test_read_unknown_format_fallback_to_mono8(): @pytest.mark.unit -@pytest.mark.integration def test_read_timeout_raises(): be, cam, s = make_backend(Settings(), []) with pytest.raises(TimeoutError): @@ -344,7 +337,6 @@ def test_read_timeout_raises(): @pytest.mark.unit -@pytest.mark.integration def test_read_status_error_raises_and_pushes_back(): w, h = 1, 1 data = b"\x00" @@ -357,7 +349,6 @@ def test_read_status_error_raises_and_pushes_back(): @pytest.mark.unit -@pytest.mark.integration def test_close_is_idempotent(): be, cam, s = make_backend(Settings(), []) be.close() @@ -370,7 +361,6 @@ def test_close_is_idempotent(): @pytest.mark.unit -@pytest.mark.integration def test_is_available_false_when_aravis_missing(monkeypatch): import dlclivegui.cameras.backends.aravis_backend as ar @@ -380,7 +370,6 @@ def test_is_available_false_when_aravis_missing(monkeypatch): @pytest.mark.unit -@pytest.mark.integration def test_get_device_count_when_unavailable(monkeypatch): import dlclivegui.cameras.backends.aravis_backend as ar @@ -389,7 +378,6 @@ def test_get_device_count_when_unavailable(monkeypatch): @pytest.mark.unit -@pytest.mark.integration def test_get_device_count_when_available(monkeypatch): import dlclivegui.cameras.backends.aravis_backend as ar @@ -405,7 +393,6 @@ def test_get_device_count_when_available(monkeypatch): @pytest.mark.unit -@pytest.mark.integration def test_open_index_out_of_range(monkeypatch): # Patch Aravis module inside backend fake = FakeAravis @@ -418,7 +405,6 @@ def test_open_index_out_of_range(monkeypatch): @pytest.mark.unit -@pytest.mark.integration def test_open_success_pushes_initial_buffers_and_configures(monkeypatch): import dlclivegui.cameras.backends.aravis_backend as ar @@ -466,7 +452,6 @@ def new_camera(device_id): @pytest.mark.unit -@pytest.mark.integration def test_open_device_default_resolution_sets_actual_resolution(monkeypatch): import dlclivegui.cameras.backends.aravis_backend as ar @@ -495,7 +480,6 @@ def test_open_device_default_resolution_sets_actual_resolution(monkeypatch): @pytest.mark.unit -@pytest.mark.integration def test_open_requested_resolution_applies_and_reports_actual(monkeypatch): import dlclivegui.cameras.backends.aravis_backend as ar @@ -524,7 +508,6 @@ def test_open_requested_resolution_applies_and_reports_actual(monkeypatch): @pytest.mark.unit -@pytest.mark.integration def test_close_flushes_stream_and_clears_state(monkeypatch): import dlclivegui.cameras.backends.aravis_backend as ar From 43d2e76adfc73644f77e08d2a8cd0584d00ef765 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 11 Feb 2026 16:52:20 +0100 Subject: [PATCH 04/30] Update main_window.py --- dlclivegui/gui/main_window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index f38bb43..e982e41 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -192,6 +192,7 @@ def __init__(self, config: ApplicationSettings | None = None): self._display_timer.start() # Show status message if myconfig.json was loaded + # FIXME @C-Achard deprecated behavior, remove later if self._config_path and self._config_path.name == "myconfig.json": self.statusBar().showMessage(f"Auto-loaded configuration from {self._config_path}", 5000) From e1e5a036ad3f5af39a257339686be57d978ba33d Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 12 Feb 2026 10:09:41 +0100 Subject: [PATCH 05/30] GenTL backend: device_id, discovery, and rebind Add robust GenTL backend features: read backend options from properties["gentl"], support CTI search paths, and a fast_start probe mode that avoids starting acquisition. Introduce stable device identity handling (device_id with serial: and fp: formats) while keeping legacy serial/index fallbacks. Improve device selection logic (match device_id, exact/substring serial matching, index fallback) and persist discovered metadata back into settings. Add helpers: _device_id_from_info, discover_devices, rebind_settings, and quick_ping for reliable discovery/rebinding and UI integration. Make Harvester usage more defensive and handle different device_info shapes. --- dlclivegui/cameras/backends/gentl_backend.py | 649 +++++++++++++++---- 1 file changed, 515 insertions(+), 134 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 3175bf5..c74c0ab 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -44,20 +44,56 @@ class GenTLCameraBackend(CameraBackend): def __init__(self, settings): super().__init__(settings) + # --- Properties namespace handling (new UI stores backend options under properties["gentl"]) --- props = settings.properties if isinstance(settings.properties, dict) else {} ns = props.get(self.OPTIONS_KEY, {}) if not isinstance(ns, dict): ns = {} + # --- CTI / transport configuration --- self._cti_file: str | None = ns.get("cti_file") or props.get("cti_file") - self._serial_number: str | None = ( - ns.get("serial_number") or ns.get("serial") or props.get("serial_number") or props.get("serial") + self._cti_search_paths: tuple[str, ...] = self._parse_cti_paths( + ns.get("cti_search_paths", props.get("cti_search_paths")) ) + + # --- Fast probe mode (CameraProbeWorker sets this) --- + # When fast_start=True, open() should avoid starting acquisition if possible. + self._fast_start: bool = bool(ns.get("fast_start", False)) + + # --- Stable identity / serial selection --- + # New UI stores stable identity as ns["device_id"], with recommended formats: + # - "serial:" for true serials + # - "fp:" when serial is missing/ambiguous + # + # We keep legacy "serial_number"/"serial" behavior as fallback. + raw_device_id = ns.get("device_id") or props.get("device_id") + legacy_serial = ns.get("serial_number") or ns.get("serial") or props.get("serial_number") or props.get("serial") + + self._device_id: str | None = str(raw_device_id).strip() if raw_device_id else None + + # Decide what to use for actual device selection in open(): + # - If device_id is "serial:XXXX" -> use XXXX as serial_number + # - Otherwise, keep legacy serial if present; open() may still use index if serial is None + self._serial_number: str | None = None + if self._device_id: + did = self._device_id + if did.startswith("serial:"): + self._serial_number = did.split("serial:", 1)[1].strip() or None + elif did.startswith("fp:"): + # fingerprint: not directly usable as serial; rebind_settings should map fp -> index + self._serial_number = legacy_serial # keep legacy if any, otherwise None + else: + # If device_id is provided without prefix, treat it as a "serial-like" value for backward compatibility + self._serial_number = did + else: + self._serial_number = str(legacy_serial).strip() if legacy_serial else None + + # --- Pixel format / image transforms (legacy + backend options) --- self._pixel_format: str = ns.get("pixel_format") or props.get("pixel_format", "Mono8") self._rotate: int = int(ns.get("rotate", props.get("rotate", 0))) % 360 self._crop: tuple[int, int, int, int] | None = self._parse_crop(ns.get("crop", props.get("crop"))) - # Exposure / Gain: 0 means Auto (do not set) + # --- Exposure / Gain: 0 means Auto (do not set) --- exp_val = getattr(settings, "exposure", 0) gain_val = getattr(settings, "gain", 0.0) @@ -81,21 +117,21 @@ def __init__(self, settings): except Exception: self._gain = None + # --- Acquisition timeout --- self._timeout: float = float(ns.get("timeout", props.get("timeout", 2.0))) - self._cti_search_paths: tuple[str, ...] = self._parse_cti_paths( - ns.get("cti_search_paths", props.get("cti_search_paths")) - ) - # Resolution request (None = device default) + # --- Resolution request (None = device default / Auto) --- + # Uses settings.width/settings.height if set; falls back to legacy props["resolution"] if present. self._requested_resolution: tuple[int, int] | None = self._get_requested_resolution_or_none() - # Actuals for GUI + # --- Actuals for GUI --- self._actual_width: int | None = None self._actual_height: int | None = None self._actual_fps: float | None = None self._actual_gain: float | None = None self._actual_exposure: float | None = None + # --- Harvesters resources --- self._harvester = None self._acquirer = None self._device_label: str | None = None @@ -169,74 +205,180 @@ def open(self) -> None: "The 'harvesters' package is required for the GenTL backend. Install it via 'pip install harvesters'." ) + # Ensure properties namespace exists for persistence back to UI + if not isinstance(self.settings.properties, dict): + self.settings.properties = {} + props = self.settings.properties + ns = props.get(self.OPTIONS_KEY, {}) + if not isinstance(ns, dict): + ns = {} + props[self.OPTIONS_KEY] = ns + self._harvester = Harvester() - cti_file = self._cti_file or self._find_cti_file() + + # Resolve CTI file: explicit > configured > search + cti_file = self._cti_file or ns.get("cti_file") or props.get("cti_file") or self._find_cti_file() self._harvester.add_file(cti_file) self._harvester.update() if not self._harvester.device_info_list: raise RuntimeError("No GenTL cameras detected via Harvesters") - serial = self._serial_number - index = int(self.settings.index or 0) - if serial: - available = self._available_serials() - matches = [s for s in available if serial in s] - if not matches: - raise RuntimeError(f"Camera with serial '{serial}' not found. Available cameras: {available}") - serial = matches[0] - else: - device_count = len(self._harvester.device_info_list) - if index < 0 or index >= device_count: - raise RuntimeError(f"Camera index {index} out of range for {device_count} GenTL device(s)") + infos = list(self._harvester.device_info_list) + + # Helper: robustly read device_info fields (supports dict-like or attribute-like entries) + def _info_get(info, key: str, default=None): + try: + if hasattr(info, "get"): + v = info.get(key) # type: ignore[attr-defined] + if v is not None: + return v + except Exception: + pass + try: + v = getattr(info, key, None) + if v is not None: + return v + except Exception: + pass + return default + + # ------------------------------------------------------------------ + # Device selection (stable device_id > serial > index) + # ------------------------------------------------------------------ + requested_index = int(self.settings.index or 0) + selected_index: int | None = None + selected_serial: str | None = None + + # 1) Try stable device_id first (supports "serial:..." and "fp:...") + target_device_id = self._device_id or ns.get("device_id") or props.get("device_id") + if target_device_id: + target_device_id = str(target_device_id).strip() + + # Match exact against computed device_id_from_info(info) + for idx, info in enumerate(infos): + try: + did = self._device_id_from_info(info) + except Exception: + did = None + if did and did == target_device_id: + selected_index = idx + selected_serial = _info_get(info, "serial_number", None) + selected_serial = str(selected_serial).strip() if selected_serial else None + break + + # If device_id is "serial:XXXX", match serial directly + if selected_index is None and target_device_id.startswith("serial:"): + serial_target = target_device_id.split("serial:", 1)[1].strip() + if serial_target: + exact = [] + for idx, info in enumerate(infos): + sn = _info_get(info, "serial_number", "") + sn = str(sn).strip() if sn is not None else "" + if sn == serial_target: + exact.append((idx, sn)) + if exact: + selected_index = exact[0][0] + selected_serial = exact[0][1] + else: + sub = [] + for idx, info in enumerate(infos): + sn = _info_get(info, "serial_number", "") + sn = str(sn).strip() if sn is not None else "" + if serial_target and serial_target in sn: + sub.append((idx, sn)) + if len(sub) == 1: + selected_index = sub[0][0] + selected_serial = sub[0][1] or None + elif len(sub) > 1: + candidates = [sn for _, sn in sub] + raise RuntimeError( + f"Ambiguous GenTL serial match for '{serial_target}'. Candidates: {candidates}" + ) - self._acquirer = self._create_acquirer(serial, index) + # 2) Try legacy serial selection if still not selected + if selected_index is None: + serial = self._serial_number + if serial: + serial = str(serial).strip() + exact = [] + for idx, info in enumerate(infos): + sn = _info_get(info, "serial_number", "") + sn = str(sn).strip() if sn is not None else "" + if sn == serial: + exact.append((idx, sn)) + if exact: + selected_index = exact[0][0] + selected_serial = exact[0][1] + else: + sub = [] + for idx, info in enumerate(infos): + sn = _info_get(info, "serial_number", "") + sn = str(sn).strip() if sn is not None else "" + if serial and serial in sn: + sub.append((idx, sn)) + if len(sub) == 1: + selected_index = sub[0][0] + selected_serial = sub[0][1] or None + elif len(sub) > 1: + candidates = [sn for _, sn in sub] + raise RuntimeError(f"Ambiguous GenTL serial match for '{serial}'. Candidates: {candidates}") + else: + available = [str(_info_get(i, "serial_number", "")).strip() for i in infos] + raise RuntimeError(f"Camera with serial '{serial}' not found. Available cameras: {available}") + + # 3) Fallback to index selection + if selected_index is None: + device_count = len(infos) + if requested_index < 0 or requested_index >= device_count: + raise RuntimeError(f"Camera index {requested_index} out of range for {device_count} GenTL device(s)") + selected_index = requested_index + sn = _info_get(infos[selected_index], "serial_number", "") + selected_serial = str(sn).strip() if sn else None + + # Update settings.index to the actual selected index (important for UI merge-back + stability) + self.settings.index = int(selected_index) + selected_info = infos[int(selected_index)] + + # ------------------------------------------------------------------ + # Create ImageAcquirer using the latest Harvesters API: Harvester.create(...) + # ------------------------------------------------------------------ + try: + if selected_serial: + self._acquirer = self._harvester.create({"serial_number": str(selected_serial)}) + else: + self._acquirer = self._harvester.create(int(selected_index)) + except TypeError: + # Some versions accept keyword argument; keep as a safety net without reintroducing legacy API. + if selected_serial: + self._acquirer = self._harvester.create({"serial_number": str(selected_serial)}) + else: + self._acquirer = self._harvester.create(index=int(selected_index)) remote = self._acquirer.remote_device node_map = remote.node_map - # print(dir(node_map)) - """ - ['AcquisitionBurstFrameCount', 'AcquisitionControl', 'AcquisitionFrameRate', 'AcquisitionMode', - 'AcquisitionStart', 'AcquisitionStop', 'AnalogControl', 'AutoFunctionsROI', 'AutoFunctionsROIEnable', - 'AutoFunctionsROIHeight', 'AutoFunctionsROILeft', 'AutoFunctionsROIPreset', 'AutoFunctionsROITop', - 'AutoFunctionsROIWidth', 'BinningHorizontal', 'BinningVertical', 'BlackLevel', 'CameraRegisterAddress', - 'CameraRegisterAddressSpace', 'CameraRegisterControl', 'CameraRegisterRead', 'CameraRegisterValue', - 'CameraRegisterWrite', 'Contrast', 'DecimationHorizontal', 'DecimationVertical', 'Denoise', - 'DeviceControl', 'DeviceFirmwareVersion', 'DeviceModelName', 'DeviceReset', 'DeviceSFNCVersionMajor', - 'DeviceSFNCVersionMinor', 'DeviceSFNCVersionSubMinor', 'DeviceScanType', 'DeviceSerialNumber', - 'DeviceTLType', 'DeviceTLVersionMajor', 'DeviceTLVersionMinor', 'DeviceTLVersionSubMinor', - 'DeviceTemperature', 'DeviceTemperatureSelector', 'DeviceType', 'DeviceUserID', 'DeviceVendorName', - 'DigitalIO', 'ExposureAuto', 'ExposureAutoHighlightReduction', 'ExposureAutoLowerLimit', - 'ExposureAutoReference', 'ExposureAutoUpperLimit', 'ExposureAutoUpperLimitAuto', 'ExposureTime', - 'GPIn', 'GPOut', 'Gain', 'GainAuto', 'GainAutoLowerLimit', 'GainAutoUpperLimit', 'Gamma', 'Height', - 'HeightMax', 'IMXLowLatencyTriggerMode', 'ImageFormatControl', 'OffsetAutoCenter', 'OffsetX', 'OffsetY', - 'PayloadSize', 'PixelFormat', 'ReverseX', 'ReverseY', 'Root', 'SensorHeight', 'SensorWidth', 'Sharpness', - 'ShowOverlay', 'SoftwareAnalogControl', 'SoftwareTransformControl', 'SoftwareTransformEnable', - 'StrobeDelay', 'StrobeDuration', 'StrobeEnable', 'StrobeOperation', 'StrobePolarity', 'TLParamsLocked', - 'TestControl', 'TestPendingAck', 'TimestampLatch', 'TimestampLatchValue', 'TimestampReset', 'ToneMappingAuto', - 'ToneMappingControl', 'ToneMappingEnable', 'ToneMappingGlobalBrightness', 'ToneMappingIntensity', - 'TransportLayerControl', 'TriggerActivation', 'TriggerDebouncer', 'TriggerDelay', 'TriggerDenoise', - 'TriggerMask', 'TriggerMode', 'TriggerOverlap', 'TriggerSelector', 'TriggerSoftware', 'TriggerSource', - 'UserSetControl', 'UserSetDefault', 'UserSetLoad', 'UserSetSave', 'UserSetSelector', 'Width', 'WidthMax'] - """ - + # Resolve human label for UI self._device_label = self._resolve_device_label(node_map) + # ------------------------------------------------------------------ + # Apply configuration (existing behavior) + # ------------------------------------------------------------------ self._configure_pixel_format(node_map) self._configure_resolution(node_map) self._configure_exposure(node_map) self._configure_gain(node_map) self._configure_frame_rate(node_map) - # Capture actual resolution even when using defaults + # ------------------------------------------------------------------ + # Capture "actual" telemetry for GUI (existing behavior) + # ------------------------------------------------------------------ try: self._actual_width = int(node_map.Width.value) self._actual_height = int(node_map.Height.value) except Exception: pass - # Capture actual FPS if available try: self._actual_fps = float(node_map.ResultingFrameRate.value) except Exception: @@ -252,8 +394,331 @@ def open(self) -> None: except Exception: self._actual_gain = None + # ------------------------------------------------------------------ + # Persist identity + richer device metadata back into settings for UI merge-back + # ------------------------------------------------------------------ + computed_id = None + try: + computed_id = self._device_id_from_info(selected_info) + except Exception: + computed_id = None + + if computed_id: + ns["device_id"] = computed_id + elif selected_serial: + ns["device_id"] = f"serial:{selected_serial}" + + # Canonical serial storage + if selected_serial: + ns["serial_number"] = str(selected_serial) + ns["device_serial_number"] = str(selected_serial) + + # UI-friendly name + if self._device_label: + ns["device_name"] = str(self._device_label) + + # Extra metadata from discovery info (helps debugging and stable identity fallbacks) + ns["device_display_name"] = str(_info_get(selected_info, "display_name", "") or "") + ns["device_info_id"] = str(_info_get(selected_info, "id_", "") or "") + ns["device_vendor"] = str(_info_get(selected_info, "vendor", "") or "") + ns["device_model"] = str(_info_get(selected_info, "model", "") or "") + ns["device_tl_type"] = str(_info_get(selected_info, "tl_type", "") or "") + ns["device_user_defined_name"] = str(_info_get(selected_info, "user_defined_name", "") or "") + ns["device_version"] = str(_info_get(selected_info, "version", "") or "") + ns["device_access_status"] = _info_get(selected_info, "access_status", None) + + # Preserve CTI used (useful for stable operation) + ns["cti_file"] = str(cti_file) + + # ------------------------------------------------------------------ + # Start streaming unless fast_start probe mode is requested + # ------------------------------------------------------------------ + if getattr(self, "_fast_start", False): + LOG.info("GenTL open() in fast_start probe mode: acquisition not started.") + return + self._acquirer.start() + @staticmethod + def _device_id_from_info(info) -> str | None: + """ + Build a stable-ish device identifier from Harvester device_info_list entries. + This helper supports both dict-like and attribute-like representations. + """ + + def _read(name: str): + # dict-like + try: + if hasattr(info, "get"): + v = info.get(name) # type: ignore[attr-defined] + if v is not None: + return v + except Exception: + pass + # attribute-like + try: + return getattr(info, name, None) + except Exception: + return None + + def _get(*names: str) -> str | None: + for n in names: + v = _read(n) + if v is None: + continue + s = str(v).strip() + if s: + return s + return None + + # Prefer serial if present (best stable key when available) + serial = _get("serial_number", "SerialNumber", "device_serial_number", "sn", "serial") + if serial: + return f"serial:{serial}" + + # Fallback components (best-effort; names may vary per producer) + vendor = _get("vendor", "vendor_name", "manufacturer", "DeviceVendorName") + model = _get("model", "model_name", "DeviceModelName") + user_id = _get("user_defined_name", "user_id", "DeviceUserID", "DeviceUserId", "device_user_id") + tl_type = _get("tl_type", "transport_layer_type", "DeviceTLType") + + unique = _get("id_", "id", "device_id", "uid", "guid", "mac_address", "interface_id", "display_name") + + parts = [] + for k, v in (("vendor", vendor), ("model", model), ("user", user_id), ("tl", tl_type), ("uid", unique)): + if v: + parts.append(f"{k}={v}") + + if not parts: + return None + + return "fp:" + "|".join(parts) + + @classmethod + def discover_devices( + cls, + *, + max_devices: int = 10, + should_cancel: callable[[], bool] | None = None, + progress_cb: callable[[str], None] | None = None, + ): + """ + Rich discovery path for CameraFactory.detect_cameras(). + Returns a list of DetectedCamera with device_id filled when possible. + """ + if Harvester is None: + return [] + + # Local import to avoid circulars at import time + from ..factory import DetectedCamera + + def _canceled() -> bool: + return bool(should_cancel and should_cancel()) + + harvester = None + try: + if progress_cb: + progress_cb("Initializing GenTL discovery…") + + harvester = Harvester() + + # Use default CTI search; we don't have per-camera settings here. + cti_file = cls._search_cti_file(cls._DEFAULT_CTI_PATTERNS) + if not cti_file: + if progress_cb: + progress_cb("No .cti found (GenTL producer missing).") + return [] + + harvester.add_file(cti_file) + harvester.update() + + infos = list(harvester.device_info_list or []) + if not infos: + return [] + + out: list[DetectedCamera] = [] + limit = min(len(infos), max_devices if max_devices > 0 else len(infos)) + + for idx in range(limit): + if _canceled(): + break + + # Create a label for the UI, using display_name if available, otherwise vendor/model/serial. + info = infos[idx] + display_name = None + try: + display_name = ( + info.get("display_name") if hasattr(info, "get") else getattr(info, "display_name", None) + ) + except Exception: + display_name = None + + if display_name: + label = str(display_name).strip() + else: + vendor = ( + getattr(info, "vendor", None) or (info.get("vendor") if hasattr(info, "get") else None) or "" + ) + model = getattr(info, "model", None) or (info.get("model") if hasattr(info, "get") else None) or "" + serial = ( + getattr(info, "serial_number", None) + or (info.get("serial_number") if hasattr(info, "get") else None) + or "" + ) + vendor = str(vendor).strip() + model = str(model).strip() + serial = str(serial).strip() + + label = f"{vendor} {model}".strip() if (vendor or model) else f"GenTL device {idx}" + if serial: + label = f"{label} ({serial})" + + device_id = cls._device_id_from_info(info) + + out.append( + DetectedCamera( + index=idx, + label=label, + device_id=device_id, + # GenTL usually doesn't expose vid/pid/path consistently; leave None unless you have it + vid=None, + pid=None, + path=None, + backend_hint=None, + ) + ) + + if progress_cb: + progress_cb(f"Found: {label}") + + out.sort(key=lambda c: c.index) + return out + + except Exception: + # Returning None would trigger probing fallback; but since you declared discovery supported, + # returning [] is usually less surprising than a slow probe storm. + return [] + finally: + if harvester is not None: + try: + harvester.reset() + except Exception: + pass + + @classmethod + def rebind_settings(cls, settings): + """ + If a stable identity exists in settings.properties['gentl'], map it to the + correct current index (and serial_number if available). + """ + if Harvester is None: + return settings + + props = settings.properties if isinstance(settings.properties, dict) else {} + ns = props.get(cls.OPTIONS_KEY, {}) + if not isinstance(ns, dict): + ns = {} + + target_id = ns.get("device_id") or ns.get("serial_number") or ns.get("serial") + if not target_id: + return settings + + harvester = None + try: + harvester = Harvester() + cti_file = ns.get("cti_file") or props.get("cti_file") or cls._search_cti_file(cls._DEFAULT_CTI_PATTERNS) + if not cti_file: + return settings + + harvester.add_file(cti_file) + harvester.update() + + infos = list(harvester.device_info_list or []) + if not infos: + return settings + + # Try exact match by computed device_id first + match_index = None + match_serial = None + + # Normalize + target_id_str = str(target_id).strip() + + for idx, info in enumerate(infos): + dev_id = cls._device_id_from_info(info) + if dev_id and dev_id == target_id_str: + match_index = idx + match_serial = getattr(info, "serial_number", None) + break + + # If not found, fallback: treat target as serial-ish substring (legacy behavior) + if match_index is None: + for idx, info in enumerate(infos): + serial = getattr(info, "serial_number", None) + if serial and target_id_str in str(serial): + match_index = idx + match_serial = serial + break + + if match_index is None: + return settings + + # Apply rebinding + settings.index = int(match_index) + + # Keep namespace consistent for open() + if not isinstance(settings.properties, dict): + settings.properties = {} + ns2 = settings.properties.setdefault(cls.OPTIONS_KEY, {}) + if not isinstance(ns2, dict): + ns2 = {} + settings.properties[cls.OPTIONS_KEY] = ns2 + + # If we got a serial, save it for open() selection (backward compatible) + if match_serial: + ns2["serial_number"] = str(match_serial) + ns2["device_id"] = target_id_str + + return settings + + except Exception: + # Any failure should not prevent fallback to index-based open + return settings + finally: + if harvester is not None: + try: + harvester.reset() + except Exception: + pass + + @classmethod + def quick_ping(cls, index: int, _unused=None) -> bool: + """ + Fast check: is there a device at this index according to Harvester? + Does not open/start acquisition. + """ + if Harvester is None: + return False + + harvester = None + try: + harvester = Harvester() + cti_file = cls._search_cti_file(cls._DEFAULT_CTI_PATTERNS) + if not cti_file: + return False + harvester.add_file(cti_file) + harvester.update() + infos = harvester.device_info_list or [] + return 0 <= int(index) < len(infos) + except Exception: + return False + finally: + if harvester is not None: + try: + harvester.reset() + except Exception: + pass + def read(self) -> tuple[np.ndarray, float]: if self._acquirer is None: raise RuntimeError("GenTL image acquirer not initialised") @@ -510,90 +975,6 @@ def _configure_pixel_format(self, node_map) -> None: except Exception as e: LOG.warning(f"Failed to set pixel format '{self._pixel_format}': {e}") - def _configure_resolution(self, node_map) -> None: - """Configure camera resolution (width and height).""" - if self._resolution is None: - return - - requested_width, requested_height = self._resolution - actual_width, actual_height = None, None - - # Try to set width - for width_attr in ("Width", "WidthMax"): - try: - node = getattr(node_map, width_attr) - if width_attr == "Width": - # Get constraints - try: - min_w = node.min - max_w = node.max - inc_w = getattr(node, "inc", 1) - # Adjust to valid value - width = self._adjust_to_increment(requested_width, min_w, max_w, inc_w) - if width != requested_width: - LOG.info( - f"Width adjusted from {requested_width} to {width} " - f"(min={min_w}, max={max_w}, inc={inc_w})" - ) - node.value = int(width) - actual_width = node.value - break - except Exception as e: - # Try setting without adjustment - try: - node.value = int(requested_width) - actual_width = node.value - break - except Exception: - LOG.warning(f"Failed to set width via {width_attr}: {e}") - continue - except AttributeError: - continue - - # Try to set height - for height_attr in ("Height", "HeightMax"): - try: - node = getattr(node_map, height_attr) - if height_attr == "Height": - # Get constraints - try: - min_h = node.min - max_h = node.max - inc_h = getattr(node, "inc", 1) - # Adjust to valid value - height = self._adjust_to_increment(requested_height, min_h, max_h, inc_h) - if height != requested_height: - LOG.info( - f"Height adjusted from {requested_height} to {height} " - f"(min={min_h}, max={max_h}, inc={inc_h})" - ) - node.value = int(height) - actual_height = node.value - break - except Exception as e: - # Try setting without adjustment - try: - node.value = int(requested_height) - actual_height = node.value - break - except Exception: - LOG.warning(f"Failed to set height via {height_attr}: {e}") - continue - except AttributeError: - continue - - # Log final resolution - if actual_width is not None and actual_height is not None: - if actual_width != requested_width or actual_height != requested_height: - LOG.warning( - f"Resolution mismatch: requested {requested_width}x{requested_height}, " - f"got {actual_width}x{actual_height}" - ) - else: - LOG.info(f"Resolution set to {actual_width}x{actual_height}") - else: - LOG.warning(f"Could not verify resolution setting (width={actual_width}, height={actual_height})") - def _configure_exposure(self, node_map) -> None: if self._exposure is None: return From d60562a419fa9e6acc43e3f208ce7ed70db59f4d Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 12 Feb 2026 10:26:36 +0100 Subject: [PATCH 06/30] Add fake GenTL fixtures and gentl backend tests Introduce a lightweight, SDK-free fake GenTL/Harvesters implementation and associated pytest fixtures to enable strict lifecycle unit tests for the gentl backend. Replaces the old _DeviceInfoAdapter with a robust attribute/dict reader (_info_get), implements _FakeNode/_FakeNodeMap/_FakeComponent/_FakePayload and a dataclass FakeImageAcquirer with strict start/fetch semantics and call tracing. Adds FakeHarvester with inventory-driven device_info_list, create/create_image_acquirer compatibility and call recording, plus fixtures gentl_inventory, fake_harvester_factory, patch_gentl_sdk (patches backend to use fakes) and gentl_settings_factory. Also adds tests/cameras/backends/test_gentl_backend.py with comprehensive unit tests covering open/read/close, fast_start behavior, device selection (serial/fingerprint), rebind_settings, discover_devices, resolution alignment, pixel format handling, and other edge cases to validate the gentl backend behavior. --- .coveragerc | 5 +- tests/cameras/backends/conftest.py | 354 ++++++++----- tests/cameras/backends/test_gentl_backend.py | 531 +++++++++++++++++++ 3 files changed, 755 insertions(+), 135 deletions(-) create mode 100644 tests/cameras/backends/test_gentl_backend.py diff --git a/.coveragerc b/.coveragerc index 481e53d..83fcc6e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,7 +3,4 @@ [run] branch = True source = dlclivegui -omit = - # omit only the parts that are pure passthrough shims to SDKs - dlclivegui/cameras/backends/basler_backend.py - dlclivegui/cameras/backends/gentl_backend.py +# omit = diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index 5789a42..0fb3ded 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -1,6 +1,10 @@ # tests/cameras/backends/conftest.py +from __future__ import annotations + import importlib import os +from dataclasses import dataclass +from typing import Any import numpy as np import pytest @@ -510,56 +514,36 @@ def patch_basler_sdk(monkeypatch, fake_pylon_module): # ----------------------------------------------------------------------------- -# Fake GenTL / harvesters SDK (open/read/close capable) + fixtures +# Fake GenTL / harvesters SDK (SDK-free) + fixtures for strict lifecycle tests # ----------------------------------------------------------------------------- class FakeGenTLTimeoutException(TimeoutError): - """ - Representative timeout: Harvesters often surfaces GenTL TimeoutException semantics. - """ + """Fake timeout/error type used as HarvesterTimeoutError in backend tests.""" pass -class _DeviceInfoAdapter: - """ - Make device_info_list entries behave whether they're dict-like or object-like. - """ - - def __init__(self, payload): - self._payload = payload - - def get(self, key, default=None): - if isinstance(self._payload, dict): - return self._payload.get(key, default) - return getattr(self._payload, key, default) - - @property - def serial_number(self): - return self.get("serial_number", "") - - @property - def vendor(self): - return self.get("vendor", "") - - @property - def model(self): - return self.get("model", "") - - @property - def display_name(self): - return self.get("display_name", "") +def _info_get(info: Any, key: str, default=None): + """Read a device-info field from dict-like or attribute-like entries.""" + try: + if hasattr(info, "get"): + v = info.get(key) + if v is not None: + return v + except Exception: + pass + try: + v = getattr(info, key, None) + if v is not None: + return v + except Exception: + pass + return default class _FakeNode: - """ - Minimal GenICam-style node with .value and optional constraints. - Harvesters exposes nodes as objects; your backend uses: - - node.value - - node.min / node.max / node.inc (for Width/Height) - - PixelFormat.symbolics (for allowed formats) - """ + """Minimal GenICam node: .value plus optional constraints and symbolics.""" def __init__(self, value=None, *, min=None, max=None, inc=1, symbolics=None): self.value = value @@ -570,31 +554,42 @@ def __init__(self, value=None, *, min=None, max=None, inc=1, symbolics=None): class _FakeNodeMap: - """Provides attribute access for nodes used by GenTLCameraBackend.""" - - def __init__(self, *, width=1920, height=1080, fps=30.0, exposure=10000.0, gain=0.0, pixel_format="Mono8"): - # Identification / label fields your _resolve_device_label() tries - self.DeviceModelName = _FakeNode("FakeGenTLModel") - self.DeviceSerialNumber = _FakeNode("FAKE-GENTL-0") - self.DeviceDisplayName = _FakeNode("FakeGenTLDisplay") - - # Format + acquisition nodes + """Node map with the attributes your GenTLCameraBackend touches.""" + + def __init__( + self, + *, + width=1920, + height=1080, + fps=30.0, + exposure=10000.0, + gain=0.0, + pixel_format="Mono8", + model="FakeGenTLModel", + serial="FAKE-GENTL-0", + display="FakeGenTLDisplay", + ): + # Label fields used by _resolve_device_label() + self.DeviceModelName = _FakeNode(model) + self.DeviceSerialNumber = _FakeNode(serial) + self.DeviceDisplayName = _FakeNode(display) + + # Pixel format node self.PixelFormat = _FakeNode( pixel_format, symbolics=["Mono8", "Mono16", "RGB8", "BGR8"], ) - # Width/Height with constraints for increment alignment logic + # Width/Height constraints for increment alignment logic self.Width = _FakeNode(int(width), min=64, max=4096, inc=2) self.Height = _FakeNode(int(height), min=64, max=4096, inc=2) - # FPS related nodes (backend may set AcquisitionFrameRate) + # FPS / actual fps self.AcquisitionFrameRateEnable = _FakeNode(True) self.AcquisitionFrameRate = _FakeNode(float(fps)) - # backend tries ResultingFrameRate for actual FPS; provide it self.ResultingFrameRate = _FakeNode(float(fps)) - # Exposure/Gain + # Exposure / gain self.ExposureAuto = _FakeNode("Off") self.ExposureTime = _FakeNode(float(exposure)) self.GainAuto = _FakeNode("Off") @@ -607,23 +602,21 @@ def __init__(self, node_map: _FakeNodeMap): class _FakeComponent: + """ + Component with .data, .width, .height like Harvesters component2D image. + Your backend does np.asarray(component.data) and reshape using height/width. + """ + def __init__(self, width: int, height: int, channels: int, dtype=np.uint8): self.width = int(width) self.height = int(height) self._channels = int(channels) - self._dtype = dtype - # Create a deterministic image payload n = self.width * self.height * self._channels if dtype == np.uint8: arr = (np.arange(n) % 255).astype(np.uint8) else: - # e.g., uint16 arr = (np.arange(n) % 65535).astype(np.uint16) - - # Harvesters often exposes component.data as a buffer-like object; - # your backend does np.asarray(component.data) and may fall back to frombuffer(bytes(...)). - # A numpy array works fine for both. self.data = arr @@ -633,10 +626,7 @@ def __init__(self, component: _FakeComponent): class _FakeFetchedBufferCtx: - """ - Context manager returned by FakeImageAcquirer.fetch(). - Must provide .payload with components. - """ + """Context manager returned by fetch(). Must have .payload.""" def __init__(self, payload: _FakePayload): self.payload = payload @@ -648,59 +638,74 @@ def __exit__(self, exc_type, exc, tb): return False +@dataclass class FakeImageAcquirer: """ - Minimal Harvesters image acquirer: + Minimal ImageAcquirer: - remote_device.node_map - - start()/stop()/destroy() - - fetch(timeout=...) -> context manager - - node_map shortcut (your backend uses self._acquirer.node_map in read()) + - node_map shortcut (backend uses self._acquirer.node_map in read()) + - start/stop/destroy + - fetch(timeout=...) -> ctx manager yielding buffer-like object + Strict rule: fetch fails unless started=True. """ - def __init__(self, *, serial="FAKE-GENTL-0", width=1920, height=1080, pixel_format="Mono8"): - self.serial = serial - self._started = False - self._destroyed = False + serial: str = "FAKE-GENTL-0" + width: int = 1920 + height: int = 1080 + pixel_format: str = "Mono8" - # Node map used by open() and read() - self.remote_device = _FakeRemoteDevice(_FakeNodeMap(width=width, height=height, pixel_format=pixel_format)) + def __post_init__(self): + self.remote_device = _FakeRemoteDevice( + _FakeNodeMap(width=self.width, height=self.height, pixel_format=self.pixel_format, serial=self.serial) + ) self.node_map = self.remote_device.node_map - # Simple FIFO of frames (buffers) + self._started = False + self._destroyed = False self._queue: list[_FakePayload] = [] - self._populate_default_frames() - def _populate_default_frames(self): - # Make one frame available by default + # Call tracing + self.start_calls = 0 + self.stop_calls = 0 + self.destroy_calls = 0 + self.fetch_calls: list[float] = [] + + # Prepare one default frame + self._enqueue_default_frame() + + def _enqueue_default_frame(self): pf = str(self.node_map.PixelFormat.value or "Mono8") if pf in ("RGB8", "BGR8"): - channels = 3 - dtype = np.uint8 + channels, dtype = 3, np.uint8 elif pf == "Mono16": - channels = 1 - dtype = np.uint16 + channels, dtype = 1, np.uint16 else: - channels = 1 - dtype = np.uint8 + channels, dtype = 1, np.uint8 comp = _FakeComponent(self.node_map.Width.value, self.node_map.Height.value, channels, dtype=dtype) self._queue.append(_FakePayload(comp)) def start(self): + self.start_calls += 1 self._started = True def stop(self): + self.stop_calls += 1 self._started = False def destroy(self): + self.destroy_calls += 1 self._destroyed = True def fetch(self, timeout: float = 2.0): + self.fetch_calls.append(float(timeout)) + + # Strict rule: cannot fetch unless started if not self._started: - raise FakeGenTLTimeoutException("Acquirer not started") + raise FakeGenTLTimeoutException("fetch called while not started") if not self._queue: - raise FakeGenTLTimeoutException(f"Timeout after {timeout}s") + raise FakeGenTLTimeoutException(f"timeout after {timeout}s") payload = self._queue.pop(0) return _FakeFetchedBufferCtx(payload) @@ -708,93 +713,180 @@ def fetch(self, timeout: float = 2.0): class FakeHarvester: """ - Minimal fake for 'from harvesters.core import Harvester' supporting: + Minimal Harvester: - add_file/update/reset - - device_info_list for enumeration - - create()/create_image_acquirer() returning FakeImageAcquirer - - This enables GenTLCameraBackend.open/read/close paths. + - device_info_list + - create(index) or create({"serial_number": ...}) + Inventory-driven so tests can control enumeration. """ - def __init__(self): - self.device_info_list = [] - self._files = [] - self._acquirers: list[FakeImageAcquirer] = [] + def __init__(self, inventory: list[dict[str, Any]] | None = None): + self._files: list[str] = [] + self._inventory: list[dict[str, Any]] = list(inventory or []) + self.device_info_list: list[Any] = [] + + # Call tracing + self.add_file_calls: list[str] = [] + self.update_calls = 0 + self.reset_calls = 0 + self.create_calls: list[Any] = [] def add_file(self, file_path: str): self._files.append(str(file_path)) + self.add_file_calls.append(str(file_path)) def update(self): - # Harvesters tutorial output shows dict-like device entries. - self.device_info_list = [ - { - "display_name": "TLSimuMono (FAKE-GENTL-0)", - "model": "FakeGenTLModel", - "vendor": "FakeVendor", - "serial_number": "FAKE-GENTL-0", - "id_": "FakeDeviceId", - "tl_type": "Custom", - "user_defined_name": "Center", - "version": "1.0.0", - } - ] + self.update_calls += 1 + # If not provided, default to a single fake device + if not self._inventory: + self._inventory = [ + { + "display_name": "TLSimuMono (FAKE-GENTL-0)", + "model": "FakeGenTLModel", + "vendor": "FakeVendor", + "serial_number": "FAKE-GENTL-0", + "id_": "FakeDeviceId", + "tl_type": "Custom", + "user_defined_name": "Center", + "version": "1.0.0", + "access_status": 1000, + } + ] + self.device_info_list = list(self._inventory) def reset(self): - # "release" resources + self.reset_calls += 1 self.device_info_list = [] self._files = [] - self._acquirers = [] def create(self, selector=None, index: int | None = None, *args, **kwargs): - serial = None - - # Selector dict commonly used: {"serial_number": "..."} [1](https://github.com/genicam/harvesters/issues/454) - if isinstance(selector, dict): - serial = selector.get("serial_number") - - if serial is None and index is None: - index = 0 + # Record call for verification + self.create_calls.append({"selector": selector, "index": index, "args": args, "kwargs": kwargs}) if not self.device_info_list: self.update() + serial = None + if isinstance(selector, dict): + serial = selector.get("serial_number") + if serial is None: if index is None: - index = 0 + # allow create(0) style + if isinstance(selector, int): + index = selector + else: + index = 0 if index < 0 or index >= len(self.device_info_list): raise RuntimeError("Index out of range") - info = _DeviceInfoAdapter(self.device_info_list[index]) - serial = info.serial_number or "FAKE-GENTL-0" + info = self.device_info_list[index] + serial = str(_info_get(info, "serial_number", "FAKE-GENTL-0")) - acq = FakeImageAcquirer(serial=serial) - self._acquirers.append(acq) - return acq + return FakeImageAcquirer(serial=str(serial)) + # Keep compatibility if anything uses the older name def create_image_acquirer(self, *args, **kwargs): - # Alias used by some Harvesters versions; just delegate to create() return self.create(*args, **kwargs) +# ----------------------------------------------------------------------------- +# GentL fixtures: inventory, patching, settings factory +# ----------------------------------------------------------------------------- + + @pytest.fixture() -def fake_harvester_class(): - """Provides FakeHarvester class for patching GenTL backend.""" - return FakeHarvester +def gentl_inventory(): + """ + Mutable inventory list used by FakeHarvester.update(). + Tests can replace contents to simulate multiple devices, ambiguity, missing fields, etc. + """ + inv: list[dict[str, Any]] = [ + { + "display_name": "TLSimuMono (FAKE-GENTL-0)", + "model": "FakeGenTLModel", + "vendor": "FakeVendor", + "serial_number": "FAKE-GENTL-0", + "id_": "FakeDeviceId", + "tl_type": "Custom", + "user_defined_name": "Center", + "version": "1.0.0", + "access_status": 1000, + } + ] + return inv @pytest.fixture() -def patch_gentl_sdk(monkeypatch, fake_harvester_class): +def fake_harvester_factory(gentl_inventory): + """ + Factory that returns a FakeHarvester bound to the current gentl_inventory. + Allows tests to mutate gentl_inventory before calling backend.open(). + """ + + def _make(): + return FakeHarvester(inventory=gentl_inventory) + + return _make + + +@pytest.fixture() +def patch_gentl_sdk(monkeypatch, fake_harvester_factory): + """ + Patch dlclivegui.cameras.backends.gentl_backend to use FakeHarvester + Fake timeout. + Also bypass CTI search logic so tests never hit filesystem/SDK paths. + """ import dlclivegui.cameras.backends.gentl_backend as gb - monkeypatch.setattr(gb, "Harvester", fake_harvester_class, raising=False) + # Patch Harvester symbol (the backend calls Harvester() directly) + monkeypatch.setattr(gb, "Harvester", lambda: fake_harvester_factory(), raising=False) + + # Keep your backend timeout contract as-is: it catches HarvesterTimeoutError monkeypatch.setattr(gb, "HarvesterTimeoutError", FakeGenTLTimeoutException, raising=False) - # Prevent CTI searching from blocking open/get_device_count + # Avoid filesystem CTI searching monkeypatch.setattr(gb.GenTLCameraBackend, "_find_cti_file", lambda self: "dummy.cti", raising=False) monkeypatch.setattr( gb.GenTLCameraBackend, "_search_cti_file", staticmethod(lambda patterns: "dummy.cti"), raising=False ) - return fake_harvester_class + return gb + + +@pytest.fixture() +def gentl_settings_factory(): + """ + Convenience factory for CameraSettings for gentl backend tests. + """ + from dlclivegui.config import CameraSettings + + def _make( + *, + index=0, + name="TestCam", + width=0, + height=0, + fps=0.0, + exposure=0, + gain=0.0, + enabled=True, + properties=None, + ): + props = properties if isinstance(properties, dict) else {} + props.setdefault("gentl", {}) + return CameraSettings( + name=name, + index=index, + backend="gentl", + width=width, + height=height, + fps=fps, + exposure=exposure, + gain=gain, + enabled=enabled, + properties=props, + ) + + return _make # ----------------------------------------------------------------------------- diff --git a/tests/cameras/backends/test_gentl_backend.py b/tests/cameras/backends/test_gentl_backend.py new file mode 100644 index 0000000..d31bc9c --- /dev/null +++ b/tests/cameras/backends/test_gentl_backend.py @@ -0,0 +1,531 @@ +# tests/cameras/backends/test_gentl_backend.py +from __future__ import annotations + +import types + +import numpy as np +import pytest + + +# --------------------------------------------------------------------- +# Core lifecycle + strict transaction model +# --------------------------------------------------------------------- +def test_open_starts_stream_and_read_returns_frame(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = gentl_settings_factory() + be = gb.GenTLCameraBackend(settings) + + be.open() + assert be._harvester is not None + assert be._acquirer is not None + + # Strict model validated via behavior: read must succeed after normal open() + frame, ts = be.read() + assert isinstance(ts, float) + assert isinstance(frame, np.ndarray) + assert frame.size > 0 + # Backend converts to BGR; ensure 3-channel output + assert frame.ndim == 3 and frame.shape[2] == 3 + + be.close() + assert be._harvester is None + assert be._acquirer is None + assert be._device_label is None + + +def test_fast_start_does_not_start_stream_and_read_times_out(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = gentl_settings_factory(properties={"gentl": {"fast_start": True}}) + be = gb.GenTLCameraBackend(settings) + + be.open() + assert be._acquirer is not None + + # Strict model: fast_start -> open() does NOT start acquisition -> read must fail + with pytest.raises(TimeoutError): + be.read() + + be.close() + + +def test_close_is_idempotent(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + be = gb.GenTLCameraBackend(gentl_settings_factory()) + be.open() + be.close() + # Must not raise + be.close() + + +def test_stop_is_safe_before_open_and_after_close(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + be = gb.GenTLCameraBackend(gentl_settings_factory()) + + # stop before open should not raise + be.stop() + + be.open() + + # stop should make acquisition unusable for strict fetch/read + be.stop() + with pytest.raises(TimeoutError): + be.read() + + be.close() + + # stop after close should not raise + be.stop() + + +def test_read_before_open_raises_runtimeerror(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + be = gb.GenTLCameraBackend(gentl_settings_factory()) + + with pytest.raises(RuntimeError): + be.read() + + +# --------------------------------------------------------------------- +# Device selection + robustness (behavior/state based, minimal circularity) +# --------------------------------------------------------------------- +def test_device_id_exact_match_selects_correct_device_and_updates_index( + patch_gentl_sdk, gentl_settings_factory, gentl_inventory +): + gb = patch_gentl_sdk + + # Two devices; device_id targets SER1 => should bind to index 1 + gentl_inventory[:] = [ + { + "display_name": "Dev0 (SER0)", + "model": "M0", + "vendor": "V", + "serial_number": "SER0", + "id_": "ID0", + "tl_type": "Custom", + "user_defined_name": "U0", + "version": "1.0.0", + "access_status": 1000, + }, + { + "display_name": "Dev1 (SER1)", + "model": "M1", + "vendor": "V", + "serial_number": "SER1", + "id_": "ID1", + "tl_type": "Custom", + "user_defined_name": "U1", + "version": "1.0.0", + "access_status": 1000, + }, + ] + + settings = gentl_settings_factory(index=0, properties={"gentl": {"device_id": "serial:SER1"}}) + be = gb.GenTLCameraBackend(settings) + be.open() + + # Backend observable outcome: settings.index updated + assert int(be.settings.index) == 1 + + # Backend observable outcome: persisted identity and serial + ns = settings.properties.get("gentl", {}) + assert ns.get("device_id") == "serial:SER1" + assert ns.get("serial_number") == "SER1" + + be.close() + + +def test_ambiguous_serial_prefix_raises(patch_gentl_sdk, gentl_settings_factory, gentl_inventory): + gb = patch_gentl_sdk + + gentl_inventory[:] = [ + {"display_name": "DevA", "serial_number": "ABC-1"}, + {"display_name": "DevB", "serial_number": "ABC-2"}, + ] + + settings = gentl_settings_factory(properties={"gentl": {"device_id": "serial:ABC"}}) + be = gb.GenTLCameraBackend(settings) + + with pytest.raises(RuntimeError): + be.open() + + +def test_open_index_out_of_range_raises(patch_gentl_sdk, gentl_settings_factory, gentl_inventory): + gb = patch_gentl_sdk + + gentl_inventory[:] = [{"display_name": "OnlyDev", "serial_number": "SER0"}] + settings = gentl_settings_factory(index=5) + be = gb.GenTLCameraBackend(settings) + + with pytest.raises(RuntimeError): + be.open() + + +def test_missing_serial_produces_fingerprint_device_id(patch_gentl_sdk, gentl_settings_factory, gentl_inventory): + gb = patch_gentl_sdk + + gentl_inventory[:] = [ + { + "display_name": "DevNoSerial", + "vendor": "V", + "model": "M", + "serial_number": "", # missing/blank + "id_": "ID-NO-SERIAL", + "tl_type": "Custom", + "user_defined_name": "U", + "version": "1.0.0", + "access_status": 1000, + } + ] + + settings = gentl_settings_factory() + be = gb.GenTLCameraBackend(settings) + be.open() + + ns = settings.properties["gentl"] + assert isinstance(ns.get("device_id"), str) + assert ns["device_id"].startswith("fp:") + + # Rich metadata should still be persisted + assert ns.get("device_info_id") == "ID-NO-SERIAL" + assert ns.get("device_display_name") == "DevNoSerial" + + be.close() + + +# --------------------------------------------------------------------- +# Persistence contract (UI relies on these keys) +# --------------------------------------------------------------------- +def test_open_persists_rich_metadata_in_namespace(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = gentl_settings_factory() + be = gb.GenTLCameraBackend(settings) + be.open() + + ns = settings.properties.get("gentl", {}) + assert isinstance(ns, dict) + + # Identity basics + assert "device_id" in ns and str(ns["device_id"]) + assert "cti_file" in ns and str(ns["cti_file"]) + + # Rich info keys (minimum contract) + for k in ( + "device_display_name", + "device_info_id", + "device_vendor", + "device_model", + "device_tl_type", + "device_user_defined_name", + "device_version", + "device_access_status", + ): + assert k in ns, f"Missing persisted key: {k}" + + # If serial exists, it should be persisted + assert ns.get("serial_number") + assert ns.get("device_serial_number") + + # device_name derived from node_map label resolution + assert ns.get("device_name") + + be.close() + + +def test_open_persists_cti_file_even_when_provided_in_props(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = gentl_settings_factory(properties={"cti_file": "from-props.cti", "gentl": {}}) + be = gb.GenTLCameraBackend(settings) + be.open() + + ns = settings.properties["gentl"] + assert isinstance(ns.get("cti_file"), str) and ns["cti_file"] + + be.close() + + +# --------------------------------------------------------------------- +# Discovery / ping / rebind (still unit-only via patched SDK) +# --------------------------------------------------------------------- +def test_discover_devices_returns_device_id_and_label(patch_gentl_sdk): + gb = patch_gentl_sdk + + cams = gb.GenTLCameraBackend.discover_devices(max_devices=10) + assert isinstance(cams, list) + assert cams + + cam0 = cams[0] + assert getattr(cam0, "label", "") + assert getattr(cam0, "device_id", None) is not None + assert str(cam0.device_id).startswith(("serial:", "fp:")) + + +def test_discover_devices_prefers_display_name_for_label(patch_gentl_sdk, gentl_inventory): + gb = patch_gentl_sdk + + gentl_inventory[:] = [ + {"display_name": "Pretty Name (SERX)", "vendor": "V", "model": "M", "serial_number": "SERX", "id_": "IDX"} + ] + + cams = gb.GenTLCameraBackend.discover_devices(max_devices=10) + assert cams and cams[0].label == "Pretty Name (SERX)" + + +def test_quick_ping_true_for_existing_false_for_missing(patch_gentl_sdk, gentl_inventory): + gb = patch_gentl_sdk + + gentl_inventory[:] = [{"display_name": "Dev0", "serial_number": "SER0"}] + assert gb.GenTLCameraBackend.quick_ping(0) is True + assert gb.GenTLCameraBackend.quick_ping(1) is False + + +def test_rebind_settings_updates_index_using_device_id_with_attribute_entries( + patch_gentl_sdk, gentl_settings_factory, gentl_inventory +): + """ + rebind_settings has some getattr(...) usage; feed attribute-like entries to match that path. + """ + gb = patch_gentl_sdk + + gentl_inventory[:] = [ + types.SimpleNamespace( + display_name="Dev0", serial_number="SER0", vendor="V", model="M0", id_="ID0", tl_type="T" + ), + types.SimpleNamespace( + display_name="Dev1", serial_number="SER1", vendor="V", model="M1", id_="ID1", tl_type="T" + ), + ] + + settings = gentl_settings_factory(index=0, properties={"gentl": {"device_id": "serial:SER1"}}) + out = gb.GenTLCameraBackend.rebind_settings(settings) + + assert int(out.index) == 1 + ns = out.properties.get("gentl", {}) + assert ns.get("device_id") == "serial:SER1" + + +def test_rebind_settings_no_device_id_no_change(patch_gentl_sdk, gentl_settings_factory, gentl_inventory): + gb = patch_gentl_sdk + + gentl_inventory[:] = [ + {"display_name": "Dev0", "serial_number": "SER0"}, + {"display_name": "Dev1", "serial_number": "SER1"}, + ] + settings = gentl_settings_factory(index=1, properties={"gentl": {}}) + out = gb.GenTLCameraBackend.rebind_settings(settings) + + assert int(out.index) == 1 + + +# --------------------------------------------------------------------- +# _configure_* coverage (assert on node_map side effects, not logs) +# --------------------------------------------------------------------- +def test_resolution_auto_does_not_modify_node_dimensions(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = gentl_settings_factory(width=0, height=0) # Auto + be = gb.GenTLCameraBackend(settings) + be.open() + + nm = be._acquirer.remote_device.node_map + assert int(nm.Width.value) == 1920 + assert int(nm.Height.value) == 1080 + + be.close() + + +def test_resolution_request_is_aligned_to_increment(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = gentl_settings_factory(width=641, height=481) # odd -> should align + be = gb.GenTLCameraBackend(settings) + be.open() + + nm = be._acquirer.remote_device.node_map + assert int(nm.Width.value) % 2 == 0 + assert int(nm.Height.value) % 2 == 0 + + assert be.actual_resolution is not None + w, h = be.actual_resolution + assert w == int(nm.Width.value) + assert h == int(nm.Height.value) + + be.close() + + +def test_manual_exposure_gain_fps_are_applied_when_nonzero(patch_gentl_sdk, gentl_settings_factory): + """ + Covers _configure_exposure/_configure_gain/_configure_frame_rate success path. + """ + gb = patch_gentl_sdk + + settings = gentl_settings_factory(exposure=20000, gain=3.0, fps=50.0) + be = gb.GenTLCameraBackend(settings) + be.open() + + nm = be._acquirer.remote_device.node_map + assert float(nm.ExposureTime.value) == pytest.approx(20000.0) + assert float(nm.Gain.value) == pytest.approx(3.0) + assert float(nm.AcquisitionFrameRate.value) == pytest.approx(50.0) + + be.close() + + +def test_pixel_format_unavailable_does_not_crash_open_and_streams(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = gentl_settings_factory(properties={"gentl": {"pixel_format": "NotAFormat"}}) + be = gb.GenTLCameraBackend(settings) + be.open() + + # No fake-internal checks; just verify it can read + frame, _ = be.read() + assert frame is not None and frame.size > 0 + + be.close() + + +# --------------------------------------------------------------------- +# Direct unit tests for _create_acquirer (fallback paths + error aggregation) +# --------------------------------------------------------------------- +def test__create_acquirer_prefers_create_serial_dict(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = gentl_settings_factory() + be = gb.GenTLCameraBackend(settings) + + class H: + device_info_list = [{"serial_number": "SER0"}] + + def create(self, selector=None, index=None): + # Return different sentinels depending on call form + if isinstance(selector, dict) and selector.get("serial_number") == "SERX": + return "ACQ_SERIAL_DICT" + raise RuntimeError("unexpected call") + + be._harvester = H() + acq = be._create_acquirer("SERX", 0) + assert acq == "ACQ_SERIAL_DICT" + + +def test__create_acquirer_index_kw_typeerror_falls_back_to_positional(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = gentl_settings_factory() + be = gb.GenTLCameraBackend(settings) + + class H: + device_info_list = [{"serial_number": "SER0"}] + + def create(self, *args, **kwargs): + # Simulate a Harvester that does NOT accept keyword index + if "index" in kwargs: + raise TypeError("index kw not supported") + # Positional index works + if len(args) == 1 and args[0] == 2: + return "ACQ_POS_INDEX" + raise RuntimeError("unexpected call") + + be._harvester = H() + acq = be._create_acquirer(None, 2) + assert acq == "ACQ_POS_INDEX" + + +def test__create_acquirer_falls_back_to_create_image_acquirer_when_create_fails( + patch_gentl_sdk, gentl_settings_factory +): + gb = patch_gentl_sdk + + settings = gentl_settings_factory() + be = gb.GenTLCameraBackend(settings) + + class H: + device_info_list = [{"serial_number": "SER0"}] + + def create(self, *args, **kwargs): + raise RuntimeError("create fails") + + def create_image_acquirer(self, selector=None, index=None): + # Succeeds here + if isinstance(selector, dict) and selector.get("serial_number") == "SERX": + return "ACQ_CIA_SERIAL" + if index == 1: + return "ACQ_CIA_INDEX" + return "ACQ_CIA_OTHER" + + be._harvester = H() + acq = be._create_acquirer("SERX", 1) + assert acq == "ACQ_CIA_SERIAL" + + +def test__create_acquirer_uses_device_info_fallback_when_available(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = gentl_settings_factory() + be = gb.GenTLCameraBackend(settings) + + device_info_obj = {"serial_number": "SER0", "id_": "ID0"} + + class H: + device_info_list = [device_info_obj] + + def create(self, *args, **kwargs): + # Fail index, succeed if given device_info object + if "index" in kwargs or (len(args) == 1 and isinstance(args[0], int)): + raise RuntimeError("index path fails") + if len(args) == 1 and args[0] is device_info_obj: + return "ACQ_DEVICE_INFO" + raise RuntimeError("unexpected call") + + be._harvester = H() + acq = be._create_acquirer(None, 0) + assert acq == "ACQ_DEVICE_INFO" + + +def test__create_acquirer_tries_default_create_when_index0_and_no_serial(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = gentl_settings_factory() + be = gb.GenTLCameraBackend(settings) + + class H: + device_info_list = [{"serial_number": "SER0"}] + + def create(self, *args, **kwargs): + # Fail index attempts; succeed only on no-arg create() + if args or kwargs: + raise RuntimeError("only no-arg create works") + return "ACQ_DEFAULT" + + be._harvester = H() + acq = be._create_acquirer(None, 0) + assert acq == "ACQ_DEFAULT" + + +def test__create_acquirer_raises_runtimeerror_with_joined_errors(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = gentl_settings_factory() + be = gb.GenTLCameraBackend(settings) + + class H: + device_info_list = [{"serial_number": "SER0"}] + + def create(self, *args, **kwargs): + raise RuntimeError("create boom") + + def create_image_acquirer(self, *args, **kwargs): + raise RuntimeError("cia boom") + + be._harvester = H() + + with pytest.raises(RuntimeError) as ei: + be._create_acquirer("SERX", 0) + + # Error message should include some context about attempted creation methods + msg = str(ei.value).lower() + assert "failed to initialise gentl image acquirer" in msg From 8efc257bebc97c126d981084abe506abbcc6472c Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 12 Feb 2026 14:37:42 +0100 Subject: [PATCH 07/30] Basler backend: device discovery & fixes Refactor and harden BaslerCameraBackend: add a basler namespace view/ensure helpers, stable device_id (serial) handling with legacy fallbacks, and persistence of device identity. Introduce fast_start probe mode to avoid starting acquisition/creating converter, and an apply_transforms option to perform rotation/crop in read(). Add testable device enumeration helpers ( _enumerate_devices_cls, discover_devices, get_device_count, quick_ping ) and utility operations (rebind_settings, sanitize_for_probe) to improve discovery, rebinding, and fast probing. Improve open()/read()/close() with safer node access, better logging, explicit handling of exposure/gain/fps, resolution snapping to node increments/ranges, read-back of actual telemetry, and clearer errors when operating in fast-start probe mode. Maintain backward compatibility with legacy properties (serial, resolution) while preferring namespaced options. --- dlclivegui/cameras/backends/basler_backend.py | 547 +++++++++++++++--- 1 file changed, 454 insertions(+), 93 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 71cb715..34c6874 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -28,18 +28,34 @@ class BaslerCameraBackend(CameraBackend): def __init__(self, settings): super().__init__(settings) - props = settings.properties if isinstance(settings.properties, dict) else {} - ns = props.get(self.OPTIONS_KEY, {}) - if not isinstance(ns, dict): - ns = {} + self._props: dict = settings.properties if isinstance(settings.properties, dict) else {} + + # Optional fast-start hint for probe workers (best-effort; doesn't change behavior yet) + self._fast_start: bool = bool(self.ns.get("fast_start", False)) + self._apply_transforms: bool = bool(self.ns.get("apply_transforms", False)) + + # Stable identity (serial-based). Prefer new namespace; fall back to legacy keys read-only. + self._device_id: str | None = None + dev_id = self.ns.get("device_id") + if dev_id: + self._device_id = str(dev_id) + else: + # legacy fallback (read-only) + legacy_serial = None + try: + legacy_serial = self._props.get("serial") or self._props.get("serial_number") + except Exception: + legacy_serial = None + if legacy_serial: + self._device_id = str(legacy_serial) + + self._requested_resolution: tuple[int, int] | None = self._get_requested_resolution_or_none() + # ---- Runtime handles (set during open) ---- self._camera: pylon.InstantCamera | None = None self._converter: pylon.ImageFormatConverter | None = None - # Resolution request (None = device default) - self._requested_resolution: tuple[int, int] | None = self._get_requested_resolution_or_none() - - # Actuals for GUI + # ---- Actuals for GUI telemetry ---- self._actual_width: int | None = None self._actual_height: int | None = None self._actual_fps: float | None = None @@ -83,59 +99,304 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: ) return caps + @property + def ns(self) -> dict: + """Basler namespace view (read-only). Always derived from current settings.properties.""" + return self.__class__._ns_from_settings(self.settings) + + @classmethod + def _ns_from_settings(cls, settings) -> dict: + """Return basler namespace dict from a settings object (read-only, safe).""" + props = settings.properties if isinstance(settings.properties, dict) else {} + ns = props.get(cls.OPTIONS_KEY, {}) + return ns if isinstance(ns, dict) else {} + + def _ensure_mutable_ns(self) -> dict: + """Ensure settings.properties and its basler namespace dict exist; return the namespace.""" + if not isinstance(self.settings.properties, dict): + self.settings.properties = {} + ns = self.settings.properties.get(self.OPTIONS_KEY) + if not isinstance(ns, dict): + ns = {} + self.settings.properties[self.OPTIONS_KEY] = ns + return ns + + @classmethod + def _enumerate_devices_cls(cls): + """Enumerate DeviceInfo entries (unit-testable via monkeypatch).""" + if pylon is None: + return [] + factory = pylon.TlFactory.GetInstance() + return factory.EnumerateDevices() + + @classmethod + def get_device_count(cls) -> int: + """Return the number of Basler devices visible to Pylon.""" + try: + return len(cls._enumerate_devices_cls()) + except Exception: + return 0 + + @classmethod + def quick_ping(cls, index: int, *args, **kwargs) -> bool: + """Best-effort presence check; avoids opening the device.""" + try: + devices = cls._enumerate_devices_cls() + idx = int(index) + return 0 <= idx < len(devices) + except Exception: + return False + + @classmethod + def discover_devices( + cls, + *, + max_devices: int = 10, + should_cancel=None, + progress_cb=None, + ): + """ + Return a rich list of DetectedCamera with stable identity (serial). + Best-effort: works for USB3/GigE; fields depend on SDK/device. + """ + if pylon is None: + return [] + + from ..factory import DetectedCamera # local import to keep module load light + + devices = cls._enumerate_devices_cls() + out = [] + + # Bound by max_devices to match factory expectations + n = min(len(devices), int(max_devices) if max_devices is not None else len(devices)) + + for i in range(n): + if should_cancel and should_cancel(): + break + if progress_cb: + progress_cb(f"Reading Basler device info ({i + 1}/{n})…") + + di = devices[i] + + # Best-effort getters; not all are present on all transports + serial = None + try: + serial = di.GetSerialNumber() + except Exception: + serial = None + + # Friendly label: Vendor Model (Serial) + vendor = model = friendly = full_name = None + try: + vendor = di.GetVendorName() + except Exception: + pass + try: + model = di.GetModelName() + except Exception: + pass + try: + friendly = di.GetFriendlyName() + except Exception: + pass + try: + full_name = di.GetFullName() + except Exception: + pass + + label_parts = [] + if vendor: + label_parts.append(str(vendor)) + if model: + label_parts.append(str(model)) + if not label_parts and friendly: + label_parts.append(str(friendly)) + label = " ".join(label_parts) if label_parts else f"Basler #{i}" + if serial: + label = f"{label} ({serial})" + + out.append( + DetectedCamera( + index=i, + label=label, + device_id=str(serial) if serial else None, # <-- stable identity + path=str(full_name) if full_name else None, + ) + ) + + return out + + @classmethod + def rebind_settings(cls, settings): + """ + If settings.properties['basler']['device_id'] (serial) exists, + update settings.index to match the current device list order. + """ + if pylon is None: + return settings + + dc = settings.model_copy(deep=True) + + ns = cls._ns_from_settings(dc) + serial = ns.get("device_id") or ns.get("serial") # allow legacy-in-namespace + + # Legacy top-level fallback (read-only compatibility) + if not serial: + props = dc.properties if isinstance(dc.properties, dict) else {} + serial = props.get("serial") or props.get("serial_number") + + if not serial: + return dc + + try: + devices = cls._enumerate_devices_cls() + for i, di in enumerate(devices): + try: + if di.GetSerialNumber() == serial: + dc.index = int(i) + + # Ensure we persist stable ID in the basler namespace + if not isinstance(dc.properties, dict): + dc.properties = {} + bns = dc.properties.get(cls.OPTIONS_KEY) + if not isinstance(bns, dict): + bns = {} + dc.properties[cls.OPTIONS_KEY] = bns + + bns["device_id"] = str(serial) # canonical + # optional friendly name cache (nice for UI) + try: + bns["device_name"] = str(di.GetFriendlyName()) + except Exception: + pass + try: + bns["device_path"] = str(di.GetFullName()) + except Exception: + pass + + return dc + except Exception: + continue + except Exception: + pass + + return dc + + @classmethod + def sanitize_for_probe(cls, settings): + """ + Keep only basler namespace + set all requested controls to Auto + so probing is fast and doesn't force modes. + """ + dc = settings.model_copy(deep=True) + + # Keep only backend namespace dict + ns = cls._ns_from_settings(dc) + dc.properties = {cls.OPTIONS_KEY: dict(ns)} # shallow copy ok + + # Force Auto for probe; do NOT set heavy parameters + dc.width = 0 + dc.height = 0 + dc.fps = 0.0 + dc.exposure = 0 + dc.gain = 0.0 + dc.rotation = 0 + dc.crop_x0 = dc.crop_y0 = dc.crop_x1 = dc.crop_y1 = 0 + + return dc + + @staticmethod + def _positive_float(value) -> float | None: + """Return float(value) if > 0 else None.""" + try: + v = float(value) + return v if v > 0 else None + except Exception: + return None + + @staticmethod + def _apply_crop(frame: np.ndarray, x0: int, y0: int, x1: int, y1: int) -> np.ndarray: + h, w = frame.shape[:2] + if x1 <= 0: + x1 = w + if y1 <= 0: + y1 = h + x0 = max(0, min(int(x0), w)) + y0 = max(0, min(int(y0), h)) + x1 = max(x0, min(int(x1), w)) + y1 = max(y0, min(int(y1), h)) + return frame[y0:y1, x0:x1] if (x1 > x0 and y1 > y0) else frame + def open(self) -> None: if pylon is None: raise RuntimeError("pypylon is required for the Basler backend but is not installed") + # ---------------------------- + # Device enumeration & selection + # ---------------------------- devices = self._enumerate_devices() if not devices: raise RuntimeError("No Basler cameras detected") device = self._select_device(devices) + self._camera = pylon.InstantCamera(pylon.TlFactory.GetInstance().CreateDevice(device)) self._camera.Open() - # Exposure (0 = Auto -> do not set) - exposure = self._settings_value( - "exposure", self.settings.properties, fallback=self.settings.exposure, treat_nonpositive_as_none=True - ) + # ---------------------------- + # Exposure (0 = Auto → do not set) + # ---------------------------- + exposure = self._positive_float(getattr(self.settings, "exposure", 0)) + if exposure is not None: try: - self._camera.ExposureTime.SetValue(float(exposure)) + self._camera.ExposureTime.SetValue(exposure) except Exception: - pass + LOG.debug("ExposureTime not writable or not supported", exc_info=True) + + # ---------------------------- + # Gain (0 = Auto → do not set) + # ---------------------------- + gain = self._positive_float(getattr(self.settings, "gain", 0)) - # Gain (0 = Auto -> do not set) - gain = self._settings_value( - "gain", self.settings.properties, fallback=self.settings.gain, treat_nonpositive_as_none=True - ) if gain is not None: try: - self._camera.Gain.SetValue(float(gain)) + self._camera.Gain.SetValue(gain) except Exception: - pass + LOG.debug("Gain not writable or not supported", exc_info=True) - # Resolution (device default if None) + # ---------------------------- + # Resolution (None → device default) + # ---------------------------- + # Re-evaluate in case settings were rebound before open() + self._requested_resolution = self._get_requested_resolution_or_none() self._configure_resolution() - # Frame rate (0.0 = Auto -> do not set) - fps = self._settings_value( - "fps", self.settings.properties, fallback=self.settings.fps, treat_nonpositive_as_none=True - ) + # ---------------------------- + # Frame rate (0.0 = Auto → do not set) + # ---------------------------- + fps = self._positive_float(getattr(self.settings, "fps", 0.0)) + if fps is not None: try: - self._camera.AcquisitionFrameRateEnable.SetValue(True) - self._camera.AcquisitionFrameRate.SetValue(float(fps)) + # Some models require enable flag to be writable + if hasattr(self._camera, "AcquisitionFrameRateEnable"): + try: + self._camera.AcquisitionFrameRateEnable.SetValue(True) + except Exception: + pass + self._camera.AcquisitionFrameRate.SetValue(fps) except Exception: - pass + LOG.debug("Frame rate not writable or not supported", exc_info=True) - # Always try to read actual FPS for probing / GUI + # ---------------------------- + # Read back actual values (telemetry for GUI / probe) + # ---------------------------- try: self._actual_fps = float(self._camera.AcquisitionFrameRate.GetValue()) except Exception: self._actual_fps = None - # Capture actual resolution even when using defaults try: self._actual_width = int(self._camera.Width.GetValue()) self._actual_height = int(self._camera.Height.GetValue()) @@ -152,15 +413,49 @@ def open(self) -> None: except Exception: self._actual_gain = None - self._camera.StartGrabbing(pylon.GrabStrategy_LatestImageOnly) - - self._converter = pylon.ImageFormatConverter() - self._converter.OutputPixelFormat = pylon.PixelType_BGR8packed - self._converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned + # ---------------------------- + # Start acquisition (skip for fast probe) + # ---------------------------- + if not self._fast_start: + self._camera.StartGrabbing(pylon.GrabStrategy_LatestImageOnly) + + self._converter = pylon.ImageFormatConverter() + self._converter.OutputPixelFormat = pylon.PixelType_BGR8packed + self._converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned + else: + LOG.debug("Fast-start probe: skipping StartGrabbing and converter") + + LOG.info( + "[Basler] open device_id=%s index=%s fast_start=%s requested=(%sx%s @ %s fps exp=%s gain=%s)", + getattr(self, "_device_id", None), + getattr(self.settings, "index", None), + getattr(self, "_fast_start", None), + getattr(self.settings, "width", None), + getattr(self.settings, "height", None), + getattr(self.settings, "fps", None), + getattr(self.settings, "exposure", None), + getattr(self.settings, "gain", None), + ) + # ---------------------------- + # Persist stable identity into namespace (migration-safe) + # ---------------------------- + try: + serial = device.GetSerialNumber() + if serial: + ns = self._ensure_mutable_ns() + ns["device_id"] = str(serial) + try: + ns["device_name"] = str(device.GetFriendlyName()) + except Exception: + pass + except Exception: + pass def read(self) -> tuple[np.ndarray, float]: - if self._camera is None or self._converter is None: + if self._camera is None: raise RuntimeError("Basler camera not opened") + if self._converter is None: + raise RuntimeError("Basler camera opened in fast-start probe mode; cannot read frames") try: grab_result = self._camera.RetrieveResult(100, pylon.TimeoutHandling_ThrowException) except Exception as exc: @@ -177,16 +472,31 @@ def read(self) -> tuple[np.ndarray, float]: self._actual_width = int(w) self._actual_height = int(h) - rotate = self._settings_value("rotate", self.settings.properties) - if rotate: - frame = self._rotate(frame, float(rotate)) - crop = self.settings.properties.get("crop") - if isinstance(crop, (list, tuple)) and len(crop) == 4: - left, right, top, bottom = map(int, crop) - frame = frame[top:bottom, left:right] + # --- Optional transforms --- + if self._apply_transforms: + # Rotation from CameraSettings + rotation = int(getattr(self.settings, "rotation", 0) or 0) + if rotation: + frame = self._rotate(frame, rotation) + + # Crop from CameraSettings + x0 = int(getattr(self.settings, "crop_x0", 0) or 0) + y0 = int(getattr(self.settings, "crop_y0", 0) or 0) + x1 = int(getattr(self.settings, "crop_x1", 0) or 0) + y1 = int(getattr(self.settings, "crop_y1", 0) or 0) + + if x0 or y0 or x1 or y1: + frame = self._apply_crop(frame, x0, y0, x1, y1) + return frame, time.time() def close(self) -> None: + LOG.info( + "[Basler] close called camera_exists=%s grabbing=%s open=%s", + self._camera is not None, + bool(self._camera and self._camera.IsGrabbing()), + bool(self._camera and self._camera.IsOpen()), + ) if self._camera is not None: if self._camera.IsGrabbing(): self._camera.StopGrabbing() @@ -203,57 +513,76 @@ def stop(self) -> None: pass def _enumerate_devices(self): - factory = pylon.TlFactory.GetInstance() - return factory.EnumerateDevices() + return self.__class__._enumerate_devices_cls() def _select_device(self, devices): - serial = self.settings.properties.get("serial") or self.settings.properties.get("serial_number") + # 1) Namespaced / cached stable identity (preferred) + serial = self._device_id + if serial: for device in devices: - if getattr(device, "GetSerialNumber", None) and device.GetSerialNumber() == serial: - return device + try: + if device.GetSerialNumber() == serial: + return device + except Exception: + continue + + # 2) Legacy top-level fallback (read-only compatibility) + legacy = None + try: + legacy = self._props.get("serial") or self._props.get("serial_number") + except Exception: + legacy = None + + if legacy: + for device in devices: + try: + if device.GetSerialNumber() == legacy: + return device + except Exception: + continue + + # 3) Index fallback index = int(self.settings.index) if index < 0 or index >= len(devices): raise RuntimeError(f"Camera index {index} out of range for {len(devices)} Basler device(s)") - return devices[index] - def _rotate(self, frame: np.ndarray, angle: float) -> np.ndarray: - try: - from imutils import rotate_bound # pragma: no cover - optional - except Exception as exc: # pragma: no cover - optional dependency - raise RuntimeError("Rotation requested for Basler camera but imutils is not installed") from exc - return rotate_bound(frame, angle) + return devices[index] - def _get_requested_resolution_or_none(self) -> tuple[int, int] | None: + @staticmethod + def _snap_to_node(value: int, node) -> int: """ - Return (w, h) if user explicitly requested a resolution. - Return None to keep device defaults. + Best-effort clamp/snap for Basler integer nodes (Width/Height). + Works with real Pylon nodes and is unit-testable with fakes. + + If node lacks GetMin/GetMax/GetInc, returns value unchanged. """ - props = self.settings.properties if isinstance(self.settings.properties, dict) else {} + v = int(value) - legacy = props.get("resolution") - if isinstance(legacy, (list, tuple)) and len(legacy) == 2: - try: - w, h = int(legacy[0]), int(legacy[1]) - if w > 0 and h > 0: - return (w, h) - except Exception: - pass + try: + vmin = int(node.GetMin()) + vmax = int(node.GetMax()) + v = max(vmin, min(v, vmax)) + except Exception: + # Node doesn't support min/max querying; keep as-is + return v try: - w = int(getattr(self.settings, "width", 0) or 0) - h = int(getattr(self.settings, "height", 0) or 0) - if w > 0 and h > 0: - return (w, h) + inc = int(node.GetInc()) + if inc > 1: + # snap down to nearest valid increment + v = vmin + ((v - vmin) // inc) * inc except Exception: pass - return None + return int(v) def _configure_resolution(self) -> None: """ - Apply width/height only if explicitly requested. + Apply width/height only if explicitly requested (GUI or override). If None, keep device defaults. + + Best-effort: if camera enforces increments/ranges, snap to valid values. """ if self._camera is None: return @@ -263,11 +592,21 @@ def _configure_resolution(self) -> None: LOG.info("Resolution: using device default.") return - req_w, req_h = req + req_w, req_h = int(req[0]), int(req[1]) + try: + # Best-effort clamp/snap (helps with Basler increment constraints) + try: + req_w = self._snap_to_node(req_w, self._camera.Width) + req_h = self._snap_to_node(req_h, self._camera.Height) + except Exception: + pass + + # Apply requested values self._camera.Width.SetValue(int(req_w)) self._camera.Height.SetValue(int(req_h)) + # Read back actual applied values aw = int(self._camera.Width.GetValue()) ah = int(self._camera.Height.GetValue()) self._actual_width = aw @@ -277,32 +616,54 @@ def _configure_resolution(self) -> None: LOG.warning(f"Resolution mismatch: requested {req_w}x{req_h}, got {aw}x{ah}") else: LOG.info(f"Resolution set to {aw}x{ah}") + except Exception as exc: LOG.warning(f"Failed to set resolution to {req_w}x{req_h}: {exc}") - @staticmethod - def _settings_value( - key: str, source: dict, fallback: float | None = None, *, treat_nonpositive_as_none: bool = True - ): + def _get_requested_resolution_or_none(self) -> tuple[int, int] | None: """ - Fetch setting from a dict with an optional fallback. + Return (w, h) if a resolution was explicitly requested. - If treat_nonpositive_as_none is True: - - numeric values <= 0 are treated as "Auto" and returned as None + Priority: + 1) CameraSettings.width/height (GUI fields) + 2) properties['basler']['resolution'] (namespaced optional override) + 3) properties['resolution'] (legacy fallback) + Return None to keep device defaults. + + Note: 'Auto' in GUI is represented by 0 for width/height. """ - value = source.get(key, fallback) - if value is None: + def _coerce_pair(val) -> tuple[int, int] | None: + if isinstance(val, (list, tuple)) and len(val) == 2: + try: + w = int(val[0]) + h = int(val[1]) + if w > 0 and h > 0: + return (w, h) + except Exception: + return None return None - # Treat 0 / <=0 as Auto by default - if treat_nonpositive_as_none and isinstance(value, (int, float)): - try: - fv = float(value) - if fv <= 0.0: - return None - return fv - except Exception: - return None + # 1) GUI fields first + try: + w = int(getattr(self.settings, "width", 0) or 0) + h = int(getattr(self.settings, "height", 0) or 0) + if w > 0 and h > 0: + return (w, h) + except Exception: + pass - return value + # 2) Namespaced optional override + props = self.settings.properties if isinstance(self.settings.properties, dict) else {} + ns = props.get(self.OPTIONS_KEY, {}) + if isinstance(ns, dict): + pair = _coerce_pair(ns.get("resolution")) + if pair: + return pair + + # 3) Legacy fallback (read-only compatibility) + pair = _coerce_pair(props.get("resolution")) + if pair: + return pair + + return None From 1ea3c3bd86f99f4029d64ec3400613b07db23fba Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 12 Feb 2026 14:41:32 +0100 Subject: [PATCH 08/30] Refactor preview restart logic, add logging Move camera probe options out of commented code and explicitly disable fast_start and apply_transforms during preview probing to avoid double transforms (Basler) and ensure a full open when probing. Replace _needs_preview_reopen with _should_restart_preview and implement a backend-agnostic policy: restart preview only for camera-side capture parameter changes (width, height, fps, exposure, gain) and do not restart for rotation/crop to provide a faster UX. Improve _apply_camera_settings by preventing applies while a loader is active, computing/logging a pre-apply diff, persisting the validated model, and then deciding whether to restart the preview; use a short QTimer delay for driver stability when restarting. Add debug/info logging around preview start/stop, loader success, backend close, and more robust error logging for loader failures. Small cleanup of exception handling and safer restart decision behavior. --- dlclivegui/gui/camera_config_dialog.py | 164 +++++++++++++++++-------- 1 file changed, 115 insertions(+), 49 deletions(-) diff --git a/dlclivegui/gui/camera_config_dialog.py b/dlclivegui/gui/camera_config_dialog.py index 3c5acc0..87244c7 100644 --- a/dlclivegui/gui/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config_dialog.py @@ -156,16 +156,21 @@ def __init__(self, cam: CameraSettings, parent: QWidget | None = None): super().__init__(parent) self._cam = copy.deepcopy(cam) - # Do not use fast_start here as we want to actually open the camera to probe capabilities - # If you want a quick probe without full open, use CameraProbeWorker instead which sets fast_start=True - # if isinstance(self._cam.properties, dict): - # ns = self._cam.properties.setdefault(self._cam.backend.lower(), {}) - # if isinstance(ns, dict): - # ns.setdefault("fast_start", True) - self._cancel = False self._backend: CameraBackend | None = None + # Do not use fast_start here as we want to actually open the camera to probe capabilities + # If you want a quick probe without full open, use CameraProbeWorker instead which sets fast_start=True + # Ensure preview open never uses fast_start probe mode + if isinstance(self._cam.properties, dict): + ns = self._cam.properties.setdefault(self._cam.backend.lower(), {}) + if isinstance(ns, dict): + ns["fast_start"] = False + # Basler implements transforms in the backend + # but preview already takes care of rotation/crop + # so we disable transform application in probe to avoid double transforms and speed up probe + ns["apply_transforms"] = False + def request_cancel(self): self._cancel = True @@ -990,35 +995,23 @@ def _on_active_camera_selected(self, row: int) -> None: # UI helpers/actions # ------------------------------- - def _needs_preview_reopen(self, cam: CameraSettings) -> bool: - if not (self._preview_active and self._preview_backend): - return False - - # FPS: for OpenCV, treat FPS changes as requiring reopen. - if self._is_backend_opencv(cam.backend): - prev_w = getattr(self._preview_backend.settings, "width", None) - prev_h = getattr(self._preview_backend.settings, "height", None) - if isinstance(prev_w, int) and isinstance(prev_h, int): - if (cam.width, cam.height) != (prev_w, prev_h): + def _should_restart_preview(self, old: CameraSettings, new: CameraSettings) -> bool: + """ + Fast UX policy: + - Do NOT restart for rotation/crop (preview applies those live). + - Restart for camera-side capture params: resolution/fps/exposure/gain. + Backend-agnostic for now (no OpenCV special casing). + """ + # Restart on these changes + for key in ("width", "height", "fps", "exposure", "gain"): + try: + if getattr(old, key, None) != getattr(new, key, None): return True - prev_fps = getattr(self._preview_backend.settings, "fps", None) - if isinstance(prev_fps, (int, float)) and abs(cam.fps - float(prev_fps)) > 0.1: - return True + except Exception: + return True # safest: restart - return any( - [ - cam.exposure != getattr(self._preview_backend.settings, "exposure", cam.exposure), - cam.gain != getattr(self._preview_backend.settings, "gain", cam.gain), - cam.rotation != getattr(self._preview_backend.settings, "rotation", cam.rotation), - (cam.crop_x0, cam.crop_y0, cam.crop_x1, cam.crop_y1) - != ( - getattr(self._preview_backend.settings, "crop_x0", cam.crop_x0), - getattr(self._preview_backend.settings, "crop_y0", cam.crop_y0), - getattr(self._preview_backend.settings, "crop_x1", cam.crop_x1), - getattr(self._preview_backend.settings, "crop_y1", cam.crop_y1), - ), - ] - ) + # No restart needed if only rotation/crop/enabled changed + return False def _backend_actual_fps(self) -> float | None: """Return backend's actual FPS if known; for OpenCV do NOT fall back to settings.fps.""" @@ -1397,6 +1390,9 @@ def _move_camera_down(self) -> None: self._refresh_camera_labels() def _apply_camera_settings(self) -> None: + if self._loading_active: + self._append_status("[Apply] Preview is loading; please wait or cancel loading first.") + return try: for sb in ( self.cam_fps, @@ -1424,24 +1420,72 @@ def _apply_camera_settings(self) -> None: cam = self._working_settings.cameras[row] self._write_form_to_cam(cam) - must_reopen = False - if self._preview_active and self._preview_backend: - prev_model = getattr(self._preview_backend, "settings", None) - if prev_model: - must_reopen = self._needs_preview_reopen(new_model) + # --- Logging: compute diff before overwriting anything --- + def _cam_diff(old: CameraSettings, new: CameraSettings) -> dict: + keys = ( + "width", + "height", + "fps", + "exposure", + "gain", + "rotation", + "crop_x0", + "crop_y0", + "crop_x1", + "crop_y1", + "enabled", + ) + out = {} + for k in keys: + try: + ov = getattr(old, k, None) + nv = getattr(new, k, None) + if ov != nv: + out[k] = (ov, nv) + except Exception: + pass + return out + + # We compare against the current preview backend settings if available, else against current_model + old_for_diff = getattr(self._preview_backend, "settings", None) if self._preview_backend else current_model + diff = _cam_diff(old_for_diff if isinstance(old_for_diff, CameraSettings) else current_model, new_model) + LOGGER.info( + "[Apply] backend=%s idx=%s changes=%s", + getattr(new_model, "backend", None), + getattr(new_model, "index", None), + diff, + ) + + # --- Persist validated model back BEFORE touching preview --- + self._working_settings.cameras[row] = new_model + self._update_active_list_item(row, new_model) + + # Decide whether we need to restart preview (fast UX) + old_settings = None + if self._preview_backend and isinstance(getattr(self._preview_backend, "settings", None), CameraSettings): + old_settings = self._preview_backend.settings + else: + old_settings = current_model + + restart = False + if self._preview_active and isinstance(old_settings, CameraSettings): + restart = self._should_restart_preview(old_settings, new_model) + + LOGGER.info( + "[Apply] preview_active=%s restart=%s backend=%s idx=%s", + self._preview_active, + restart, + new_model.backend, + new_model.index, + ) if self._preview_active: - if must_reopen: + if restart: + self._append_status("[Apply] Restarting preview to apply camera settings…") self._stop_preview() - self._start_preview() + QTimer.singleShot(100, self._start_preview) # small delay for drivers (Basler/Pylon) else: - self._reconcile_fps_from_backend(new_model) - if not self._backend_actual_fps(): - self._append_status("[Info] FPS will reconcile automatically during preview.") - - # Persist validated model back - self._working_settings.cameras[row] = new_model - self._update_active_list_item(row, new_model) + self._append_status("[Apply] Applied without restart (crop/rotation update is live).") except Exception as exc: LOGGER.exception("Apply camera settings failed") @@ -1510,6 +1554,15 @@ def _start_preview(self) -> None: cam = item.data(Qt.ItemDataRole.UserRole) if not cam: return + LOGGER.info( + "[Preview] start requested row=%s backend=%s idx=%s name=%s loading=%s active=%s", + self._current_edit_index, + cam.backend, + cam.index, + cam.name, + self._loading_active, + self._preview_active, + ) # Ensure any existing preview or loader is stopped/canceled self._stop_preview() @@ -1536,6 +1589,12 @@ def _start_preview(self) -> None: def _stop_preview(self) -> None: """Stop camera preview and cancel any ongoing loading.""" + LOGGER.info( + "[Preview] stop requested loading=%s active=%s backend=%s", + self._loading_active, + self._preview_active, + getattr(getattr(self._preview_backend, "settings", None), "backend", None), + ) # Cancel loader if running if self._loader and self._loader.isRunning(): self._loader.request_cancel() @@ -1548,6 +1607,7 @@ def _stop_preview(self) -> None: # Close backend if self._preview_backend: try: + LOGGER.debug("[Preview] closing backend object=%r", self._preview_backend) self._preview_backend.close() except Exception: pass @@ -1610,6 +1670,12 @@ def _on_loader_success(self, payload) -> None: if isinstance(payload, CameraSettings): cam_settings = payload self._append_status("Opening camera…") + LOGGER.debug( + "[Loader] success -> opening camera backend=%s idx=%s props_keys=%s", + cam_settings.backend, + cam_settings.index, + list(cam_settings.properties.keys()) if isinstance(cam_settings.properties, dict) else None, + ) self._preview_backend = CameraFactory.create(cam_settings) self._preview_backend.open() @@ -1666,7 +1732,7 @@ def _on_loader_success(self, payload) -> None: def _on_loader_error(self, error: str) -> None: self._append_status(f"Error: {error}") - LOGGER.exception("Failed to start preview") + LOGGER.error("[Loader] error: %s", error, exc_info=True) self._preview_active = False self._loading_active = False self._hide_loading_overlay() From c17e1f22c3293ca90cca860be56392e430d942b1 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 12 Feb 2026 15:00:03 +0100 Subject: [PATCH 09/30] Remove optional transforms from Basler backend Remove the apply_transforms flag and associated transform logic from dlclivegui/cameras/backends/basler_backend.py. This deletes the _apply_crop helper, the attribute initialization for _apply_transforms, and the code that applied rotation and cropping to captured frames, leaving frames unmodified by the backend. Simplifies the Basler backend by delegating any image transforms to upstream/post-processing. --- dlclivegui/cameras/backends/basler_backend.py | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 34c6874..45f25f9 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -32,7 +32,6 @@ def __init__(self, settings): # Optional fast-start hint for probe workers (best-effort; doesn't change behavior yet) self._fast_start: bool = bool(self.ns.get("fast_start", False)) - self._apply_transforms: bool = bool(self.ns.get("apply_transforms", False)) # Stable identity (serial-based). Prefer new namespace; fall back to legacy keys read-only. self._device_id: str | None = None @@ -314,19 +313,6 @@ def _positive_float(value) -> float | None: except Exception: return None - @staticmethod - def _apply_crop(frame: np.ndarray, x0: int, y0: int, x1: int, y1: int) -> np.ndarray: - h, w = frame.shape[:2] - if x1 <= 0: - x1 = w - if y1 <= 0: - y1 = h - x0 = max(0, min(int(x0), w)) - y0 = max(0, min(int(y0), h)) - x1 = max(x0, min(int(x1), w)) - y1 = max(y0, min(int(y1), h)) - return frame[y0:y1, x0:x1] if (x1 > x0 and y1 > y0) else frame - def open(self) -> None: if pylon is None: raise RuntimeError("pypylon is required for the Basler backend but is not installed") @@ -472,22 +458,6 @@ def read(self) -> tuple[np.ndarray, float]: self._actual_width = int(w) self._actual_height = int(h) - # --- Optional transforms --- - if self._apply_transforms: - # Rotation from CameraSettings - rotation = int(getattr(self.settings, "rotation", 0) or 0) - if rotation: - frame = self._rotate(frame, rotation) - - # Crop from CameraSettings - x0 = int(getattr(self.settings, "crop_x0", 0) or 0) - y0 = int(getattr(self.settings, "crop_y0", 0) or 0) - x1 = int(getattr(self.settings, "crop_x1", 0) or 0) - y1 = int(getattr(self.settings, "crop_y1", 0) or 0) - - if x0 or y0 or x1 or y1: - frame = self._apply_crop(frame, x0, y0, x1, y1) - return frame, time.time() def close(self) -> None: From af3564f57bf1253fbde5dfbc5cfc4aa7bf818327 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 12 Feb 2026 15:00:37 +0100 Subject: [PATCH 10/30] Refine crop validation and camera UI commit flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config: tighten CameraSettings crop handling — treat all-zeros as "no crop", allow x1/y1 == 0 to mean "to edge", and only enforce x1>x0 / y1>y0 when x1/y1 are explicitly >0. GUI (camera_config_dialog): remove backend apply_transforms tweak in probe worker; add robust auto-commit behavior to prevent losing/accepting invalid edits when switching selection, adding/removing/reordering cameras or closing the dialog. Introduce _commit_pending_edits() which attempts to apply pending changes (and shows a warning on failure), make _apply_camera_settings() return success as a boolean, and block UI actions when validation fails. Also auto-applies pending settings on OK and ensures previews are stopped appropriately. Main window: ensure camera.properties is a dict and set backend-namespaced properties; set fast_start in backend namespace (and initialize properties safely) instead of the old top-level quick default. These changes improve UX by validating/committing edits proactively and make crop semantics clearer and more flexible. --- dlclivegui/config.py | 18 +++++-- dlclivegui/gui/camera_config_dialog.py | 72 +++++++++++++++++++++++--- dlclivegui/gui/main_window.py | 6 ++- 3 files changed, 85 insertions(+), 11 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 7a247b3..5c14d40 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -85,10 +85,20 @@ def _coerce_gain(cls, v): def _validate_crop(self): for f in ("crop_x0", "crop_y0", "crop_x1", "crop_y1"): setattr(self, f, max(0, int(getattr(self, f)))) - # Optional: if any crop is set, enforce x1>x0 and y1>y0 - if any([self.crop_x0, self.crop_y0, self.crop_x1, self.crop_y1]): - if not (self.crop_x1 > self.crop_x0 and self.crop_y1 > self.crop_y0): - raise ValueError("Invalid crop rectangle: require x1>x0 and y1>y0 when cropping is enabled.") + + # No crop + if self.crop_x0 == self.crop_y0 == self.crop_x1 == self.crop_y1 == 0: + return self + + # Allow x1/y1 == 0 to mean "to edge" + # If x1 is explicitly set (>0), it must be > x0 + if self.crop_x1 > 0 and self.crop_x1 <= self.crop_x0: + raise ValueError("Invalid crop rectangle: require x1 > x0 (or x1=0 for 'to edge').") + + # If y1 is explicitly set (>0), it must be > y0 + if self.crop_y1 > 0 and self.crop_y1 <= self.crop_y0: + raise ValueError("Invalid crop rectangle: require y1 > y0 (or y1=0 for 'to edge').") + return self def get_crop_region(self) -> tuple[int, int, int, int] | None: diff --git a/dlclivegui/gui/camera_config_dialog.py b/dlclivegui/gui/camera_config_dialog.py index 87244c7..54e789e 100644 --- a/dlclivegui/gui/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config_dialog.py @@ -166,10 +166,6 @@ def __init__(self, cam: CameraSettings, parent: QWidget | None = None): ns = self._cam.properties.setdefault(self._cam.backend.lower(), {}) if isinstance(ns, dict): ns["fast_start"] = False - # Basler implements transforms in the backend - # but preview already takes care of rotation/crop - # so we disable transform application in probe to avoid double transforms and speed up probe - ns["apply_transforms"] = False def request_cancel(self): self._cancel = True @@ -975,10 +971,25 @@ def _on_available_camera_double_clicked(self, item: QListWidgetItem) -> None: self._add_selected_camera() def _on_active_camera_selected(self, row: int) -> None: + prev_row = self._current_edit_index + + # If switching away from a previous camera, commit pending edits first + if prev_row is not None and prev_row != row: + if not self._commit_pending_edits(reason="before switching camera selection"): + # Revert selection back to previous row so the user stays on the invalid camera + try: + self.active_cameras_list.blockSignals(True) + self.active_cameras_list.setCurrentRow(prev_row) + finally: + self.active_cameras_list.blockSignals(False) + return + # Stop any running preview when selection changes if self._preview_active: self._stop_preview() + self._current_edit_index = row + self._update_button_states() if row < 0 or row >= self.active_cameras_list.count(): self._clear_settings_form() @@ -1301,6 +1312,8 @@ def _on_probe_finished(self) -> None: self._probe_worker = None def _add_selected_camera(self) -> None: + if not self._commit_pending_edits(reason="before adding a new camera"): + return row = self.available_cameras_list.currentRow() if row < 0: return @@ -1356,6 +1369,8 @@ def _add_selected_camera(self) -> None: self._start_probe_for_camera(new_cam) def _remove_selected_camera(self) -> None: + if not self._commit_pending_edits(reason="before removing a camera"): + return row = self.active_cameras_list.currentRow() if row < 0: return @@ -1368,6 +1383,8 @@ def _remove_selected_camera(self) -> None: self._update_button_states() def _move_camera_up(self) -> None: + if not self._commit_pending_edits(reason="before reordering cameras"): + return row = self.active_cameras_list.currentRow() if row <= 0: return @@ -1379,6 +1396,8 @@ def _move_camera_up(self) -> None: self._refresh_camera_labels() def _move_camera_down(self) -> None: + if not self._commit_pending_edits(reason="before reordering cameras"): + return row = self.active_cameras_list.currentRow() if row < 0 or row >= self.active_cameras_list.count() - 1: return @@ -1389,10 +1408,39 @@ def _move_camera_down(self) -> None: cams[row], cams[row + 1] = cams[row + 1], cams[row] self._refresh_camera_labels() - def _apply_camera_settings(self) -> None: + def _commit_pending_edits(self, *, reason: str = "") -> bool: + """ + Auto-apply pending edits (if any) before context-changing actions. + Returns True if it's safe to proceed, False if validation failed. + """ + # No selection → nothing to commit + if self._current_edit_index is None or self._current_edit_index < 0: + return True + + # If Apply button isn't enabled, assume no pending edits + if not self.apply_settings_btn.isEnabled(): + return True + + try: + self._append_status(f"[Auto-Apply] Committing pending edits ({reason})…") + ok = self._apply_camera_settings() + return bool(ok) + except Exception as exc: + # _apply_camera_settings already shows a QMessageBox in many cases, + # but we add a clear guardrail here in case it doesn't. + QMessageBox.warning( + self, + "Unsaved / Invalid Settings", + "Your current camera settings are not valid and cannot be applied yet.\n\n" + "Please fix the highlighted fields (e.g. crop rectangle) or press Reset.\n\n" + f"Details: {exc}", + ) + return False + + def _apply_camera_settings(self) -> bool: if self._loading_active: self._append_status("[Apply] Preview is loading; please wait or cancel loading first.") - return + return False try: for sb in ( self.cam_fps, @@ -1487,9 +1535,12 @@ def _cam_diff(old: CameraSettings, new: CameraSettings) -> dict: else: self._append_status("[Apply] Applied without restart (crop/rotation update is live).") + return True + except Exception as exc: LOGGER.exception("Apply camera settings failed") QMessageBox.warning(self, "Apply Settings Error", str(exc)) + return False def _update_button_states(self) -> None: active_row = self.active_cameras_list.currentRow() @@ -1503,6 +1554,15 @@ def _update_button_states(self) -> None: self.add_camera_btn.setEnabled(available_row >= 0) def _on_ok_clicked(self) -> None: + # Auto-apply pending edits before saving + if not self._commit_pending_edits(reason="before going back to the main window"): + return + try: + if self.apply_settings_btn.isEnabled(): + self._append_status("[OK button] Auto-applying pending settings before closing dialog.") + self._apply_camera_settings() + except Exception: + LOGGER.exception("[OK button] Auto-apply failed") self._stop_preview() active = self._working_settings.get_active_cameras() if self._working_settings.cameras and not active: diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index e982e41..92b94c9 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1454,7 +1454,11 @@ def _start_preview(self) -> None: # Store active settings for single camera mode (for DLC, recording frame rate, etc.) self._active_camera_settings = active_cams[0] if active_cams else None for cam in active_cams: - cam.properties.setdefault("fast_start", True) + if not isinstance(cam.properties, dict): + cam.properties = {} + ns = cam.properties.setdefault((cam.backend or "").lower(), {}) + if isinstance(ns, dict): + ns["fast_start"] = False self.multi_camera_controller.start(active_cams) self._update_inference_buttons() From 55e5c5249d52f01a2e4de22e477a04c1d764a56a Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 12 Feb 2026 15:05:58 +0100 Subject: [PATCH 11/30] Show dirty state for Apply Settings button Add a visual "dirty" state for the Apply Settings button to make unapplied camera config edits more visible. Introduces _set_apply_dirty to toggle button text, icon and tooltip, and a _mark_dirty handler that is connected to various input signals (valueChanged, currentIndexChanged, stateChanged). Ensure the dirty/state is cleared when populating settings and after a successful apply, and unify/replace previous ad-hoc enable calls so the button reliably reflects pending edits. --- dlclivegui/gui/camera_config_dialog.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/dlclivegui/gui/camera_config_dialog.py b/dlclivegui/gui/camera_config_dialog.py index 54e789e..03527aa 100644 --- a/dlclivegui/gui/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config_dialog.py @@ -785,6 +785,17 @@ def _camera_identity_key(self, cam: CameraSettings) -> tuple: return (backend, "device_id", device_id) return (backend, "index", int(cam.index)) + def _set_apply_dirty(self, dirty: bool) -> None: + """Visually mark Apply Settings button as 'dirty' (pending edits).""" + if dirty: + self.apply_settings_btn.setText("Apply Settings *") + self.apply_settings_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxWarning)) + self.apply_settings_btn.setToolTip("You have unapplied changes. Click to apply them.") + else: + self.apply_settings_btn.setText("Apply Settings") + self.apply_settings_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton)) + self.apply_settings_btn.setToolTip("") + # ------------------------------- # Signals / population # ------------------------------- @@ -805,6 +816,11 @@ def _connect_signals(self) -> None: self.cancel_btn.clicked.connect(self.reject) self.scan_started.connect(lambda _: setattr(self, "_dialog_active", True)) self.scan_finished.connect(lambda: setattr(self, "_dialog_active", False)) + + def _mark_dirty(*_args): + self.apply_settings_btn.setEnabled(True) + self._set_apply_dirty(True) + for sb in ( self.cam_fps, self.cam_crop_x0, @@ -815,7 +831,10 @@ def _connect_signals(self) -> None: self.cam_height, ): if hasattr(sb, "valueChanged"): - sb.valueChanged.connect(lambda _=None: self.apply_settings_btn.setEnabled(True)) + sb.valueChanged.connect(_mark_dirty) + + self.cam_rotation.currentIndexChanged.connect(lambda *_: _mark_dirty()) + self.cam_enabled_checkbox.stateChanged.connect(lambda *_: _mark_dirty()) self.cam_rotation.currentIndexChanged.connect(lambda _: self.apply_settings_btn.setEnabled(True)) def _populate_from_settings(self) -> None: @@ -1107,6 +1126,8 @@ def _load_camera_to_form(self, cam: CameraSettings) -> None: self.cam_crop_y1.setValue(cam.crop_y1) self.apply_settings_btn.setEnabled(True) self._set_detected_labels(cam) + self.apply_settings_btn.setEnabled(False) + self._set_apply_dirty(False) def _write_form_to_cam(self, cam: CameraSettings) -> None: cam.enabled = bool(self.cam_enabled_checkbox.isChecked()) @@ -1535,6 +1556,8 @@ def _cam_diff(old: CameraSettings, new: CameraSettings) -> dict: else: self._append_status("[Apply] Applied without restart (crop/rotation update is live).") + self.apply_settings_btn.setEnabled(False) + self._set_apply_dirty(False) return True except Exception as exc: From 4f776555d63114669abdd2057b7f4cb875d55537 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 12 Feb 2026 16:21:31 +0100 Subject: [PATCH 12/30] Basler: better exposure/gain and stream reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Basler backend: simplify and harden exposure/gain configuration by only applying positive values, turning off auto modes when available, and logging failures as warnings. Harden stream lifecycle for previews by stopping grabbing if needed, creating the ImageFormatConverter before StartGrabbing, forcing MaxNumBuffer, starting grabbing reliably and logging grab state; also wrap StopGrabbing on shutdown to ignore errors. Camera config UI: refine property merge and UI behavior — avoid reloading the form after merge, safely refresh camera labels by guarding null lists and blocking signals, ignore redundant selection events, and add explicit preview restart/start helpers (_restart_preview_for_camera and _start_preview_with_camera). Ensure previews never use fast_start mode and improve logging around selection and preview startup. --- dlclivegui/cameras/backends/basler_backend.py | 65 +++++++++---- dlclivegui/gui/camera_config_dialog.py | 95 +++++++++++++++++-- 2 files changed, 132 insertions(+), 28 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 45f25f9..671c973 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -329,27 +329,25 @@ def open(self) -> None: self._camera = pylon.InstantCamera(pylon.TlFactory.GetInstance().CreateDevice(device)) self._camera.Open() - # ---------------------------- - # Exposure (0 = Auto → do not set) - # ---------------------------- - exposure = self._positive_float(getattr(self.settings, "exposure", 0)) - - if exposure is not None: + # Exposure + if getattr(self.settings, "exposure", 0) > 0: try: - self._camera.ExposureTime.SetValue(exposure) - except Exception: - LOG.debug("ExposureTime not writable or not supported", exc_info=True) - - # ---------------------------- - # Gain (0 = Auto → do not set) - # ---------------------------- - gain = self._positive_float(getattr(self.settings, "gain", 0)) - - if gain is not None: + if hasattr(self._camera, "ExposureAuto"): + self._camera.ExposureAuto.SetValue("Off") + self._camera.ExposureTime.SetValue(float(self.settings.exposure)) + LOG.info("[Basler] Exposure set to %s us (auto off)", self.settings.exposure) + except Exception as exc: + LOG.warning("[Basler] Failed to set exposure: %s", exc) + + # Gain + if getattr(self.settings, "gain", 0.0) > 0.0: try: - self._camera.Gain.SetValue(gain) - except Exception: - LOG.debug("Gain not writable or not supported", exc_info=True) + if hasattr(self._camera, "GainAuto"): + self._camera.GainAuto.SetValue("Off") + self._camera.Gain.SetValue(float(self.settings.gain)) + LOG.info("[Basler] Gain set to %s dB (auto off)", self.settings.gain) + except Exception as exc: + LOG.warning("[Basler] Failed to set gain: %s", exc) # ---------------------------- # Resolution (None → device default) @@ -403,11 +401,33 @@ def open(self) -> None: # Start acquisition (skip for fast probe) # ---------------------------- if not self._fast_start: - self._camera.StartGrabbing(pylon.GrabStrategy_LatestImageOnly) + # --- HARD RESET of stream state (critical after fast-start probe) --- + try: + if hasattr(self._camera, "StopGrabbing") and self._camera.IsGrabbing(): + self._camera.StopGrabbing() + except Exception: + pass + # Converter BEFORE StartGrabbing self._converter = pylon.ImageFormatConverter() self._converter.OutputPixelFormat = pylon.PixelType_BGR8packed self._converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned + + # Force stream configuration reset + try: + if hasattr(self._camera, "MaxNumBuffer"): + self._camera.MaxNumBuffer.SetValue(10) + except Exception: + pass + + self._camera.StartGrabbing( + pylon.GrabStrategy_LatestImageOnly, + ) + LOG.info( + "[Basler] grabbing=%s max_buffers=%s", + self._camera.IsGrabbing(), + self._camera.MaxNumBuffer.GetValue() if hasattr(self._camera, "MaxNumBuffer") else "N/A", + ) else: LOG.debug("Fast-start probe: skipping StartGrabbing and converter") @@ -469,7 +489,10 @@ def close(self) -> None: ) if self._camera is not None: if self._camera.IsGrabbing(): - self._camera.StopGrabbing() + try: + self._camera.StopGrabbing() + except Exception: + pass if self._camera.IsOpen(): self._camera.Close() self._camera = None diff --git a/dlclivegui/gui/camera_config_dialog.py b/dlclivegui/gui/camera_config_dialog.py index 03527aa..be27baa 100644 --- a/dlclivegui/gui/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config_dialog.py @@ -298,11 +298,9 @@ def _merge_backend_settings_back(self, opened_settings: CameraSettings) -> None: except Exception: pass - # Merge properties (especially stable IDs) back if isinstance(opened_settings.properties, dict): if not isinstance(target.properties, dict): target.properties = {} - # shallow merge is ok; backend namespaces are nested dicts for k, v in opened_settings.properties.items(): if isinstance(v, dict) and isinstance(target.properties.get(k), dict): target.properties[k].update(v) @@ -311,7 +309,6 @@ def _merge_backend_settings_back(self, opened_settings: CameraSettings) -> None: # Update UI list item text to reflect any changes self._update_active_list_item(row, target) - self._load_camera_to_form(target) # ------------------------------- # UI setup @@ -858,12 +855,18 @@ def _format_camera_label(self, cam: CameraSettings, index: int = -1) -> str: def _refresh_camera_labels(self) -> None: cam_list = getattr(self, "active_cameras_list", None) - if cam_list: + if not cam_list: + return + + cam_list.blockSignals(True) # prevent unwanted selection change events during update + try: for i in range(cam_list.count()): item = cam_list.item(i) cam = item.data(Qt.ItemDataRole.UserRole) if cam: item.setText(self._format_camera_label(cam, i)) + finally: + cam_list.blockSignals(False) def _on_backend_changed(self, _index: int) -> None: self._refresh_available_cameras() @@ -990,8 +993,20 @@ def _on_available_camera_double_clicked(self, item: QListWidgetItem) -> None: self._add_selected_camera() def _on_active_camera_selected(self, row: int) -> None: + LOGGER.info( + "[Select] row=%s prev=%s preview_active=%s loading_active=%s", + row, + self._current_edit_index, + self._preview_active, + self._loading_active, + ) prev_row = self._current_edit_index + # If row is the same, ignore + if prev_row is not None and prev_row == row: + LOGGER.debug("[Selection] Redundant currentRowChanged to same index %d; ignoring.", row) + return + # If switching away from a previous camera, commit pending edits first if prev_row is not None and prev_row != row: if not self._commit_pending_edits(reason="before switching camera selection"): @@ -1551,8 +1566,7 @@ def _cam_diff(old: CameraSettings, new: CameraSettings) -> dict: if self._preview_active: if restart: self._append_status("[Apply] Restarting preview to apply camera settings…") - self._stop_preview() - QTimer.singleShot(100, self._start_preview) # small delay for drivers (Basler/Pylon) + QTimer.singleShot(0, lambda cam=new_model: self._restart_preview_for_camera(cam)) else: self._append_status("[Apply] Applied without restart (crop/rotation update is live).") @@ -1627,10 +1641,72 @@ def _toggle_preview(self) -> None: else: self._start_preview() + def _restart_preview_for_camera(self, cam: CameraSettings) -> None: + """Restart preview for a specific camera, independent of UI selection.""" + LOGGER.info( + "[Preview] restarting explicitly for backend=%s idx=%s", + cam.backend, + cam.index, + ) + + # Stop any running preview cleanly + self._stop_preview() + + # Force preview-safe backend flags + if isinstance(cam.properties, dict): + ns = cam.properties.setdefault((cam.backend or "").lower(), {}) + if isinstance(ns, dict): + ns["fast_start"] = False + + # Start preview without relying on selection state + self._start_preview_with_camera(cam) + + def _start_preview_with_camera(self, cam: CameraSettings) -> None: + """Start preview for a given CameraSettings object.""" + LOGGER.info( + "[Preview] start (explicit) backend=%s idx=%s name=%s", + cam.backend, + cam.index, + cam.name, + ) + + # Create loader directly from camera + self._loader = CameraLoadWorker(cam, self) + self._loader.progress.connect(self._on_loader_progress) + self._loader.success.connect(self._on_loader_success) + self._loader.error.connect(self._on_loader_error) + self._loader.canceled.connect(self._on_loader_canceled) + self._loader.finished.connect(self._on_loader_finished) + + self._loading_active = True + self._update_button_states() + + # Prepare UI + self.preview_group.setVisible(True) + self.preview_label.setText("No preview") + self.preview_status.clear() + self._show_loading_overlay("Loading camera…") + self._set_preview_button_loading(True) + + self._loader.start() + def _start_preview(self) -> None: """Start camera preview asynchronously (no UI freeze).""" - if self._current_edit_index is None or self._current_edit_index < 0: + row = self._current_edit_index + if row is None or row < 0: + row = self.active_cameras_list.currentRow() + + if row is None or row < 0: + LOGGER.warning("[Preview] No camera selected to start preview.") return + + self._current_edit_index = row + LOGGER.info( + "[Preview] resolved start row=%s active_row=%s", + self._current_edit_index, + self.active_cameras_list.currentRow(), + ) + item = self.active_cameras_list.item(self._current_edit_index) if not item: return @@ -1651,6 +1727,11 @@ def _start_preview(self) -> None: self._stop_preview() # if self._loader and self._loader.isRunning(): # self._loader.request_cancel() + # Never use probe or fast_start mode + if isinstance(cam.properties, dict): + ns = cam.properties.get((cam.backend or "").lower(), {}) + if isinstance(ns, dict): + ns["fast_start"] = False # Create worker self._loader = CameraLoadWorker(cam, self) self._loader.progress.connect(self._on_loader_progress) From ff797b2f3f134d4f68617195d487ee28c6520a6a Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 12 Feb 2026 16:40:57 +0100 Subject: [PATCH 13/30] Suppress selection side-effects and block signals Avoid unwanted UI-driven side-effects when programmatically updating or selecting cameras. Add suppression flags to skip selection handlers/ actions during updates and preview startup, and wrap preview start/stop in a try/finally to restore suppression. Block widget signals while loading camera settings to prevent spurious change events, add exposure/gain to the form field groups, and skip probing when a preview is active. Also add a few diagnostic logs to help trace selection/preview behavior. --- dlclivegui/gui/camera_config_dialog.py | 131 +++++++++++++++++-------- 1 file changed, 90 insertions(+), 41 deletions(-) diff --git a/dlclivegui/gui/camera_config_dialog.py b/dlclivegui/gui/camera_config_dialog.py index be27baa..9a11fff 100644 --- a/dlclivegui/gui/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config_dialog.py @@ -225,6 +225,7 @@ def __init__( self._probe_apply_to_requested: bool = False self._probe_target_row: int | None = None self._current_edit_index: int | None = None + self._suppress_selection_actions: bool = False # Preview state self._preview_backend: CameraBackend | None = None @@ -729,6 +730,8 @@ def eventFilter(self, obj, event): self.cam_fps, self.cam_width, self.cam_height, + self.cam_exposure, + self.cam_gain, self.cam_crop_x0, self.cam_crop_y0, self.cam_crop_x1, @@ -824,6 +827,8 @@ def _mark_dirty(*_args): self.cam_crop_y0, self.cam_crop_x1, self.cam_crop_y1, + self.cam_exposure, + self.cam_gain, self.cam_width, self.cam_height, ): @@ -993,14 +998,24 @@ def _on_available_camera_double_clicked(self, item: QListWidgetItem) -> None: self._add_selected_camera() def _on_active_camera_selected(self, row: int) -> None: + if getattr(self, "_suppress_selection_change", False): + LOGGER.debug("[Selection] Suppressed currentRowChanged event at index %d.", row) + return + prev_row = self._current_edit_index LOGGER.info( "[Select] row=%s prev=%s preview_active=%s loading_active=%s", row, - self._current_edit_index, + prev_row, self._preview_active, self._loading_active, ) - prev_row = self._current_edit_index + if row is None or row < 0: + LOGGER.debug( + "[Selection] row<0 (selection cleared) ignored to avoid" + " stopping preview/loading when clicking away. row=%s", + row, + ) + return # If row is the same, ignore if prev_row is not None and prev_row == row: @@ -1111,36 +1126,62 @@ def _update_active_list_item(self, row: int, cam: CameraSettings) -> None: item = self.active_cameras_list.item(row) if not item: return - item.setText(self._format_camera_label(cam, row)) - item.setData(Qt.ItemDataRole.UserRole, cam) - item.setForeground(Qt.GlobalColor.gray if not cam.enabled else Qt.GlobalColor.black) - self._refresh_camera_labels() - self._update_button_states() + self._suppress_selection_change = True # prevent unwanted selection change events during update + try: + item.setText(self._format_camera_label(cam, row)) + item.setData(Qt.ItemDataRole.UserRole, cam) + item.setForeground(Qt.GlobalColor.gray if not cam.enabled else Qt.GlobalColor.black) + self._refresh_camera_labels() + self._update_button_states() + finally: + self._suppress_selection_change = False def _load_camera_to_form(self, cam: CameraSettings) -> None: - backend = (cam.backend or "").lower() - props = cam.properties if isinstance(cam.properties, dict) else {} - ns = props.get(backend, {}) if isinstance(props, dict) else {} - self.cam_enabled_checkbox.setChecked(cam.enabled) - self.cam_name_label.setText(cam.name) - self.cam_device_name_label.setText(str(ns.get("device_id", ""))) - self.cam_index_label.setText(str(cam.index)) - self.cam_backend_label.setText(cam.backend) - self._update_controls_for_backend(cam.backend) - self.cam_width.setValue(cam.width) - self.cam_height.setValue(cam.height) - self.cam_fps.setValue(cam.fps) - self.cam_exposure.setValue(cam.exposure) - self.cam_gain.setValue(cam.gain) - rot_index = self.cam_rotation.findData(cam.rotation) - if rot_index >= 0: - self.cam_rotation.setCurrentIndex(rot_index) - self.cam_crop_x0.setValue(cam.crop_x0) - self.cam_crop_y0.setValue(cam.crop_y0) - self.cam_crop_x1.setValue(cam.crop_x1) - self.cam_crop_y1.setValue(cam.crop_y1) - self.apply_settings_btn.setEnabled(True) - self._set_detected_labels(cam) + block = [ + self.cam_enabled_checkbox, + self.cam_width, + self.cam_height, + self.cam_fps, + self.cam_exposure, + self.cam_gain, + self.cam_rotation, + self.cam_crop_x0, + self.cam_crop_y0, + self.cam_crop_x1, + self.cam_crop_y1, + ] + for widget in block: + if hasattr(widget, "blockSignals"): + widget.blockSignals(True) + try: + backend = (cam.backend or "").lower() + props = cam.properties if isinstance(cam.properties, dict) else {} + ns = props.get(backend, {}) if isinstance(props, dict) else {} + self.cam_enabled_checkbox.setChecked(cam.enabled) + self.cam_name_label.setText(cam.name) + self.cam_device_name_label.setText(str(ns.get("device_id", ""))) + self.cam_index_label.setText(str(cam.index)) + self.cam_backend_label.setText(cam.backend) + self._update_controls_for_backend(cam.backend) + self.cam_width.setValue(cam.width) + self.cam_height.setValue(cam.height) + self.cam_fps.setValue(cam.fps) + self.cam_exposure.setValue(cam.exposure) + self.cam_gain.setValue(cam.gain) + rot_index = self.cam_rotation.findData(cam.rotation) + if rot_index >= 0: + self.cam_rotation.setCurrentIndex(rot_index) + self.cam_crop_x0.setValue(cam.crop_x0) + self.cam_crop_y0.setValue(cam.crop_y0) + self.cam_crop_x1.setValue(cam.crop_x1) + self.cam_crop_y1.setValue(cam.crop_y1) + self.apply_settings_btn.setEnabled(True) + self._set_detected_labels(cam) + finally: + for widget in block: + if hasattr(widget, "blockSignals"): + widget.blockSignals(False) + self.apply_settings_btn.setEnabled(False) self._set_apply_dirty(False) @@ -1185,7 +1226,7 @@ def _start_probe_for_camera(self, cam: CameraSettings, *, apply_to_requested: bo requested width/height/fps with detected device values. """ # Don’t probe if preview is active/loading - if self._loading_active: + if self._loading_active or self._preview_active: return # Track probe intent @@ -1483,6 +1524,8 @@ def _apply_camera_settings(self) -> bool: self.cam_crop_x0, self.cam_width, self.cam_height, + self.cam_exposure, + self.cam_gain, self.cam_crop_y0, self.cam_crop_x1, self.cam_crop_y1, @@ -1648,18 +1691,21 @@ def _restart_preview_for_camera(self, cam: CameraSettings) -> None: cam.backend, cam.index, ) + self._suppress_selection_actions = True + try: + # Stop any running preview cleanly + self._stop_preview() - # Stop any running preview cleanly - self._stop_preview() - - # Force preview-safe backend flags - if isinstance(cam.properties, dict): - ns = cam.properties.setdefault((cam.backend or "").lower(), {}) - if isinstance(ns, dict): - ns["fast_start"] = False + # Force preview-safe backend flags + if isinstance(cam.properties, dict): + ns = cam.properties.setdefault((cam.backend or "").lower(), {}) + if isinstance(ns, dict): + ns["fast_start"] = False - # Start preview without relying on selection state - self._start_preview_with_camera(cam) + # Start preview without relying on selection state + self._start_preview_with_camera(cam) + finally: + self._suppress_selection_actions = False def _start_preview_with_camera(self, cam: CameraSettings) -> None: """Start preview for a given CameraSettings object.""" @@ -1759,6 +1805,9 @@ def _stop_preview(self) -> None: self._preview_active, getattr(getattr(self._preview_backend, "settings", None), "backend", None), ) + # Also show traceback to see who called stop_preview, + # since this should only be called from a few places. + # LOGGER.debug("[Preview] stop_preview called from: %s", "".join(traceback.format_stack(limit=6))) # Cancel loader if running if self._loader and self._loader.isRunning(): self._loader.request_cancel() From 4404f78cc81025663b9509ad59828cd66f8cd875 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 12 Feb 2026 17:29:42 +0100 Subject: [PATCH 14/30] Robust GUI test teardown; block QMessageBox Improve camera dialog test fixture to show the dialog, yield it to tests, and perform a robust teardown: stop preview, reject/close the dialog, and wait for loader/scan worker threads and preview state to clear. Also add an autouse fixture that monkeypatches QMessageBox methods to raise on any unexpected modal dialog, preventing tests from hanging due to blocking message boxes. Small formatting/inline cleanup of MultiCameraSettings initialization included. --- .../gui/camera_config/test_cam_dialog_e2e.py | 27 ++++++++++++----- .../gui/camera_config/test_cam_dialog_unit.py | 29 +++++++++++++++++-- tests/gui/conftest.py | 20 +++++++++++++ 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/tests/gui/camera_config/test_cam_dialog_e2e.py b/tests/gui/camera_config/test_cam_dialog_e2e.py index 9e23f04..7d63872 100644 --- a/tests/gui/camera_config/test_cam_dialog_e2e.py +++ b/tests/gui/camera_config/test_cam_dialog_e2e.py @@ -47,19 +47,32 @@ def patch_factory(monkeypatch): @pytest.fixture def dialog(qtbot, patch_factory): - s = MultiCameraSettings( - cameras=[ - CameraSettings(name="A", backend="opencv", index=0, enabled=True), - ] - ) + s = MultiCameraSettings(cameras=[CameraSettings(name="A", backend="opencv", index=0, enabled=True)]) d = CameraConfigDialog(None, s) qtbot.addWidget(d) - return d + d.show() + qtbot.waitExposed(d) + yield d -# ---------------- End‑to‑End tests ---------------- + # --- robust teardown --- + try: + d._stop_preview() + except Exception: + pass + try: + d.reject() # calls _stop_preview + cancels scan worker + except Exception: + d.close() + # wait for threads to stop + qtbot.waitUntil(lambda: getattr(d, "_loader", None) is None, timeout=2000) + qtbot.waitUntil(lambda: getattr(d, "_scan_worker", None) is None, timeout=2000) + qtbot.waitUntil(lambda: not getattr(d, "_preview_active", False), timeout=2000) + + +# ---------------- End‑to‑End tests ---------------- def test_e2e_async_camera_scan(dialog, qtbot): qtbot.mouseClick(dialog.refresh_btn, Qt.LeftButton) diff --git a/tests/gui/camera_config/test_cam_dialog_unit.py b/tests/gui/camera_config/test_cam_dialog_unit.py index ecf543c..c8a60fc 100644 --- a/tests/gui/camera_config/test_cam_dialog_unit.py +++ b/tests/gui/camera_config/test_cam_dialog_unit.py @@ -1,17 +1,30 @@ # tests/gui/camera_config/test_cam_dialog_unit.py from __future__ import annotations +import numpy as np import pytest from PySide6.QtCore import Qt +from dlclivegui.cameras import CameraFactory +from dlclivegui.cameras.base import CameraBackend from dlclivegui.cameras.factory import DetectedCamera from dlclivegui.config import CameraSettings, MultiCameraSettings from dlclivegui.gui.camera_config_dialog import CameraConfigDialog +class FakeBackend(CameraBackend): + def open(self): + pass + + def close(self): + pass + + def read(self): + return np.zeros((10, 10, 3), dtype=np.uint8), 0.0 + + @pytest.fixture def dialog(qtbot, monkeypatch): - # Patch detect_cameras to avoid hardware access monkeypatch.setattr( "dlclivegui.cameras.CameraFactory.detect_cameras", lambda backend, max_devices=10, **kw: [ @@ -19,6 +32,10 @@ def dialog(qtbot, monkeypatch): DetectedCamera(index=1, label=f"{backend}-Y"), ], ) + monkeypatch.setattr(CameraFactory, "create", lambda s: FakeBackend(s)) + + # Optional: prevent probe from running at all in pure unit tests + monkeypatch.setattr(CameraConfigDialog, "_start_probe_for_camera", lambda *a, **k: None) s = MultiCameraSettings( cameras=[ @@ -28,7 +45,15 @@ def dialog(qtbot, monkeypatch): ) d = CameraConfigDialog(None, s) qtbot.addWidget(d) - return d + d.show() + qtbot.waitExposed(d) + + yield d + + try: + d.reject() + except Exception: + d.close() # ---------------------- UNIT TESTS ---------------------- diff --git a/tests/gui/conftest.py b/tests/gui/conftest.py index 2e8ff16..3e0c17c 100644 --- a/tests/gui/conftest.py +++ b/tests/gui/conftest.py @@ -2,6 +2,7 @@ from __future__ import annotations import pytest +from PySide6.QtWidgets import QMessageBox from dlclivegui.cameras.factory import CameraFactory from dlclivegui.config import CameraSettings @@ -73,3 +74,22 @@ def _isolate_qsettings(tmp_path): s.sync() yield + + +@pytest.fixture(autouse=True) +def no_modal_messageboxes(monkeypatch): + """ + Fail fast if a QMessageBox is shown unexpectedly. + This prevents teardown hangs caused by modal dialogs. + """ + + def _report(*args, **kwargs): + # args often: (parent, title, text, ...) + title = args[1] if len(args) > 1 else "" + text = args[2] if len(args) > 2 else "" + raise AssertionError(f"Unexpected QMessageBox: {title}\n{text}") + + monkeypatch.setattr(QMessageBox, "warning", staticmethod(_report)) + monkeypatch.setattr(QMessageBox, "critical", staticmethod(_report)) + monkeypatch.setattr(QMessageBox, "information", staticmethod(_report)) + monkeypatch.setattr(QMessageBox, "question", staticmethod(_report)) From abed9d1e3d8525c701bdba8c92778a72babf4f96 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 12 Feb 2026 17:53:10 +0100 Subject: [PATCH 15/30] Cancel probe worker and copy QImage Ensure the probe worker is cleanly stopped when shutting down: call request_cancel(), wait (up to 1500ms), and clear the _probe_worker reference to avoid dangling threads. Also make a copy of the QImage created from the frame buffer (QImage(...).copy()) so the Qt image doesn't reference the underlying frame memory, preventing use-after-free crashes or visual corruption. --- dlclivegui/gui/camera_config_dialog.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/dlclivegui/gui/camera_config_dialog.py b/dlclivegui/gui/camera_config_dialog.py index 9a11fff..cbd5c36 100644 --- a/dlclivegui/gui/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config_dialog.py @@ -1662,6 +1662,13 @@ def reject(self) -> None: pass self._scan_worker.wait(1500) self._scan_worker = None + if getattr(self, "_probe_worker", None) and self._probe_worker.isRunning(): + try: + self._probe_worker.request_cancel() + except Exception: + pass + self._probe_worker.wait(1500) + self._probe_worker = None self._hide_scan_overlay() self.scan_progress.setVisible(False) @@ -2027,7 +2034,7 @@ def _update_preview(self) -> None: h, w, ch = frame.shape bytes_per_line = ch * w - q_img = QImage(frame.data, w, h, bytes_per_line, QImage.Format.Format_RGB888) + q_img = QImage(frame.data, w, h, bytes_per_line, QImage.Format.Format_RGB888).copy() self.preview_label.setPixmap(QPixmap.fromImage(q_img)) except Exception as exc: From 6db3f7414a965a44a9ba4267968f25b274fd65fe Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 12 Feb 2026 19:30:11 +0100 Subject: [PATCH 16/30] Improve preview handling and refactor tests Camera dialog: tighten preview lifecycle and disabled-control handling. Introduces a preview-starting flag, commits pending edits before starting preview, consolidates loader/start/stop flow, and treats exposure/gain as 0 when their controls are disabled so they won't trigger unnecessary restarts. Also fixes various preview UI/loader state transitions and button states during async start/stop. Tests: large refactor of test fixtures to provide deterministic fake backends and DLCLive doubles. Adds reusable test backend helpers (make_backend_class, temp_backend, register_fake_backend_session), a stable fake_backend_factory, and simplifies GUI autouse patches. Expands and reorganizes unit and end-to-end camera-config tests to cover preview start/stop, restart/no-restart semantics, scan cancellation, duplicate/max-camera guards, commit-on-select/OK behavior, crop validation, and backend capability handling. Updates gui conftest to use the new fake backend and DLCLive test doubles. --- dlclivegui/gui/camera_config_dialog.py | 126 ++++--- tests/conftest.py | 312 ++++++++++------ .../gui/camera_config/test_cam_dialog_e2e.py | 276 ++++++++++++-- .../gui/camera_config/test_cam_dialog_unit.py | 337 +++++++++++++----- tests/gui/conftest.py | 19 +- 5 files changed, 785 insertions(+), 285 deletions(-) diff --git a/dlclivegui/gui/camera_config_dialog.py b/dlclivegui/gui/camera_config_dialog.py index cbd5c36..0fa84b2 100644 --- a/dlclivegui/gui/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config_dialog.py @@ -231,6 +231,7 @@ def __init__( self._preview_backend: CameraBackend | None = None self._preview_timer: QTimer | None = None self._preview_active: bool = False + self._preview_starting: bool = False # Camera detection worker self._scan_worker: DetectCamerasWorker | None = None @@ -271,8 +272,8 @@ def _build_model_from_form(self, base: CameraSettings) -> CameraSettings: "width": int(self.cam_width.value()), "height": int(self.cam_height.value()), "fps": float(self.cam_fps.value()), - "exposure": int(self.cam_exposure.value()), - "gain": float(self.cam_gain.value()), + "exposure": int(self.cam_exposure.value()) if self.cam_exposure.isEnabled() else 0, + "gain": float(self.cam_gain.value()) if self.cam_gain.isEnabled() else 0.0, "rotation": int(self.cam_rotation.currentData() or 0), "crop_x0": int(self.cam_crop_x0.value()), "crop_y0": int(self.cam_crop_y0.value()), @@ -1190,8 +1191,8 @@ def _write_form_to_cam(self, cam: CameraSettings) -> None: cam.width = int(self.cam_width.value()) cam.height = int(self.cam_height.value()) cam.fps = float(self.cam_fps.value()) - cam.exposure = int(self.cam_exposure.value()) - cam.gain = float(self.cam_gain.value()) + cam.exposure = int(self.cam_exposure.value() if self.cam_exposure.isEnabled() else 0) + cam.gain = float(self.cam_gain.value() if self.cam_gain.isEnabled() else 0.0) cam.rotation = int(self.cam_rotation.currentData() or 0) cam.crop_x0 = int(self.cam_crop_x0.value()) cam.crop_y0 = int(self.cam_crop_y0.value()) @@ -1597,6 +1598,11 @@ def _cam_diff(old: CameraSettings, new: CameraSettings) -> dict: restart = False if self._preview_active and isinstance(old_settings, CameraSettings): restart = self._should_restart_preview(old_settings, new_model) + # If the preview is starting but not fully active yet, + # we can skip the restart since the new settings will be picked up on start anyway + if self._preview_active and not getattr(self, "._preview_starting", False): + if restart: + QTimer.singleShot(0, lambda cam=new_model: self._restart_preview_for_camera(cam)) LOGGER.info( "[Apply] preview_active=%s restart=%s backend=%s idx=%s", @@ -1745,64 +1751,72 @@ def _start_preview_with_camera(self, cam: CameraSettings) -> None: def _start_preview(self) -> None: """Start camera preview asynchronously (no UI freeze).""" - row = self._current_edit_index - if row is None or row < 0: - row = self.active_cameras_list.currentRow() - - if row is None or row < 0: - LOGGER.warning("[Preview] No camera selected to start preview.") + if not self._commit_pending_edits(reason="before starting preview"): return + if self._preview_active or self._loading_active: + return + self.starting_preview = True + try: + row = self._current_edit_index + if row is None or row < 0: + row = self.active_cameras_list.currentRow() - self._current_edit_index = row - LOGGER.info( - "[Preview] resolved start row=%s active_row=%s", - self._current_edit_index, - self.active_cameras_list.currentRow(), - ) + if row is None or row < 0: + LOGGER.warning("[Preview] No camera selected to start preview.") + return - item = self.active_cameras_list.item(self._current_edit_index) - if not item: - return - cam = item.data(Qt.ItemDataRole.UserRole) - if not cam: - return - LOGGER.info( - "[Preview] start requested row=%s backend=%s idx=%s name=%s loading=%s active=%s", - self._current_edit_index, - cam.backend, - cam.index, - cam.name, - self._loading_active, - self._preview_active, - ) + self._current_edit_index = row + LOGGER.info( + "[Preview] resolved start row=%s active_row=%s", + self._current_edit_index, + self.active_cameras_list.currentRow(), + ) - # Ensure any existing preview or loader is stopped/canceled - self._stop_preview() - # if self._loader and self._loader.isRunning(): - # self._loader.request_cancel() - # Never use probe or fast_start mode - if isinstance(cam.properties, dict): - ns = cam.properties.get((cam.backend or "").lower(), {}) - if isinstance(ns, dict): - ns["fast_start"] = False - # Create worker - self._loader = CameraLoadWorker(cam, self) - self._loader.progress.connect(self._on_loader_progress) - self._loader.success.connect(self._on_loader_success) - self._loader.error.connect(self._on_loader_error) - self._loader.canceled.connect(self._on_loader_canceled) - self._loader.finished.connect(self._on_loader_finished) - self._loading_active = True - self._update_button_states() + item = self.active_cameras_list.item(self._current_edit_index) + if not item: + return + cam = item.data(Qt.ItemDataRole.UserRole) + if not cam: + return + LOGGER.info( + "[Preview] start requested row=%s backend=%s idx=%s name=%s loading=%s active=%s", + self._current_edit_index, + cam.backend, + cam.index, + cam.name, + self._loading_active, + self._preview_active, + ) - # Prepare UI - self.preview_group.setVisible(True) - self.preview_label.setText("No preview") - self.preview_status.clear() - self._show_loading_overlay("Loading camera…") - self._set_preview_button_loading(True) + # Ensure any existing preview or loader is stopped/canceled + self._stop_preview() + # if self._loader and self._loader.isRunning(): + # self._loader.request_cancel() + # Never use probe or fast_start mode + if isinstance(cam.properties, dict): + ns = cam.properties.get((cam.backend or "").lower(), {}) + if isinstance(ns, dict): + ns["fast_start"] = False + # Create worker + self._loader = CameraLoadWorker(cam, self) + self._loader.progress.connect(self._on_loader_progress) + self._loader.success.connect(self._on_loader_success) + self._loader.error.connect(self._on_loader_error) + self._loader.canceled.connect(self._on_loader_canceled) + self._loader.finished.connect(self._on_loader_finished) + self._loading_active = True + self._update_button_states() - self._loader.start() + # Prepare UI + self.preview_group.setVisible(True) + self.preview_label.setText("No preview") + self.preview_status.clear() + self._show_loading_overlay("Loading camera…") + self._set_preview_button_loading(True) + + self._loader.start() + finally: + self.starting_preview = False def _stop_preview(self) -> None: """Stop camera preview and cancel any ongoing loading.""" diff --git a/tests/conftest.py b/tests/conftest.py index 50b382a..6f3ee40 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,8 @@ from __future__ import annotations import time +from collections.abc import Callable +from contextlib import contextmanager from pathlib import Path import numpy as np @@ -9,7 +11,12 @@ from PySide6.QtCore import Qt from dlclivegui.cameras import CameraFactory -from dlclivegui.cameras.base import CameraBackend +from dlclivegui.cameras.base import ( + CameraBackend, + SupportLevel, + register_backend_direct, + unregister_backend, +) from dlclivegui.config import ( DEFAULT_CONFIG, ApplicationSettings, @@ -19,10 +26,135 @@ ) from dlclivegui.gui.main_window import DLCLiveMainWindow +# --------------------------------------------------------------------- +# Generic backend helpers (removes FakeBackend/temp_backend duplication) +# --------------------------------------------------------------------- + +DEFAULT_TEST_CAPS: dict[str, SupportLevel] = { + "set_resolution": SupportLevel.SUPPORTED, + "set_fps": SupportLevel.SUPPORTED, + "set_exposure": SupportLevel.SUPPORTED, + "set_gain": SupportLevel.SUPPORTED, + "device_discovery": SupportLevel.SUPPORTED, + "stable_identity": SupportLevel.SUPPORTED, +} + + +def make_backend_class( + name: str, + *, + caps: dict[str, SupportLevel] | None = None, + frame_shape: tuple[int, int, int] = (48, 64, 3), + timestamp_fn: Callable[[], float] = time.time, +) -> type[CameraBackend]: + """ + Create a lightweight CameraBackend subclass for tests. + + - caps: static_capabilities returned to the GUI + - frame_shape: deterministic black image returned on read() + """ + caps = dict(caps) if caps is not None else dict(DEFAULT_TEST_CAPS) + + class _TestBackend(CameraBackend): + OPTIONS_KEY = name + + def __init__(self, settings: CameraSettings): + super().__init__(settings) + self._opened = False + self._counter = 0 + + @classmethod + def is_available(cls) -> bool: + return True + + @classmethod + def static_capabilities(cls) -> dict[str, SupportLevel]: + return dict(caps) + + def open(self) -> None: + self._opened = True + + def close(self) -> None: + self._opened = False + + def stop(self) -> None: + # Optional API; no-op for tests + return + + def read(self): + if not self._opened: + raise RuntimeError("not opened") + self._counter += 1 + frame = np.zeros(frame_shape, dtype=np.uint8) + return frame, float(timestamp_fn()) + + _TestBackend.__name__ = f"TestBackend_{name}" + return _TestBackend + + +@contextmanager +def _temp_backend(name: str, *, caps: dict[str, SupportLevel], frame_shape=(10, 10, 3)): + backend_cls = make_backend_class(name, caps=caps, frame_shape=frame_shape) + register_backend_direct(name, backend_cls) + try: + yield backend_cls + finally: + unregister_backend(name) + + +@pytest.fixture +def temp_backend(): + return _temp_backend + + +@pytest.fixture(scope="session", autouse=True) +def register_fake_backend_session(): + """ + Register the "fake" backend once per test session. + Your app config uses backend="fake", so this makes CameraFactory.create work naturally + without monkeypatching CameraFactory.create everywhere. + """ + fake_cls = make_backend_class("fake", caps=DEFAULT_TEST_CAPS, frame_shape=(48, 64, 3)) + register_backend_direct("fake", fake_cls) + try: + tuple(CameraFactory.backend_names()) + except Exception: + pass + try: + yield fake_cls + finally: + unregister_backend("fake") + + +@pytest.fixture(scope="session") +def fake_backend_cls(register_fake_backend_session): + """Return the registered fake backend class.""" + return register_fake_backend_session + + +@pytest.fixture +def fake_backend_factory(fake_backend_cls): + """ + Return a factory(settings) -> backend instance. + Always forces backend='fake' for deterministic identity/caps. + """ + + def _factory(settings: CameraSettings): + try: + s = settings.model_copy(deep=True) + except Exception: + s = settings + s.backend = "fake" + return fake_backend_cls(s) + + return _factory + # --------------------------------------------------------------------- # Test doubles # --------------------------------------------------------------------- + + class FakeDLCLive: """A minimal fake DLCLive object for testing.""" @@ -36,7 +168,6 @@ def init_inference(self, frame): def get_pose(self, frame, frame_time=None): self.pose_calls += 1 - # Deterministic small pose array return np.ones((2, 2), dtype=float) @@ -50,63 +181,15 @@ def _factory(**opts): return _factory -class FakeBackend(CameraBackend): - def __init__(self, settings): - super().__init__(settings) - self._opened = False - self._counter = 0 - - @classmethod - def is_available(cls) -> bool: - return True - - def open(self) -> None: - self._opened = True - - def read(self): - # Produce a deterministic small frame - if not self._opened: - raise RuntimeError("not opened") - self._counter += 1 - frame = np.zeros((48, 64, 3), dtype=np.uint8) - ts = time.time() - return frame, ts - - def close(self) -> None: - self._opened = False - - def stop(self) -> None: - pass - - -@pytest.fixture -def fake_backend_factory(): - """A factory that creates FakeBackend instances.""" - - def _factory(settings): - return FakeBackend(settings) - - return _factory - - -# --------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------- -@pytest.fixture -def patch_factory(monkeypatch): - def _create(settings): - return FakeBackend(settings) - - monkeypatch.setattr(CameraFactory, "create", staticmethod(_create)) - return _create +@pytest.fixture(scope="session") +def FakeDLCLiveClass(): + return FakeDLCLive @pytest.fixture def monkeypatch_dlclive(monkeypatch): """ - Replace the dlclive.DLCLive import with FakeDLCLive *within* the dlc_processor module. - - Scope is function-level by default, which keeps tests isolated. + Replace dlclive.DLCLive import with FakeDLCLive within dlc_processor module. """ from dlclivegui.services import dlc_processor @@ -116,73 +199,87 @@ def monkeypatch_dlclive(monkeypatch): @pytest.fixture def settings_model(): - """A standard Pydantic DLCProcessorSettingsModel for tests.""" + """A standard Pydantic DLCProcessorSettings for tests.""" return DLCProcessorSettings(model_path="dummy.pt") -# ---------- Test helpers: application configuration with two fake cameras ---------- -@pytest.fixture -def app_config_two_cams(tmp_path) -> ApplicationSettings: - """An app config with two enabled cameras (fake backend) and writable recording dir.""" +# --------------------------------------------------------------------- +# Reusable config builder (removes duplication in app_config_* fixtures) +# --------------------------------------------------------------------- + + +def make_app_config( + *, + tmp_path: Path, + num_cams: int = 2, + backend: str = "fake", + enabled: bool = True, + fps: float = 30.0, + max_cameras: int = 4, + tile_layout: str = "auto", + recording_enabled: bool = True, +) -> ApplicationSettings: cfg = ApplicationSettings.from_dict(DEFAULT_CONFIG.to_dict()) - cam_a = CameraSettings(name="CamA", backend="fake", index=0, enabled=True, fps=30.0) - cam_b = CameraSettings(name="CamB", backend="fake", index=1, enabled=True, fps=30.0) + cams: list[CameraSettings] = [] + for i in range(num_cams): + cams.append(CameraSettings(name=f"Cam{i}", backend=backend, index=i, enabled=enabled, fps=fps)) - cfg.multi_camera = MultiCameraSettings(cameras=[cam_a, cam_b], max_cameras=4, tile_layout="auto") - cfg.camera = cam_a # kept for backward-compat single-camera access in UI + cfg.multi_camera = MultiCameraSettings(cameras=cams, max_cameras=max_cameras, tile_layout=tile_layout) + cfg.camera = cams[0] if cams else CameraSettings() # backward compat cfg.recording.directory = str(tmp_path / "videos") - cfg.recording.enabled = True + cfg.recording.enabled = bool(recording_enabled) return cfg -# ---------- The main window fixture ---------- @pytest.fixture -def window(qtbot, app_config_two_cams) -> DLCLiveMainWindow: +def app_config_two_cams(tmp_path) -> ApplicationSettings: + """An app config with two enabled cameras and writable recording dir.""" + return make_app_config(tmp_path=tmp_path, num_cams=2, backend="fake", enabled=True, fps=30.0) + + +# --------------------------------------------------------------------- +# Main window fixture +# --------------------------------------------------------------------- + + +@pytest.fixture +def window(qtbot, app_config_two_cams): """ - Construct the real DLCLiveMainWindow with a valid two-camera config, - make it headless, show it, and yield it. Threads and timers are managed by close(). + Construct the real DLCLiveMainWindow with a valid config, + make it headless, show it, and yield it. """ w = DLCLiveMainWindow(config=app_config_two_cams) qtbot.addWidget(w) - # Don't pop windows in CI: w.setAttribute(Qt.WA_DontShowOnScreen, True) w.show() try: yield w finally: - # The window's closeEvent stops controllers, recorders, timers, etc. - # Use .close() to trigger the standard shutdown path. try: w.close() except Exception: pass +# --------------------------------------------------------------------- +# Drawing / recording helpers (unchanged, but still isolated) +# --------------------------------------------------------------------- + + @pytest.fixture def draw_pose_stub(monkeypatch): """Fake pose drawing that records offset/scale and draws a bright pixel.""" calls = {} - def _stub_draw_pose( - frame, - pose, - p_cutoff=None, - colormap=None, - offset=(0, 0), - scale=(1.0, 1.0), - **_ignored, - ): - # record args passed to draw_pose + def _stub_draw_pose(frame, pose, p_cutoff=None, colormap=None, offset=(0, 0), scale=(1.0, 1.0), **_ignored): calls["offset"] = offset calls["scale"] = scale - # pose format: {"x": int, "y": int} x = pose["x"] y = pose["y"] - ox, oy = offset sx, sy = scale @@ -194,32 +291,22 @@ def _stub_draw_pose( out[yy, xx] = (0, 255, 0) # bright green pixel (BGR) return out - # IMPORTANT: patch draw_pose where main_window imports it import dlclivegui.gui.main_window as mw_mod monkeypatch.setattr(mw_mod, "draw_pose", _stub_draw_pose) - return calls -# ---------- Convenience fixtures that expose controller/processor from the window ---------- @pytest.fixture def multi_camera_controller(window): - """ - Return the *controller used by the window* so tests can wait on all_started/all_stopped. - """ return window.multi_camera_controller @pytest.fixture def dlc_processor(window): - """ - Return the *processor used by the window* so tests can connect to pose/initialized. - """ return window._dlc -# ---------- Monkeypatch RecordingManager start_all to capture args and return fake path ---------- @pytest.fixture def start_all_spy(monkeypatch, tmp_path): """ @@ -233,25 +320,21 @@ def _fake_start_all(self, recording, active_cams, current_frames, **kwargs): calls["current_frames"] = current_frames calls["kwargs"] = kwargs - # deterministic fake path returned to GUI run_dir = tmp_path / "videos" / "Sess" / "run_TEST" run_dir.mkdir(parents=True, exist_ok=True) return run_dir - # IMPORTANT: patch the RecordingManager class that the GUI imports. from dlclivegui.gui import recording_manager as rm_mod monkeypatch.setattr(rm_mod.RecordingManager, "start_all", _fake_start_all) - return calls -# ---------- Fake processor ---------- class _FakeProcessor: def __init__(self): self.conns = [object()] - self._recording = True # just needs to exist - self._vid_recording = True # attribute presence required by your code + self._recording = True + self._vid_recording = True self.video_recording = True self.session_name = "auto_ABC" self.recording = True @@ -259,7 +342,6 @@ def __init__(self): @pytest.fixture def fake_processor(): - """Return a simple fake processor for testing.""" return _FakeProcessor() @@ -304,19 +386,11 @@ def get_stats(self): @pytest.fixture def recording_settings(app_config_two_cams): - """ - RecordingSettingsModel clone derived from app_config_two_cams. - Keeps tests isolated from mutation across runs. - """ return app_config_two_cams.recording.model_copy(deep=True) @pytest.fixture def patch_video_recorder(monkeypatch): - """ - Patch the VideoRecorder symbol used inside dlclivegui.gui.recording_manager - so RecordingManager tests don't invoke vidgear/ffmpeg. - """ import dlclivegui.gui.recording_manager as rm_mod monkeypatch.setattr(rm_mod, "VideoRecorder", FakeVideoRecorder) @@ -325,7 +399,6 @@ def patch_video_recorder(monkeypatch): @pytest.fixture def recording_frame_spy(monkeypatch, window): - """Capture frames passed to RecordingManager.write_frame calls.""" captured = {} def _fake_write_frame(cam_id, frame, timestamp=None): @@ -337,10 +410,6 @@ def _fake_write_frame(cam_id, frame, timestamp=None): @pytest.fixture def patch_build_run_dir(monkeypatch, tmp_path): - """ - Patch build_run_dir (resolved in dlclivegui.gui.recording_manager namespace) - to return a deterministic run directory and capture the call args. - """ import dlclivegui.gui.recording_manager as rm_mod spy = {"session_dir": None, "use_timestamp": None} @@ -355,3 +424,20 @@ def _fake_build_run_dir(session_dir: Path, *, use_timestamp: bool): monkeypatch.setattr(rm_mod, "build_run_dir", _fake_build_run_dir) return spy, run_dir + + +# --------------------------------------------------------------------- +# Optional legacy fixture: patch_factory (keep only if some tests still depend on it) +# --------------------------------------------------------------------- +@pytest.fixture +def patch_factory(monkeypatch, fake_backend_factory): + """ + Patch CameraFactory.create to always return the fake backend, regardless of backend name. + This supports tests that still create CameraSettings(backend="opencv", ...). + """ + + def _create(settings: CameraSettings): + return fake_backend_factory(settings) + + monkeypatch.setattr(CameraFactory, "create", staticmethod(_create)) + return _create diff --git a/tests/gui/camera_config/test_cam_dialog_e2e.py b/tests/gui/camera_config/test_cam_dialog_e2e.py index 7d63872..a4435d4 100644 --- a/tests/gui/camera_config/test_cam_dialog_e2e.py +++ b/tests/gui/camera_config/test_cam_dialog_e2e.py @@ -1,25 +1,50 @@ # tests/gui/camera_config/test_cam_dialog_e2e.py from __future__ import annotations +import time + import numpy as np import pytest from PySide6.QtCore import Qt +from PySide6.QtWidgets import QMessageBox from dlclivegui.cameras import CameraFactory from dlclivegui.cameras.base import CameraBackend from dlclivegui.cameras.factory import DetectedCamera from dlclivegui.config import CameraSettings, MultiCameraSettings -from dlclivegui.gui.camera_config_dialog import CameraConfigDialog +from dlclivegui.gui.camera_config_dialog import CameraConfigDialog, CameraLoadWorker -# ---------------- Fake backend ---------------- +# ---------------- Fake backends ---------------- class FakeBackend(CameraBackend): + """Simple preview backend that always returns an RGB frame.""" + + def __init__(self, settings): + super().__init__(settings) + self._opened = False + + def open(self): + self._opened = True + + def close(self): + self._opened = False + + def read(self): + return np.zeros((30, 40, 3), dtype=np.uint8), 0.1 + + +class CountingBackend(CameraBackend): + """Backend that counts opens (used to validate restart behavior).""" + + opens = 0 + def __init__(self, settings): super().__init__(settings) self._opened = False def open(self): + type(self).opens += 1 self._opened = True def close(self): @@ -34,7 +59,12 @@ def read(self): @pytest.fixture def patch_factory(monkeypatch): + """ + Patch camera factory so no hardware access occurs, and scan is deterministic. + Default backend is FakeBackend unless overridden per-test. + """ monkeypatch.setattr(CameraFactory, "create", lambda s: FakeBackend(s)) + monkeypatch.setattr( CameraFactory, "detect_cameras", @@ -47,7 +77,15 @@ def patch_factory(monkeypatch): @pytest.fixture def dialog(qtbot, patch_factory): - s = MultiCameraSettings(cameras=[CameraSettings(name="A", backend="opencv", index=0, enabled=True)]) + """ + E2E fixture: allow scan thread + preview loader + timer to run. + Includes robust teardown to avoid leaked threads/timers. + """ + s = MultiCameraSettings( + cameras=[ + CameraSettings(name="A", backend="opencv", index=0, enabled=True), + ] + ) d = CameraConfigDialog(None, s) qtbot.addWidget(d) d.show() @@ -55,62 +93,260 @@ def dialog(qtbot, patch_factory): yield d - # --- robust teardown --- + # ----- robust teardown ----- try: d._stop_preview() except Exception: pass try: - d.reject() # calls _stop_preview + cancels scan worker + d.reject() except Exception: d.close() - # wait for threads to stop qtbot.waitUntil(lambda: getattr(d, "_loader", None) is None, timeout=2000) qtbot.waitUntil(lambda: getattr(d, "_scan_worker", None) is None, timeout=2000) qtbot.waitUntil(lambda: not getattr(d, "_preview_active", False), timeout=2000) -# ---------------- End‑to‑End tests ---------------- +# ---------------- E2E tests ---------------- + + +@pytest.mark.gui def test_e2e_async_camera_scan(dialog, qtbot): qtbot.mouseClick(dialog.refresh_btn, Qt.LeftButton) - with qtbot.waitSignal(dialog.scan_finished, timeout=2000): pass - assert dialog.available_cameras_list.count() == 2 +@pytest.mark.gui def test_e2e_preview_start_stop(dialog, qtbot): dialog.active_cameras_list.setCurrentRow(0) - qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton) - # loader thread finishes → preview becomes active qtbot.waitUntil(lambda: dialog._loader is None and dialog._preview_active, timeout=2000) - assert dialog._preview_active - # preview running → pixmap must update qtbot.waitUntil(lambda: dialog.preview_label.pixmap() is not None, timeout=2000) - # stop preview qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton) + qtbot.waitUntil(lambda: not dialog._preview_active, timeout=2000) - assert dialog._preview_active is False assert dialog._preview_backend is None + assert dialog._preview_timer is None + +@pytest.mark.gui +def test_e2e_apply_settings_restarts_preview_on_restart_fields(dialog, qtbot, monkeypatch): + """ + Change a restart-relevant field (fps) and verify preview actually restarts + (open() called again) while staying active. + """ + CountingBackend.opens = 0 + monkeypatch.setattr(CameraFactory, "create", lambda s: CountingBackend(s)) -def test_e2e_apply_settings_reopens_preview(dialog, qtbot): dialog.active_cameras_list.setCurrentRow(0) qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton) - - # Wait for preview start qtbot.waitUntil(lambda: dialog._loader is None and dialog._preview_active, timeout=2000) + before = CountingBackend.opens + assert before >= 1 + dialog.cam_fps.setValue(99.0) qtbot.mouseClick(dialog.apply_settings_btn, Qt.LeftButton) - # Should still be active → restarted backend - qtbot.waitUntil(lambda: dialog._preview_active and dialog._preview_backend is not None, timeout=2000) + qtbot.waitUntil(lambda: CountingBackend.opens >= before + 1, timeout=2000) + assert dialog._preview_active + assert dialog._preview_backend is not None + + +@pytest.mark.gui +def test_e2e_apply_settings_does_not_restart_on_crop_or_rotation(dialog, qtbot, monkeypatch): + """ + Crop/rotation are applied live in preview; Apply should not restart backend. + We validate by ensuring backend open count does not increase. + """ + CountingBackend.opens = 0 + monkeypatch.setattr(CameraFactory, "create", lambda s: CountingBackend(s)) + + dialog.active_cameras_list.setCurrentRow(0) + qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton) + qtbot.waitUntil(lambda: dialog._loader is None and dialog._preview_active, timeout=2000) + + before = CountingBackend.opens + assert before >= 1 + + dialog.cam_crop_x0.setValue(5) + dialog.cam_rotation.setCurrentIndex(1) + qtbot.mouseClick(dialog.apply_settings_btn, Qt.LeftButton) + + qtbot.wait(200) + assert CountingBackend.opens == before + assert dialog._preview_active + + +@pytest.mark.gui +def test_e2e_selection_change_auto_commits(dialog, qtbot): + """ + Guard contract in E2E mode: switching selection commits pending edits. + We add a second camera deterministically via the available list. + """ + dialog._on_scan_result([DetectedCamera(index=1, label="ExtraCam")]) + dialog.available_cameras_list.setCurrentRow(0) + qtbot.mouseClick(dialog.add_camera_btn, Qt.LeftButton) + + assert len(dialog._working_settings.cameras) >= 2 + + dialog.active_cameras_list.setCurrentRow(0) + qtbot.waitUntil(lambda: dialog._current_edit_index == 0, timeout=1000) + + dialog.cam_fps.setValue(33.0) + assert dialog.apply_settings_btn.isEnabled() + + dialog.active_cameras_list.setCurrentRow(1) + qtbot.waitUntil(lambda: dialog._current_edit_index == 1, timeout=1000) + + assert dialog._working_settings.cameras[0].fps == 33.0 + + +@pytest.mark.gui +def test_cancel_scan(dialog, qtbot, monkeypatch): + def slow_detect(backend, max_devices=10, should_cancel=None, progress_cb=None, **kwargs): + # simulate long scan that can be interrupted + for i in range(50): + if should_cancel and should_cancel(): + break + if progress_cb: + progress_cb(f"Scanning… {i}") + time.sleep(0.02) + # Return something (could be empty if canceled early) + return [DetectedCamera(index=0, label=f"{backend}-X")] + + monkeypatch.setattr(CameraFactory, "detect_cameras", slow_detect) + + qtbot.mouseClick(dialog.refresh_btn, Qt.LeftButton) + qtbot.waitUntil(lambda: dialog.scan_cancel_btn.isVisible(), timeout=1000) + + qtbot.mouseClick(dialog.scan_cancel_btn, Qt.LeftButton) + + with qtbot.waitSignal(dialog.scan_finished, timeout=3000): + pass + + # UI should be re-enabled after finish + assert dialog.refresh_btn.isEnabled() + assert dialog.backend_combo.isEnabled() + + +def _select_backend(dialog, backend_name: str): + idx = dialog.backend_combo.findData(backend_name) + assert idx >= 0, f"Backend {backend_name} not present" + dialog.backend_combo.setCurrentIndex(idx) + + +@pytest.mark.gui +def test_duplicate_camera_prevented(dialog, qtbot, monkeypatch, temp_backend): + calls = {"n": 0} + + def _warn(parent, title, text, *args, **kwargs): + calls["n"] += 1 + return QMessageBox.Ok + + monkeypatch.setattr(QMessageBox, "warning", staticmethod(_warn)) + + # Ensure the available list is interpreted as "opencv" (identity key uses backend) + _select_backend(dialog, "opencv") + + initial_count = dialog.active_cameras_list.count() + + dialog._on_scan_result([DetectedCamera(index=0, label="opencv-X")]) + dialog.available_cameras_list.setCurrentRow(0) + + qtbot.mouseClick(dialog.add_camera_btn, Qt.LeftButton) + + assert dialog.active_cameras_list.count() == initial_count + assert calls["n"] >= 1 + + +@pytest.mark.gui +def test_max_cameras_prevented(qtbot, monkeypatch, patch_factory): + calls = {"n": 0} + + def _warn(parent, title, text, *args, **kwargs): + calls["n"] += 1 + return QMessageBox.Ok + + monkeypatch.setattr(QMessageBox, "warning", staticmethod(_warn)) + + s = MultiCameraSettings( + cameras=[ + CameraSettings(name="C0", backend="opencv", index=0, enabled=True), + CameraSettings(name="C1", backend="opencv", index=1, enabled=True), + CameraSettings(name="C2", backend="opencv", index=2, enabled=True), + CameraSettings(name="C3", backend="opencv", index=3, enabled=True), + ] + ) + d = CameraConfigDialog(None, s) + qtbot.addWidget(d) + d.show() + qtbot.waitExposed(d) + + initial_count = d.active_cameras_list.count() + + d._on_scan_result([DetectedCamera(index=4, label="Extra")]) + d.available_cameras_list.setCurrentRow(0) + + qtbot.mouseClick(d.add_camera_btn, Qt.LeftButton) + + assert d.active_cameras_list.count() == initial_count + assert calls["n"] >= 1 + + d.reject() + + +@pytest.mark.gui +def test_ok_auto_applies_pending_edits(dialog, qtbot): + dialog.active_cameras_list.setCurrentRow(0) + qtbot.waitUntil(lambda: dialog._current_edit_index == 0, timeout=1000) + + dialog.cam_fps.setValue(77.0) + assert dialog.apply_settings_btn.isEnabled() + + with qtbot.waitSignal(dialog.settings_changed, timeout=2000) as sig: + qtbot.mouseClick(dialog.ok_btn, Qt.LeftButton) + + emitted = sig.args[0] + assert emitted.cameras[0].fps == 77.0 + + +@pytest.mark.gui +def test_cancel_loading_preview_button(dialog, qtbot, monkeypatch): + # Make loading slow so Cancel Loading has time to work deterministically + + def slow_run(self): + self.progress.emit("Creating backend…") + time.sleep(0.2) # give test time to click cancel + if getattr(self, "_cancel", False): + self.canceled.emit() + return + self.progress.emit("Opening device…") + time.sleep(0.2) + if getattr(self, "_cancel", False): + self.canceled.emit() + return + self.success.emit(self._cam) + + monkeypatch.setattr(CameraLoadWorker, "run", slow_run) + + dialog.active_cameras_list.setCurrentRow(0) + qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton) # Start Preview => loading active + + qtbot.waitUntil(lambda: dialog._loading_active, timeout=1000) + + # Click again quickly => Cancel Loading + qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton) + + # Ensure loader goes away and preview doesn't become active + qtbot.waitUntil(lambda: dialog._loader is None and not dialog._loading_active, timeout=2000) + assert dialog._preview_active is False + assert dialog._preview_backend is None diff --git a/tests/gui/camera_config/test_cam_dialog_unit.py b/tests/gui/camera_config/test_cam_dialog_unit.py index c8a60fc..9f9b5b6 100644 --- a/tests/gui/camera_config/test_cam_dialog_unit.py +++ b/tests/gui/camera_config/test_cam_dialog_unit.py @@ -1,132 +1,309 @@ # tests/gui/camera_config/test_cam_dialog_unit.py from __future__ import annotations -import numpy as np import pytest from PySide6.QtCore import Qt +from PySide6.QtWidgets import QMessageBox -from dlclivegui.cameras import CameraFactory -from dlclivegui.cameras.base import CameraBackend from dlclivegui.cameras.factory import DetectedCamera from dlclivegui.config import CameraSettings, MultiCameraSettings from dlclivegui.gui.camera_config_dialog import CameraConfigDialog +# ---------------------------- +# Unit dialog fixture (deterministic, no threads) +# ---------------------------- -class FakeBackend(CameraBackend): - def open(self): - pass - def close(self): - pass +@pytest.fixture +def dialog_unit(qtbot, monkeypatch): + """ + Unit fixture: disable async scan + probe to keep tests deterministic. - def read(self): - return np.zeros((10, 10, 3), dtype=np.uint8), 0.0 + We want to test: + - dirty state logic + - auto-commit guards (commit before selection change, list ops, OK) + - apply persistence into _working_settings + - UI list mutations (add/remove/move) + We do NOT want: + - background scan threads + - probe open/close hardware + - timer-driven preview activity + """ + # Prevent async scan on init (dialog calls _populate_from_settings -> _refresh_available_cameras) + monkeypatch.setattr(CameraConfigDialog, "_refresh_available_cameras", lambda self: None) -@pytest.fixture -def dialog(qtbot, monkeypatch): - monkeypatch.setattr( - "dlclivegui.cameras.CameraFactory.detect_cameras", - lambda backend, max_devices=10, **kw: [ - DetectedCamera(index=0, label=f"{backend}-X"), - DetectedCamera(index=1, label=f"{backend}-Y"), - ], - ) - monkeypatch.setattr(CameraFactory, "create", lambda s: FakeBackend(s)) - - # Optional: prevent probe from running at all in pure unit tests + # Prevent probe worker from opening backends (selection triggers probe in current dialog) monkeypatch.setattr(CameraConfigDialog, "_start_probe_for_camera", lambda *a, **k: None) s = MultiCameraSettings( cameras=[ CameraSettings(name="CamA", backend="opencv", index=0, enabled=True), - CameraSettings(name="CamB", backend="opencv", index=1, enabled=False), + CameraSettings(name="CamB", backend="opencv", index=1, enabled=True), ] ) d = CameraConfigDialog(None, s) qtbot.addWidget(d) d.show() qtbot.waitExposed(d) + return d + + +# ---------------------- +# UNIT TESTS +# ---------------------- + + +@pytest.mark.gui +def test_load_form_is_clean(dialog_unit, qtbot): + """Selecting a camera loads values but should not mark the form dirty.""" + dialog_unit.active_cameras_list.setCurrentRow(0) + qtbot.waitUntil(lambda: dialog_unit._current_edit_index == 0, timeout=1000) + + assert not dialog_unit.apply_settings_btn.isEnabled() + assert "*" not in dialog_unit.apply_settings_btn.text() + + +@pytest.mark.gui +@pytest.mark.parametrize( + "mutator", + [ + lambda d: d.cam_width.setValue(320), + lambda d: d.cam_height.setValue(240), + lambda d: d.cam_fps.setValue(55.0), + lambda d: d.cam_exposure.setValue(1000), + lambda d: d.cam_gain.setValue(12.0), + lambda d: d.cam_rotation.setCurrentIndex(1), + lambda d: d.cam_crop_x0.setValue(10), + lambda d: d.cam_crop_y0.setValue(10), + lambda d: d.cam_crop_x1.setValue(50), + lambda d: d.cam_crop_y1.setValue(50), + lambda d: d.cam_enabled_checkbox.setChecked(False), + ], +) +def test_any_change_marks_dirty(dialog_unit, qtbot, mutator): + """Any user change to an editable field should enable Apply and mark it dirty.""" + dialog_unit.active_cameras_list.setCurrentRow(0) + qtbot.waitUntil(lambda: dialog_unit._current_edit_index == 0, timeout=1000) + + mutator(dialog_unit) + + assert dialog_unit.apply_settings_btn.isEnabled() + assert "*" in dialog_unit.apply_settings_btn.text() + + +@pytest.mark.gui +def test_apply_clears_dirty_and_updates_model(dialog_unit, qtbot): + """Apply persists edits into working settings and clears dirty state.""" + dialog_unit.active_cameras_list.setCurrentRow(0) + qtbot.waitUntil(lambda: dialog_unit._current_edit_index == 0, timeout=1000) + + dialog_unit.cam_fps.setValue(55.0) + dialog_unit.cam_gain.setValue(12.0) + + assert dialog_unit.apply_settings_btn.isEnabled() + qtbot.mouseClick(dialog_unit.apply_settings_btn, Qt.LeftButton) + + assert not dialog_unit.apply_settings_btn.isEnabled() + assert "*" not in dialog_unit.apply_settings_btn.text() + + updated = dialog_unit._working_settings.cameras[0] + assert updated.fps == 55.0 + assert updated.gain == 0.0 # gain should not update for OpenCV backend (disabled in UI) + + +@pytest.mark.gui +def test_switch_selection_auto_commits_pending_edits(dialog_unit, qtbot): + """Guard contract: switching cameras should auto-apply pending edits.""" + dialog_unit.active_cameras_list.setCurrentRow(0) + qtbot.waitUntil(lambda: dialog_unit._current_edit_index == 0, timeout=1000) + + dialog_unit.cam_fps.setValue(42.0) + assert dialog_unit.apply_settings_btn.isEnabled() + + dialog_unit.active_cameras_list.setCurrentRow(1) + qtbot.waitUntil(lambda: dialog_unit._current_edit_index == 1, timeout=1000) + + assert dialog_unit._working_settings.cameras[0].fps == 42.0 + + +@pytest.mark.gui +def test_invalid_crop_blocks_switch_and_reverts_selection(dialog_unit, qtbot, monkeypatch): + """ + Real validation: CameraSettings enforces that if any crop is set, + we require x1 > x0 and y1 > y0. + + This test intentionally creates an invalid crop and verifies that: + - selection change is blocked + - selection reverts to the original row + - a warning dialog would have been shown (we stub it here) + """ + calls = {"n": 0, "title": None, "text": None} - yield d + def _warn(parent, title, text, *args, **kwargs): + calls["n"] += 1 + calls["title"] = title + calls["text"] = text + return QMessageBox.Ok - try: - d.reject() - except Exception: - d.close() + # Override the global "fail fast" fixture for this test only + monkeypatch.setattr(QMessageBox, "warning", staticmethod(_warn)) + + dialog_unit.active_cameras_list.setCurrentRow(0) + qtbot.waitUntil(lambda: dialog_unit._current_edit_index == 0, timeout=1000) + + # Enable cropping but make it invalid: x1 <= x0 and y1 <= y0 + dialog_unit.cam_crop_x0.setValue(100) + dialog_unit.cam_crop_y0.setValue(100) + dialog_unit.cam_crop_x1.setValue(50) # invalid (x1 <= x0) + dialog_unit.cam_crop_y1.setValue(80) # invalid (y1 <= y0) + + assert dialog_unit.apply_settings_btn.isEnabled() + + # Attempt to switch: should be blocked and revert to row 0 + dialog_unit.active_cameras_list.setCurrentRow(1) + qtbot.waitUntil(lambda: dialog_unit.active_cameras_list.currentRow() == 0, timeout=1000) + + assert calls["n"] >= 1 + assert "Invalid crop rectangle" in (calls["text"] or "") -# ---------------------- UNIT TESTS ---------------------- @pytest.mark.gui -def test_add_camera_populates_working_settings(dialog, qtbot): - dialog._on_scan_result([DetectedCamera(index=2, label="ExtraCam2")]) - dialog.available_cameras_list.setCurrentRow(0) +def test_ok_auto_applies_pending_edits_before_emitting(dialog_unit, qtbot): + """OK should auto-apply pending edits before emitting settings_changed.""" + dialog_unit.active_cameras_list.setCurrentRow(0) + qtbot.waitUntil(lambda: dialog_unit._current_edit_index == 0, timeout=1000) - qtbot.mouseClick(dialog.add_camera_btn, Qt.LeftButton) + dialog_unit.cam_gain.setValue(7.5) + assert dialog_unit.apply_settings_btn.isEnabled() - added = dialog._working_settings.cameras[-1] + with qtbot.waitSignal(dialog_unit.settings_changed, timeout=1000) as sig: + qtbot.mouseClick(dialog_unit.ok_btn, Qt.LeftButton) + + emitted = sig.args[0] + assert emitted.cameras[0].gain == 0.0 # gain should not update for OpenCV backend (disabled in UI) + + +@pytest.mark.gui +def test_add_camera_populates_working_settings(dialog_unit, qtbot): + """ + Add camera should append a new CameraSettings into _working_settings. + We directly call _on_scan_result to populate available list deterministically. + """ + dialog_unit._on_scan_result([DetectedCamera(index=2, label="ExtraCam2")]) + dialog_unit.available_cameras_list.setCurrentRow(0) + + qtbot.mouseClick(dialog_unit.add_camera_btn, Qt.LeftButton) + + added = dialog_unit._working_settings.cameras[-1] assert added.index == 2 assert added.name == "ExtraCam2" @pytest.mark.gui -def test_remove_camera(dialog, qtbot): - dialog.active_cameras_list.setCurrentRow(0) - qtbot.mouseClick(dialog.remove_camera_btn, Qt.LeftButton) +def test_remove_camera_shrinks_working_settings(dialog_unit, qtbot): + dialog_unit.active_cameras_list.setCurrentRow(0) + qtbot.mouseClick(dialog_unit.remove_camera_btn, Qt.LeftButton) - assert len(dialog._working_settings.cameras) == 1 - assert dialog._working_settings.cameras[0].name == "CamB" + assert len(dialog_unit._working_settings.cameras) == 1 + assert dialog_unit._working_settings.cameras[0].name == "CamB" @pytest.mark.gui -def test_apply_settings_updates_model(dialog, qtbot): - dialog.active_cameras_list.setCurrentRow(0) +def test_move_camera_up_commits_then_reorders(dialog_unit, qtbot): + """ + Move actions should auto-commit pending edits before reordering. + """ + dialog_unit.active_cameras_list.setCurrentRow(1) + qtbot.waitUntil(lambda: dialog_unit._current_edit_index == 1, timeout=1000) - dialog.cam_fps.setValue(55.0) - dialog.cam_gain.setValue(12.0) + dialog_unit.cam_fps.setValue(88.0) + assert dialog_unit.apply_settings_btn.isEnabled() - qtbot.mouseClick(dialog.apply_settings_btn, Qt.LeftButton) + qtbot.mouseClick(dialog_unit.move_up_btn, Qt.LeftButton) - updated = dialog._working_settings.cameras[0] - assert updated.fps == 55.0 - assert updated.gain == 12.0 + # CamB moved to index 0 after move; commit should persist to that camera + assert dialog_unit._working_settings.cameras[0].fps == 88.0 + + +@pytest.mark.gui +def test_opencv_gain_is_disabled_and_does_not_change_model(dialog_unit, qtbot): + dialog_unit.active_cameras_list.setCurrentRow(0) + qtbot.waitUntil(lambda: dialog_unit._current_edit_index == 0, timeout=1000) + + # OpenCV: gain control should be disabled by design + assert not dialog_unit.cam_gain.isEnabled() + + # Model remains Auto (0.0) + assert dialog_unit._working_settings.cameras[0].gain == 0.0 @pytest.mark.gui -def test_backend_control_disables_exposure_gain_for_opencv(dialog, monkeypatch): +def test_enter_in_fps_commits_and_does_not_close(dialog_unit, qtbot): + dialog_unit.active_cameras_list.setCurrentRow(0) + qtbot.waitUntil(lambda: dialog_unit._current_edit_index == 0, timeout=1000) + + le = dialog_unit.cam_fps.lineEdit() + assert le is not None + + le.setFocus() + le.selectAll() + qtbot.keyClicks(le, "55") + qtbot.keyClick(le, Qt.Key_Return) + + assert dialog_unit.isVisible() + assert dialog_unit._working_settings.cameras[0].fps == 55.0 + + +@pytest.mark.gui +def test_enter_in_gain_commits_for_gain_capable_backend(dialog_unit, qtbot, temp_backend): from dlclivegui.cameras.base import SupportLevel - def fake_caps(name: str): - if name == "opencv": - return { - "set_exposure": SupportLevel.UNSUPPORTED, # or UNSUPPORTED if you prefer - "set_gain": SupportLevel.UNSUPPORTED, - "set_resolution": SupportLevel.SUPPORTED, - "set_fps": SupportLevel.BEST_EFFORT, - "device_discovery": SupportLevel.SUPPORTED, - "stable_identity": SupportLevel.SUPPORTED, - } - if name == "basler": - return { - "set_exposure": SupportLevel.SUPPORTED, - "set_gain": SupportLevel.SUPPORTED, - "set_resolution": SupportLevel.SUPPORTED, - "set_fps": SupportLevel.SUPPORTED, - "device_discovery": SupportLevel.BEST_EFFORT, - "stable_identity": SupportLevel.SUPPORTED, - } - return {} - - monkeypatch.setattr( - "dlclivegui.cameras.CameraFactory.backend_capabilities", - lambda backend_name: fake_caps(backend_name), - raising=False, - ) + with temp_backend( + "test_gain", + caps={ + "set_gain": SupportLevel.SUPPORTED, + "set_exposure": SupportLevel.SUPPORTED, + "set_resolution": SupportLevel.SUPPORTED, + "set_fps": SupportLevel.SUPPORTED, + "device_discovery": SupportLevel.SUPPORTED, + "stable_identity": SupportLevel.SUPPORTED, + }, + ): + # switch camera backend to the temp backend and reload form + dialog_unit._working_settings.cameras[0].backend = "test_gain" + dialog_unit._load_camera_to_form(dialog_unit._working_settings.cameras[0]) + + assert dialog_unit.cam_gain.isEnabled() + + le = dialog_unit.cam_gain.lineEdit() + assert le is not None + + le.setFocus() + le.selectAll() + qtbot.keyClicks(le, "12.0") + qtbot.keyClick(le, Qt.Key_Return) + + assert dialog_unit.isVisible() + assert dialog_unit._working_settings.cameras[0].gain == 12.0 + + +@pytest.mark.gui +def test_disabled_gain_exposure_do_not_affect_model_for_opencv(dialog_unit, qtbot): + dialog_unit.active_cameras_list.setCurrentRow(0) + qtbot.waitUntil(lambda: dialog_unit._current_edit_index == 0, timeout=1000) + + assert not dialog_unit.cam_gain.isEnabled() + assert not dialog_unit.cam_exposure.isEnabled() + + # programmatic setValue shouldn't persist when disabled + dialog_unit.cam_gain.setValue(12.0) + dialog_unit.cam_exposure.setValue(1234) - dialog._update_controls_for_backend("opencv") - assert not dialog.cam_exposure.isEnabled() - assert not dialog.cam_gain.isEnabled() + dialog_unit.cam_fps.setValue(55.0) # make dirty + qtbot.mouseClick(dialog_unit.apply_settings_btn, Qt.LeftButton) - dialog._update_controls_for_backend("basler") - assert dialog.cam_exposure.isEnabled() - assert dialog.cam_gain.isEnabled() + cam = dialog_unit._working_settings.cameras[0] + assert cam.fps == 55.0 + assert cam.gain == 0.0 + assert cam.exposure == 0 diff --git a/tests/gui/conftest.py b/tests/gui/conftest.py index 3e0c17c..51b1cf2 100644 --- a/tests/gui/conftest.py +++ b/tests/gui/conftest.py @@ -5,26 +5,17 @@ from PySide6.QtWidgets import QMessageBox from dlclivegui.cameras.factory import CameraFactory -from dlclivegui.config import CameraSettings from dlclivegui.gui.main_window import DLCLiveMainWindow # ---------- Autouse patches to keep GUI tests fast and side-effect-free ---------- @pytest.fixture(autouse=True) def _patch_camera_factory(monkeypatch, request, fake_backend_factory): - """ - Replace hardware backends with FakeBackend globally for GUI tests. - We patch at the central creation point used by the controller. - """ if request.node.get_closest_marker("gui") is None: yield return - def _create_stub(settings: CameraSettings): - # FakeBackend ignores 'backend' and produces deterministic frames - return fake_backend_factory(settings) - - monkeypatch.setattr(CameraFactory, "create", staticmethod(_create_stub)) + monkeypatch.setattr(CameraFactory, "create", staticmethod(fake_backend_factory)) yield @@ -46,14 +37,10 @@ def _patch_camera_validation(monkeypatch): @pytest.fixture(autouse=True) -def _patch_dlclive_to_fake(monkeypatch, fake_dlclive_factory): - """ - Ensure dlclive is replaced by the test double in the DLCLiveProcessor module. - (The window will instantiate DLCLiveProcessor internally, which imports DLCLive.) - """ +def _patch_dlclive_to_fake(monkeypatch, FakeDLCLiveClass): from dlclivegui.services import dlc_processor as dlcp_mod - monkeypatch.setattr(dlcp_mod, "DLCLive", fake_dlclive_factory) + monkeypatch.setattr(dlcp_mod, "DLCLive", FakeDLCLiveClass) @pytest.fixture(autouse=True) From be27cc4794eceafe60cbdbd4fa9076947a65ee2c Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 12 Feb 2026 19:37:21 +0100 Subject: [PATCH 17/30] Make camera dialog tests deterministic Stabilize GUI tests by making backend discovery and capabilities deterministic. Add _select_backend_for_active_cam helper to ensure combo/backend coherence during tests. Replace global fake/opencv mixing with a patch_detect_cameras fixture (staticmethod) and use a stable 'fake' backend in E2E fixtures. Inline CountingBackend in relevant tests and monkeypatch CameraFactory.create as staticmethod to avoid hardware access. In unit tests, patch backend_capabilities for predictable enable/disable state and switch assertions to FPS (supported) instead of gain. Misc: minor test cleanups, docstring tweaks, and staticmethod adjustments for slow scan/loader helpers. --- .../gui/camera_config/test_cam_dialog_e2e.py | 228 +++++++++++------- .../gui/camera_config/test_cam_dialog_unit.py | 33 ++- 2 files changed, 174 insertions(+), 87 deletions(-) diff --git a/tests/gui/camera_config/test_cam_dialog_e2e.py b/tests/gui/camera_config/test_cam_dialog_e2e.py index a4435d4..86d5b1c 100644 --- a/tests/gui/camera_config/test_cam_dialog_e2e.py +++ b/tests/gui/camera_config/test_cam_dialog_e2e.py @@ -14,76 +14,90 @@ from dlclivegui.config import CameraSettings, MultiCameraSettings from dlclivegui.gui.camera_config_dialog import CameraConfigDialog, CameraLoadWorker -# ---------------- Fake backends ---------------- +# --------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------- -class FakeBackend(CameraBackend): - """Simple preview backend that always returns an RGB frame.""" - - def __init__(self, settings): - super().__init__(settings) - self._opened = False - - def open(self): - self._opened = True - - def close(self): - self._opened = False - - def read(self): - return np.zeros((30, 40, 3), dtype=np.uint8), 0.1 - - -class CountingBackend(CameraBackend): - """Backend that counts opens (used to validate restart behavior).""" - - opens = 0 - - def __init__(self, settings): - super().__init__(settings) - self._opened = False - - def open(self): - type(self).opens += 1 - self._opened = True +def _select_backend_for_active_cam(dialog: CameraConfigDialog, cam_row: int = 0) -> str: + """ + Ensure backend combo is set to the backend of the active camera at cam_row. + If that backend is not present in the combo, fall back to the current combo backend + and update the camera setting backend to match (so identity/dup logic stays coherent). + Returns the backend key actually selected (lowercase). + """ + # backend requested by the camera settings + backend = (dialog._working_settings.cameras[cam_row].backend or "").lower() + + idx = dialog.backend_combo.findData(backend) + if idx >= 0: + dialog.backend_combo.setCurrentIndex(idx) + return backend + + # Fallback: use current combo backend (or first item) and update the camera backend to match + fallback = dialog.backend_combo.currentData() + if not fallback and dialog.backend_combo.count() > 0: + fallback = dialog.backend_combo.itemData(0) + dialog.backend_combo.setCurrentIndex(0) + + fallback = (fallback or "").lower() + assert fallback, "No backend available in combo" + + # Ensure camera backend matches combo so duplicate logic compares apples-to-apples + dialog._working_settings.cameras[cam_row].backend = fallback + # Also update the list item UserRole object (so UI selection holds the updated backend) + try: + item = dialog.active_cameras_list.item(cam_row) + if item is not None: + cam = item.data(Qt.ItemDataRole.UserRole) + if cam is not None: + cam.backend = fallback + item.setData(Qt.ItemDataRole.UserRole, cam) + except Exception: + pass - def close(self): - self._opened = False + # Update labels/UI for consistency + try: + dialog._update_active_list_item(cam_row, dialog._working_settings.cameras[cam_row]) + dialog._update_controls_for_backend(fallback) + except Exception: + pass - def read(self): - return np.zeros((30, 40, 3), dtype=np.uint8), 0.1 + return fallback -# ---------------- Fixtures ---------------- +# --------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------- @pytest.fixture -def patch_factory(monkeypatch): +def patch_detect_cameras(monkeypatch): """ - Patch camera factory so no hardware access occurs, and scan is deterministic. - Default backend is FakeBackend unless overridden per-test. + Make discovery deterministic for these tests. + (GUI conftest patches create(), but not necessarily detect_cameras().) """ - monkeypatch.setattr(CameraFactory, "create", lambda s: FakeBackend(s)) - monkeypatch.setattr( CameraFactory, "detect_cameras", - lambda backend, max_devices=10, **kw: [ - DetectedCamera(index=0, label=f"{backend}-X"), - DetectedCamera(index=1, label=f"{backend}-Y"), - ], + staticmethod( + lambda backend, max_devices=10, **kw: [ + DetectedCamera(index=0, label=f"{backend}-X"), + DetectedCamera(index=1, label=f"{backend}-Y"), + ] + ), ) @pytest.fixture -def dialog(qtbot, patch_factory): +def dialog(qtbot, patch_detect_cameras): """ - E2E fixture: allow scan thread + preview loader + timer to run. - Includes robust teardown to avoid leaked threads/timers. + E2E fixture: dialog with scan worker + loader + preview timer enabled. + Uses a backend that is guaranteed to exist in test registry: 'fake'. """ s = MultiCameraSettings( cameras=[ - CameraSettings(name="A", backend="opencv", index=0, enabled=True), + CameraSettings(name="A", backend="fake", index=0, enabled=True), ] ) d = CameraConfigDialog(None, s) @@ -109,7 +123,9 @@ def dialog(qtbot, patch_factory): qtbot.waitUntil(lambda: not getattr(d, "_preview_active", False), timeout=2000) -# ---------------- E2E tests ---------------- +# --------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------- @pytest.mark.gui @@ -141,10 +157,28 @@ def test_e2e_preview_start_stop(dialog, qtbot): def test_e2e_apply_settings_restarts_preview_on_restart_fields(dialog, qtbot, monkeypatch): """ Change a restart-relevant field (fps) and verify preview actually restarts - (open() called again) while staying active. + by observing open() being called again. """ + + class CountingBackend(CameraBackend): + opens = 0 + + def __init__(self, settings): + super().__init__(settings) + self._opened = False + + def open(self): + type(self).opens += 1 + self._opened = True + + def close(self): + self._opened = False + + def read(self): + return np.zeros((30, 40, 3), dtype=np.uint8), 0.1 + CountingBackend.opens = 0 - monkeypatch.setattr(CameraFactory, "create", lambda s: CountingBackend(s)) + monkeypatch.setattr(CameraFactory, "create", staticmethod(lambda s: CountingBackend(s))) dialog.active_cameras_list.setCurrentRow(0) qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton) @@ -165,10 +199,28 @@ def test_e2e_apply_settings_restarts_preview_on_restart_fields(dialog, qtbot, mo def test_e2e_apply_settings_does_not_restart_on_crop_or_rotation(dialog, qtbot, monkeypatch): """ Crop/rotation are applied live in preview; Apply should not restart backend. - We validate by ensuring backend open count does not increase. + We validate by ensuring open() count does not increase. """ + + class CountingBackend(CameraBackend): + opens = 0 + + def __init__(self, settings): + super().__init__(settings) + self._opened = False + + def open(self): + type(self).opens += 1 + self._opened = True + + def close(self): + self._opened = False + + def read(self): + return np.zeros((30, 40, 3), dtype=np.uint8), 0.1 + CountingBackend.opens = 0 - monkeypatch.setattr(CameraFactory, "create", lambda s: CountingBackend(s)) + monkeypatch.setattr(CameraFactory, "create", staticmethod(lambda s: CountingBackend(s))) dialog.active_cameras_list.setCurrentRow(0) qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton) @@ -189,9 +241,13 @@ def test_e2e_apply_settings_does_not_restart_on_crop_or_rotation(dialog, qtbot, @pytest.mark.gui def test_e2e_selection_change_auto_commits(dialog, qtbot): """ - Guard contract in E2E mode: switching selection commits pending edits. - We add a second camera deterministically via the available list. + Guard contract: switching selection commits pending edits. + Use FPS (supported) rather than gain (OpenCV gain is intentionally disabled). """ + # Ensure backend combo matches active cam (important for add/dup logic) + _select_backend_for_active_cam(dialog, cam_row=0) + + # Add second camera deterministically dialog._on_scan_result([DetectedCamera(index=1, label="ExtraCam")]) dialog.available_cameras_list.setCurrentRow(0) qtbot.mouseClick(dialog.add_camera_btn, Qt.LeftButton) @@ -213,17 +269,15 @@ def test_e2e_selection_change_auto_commits(dialog, qtbot): @pytest.mark.gui def test_cancel_scan(dialog, qtbot, monkeypatch): def slow_detect(backend, max_devices=10, should_cancel=None, progress_cb=None, **kwargs): - # simulate long scan that can be interrupted for i in range(50): if should_cancel and should_cancel(): break if progress_cb: progress_cb(f"Scanning… {i}") time.sleep(0.02) - # Return something (could be empty if canceled early) return [DetectedCamera(index=0, label=f"{backend}-X")] - monkeypatch.setattr(CameraFactory, "detect_cameras", slow_detect) + monkeypatch.setattr(CameraFactory, "detect_cameras", staticmethod(slow_detect)) qtbot.mouseClick(dialog.refresh_btn, Qt.LeftButton) qtbot.waitUntil(lambda: dialog.scan_cancel_btn.isVisible(), timeout=1000) @@ -233,19 +287,16 @@ def slow_detect(backend, max_devices=10, should_cancel=None, progress_cb=None, * with qtbot.waitSignal(dialog.scan_finished, timeout=3000): pass - # UI should be re-enabled after finish assert dialog.refresh_btn.isEnabled() assert dialog.backend_combo.isEnabled() -def _select_backend(dialog, backend_name: str): - idx = dialog.backend_combo.findData(backend_name) - assert idx >= 0, f"Backend {backend_name} not present" - dialog.backend_combo.setCurrentIndex(idx) - - @pytest.mark.gui -def test_duplicate_camera_prevented(dialog, qtbot, monkeypatch, temp_backend): +def test_duplicate_camera_prevented(dialog, qtbot, monkeypatch): + """ + Duplicate detection compares identity keys including backend. + Ensure backend combo is set to match existing active camera backend. + """ calls = {"n": 0} def _warn(parent, title, text, *args, **kwargs): @@ -254,12 +305,12 @@ def _warn(parent, title, text, *args, **kwargs): monkeypatch.setattr(QMessageBox, "warning", staticmethod(_warn)) - # Ensure the available list is interpreted as "opencv" (identity key uses backend) - _select_backend(dialog, "opencv") + backend = _select_backend_for_active_cam(dialog, cam_row=0) initial_count = dialog.active_cameras_list.count() - dialog._on_scan_result([DetectedCamera(index=0, label="opencv-X")]) + # Same backend + same index -> duplicate + dialog._on_scan_result([DetectedCamera(index=0, label=f"{backend}-X")]) dialog.available_cameras_list.setCurrentRow(0) qtbot.mouseClick(dialog.add_camera_btn, Qt.LeftButton) @@ -269,7 +320,10 @@ def _warn(parent, title, text, *args, **kwargs): @pytest.mark.gui -def test_max_cameras_prevented(qtbot, monkeypatch, patch_factory): +def test_max_cameras_prevented(qtbot, monkeypatch, patch_detect_cameras): + """ + Dialog enforces MAX_CAMERAS enabled cameras. Use backend='fake' for stability. + """ calls = {"n": 0} def _warn(parent, title, text, *args, **kwargs): @@ -280,10 +334,10 @@ def _warn(parent, title, text, *args, **kwargs): s = MultiCameraSettings( cameras=[ - CameraSettings(name="C0", backend="opencv", index=0, enabled=True), - CameraSettings(name="C1", backend="opencv", index=1, enabled=True), - CameraSettings(name="C2", backend="opencv", index=2, enabled=True), - CameraSettings(name="C3", backend="opencv", index=3, enabled=True), + CameraSettings(name="C0", backend="fake", index=0, enabled=True), + CameraSettings(name="C1", backend="fake", index=1, enabled=True), + CameraSettings(name="C2", backend="fake", index=2, enabled=True), + CameraSettings(name="C3", backend="fake", index=3, enabled=True), ] ) d = CameraConfigDialog(None, s) @@ -291,17 +345,20 @@ def _warn(parent, title, text, *args, **kwargs): d.show() qtbot.waitExposed(d) - initial_count = d.active_cameras_list.count() + try: + _select_backend_for_active_cam(d, cam_row=0) - d._on_scan_result([DetectedCamera(index=4, label="Extra")]) - d.available_cameras_list.setCurrentRow(0) + initial_count = d.active_cameras_list.count() - qtbot.mouseClick(d.add_camera_btn, Qt.LeftButton) + d._on_scan_result([DetectedCamera(index=4, label="Extra")]) + d.available_cameras_list.setCurrentRow(0) - assert d.active_cameras_list.count() == initial_count - assert calls["n"] >= 1 + qtbot.mouseClick(d.add_camera_btn, Qt.LeftButton) - d.reject() + assert d.active_cameras_list.count() == initial_count + assert calls["n"] >= 1 + finally: + d.reject() @pytest.mark.gui @@ -321,11 +378,13 @@ def test_ok_auto_applies_pending_edits(dialog, qtbot): @pytest.mark.gui def test_cancel_loading_preview_button(dialog, qtbot, monkeypatch): - # Make loading slow so Cancel Loading has time to work deterministically + """ + Deterministic cancel-loading test: slow down worker so Cancel Loading can interrupt. + """ def slow_run(self): self.progress.emit("Creating backend…") - time.sleep(0.2) # give test time to click cancel + time.sleep(0.2) if getattr(self, "_cancel", False): self.canceled.emit() return @@ -343,10 +402,9 @@ def slow_run(self): qtbot.waitUntil(lambda: dialog._loading_active, timeout=1000) - # Click again quickly => Cancel Loading + # Click again => Cancel Loading qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton) - # Ensure loader goes away and preview doesn't become active qtbot.waitUntil(lambda: dialog._loader is None and not dialog._loading_active, timeout=2000) assert dialog._preview_active is False assert dialog._preview_backend is None diff --git a/tests/gui/camera_config/test_cam_dialog_unit.py b/tests/gui/camera_config/test_cam_dialog_unit.py index 9f9b5b6..0eb8d45 100644 --- a/tests/gui/camera_config/test_cam_dialog_unit.py +++ b/tests/gui/camera_config/test_cam_dialog_unit.py @@ -36,6 +36,34 @@ def dialog_unit(qtbot, monkeypatch): # Prevent probe worker from opening backends (selection triggers probe in current dialog) monkeypatch.setattr(CameraConfigDialog, "_start_probe_for_camera", lambda *a, **k: None) + # Ensure capability-driven enable/disable states are deterministic for these unit tests. + # We intentionally disable gain/exposure for OpenCV by choice, but keep FPS/resolution enabled for UX tests. + from dlclivegui.cameras import CameraFactory + from dlclivegui.cameras.base import SupportLevel + + def _caps(backend_name: str): + key = (backend_name or "").lower() + if key == "opencv": + return { + "set_resolution": SupportLevel.SUPPORTED, + "set_fps": SupportLevel.SUPPORTED, + "set_exposure": SupportLevel.UNSUPPORTED, # by choice + "set_gain": SupportLevel.UNSUPPORTED, # by choice + "device_discovery": SupportLevel.SUPPORTED, + "stable_identity": SupportLevel.SUPPORTED, + } + # Default for tests: allow everything (useful if temp_backend enables gain, etc.) + return { + "set_resolution": SupportLevel.SUPPORTED, + "set_fps": SupportLevel.SUPPORTED, + "set_exposure": SupportLevel.SUPPORTED, + "set_gain": SupportLevel.SUPPORTED, + "device_discovery": SupportLevel.SUPPORTED, + "stable_identity": SupportLevel.SUPPORTED, + } + + monkeypatch.setattr(CameraFactory, "backend_capabilities", staticmethod(_caps), raising=False) + s = MultiCameraSettings( cameras=[ CameraSettings(name="CamA", backend="opencv", index=0, enabled=True), @@ -174,14 +202,15 @@ def test_ok_auto_applies_pending_edits_before_emitting(dialog_unit, qtbot): dialog_unit.active_cameras_list.setCurrentRow(0) qtbot.waitUntil(lambda: dialog_unit._current_edit_index == 0, timeout=1000) - dialog_unit.cam_gain.setValue(7.5) + # Use FPS here (supported) to ensure the test validates meaningful auto-apply. + dialog_unit.cam_fps.setValue(77.0) assert dialog_unit.apply_settings_btn.isEnabled() with qtbot.waitSignal(dialog_unit.settings_changed, timeout=1000) as sig: qtbot.mouseClick(dialog_unit.ok_btn, Qt.LeftButton) emitted = sig.args[0] - assert emitted.cameras[0].gain == 0.0 # gain should not update for OpenCV backend (disabled in UI) + assert emitted.cameras[0].fps == 77.0 @pytest.mark.gui From 0701baee750defabb1ffd84a868cec19c75ba7f0 Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:40:08 +0100 Subject: [PATCH 18/30] update pyproject.toml: add pytest-timeout --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2b3c18a..8827b8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dev = [ "pytest-cov>=4.0", "pytest-mock>=3.10", "pytest-qt>=4.2", + "pytest-timeout>=2.0", "pre-commit", "hypothesis>=6.0", ] @@ -62,6 +63,7 @@ test = [ "pytest-cov>=4.0", "pytest-mock>=3.10", "pytest-qt>=4.2", + "pytest-timeout>=2.0", "hypothesis>=6.0", ] @@ -108,6 +110,7 @@ markers = [ "hardware: Tests that require specific hardware, notable camera backends", # "slow: Tests that take a long time to run", "gui: Tests that require GUI interaction", + "timeout: Test timeout in seconds (pytest-timeout)", ] [tool.coverage.run] From 48faa4163639b43dc95599e94ee6d52cb4ed6560 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 13 Feb 2026 14:59:28 +0100 Subject: [PATCH 19/30] Refactor camera config dialog and reduce code duplication (#46) * Extract camera loaders; refactor preview UI state Move camera worker and preview state logic into a new module (dlclivegui/gui/camera_loaders.py) and refactor the CameraConfigDialog to use it. Added DetectCamerasWorker, CameraProbeWorker, CameraLoadWorker, PreviewState enum and PreviewSession dataclass to centralize loader/backend/timer intent. Removed the embedded worker classes from camera_config_dialog.py and replaced multiple booleans/flags with a single PreviewSession, an epoch-based signal invalidation mechanism, coalesced preview restarts, and unified scan/preview UI syncing. Also adjusted loader signal handlers to be epoch-aware, simplified start/stop flows, and made small docstring/comment tweaks in basler_backend.py regarding fast_start. * Refactor camera config into package Move camera configuration code into a dedicated gui/camera_config package and extract UI construction into a new ui_blocks module. Renamed camera_config_dialog.py and camera_loaders.py to camera_config/camera_config_dialog.py and camera_config/loaders.py respectively, replaced the large _setup_ui implementation with setup_camera_config_dialog_ui(dlg) from ui_blocks.py, and added ui_blocks.py to build the dialog UI. Updated relative imports in __init__.py, main_window.py, and affected modules/tests to the new paths. This refactor improves modularity and keeps the dialog file focused on logic rather than bulky widget construction. * Refactor camera config dialog & add identity utils Move camera identity utilities into cameras.factory (apply_detected_identity, camera_identity_key) and add CameraSettings.check_diff for concise settings diffs. Major refactor of CameraConfigDialog: reorganize UI/state helpers, probe/preview lifecycle, auto-apply pending edits, improved scan/probe cancellation and dialog reject handling, duplicate-camera checks, add reorder/add/remove safety, and split preview helpers into a new preview module; also remove unused cv2 import. These changes centralize identity handling, improve preview & probe UX, and add safer settings application and logging. Tests updated to match the new contracts. * Centralize camera dialog cleanup and fixes Add a unified _on_close_cleanup and closeEvent to ensure preview/worker shutdown and UI reset on dialog close/cancel. Make cleanup idempotent with a _cleanup_done guard, shorten worker wait times to reduce UI freeze, and defensively reset scan UI widgets. Connect scan cancel button handler. Initialize _multi_camera_settings fallback, tighten type annotations, and note eventFilter UI assignments. Refactor form/apply flow to build a new CameraSettings model, compute diffs, replace the working camera entry and update the active list item (apply now returns a truthy value). Call _reconcile_fps_from_backend when loading settings, remove exc_info from a loader error log, and invoke cleanup before accepting the dialog. These changes improve robustness during shutdown and reduce UI hangs. * Refactor preview helpers into controller Move preview state and session datatypes into preview.py and refactor low-level image ops into MultiCameraController static methods. preview.py now delegates rotation, crop, resize and pixmap conversion to MultiCameraController (with proper type hints and QTimer usage). loaders.py removes duplicate PreviewState/PreviewSession definitions and cleans imports. camera_config_dialog uses getattr to safely read backend.actual_fps and updates imports to match the refactor. MultiCameraController gains apply_rotation/apply_crop/apply_resize/ensure_color_* and to_display_pixmap utilities (default: no upscale when resizing). * Update tests to use PreviewState and dialog._preview Adapt camera config GUI tests to the refactored preview API: import PreviewState and replace checks against legacy dialog attributes (_loader, _preview_active, _preview_backend, _preview_timer) with dialog._preview.loader, dialog._preview.state (using PreviewState.*), dialog._preview.backend, and dialog._preview.timer. Update waitUntil conditions and assertions across test_cam_dialog_e2e.py to reflect the new preview state machine and object structure. --- dlclivegui/__init__.py | 2 +- dlclivegui/cameras/backends/basler_backend.py | 3 +- dlclivegui/cameras/factory.py | 46 + dlclivegui/config.py | 26 + .../camera_config_dialog.py | 2183 +++++++---------- dlclivegui/gui/camera_config/loaders.py | 139 ++ dlclivegui/gui/camera_config/preview.py | 77 + dlclivegui/gui/camera_config/ui_blocks.py | 495 ++++ dlclivegui/gui/main_window.py | 2 +- .../services/multi_camera_controller.py | 64 +- .../backends/test_generic_contracts.py | 4 +- .../gui/camera_config/test_cam_dialog_e2e.py | 36 +- .../gui/camera_config/test_cam_dialog_unit.py | 2 +- 13 files changed, 1703 insertions(+), 1376 deletions(-) rename dlclivegui/gui/{ => camera_config}/camera_config_dialog.py (54%) create mode 100644 dlclivegui/gui/camera_config/loaders.py create mode 100644 dlclivegui/gui/camera_config/preview.py create mode 100644 dlclivegui/gui/camera_config/ui_blocks.py diff --git a/dlclivegui/__init__.py b/dlclivegui/__init__.py index 98c82aa..9cc2640 100644 --- a/dlclivegui/__init__.py +++ b/dlclivegui/__init__.py @@ -7,7 +7,7 @@ MultiCameraSettings, RecordingSettings, ) -from .gui.camera_config_dialog import CameraConfigDialog +from .gui.camera_config.camera_config_dialog import CameraConfigDialog from .gui.main_window import DLCLiveMainWindow from .main import main from .services.multi_camera_controller import MultiCameraController, MultiFrameData diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 671c973..8e7b0e1 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -30,7 +30,8 @@ def __init__(self, settings): self._props: dict = settings.properties if isinstance(settings.properties, dict) else {} - # Optional fast-start hint for probe workers (best-effort; doesn't change behavior yet) + # Optional fast-start hint for probe workers + # (may skip StartGrabbing and converter setup for faster capability probing; not suitable for normal capture) self._fast_start: bool = bool(self.ns.get("fast_start", False)) # Stable identity (serial-based). Prefer new namespace; fall back to legacy keys read-only. diff --git a/dlclivegui/cameras/factory.py b/dlclivegui/cameras/factory.py index 5bdd1fb..812c139 100644 --- a/dlclivegui/cameras/factory.py +++ b/dlclivegui/cameras/factory.py @@ -415,3 +415,49 @@ def _resolve_backend(name: str) -> type[CameraBackend]: "Tip: enable strict import failures with DLC_CAMERA_BACKENDS_STRICT_IMPORT=1" ) raise RuntimeError(msg) from exc + + +# ------------------------------- +# Camera identity utilities +# ------------------------------- + + +def apply_detected_identity(cam: CameraSettings, detected: DetectedCamera, backend: str) -> None: + """Persist stable identity from a detected camera into cam.properties under backend namespace.""" + if not isinstance(cam.properties, dict): + cam.properties = {} + + ns = cam.properties.get(backend.lower()) + if not isinstance(ns, dict): + ns = {} + cam.properties[backend.lower()] = ns + + # Store whatever we have (backend-specific but written generically) + if getattr(detected, "device_id", None): + ns["device_id"] = detected.device_id + if getattr(detected, "vid", None) is not None: + ns["device_vid"] = int(detected.vid) + if getattr(detected, "pid", None) is not None: + ns["device_pid"] = int(detected.pid) + if getattr(detected, "path", None): + ns["device_path"] = detected.path + + # Optional: store human name for matching fallback + if getattr(detected, "label", None): + ns["device_name"] = detected.label + + # Optional: store backend_hint if you expose it (e.g., CAP_DSHOW) + if getattr(detected, "backend_hint", None) is not None: + ns["backend_hint"] = int(detected.backend_hint) + + +def camera_identity_key(cam: CameraSettings) -> tuple: + backend = (cam.backend or "").lower() + props = cam.properties if isinstance(cam.properties, dict) else {} + ns = props.get(backend, {}) if isinstance(props, dict) else {} + device_id = ns.get("device_id") + + # Prefer stable identity if present, otherwise fallback + if device_id: + return (backend, "device_id", device_id) + return (backend, "index", int(cam.index)) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 5c14d40..645371c 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -140,6 +140,32 @@ def apply_defaults(self) -> CameraSettings: return self + @staticmethod + def check_diff(old: CameraSettings, new: CameraSettings) -> dict: + keys = ( + "width", + "height", + "fps", + "exposure", + "gain", + "rotation", + "crop_x0", + "crop_y0", + "crop_x1", + "crop_y1", + "enabled", + ) + out = {} + for k in keys: + try: + ov = getattr(old, k, None) + nv = getattr(new, k, None) + if ov != nv: + out[k] = (ov, nv) + except Exception: + pass + return out + class MultiCameraSettings(BaseModel): cameras: list[CameraSettings] = Field(default_factory=list) diff --git a/dlclivegui/gui/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py similarity index 54% rename from dlclivegui/gui/camera_config_dialog.py rename to dlclivegui/gui/camera_config/camera_config_dialog.py index 0fa84b2..789b429 100644 --- a/dlclivegui/gui/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -1,203 +1,32 @@ """Camera configuration dialog for multi-camera setup (with async preview loading).""" -# dlclivegui/gui/camera_config_dialog.py +# dlclivegui/gui/camera_config/camera_config_dialog.py from __future__ import annotations import copy import logging -import cv2 -from PySide6.QtCore import QEvent, Qt, QThread, QTimer, Signal -from PySide6.QtGui import QFont, QImage, QKeyEvent, QPixmap, QTextCursor +from PySide6.QtCore import QEvent, Qt, QTimer, Signal +from PySide6.QtGui import QKeyEvent, QTextCursor from PySide6.QtWidgets import ( - QCheckBox, - QComboBox, QDialog, - QDoubleSpinBox, - QFormLayout, - QGroupBox, - QHBoxLayout, - QLabel, - QListWidget, QListWidgetItem, QMessageBox, - QProgressBar, - QPushButton, QScrollArea, - QSizePolicy, - QSpinBox, QStyle, - QTextEdit, - QVBoxLayout, QWidget, ) -from ..cameras import CameraFactory -from ..cameras.base import CameraBackend -from ..cameras.factory import DetectedCamera -from ..config import CameraSettings, MultiCameraSettings -from .misc.drag_spinbox import ScrubSpinBox -from .misc.eliding_label import ElidingPathLabel -from .misc.layouts import _make_two_field_row +from ...cameras.factory import CameraFactory, DetectedCamera, apply_detected_identity, camera_identity_key +from ...config import CameraSettings, MultiCameraSettings +from .loaders import CameraLoadWorker, CameraProbeWorker, DetectCamerasWorker +from .preview import PreviewSession, PreviewState, apply_crop, apply_rotation, resize_to_fit, to_display_pixmap +from .ui_blocks import setup_camera_config_dialog_ui LOGGER = logging.getLogger(__name__) LOGGER.setLevel(logging.DEBUG) # TODO @C-Achard remove for release -def _apply_detected_identity(cam: CameraSettings, detected: DetectedCamera, backend: str) -> None: - """Persist stable identity from a detected camera into cam.properties under backend namespace.""" - if not isinstance(cam.properties, dict): - cam.properties = {} - - ns = cam.properties.get(backend.lower()) - if not isinstance(ns, dict): - ns = {} - cam.properties[backend.lower()] = ns - - # Store whatever we have (backend-specific but written generically) - if getattr(detected, "device_id", None): - ns["device_id"] = detected.device_id - if getattr(detected, "vid", None) is not None: - ns["device_vid"] = int(detected.vid) - if getattr(detected, "pid", None) is not None: - ns["device_pid"] = int(detected.pid) - if getattr(detected, "path", None): - ns["device_path"] = detected.path - - # Optional: store human name for matching fallback - if getattr(detected, "label", None): - ns["device_name"] = detected.label - - # Optional: store backend_hint if you expose it (e.g., CAP_DSHOW) - if getattr(detected, "backend_hint", None) is not None: - ns["backend_hint"] = int(detected.backend_hint) - - -# ------------------------------- -# Background worker to detect cameras -# ------------------------------- -class DetectCamerasWorker(QThread): - """Background worker to detect cameras for the selected backend.""" - - progress = Signal(str) # human-readable text - result = Signal(list) # list[DetectedCamera] - error = Signal(str) - finished = Signal() - - def __init__(self, backend: str, max_devices: int = 10, parent: QWidget | None = None): - super().__init__(parent) - self.backend = backend - self.max_devices = max_devices - - def run(self): - try: - # Initial message - self.progress.emit(f"Scanning {self.backend} cameras…") - - cams = CameraFactory.detect_cameras( - self.backend, - max_devices=self.max_devices, - should_cancel=self.isInterruptionRequested, - progress_cb=self.progress.emit, - ) - self.result.emit(cams) - except Exception as exc: - self.error.emit(f"{type(exc).__name__}: {exc}") - finally: - self.finished.emit() - - -class CameraProbeWorker(QThread): - """Request a quick device probe (open/close) without starting preview.""" - - progress = Signal(str) - success = Signal(object) # emits CameraSettings - error = Signal(str) - finished = Signal() - - def __init__(self, cam: CameraSettings, parent: QWidget | None = None): - super().__init__(parent) - self._cam = copy.deepcopy(cam) - self._cancel = False - - # Enable fast_start when supported (backend reads namespace options) - if isinstance(self._cam.properties, dict): - ns = self._cam.properties.setdefault(self._cam.backend.lower(), {}) - if isinstance(ns, dict): - ns.setdefault("fast_start", True) - - def request_cancel(self): - self._cancel = True - - def run(self): - try: - self.progress.emit("Probing device defaults…") - if self._cancel: - return - self.success.emit(self._cam) - except Exception as exc: - self.error.emit(f"{type(exc).__name__}: {exc}") - finally: - self.finished.emit() - - -# ------------------------------- -# Singleton camera preview loader worker -# ------------------------------- -class CameraLoadWorker(QThread): - """Open/configure a camera backend off the UI thread with progress and cancel support.""" - - progress = Signal(str) # Human-readable status updates - success = Signal(object) # Emits the ready backend (CameraBackend) - error = Signal(str) # Emits error message - canceled = Signal() # Emits when canceled before success - - def __init__(self, cam: CameraSettings, parent: QWidget | None = None): - super().__init__(parent) - self._cam = copy.deepcopy(cam) - - self._cancel = False - self._backend: CameraBackend | None = None - - # Do not use fast_start here as we want to actually open the camera to probe capabilities - # If you want a quick probe without full open, use CameraProbeWorker instead which sets fast_start=True - # Ensure preview open never uses fast_start probe mode - if isinstance(self._cam.properties, dict): - ns = self._cam.properties.setdefault(self._cam.backend.lower(), {}) - if isinstance(ns, dict): - ns["fast_start"] = False - - def request_cancel(self): - self._cancel = True - - def _check_cancel(self) -> bool: - if self._cancel: - self.progress.emit("Canceled by user.") - return True - return False - - def run(self): - try: - self.progress.emit("Creating backend…") - if self._check_cancel(): - self.canceled.emit() - return - - LOGGER.debug("Creating camera backend for %s:%d", self._cam.backend, self._cam.index) - self.progress.emit("Opening device…") - # Open only in GUI thread to avoid simultaneous opens - self.success.emit(self._cam) - - except Exception as exc: - msg = f"{type(exc).__name__}: {exc}" - try: - if self._backend: - self._backend.close() - except Exception: - pass - self.error.emit(msg) - - class CameraConfigDialog(QDialog): """Dialog for configuring multiple cameras with async preview loading.""" @@ -207,6 +36,10 @@ class CameraConfigDialog(QDialog): scan_started = Signal(str) scan_finished = Signal() + # ------------------------------- + # Constructor, properties, Qt lifecycle + # ------------------------------- + def __init__( self, parent: QWidget | None = None, @@ -216,10 +49,10 @@ def __init__( self.setWindowTitle("Configure Cameras") self.setMinimumSize(960, 720) - self._dlc_camera_id = None - self.dlc_camera_id: str | None = None + self._dlc_camera_id: str | None = None + # self.dlc_camera_id: str | None = None # Actual/working camera settings - self._multi_camera_settings = multi_camera_settings + self._multi_camera_settings = multi_camera_settings or MultiCameraSettings(cameras=[]) self._working_settings = self._multi_camera_settings.model_copy(deep=True) self._detected_cameras: list[DetectedCamera] = [] self._probe_apply_to_requested: bool = False @@ -228,19 +61,12 @@ def __init__( self._suppress_selection_actions: bool = False # Preview state - self._preview_backend: CameraBackend | None = None - self._preview_timer: QTimer | None = None - self._preview_active: bool = False - self._preview_starting: bool = False + self._preview: PreviewSession = PreviewSession() # Camera detection worker self._scan_worker: DetectCamerasWorker | None = None - # Singleton loader per dialog - self._loader: CameraLoadWorker | None = None - self._loading_active: bool = False - - # UI elements for eventFilter + # UI elements for eventFilter (assigned in _setup_ui) self._settings_scroll: QScrollArea | None = None self._settings_scroll_contents: QWidget | None = None @@ -259,442 +85,6 @@ def dlc_camera_id(self, value: str | None) -> None: self._dlc_camera_id = value self._refresh_camera_labels() - # ------------------------------- - # Config helpers - # ------------------------------ - - def _build_model_from_form(self, base: CameraSettings) -> CameraSettings: - # construct a dict from form widgets; Pydantic will coerce/validate - payload = base.model_dump() - payload.update( - { - "enabled": bool(self.cam_enabled_checkbox.isChecked()), - "width": int(self.cam_width.value()), - "height": int(self.cam_height.value()), - "fps": float(self.cam_fps.value()), - "exposure": int(self.cam_exposure.value()) if self.cam_exposure.isEnabled() else 0, - "gain": float(self.cam_gain.value()) if self.cam_gain.isEnabled() else 0.0, - "rotation": int(self.cam_rotation.currentData() or 0), - "crop_x0": int(self.cam_crop_x0.value()), - "crop_y0": int(self.cam_crop_y0.value()), - "crop_x1": int(self.cam_crop_x1.value()), - "crop_y1": int(self.cam_crop_y1.value()), - } - ) - # Validate and coerce; if invalid, Pydantic will raise - return CameraSettings.model_validate(payload) - - def _merge_backend_settings_back(self, opened_settings: CameraSettings) -> None: - """Merge identity/index changes learned during preview open back into the working settings.""" - if self._current_edit_index is None: - return - row = self._current_edit_index - if row < 0 or row >= len(self._working_settings.cameras): - return - - target = self._working_settings.cameras[row] - - # Update index if backend rebinding occurred - try: - target.index = int(opened_settings.index) - except Exception: - pass - - if isinstance(opened_settings.properties, dict): - if not isinstance(target.properties, dict): - target.properties = {} - for k, v in opened_settings.properties.items(): - if isinstance(v, dict) and isinstance(target.properties.get(k), dict): - target.properties[k].update(v) - else: - target.properties[k] = v - - # Update UI list item text to reflect any changes - self._update_active_list_item(row, target) - - # ------------------------------- - # UI setup - # ------------------------------- - def _set_detected_labels(self, cam: CameraSettings) -> None: - """Update the read-only detected labels based on cam.properties[backend].""" - backend = (cam.backend or "").lower() - props = cam.properties if isinstance(cam.properties, dict) else {} - ns = props.get(backend, {}) if isinstance(props.get(backend, None), dict) else {} - - det_res = ns.get("detected_resolution") - det_fps = ns.get("detected_fps") - - if isinstance(det_res, (list, tuple)) and len(det_res) == 2: - try: - w, h = int(det_res[0]), int(det_res[1]) - self.detected_resolution_label.setText(f"{w}×{h}") - except Exception: - self.detected_resolution_label.setText("—") - else: - self.detected_resolution_label.setText("—") - - if isinstance(det_fps, (int, float)) and float(det_fps) > 0: - self.detected_fps_label.setText(f"{float(det_fps):.2f}") - else: - self.detected_fps_label.setText("—") - - def _setup_ui(self) -> None: - # Main layout for the dialog - main_layout = QVBoxLayout(self) - - # Horizontal layout for left and right panels - panels_layout = QHBoxLayout() - - # Left panel: Camera list and controls - left_panel = QWidget() - left_layout = QVBoxLayout(left_panel) - - # Active cameras list - active_group = QGroupBox("Active Cameras") - active_layout = QVBoxLayout(active_group) - - self.active_cameras_list = QListWidget() - self.active_cameras_list.setMinimumWidth(250) - active_layout.addWidget(self.active_cameras_list) - - # Buttons for managing active cameras - list_buttons = QHBoxLayout() - self.remove_camera_btn = QPushButton("Remove") - self.remove_camera_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon)) - self.remove_camera_btn.setEnabled(False) - self.move_up_btn = QPushButton("↑") - self.move_up_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowUp)) - self.move_up_btn.setEnabled(False) - self.move_down_btn = QPushButton("↓") - self.move_down_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) - self.move_down_btn.setEnabled(False) - list_buttons.addWidget(self.remove_camera_btn) - list_buttons.addWidget(self.move_up_btn) - list_buttons.addWidget(self.move_down_btn) - active_layout.addLayout(list_buttons) - - left_layout.addWidget(active_group) - - # Available cameras section - available_group = QGroupBox("Available Cameras") - available_layout = QVBoxLayout(available_group) - - # Backend selection - backend_layout = QHBoxLayout() - backend_layout.addWidget(QLabel("Backend:")) - self.backend_combo = QComboBox() - availability = CameraFactory.available_backends() - for backend in CameraFactory.backend_names(): - label = backend - if not availability.get(backend, True): - label = f"{backend} (unavailable)" - self.backend_combo.addItem(label, backend) - if self.backend_combo.count() == 0: - raise RuntimeError("No camera backends are registered!") - # Switch to first available backend - for i in range(self.backend_combo.count()): - backend = self.backend_combo.itemData(i) - if availability.get(backend, False): - self.backend_combo.setCurrentIndex(i) - break - backend_layout.addWidget(self.backend_combo) - self.refresh_btn = QPushButton("Refresh") - self.refresh_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload)) - backend_layout.addWidget(self.refresh_btn) - available_layout.addLayout(backend_layout) - - self.available_cameras_list = QListWidget() - available_layout.addWidget(self.available_cameras_list) - - # Show status overlay during scan - self._scan_overlay = QLabel(available_group) - self._scan_overlay.setVisible(False) - self._scan_overlay.setAlignment(Qt.AlignCenter) - self._scan_overlay.setWordWrap(True) - self._scan_overlay.setStyleSheet( - "background-color: rgba(0, 0, 0, 140);color: white;padding: 12px;border: 1px solid #333;font-size: 12px;" - ) - self._scan_overlay.setText("Discovering cameras…") - self.available_cameras_list.installEventFilter(self) - - # Indeterminate progress bar + status text for async scan - self.scan_progress = QProgressBar() - self.scan_progress.setRange(0, 0) - self.scan_progress.setVisible(False) - - available_layout.addWidget(self.scan_progress) - - self.scan_cancel_btn = QPushButton("Cancel Scan") - self.scan_cancel_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserStop)) - self.scan_cancel_btn.setVisible(False) - self.scan_cancel_btn.clicked.connect(self._on_scan_cancel) - available_layout.addWidget(self.scan_cancel_btn) - - self.add_camera_btn = QPushButton("Add Selected Camera →") - self.add_camera_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowRight)) - self.add_camera_btn.setEnabled(False) - available_layout.addWidget(self.add_camera_btn) - - left_layout.addWidget(available_group) - - # Right panel: Camera settings editor - right_panel = QWidget() - right_layout = QVBoxLayout(right_panel) - - settings_group = QGroupBox("Camera Settings") - self.settings_form = QFormLayout(settings_group) - self.settings_form.setVerticalSpacing(6) - self.settings_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow) - - # --- Basic toggles/labels --- - self.cam_enabled_checkbox = QCheckBox("Enabled") - self.cam_enabled_checkbox.setChecked(True) - self.settings_form.addRow(self.cam_enabled_checkbox) - - self.cam_name_label = QLabel("Camera 0") - self.cam_name_label.setStyleSheet("font-weight: bold; font-size: 14px;") - self.settings_form.addRow("Name:", self.cam_name_label) - - self.cam_device_name_label = ElidingPathLabel("") - self.cam_device_name_label.setTextInteractionFlags(Qt.TextSelectableByMouse) - self.cam_device_name_label.setWordWrap(True) - self.settings_form.addRow("Device ID:", self.cam_device_name_label) - - self.cam_index_label = QLabel("0") - # self.settings_form.addRow("Index:", self.cam_index_label) - - self.cam_backend_label = QLabel("opencv") - # self.settings_form.addRow("Backend:", self.cam_backend_label) - id_backend_row = _make_two_field_row( - "Index:", self.cam_index_label, "Backend:", self.cam_backend_label, key_width=120, gap=15 - ) - self.settings_form.addRow(id_backend_row) - - # --- Detected read-only labels (do NOT change requested values) --- - self.detected_resolution_label = QLabel("—") - self.detected_resolution_label.setTextInteractionFlags(Qt.TextSelectableByMouse) - - self.detected_fps_label = QLabel("—") - self.detected_fps_label.setTextInteractionFlags(Qt.TextSelectableByMouse) - detected_row = _make_two_field_row( - "Detected resolution:", - self.detected_resolution_label, - "Detected FPS:", - self.detected_fps_label, - key_width=120, - gap=10, - ) - self.settings_form.addRow(detected_row) - - # --- Requested resolution controls (Auto = 0) --- - self.cam_width = QSpinBox() - self.cam_width.setRange(0, 10000) - self.cam_width.setValue(0) - self.cam_width.setSpecialValueText("Auto") - - self.cam_height = QSpinBox() - self.cam_height.setRange(0, 10000) - self.cam_height.setValue(0) - self.cam_height.setSpecialValueText("Auto") - - res_row = _make_two_field_row("W", self.cam_width, "H", self.cam_height, key_width=30) - self.settings_form.addRow("Resolution:", res_row) - - # --- FPS + Rotation grouped (CREATE cam_rotation ONCE) --- - self.cam_fps = QDoubleSpinBox() - self.cam_fps.setRange(0.0, 240.0) - self.cam_fps.setDecimals(2) - self.cam_fps.setSingleStep(1.0) - self.cam_fps.setValue(0.0) - self.cam_fps.setSpecialValueText("Auto") - - self.cam_rotation = QComboBox() - self.cam_rotation.addItem("0°", 0) - self.cam_rotation.addItem("90°", 90) - self.cam_rotation.addItem("180°", 180) - self.cam_rotation.addItem("270°", 270) - - fps_rot_row = _make_two_field_row("FPS", self.cam_fps, "Rot", self.cam_rotation, key_width=30) - self.settings_form.addRow("Capture:", fps_rot_row) - - # --- Exposure + Gain grouped --- - self.cam_exposure = QSpinBox() - self.cam_exposure.setRange(0, 1000000) - self.cam_exposure.setValue(0) - self.cam_exposure.setSpecialValueText("Auto") - self.cam_exposure.setSuffix(" μs") - - self.cam_gain = QDoubleSpinBox() - self.cam_gain.setRange(0.0, 100.0) - self.cam_gain.setValue(0.0) - self.cam_gain.setSpecialValueText("Auto") - self.cam_gain.setDecimals(2) - - exp_gain_row = _make_two_field_row("Exp", self.cam_exposure, "Gain", self.cam_gain, key_width=30) - self.settings_form.addRow("Analog:", exp_gain_row) - - # --- Crop row (keep as you already have it) --- - crop_widget = QWidget() - crop_layout = QHBoxLayout(crop_widget) - crop_layout.setContentsMargins(0, 0, 0, 0) - - self.cam_crop_x0 = ScrubSpinBox() - self.cam_crop_x0.setRange(0, 7680) - self.cam_crop_x0.setPrefix("x0:") - self.cam_crop_x0.setSpecialValueText("x0:None") - crop_layout.addWidget(self.cam_crop_x0) - - self.cam_crop_y0 = ScrubSpinBox() - self.cam_crop_y0.setRange(0, 4320) - self.cam_crop_y0.setPrefix("y0:") - self.cam_crop_y0.setSpecialValueText("y0:None") - crop_layout.addWidget(self.cam_crop_y0) - - self.cam_crop_x1 = ScrubSpinBox() - self.cam_crop_x1.setRange(0, 7680) - self.cam_crop_x1.setPrefix("x1:") - self.cam_crop_x1.setSpecialValueText("x1:None") - crop_layout.addWidget(self.cam_crop_x1) - - self.cam_crop_y1 = ScrubSpinBox() - self.cam_crop_y1.setRange(0, 4320) - self.cam_crop_y1.setPrefix("y1:") - self.cam_crop_y1.setSpecialValueText("y1:None") - crop_layout.addWidget(self.cam_crop_y1) - - self.settings_form.addRow("Crop:", crop_widget) - - self.apply_settings_btn = QPushButton("Apply Settings") - self.apply_settings_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton)) - self.apply_settings_btn.setEnabled(False) - # self.settings_form.addRow(self.apply_settings_btn) - - self.reset_settings_btn = QPushButton("Reset Settings") - self.reset_settings_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogResetButton)) - self.reset_settings_btn.setEnabled(False) - # self.settings_form.addRow(self.reset_settings_btn) - - sttgs_buttons_row = QWidget() - sttgs_button_layout = QHBoxLayout(sttgs_buttons_row) - sttgs_button_layout.setContentsMargins(0, 0, 0, 0) - sttgs_button_layout.setSpacing(8) - sttgs_button_layout.addWidget(self.apply_settings_btn) - sttgs_button_layout.addWidget(self.reset_settings_btn) - - self.settings_form.addRow(sttgs_buttons_row) - - self.preview_btn = QPushButton("Start Preview") - self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) - self.preview_btn.setEnabled(False) - self.settings_form.addRow(self.preview_btn) - - # ---------------------------- - # Preview group - # ---------------------------- - self.preview_group = QGroupBox("Camera Preview") - preview_layout = QVBoxLayout(self.preview_group) - - self.preview_label = QLabel("No preview") - self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.preview_label.setMinimumSize(320, 240) - self.preview_label.setMaximumSize(400, 300) - self.preview_label.setStyleSheet("background-color: #1a1a1a; color: #888;") - preview_layout.addWidget(self.preview_label) - self.preview_label.installEventFilter(self) - - self.preview_status = QTextEdit() - self.preview_status.setReadOnly(True) - self.preview_status.setFixedHeight(45) - self.preview_status.setStyleSheet( - "QTextEdit { background: #141414; color: #bdbdbd; border: 1px solid #2a2a2a; }" - ) - font = QFont("Consolas") - font.setPointSize(9) - self.preview_status.setFont(font) - preview_layout.addWidget(self.preview_status) - - self._loading_overlay = QLabel(self.preview_group) - self._loading_overlay.setVisible(False) - self._loading_overlay.setAlignment(Qt.AlignCenter) - self._loading_overlay.setStyleSheet("background-color: rgba(0,0,0,140); color: white; border: 1px solid #333;") - self._loading_overlay.setText("Loading camera…") - - self.preview_group.setVisible(False) - - # ---------------------------- - # Scroll area to prevent squishing - # ---------------------------- - scroll = QScrollArea() - # scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) - scroll.setWidgetResizable(True) - scroll.setFrameShape(QScrollArea.NoFrame) - - scroll_contents = QWidget() - scroll_contents.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred) - self._settings_scroll = scroll - self._settings_scroll_contents = scroll_contents - scroll_contents.setMinimumWidth(scroll.viewport().width()) - scroll.viewport().installEventFilter(self) - scroll_layout = QVBoxLayout(scroll_contents) - scroll_layout.setContentsMargins(0, 0, 0, 10) - scroll_layout.setSpacing(10) - - # Give groups a sane size policy; scroll handles overflow - settings_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum) - self.preview_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum) - - scroll_layout.addWidget(settings_group) - scroll_layout.addWidget(self.preview_group) - scroll_layout.addStretch(1) - - scroll.setWidget(scroll_contents) - right_layout.addWidget(scroll) - - # Dialog buttons - sttgs_button_layout = QHBoxLayout() - self.ok_btn = QPushButton("OK") - self.ok_btn.setAutoDefault(False) - self.ok_btn.setDefault(False) - self.ok_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogOkButton)) - self.cancel_btn = QPushButton("Cancel") - self.cancel_btn.setAutoDefault(False) - self.cancel_btn.setDefault(False) - self.cancel_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogCancelButton)) - sttgs_button_layout.addStretch(1) - sttgs_button_layout.addWidget(self.ok_btn) - sttgs_button_layout.addWidget(self.cancel_btn) - - # Add panels to horizontal layout - panels_layout.addWidget(left_panel, stretch=1) - panels_layout.addWidget(right_panel, stretch=1) - - # Add everything to main layout - main_layout.addLayout(panels_layout) - main_layout.addLayout(sttgs_button_layout) - - # Pressing enter on any settings field applies settings - self.cam_fps.setKeyboardTracking(False) - fields = [ - self.cam_enabled_checkbox, - self.cam_width, - self.cam_height, - self.cam_fps, - self.cam_exposure, - self.cam_gain, - self.cam_crop_x0, - self.cam_crop_y0, - self.cam_crop_x1, - self.cam_crop_y1, - ] - for field in fields: - if hasattr(field, "lineEdit"): - if hasattr(field.lineEdit(), "returnPressed"): - field.lineEdit().returnPressed.connect(self._apply_camera_settings) - if hasattr(field, "installEventFilter"): - field.installEventFilter(self) - # Maintain overlay geometry when resizing def resizeEvent(self, event): super().resizeEvent(event) @@ -719,7 +109,7 @@ def eventFilter(self, obj, event): return False # allow normal processing # Keep your existing overlay resize handling - if obj is self.available_cameras_list and event.type() == event.Type.Resize: + if obj is self.available_cameras_list and event.type() == QEvent.Type.Resize: if self._scan_overlay and self._scan_overlay.isVisible(): self._position_scan_overlay() return super().eventFilter(obj, event) @@ -750,6 +140,82 @@ def eventFilter(self, obj, event): return super().eventFilter(obj, event) + def closeEvent(self, event): + """Handle dialog close event to ensure cleanup.""" + self._on_close_cleanup() + super().closeEvent(event) + + def reject(self) -> None: + """Handle dialog rejection (Cancel or close).""" + self._on_close_cleanup() + super().reject() + + def _on_close_cleanup(self) -> None: + """Stop preview, cancel workers, and reset scan UI. Safe to call multiple times.""" + # Guard to avoid running twice if closeEvent + reject/accept both run + if getattr(self, "_cleanup_done", False): + return + self._cleanup_done = True + + # Stop preview (loader + backend + timer) + try: + self._stop_preview() + except Exception: + LOGGER.exception("Cleanup: failed stopping preview") + + # Cancel scan worker + sw = getattr(self, "_scan_worker", None) + if sw and sw.isRunning(): + try: + sw.requestInterruption() + except Exception: + pass + # Keep this short to reduce UI freeze + sw.wait(300) + self._scan_worker = None + + # Cancel probe worker + pw = getattr(self, "_probe_worker", None) + if pw and pw.isRunning(): + try: + pw.request_cancel() + except Exception: + pass + pw.wait(300) + self._probe_worker = None + + # Hide overlays / reset UI bits + try: + self._hide_scan_overlay() + except Exception: + pass + + # Defensive: some widgets may not exist depending on UI setup timing + for w, visible, enabled in ( + ("scan_progress", False, None), + ("scan_cancel_btn", False, True), + ("refresh_btn", None, True), + ("backend_combo", None, True), + ): + widget = getattr(self, w, None) + if widget is None: + continue + if visible is not None: + widget.setVisible(visible) + if enabled is not None: + widget.setEnabled(enabled) + + try: + self._sync_scan_ui() + except Exception: + pass + + # ------------------------------- + # UI setup + # ------------------------------- + def _setup_ui(self) -> None: + setup_camera_config_dialog_ui(self) + def _position_scan_overlay(self) -> None: """Position scan overlay to cover the available_cameras_list area.""" if not self._scan_overlay or not self.available_cameras_list: @@ -775,30 +241,8 @@ def _position_loading_overlay(self): rect = self.preview_label.rect() self._loading_overlay.setGeometry(gp.x(), gp.y(), rect.width(), rect.height()) - def _camera_identity_key(self, cam: CameraSettings) -> tuple: - backend = (cam.backend or "").lower() - props = cam.properties if isinstance(cam.properties, dict) else {} - ns = props.get(backend, {}) if isinstance(props, dict) else {} - device_id = ns.get("device_id") - - # Prefer stable identity if present, otherwise fallback - if device_id: - return (backend, "device_id", device_id) - return (backend, "index", int(cam.index)) - - def _set_apply_dirty(self, dirty: bool) -> None: - """Visually mark Apply Settings button as 'dirty' (pending edits).""" - if dirty: - self.apply_settings_btn.setText("Apply Settings *") - self.apply_settings_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxWarning)) - self.apply_settings_btn.setToolTip("You have unapplied changes. Click to apply them.") - else: - self.apply_settings_btn.setText("Apply Settings") - self.apply_settings_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton)) - self.apply_settings_btn.setToolTip("") - # ------------------------------- - # Signals / population + # Signal setup # ------------------------------- def _connect_signals(self) -> None: self.backend_combo.currentIndexChanged.connect(self._on_backend_changed) @@ -817,6 +261,7 @@ def _connect_signals(self) -> None: self.cancel_btn.clicked.connect(self.reject) self.scan_started.connect(lambda _: setattr(self, "_dialog_active", True)) self.scan_finished.connect(lambda: setattr(self, "_dialog_active", False)) + self.scan_cancel_btn.clicked.connect(self._on_scan_cancel) def _mark_dirty(*_args): self.apply_settings_btn.setEnabled(True) @@ -838,26 +283,104 @@ def _mark_dirty(*_args): self.cam_rotation.currentIndexChanged.connect(lambda *_: _mark_dirty()) self.cam_enabled_checkbox.stateChanged.connect(lambda *_: _mark_dirty()) - self.cam_rotation.currentIndexChanged.connect(lambda _: self.apply_settings_btn.setEnabled(True)) - - def _populate_from_settings(self) -> None: - """Populate the dialog from existing settings.""" - self.active_cameras_list.clear() - for i, cam in enumerate(self._working_settings.cameras): - item = QListWidgetItem(self._format_camera_label(cam, i)) - item.setData(Qt.ItemDataRole.UserRole, cam) - if not cam.enabled: - item.setForeground(Qt.GlobalColor.gray) - self.active_cameras_list.addItem(item) - self._refresh_available_cameras() - self._update_button_states() - - def _format_camera_label(self, cam: CameraSettings, index: int = -1) -> str: - status = "✓" if cam.enabled else "○" - this_id = f"{cam.backend}:{cam.index}" - dlc_indicator = " [DLC]" if this_id == self._dlc_camera_id and cam.enabled else "" - return f"{status} {cam.name} [{cam.backend}:{cam.index}]{dlc_indicator}" + # ------------------------------- + # UI state updates + # ------------------------------- + def _set_apply_dirty(self, dirty: bool) -> None: + """Visually mark Apply Settings button as 'dirty' (pending edits).""" + if dirty: + self.apply_settings_btn.setText("Apply Settings *") + self.apply_settings_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxWarning)) + self.apply_settings_btn.setToolTip("You have unapplied changes. Click to apply them.") + else: + self.apply_settings_btn.setText("Apply Settings") + self.apply_settings_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton)) + self.apply_settings_btn.setToolTip("") + + def _update_button_states(self) -> None: + scan_running = self._is_scan_running() + + active_row = self.active_cameras_list.currentRow() + has_active_selection = active_row >= 0 + allow_structure_edits = has_active_selection and not scan_running + + self.remove_camera_btn.setEnabled(allow_structure_edits) + self.move_up_btn.setEnabled(allow_structure_edits and active_row > 0) + self.move_down_btn.setEnabled(allow_structure_edits and active_row < self.active_cameras_list.count() - 1) + # During loading, preview button becomes "Cancel Loading" + self.preview_btn.setEnabled(has_active_selection or self._preview.state == PreviewState.LOADING) + available_row = self.available_cameras_list.currentRow() + self.add_camera_btn.setEnabled(available_row >= 0 and not scan_running) + + def _sync_scan_ui(self) -> None: + """ + Sync *scan-related* UI controls based on scan state. + + Conservative policy during scan: + - Allow editing/previewing already configured cameras (Active list) + - Disallow structural changes (add/remove/reorder) and available-list actions + """ + scanning = self._is_scan_running() + + # Discovery controls + self.backend_combo.setEnabled(not scanning) + self.refresh_btn.setEnabled(not scanning) + + # Available camera list + add flow is blocked during scan + self.available_cameras_list.setEnabled(not scanning) + self.add_camera_btn.setEnabled(False if scanning else (self.available_cameras_list.currentRow() >= 0)) + + # Scan cancel button visibility is already managed in your scan start/finish, + # but keeping enabled state here makes it robust. + if hasattr(self, "scan_cancel_btn"): + self.scan_cancel_btn.setEnabled(scanning) + + def _sync_preview_ui(self) -> None: + """Update buttons/overlays based on preview state only.""" + st = self._preview.state + + if st == PreviewState.LOADING: + self._set_preview_button_loading(True) + self.preview_btn.setEnabled(True) + self.preview_group.setVisible(True) + elif st == PreviewState.ACTIVE: + self._set_preview_button_loading(False) + self.preview_btn.setText("Stop Preview") + self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop)) + self.preview_btn.setEnabled(True) + self.preview_group.setVisible(True) + else: # IDLE / STOPPING / ERROR + self._set_preview_button_loading(False) + self.preview_btn.setText("Start Preview") + self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) + self.preview_btn.setEnabled(self.active_cameras_list.currentRow() >= 0) + self.preview_group.setVisible(False) + + self._update_button_states() + + def _set_detected_labels(self, cam: CameraSettings) -> None: + """Update the read-only detected labels based on cam.properties[backend].""" + backend = (cam.backend or "").lower() + props = cam.properties if isinstance(cam.properties, dict) else {} + ns = props.get(backend, {}) if isinstance(props.get(backend, None), dict) else {} + + det_res = ns.get("detected_resolution") + det_fps = ns.get("detected_fps") + + if isinstance(det_res, (list, tuple)) and len(det_res) == 2: + try: + w, h = int(det_res[0]), int(det_res[1]) + self.detected_resolution_label.setText(f"{w}×{h}") + except Exception: + self.detected_resolution_label.setText("—") + else: + self.detected_resolution_label.setText("—") + + if isinstance(det_fps, (int, float)) and float(det_fps) > 0: + self.detected_fps_label.setText(f"{float(det_fps):.2f}") + else: + self.detected_fps_label.setText("—") def _refresh_camera_labels(self) -> None: cam_list = getattr(self, "active_cameras_list", None) @@ -874,11 +397,26 @@ def _refresh_camera_labels(self) -> None: finally: cam_list.blockSignals(False) - def _on_backend_changed(self, _index: int) -> None: - self._refresh_available_cameras() + def _format_camera_label(self, cam: CameraSettings, index: int = -1) -> str: + status = "✓" if cam.enabled else "○" + this_id = f"{cam.backend}:{cam.index}" + dlc_indicator = " [DLC]" if this_id == self._dlc_camera_id and cam.enabled else "" + return f"{status} {cam.name} [{cam.backend}:{cam.index}]{dlc_indicator}" - def _is_backend_opencv(self, backend_name: str) -> bool: - return backend_name.lower() == "opencv" + def _update_active_list_item(self, row: int, cam: CameraSettings) -> None: + """Refresh the active camera list row text and color.""" + item = self.active_cameras_list.item(row) + if not item: + return + self._suppress_selection_actions = True # prevent unwanted selection change events during update + try: + item.setText(self._format_camera_label(cam, row)) + item.setData(Qt.ItemDataRole.UserRole, cam) + item.setForeground(Qt.GlobalColor.gray if not cam.enabled else Qt.GlobalColor.black) + self._refresh_camera_labels() + self._update_button_states() + finally: + self._suppress_selection_actions = False def _update_controls_for_backend(self, backend_name: str) -> None: backend_key = (backend_name or "opencv").lower() @@ -912,6 +450,37 @@ def apply(widget, feature: str, label: str, *, allow_best_effort: bool = True): apply(self.cam_exposure, "set_exposure", "Exposure") apply(self.cam_gain, "set_gain", "Gain") + def _set_preview_button_loading(self, loading: bool) -> None: + if loading: + self.preview_btn.setText("Cancel Loading") + self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserStop)) + else: + self.preview_btn.setText("Start Preview") + self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) + + def _show_loading_overlay(self, message: str) -> None: + self._loading_overlay.setText(message) + self._loading_overlay.setVisible(True) + self._position_loading_overlay() + + def _hide_loading_overlay(self) -> None: + self._loading_overlay.setVisible(False) + + def _append_status(self, text: str) -> None: + LOGGER.debug(f"Preview status: {text}") + self.preview_status.append(text) + self.preview_status.moveCursor(QTextCursor.End) + self.preview_status.ensureCursorVisible() + + # ------------------------------- + # Camera discovery and probing + # ------------------------------- + def _on_backend_changed(self, _index: int) -> None: + self._refresh_available_cameras() + + def _is_scan_running(self) -> bool: + return bool(self._scan_worker and self._scan_worker.isRunning()) + def _refresh_available_cameras(self) -> None: """Refresh the list of available cameras asynchronously.""" backend = self.backend_combo.currentData() @@ -931,10 +500,14 @@ def _refresh_available_cameras(self) -> None: self.scan_progress.setRange(0, 0) self.scan_progress.setVisible(True) self.scan_cancel_btn.setVisible(True) + self.available_cameras_list.setEnabled(False) self.add_camera_btn.setEnabled(False) self.refresh_btn.setEnabled(False) self.backend_combo.setEnabled(False) + self._sync_scan_ui() + self._update_button_states() + # Start worker self._scan_worker = DetectCamerasWorker(backend, max_devices=10, parent=self) self._scan_worker.progress.connect(self._on_scan_progress) @@ -974,9 +547,11 @@ def _on_scan_finished(self) -> None: self.scan_cancel_btn.setVisible(False) self.scan_cancel_btn.setEnabled(True) + self.available_cameras_list.setEnabled(True) self.refresh_btn.setEnabled(True) self.backend_combo.setEnabled(True) + self._sync_scan_ui() self._update_button_states() self.scan_finished.emit() @@ -993,22 +568,29 @@ def _on_scan_cancel(self) -> None: self.scan_cancel_btn.setEnabled(False) def _on_available_camera_selected(self, row: int) -> None: - self.add_camera_btn.setEnabled(row >= 0) + if self._scan_worker and self._scan_worker.isRunning(): + self.add_camera_btn.setEnabled(False) + return + self.add_camera_btn.setEnabled(row >= 0 and not self._is_scan_running()) def _on_available_camera_double_clicked(self, item: QListWidgetItem) -> None: + if self._is_scan_running(): + return self._add_selected_camera() + # ------------------------------- + # Active camera selection and list + # ------------------------------- def _on_active_camera_selected(self, row: int) -> None: - if getattr(self, "_suppress_selection_change", False): + if getattr(self, "_suppress_selection_actions", False): LOGGER.debug("[Selection] Suppressed currentRowChanged event at index %d.", row) return prev_row = self._current_edit_index LOGGER.info( - "[Select] row=%s prev=%s preview_active=%s loading_active=%s", + "[Select] row=%s prev=%s preview_state=%s", row, prev_row, - self._preview_active, - self._loading_active, + self._preview.state, ) if row is None or row < 0: LOGGER.debug( @@ -1035,7 +617,7 @@ def _on_active_camera_selected(self, row: int) -> None: return # Stop any running preview when selection changes - if self._preview_active: + if self._preview.state in (PreviewState.ACTIVE, PreviewState.LOADING): self._stop_preview() self._current_edit_index = row @@ -1052,92 +634,128 @@ def _on_active_camera_selected(self, row: int) -> None: self._load_camera_to_form(cam) self._start_probe_for_camera(cam, apply_to_requested=False) - # ------------------------------- - # UI helpers/actions - # ------------------------------- + def _add_selected_camera(self) -> None: + if not self._commit_pending_edits(reason="before adding a new camera"): + return + row = self.available_cameras_list.currentRow() + if row < 0: + return + # limit check + active_count = len( + [ + i + for i in range(self.active_cameras_list.count()) + if self.active_cameras_list.item(i).data(Qt.ItemDataRole.UserRole).enabled + ] + ) + if active_count >= self.MAX_CAMERAS: + QMessageBox.warning(self, "Maximum Cameras", f"Maximum of {self.MAX_CAMERAS} active cameras allowed.") + return + item = self.available_cameras_list.item(row) + detected = item.data(Qt.ItemDataRole.UserRole) + # make sure this is to lower for comparison against camera_identity_key + backend = (self.backend_combo.currentData() or "opencv").lower() - def _should_restart_preview(self, old: CameraSettings, new: CameraSettings) -> bool: - """ - Fast UX policy: - - Do NOT restart for rotation/crop (preview applies those live). - - Restart for camera-side capture params: resolution/fps/exposure/gain. - Backend-agnostic for now (no OpenCV special casing). - """ - # Restart on these changes - for key in ("width", "height", "fps", "exposure", "gain"): - try: - if getattr(old, key, None) != getattr(new, key, None): - return True - except Exception: - return True # safest: restart + det_key = None + if getattr(detected, "device_id", None): + det_key = (backend, "device_id", detected.device_id) + else: + det_key = (backend, "index", int(detected.index)) - # No restart needed if only rotation/crop/enabled changed - return False + for i in range(self.active_cameras_list.count()): + existing_cam = self.active_cameras_list.item(i).data(Qt.ItemDataRole.UserRole) + if camera_identity_key(existing_cam) == det_key: + QMessageBox.warning(self, "Duplicate Camera", "This camera is already in the active list.") + return - def _backend_actual_fps(self) -> float | None: - """Return backend's actual FPS if known; for OpenCV do NOT fall back to settings.fps.""" - if not self._preview_backend: - return None - try: - actual = getattr(self._preview_backend, "actual_fps", None) - if isinstance(actual, (int, float)) and actual > 0: - return float(actual) - return None - except Exception: - return None + new_cam = CameraSettings( + name=detected.label, + index=detected.index, + width=0, + height=0, + fps=0.0, + backend=backend, + exposure=0, + gain=0.0, + enabled=True, + properties={}, + ) + apply_detected_identity(new_cam, detected, backend) + self._working_settings.cameras.append(new_cam) + new_index = len(self._working_settings.cameras) - 1 + new_item = QListWidgetItem(self._format_camera_label(new_cam, new_index)) + new_item.setData(Qt.ItemDataRole.UserRole, new_cam) + self.active_cameras_list.addItem(new_item) + self.active_cameras_list.setCurrentItem(new_item) + self._refresh_camera_labels() + self._update_button_states() + self._start_probe_for_camera(new_cam) - def _adjust_preview_timer_for_fps(self, fps: float | None) -> None: - """Adjust preview cadence to match actual FPS (bounded for CPU).""" - if not self._preview_timer or not fps or fps <= 0: + def _remove_selected_camera(self) -> None: + if not self._commit_pending_edits(reason="before removing a camera"): return - interval_ms = max(15, int(1000.0 / min(max(fps, 1.0), 60.0))) - self._preview_timer.start(interval_ms) - - def _reconcile_fps_from_backend(self, cam: CameraSettings) -> None: - """Reconcile preview cadence to actual FPS without overriding Auto request.""" - if not self._is_backend_opencv(cam.backend): + row = self.active_cameras_list.currentRow() + if row < 0: return + self.active_cameras_list.takeItem(row) + if row < len(self._working_settings.cameras): + del self._working_settings.cameras[row] + self._current_edit_index = None + self._clear_settings_form() + self._refresh_camera_labels() + self._update_button_states() - # If user requested Auto (0), do not overwrite the request. - if float(getattr(cam, "fps", 0.0) or 0.0) <= 0.0: - actual = self._backend_actual_fps() - if actual: - self._append_status(f"[Info] Auto FPS; device reports ~{actual:.2f}. Preview timer adjusted.") - self._adjust_preview_timer_for_fps(actual) - else: - self._append_status("[Info] Auto FPS; OpenCV can't reliably report actual FPS.") + def _move_camera_up(self) -> None: + if not self._commit_pending_edits(reason="before reordering cameras"): return - - # If user requested a specific FPS, optionally clamp UI to actual if measurable. - actual = self._backend_actual_fps() - if actual is None: - self._append_status("[Info] OpenCV can't reliably report actual FPS; keeping requested value.") + row = self.active_cameras_list.currentRow() + if row <= 0: return + item = self.active_cameras_list.takeItem(row) + self.active_cameras_list.insertItem(row - 1, item) + self.active_cameras_list.setCurrentRow(row - 1) + cams = self._working_settings.cameras + cams[row], cams[row - 1] = cams[row - 1], cams[row] + self._refresh_camera_labels() - if abs(cam.fps - actual) > 0.5: - cam.fps = actual - self.cam_fps.setValue(actual) - self._append_status(f"[Info] FPS adjusted to device-supported ~{actual:.2f}.") - self._adjust_preview_timer_for_fps(actual) - else: - self._adjust_preview_timer_for_fps(actual) - - def _update_active_list_item(self, row: int, cam: CameraSettings) -> None: - """Refresh the active camera list row text and color.""" - item = self.active_cameras_list.item(row) - if not item: + def _move_camera_down(self) -> None: + if not self._commit_pending_edits(reason="before reordering cameras"): return - self._suppress_selection_change = True # prevent unwanted selection change events during update - try: - item.setText(self._format_camera_label(cam, row)) - item.setData(Qt.ItemDataRole.UserRole, cam) - item.setForeground(Qt.GlobalColor.gray if not cam.enabled else Qt.GlobalColor.black) - self._refresh_camera_labels() - self._update_button_states() - finally: - self._suppress_selection_change = False + row = self.active_cameras_list.currentRow() + if row < 0 or row >= self.active_cameras_list.count() - 1: + return + item = self.active_cameras_list.takeItem(row) + self.active_cameras_list.insertItem(row + 1, item) + self.active_cameras_list.setCurrentRow(row + 1) + cams = self._working_settings.cameras + cams[row], cams[row + 1] = cams[row + 1], cams[row] + self._refresh_camera_labels() - def _load_camera_to_form(self, cam: CameraSettings) -> None: + # ------------------------------- + # Form/model mapping & settings application + # ------------------------------- + def _build_model_from_form(self, base: CameraSettings) -> CameraSettings: + # construct a dict from form widgets; Pydantic will coerce/validate + payload = base.model_dump() + payload.update( + { + "enabled": bool(self.cam_enabled_checkbox.isChecked()), + "width": int(self.cam_width.value()), + "height": int(self.cam_height.value()), + "fps": float(self.cam_fps.value()), + "exposure": int(self.cam_exposure.value()) if self.cam_exposure.isEnabled() else 0, + "gain": float(self.cam_gain.value()) if self.cam_gain.isEnabled() else 0.0, + "rotation": int(self.cam_rotation.currentData() or 0), + "crop_x0": int(self.cam_crop_x0.value()), + "crop_y0": int(self.cam_crop_y0.value()), + "crop_x1": int(self.cam_crop_x1.value()), + "crop_y1": int(self.cam_crop_y1.value()), + } + ) + # Validate and coerce; if invalid, Pydantic will raise + return CameraSettings.model_validate(payload) + + def _load_camera_to_form(self, cam: CameraSettings) -> None: block = [ self.cam_enabled_checkbox, self.cam_width, @@ -1199,6 +817,113 @@ def _write_form_to_cam(self, cam: CameraSettings) -> None: cam.crop_x1 = int(self.cam_crop_x1.value()) cam.crop_y1 = int(self.cam_crop_y1.value()) + def _commit_pending_edits(self, *, reason: str = "") -> bool: + """ + Auto-apply pending edits (if any) before context-changing actions. + Returns True if it's safe to proceed, False if validation failed. + """ + # No selection → nothing to commit + if self._current_edit_index is None or self._current_edit_index < 0: + return True + + # If Apply button isn't enabled, assume no pending edits + if not self.apply_settings_btn.isEnabled(): + return True + + try: + self._append_status(f"[Auto-Apply] Committing pending edits ({reason})…") + ok = self._apply_camera_settings() + return bool(ok) + except Exception as exc: + # _apply_camera_settings already shows a QMessageBox in many cases, + # but we add a clear guardrail here in case it doesn't. + QMessageBox.warning( + self, + "Unsaved / Invalid Settings", + "Your current camera settings are not valid and cannot be applied yet.\n\n" + "Please fix the highlighted fields (e.g. crop rectangle) or press Reset.\n\n" + f"Details: {exc}", + ) + return False + + def _apply_camera_settings(self) -> bool: + try: + for sb in ( + self.cam_fps, + self.cam_crop_x0, + self.cam_width, + self.cam_height, + self.cam_exposure, + self.cam_gain, + self.cam_crop_y0, + self.cam_crop_x1, + self.cam_crop_y1, + ): + try: + if hasattr(sb, "interpretText"): + sb.interpretText() + except Exception: + pass + if self._current_edit_index is None: + return True + row = self._current_edit_index + if row < 0 or row >= len(self._working_settings.cameras): + return True + + current_model = self._working_settings.cameras[row] + new_model = self._build_model_from_form(current_model) + + diff = CameraSettings.check_diff(current_model, new_model) + + self._working_settings.cameras[row] = new_model + self._update_active_list_item(row, new_model) + + LOGGER.info( + "[Apply] backend=%s idx=%s changes=%s", + getattr(new_model, "backend", None), + getattr(new_model, "index", None), + diff, + ) + + # --- Persist validated model back BEFORE touching preview --- + self._working_settings.cameras[row] = new_model + self._update_active_list_item(row, new_model) + + # Decide whether we need to restart preview (fast UX) + old_settings = None + if self._preview.backend and isinstance(getattr(self._preview.backend, "settings", None), CameraSettings): + old_settings = self._preview.backend.settings + else: + old_settings = current_model + + restart = False + should_consider_restart = self._preview.state == PreviewState.ACTIVE and isinstance( + old_settings, CameraSettings + ) + if should_consider_restart: + restart = self._should_restart_preview(old_settings, new_model) + + LOGGER.info( + "[Apply] preview_state=%s restart=%s backend=%s idx=%s", + self._preview.state, + restart, + new_model.backend, + new_model.index, + ) + + if self._preview.state == PreviewState.ACTIVE and restart: + self._append_status("[Apply] Restarting preview to apply camera settings changes.") + self._request_preview_restart(new_model, reason="apply-settings") + + self.apply_settings_btn.setEnabled(False) + self._set_apply_dirty(False) + return True + + except Exception as exc: + LOGGER.exception("Apply camera settings failed") + QMessageBox.warning(self, "Apply Settings Error", str(exc)) + return False + def _clear_settings_form(self) -> None: self.cam_enabled_checkbox.setChecked(True) self.cam_name_label.setText("") @@ -1220,43 +945,18 @@ def _clear_settings_form(self) -> None: self.apply_settings_btn.setEnabled(False) self.reset_settings_btn.setEnabled(False) - def _start_probe_for_camera(self, cam: CameraSettings, *, apply_to_requested: bool = False) -> None: - """Start a quick probe to fill detected labels. - - If apply_to_requested=True, the probe result will also overwrite the selected camera's - requested width/height/fps with detected device values. - """ - # Don’t probe if preview is active/loading - if self._loading_active or self._preview_active: - return - - # Track probe intent - self._probe_apply_to_requested = bool(apply_to_requested) - self._probe_target_row = int(self._current_edit_index) if self._current_edit_index is not None else None - - # Show current detected values if present - self._set_detected_labels(cam) - - # If we already have detected values and we are NOT applying them, skip probing - backend = (cam.backend or "").lower() - props = cam.properties if isinstance(cam.properties, dict) else {} - ns = props.get(backend, {}) if isinstance(props.get(backend, None), dict) else {} - if not apply_to_requested: - det_res = ns.get("detected_resolution") - if isinstance(det_res, (list, tuple)) and len(det_res) == 2: - try: - if int(det_res[0]) > 0 and int(det_res[1]) > 0: - return - except Exception: - pass + def _populate_from_settings(self) -> None: + """Populate the dialog from existing settings.""" + self.active_cameras_list.clear() + for i, cam in enumerate(self._working_settings.cameras): + item = QListWidgetItem(self._format_camera_label(cam, i)) + item.setData(Qt.ItemDataRole.UserRole, cam) + if not cam.enabled: + item.setForeground(Qt.GlobalColor.gray) + self.active_cameras_list.addItem(item) - # Start probe worker (settings will be opened in GUI thread for safety) - self._probe_worker = CameraProbeWorker(cam, self) - self._probe_worker.progress.connect(self._append_status) - self._probe_worker.success.connect(self._on_probe_success) - self._probe_worker.error.connect(self._on_probe_error) - self._probe_worker.finished.connect(self._on_probe_finished) - self._probe_worker.start() + self._refresh_available_cameras() + self._update_button_states() def _reset_selected_camera(self, *, clear_backend_cache: bool = False) -> None: """Reset the selected camera by probing device defaults and applying them to requested values.""" @@ -1267,7 +967,7 @@ def _reset_selected_camera(self, *, clear_backend_cache: bool = False) -> None: return # Stop preview to avoid fighting an open capture - if self._preview_active: + if self._preview.state in (PreviewState.ACTIVE, PreviewState.LOADING): self._stop_preview() cam = self._working_settings.cameras[row] @@ -1302,6 +1002,68 @@ def _reset_selected_camera(self, *, clear_backend_cache: bool = False) -> None: self.apply_settings_btn.setEnabled(True) + def _on_ok_clicked(self) -> None: + # Auto-apply pending edits before saving + if not self._commit_pending_edits(reason="before going back to the main window"): + return + try: + if self.apply_settings_btn.isEnabled(): + self._append_status("[OK button] Auto-applying pending settings before closing dialog.") + self._apply_camera_settings() + except Exception: + LOGGER.exception("[OK button] Auto-apply failed") + self._stop_preview() + active = self._working_settings.get_active_cameras() + if self._working_settings.cameras and not active: + QMessageBox.warning(self, "No Active Cameras", "Please enable at least one camera or remove all cameras.") + return + self.settings_changed.emit(copy.deepcopy(self._working_settings)) + + self._on_close_cleanup() + self.accept() + + # ------------------------------- + # Probe (device telemetry) management + # ------------------------------- + + def _start_probe_for_camera(self, cam: CameraSettings, *, apply_to_requested: bool = False) -> None: + """Start a quick probe to fill detected labels. + + If apply_to_requested=True, the probe result will also overwrite the selected camera's + requested width/height/fps with detected device values. + """ + # Don’t probe if preview is active/loading + if self._preview.state in (PreviewState.ACTIVE, PreviewState.LOADING): + return + + # Track probe intent + self._probe_apply_to_requested = bool(apply_to_requested) + self._probe_target_row = int(self._current_edit_index) if self._current_edit_index is not None else None + + # Show current detected values if present + self._set_detected_labels(cam) + + # If we already have detected values and we are NOT applying them, skip probing + backend = (cam.backend or "").lower() + props = cam.properties if isinstance(cam.properties, dict) else {} + ns = props.get(backend, {}) if isinstance(props.get(backend, None), dict) else {} + if not apply_to_requested: + det_res = ns.get("detected_resolution") + if isinstance(det_res, (list, tuple)) and len(det_res) == 2: + try: + if int(det_res[0]) > 0 and int(det_res[1]) > 0: + return + except Exception: + pass + + # Start probe worker (settings will be opened in GUI thread for safety) + self._probe_worker = CameraProbeWorker(cam, self) + self._probe_worker.progress.connect(self._append_status) + self._probe_worker.success.connect(self._on_probe_success) + self._probe_worker.error.connect(self._on_probe_error) + self._probe_worker.finished.connect(self._on_probe_finished) + self._probe_worker.start() + def _on_probe_success(self, payload) -> None: """Open/close quickly to read actual_resolution/actual_fps and store as detected_*. @@ -1389,633 +1151,382 @@ def _on_probe_error(self, msg: str) -> None: def _on_probe_finished(self) -> None: self._probe_worker = None - def _add_selected_camera(self) -> None: - if not self._commit_pending_edits(reason="before adding a new camera"): - return - row = self.available_cameras_list.currentRow() - if row < 0: + def _merge_backend_settings_back(self, opened_settings: CameraSettings) -> None: + """Merge identity/index changes learned during preview open back into the working settings.""" + if self._current_edit_index is None: return - # limit check - active_count = len( - [ - i - for i in range(self.active_cameras_list.count()) - if self.active_cameras_list.item(i).data(Qt.ItemDataRole.UserRole).enabled - ] - ) - if active_count >= self.MAX_CAMERAS: - QMessageBox.warning(self, "Maximum Cameras", f"Maximum of {self.MAX_CAMERAS} active cameras allowed.") + row = self._current_edit_index + if row < 0 or row >= len(self._working_settings.cameras): return - item = self.available_cameras_list.item(row) - detected = item.data(Qt.ItemDataRole.UserRole) - # make sure this is to lower for comparison against camera_identity_key - backend = (self.backend_combo.currentData() or "opencv").lower() - det_key = None - if getattr(detected, "device_id", None): - det_key = (backend, "device_id", detected.device_id) - else: - det_key = (backend, "index", int(detected.index)) + target = self._working_settings.cameras[row] - for i in range(self.active_cameras_list.count()): - existing_cam = self.active_cameras_list.item(i).data(Qt.ItemDataRole.UserRole) - if self._camera_identity_key(existing_cam) == det_key: - QMessageBox.warning(self, "Duplicate Camera", "This camera is already in the active list.") - return - - new_cam = CameraSettings( - name=detected.label, - index=detected.index, - width=0, - height=0, - fps=0.0, - backend=backend, - exposure=0, - gain=0.0, - enabled=True, - properties={}, - ) - _apply_detected_identity(new_cam, detected, backend) - self._working_settings.cameras.append(new_cam) - new_index = len(self._working_settings.cameras) - 1 - new_item = QListWidgetItem(self._format_camera_label(new_cam, new_index)) - new_item.setData(Qt.ItemDataRole.UserRole, new_cam) - self.active_cameras_list.addItem(new_item) - self.active_cameras_list.setCurrentItem(new_item) - self._refresh_camera_labels() - self._update_button_states() - self._start_probe_for_camera(new_cam) - - def _remove_selected_camera(self) -> None: - if not self._commit_pending_edits(reason="before removing a camera"): - return - row = self.active_cameras_list.currentRow() - if row < 0: - return - self.active_cameras_list.takeItem(row) - if row < len(self._working_settings.cameras): - del self._working_settings.cameras[row] - self._current_edit_index = None - self._clear_settings_form() - self._refresh_camera_labels() - self._update_button_states() - - def _move_camera_up(self) -> None: - if not self._commit_pending_edits(reason="before reordering cameras"): - return - row = self.active_cameras_list.currentRow() - if row <= 0: - return - item = self.active_cameras_list.takeItem(row) - self.active_cameras_list.insertItem(row - 1, item) - self.active_cameras_list.setCurrentRow(row - 1) - cams = self._working_settings.cameras - cams[row], cams[row - 1] = cams[row - 1], cams[row] - self._refresh_camera_labels() - - def _move_camera_down(self) -> None: - if not self._commit_pending_edits(reason="before reordering cameras"): - return - row = self.active_cameras_list.currentRow() - if row < 0 or row >= self.active_cameras_list.count() - 1: - return - item = self.active_cameras_list.takeItem(row) - self.active_cameras_list.insertItem(row + 1, item) - self.active_cameras_list.setCurrentRow(row + 1) - cams = self._working_settings.cameras - cams[row], cams[row + 1] = cams[row + 1], cams[row] - self._refresh_camera_labels() - - def _commit_pending_edits(self, *, reason: str = "") -> bool: - """ - Auto-apply pending edits (if any) before context-changing actions. - Returns True if it's safe to proceed, False if validation failed. - """ - # No selection → nothing to commit - if self._current_edit_index is None or self._current_edit_index < 0: - return True - - # If Apply button isn't enabled, assume no pending edits - if not self.apply_settings_btn.isEnabled(): - return True - - try: - self._append_status(f"[Auto-Apply] Committing pending edits ({reason})…") - ok = self._apply_camera_settings() - return bool(ok) - except Exception as exc: - # _apply_camera_settings already shows a QMessageBox in many cases, - # but we add a clear guardrail here in case it doesn't. - QMessageBox.warning( - self, - "Unsaved / Invalid Settings", - "Your current camera settings are not valid and cannot be applied yet.\n\n" - "Please fix the highlighted fields (e.g. crop rectangle) or press Reset.\n\n" - f"Details: {exc}", - ) - return False - - def _apply_camera_settings(self) -> bool: - if self._loading_active: - self._append_status("[Apply] Preview is loading; please wait or cancel loading first.") - return False + # Update index if backend rebinding occurred try: - for sb in ( - self.cam_fps, - self.cam_crop_x0, - self.cam_width, - self.cam_height, - self.cam_exposure, - self.cam_gain, - self.cam_crop_y0, - self.cam_crop_x1, - self.cam_crop_y1, - ): - try: - if hasattr(sb, "interpretText"): - sb.interpretText() - except Exception: - pass - if self._current_edit_index is None: - return - row = self._current_edit_index - if row < 0 or row >= len(self._working_settings.cameras): - return - - current_model = self._working_settings.cameras[row] - new_model = self._build_model_from_form(current_model) - - cam = self._working_settings.cameras[row] - self._write_form_to_cam(cam) - - # --- Logging: compute diff before overwriting anything --- - def _cam_diff(old: CameraSettings, new: CameraSettings) -> dict: - keys = ( - "width", - "height", - "fps", - "exposure", - "gain", - "rotation", - "crop_x0", - "crop_y0", - "crop_x1", - "crop_y1", - "enabled", - ) - out = {} - for k in keys: - try: - ov = getattr(old, k, None) - nv = getattr(new, k, None) - if ov != nv: - out[k] = (ov, nv) - except Exception: - pass - return out - - # We compare against the current preview backend settings if available, else against current_model - old_for_diff = getattr(self._preview_backend, "settings", None) if self._preview_backend else current_model - diff = _cam_diff(old_for_diff if isinstance(old_for_diff, CameraSettings) else current_model, new_model) - LOGGER.info( - "[Apply] backend=%s idx=%s changes=%s", - getattr(new_model, "backend", None), - getattr(new_model, "index", None), - diff, - ) - - # --- Persist validated model back BEFORE touching preview --- - self._working_settings.cameras[row] = new_model - self._update_active_list_item(row, new_model) - - # Decide whether we need to restart preview (fast UX) - old_settings = None - if self._preview_backend and isinstance(getattr(self._preview_backend, "settings", None), CameraSettings): - old_settings = self._preview_backend.settings - else: - old_settings = current_model - - restart = False - if self._preview_active and isinstance(old_settings, CameraSettings): - restart = self._should_restart_preview(old_settings, new_model) - # If the preview is starting but not fully active yet, - # we can skip the restart since the new settings will be picked up on start anyway - if self._preview_active and not getattr(self, "._preview_starting", False): - if restart: - QTimer.singleShot(0, lambda cam=new_model: self._restart_preview_for_camera(cam)) - - LOGGER.info( - "[Apply] preview_active=%s restart=%s backend=%s idx=%s", - self._preview_active, - restart, - new_model.backend, - new_model.index, - ) + target.index = int(opened_settings.index) + except Exception: + pass - if self._preview_active: - if restart: - self._append_status("[Apply] Restarting preview to apply camera settings…") - QTimer.singleShot(0, lambda cam=new_model: self._restart_preview_for_camera(cam)) + if isinstance(opened_settings.properties, dict): + if not isinstance(target.properties, dict): + target.properties = {} + for k, v in opened_settings.properties.items(): + if isinstance(v, dict) and isinstance(target.properties.get(k), dict): + target.properties[k].update(v) else: - self._append_status("[Apply] Applied without restart (crop/rotation update is live).") - - self.apply_settings_btn.setEnabled(False) - self._set_apply_dirty(False) - return True - - except Exception as exc: - LOGGER.exception("Apply camera settings failed") - QMessageBox.warning(self, "Apply Settings Error", str(exc)) - return False + target.properties[k] = v - def _update_button_states(self) -> None: - active_row = self.active_cameras_list.currentRow() - has_active_selection = active_row >= 0 - self.remove_camera_btn.setEnabled(has_active_selection) - self.move_up_btn.setEnabled(has_active_selection and active_row > 0) - self.move_down_btn.setEnabled(has_active_selection and active_row < self.active_cameras_list.count() - 1) - # During loading, preview button becomes "Cancel Loading" - self.preview_btn.setEnabled(has_active_selection or self._loading_active) - available_row = self.available_cameras_list.currentRow() - self.add_camera_btn.setEnabled(available_row >= 0) + # Update UI list item text to reflect any changes + self._update_active_list_item(row, target) - def _on_ok_clicked(self) -> None: - # Auto-apply pending edits before saving - if not self._commit_pending_edits(reason="before going back to the main window"): - return - try: - if self.apply_settings_btn.isEnabled(): - self._append_status("[OK button] Auto-applying pending settings before closing dialog.") - self._apply_camera_settings() - except Exception: - LOGGER.exception("[OK button] Auto-apply failed") - self._stop_preview() - active = self._working_settings.get_active_cameras() - if self._working_settings.cameras and not active: - QMessageBox.warning(self, "No Active Cameras", "Please enable at least one camera or remove all cameras.") + def _adjust_preview_timer_for_fps(self, fps: float | None) -> None: + """Adjust preview cadence to match actual FPS (bounded for CPU).""" + if not self._preview.timer or not fps or fps <= 0: return - self.settings_changed.emit(copy.deepcopy(self._working_settings)) - self.accept() + interval_ms = max(15, int(1000.0 / min(max(fps, 1.0), 60.0))) + self._preview.timer.start(interval_ms) - def reject(self) -> None: - """Handle dialog rejection (Cancel or close).""" - self._stop_preview() + def _reconcile_fps_from_backend(self, cam: CameraSettings) -> None: + """Reconcile preview cadence to actual FPS without overriding Auto request.""" - if getattr(self, "_scan_worker", None) and self._scan_worker.isRunning(): - try: - self._scan_worker.requestInterruption() - except Exception: - pass - self._scan_worker.wait(1500) - self._scan_worker = None - if getattr(self, "_probe_worker", None) and self._probe_worker.isRunning(): - try: - self._probe_worker.request_cancel() - except Exception: - pass - self._probe_worker.wait(1500) - self._probe_worker = None + # If user requested Auto (0), do not overwrite the request. + if float(getattr(cam, "fps", 0.0) or 0.0) <= 0.0: + actual = getattr(self._preview.backend, "actual_fps", None) if self._preview.backend else None + if actual: + self._append_status(f"[Info] Auto FPS; device reports ~{actual:.2f}. Preview timer adjusted.") + self._adjust_preview_timer_for_fps(actual) + else: + self._append_status("[Info] Auto FPS; backend can't reliably report actual FPS.") + return - self._hide_scan_overlay() - self.scan_progress.setVisible(False) - self.scan_cancel_btn.setVisible(False) - self.scan_cancel_btn.setEnabled(True) - self.refresh_btn.setEnabled(True) - self.backend_combo.setEnabled(True) + # If user requested a specific FPS, optionally clamp UI to actual if measurable. + actual = getattr(self._preview.backend, "actual_fps", None) if self._preview.backend else None + if actual is None: + self._append_status("[Info] Backend can't reliably report actual FPS; keeping requested value.") + return - super().reject() + if abs(cam.fps - actual) > 0.5: + cam.fps = actual + self.cam_fps.setValue(actual) + self._append_status(f"[Info] FPS adjusted to device-supported ~{actual:.2f}.") + self._adjust_preview_timer_for_fps(actual) + else: + self._adjust_preview_timer_for_fps(actual) - # ------------------------------- - # Preview start/stop (ASYNC) - # ------------------------------- + # --------------------------------- + # Preview lifecycle management (start/stop + loading state) + # --------------------------------- def _toggle_preview(self) -> None: - if self._loading_active: + if self._preview.state == PreviewState.LOADING: self._cancel_loading() return - if self._preview_active: + if self._preview.state == PreviewState.ACTIVE: self._stop_preview() else: self._start_preview() - def _restart_preview_for_camera(self, cam: CameraSettings) -> None: - """Restart preview for a specific camera, independent of UI selection.""" - LOGGER.info( - "[Preview] restarting explicitly for backend=%s idx=%s", - cam.backend, - cam.index, - ) - self._suppress_selection_actions = True - try: - # Stop any running preview cleanly - self._stop_preview() + def _begin_preview_load(self, cam: CameraSettings, *, reason: str) -> None: + """ + Begin (re)loading preview for cam. + + Purpose: + - Bumps epoch of the preview to invalidate + any in-flight loader results from previous previews. + See _bump_epoch and _on_loader_* methods. + - Enters LOADING state + - Creates and wires loader + - Sets requested_cam + """ + LOGGER.info("[Preview] begin load reason=%s backend=%s idx=%s", reason, cam.backend, cam.index) - # Force preview-safe backend flags - if isinstance(cam.properties, dict): - ns = cam.properties.setdefault((cam.backend or "").lower(), {}) - if isinstance(ns, dict): - ns["fast_start"] = False + # If already loading, just coalesce restart/intention + if self._preview.state == PreviewState.LOADING: + self._preview.pending_restart = copy.deepcopy(cam) + return - # Start preview without relying on selection state - self._start_preview_with_camera(cam) - finally: - self._suppress_selection_actions = False + # Stop any existing backend/timer/loader cleanly + self._stop_preview_internal(reason="begin-load") - def _start_preview_with_camera(self, cam: CameraSettings) -> None: - """Start preview for a given CameraSettings object.""" - LOGGER.info( - "[Preview] start (explicit) backend=%s idx=%s name=%s", - cam.backend, - cam.index, - cam.name, - ) + self._preview.state = PreviewState.LOADING + epoch = self._bump_epoch() + self._preview.requested_cam = copy.deepcopy(cam) + self._preview.pending_restart = None + self._preview.restart_scheduled = False - # Create loader directly from camera - self._loader = CameraLoadWorker(cam, self) - self._loader.progress.connect(self._on_loader_progress) - self._loader.success.connect(self._on_loader_success) - self._loader.error.connect(self._on_loader_error) - self._loader.canceled.connect(self._on_loader_canceled) - self._loader.finished.connect(self._on_loader_finished) + # Force preview-safe backend flags + if isinstance(self._preview.requested_cam.properties, dict): + ns = self._preview.requested_cam.properties.setdefault((cam.backend or "").lower(), {}) + if isinstance(ns, dict): + ns["fast_start"] = False - self._loading_active = True - self._update_button_states() + loader = CameraLoadWorker(self._preview.requested_cam, self) + self._preview.loader = loader - # Prepare UI - self.preview_group.setVisible(True) - self.preview_label.setText("No preview") + # Connect signals with epoch captured + loader.progress.connect(lambda msg, e=epoch: self._on_loader_progress(e, msg)) + loader.success.connect(lambda payload, e=epoch: self._on_loader_success(e, payload)) + loader.error.connect(lambda err, e=epoch: self._on_loader_error(e, err)) + loader.canceled.connect(lambda e=epoch: self._on_loader_canceled(e)) + loader.finished.connect(lambda e=epoch: self._on_loader_finished(e)) + + # UI self.preview_status.clear() self._show_loading_overlay("Loading camera…") - self._set_preview_button_loading(True) + self._sync_preview_ui() - self._loader.start() + loader.start() def _start_preview(self) -> None: """Start camera preview asynchronously (no UI freeze).""" if not self._commit_pending_edits(reason="before starting preview"): return - if self._preview_active or self._loading_active: + if self._preview.state in (PreviewState.ACTIVE, PreviewState.LOADING): return - self.starting_preview = True - try: - row = self._current_edit_index - if row is None or row < 0: - row = self.active_cameras_list.currentRow() - - if row is None or row < 0: - LOGGER.warning("[Preview] No camera selected to start preview.") - return - self._current_edit_index = row - LOGGER.info( - "[Preview] resolved start row=%s active_row=%s", - self._current_edit_index, - self.active_cameras_list.currentRow(), - ) + row = self._current_edit_index + if row is None or row < 0: + row = self.active_cameras_list.currentRow() - item = self.active_cameras_list.item(self._current_edit_index) - if not item: - return - cam = item.data(Qt.ItemDataRole.UserRole) - if not cam: - return - LOGGER.info( - "[Preview] start requested row=%s backend=%s idx=%s name=%s loading=%s active=%s", - self._current_edit_index, - cam.backend, - cam.index, - cam.name, - self._loading_active, - self._preview_active, - ) + if row is None or row < 0: + LOGGER.warning("[Preview] No camera selected to start preview.") + return - # Ensure any existing preview or loader is stopped/canceled - self._stop_preview() - # if self._loader and self._loader.isRunning(): - # self._loader.request_cancel() - # Never use probe or fast_start mode - if isinstance(cam.properties, dict): - ns = cam.properties.get((cam.backend or "").lower(), {}) - if isinstance(ns, dict): - ns["fast_start"] = False - # Create worker - self._loader = CameraLoadWorker(cam, self) - self._loader.progress.connect(self._on_loader_progress) - self._loader.success.connect(self._on_loader_success) - self._loader.error.connect(self._on_loader_error) - self._loader.canceled.connect(self._on_loader_canceled) - self._loader.finished.connect(self._on_loader_finished) - self._loading_active = True - self._update_button_states() + self._current_edit_index = row + LOGGER.info( + "[Preview] resolved start row=%s active_row=%s", + self._current_edit_index, + self.active_cameras_list.currentRow(), + ) - # Prepare UI - self.preview_group.setVisible(True) - self.preview_label.setText("No preview") - self.preview_status.clear() - self._show_loading_overlay("Loading camera…") - self._set_preview_button_loading(True) + item = self.active_cameras_list.item(self._current_edit_index) + if not item: + return + cam = item.data(Qt.ItemDataRole.UserRole) + if not cam: + return - self._loader.start() - finally: - self.starting_preview = False + self._begin_preview_load(cam, reason="user-start") def _stop_preview(self) -> None: - """Stop camera preview and cancel any ongoing loading.""" - LOGGER.info( - "[Preview] stop requested loading=%s active=%s backend=%s", - self._loading_active, - self._preview_active, - getattr(getattr(self._preview_backend, "settings", None), "backend", None), - ) - # Also show traceback to see who called stop_preview, - # since this should only be called from a few places. - # LOGGER.debug("[Preview] stop_preview called from: %s", "".join(traceback.format_stack(limit=6))) - # Cancel loader if running - if self._loader and self._loader.isRunning(): - self._loader.request_cancel() - self._loader.wait(1500) - self._loader = None + self._stop_preview_internal(reason="user-stop") + self._sync_preview_ui() + + def _stop_preview_internal(self, *, reason: str) -> None: + """Tear down loader/backend/timer. Safe to call from anywhere.""" + LOGGER.info("[Preview] stop reason=%s state=%s", reason, self._preview.state) + + self._preview.state = PreviewState.STOPPING + + # Invalidate all in-flight signals immediately + self._bump_epoch() + + # Cancel loader + if self._preview.loader and self._preview.loader.isRunning(): + self._preview.loader.request_cancel() + self._preview.loader.wait(1500) + self._preview.loader = None + # Stop timer - if self._preview_timer: - self._preview_timer.stop() - self._preview_timer = None + if self._preview.timer: + self._preview.timer.stop() + self._preview.timer = None + # Close backend - if self._preview_backend: + if self._preview.backend: try: - LOGGER.debug("[Preview] closing backend object=%r", self._preview_backend) - self._preview_backend.close() + self._preview.backend.close() except Exception: pass - self._preview_backend = None - # Reset UI - self._loading_active = False - self._preview_active = False - self._set_preview_button_loading(False) - self.preview_btn.setText("Start Preview") - self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) - self.preview_group.setVisible(False) - self.preview_label.setText("No preview") - self.preview_label.setPixmap(QPixmap()) + self._preview.backend = None + self._preview.pending_restart = None + self._preview.requested_cam = None + self._preview.restart_scheduled = False + self._hide_loading_overlay() - self._update_button_states() + self._preview.state = PreviewState.IDLE - # ------------------------------- - # Loader UI helpers / slots - # ------------------------------- - def _set_preview_button_loading(self, loading: bool) -> None: - if loading: - self.preview_btn.setText("Cancel Loading") - self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserStop)) - else: - self.preview_btn.setText("Start Preview") - self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) + def _bump_epoch(self) -> int: + self._preview.epoch += 1 + return self._preview.epoch - def _show_loading_overlay(self, message: str) -> None: - self._loading_overlay.setText(message) - self._loading_overlay.setVisible(True) - self._position_loading_overlay() + def _should_restart_preview(self, old: CameraSettings, new: CameraSettings) -> bool: + """ + Fast UX policy: + - Do NOT restart for rotation/crop (preview applies those live). + - Restart for camera-side capture params: resolution/fps/exposure/gain. + Backend-agnostic for now (no OpenCV special casing). + """ + # Restart on these changes + for key in ("width", "height", "fps", "exposure", "gain"): + try: + if getattr(old, key, None) != getattr(new, key, None): + return True + except Exception: + return True # safest: restart - def _hide_loading_overlay(self) -> None: - self._loading_overlay.setVisible(False) + # No restart needed if only rotation/crop/enabled changed + return False - def _append_status(self, text: str) -> None: - LOGGER.debug(f"Preview status: {text}") - self.preview_status.append(text) - self.preview_status.moveCursor(QTextCursor.End) - self.preview_status.ensureCursorVisible() + def _request_preview_restart(self, cam: CameraSettings, *, reason: str) -> None: + """ + Request a preview restart. Coalesced to at most one scheduled callback. + If currently LOADING, stores pending_restart instead of thrashing loader. + """ + if self._preview.state == PreviewState.LOADING: + self._preview.pending_restart = copy.deepcopy(cam) + return + + if self._preview.state != PreviewState.ACTIVE: + return + + self._preview.pending_restart = copy.deepcopy(cam) + + if self._preview.restart_scheduled: + return + self._preview.restart_scheduled = True + + QTimer.singleShot(0, lambda: self._execute_pending_restart(reason=reason)) + + def _execute_pending_restart(self, *, reason: str) -> None: + self._preview.restart_scheduled = False + cam = self._preview.pending_restart + self._preview.pending_restart = None + if not cam: + return + + LOGGER.info("[Preview] executing restart reason=%s", reason) + self._begin_preview_load(cam, reason="restart") def _cancel_loading(self) -> None: - if self._loader and self._loader.isRunning(): + loader = self._preview.loader + if loader and loader.isRunning(): self._append_status("Cancel requested…") - self._loader.request_cancel() - # UI will flip back on finished -> _on_loader_finished + loader.request_cancel() else: - self._loading_active = False - self._set_preview_button_loading(False) - self._hide_loading_overlay() - self._update_button_states() + # If nothing is running, ensure state is IDLE + self._stop_preview_internal(reason="cancel-loading-noop") + self._sync_preview_ui() + + def _is_current_epoch(self, e: int) -> bool: + return e == self._preview.epoch # Loader signal handlers - def _on_loader_progress(self, message: str) -> None: + def _on_loader_progress(self, e: int, message: str) -> None: + if not self._is_current_epoch(e): + return self._show_loading_overlay(message) self._append_status(message) - def _on_loader_success(self, payload) -> None: + def _on_loader_success(self, e: int, payload) -> None: + if not self._is_current_epoch(e): + return + if self._preview.state != PreviewState.LOADING: + return + try: - if isinstance(payload, CameraSettings): - cam_settings = payload - self._append_status("Opening camera…") - LOGGER.debug( - "[Loader] success -> opening camera backend=%s idx=%s props_keys=%s", - cam_settings.backend, - cam_settings.index, - list(cam_settings.properties.keys()) if isinstance(cam_settings.properties, dict) else None, - ) - self._preview_backend = CameraFactory.create(cam_settings) - self._preview_backend.open() - - req_w = getattr(self._preview_backend.settings, "width", None) - req_h = getattr(self._preview_backend.settings, "height", None) - actual_res = getattr(self._preview_backend, "actual_resolution", None) - if req_w and req_h: - if actual_res: - self._append_status( - f"Requested resolution: {req_w}x{req_h}, actual: {actual_res[0]}x{actual_res[1]}" - ) - else: - self._append_status(f"Requested resolution: {req_w}x{req_h}, actual: unknown") - - opened_sttngs = getattr(self._preview_backend, "settings", None) - if isinstance(opened_sttngs, CameraSettings): - backend = opened_sttngs.backend - index = opened_sttngs.index - device_name = (opened_sttngs.properties or {}).get(backend.lower(), {}).get("device_name", "") - msg = f"Opened {backend}:{index}" - if device_name: - msg += f" ({device_name})" - self._append_status(msg) - self._merge_backend_settings_back(opened_sttngs) - if self._current_edit_index is not None and 0 <= self._current_edit_index < len( - self._working_settings.cameras - ): - self._load_camera_to_form(self._working_settings.cameras[self._current_edit_index]) - else: + if not isinstance(payload, CameraSettings): raise TypeError(f"Unexpected success payload type: {type(payload)}") + self._append_status("Opening camera…") + LOGGER.debug( + "[Loader] success -> opening camera backend=%s idx=%s props_keys=%s", + payload.backend, + payload.index, + list(payload.properties.keys()) if isinstance(payload.properties, dict) else None, + ) + backend = CameraFactory.create(payload) + backend.open() + self._preview.backend = backend + self._preview.state = PreviewState.ACTIVE + + req_w = getattr(self._preview.backend.settings, "width", None) + req_h = getattr(self._preview.backend.settings, "height", None) + actual_res = getattr(self._preview.backend, "actual_resolution", None) + if req_w and req_h: + if actual_res: + self._append_status( + f"Requested resolution: {req_w}x{req_h}, actual: {actual_res[0]}x{actual_res[1]}" + ) + else: + self._append_status(f"Requested resolution: {req_w}x{req_h}, actual: unknown") + + opened_sttngs = getattr(self._preview.backend, "settings", None) + if isinstance(opened_sttngs, CameraSettings): + backend = opened_sttngs.backend + index = opened_sttngs.index + device_name = (opened_sttngs.properties or {}).get(backend.lower(), {}).get("device_name", "") + msg = f"Opened {backend}:{index}" + if device_name: + msg += f" ({device_name})" + self._append_status(msg) + self._merge_backend_settings_back(opened_sttngs) + if self._current_edit_index is not None and 0 <= self._current_edit_index < len( + self._working_settings.cameras + ): + self._load_camera_to_form(self._working_settings.cameras[self._current_edit_index]) + self._reconcile_fps_from_backend(opened_sttngs) # Start preview UX - self._append_status("Starting preview…") - self._preview_active = True - self.preview_btn.setText("Stop Preview") - self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop)) - self.preview_group.setVisible(True) - self.preview_label.setText("Starting…") self._hide_loading_overlay() + self._sync_preview_ui() # Timer @ ~25 fps default; cadence may be overridden above - self._preview_timer = QTimer(self) - self._preview_timer.timeout.connect(self._update_preview) - self._preview_timer.start(40) + self._preview.timer = QTimer(self) + self._preview.timer.timeout.connect(self._update_preview) + self._preview.timer.start(40) # FPS reconciliation + cadence (single source of truth) - actual_fps = self._backend_actual_fps() + actual_fps = getattr(self._preview.backend, "actual_fps", None) if actual_fps: self._adjust_preview_timer_for_fps(actual_fps) self.apply_settings_btn.setEnabled(True) except Exception as exc: - self._on_loader_error(str(exc)) + self._on_loader_error(e, str(exc)) - def _on_loader_error(self, error: str) -> None: + def _on_loader_error(self, e: int, error: str) -> None: + if not self._is_current_epoch(e): + return self._append_status(f"Error: {error}") - LOGGER.error("[Loader] error: %s", error, exc_info=True) - self._preview_active = False - self._loading_active = False - self._hide_loading_overlay() - self.preview_group.setVisible(False) - self._set_preview_button_loading(False) - self._update_button_states() + LOGGER.error("[Loader] error: %s", error) + self._stop_preview_internal(reason="loader-error") QMessageBox.warning(self, "Preview Error", f"Failed to start camera preview:\n{error}") + self._sync_preview_ui() - def _on_loader_canceled(self) -> None: + def _on_loader_canceled(self, e: int) -> None: + if not self._is_current_epoch(e): + return self._append_status("Loading canceled.") - self._hide_loading_overlay() + self._stop_preview_internal(reason="loader-canceled") + self._sync_preview_ui() + + def _on_loader_finished(self, e: int) -> None: + if not self._is_current_epoch(e): + return - def _on_loader_finished(self): - self._loading_active = False - self._loader = None + pending = self._preview.pending_restart + self._preview.pending_restart = None + self._preview.restart_scheduled = False + self._preview.loader = None - # If preview ended successfully, ensure Stop Preview is shown - if self._preview_active: - self.preview_btn.setText("Stop Preview") - self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop)) - else: - # Otherwise show Start Preview - self.preview_btn.setText("Start Preview") - self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) + if pending and self._preview.state == PreviewState.IDLE: + LOGGER.debug("[Loader] finished with pending restart for backend=%s idx=%s", pending.backend, pending.index) + self._begin_preview_load(pending, reason="pending-restart-after-finish") - # ALWAYS refresh button states - self._update_button_states() + self._sync_preview_ui() - # ------------------------------- - # Preview frame update - # ------------------------------- def _update_preview(self) -> None: """Update preview frame.""" - if not self._preview_backend or not self._preview_active: + if self._preview.state != PreviewState.ACTIVE or not self._preview.backend: return try: - frame, _ = self._preview_backend.read() + frame, _ = self._preview.backend.read() if frame is None or frame.size == 0: return # Apply rotation if set in the form (real-time from UI) rotation = self.cam_rotation.currentData() - if rotation == 90: - frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) - elif rotation == 180: - frame = cv2.rotate(frame, cv2.ROTATE_180) - elif rotation == 270: - frame = cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE) + frame = apply_rotation(frame, rotation) # Apply crop if set in the form (real-time from UI) h, w = frame.shape[:2] @@ -2023,33 +1534,13 @@ def _update_preview(self) -> None: y0 = self.cam_crop_y0.value() x1 = self.cam_crop_x1.value() or w y1 = self.cam_crop_y1.value() or h - # Clamp to frame bounds - x0 = max(0, min(x0, w)) - y0 = max(0, min(y0, h)) - x1 = max(x0, min(x1, w)) - y1 = max(y0, min(y1, h)) - if x1 > x0 and y1 > y0: - frame = frame[y0:y1, x0:x1] + frame = apply_crop(frame, x0, y0, x1, y1) # Resize to fit preview label - h, w = frame.shape[:2] - max_w, max_h = 400, 300 - scale = min(max_w / w, max_h / h) - new_w, new_h = int(w * scale), int(h * scale) - frame = cv2.resize(frame, (new_w, new_h)) - - # Convert to QImage and display - if frame.ndim == 2: - frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2RGB) - elif frame.shape[2] == 4: - frame = cv2.cvtColor(frame, cv2.COLOR_BGRA2RGB) - else: - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + frame = resize_to_fit(frame, max_w=400, max_h=300) - h, w, ch = frame.shape - bytes_per_line = ch * w - q_img = QImage(frame.data, w, h, bytes_per_line, QImage.Format.Format_RGB888).copy() - self.preview_label.setPixmap(QPixmap.fromImage(q_img)) + q_img = to_display_pixmap(frame) + self.preview_label.setPixmap(q_img) except Exception as exc: LOGGER.debug(f"Preview frame skipped: {exc}") diff --git a/dlclivegui/gui/camera_config/loaders.py b/dlclivegui/gui/camera_config/loaders.py new file mode 100644 index 0000000..6305b8d --- /dev/null +++ b/dlclivegui/gui/camera_config/loaders.py @@ -0,0 +1,139 @@ +"""Workers and state logic for loading cameras in the GUI.""" + +# dlclivegui/gui/camera_loaders.py +import copy +import logging + +from PySide6.QtCore import QThread, Signal +from PySide6.QtWidgets import QWidget + +from ...cameras.base import CameraSettings +from ...cameras.factory import CameraBackend, CameraFactory + +LOGGER = logging.getLogger(__name__) +LOGGER.setLevel(logging.DEBUG) + + +# ------------------------------- +# Background worker to detect cameras +# ------------------------------- +class DetectCamerasWorker(QThread): + """Background worker to detect cameras for the selected backend.""" + + progress = Signal(str) # human-readable text + result = Signal(list) # list[DetectedCamera] + error = Signal(str) + finished = Signal() + + def __init__(self, backend: str, max_devices: int = 10, parent: QWidget | None = None): + super().__init__(parent) + self.backend = backend + self.max_devices = max_devices + + def run(self): + try: + # Initial message + self.progress.emit(f"Scanning {self.backend} cameras…") + + cams = CameraFactory.detect_cameras( + self.backend, + max_devices=self.max_devices, + should_cancel=self.isInterruptionRequested, + progress_cb=self.progress.emit, + ) + self.result.emit(cams) + except Exception as exc: + self.error.emit(f"{type(exc).__name__}: {exc}") + finally: + self.finished.emit() + + +class CameraProbeWorker(QThread): + """Request a quick device probe (open/close) without starting preview.""" + + progress = Signal(str) + success = Signal(object) # emits CameraSettings + error = Signal(str) + finished = Signal() + + def __init__(self, cam: CameraSettings, parent: QWidget | None = None): + super().__init__(parent) + self._cam = copy.deepcopy(cam) + self._cancel = False + + # Enable fast_start when supported (backend reads namespace options) + if isinstance(self._cam.properties, dict): + ns = self._cam.properties.setdefault(self._cam.backend.lower(), {}) + if isinstance(ns, dict): + ns.setdefault("fast_start", True) + + def request_cancel(self): + self._cancel = True + + def run(self): + try: + self.progress.emit("Probing device defaults…") + if self._cancel: + return + self.success.emit(self._cam) + except Exception as exc: + self.error.emit(f"{type(exc).__name__}: {exc}") + finally: + self.finished.emit() + + +# ------------------------------- +# Singleton camera preview loader worker +# ------------------------------- +class CameraLoadWorker(QThread): + """Open/configure a camera backend off the UI thread with progress and cancel support.""" + + progress = Signal(str) # Human-readable status updates + success = Signal(object) # Emits the ready backend (CameraBackend) + error = Signal(str) # Emits error message + canceled = Signal() # Emits when canceled before success + + def __init__(self, cam: CameraSettings, parent: QWidget | None = None): + super().__init__(parent) + self._cam = copy.deepcopy(cam) + + self._cancel = False + self._backend: CameraBackend | None = None + + # Do not use fast_start here as we want to actually open the camera to probe capabilities + # If you want a quick probe without full open, use CameraProbeWorker instead which sets fast_start=True + # Ensure preview open never uses fast_start probe mode + if isinstance(self._cam.properties, dict): + ns = self._cam.properties.setdefault(self._cam.backend.lower(), {}) + if isinstance(ns, dict): + ns["fast_start"] = False + + def request_cancel(self): + self._cancel = True + + def _check_cancel(self) -> bool: + if self._cancel: + self.progress.emit("Canceled by user.") + return True + return False + + def run(self): + try: + self.progress.emit("Creating backend…") + if self._check_cancel(): + self.canceled.emit() + return + + LOGGER.debug("Creating camera backend for %s:%d", self._cam.backend, self._cam.index) + self.progress.emit("Opening device…") + # Open only in GUI thread to avoid simultaneous opens + self.success.emit(self._cam) + + except Exception as exc: + msg = f"{type(exc).__name__}: {exc}" + try: + if self._backend: + self._backend.close() + except Exception: + pass + self.error.emit(msg) diff --git a/dlclivegui/gui/camera_config/preview.py b/dlclivegui/gui/camera_config/preview.py new file mode 100644 index 0000000..bbd1aef --- /dev/null +++ b/dlclivegui/gui/camera_config/preview.py @@ -0,0 +1,77 @@ +# dlclivegui/gui/camera_config/preview.py +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum, auto +from typing import TYPE_CHECKING + +from PySide6.QtCore import QTimer + +from ...services.multi_camera_controller import MultiCameraController + +if TYPE_CHECKING: + from ...cameras.base import CameraBackend + from ...config import CameraSettings + from .loaders import CameraLoadWorker + + +class PreviewState(Enum): + """Preview lifecycle state..""" + + IDLE = auto() # No loader, no backend, no timer. + LOADING = auto() # Loader started; waiting for success/error/canceled. + ACTIVE = auto() # Backend open + preview timer running. + STOPPING = auto() # Tearing down loader/backend/timer. + ERROR = auto() # Terminal error state (optional; can just go back to IDLE) + + +@dataclass +class PreviewSession: + """ + Owns all runtime objects for preview and defines intent. + + epoch: + Monotonically increasing integer used to invalidate stale signals from previous loaders. + Any signal handler must check that the epoch matches the current session epoch. + + state: + PreviewState that replaces multiple booleans. + + requested_cam: + The CameraSettings snapshot used to start the current LOADING request. + + backend / timer / loader: + Runtime handles. Only valid in states where they should exist. + """ + + epoch: int = 0 + state: PreviewState = PreviewState.IDLE + requested_cam: CameraSettings | None = None + loader: CameraLoadWorker | None = None + backend: CameraBackend | None = None + timer: QTimer | None = None + + pending_restart: CameraSettings | None = None + restart_scheduled: bool = False # Coalesces restarts to “at most once in the queue”. + + +def apply_rotation(frame, rotation): + return MultiCameraController.apply_rotation(frame, rotation) + + +def apply_crop(frame, x0, y0, x1, y1): + h, w = frame.shape[:2] + x0 = max(0, min(x0, w)) + y0 = max(0, min(y0, h)) + x1 = max(x0, min(x1, w)) + y1 = max(y0, min(y1, h)) + + return MultiCameraController.apply_crop(frame, (x0, y0, x1, y1)) + + +def resize_to_fit(frame, max_w=400, max_h=300): + return MultiCameraController.apply_resize(frame, max_w, max_h) + + +def to_display_pixmap(frame): + return MultiCameraController.to_display_pixmap(frame) diff --git a/dlclivegui/gui/camera_config/ui_blocks.py b/dlclivegui/gui/camera_config/ui_blocks.py new file mode 100644 index 0000000..b504e75 --- /dev/null +++ b/dlclivegui/gui/camera_config/ui_blocks.py @@ -0,0 +1,495 @@ +"""UI building blocks for camera configuration dialog.""" + +# dlclivegui/gui/camera_config/ui_blocks.py + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDoubleSpinBox, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QListWidget, + QProgressBar, + QPushButton, + QScrollArea, + QSizePolicy, + QSpinBox, + QStyle, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from ...cameras import CameraFactory +from ..misc.drag_spinbox import ScrubSpinBox +from ..misc.eliding_label import ElidingPathLabel +from ..misc.layouts import _make_two_field_row + +if TYPE_CHECKING: + from camera_config_dialog import CameraConfigDialog + + +__all__ = [ + "setup_camera_config_dialog_ui", + "build_left_panel", + "build_right_panel", + "build_dialog_buttons_row", + "build_active_cameras_group", + "build_available_cameras_group", + "build_settings_group", + "build_preview_group", + "build_right_scroll_container", +] + + +# --------------------------------------------------------------------- +# Public high-level entry point +# --------------------------------------------------------------------- +def setup_camera_config_dialog_ui(dlg: CameraConfigDialog) -> None: + """ + Build the full dialog UI on `dlg`. + + This mirrors the original _setup_ui() structure: + - main vertical layout + - left/right panels row + - OK/Cancel buttons row + + All widgets are attached to `dlg` using the same attribute names + as the original monolithic implementation. + """ + main_layout = QVBoxLayout(dlg) + + panels_layout = QHBoxLayout() + left_panel = build_left_panel(dlg) + right_panel = build_right_panel(dlg) + + panels_layout.addWidget(left_panel, stretch=1) + panels_layout.addWidget(right_panel, stretch=1) + + buttons_layout = build_dialog_buttons_row(dlg) + + main_layout.addLayout(panels_layout) + main_layout.addLayout(buttons_layout) + + +# --------------------------------------------------------------------- +# Left side: Active + Available panels +# --------------------------------------------------------------------- +def build_left_panel(dlg: CameraConfigDialog) -> QWidget: + """Build the entire left panel (Active Cameras + Available Cameras).""" + left_panel = QWidget() + left_layout = QVBoxLayout(left_panel) + + active_group = build_active_cameras_group(dlg) + available_group = build_available_cameras_group(dlg) + + left_layout.addWidget(active_group) + left_layout.addWidget(available_group) + + return left_panel + + +def build_active_cameras_group(dlg: CameraConfigDialog) -> QGroupBox: + """Build the 'Active Cameras' group box.""" + active_group = QGroupBox("Active Cameras") + active_layout = QVBoxLayout(active_group) + + dlg.active_cameras_list = QListWidget() + dlg.active_cameras_list.setMinimumWidth(250) + active_layout.addWidget(dlg.active_cameras_list) + + # Buttons for managing active cameras + list_buttons = QHBoxLayout() + + dlg.remove_camera_btn = QPushButton("Remove") + dlg.remove_camera_btn.setIcon(dlg.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon)) + dlg.remove_camera_btn.setEnabled(False) + + dlg.move_up_btn = QPushButton("↑") + dlg.move_up_btn.setIcon(dlg.style().standardIcon(QStyle.StandardPixmap.SP_ArrowUp)) + dlg.move_up_btn.setEnabled(False) + + dlg.move_down_btn = QPushButton("↓") + dlg.move_down_btn.setIcon(dlg.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) + dlg.move_down_btn.setEnabled(False) + + list_buttons.addWidget(dlg.remove_camera_btn) + list_buttons.addWidget(dlg.move_up_btn) + list_buttons.addWidget(dlg.move_down_btn) + + active_layout.addLayout(list_buttons) + return active_group + + +def build_available_cameras_group(dlg: CameraConfigDialog) -> QGroupBox: + """Build the 'Available Cameras' group box, including scan UI widgets.""" + available_group = QGroupBox("Available Cameras") + available_layout = QVBoxLayout(available_group) + + # Backend selection row + backend_layout = QHBoxLayout() + backend_layout.addWidget(QLabel("Backend:")) + + dlg.backend_combo = QComboBox() + + availability = CameraFactory.available_backends() + for backend in CameraFactory.backend_names(): + label = backend + if not availability.get(backend, True): + label = f"{backend} (unavailable)" + dlg.backend_combo.addItem(label, backend) + + if dlg.backend_combo.count() == 0: + raise RuntimeError("No camera backends are registered!") + + # Switch to first available backend + for i in range(dlg.backend_combo.count()): + backend = dlg.backend_combo.itemData(i) + if availability.get(backend, False): + dlg.backend_combo.setCurrentIndex(i) + break + + backend_layout.addWidget(dlg.backend_combo) + + dlg.refresh_btn = QPushButton("Refresh") + dlg.refresh_btn.setIcon(dlg.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload)) + backend_layout.addWidget(dlg.refresh_btn) + + available_layout.addLayout(backend_layout) + + # Available list + dlg.available_cameras_list = QListWidget() + available_layout.addWidget(dlg.available_cameras_list) + + # Scan overlay (covers the available list area) + dlg._scan_overlay = QLabel(available_group) + dlg._scan_overlay.setVisible(False) + dlg._scan_overlay.setAlignment(Qt.AlignCenter) + dlg._scan_overlay.setWordWrap(True) + dlg._scan_overlay.setStyleSheet( + "background-color: rgba(0, 0, 0, 140);color: white;padding: 12px;border: 1px solid #333;font-size: 12px;" + ) + dlg._scan_overlay.setText("Discovering cameras…") + + # Keep existing event filter behavior + dlg.available_cameras_list.installEventFilter(dlg) + + # Indeterminate progress bar + status text for async scan + dlg.scan_progress = QProgressBar() + dlg.scan_progress.setRange(0, 0) + dlg.scan_progress.setVisible(False) + available_layout.addWidget(dlg.scan_progress) + + # Scan cancel button + dlg.scan_cancel_btn = QPushButton("Cancel Scan") + dlg.scan_cancel_btn.setIcon(dlg.style().standardIcon(QStyle.StandardPixmap.SP_BrowserStop)) + dlg.scan_cancel_btn.setVisible(False) + + # The original UI block connects cancel here; preserve that. + # dlg must provide _on_scan_cancel + if hasattr(dlg, "_on_scan_cancel"): + dlg.scan_cancel_btn.clicked.connect(dlg._on_scan_cancel) # type: ignore[attr-defined] + + available_layout.addWidget(dlg.scan_cancel_btn) + + # Add camera button + dlg.add_camera_btn = QPushButton("Add Selected Camera →") + dlg.add_camera_btn.setIcon(dlg.style().standardIcon(QStyle.StandardPixmap.SP_ArrowRight)) + dlg.add_camera_btn.setEnabled(False) + available_layout.addWidget(dlg.add_camera_btn) + + return available_group + + +# --------------------------------------------------------------------- +# Right side: Settings + Preview + Scroll container +# --------------------------------------------------------------------- +def build_right_panel(dlg: CameraConfigDialog) -> QWidget: + """Build the entire right panel (settings + preview inside a scroll area).""" + right_panel = QWidget() + right_layout = QVBoxLayout(right_panel) + + settings_group = build_settings_group(dlg) + preview_group = build_preview_group(dlg) + + scroll = build_right_scroll_container(dlg, settings_group, preview_group) + right_layout.addWidget(scroll) + + return right_panel + + +def build_settings_group(dlg: CameraConfigDialog) -> QGroupBox: + """Build the 'Camera Settings' group box and its form widgets.""" + settings_group = QGroupBox("Camera Settings") + dlg.settings_form = QFormLayout(settings_group) + dlg.settings_form.setVerticalSpacing(6) + dlg.settings_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow) + + # --- Basic toggles/labels --- + dlg.cam_enabled_checkbox = QCheckBox("Enabled") + dlg.cam_enabled_checkbox.setChecked(True) + dlg.settings_form.addRow(dlg.cam_enabled_checkbox) + + dlg.cam_name_label = QLabel("Camera 0") + dlg.cam_name_label.setStyleSheet("font-weight: bold; font-size: 14px;") + dlg.settings_form.addRow("Name:", dlg.cam_name_label) + + dlg.cam_device_name_label = ElidingPathLabel("") + dlg.cam_device_name_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + dlg.cam_device_name_label.setWordWrap(True) + dlg.settings_form.addRow("Device ID:", dlg.cam_device_name_label) + + dlg.cam_index_label = QLabel("0") + + dlg.cam_backend_label = QLabel("opencv") + id_backend_row = _make_two_field_row( + "Index:", + dlg.cam_index_label, + "Backend:", + dlg.cam_backend_label, + key_width=120, + gap=15, + ) + dlg.settings_form.addRow(id_backend_row) + + # --- Detected read-only labels --- + dlg.detected_resolution_label = QLabel("—") + dlg.detected_resolution_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + + dlg.detected_fps_label = QLabel("—") + dlg.detected_fps_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + + detected_row = _make_two_field_row( + "Detected resolution:", + dlg.detected_resolution_label, + "Detected FPS:", + dlg.detected_fps_label, + key_width=120, + gap=10, + ) + dlg.settings_form.addRow(detected_row) + + # --- Requested resolution controls (Auto = 0) --- + dlg.cam_width = QSpinBox() + dlg.cam_width.setRange(0, 10000) + dlg.cam_width.setValue(0) + dlg.cam_width.setSpecialValueText("Auto") + + dlg.cam_height = QSpinBox() + dlg.cam_height.setRange(0, 10000) + dlg.cam_height.setValue(0) + dlg.cam_height.setSpecialValueText("Auto") + + res_row = _make_two_field_row("W", dlg.cam_width, "H", dlg.cam_height, key_width=30) + dlg.settings_form.addRow("Resolution:", res_row) + + # --- FPS + Rotation grouped --- + dlg.cam_fps = QDoubleSpinBox() + dlg.cam_fps.setRange(0.0, 240.0) + dlg.cam_fps.setDecimals(2) + dlg.cam_fps.setSingleStep(1.0) + dlg.cam_fps.setValue(0.0) + dlg.cam_fps.setSpecialValueText("Auto") + + dlg.cam_rotation = QComboBox() + dlg.cam_rotation.addItem("0°", 0) + dlg.cam_rotation.addItem("90°", 90) + dlg.cam_rotation.addItem("180°", 180) + dlg.cam_rotation.addItem("270°", 270) + + fps_rot_row = _make_two_field_row("FPS", dlg.cam_fps, "Rot", dlg.cam_rotation, key_width=30) + dlg.settings_form.addRow("Capture:", fps_rot_row) + + # --- Exposure + Gain grouped --- + dlg.cam_exposure = QSpinBox() + dlg.cam_exposure.setRange(0, 1000000) + dlg.cam_exposure.setValue(0) + dlg.cam_exposure.setSpecialValueText("Auto") + dlg.cam_exposure.setSuffix(" μs") + + dlg.cam_gain = QDoubleSpinBox() + dlg.cam_gain.setRange(0.0, 100.0) + dlg.cam_gain.setValue(0.0) + dlg.cam_gain.setSpecialValueText("Auto") + dlg.cam_gain.setDecimals(2) + + exp_gain_row = _make_two_field_row("Exp", dlg.cam_exposure, "Gain", dlg.cam_gain, key_width=30) + dlg.settings_form.addRow("Analog:", exp_gain_row) + + # --- Crop row --- + crop_widget = QWidget() + crop_layout = QHBoxLayout(crop_widget) + crop_layout.setContentsMargins(0, 0, 0, 0) + + dlg.cam_crop_x0 = ScrubSpinBox() + dlg.cam_crop_x0.setRange(0, 7680) + dlg.cam_crop_x0.setPrefix("x0:") + dlg.cam_crop_x0.setSpecialValueText("x0:None") + crop_layout.addWidget(dlg.cam_crop_x0) + + dlg.cam_crop_y0 = ScrubSpinBox() + dlg.cam_crop_y0.setRange(0, 4320) + dlg.cam_crop_y0.setPrefix("y0:") + dlg.cam_crop_y0.setSpecialValueText("y0:None") + crop_layout.addWidget(dlg.cam_crop_y0) + + dlg.cam_crop_x1 = ScrubSpinBox() + dlg.cam_crop_x1.setRange(0, 7680) + dlg.cam_crop_x1.setPrefix("x1:") + dlg.cam_crop_x1.setSpecialValueText("x1:None") + crop_layout.addWidget(dlg.cam_crop_x1) + + dlg.cam_crop_y1 = ScrubSpinBox() + dlg.cam_crop_y1.setRange(0, 4320) + dlg.cam_crop_y1.setPrefix("y1:") + dlg.cam_crop_y1.setSpecialValueText("y1:None") + crop_layout.addWidget(dlg.cam_crop_y1) + + dlg.settings_form.addRow("Crop:", crop_widget) + + # Apply/Reset buttons row + dlg.apply_settings_btn = QPushButton("Apply Settings") + dlg.apply_settings_btn.setIcon(dlg.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton)) + dlg.apply_settings_btn.setEnabled(False) + + dlg.reset_settings_btn = QPushButton("Reset Settings") + dlg.reset_settings_btn.setIcon(dlg.style().standardIcon(QStyle.StandardPixmap.SP_DialogResetButton)) + dlg.reset_settings_btn.setEnabled(False) + + sttgs_buttons_row = QWidget() + sttgs_button_layout = QHBoxLayout(sttgs_buttons_row) + sttgs_button_layout.setContentsMargins(0, 0, 0, 0) + sttgs_button_layout.setSpacing(8) + sttgs_button_layout.addWidget(dlg.apply_settings_btn) + sttgs_button_layout.addWidget(dlg.reset_settings_btn) + + dlg.settings_form.addRow(sttgs_buttons_row) + + # Preview button + dlg.preview_btn = QPushButton("Start Preview") + dlg.preview_btn.setIcon(dlg.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) + dlg.preview_btn.setEnabled(False) + dlg.settings_form.addRow(dlg.preview_btn) + + # Pressing enter on any settings field applies settings + dlg.cam_fps.setKeyboardTracking(False) + + fields = [ + dlg.cam_enabled_checkbox, + dlg.cam_width, + dlg.cam_height, + dlg.cam_fps, + dlg.cam_exposure, + dlg.cam_gain, + dlg.cam_crop_x0, + dlg.cam_crop_y0, + dlg.cam_crop_x1, + dlg.cam_crop_y1, + ] + for field in fields: + if hasattr(field, "lineEdit"): + le = field.lineEdit() # type: ignore[call-arg] + if hasattr(le, "returnPressed") and hasattr(dlg, "_apply_camera_settings"): + le.returnPressed.connect(dlg._apply_camera_settings) # type: ignore[attr-defined] + if hasattr(field, "installEventFilter"): + field.installEventFilter(dlg) + + return settings_group + + +def build_preview_group(dlg: CameraConfigDialog) -> QGroupBox: + """Build the 'Camera Preview' group box.""" + dlg.preview_group = QGroupBox("Camera Preview") + preview_layout = QVBoxLayout(dlg.preview_group) + + dlg.preview_label = QLabel("No preview") + dlg.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + dlg.preview_label.setMinimumSize(320, 240) + dlg.preview_label.setMaximumSize(400, 300) + dlg.preview_label.setStyleSheet("background-color: #1a1a1a; color: #888;") + preview_layout.addWidget(dlg.preview_label) + dlg.preview_label.installEventFilter(dlg) + + dlg.preview_status = QTextEdit() + dlg.preview_status.setReadOnly(True) + dlg.preview_status.setFixedHeight(45) + dlg.preview_status.setStyleSheet("QTextEdit { background: #141414; color: #bdbdbd; border: 1px solid #2a2a2a; }") + font = QFont("Consolas") + font.setPointSize(9) + dlg.preview_status.setFont(font) + preview_layout.addWidget(dlg.preview_status) + + dlg._loading_overlay = QLabel(dlg.preview_group) + dlg._loading_overlay.setVisible(False) + dlg._loading_overlay.setAlignment(Qt.AlignCenter) + dlg._loading_overlay.setStyleSheet("background-color: rgba(0,0,0,140); color: white; border: 1px solid #333;") + dlg._loading_overlay.setText("Loading camera…") + + dlg.preview_group.setVisible(False) + return dlg.preview_group + + +def build_right_scroll_container( + dlg: CameraConfigDialog, settings_group: QGroupBox, preview_group: QGroupBox +) -> QScrollArea: + """Wrap the settings and preview groups in a scroll area to prevent squishing.""" + scroll = QScrollArea() + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.NoFrame) + + scroll_contents = QWidget() + scroll_contents.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred) + + dlg._settings_scroll = scroll + dlg._settings_scroll_contents = scroll_contents + + scroll_contents.setMinimumWidth(scroll.viewport().width()) + scroll.viewport().installEventFilter(dlg) + + scroll_layout = QVBoxLayout(scroll_contents) + scroll_layout.setContentsMargins(0, 0, 0, 10) + scroll_layout.setSpacing(10) + + settings_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum) + preview_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum) + + scroll_layout.addWidget(settings_group) + scroll_layout.addWidget(preview_group) + scroll_layout.addStretch(1) + + scroll.setWidget(scroll_contents) + return scroll + + +# --------------------------------------------------------------------- +# Bottom row: OK / Cancel buttons +# --------------------------------------------------------------------- +def build_dialog_buttons_row(dlg: CameraConfigDialog) -> QHBoxLayout: + """Build the bottom OK/Cancel button row.""" + sttgs_button_layout = QHBoxLayout() + + dlg.ok_btn = QPushButton("OK") + dlg.ok_btn.setAutoDefault(False) + dlg.ok_btn.setDefault(False) + dlg.ok_btn.setIcon(dlg.style().standardIcon(QStyle.StandardPixmap.SP_DialogOkButton)) + + dlg.cancel_btn = QPushButton("Cancel") + dlg.cancel_btn.setAutoDefault(False) + dlg.cancel_btn.setDefault(False) + dlg.cancel_btn.setIcon(dlg.style().standardIcon(QStyle.StandardPixmap.SP_DialogCancelButton)) + + sttgs_button_layout.addStretch(1) + sttgs_button_layout.addWidget(dlg.ok_btn) + sttgs_button_layout.addWidget(dlg.cancel_btn) + + return sttgs_button_layout diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 92b94c9..a70c0cf 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -71,7 +71,7 @@ from ..utils.settings_store import DLCLiveGUISettingsStore, ModelPathStore from ..utils.stats import format_dlc_stats from ..utils.utils import FPSTracker -from .camera_config_dialog import CameraConfigDialog +from .camera_config.camera_config_dialog import CameraConfigDialog from .misc.drag_spinbox import ScrubSpinBox from .misc.eliding_label import ElidingPathLabel from .recording_manager import RecordingManager diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 76cb651..8db469f 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -10,6 +10,7 @@ import cv2 import numpy as np from PySide6.QtCore import QObject, QThread, Signal, Slot +from PySide6.QtGui import QImage, QPixmap from dlclivegui.cameras import CameraFactory from dlclivegui.cameras.base import CameraBackend @@ -218,13 +219,13 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float # Apply rotation if configured settings = self._settings.get(camera_id) if settings and settings.rotation: - frame = self._apply_rotation(frame, settings.rotation) + frame = MultiCameraController.apply_rotation(frame, settings.rotation) # Apply cropping if configured if settings: crop_region = settings.get_crop_region() if crop_region: - frame = self._apply_crop(frame, crop_region) + frame = MultiCameraController.apply_crop(frame, crop_region) with self._frame_lock: self._frames[camera_id] = frame @@ -240,7 +241,8 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float ) self.frame_ready.emit(frame_data) - def _apply_rotation(self, frame: np.ndarray, degrees: int) -> np.ndarray: + @staticmethod + def apply_rotation(frame: np.ndarray, degrees: int) -> np.ndarray: """Apply rotation to frame.""" if degrees == 90: return cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) @@ -250,7 +252,8 @@ def _apply_rotation(self, frame: np.ndarray, degrees: int) -> np.ndarray: return cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE) return frame - def _apply_crop(self, frame: np.ndarray, crop_region: tuple[int, int, int, int]) -> np.ndarray: + @staticmethod + def apply_crop(frame: np.ndarray, crop_region: tuple[int, int, int, int]) -> np.ndarray: """Apply crop to frame.""" x0, y0, x1, y1 = crop_region height, width = frame.shape[:2] @@ -264,6 +267,52 @@ def _apply_crop(self, frame: np.ndarray, crop_region: tuple[int, int, int, int]) return frame[y0:y1, x0:x1] return frame + @staticmethod + def apply_resize(frame: np.ndarray, max_w: int, max_h: int, allow_upscale: bool = False) -> np.ndarray: + """Resize frame to fit within max dimensions while maintaining aspect ratio.""" + h, w = frame.shape[:2] + if w == 0 or h == 0: + LOGGER.warning("Cannot resize frame with zero width or height") + return frame + + scale = min(max_w / w, max_h / h) + if not allow_upscale: + scale = min(scale, 1.0) + + if scale == 1.0: + return frame + + new_w, new_h = int(w * scale), int(h * scale) + return cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_AREA) + + @staticmethod + def ensure_color_bgr(frame: np.ndarray) -> np.ndarray: + """Ensure frame is 3-channel color.""" + if frame.ndim == 2: + return cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) + elif frame.shape[2] == 4: + return cv2.cvtColor(frame, cv2.COLOR_BGRA2BGR) + return frame + + @staticmethod + def ensure_color_rgb(frame: np.ndarray) -> np.ndarray: + if frame.ndim == 2: + frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2RGB) + elif frame.shape[2] == 4: + frame = cv2.cvtColor(frame, cv2.COLOR_BGRA2RGB) + else: + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + return frame + + @staticmethod + def to_display_pixmap(frame: np.ndarray) -> QPixmap: + """Convert a frame to QPixmap for display.""" + frame = MultiCameraController.ensure_color_rgb(frame) + h, w, ch = frame.shape + bytes_per_line = ch * w + q_img = QImage(frame.data, w, h, bytes_per_line, QImage.Format.Format_RGB888).copy() + return QPixmap.fromImage(q_img) + def _create_tiled_frame(self) -> np.ndarray: """Create a tiled frame from all camera frames. @@ -329,13 +378,10 @@ def _create_tiled_frame(self) -> np.ndarray: col = idx % cols # Ensure frame is 3-channel - if frame.ndim == 2: - frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) - elif frame.shape[2] == 4: - frame = cv2.cvtColor(frame, cv2.COLOR_BGRA2BGR) + frame = MultiCameraController.ensure_color_bgr(frame) # Resize to tile size - resized = cv2.resize(frame, (tile_w, tile_h)) + resized = MultiCameraController.apply_resize(frame, tile_w, tile_h, allow_upscale=True) # Add camera ID label if idx < len(cam_ids): diff --git a/tests/cameras/backends/test_generic_contracts.py b/tests/cameras/backends/test_generic_contracts.py index 1c8d770..317a12c 100644 --- a/tests/cameras/backends/test_generic_contracts.py +++ b/tests/cameras/backends/test_generic_contracts.py @@ -25,9 +25,9 @@ def _try_import_gui_apply_identity(): try: - from dlclivegui.gui.camera_config_dialog import _apply_detected_identity # type: ignore + from dlclivegui.gui.camera_config.camera_config_dialog import apply_detected_identity # type: ignore - return _apply_detected_identity + return apply_detected_identity except Exception: return None diff --git a/tests/gui/camera_config/test_cam_dialog_e2e.py b/tests/gui/camera_config/test_cam_dialog_e2e.py index 86d5b1c..95db414 100644 --- a/tests/gui/camera_config/test_cam_dialog_e2e.py +++ b/tests/gui/camera_config/test_cam_dialog_e2e.py @@ -12,7 +12,8 @@ from dlclivegui.cameras.base import CameraBackend from dlclivegui.cameras.factory import DetectedCamera from dlclivegui.config import CameraSettings, MultiCameraSettings -from dlclivegui.gui.camera_config_dialog import CameraConfigDialog, CameraLoadWorker +from dlclivegui.gui.camera_config.camera_config_dialog import CameraConfigDialog, CameraLoadWorker +from dlclivegui.gui.camera_config.preview import PreviewState # --------------------------------------------------------------------- # Helpers @@ -141,16 +142,18 @@ def test_e2e_preview_start_stop(dialog, qtbot): dialog.active_cameras_list.setCurrentRow(0) qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton) - qtbot.waitUntil(lambda: dialog._loader is None and dialog._preview_active, timeout=2000) - assert dialog._preview_active + qtbot.waitUntil( + lambda: dialog._preview.loader is None and dialog._preview.state == PreviewState.ACTIVE, timeout=2000 + ) + assert dialog._preview.backend is not None qtbot.waitUntil(lambda: dialog.preview_label.pixmap() is not None, timeout=2000) qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton) - qtbot.waitUntil(lambda: not dialog._preview_active, timeout=2000) + qtbot.waitUntil(lambda: dialog._preview.state == PreviewState.IDLE, timeout=2000) - assert dialog._preview_backend is None - assert dialog._preview_timer is None + assert dialog._preview.backend is None + assert dialog._preview.timer is None @pytest.mark.gui @@ -182,7 +185,9 @@ def read(self): dialog.active_cameras_list.setCurrentRow(0) qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton) - qtbot.waitUntil(lambda: dialog._loader is None and dialog._preview_active, timeout=2000) + qtbot.waitUntil( + lambda: dialog._preview.loader is None and dialog._preview.state == PreviewState.ACTIVE, timeout=2000 + ) before = CountingBackend.opens assert before >= 1 @@ -191,8 +196,8 @@ def read(self): qtbot.mouseClick(dialog.apply_settings_btn, Qt.LeftButton) qtbot.waitUntil(lambda: CountingBackend.opens >= before + 1, timeout=2000) - assert dialog._preview_active - assert dialog._preview_backend is not None + assert dialog._preview.state == PreviewState.ACTIVE + assert dialog._preview.backend is not None @pytest.mark.gui @@ -224,7 +229,9 @@ def read(self): dialog.active_cameras_list.setCurrentRow(0) qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton) - qtbot.waitUntil(lambda: dialog._loader is None and dialog._preview_active, timeout=2000) + qtbot.waitUntil( + lambda: dialog._preview.loader is None and dialog._preview.state == PreviewState.ACTIVE, timeout=2000 + ) before = CountingBackend.opens assert before >= 1 @@ -235,7 +242,7 @@ def read(self): qtbot.wait(200) assert CountingBackend.opens == before - assert dialog._preview_active + assert dialog._preview.state == PreviewState.ACTIVE @pytest.mark.gui @@ -400,11 +407,10 @@ def slow_run(self): dialog.active_cameras_list.setCurrentRow(0) qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton) # Start Preview => loading active - qtbot.waitUntil(lambda: dialog._loading_active, timeout=1000) + qtbot.waitUntil(lambda: dialog._preview.state == PreviewState.LOADING, timeout=1000) # Click again => Cancel Loading qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton) - qtbot.waitUntil(lambda: dialog._loader is None and not dialog._loading_active, timeout=2000) - assert dialog._preview_active is False - assert dialog._preview_backend is None + qtbot.waitUntil(lambda: dialog._preview.loader is None and dialog._preview.state == PreviewState.IDLE, timeout=2000) + assert dialog._preview.backend is None diff --git a/tests/gui/camera_config/test_cam_dialog_unit.py b/tests/gui/camera_config/test_cam_dialog_unit.py index 0eb8d45..fc73f75 100644 --- a/tests/gui/camera_config/test_cam_dialog_unit.py +++ b/tests/gui/camera_config/test_cam_dialog_unit.py @@ -7,7 +7,7 @@ from dlclivegui.cameras.factory import DetectedCamera from dlclivegui.config import CameraSettings, MultiCameraSettings -from dlclivegui.gui.camera_config_dialog import CameraConfigDialog +from dlclivegui.gui.camera_config.camera_config_dialog import CameraConfigDialog # ---------------------------- # Unit dialog fixture (deterministic, no threads) From 262d558225ab4907be0e070f24f3a043ce259bbb Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 16 Feb 2026 08:56:01 +0100 Subject: [PATCH 20/30] Wait for scan completion in max cameras test Improve stability of test_max_cameras_prevented by waiting for the scan to finish before injecting results. Removed the backend='fake' remark from the docstring and added qtbot.waitUntil(lambda: not d._is_scan_running(), timeout=1000) and d._on_scan_finished() around the simulated scan result to ensure the dialog is in a stable state before interacting with UI widgets. --- tests/gui/camera_config/test_cam_dialog_e2e.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/gui/camera_config/test_cam_dialog_e2e.py b/tests/gui/camera_config/test_cam_dialog_e2e.py index 95db414..160bf12 100644 --- a/tests/gui/camera_config/test_cam_dialog_e2e.py +++ b/tests/gui/camera_config/test_cam_dialog_e2e.py @@ -329,7 +329,7 @@ def _warn(parent, title, text, *args, **kwargs): @pytest.mark.gui def test_max_cameras_prevented(qtbot, monkeypatch, patch_detect_cameras): """ - Dialog enforces MAX_CAMERAS enabled cameras. Use backend='fake' for stability. + Dialog enforces MAX_CAMERAS enabled cameras. """ calls = {"n": 0} @@ -357,7 +357,9 @@ def _warn(parent, title, text, *args, **kwargs): initial_count = d.active_cameras_list.count() + qtbot.waitUntil(lambda: not d._is_scan_running(), timeout=1000) d._on_scan_result([DetectedCamera(index=4, label="Extra")]) + d._on_scan_finished() d.available_cameras_list.setCurrentRow(0) qtbot.mouseClick(d.add_camera_btn, Qt.LeftButton) From 38005abf3087e0d136c1260caa7cdc5be2e14837 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 16 Feb 2026 11:59:09 +0100 Subject: [PATCH 21/30] Add color/colormap UI and refactor layouts Introduce a new color_dropdowns module to provide colormap and bbox-color QComboBox helpers (gradient swatches, matplotlib registry integration, and enum-based BGR swatches). Refactor layouts.make_two_field_row (renamed and enhanced) to support flexible key/value pairs, styling, and optional spacing, and add enable_combo_shrink_to_current to size combos to their current item. Integrate these into the main window: wire a Visualization group with colormap and bbox color controls, use the new helpers to populate and manage combo state, add _on_colormap_changed, and update bbox color handling. Also update ui_blocks to use make_two_field_row, tweak several UI labels/rows, comment out the PYLON emulation env var, and change several CameraConfigDialog log statements from INFO to DEBUG for less noisy logging. --- .../gui/camera_config/camera_config_dialog.py | 12 +- dlclivegui/gui/camera_config/ui_blocks.py | 12 +- dlclivegui/gui/main_window.py | 105 +++++-- dlclivegui/gui/misc/color_dropdowns.py | 263 ++++++++++++++++++ dlclivegui/gui/misc/layouts.py | 253 +++++++++++++---- 5 files changed, 553 insertions(+), 92 deletions(-) create mode 100644 dlclivegui/gui/misc/color_dropdowns.py diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index 789b429..aba582b 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -586,7 +586,7 @@ def _on_active_camera_selected(self, row: int) -> None: LOGGER.debug("[Selection] Suppressed currentRowChanged event at index %d.", row) return prev_row = self._current_edit_index - LOGGER.info( + LOGGER.debug( "[Select] row=%s prev=%s preview_state=%s", row, prev_row, @@ -878,7 +878,7 @@ def _apply_camera_settings(self) -> bool: self._working_settings.cameras[row] = new_model self._update_active_list_item(row, new_model) - LOGGER.info( + LOGGER.debug( "[Apply] backend=%s idx=%s changes=%s", getattr(new_model, "backend", None), getattr(new_model, "index", None), @@ -903,7 +903,7 @@ def _apply_camera_settings(self) -> bool: if should_consider_restart: restart = self._should_restart_preview(old_settings, new_model) - LOGGER.info( + LOGGER.debug( "[Apply] preview_state=%s restart=%s backend=%s idx=%s", self._preview.state, restart, @@ -1237,7 +1237,7 @@ def _begin_preview_load(self, cam: CameraSettings, *, reason: str) -> None: - Creates and wires loader - Sets requested_cam """ - LOGGER.info("[Preview] begin load reason=%s backend=%s idx=%s", reason, cam.backend, cam.index) + LOGGER.debug("[Preview] begin load reason=%s backend=%s idx=%s", reason, cam.backend, cam.index) # If already loading, just coalesce restart/intention if self._preview.state == PreviewState.LOADING: @@ -1292,7 +1292,7 @@ def _start_preview(self) -> None: return self._current_edit_index = row - LOGGER.info( + LOGGER.debug( "[Preview] resolved start row=%s active_row=%s", self._current_edit_index, self.active_cameras_list.currentRow(), @@ -1313,7 +1313,7 @@ def _stop_preview(self) -> None: def _stop_preview_internal(self, *, reason: str) -> None: """Tear down loader/backend/timer. Safe to call from anywhere.""" - LOGGER.info("[Preview] stop reason=%s state=%s", reason, self._preview.state) + LOGGER.debug("[Preview] stop reason=%s state=%s", reason, self._preview.state) self._preview.state = PreviewState.STOPPING diff --git a/dlclivegui/gui/camera_config/ui_blocks.py b/dlclivegui/gui/camera_config/ui_blocks.py index b504e75..28395c0 100644 --- a/dlclivegui/gui/camera_config/ui_blocks.py +++ b/dlclivegui/gui/camera_config/ui_blocks.py @@ -31,7 +31,7 @@ from ...cameras import CameraFactory from ..misc.drag_spinbox import ScrubSpinBox from ..misc.eliding_label import ElidingPathLabel -from ..misc.layouts import _make_two_field_row +from ..misc.layouts import make_two_field_row if TYPE_CHECKING: from camera_config_dialog import CameraConfigDialog @@ -250,7 +250,7 @@ def build_settings_group(dlg: CameraConfigDialog) -> QGroupBox: dlg.cam_index_label = QLabel("0") dlg.cam_backend_label = QLabel("opencv") - id_backend_row = _make_two_field_row( + id_backend_row = make_two_field_row( "Index:", dlg.cam_index_label, "Backend:", @@ -267,7 +267,7 @@ def build_settings_group(dlg: CameraConfigDialog) -> QGroupBox: dlg.detected_fps_label = QLabel("—") dlg.detected_fps_label.setTextInteractionFlags(Qt.TextSelectableByMouse) - detected_row = _make_two_field_row( + detected_row = make_two_field_row( "Detected resolution:", dlg.detected_resolution_label, "Detected FPS:", @@ -288,7 +288,7 @@ def build_settings_group(dlg: CameraConfigDialog) -> QGroupBox: dlg.cam_height.setValue(0) dlg.cam_height.setSpecialValueText("Auto") - res_row = _make_two_field_row("W", dlg.cam_width, "H", dlg.cam_height, key_width=30) + res_row = make_two_field_row("W", dlg.cam_width, "H", dlg.cam_height, key_width=30) dlg.settings_form.addRow("Resolution:", res_row) # --- FPS + Rotation grouped --- @@ -305,7 +305,7 @@ def build_settings_group(dlg: CameraConfigDialog) -> QGroupBox: dlg.cam_rotation.addItem("180°", 180) dlg.cam_rotation.addItem("270°", 270) - fps_rot_row = _make_two_field_row("FPS", dlg.cam_fps, "Rot", dlg.cam_rotation, key_width=30) + fps_rot_row = make_two_field_row("FPS", dlg.cam_fps, "Rot", dlg.cam_rotation, key_width=30) dlg.settings_form.addRow("Capture:", fps_rot_row) # --- Exposure + Gain grouped --- @@ -321,7 +321,7 @@ def build_settings_group(dlg: CameraConfigDialog) -> QGroupBox: dlg.cam_gain.setSpecialValueText("Auto") dlg.cam_gain.setDecimals(2) - exp_gain_row = _make_two_field_row("Exp", dlg.cam_exposure, "Gain", dlg.cam_gain, key_width=30) + exp_gain_row = make_two_field_row("Exp", dlg.cam_exposure, "Gain", dlg.cam_gain, key_width=30) dlg.settings_form.addRow("Analog:", exp_gain_row) # --- Crop row --- diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index a70c0cf..9a794ed 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -9,8 +9,10 @@ import time from pathlib import Path -os.environ["PYLON_CAMEMU"] = "2" - +# NOTE @C-Achard: his could be added in settings eventually +# Forces pypylon to create 2 emulation virtual cameras, +# mostly for testing. This shold not be enabled for release. +# os.environ["PYLON_CAMEMU"] = "2" import cv2 import numpy as np from PySide6.QtCore import QRect, QSettings, Qt, QTimer, QUrl @@ -72,6 +74,8 @@ from ..utils.stats import format_dlc_stats from ..utils.utils import FPSTracker from .camera_config.camera_config_dialog import CameraConfigDialog +from .misc import color_dropdowns as color_ui +from .misc import layouts as lyts from .misc.drag_spinbox import ScrubSpinBox from .misc.eliding_label import ElidingPathLabel from .recording_manager import RecordingManager @@ -306,7 +310,7 @@ def _setup_ui(self) -> None: controls_layout.addWidget(self._build_camera_group()) controls_layout.addWidget(self._build_dlc_group()) controls_layout.addWidget(self._build_recording_group()) - controls_layout.addWidget(self._build_bbox_group()) + controls_layout.addWidget(self._build_viz_group()) # Preview/Stop buttons at bottom of controls - wrap in widget button_bar_widget = QWidget() @@ -378,7 +382,7 @@ def _build_menus(self) -> None: self._init_theme_actions() def _build_camera_group(self) -> QGroupBox: - group = QGroupBox("Camera settings") + group = QGroupBox("Camera") form = QFormLayout(group) # Camera config button - opens dialog for all camera configuration @@ -397,7 +401,7 @@ def _build_camera_group(self) -> QGroupBox: return group def _build_dlc_group(self) -> QGroupBox: - group = QGroupBox("DLCLive settings") + group = QGroupBox("DLCLive") form = QFormLayout(group) path_layout = QHBoxLayout() @@ -443,7 +447,7 @@ def _build_dlc_group(self) -> QGroupBox: # form.addRow("Additional options", self.additional_options_edit) self.dlc_camera_combo = QComboBox() self.dlc_camera_combo.setToolTip("Select which camera to use for pose inference") - form.addRow("Inference Camera", self.dlc_camera_combo) + form.addRow("Inference camera", self.dlc_camera_combo) # Wrap inference buttons in a widget to prevent shifting inference_button_widget = QWidget() @@ -461,9 +465,9 @@ def _build_dlc_group(self) -> QGroupBox: inference_buttons.addWidget(self.stop_inference_button) form.addRow(inference_button_widget) - self.show_predictions_checkbox = QCheckBox("Display pose predictions") - self.show_predictions_checkbox.setChecked(True) - form.addRow(self.show_predictions_checkbox) + # self.show_predictions_checkbox = QCheckBox("Display pose predictions") + # self.show_predictions_checkbox.setChecked(True) + # form.addRow(self.show_predictions_checkbox) self.allow_processor_ctrl_checkbox = QCheckBox("Allow processor-based control") self.allow_processor_ctrl_checkbox.setChecked(False) @@ -495,7 +499,7 @@ def _build_recording_group(self) -> QGroupBox: # Session + run name self.session_name_edit = QLineEdit() self.session_name_edit.setPlaceholderText("e.g. mouseA_day1") - form.addRow("Session name", self.session_name_edit) + # form.addRow("Session name", self.session_name_edit) self.use_timestamp_checkbox = QCheckBox("Use timestamp for run folder name") self.use_timestamp_checkbox.setChecked(True) @@ -503,7 +507,11 @@ def _build_recording_group(self) -> QGroupBox: "If checked, run folder will be run_YYYYMMDD_HHMMSS_mmm.\n" "If unchecked, run folder will be run_0001, run_0002, ..." ) - form.addRow("", self.use_timestamp_checkbox) + # form.addRow("", self.use_timestamp_checkbox) + session_opts = lyts.make_two_field_row( + "Session name", self.session_name_edit, None, self.use_timestamp_checkbox, key_width=100 + ) + form.addRow(session_opts) # Show recording path preview @@ -575,7 +583,7 @@ def _build_recording_group(self) -> QGroupBox: ## CRF crf_label = QLabel("CRF") - crf_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred) + crf_label.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred) grid.addWidget(crf_label, 0, 4) self.crf_spin = QSpinBox() @@ -618,24 +626,55 @@ def _build_recording_group(self) -> QGroupBox: return group - def _build_bbox_group(self) -> QGroupBox: - group = QGroupBox("Bounding Box Visualization") + def _build_viz_group(self) -> QGroupBox: + group = QGroupBox("Visualization") form = QFormLayout(group) + self.show_predictions_checkbox = QCheckBox("Display pose predictions") + self.show_predictions_checkbox.setChecked(True) + + self.cmap_combo = QComboBox() + self.cmap_combo.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred) + self.cmap_combo.setToolTip("Select colormap to use when displaying keypoints (bodypart-based coloring)") + color_ui.populate_colormap_combo( + self.cmap_combo, + current=self._colormap, + favorites_first=["turbo", "jet", "hsv"], + exclude_reversed=True, + filters={"cet_": 5}, # include only first 5 colormaps from the 'cet_' family to avoid redundant options + ) + lyts.enable_combo_shrink_to_current(self.cmap_combo, min_width=80, max_width=200) + + keypoints_settings = lyts.make_two_field_row( + "Keypoint colormap: ", + self.cmap_combo, + None, + self.show_predictions_checkbox, + key_width=None, + left_stretch=0, + right_stretch=0, + ) + form.addRow(keypoints_settings) - row_widget = QWidget() - checkbox_layout = QHBoxLayout(row_widget) - checkbox_layout.setContentsMargins(0, 0, 0, 0) self.bbox_enabled_checkbox = QCheckBox("Show bounding box") self.bbox_enabled_checkbox.setChecked(False) - checkbox_layout.addWidget(self.bbox_enabled_checkbox) - checkbox_layout.addWidget(QLabel("Color:")) self.bbox_color_combo = QComboBox() - self._populate_bbox_color_combo_with_swatches() + self.bbox_color_combo.setToolTip("Select color for bounding box") + self.bbox_color_combo.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred) + color_ui.populate_bbox_color_combo(self.bbox_color_combo, BBoxColors, current_bgr=self._bbox_color) self.bbox_color_combo.setCurrentIndex(0) - checkbox_layout.addWidget(self.bbox_color_combo) - checkbox_layout.addStretch(1) - form.addRow(row_widget) + lyts.enable_combo_shrink_to_current(self.bbox_color_combo, min_width=80, max_width=200) + + bbox_settings = lyts.make_two_field_row( + "Bounding box color: ", + self.bbox_color_combo, + None, + self.bbox_enabled_checkbox, + key_width=None, + left_stretch=0, + right_stretch=0, + ) + form.addRow(bbox_settings) bbox_layout = QHBoxLayout() self.bbox_x0_spin = ScrubSpinBox() @@ -679,7 +718,10 @@ def _connect_signals(self) -> None: # Camera config dialog self.config_cameras_button.clicked.connect(self._open_camera_config_dialog) - # Connect bounding box controls + # Visualization settings + ## Colormap change + self.cmap_combo.currentIndexChanged.connect(self._on_colormap_changed) + ## Connect bounding box controls self.bbox_enabled_checkbox.stateChanged.connect(self._on_bbox_changed) self.bbox_x0_spin.valueChanged.connect(self._on_bbox_changed) self.bbox_y0_spin.valueChanged.connect(self._on_bbox_changed) @@ -760,9 +802,11 @@ def _apply_config(self, config: ApplicationSettings) -> None: viz = config.visualization self._p_cutoff = viz.p_cutoff self._colormap = viz.colormap + if hasattr(self, "cmap_combo"): + color_ui.set_cmap_combo_from_name(self.cmap_combo, self._colormap, fallback="viridis") self._bbox_color = viz.get_bbox_color_bgr() if hasattr(self, "bbox_color_combo"): - self._set_combo_from_color(self._bbox_color) + color_ui.set_bbox_combo_from_bgr(self.bbox_color_combo, self._bbox_color) # Update DLC camera list self._refresh_dlc_camera_list() @@ -1050,11 +1094,16 @@ def _on_use_timestamp_changed(self, _state: int) -> None: self._settings_store.set_use_timestamp(self.use_timestamp_checkbox.isChecked()) self._update_recording_path_preview() + def _on_colormap_changed(self, _index: int) -> None: + self._colormap = color_ui.get_cmap_name_from_combo(self.cmap_combo, fallback=self._colormap) + if self._current_frame is not None: + self._display_frame(self._current_frame, force=True) + def _on_bbox_color_changed(self, _index: int) -> None: - enum_item = self.bbox_color_combo.currentData() - if enum_item is None: + bgr = color_ui.get_bbox_bgr_from_combo(self.bbox_color_combo, fallback=self._bbox_color) + if bgr is None: return - self._bbox_color = enum_item.value + self._bbox_color = bgr if self._current_frame is not None: self._display_frame(self._current_frame, force=True) diff --git a/dlclivegui/gui/misc/color_dropdowns.py b/dlclivegui/gui/misc/color_dropdowns.py new file mode 100644 index 0000000..ce0e713 --- /dev/null +++ b/dlclivegui/gui/misc/color_dropdowns.py @@ -0,0 +1,263 @@ +"""UI elements for color selection dropdowns (colors, colormaps)""" + +# dlclivegui/gui/misc/color_dropdowns.py +from __future__ import annotations + +from collections.abc import Iterable, Sequence + +import numpy as np +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QIcon, QImage, QPainter, QPixmap +from PySide6.QtWidgets import QComboBox + +BGR = tuple[int, int, int] + + +# ------------------------------ +# BBox color combo (enum-based) +# ------------------------------ +def make_bgr_swatch_icon( + bgr: BGR, + *, + width: int = 40, + height: int = 16, + border: int = 1, +) -> QIcon: + """Create a small BGR color swatch icon for use in QComboBox items.""" + pix = QPixmap(width, height) + pix.fill(Qt.transparent) + + p = QPainter(pix) + # Border/background + p.fillRect(0, 0, width, height, Qt.black) + # Inner background + p.fillRect(border, border, width - 2 * border, height - 2 * border, Qt.white) + + # Convert BGR -> RGB for Qt + rgb = (bgr[2], bgr[1], bgr[0]) + p.fillRect(border + 1, border + 1, width - 2 * (border + 1), height - 2 * (border + 1), QColor(*rgb)) + + p.end() + return QIcon(pix) + + +def populate_bbox_color_combo( + combo: QComboBox, + colors_enum: Iterable, + *, + current_bgr: BGR | None = None, + include_icons: bool = True, +) -> None: + """ + Populate a QComboBox with bbox colors from an enum (e.g. BBoxColors). + + The enum items are stored as itemData so you can retrieve .value (BGR). + """ + combo.blockSignals(True) + combo.clear() + + for enum_item in colors_enum: + bgr: BGR = enum_item.value + name = getattr(enum_item, "name", str(enum_item)).title() + + if include_icons: + icon = make_bgr_swatch_icon(bgr) + combo.addItem(icon, name, enum_item) + else: + combo.addItem(name, enum_item) + + # Set selection if current_bgr provided + if current_bgr is not None: + set_bbox_combo_from_bgr(combo, current_bgr) + + combo.blockSignals(False) + + +def set_bbox_combo_from_bgr(combo: QComboBox, bgr: BGR) -> None: + """Select the first item whose enum_item.value == bgr.""" + for i in range(combo.count()): + enum_item = combo.itemData(i) + if enum_item is not None and getattr(enum_item, "value", None) == bgr: + combo.setCurrentIndex(i) + return + + +def get_bbox_bgr_from_combo(combo: QComboBox, *, fallback: BGR | None = None) -> BGR | None: + """Return selected BGR value (enum_item.value).""" + enum_item = combo.currentData() + if enum_item is None: + return fallback + return getattr(enum_item, "value", fallback) + + +# ----------------------------------- +# Matplotlib colormap combo (registry) +# ----------------------------------- +def _safe_import_matplotlib_colormaps(): + """ + Import matplotlib colormap registry lazily. + Returns (colormaps_registry, ok_bool). + """ + try: + from matplotlib import colormaps + + return colormaps, True + except Exception: + return None, False + + +def list_matplotlib_colormap_names( + *, + exclude_reversed: bool = True, + favorites_first: Sequence[str] | None = None, +) -> list[str]: + """ + Return a list of registered Matplotlib colormap names. + + Uses `list(matplotlib.colormaps)` (Matplotlib's documented way to list all + registered colormaps). + Optionally excludes reversed maps (*_r) + """ + registry, ok = _safe_import_matplotlib_colormaps() + if not ok or registry is None: + return [] + + names = sorted(list(registry)) + if exclude_reversed: + names = [n for n in names if not n.endswith("_r")] + + if favorites_first: + fav = [n for n in favorites_first if n in names] + rest = [n for n in names if n not in set(fav)] + return fav + rest + + return names + + +def make_cmap_gradient_icon( + cmap_name: str, + *, + width: int = 80, + height: int = 14, +) -> QIcon | None: + """ + Create a gradient icon by sampling a Matplotlib colormap. + + Uses the colormap registry lookup: `matplotlib.colormaps[name]`. + Returns None if Matplotlib isn't available. + """ + registry, ok = _safe_import_matplotlib_colormaps() + if not ok or registry is None: + return None + + try: + cmap = registry[cmap_name] + except Exception: + return None + + x = np.linspace(0.0, 1.0, width) + rgba = (cmap(x) * 255).astype(np.uint8) # (width, 4) + + # Convert to RGB row and repeat vertically + rgb_row = rgba[:, :3] # (width, 3) + rgb_img = np.repeat(rgb_row[np.newaxis, :, :], height, axis=0) # (height, width, 3) + + # QImage referencing numpy memory; copy into QPixmap to own memory safely + qimg = QImage(rgb_img.data, width, height, 3 * width, QImage.Format.Format_RGB888) + pix = QPixmap.fromImage(qimg.copy()) + + return QIcon(pix) + + +def populate_colormap_combo( + combo: QComboBox, + *, + current: str | None = None, + include_icons: bool = True, + exclude_reversed: bool = True, + favorites_first: Sequence[str] | None = None, + filters: dict[str, int] | None = None, + icon_width: int = 80, + icon_height: int = 14, +) -> None: + """ + Populate a QComboBox with Matplotlib colormap names. + + - Names come from Matplotlib's colormap registry (`list(colormaps)`). + - Optionally hides reversed maps (*_r). + - Stores the name string as itemData. + + Args: + combo: The QComboBox to populate. + current: Optional name to select after populating. + include_icons: If True, adds a gradient icon for each colormap. + exclude_reversed: If True, excludes colormaps with names ending in "_r". + favorites_first: Optional list of colormap names to prioritize at the top. + filters: Optional dict of {substring: min_count} to filter certain colormaps + to have a maximum count (e.g. {"cet_": 10}, + including at most 10 colormaps containing "cet_"). + icon_width: Width of the gradient icons in pixels. + icon_height: Height of the gradient icons in pixels. + """ + names = list_matplotlib_colormap_names( + exclude_reversed=exclude_reversed, + favorites_first=favorites_first, + ) + if filters: + filtered_names = [] + for substr, max_count in filters.items(): + matching = [n for n in names if substr in n] + filtered_names.extend(matching[:max_count]) + unmatched = [n for n in names if not any(substr in n for substr in filters)] + filtered_names.extend(unmatched) + names = filtered_names + names = sorted(names) + + combo.blockSignals(True) + combo.clear() + + # If Matplotlib isn't available, still allow typing/selection of a name + if not names: + if current: + combo.addItem(current, current) + combo.setCurrentIndex(0) + combo.setEditable(True) + combo.blockSignals(False) + return + + for name in names: + if include_icons: + icon = make_cmap_gradient_icon(name, width=icon_width, height=icon_height) + if icon is not None: + combo.addItem(icon, name, name) + else: + combo.addItem(name, name) + else: + combo.addItem(name, name) + + if current: + set_cmap_combo_from_name(combo, current) + + combo.blockSignals(False) + + +def set_cmap_combo_from_name(combo: QComboBox, name: str, *, fallback: str | None = "viridis") -> None: + """Select `name` if present, else fallback if present.""" + idx = combo.findData(name) + if idx >= 0: + combo.setCurrentIndex(idx) + return + if fallback: + idx2 = combo.findData(fallback) + if idx2 >= 0: + combo.setCurrentIndex(idx2) + + +def get_cmap_name_from_combo(combo: QComboBox, *, fallback: str = "viridis") -> str: + """Return selected colormap name (itemData) or editable text.""" + data = combo.currentData() + if isinstance(data, str) and data: + return data + # If combo is editable + text = combo.currentText().strip() + return text or fallback diff --git a/dlclivegui/gui/misc/layouts.py b/dlclivegui/gui/misc/layouts.py index afe263c..2b09b47 100644 --- a/dlclivegui/gui/misc/layouts.py +++ b/dlclivegui/gui/misc/layouts.py @@ -1,22 +1,129 @@ -"""Utility functions to create common layouts.""" - # dlclivegui/gui/misc/layouts.py -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QGridLayout, QLabel, QSizePolicy, QWidget +from __future__ import annotations + +from collections.abc import Sequence + +from PySide6.QtCore import QObject, Qt +from PySide6.QtWidgets import QComboBox, QGridLayout, QLabel, QSizePolicy, QStyle, QStyleOptionComboBox, QWidget + + +def _combo_width_for_current_text(combo: QComboBox, extra_padding: int = 10) -> int: + """ + Compute a width that fits the *current* text + icon + arrow/frame. + Uses Qt style metrics to be platform/theme-correct. + """ + opt = QStyleOptionComboBox() + combo.initStyleOption(opt) + + text = combo.currentText() or "" + fm = combo.fontMetrics() + text_px = fm.horizontalAdvance(text) + + icon_px = 0 + # Account for icon shown in the label + if not combo.itemIcon(combo.currentIndex()).isNull(): + icon_px = combo.iconSize().width() + 4 # 4px spacing fudge + + # Frame + arrow area (common approach: combo frame + scrollbar extent for arrow) + frame = combo.style().pixelMetric(QStyle.PM_ComboBoxFrameWidth, opt, combo) + arrow = combo.style().pixelMetric(QStyle.PM_ScrollBarExtent, opt, combo) + + # Total + return text_px + icon_px + (2 * frame) + arrow + extra_padding + + +def enable_combo_shrink_to_current( + combo: QComboBox, + *, + min_width: int = 80, + max_width: int | None = None, + extra_padding: int = 10, +) -> QObject: + """ + Make QComboBox width follow the current item width (instead of widest item). + + Returns an object you can keep alive if you want; in practice, the connections + are owned by `combo`, but returning a QObject is convenient for future expansion. + """ + # Let the widget be fixed-width (we'll drive it dynamically) + combo.setSizePolicy(QSizePolicy.Fixed, combo.sizePolicy().verticalPolicy()) + + def _update(): + w = _combo_width_for_current_text(combo, extra_padding=extra_padding) + w = max(min_width, w) + if max_width is not None: + w = min(max_width, w) + combo.setFixedWidth(w) + + # Update when selection changes + combo.currentIndexChanged.connect(lambda _i: _update()) + combo.currentTextChanged.connect(lambda _t: _update()) + + # First update after items are populated + _update() + # Return dummy handle (could be used for disconnecting later if needed) + return combo -def _make_two_field_row( - left_label: str, - left_widget: QWidget, - right_label: str, - right_widget: QWidget, + +def make_two_field_row( + left_label: str | None, + left_widget: QWidget | None, + right_label: str | None, + right_widget: QWidget | None, left_stretch: int = 1, right_stretch: int = 1, *, - key_width: int = 100, # width for the "label" columns (Index:, Backend:, etc.) - gap: int = 10, # space between the two pairs + key_width: int | None = 100, + gap: int = 10, + reserve_key_space_if_none: bool = False, + style_values: bool = True, + value_style_qss: str | None = None, + value_style_types: Sequence[type[QWidget]] = (QLabel,), # extend if you want + value_style_classnames: Sequence[str] = ("ElidingPathLabel",), ) -> QWidget: - """Two pairs in one row with aligned columns: [key][value] [key][value].""" + """ + Two pairs in one row: [key][value] [key][value], but dynamically built. + + Key behavior: + - If a label is None: + - if reserve_key_space_if_none=False: no key widget is created (no space used). + - if reserve_key_space_if_none=True: an empty QLabel is created to keep alignment. + - If a widget is None: that side is omitted entirely. + - The gap is only inserted if BOTH sides exist. + - Column stretching applies only to the value columns that actually exist. + + Args: + left_label: Text for the left key label, or None for no label. + left_widget: Widget for the left value, or None for no widget. + right_label: Text for the right key label, or None for no label. + right_widget: Widget for the right value, or None for no widget. + left_stretch: Stretch factor for the left value column (default 1). + right_stretch: Stretch factor for the right value column (default 1). + key_width: If not None, fixed width for key labels; if 0, minimal width. + gap: Horizontal gap in pixels between the two pairs (default 10). + reserve_key_space_if_none: If True, reserves space for key label even if text is None. + style_values: If True, applies value_style_qss to value widgets of specified types. + value_style_qss: Custom QSS string for styling value widgets; if None, uses default. + value_style_types: Widget types to apply value styling to (default: QLabel). + value_style_classnames: Widget class names to apply value styling to + (for custom widgets without importing their classes). + + Returns: + QWidget: A QWidget containing the two-field row layout. + """ + + # ---------- sanitize args ---------- + if left_widget is None and right_widget is None: + # Nothing to show; return an empty widget with no margins + empty = QWidget() + empty.setContentsMargins(0, 0, 0, 0) + return empty + + gap = max(0, int(gap)) + # Negative stretch doesn’t make sense; treat as 0 + left_stretch = max(0, int(left_stretch)) + right_stretch = max(0, int(right_stretch)) row = QWidget() grid = QGridLayout(row) @@ -24,56 +131,98 @@ def _make_two_field_row( grid.setHorizontalSpacing(10) grid.setVerticalSpacing(0) - # Key labels - l1 = QLabel(left_label) - l2 = QLabel(right_label) + # Default value styling + if value_style_qss is None: + value_style_qss = """ + QLabel, ElidingPathLabel { + font-weight: 700; + color: palette(text); + background-color: rgba(127,127,127,0.12); + border: 1px solid rgba(127,127,127,0.18); + border-radius: 6px; + padding: 2px 6px; + } + """ - for lbl in (l1, l2): - lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - lbl.setFixedWidth(key_width) - # lbl.setStyleSheet("QLabel { color: palette(mid); font-weight: 500; }") - - def style_value(w: QWidget): - w.setStyleSheet( - """ - QLabel, ElidingPathLabel { - font-weight: 700; - color: palette(text); - background-color: rgba(127,127,127,0.12); - border: 1px solid rgba(127,127,127,0.18); - border-radius: 6px; - padding: 2px 6px; - } - """ - ) - if isinstance(w, QLabel): - w.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + def _should_style(w: QWidget) -> bool: + if isinstance(w, tuple(value_style_types)): + return True + # Avoid importing custom widgets just for isinstance; match by class name + return w.__class__.__name__ in set(value_style_classnames) + + def _style_value(w: QWidget) -> None: + if not style_values: + return + if _should_style(w): + w.setStyleSheet(value_style_qss) + if isinstance(w, QLabel): + w.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + # Keep widgets from stretching weirdly sp = w.sizePolicy() sp.setHorizontalPolicy(QSizePolicy.Preferred) w.setSizePolicy(sp) - style_value(left_widget) - style_value(right_widget) + def _make_key_label(text: str | None) -> QLabel | None: + """ + Create a key QLabel or None. + If reserve_key_space_if_none=True and text is None, create empty QLabel. + """ + if text is None and not reserve_key_space_if_none: + return None + + lbl = QLabel("" if text is None else text) + lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + + if key_width is not None: + # If key_width is 0, make it truly minimal; else fixed width. + if key_width <= 0: + lbl.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred) + else: + lbl.setFixedWidth(key_width) + + return lbl + + # ---------- dynamic column builder ---------- + col = 0 + value_cols: list[tuple[int, int]] = [] # (column_index, stretch) + + def _add_pair(label_text: str | None, widget: QWidget | None, stretch: int) -> None: + nonlocal col + if widget is None: + return + + key_lbl = _make_key_label(label_text) + if key_lbl is not None: + grid.addWidget(key_lbl, 0, col) + grid.setColumnStretch(col, 0) + col += 1 + + _style_value(widget) + grid.addWidget(widget, 0, col) + value_cols.append((col, stretch)) + col += 1 - # Layout columns: 0=key1, 1=val1, 2=gap spacer, 3=key2, 4=val2 - grid.addWidget(l1, 0, 0) - grid.addWidget(left_widget, 0, 1) + left_present = left_widget is not None + right_present = right_widget is not None - spacer = QWidget() - spacer.setFixedWidth(gap) - grid.addWidget(spacer, 0, 2) + # Left pair + _add_pair(left_label, left_widget, left_stretch) - grid.addWidget(l2, 0, 3) - grid.addWidget(right_widget, 0, 4) + # Gap only if both exist and gap > 0 + if left_present and right_present and gap > 0: + spacer = QWidget() + spacer.setFixedWidth(gap) + spacer.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred) + grid.addWidget(spacer, 0, col) + grid.setColumnStretch(col, 0) + col += 1 - # Stretch values, not keys - grid.setColumnStretch(1, left_stretch) - grid.setColumnStretch(4, right_stretch) + # Right pair + _add_pair(right_label, right_widget, right_stretch) - # Prevent keys from stretching - grid.setColumnStretch(0, 0) - grid.setColumnStretch(3, 0) - grid.setColumnStretch(2, 0) + # Apply stretch only to value columns that exist + for c, s in value_cols: + grid.setColumnStretch(c, s) return row From 4cac077a1d52a8a04aa88fd0719ebb273955ec25 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 16 Feb 2026 12:56:25 +0100 Subject: [PATCH 22/30] Refactor color combos & add sizing behavior Introduce a ShrinkCurrentWidePopupComboBox and ComboSizing to make combobox controls shrink to the current selection while the popup widens to fit the longest item. Add factory helpers (make_colormap_combo, make_bbox_color_combo) that create/populate colormap and bbox-color combos with sizing, icons, tooltips and safer Matplotlib handling. Rename/refactor colormap helpers (list_colormap_names, _safe_mpl_colormaps_registry), improve gradient icon creation, and preserve editable behavior if Matplotlib is unavailable. Update main_window to use the new factories, pass sizing and icon options, and adjust key widths for layout consistency. --- dlclivegui/gui/main_window.py | 30 ++- dlclivegui/gui/misc/color_dropdowns.py | 358 ++++++++++++++++++------- 2 files changed, 271 insertions(+), 117 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 9a794ed..14968ea 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -632,24 +632,22 @@ def _build_viz_group(self) -> QGroupBox: self.show_predictions_checkbox = QCheckBox("Display pose predictions") self.show_predictions_checkbox.setChecked(True) - self.cmap_combo = QComboBox() - self.cmap_combo.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred) - self.cmap_combo.setToolTip("Select colormap to use when displaying keypoints (bodypart-based coloring)") - color_ui.populate_colormap_combo( - self.cmap_combo, + self.cmap_combo = color_ui.make_colormap_combo( current=self._colormap, + tooltip="Select colormap to use when displaying keypoints (bodypart-based coloring)", favorites_first=["turbo", "jet", "hsv"], exclude_reversed=True, filters={"cet_": 5}, # include only first 5 colormaps from the 'cet_' family to avoid redundant options + include_icons=True, + sizing=color_ui.ComboSizing(min_width=80, max_width=200, popup_extra_padding=40), ) - lyts.enable_combo_shrink_to_current(self.cmap_combo, min_width=80, max_width=200) keypoints_settings = lyts.make_two_field_row( "Keypoint colormap: ", self.cmap_combo, None, self.show_predictions_checkbox, - key_width=None, + key_width=120, left_stretch=0, right_stretch=0, ) @@ -658,19 +656,23 @@ def _build_viz_group(self) -> QGroupBox: self.bbox_enabled_checkbox = QCheckBox("Show bounding box") self.bbox_enabled_checkbox.setChecked(False) - self.bbox_color_combo = QComboBox() - self.bbox_color_combo.setToolTip("Select color for bounding box") - self.bbox_color_combo.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred) - color_ui.populate_bbox_color_combo(self.bbox_color_combo, BBoxColors, current_bgr=self._bbox_color) - self.bbox_color_combo.setCurrentIndex(0) - lyts.enable_combo_shrink_to_current(self.bbox_color_combo, min_width=80, max_width=200) + self.bbox_color_combo = color_ui.make_bbox_color_combo( + BBoxColors, + current_bgr=self._bbox_color, + include_icons=True, + tooltip="Select color for bounding box", + sizing=color_ui.ComboSizing( + min_width=80, + max_width=200, + ), + ) bbox_settings = lyts.make_two_field_row( "Bounding box color: ", self.bbox_color_combo, None, self.bbox_enabled_checkbox, - key_width=None, + key_width=120, left_stretch=0, right_stretch=0, ) diff --git a/dlclivegui/gui/misc/color_dropdowns.py b/dlclivegui/gui/misc/color_dropdowns.py index ce0e713..bb0f0ac 100644 --- a/dlclivegui/gui/misc/color_dropdowns.py +++ b/dlclivegui/gui/misc/color_dropdowns.py @@ -1,57 +1,170 @@ -"""UI elements for color selection dropdowns (colors, colormaps)""" - +""" +UI elements for color selection dropdowns (bbox colors, matplotlib colormaps). + +- BBox color combo: enum-based with BGR swatch icons. +- Colormap combo: Matplotlib registry-based, optional gradient icons. +- ShrinkCurrentWidePopupComboBox: combobox label shrinks to current selection, + while the popup widens to the longest item to avoid eliding. +""" # dlclivegui/gui/misc/color_dropdowns.py + from __future__ import annotations -from collections.abc import Iterable, Sequence +from collections.abc import Callable, Iterable, Sequence +from dataclasses import dataclass +from typing import TypeVar import numpy as np from PySide6.QtCore import Qt from PySide6.QtGui import QColor, QIcon, QImage, QPainter, QPixmap -from PySide6.QtWidgets import QComboBox +from PySide6.QtWidgets import ( + QComboBox, + QSizePolicy, + QStyle, + QStyleOptionComboBox, +) BGR = tuple[int, int, int] +TEnum = TypeVar("TEnum") -# ------------------------------ -# BBox color combo (enum-based) -# ------------------------------ -def make_bgr_swatch_icon( - bgr: BGR, - *, - width: int = 40, - height: int = 16, - border: int = 1, -) -> QIcon: - """Create a small BGR color swatch icon for use in QComboBox items.""" +# ----------------------------------------------------------------------------- +# Combo sizing: shrink to current selection + wide popup +# ----------------------------------------------------------------------------- +@dataclass(frozen=True) +class ComboSizing: + """Sizing policy for ShrinkCurrentWidePopupComboBox.""" + + # Combobox (label) sizing: + min_width: int = 80 + max_width: int | None = None + extra_padding: int = 10 + + # Popup sizing: + popup_extra_padding: int = 24 + popup_elide_mode: Qt.TextElideMode = Qt.ElideNone + + +class ShrinkCurrentWidePopupComboBox(QComboBox): + """ + Combobox whose control (label) shrinks to the current selection, while the popup + widens to fit the widest item (so long entries are not elided). + """ + + def __init__(self, *args, sizing: ComboSizing | None = None, **kwargs): + super().__init__(*args, **kwargs) + if sizing is None: + sizing = ComboSizing() + self._sizing = sizing + + # We drive width explicitly -> fixed horizontal policy is predictable. + self.setSizePolicy(QSizePolicy.Fixed, self.sizePolicy().verticalPolicy()) + + self.currentIndexChanged.connect(lambda _i: self.update_shrink_width()) + self.currentTextChanged.connect(lambda _t: self.update_shrink_width()) + + # --- control width (current selection) --- + def _width_for_current_text(self) -> int: + opt = QStyleOptionComboBox() + self.initStyleOption(opt) + + text = self.currentText() or "" + fm = self.fontMetrics() + text_px = fm.horizontalAdvance(text) + + icon_px = 0 + idx = self.currentIndex() + if idx >= 0 and not self.itemIcon(idx).isNull(): + icon_px = self.iconSize().width() + 4 + + frame = self.style().pixelMetric(QStyle.PM_ComboBoxFrameWidth, opt, self) + arrow = self.style().pixelMetric(QStyle.PM_ScrollBarExtent, opt, self) + + return text_px + icon_px + (2 * frame) + arrow + int(self._sizing.extra_padding) + + def update_shrink_width(self) -> None: + """Update combobox control width to fit current item.""" + w = max(int(self._sizing.min_width), self._width_for_current_text()) + if self._sizing.max_width is not None: + w = min(int(self._sizing.max_width), w) + + if self.width() != w: + self.setFixedWidth(w) + + # --- popup width (widest item) --- + def _max_popup_item_width(self) -> int: + fm = self.fontMetrics() + icon_w = self.iconSize().width() + + max_w = 0 + for i in range(self.count()): + t = self.itemText(i) or "" + w = fm.horizontalAdvance(t) + if not self.itemIcon(i).isNull(): + w += icon_w + 6 + max_w = max(max_w, w) + + opt = QStyleOptionComboBox() + self.initStyleOption(opt) + frame = self.style().pixelMetric(QStyle.PM_ComboBoxFrameWidth, opt, self) + scroll = self.style().pixelMetric(QStyle.PM_ScrollBarExtent, opt, self) + + return max( + max_w + 2 * frame + scroll + int(self._sizing.popup_extra_padding), + self.width(), + ) + + def showPopup(self) -> None: + # Ensure control width is up to date + self.update_shrink_width() + + view = self.view() + try: + view.setTextElideMode(self._sizing.popup_elide_mode) + except Exception: + pass + + view.setMinimumWidth(self._max_popup_item_width()) + super().showPopup() + + +# ----------------------------------------------------------------------------- +# BBox color combo helpers (enum-based) +# ----------------------------------------------------------------------------- +def _bgr_to_qcolor(bgr: BGR) -> QColor: + return QColor(bgr[2], bgr[1], bgr[0]) + + +def make_bgr_swatch_icon(bgr: BGR, *, width: int = 40, height: int = 16, border: int = 1) -> QIcon: + """Create a small BGR swatch icon for use in QComboBox items.""" pix = QPixmap(width, height) pix.fill(Qt.transparent) p = QPainter(pix) - # Border/background - p.fillRect(0, 0, width, height, Qt.black) - # Inner background + p.fillRect(0, 0, width, height, Qt.black) # border background p.fillRect(border, border, width - 2 * border, height - 2 * border, Qt.white) - # Convert BGR -> RGB for Qt - rgb = (bgr[2], bgr[1], bgr[0]) - p.fillRect(border + 1, border + 1, width - 2 * (border + 1), height - 2 * (border + 1), QColor(*rgb)) - + p.fillRect( + border + 1, + border + 1, + width - 2 * (border + 1), + height - 2 * (border + 1), + _bgr_to_qcolor(bgr), + ) p.end() return QIcon(pix) def populate_bbox_color_combo( combo: QComboBox, - colors_enum: Iterable, + colors_enum: Iterable[TEnum], *, current_bgr: BGR | None = None, include_icons: bool = True, ) -> None: """ Populate a QComboBox with bbox colors from an enum (e.g. BBoxColors). - - The enum items are stored as itemData so you can retrieve .value (BGR). + Stores the enum item as itemData so you can retrieve .value (BGR). """ combo.blockSignals(True) combo.clear() @@ -59,20 +172,35 @@ def populate_bbox_color_combo( for enum_item in colors_enum: bgr: BGR = enum_item.value name = getattr(enum_item, "name", str(enum_item)).title() - if include_icons: - icon = make_bgr_swatch_icon(bgr) - combo.addItem(icon, name, enum_item) + combo.addItem(make_bgr_swatch_icon(bgr), name, enum_item) else: combo.addItem(name, enum_item) - # Set selection if current_bgr provided if current_bgr is not None: set_bbox_combo_from_bgr(combo, current_bgr) combo.blockSignals(False) +def make_bbox_color_combo( + colors_enum: Iterable[TEnum], + *, + current_bgr: BGR | None = None, + include_icons: bool = True, + tooltip: str = "Select bounding box color", + sizing: ComboSizing | None = None, +) -> QComboBox: + """Factory: create and populate a bbox color combobox.""" + combo = ShrinkCurrentWidePopupComboBox(sizing=sizing) if sizing is not None else QComboBox() + combo.setToolTip(tooltip) + populate_bbox_color_combo(combo, colors_enum, current_bgr=current_bgr, include_icons=include_icons) + if isinstance(combo, ShrinkCurrentWidePopupComboBox): + combo.update_shrink_width() + + return combo + + def set_bbox_combo_from_bgr(combo: QComboBox, bgr: BGR) -> None: """Select the first item whose enum_item.value == bgr.""" for i in range(combo.count()): @@ -90,64 +218,73 @@ def get_bbox_bgr_from_combo(combo: QComboBox, *, fallback: BGR | None = None) -> return getattr(enum_item, "value", fallback) -# ----------------------------------- -# Matplotlib colormap combo (registry) -# ----------------------------------- -def _safe_import_matplotlib_colormaps(): - """ - Import matplotlib colormap registry lazily. - Returns (colormaps_registry, ok_bool). - """ +# ----------------------------------------------------------------------------- +# Matplotlib colormap combo helpers +# ----------------------------------------------------------------------------- +def _safe_mpl_colormaps_registry(): + """Return matplotlib.colormaps registry, or None if matplotlib isn't available.""" try: from matplotlib import colormaps - return colormaps, True + return colormaps except Exception: - return None, False + return None -def list_matplotlib_colormap_names( +def list_colormap_names( *, exclude_reversed: bool = True, favorites_first: Sequence[str] | None = None, + filters: dict[str, int] | None = None, ) -> list[str]: """ - Return a list of registered Matplotlib colormap names. + List Matplotlib-registered colormap names. - Uses `list(matplotlib.colormaps)` (Matplotlib's documented way to list all - registered colormaps). - Optionally excludes reversed maps (*_r) + Args: + exclude_reversed: Drop *_r. + favorites_first: If provided, move these names to the top (if present). + filters: Prefix-family limits, e.g. {"cet_": 5} keeps only the first 5 names + that start with "cet_". """ - registry, ok = _safe_import_matplotlib_colormaps() - if not ok or registry is None: + registry = _safe_mpl_colormaps_registry() + if registry is None: return [] - names = sorted(list(registry)) + names = sorted(list(registry)) # registry is iterable of names + if exclude_reversed: names = [n for n in names if not n.endswith("_r")] + if filters: + # Apply per-prefix limits deterministically. + kept: list[str] = [] + used: set[str] = set() + + # For each prefix, take first N matches in sorted order. + for filtered, limit in filters.items(): + limit_n = max(0, int(limit)) + matches = [n for n in names if filtered in n] + for n in matches[:limit_n]: + kept.append(n) + used.add(n) + + # Keep all names not covered by any filtered prefix, plus the limited ones. + filtered_prefixes = tuple(filters.keys()) + remainder = [n for n in names if (not any(fp in n for fp in filtered_prefixes)) and (n not in used)] + names = remainder + kept + if favorites_first: fav = [n for n in favorites_first if n in names] rest = [n for n in names if n not in set(fav)] - return fav + rest + names = fav + rest return names -def make_cmap_gradient_icon( - cmap_name: str, - *, - width: int = 80, - height: int = 14, -) -> QIcon | None: - """ - Create a gradient icon by sampling a Matplotlib colormap. - - Uses the colormap registry lookup: `matplotlib.colormaps[name]`. - Returns None if Matplotlib isn't available. - """ - registry, ok = _safe_import_matplotlib_colormaps() - if not ok or registry is None: +def make_cmap_gradient_icon(cmap_name: str, *, width: int = 80, height: int = 14) -> QIcon | None: + """Create a gradient icon by sampling a Matplotlib colormap.""" + registry = _safe_mpl_colormaps_registry() + if registry is None: return None try: @@ -156,16 +293,12 @@ def make_cmap_gradient_icon( return None x = np.linspace(0.0, 1.0, width) - rgba = (cmap(x) * 255).astype(np.uint8) # (width, 4) + rgba = (cmap(x) * 255).astype(np.uint8) # (width,4) + rgb = rgba[:, :3] # (width,3) + img = np.repeat(rgb[np.newaxis, :, :], height, axis=0) # (height,width,3) - # Convert to RGB row and repeat vertically - rgb_row = rgba[:, :3] # (width, 3) - rgb_img = np.repeat(rgb_row[np.newaxis, :, :], height, axis=0) # (height, width, 3) - - # QImage referencing numpy memory; copy into QPixmap to own memory safely - qimg = QImage(rgb_img.data, width, height, 3 * width, QImage.Format.Format_RGB888) + qimg = QImage(img.data, width, height, 3 * width, QImage.Format.Format_RGB888) pix = QPixmap.fromImage(qimg.copy()) - return QIcon(pix) @@ -179,49 +312,29 @@ def populate_colormap_combo( filters: dict[str, int] | None = None, icon_width: int = 80, icon_height: int = 14, + editable_if_no_mpl: bool = True, ) -> None: """ Populate a QComboBox with Matplotlib colormap names. - - Names come from Matplotlib's colormap registry (`list(colormaps)`). - - Optionally hides reversed maps (*_r). - - Stores the name string as itemData. - - Args: - combo: The QComboBox to populate. - current: Optional name to select after populating. - include_icons: If True, adds a gradient icon for each colormap. - exclude_reversed: If True, excludes colormaps with names ending in "_r". - favorites_first: Optional list of colormap names to prioritize at the top. - filters: Optional dict of {substring: min_count} to filter certain colormaps - to have a maximum count (e.g. {"cet_": 10}, - including at most 10 colormaps containing "cet_"). - icon_width: Width of the gradient icons in pixels. - icon_height: Height of the gradient icons in pixels. + Stores the cmap name string as itemData for each entry. """ - names = list_matplotlib_colormap_names( + names = list_colormap_names( exclude_reversed=exclude_reversed, favorites_first=favorites_first, + filters=filters, ) - if filters: - filtered_names = [] - for substr, max_count in filters.items(): - matching = [n for n in names if substr in n] - filtered_names.extend(matching[:max_count]) - unmatched = [n for n in names if not any(substr in n for substr in filters)] - filtered_names.extend(unmatched) - names = filtered_names - names = sorted(names) combo.blockSignals(True) combo.clear() - # If Matplotlib isn't available, still allow typing/selection of a name + # Matplotlib not available: allow typing a name if not names: + if editable_if_no_mpl: + combo.setEditable(True) if current: combo.addItem(current, current) combo.setCurrentIndex(0) - combo.setEditable(True) combo.blockSignals(False) return @@ -241,16 +354,56 @@ def populate_colormap_combo( combo.blockSignals(False) -def set_cmap_combo_from_name(combo: QComboBox, name: str, *, fallback: str | None = "viridis") -> None: +def make_colormap_combo( + *, + current: str = "viridis", + tooltip: str = "Select colormap to use when displaying keypoints", + sizing: ComboSizing | None = None, + include_icons: bool = True, + exclude_reversed: bool = True, + favorites_first: Sequence[str] | None = None, + filters: dict[str, int] | None = None, + icon_width: int = 80, + icon_height: int = 14, + editable_if_no_mpl: bool = True, + on_changed: Callable[[str], None] | None = None, +) -> ShrinkCurrentWidePopupComboBox: + """Factory: create + populate + apply sizing behavior for a colormap combo.""" + if sizing is None: + sizing = ComboSizing() + combo = ShrinkCurrentWidePopupComboBox(sizing=sizing) + combo.setToolTip(tooltip) + + populate_colormap_combo( + combo, + current=current, + include_icons=include_icons, + exclude_reversed=exclude_reversed, + favorites_first=favorites_first, + filters=filters, + icon_width=icon_width, + icon_height=icon_height, + editable_if_no_mpl=editable_if_no_mpl, + ) + + combo.update_shrink_width() + + if on_changed is not None: + combo.currentIndexChanged.connect(lambda _i: on_changed(get_cmap_name_from_combo(combo, fallback=current))) + combo.currentTextChanged.connect(lambda _t: on_changed(get_cmap_name_from_combo(combo, fallback=current))) + + return combo + + +def set_cmap_combo_from_name(combo: QComboBox, name: str, *, fallback: str = "viridis") -> None: """Select `name` if present, else fallback if present.""" idx = combo.findData(name) if idx >= 0: combo.setCurrentIndex(idx) return - if fallback: - idx2 = combo.findData(fallback) - if idx2 >= 0: - combo.setCurrentIndex(idx2) + idx2 = combo.findData(fallback) + if idx2 >= 0: + combo.setCurrentIndex(idx2) def get_cmap_name_from_combo(combo: QComboBox, *, fallback: str = "viridis") -> str: @@ -258,6 +411,5 @@ def get_cmap_name_from_combo(combo: QComboBox, *, fallback: str = "viridis") -> data = combo.currentData() if isinstance(data, str) and data: return data - # If combo is editable text = combo.currentText().strip() return text or fallback From 12f3c4b59ffdf90f253bf672d27d4208d61eac15 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 16 Feb 2026 13:11:05 +0100 Subject: [PATCH 23/30] Handle SIGINT and use compact combo widgets Add graceful SIGINT handling for the Qt app by installing a signal handler that closes the main window and starting a small QTimer keepalive so Python can process signals while the event loop runs. The timer is stored on QApplication as _sig_timer and cleaned up on aboutToQuit. In the GUI, replace plain QComboBox instances with color_ui.ShrinkCurrentWidePopupComboBox for processor and camera controls, and layout them together using lyts.make_two_field_row to produce a compact, stable row and avoid shifting. --- dlclivegui/gui/main_window.py | 16 ++++++++++---- dlclivegui/main.py | 39 ++++++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 14968ea..66d80f6 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -437,17 +437,25 @@ def _build_dlc_group(self) -> QGroupBox: processor_path_layout.addWidget(self.refresh_processors_button) form.addRow("Processor folder", processor_path_layout) - self.processor_combo = QComboBox() + self.processor_combo = color_ui.ShrinkCurrentWidePopupComboBox() self.processor_combo.addItem("No Processor", None) - form.addRow("Processor", self.processor_combo) + # form.addRow("Processor", self.processor_combo) # self.additional_options_edit = QPlainTextEdit() # self.additional_options_edit.setPlaceholderText("") # self.additional_options_edit.setFixedHeight(40) # form.addRow("Additional options", self.additional_options_edit) - self.dlc_camera_combo = QComboBox() + self.dlc_camera_combo = color_ui.ShrinkCurrentWidePopupComboBox() self.dlc_camera_combo.setToolTip("Select which camera to use for pose inference") - form.addRow("Inference camera", self.dlc_camera_combo) + # form.addRow("Inference camera", self.dlc_camera_combo) + processing_sttgs = lyts.make_two_field_row( + "Inference camera", + self.dlc_camera_combo, + "Processor", + self.processor_combo, + key_width=None, + ) + form.addRow(processing_sttgs) # Wrap inference buttons in a widget to prevent shifting inference_button_widget = QWidget() diff --git a/dlclivegui/main.py b/dlclivegui/main.py index 69f0e92..aebb770 100644 --- a/dlclivegui/main.py +++ b/dlclivegui/main.py @@ -21,14 +21,45 @@ ) +def _maybe_allow_keyboard_interrupt(app: QApplication) -> None: + """ + Gracefully handle Ctrl+C (SIGINT) by closing the main window and quitting Qt. + """ + + def _request_quit() -> None: + win = getattr(app, "_main_window", None) + if win is not None: + # Trigger your existing closeEvent cleanup (camera stop, threads, timers, etc.) + win.close() + else: + app.quit() + + def _sigint_handler(_signum, _frame) -> None: + QTimer.singleShot(0, _request_quit) + + signal.signal(signal.SIGINT, _sigint_handler) + + # Keepalive timer to allow Python to handle signals while Qt is running. + sig_timer = QTimer(app) + sig_timer.setInterval(100) # 50–200ms typical; keep low overhead + sig_timer.timeout.connect(lambda: None) + sig_timer.start() + + if not hasattr(app, "_sig_timer"): + app._sig_timer = sig_timer + else: + raise RuntimeError("QApplication already has _sig_timer attribute, which is reserved for SIGINT handling.") + + def main() -> None: - signal.signal(signal.SIGINT, signal.SIG_DFL) + # signal.signal(signal.SIGINT, signal.SIG_DFL) # HiDPI pixmaps - always enabled in Qt 6 so no need to set it explicitly # QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True) app = QApplication(sys.argv) app.setWindowIcon(QIcon(LOGO)) + _maybe_allow_keyboard_interrupt(app) if SHOW_SPLASH: cfg = SplashConfig( @@ -53,6 +84,12 @@ def show_main(): app._main_window = DLCLiveMainWindow() app._main_window.show() + def _cleanup(): + t = getattr(app, "_sig_timer", None) + if t is not None: + t.stop() + + app.aboutToQuit.connect(_cleanup) sys.exit(app.exec()) From 81b0f4e8d6d3e498a6a189e5e1aab7e133aa5778 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 16 Feb 2026 13:55:29 +0100 Subject: [PATCH 24/30] Log keyboard interrupt on quit Import the logging module and emit an informational log when a keyboard interrupt triggers _request_quit inside _maybe_allow_keyboard_interrupt. This adds visibility for debugging application shutdowns without changing existing close behavior. --- dlclivegui/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dlclivegui/main.py b/dlclivegui/main.py index aebb770..35494f7 100644 --- a/dlclivegui/main.py +++ b/dlclivegui/main.py @@ -1,6 +1,7 @@ # dlclivegui/gui/main.py from __future__ import annotations +import logging import signal import sys @@ -27,6 +28,7 @@ def _maybe_allow_keyboard_interrupt(app: QApplication) -> None: """ def _request_quit() -> None: + logging.info("Keyboard interrupt received, closing application...") win = getattr(app, "_main_window", None) if win is not None: # Trigger your existing closeEvent cleanup (camera stop, threads, timers, etc.) From e9b5d528d141adf66823cc873844b8dbf6156719 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 16 Feb 2026 13:55:59 +0100 Subject: [PATCH 25/30] Make controls dockable and refine UI layout Replace the previous side-panel layout with a QDockWidget-based controls panel to allow docking/undocking and prevent UI shifting. Extract stats layout into _build_stats_layout and enable selectable stats text. Add sizing/shrink options and placeholder for processor and camera combo boxes and call update_shrink_width at key points so combo widths adapt. Add controls toggle to the View menu, set dock features/options, and give the dock a stable objectName for state saving. Also stop the display timer on shutdown and perform minor UI/layout cleanups and refactors (imports and button/preview layout adjustments). --- dlclivegui/gui/main_window.py | 128 +++++++++++++++++++++------------- 1 file changed, 78 insertions(+), 50 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 66d80f6..97403fe 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -31,6 +31,7 @@ from PySide6.QtWidgets import ( QCheckBox, QComboBox, + QDockWidget, QFileDialog, QFormLayout, QGridLayout, @@ -231,28 +232,86 @@ def _load_icons(self): self.setWindowIcon(QIcon(LOGO)) def _setup_ui(self) -> None: - central = QWidget() - layout = QHBoxLayout(central) + # central = QWidget() + # layout = QHBoxLayout(central) # Video panel with display and performance stats video_panel = QWidget() video_layout = QVBoxLayout(video_panel) video_layout.setContentsMargins(0, 0, 0, 0) - - # Video display widget + ## Video display widget self.video_label = QLabel() self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.video_label.setMinimumSize(640, 360) self.video_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) video_layout.addWidget(self.video_label, stretch=1) - - # Stats panel below video with clear labels + ## Stats panel below video with clear labels stats_widget = QWidget() stats_widget.setStyleSheet("padding: 5px;") # stats_widget.setMinimumWidth(800) # Prevent excessive line breaks stats_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) stats_widget.setMinimumHeight(80) + self._build_stats_layout(stats_widget) + + video_layout.addWidget(stats_widget, stretch=0) + + # Central widget is just the video panel (video + stats) + video_panel.setLayout(video_layout) + self.setCentralWidget(video_panel) + + # Allow user to select stats text + for lbl in (self.camera_stats_label, self.dlc_stats_label, self.recording_stats_label): + lbl.setTextInteractionFlags(Qt.TextSelectableByMouse) + + # Controls panel with fixed width to prevent shifting + controls_widget = QWidget() + # controls_widget.setMaximumWidth(500) + controls_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + controls_layout = QVBoxLayout(controls_widget) + controls_layout.setContentsMargins(5, 5, 5, 5) + controls_layout.addWidget(self._build_camera_group()) + controls_layout.addWidget(self._build_dlc_group()) + controls_layout.addWidget(self._build_recording_group()) + controls_layout.addWidget(self._build_viz_group()) + + # Preview/Stop buttons at bottom of controls - wrap in widget + button_bar_widget = QWidget() + button_bar = QHBoxLayout(button_bar_widget) + button_bar.setContentsMargins(0, 5, 0, 5) + self.preview_button = QPushButton("Start Preview") + self.preview_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) + self.preview_button.setMinimumWidth(150) + self.stop_preview_button = QPushButton("Stop Preview") + self.stop_preview_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop)) + self.stop_preview_button.setEnabled(False) + self.stop_preview_button.setMinimumWidth(150) + button_bar.addWidget(self.preview_button) + button_bar.addWidget(self.stop_preview_button) + controls_layout.addWidget(button_bar_widget) + controls_layout.addStretch(1) + + # Add controls and video panel to main layout + ## Dock widget for controls + self.controls_dock = QDockWidget("Controls", self) + self.controls_dock.setObjectName("ControlsDock") # important for state saving + self.controls_dock.setWidget(controls_widget) + ### Dock features + self.controls_dock.setFeatures( # must not close independently + QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable + ) + self.addDockWidget(Qt.LeftDockWidgetArea, self.controls_dock) + self.setDockOptions( + self.dockOptions() + | QMainWindow.DockOption.AllowTabbedDocks + | QMainWindow.DockOption.GroupedDragging + | QMainWindow.DockOption.AnimatedDocks + ) + + self.setStatusBar(QStatusBar()) + self._build_menus() + QTimer.singleShot(0, self._show_logo_and_text) + def _build_stats_layout(self, stats_widget: QWidget) -> QGridLayout: stats_layout = QGridLayout(stats_widget) stats_layout.setContentsMargins(5, 5, 5, 5) stats_layout.setHorizontalSpacing(8) # tighten horizontal gap between title and value @@ -295,49 +354,8 @@ def _setup_ui(self) -> None: # Critical: make column 1 (values) eat the width, keep column 0 tight stats_layout.setColumnStretch(0, 0) stats_layout.setColumnStretch(1, 1) - video_layout.addWidget(stats_widget, stretch=0) - - # Allow user to select stats text - for lbl in (self.camera_stats_label, self.dlc_stats_label, self.recording_stats_label): - lbl.setTextInteractionFlags(Qt.TextSelectableByMouse) - # Controls panel with fixed width to prevent shifting - controls_widget = QWidget() - controls_widget.setMaximumWidth(500) - controls_widget.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) - controls_layout = QVBoxLayout(controls_widget) - controls_layout.setContentsMargins(5, 5, 5, 5) - controls_layout.addWidget(self._build_camera_group()) - controls_layout.addWidget(self._build_dlc_group()) - controls_layout.addWidget(self._build_recording_group()) - controls_layout.addWidget(self._build_viz_group()) - - # Preview/Stop buttons at bottom of controls - wrap in widget - button_bar_widget = QWidget() - button_bar = QHBoxLayout(button_bar_widget) - button_bar.setContentsMargins(0, 5, 0, 5) - self.preview_button = QPushButton("Start Preview") - self.preview_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) - self.preview_button.setMinimumWidth(150) - self.stop_preview_button = QPushButton("Stop Preview") - self.stop_preview_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop)) - self.stop_preview_button.setEnabled(False) - self.stop_preview_button.setMinimumWidth(150) - button_bar.addWidget(self.preview_button) - button_bar.addWidget(self.stop_preview_button) - controls_layout.addWidget(button_bar_widget) - controls_layout.addStretch(1) - - # Add controls and video panel to main layout - layout.addWidget(controls_widget, stretch=0) - layout.addWidget(video_panel, stretch=1) - layout.setStretch(0, 0) - layout.setStretch(1, 1) - - self.setCentralWidget(central) - self.setStatusBar(QStatusBar()) - self._build_menus() - QTimer.singleShot(0, self._show_logo_and_text) + stats_widget.setLayout(stats_layout) def _build_menus(self) -> None: # File menu @@ -365,6 +383,7 @@ def _build_menus(self) -> None: # View menu view_menu = self.menuBar().addMenu("&View") + view_menu.addAction(self.controls_dock.toggleViewAction()) appearance_menu = view_menu.addMenu("Appearance") ## Style actions self.action_dark_mode = QAction("Dark theme", self, checkable=True) @@ -437,7 +456,7 @@ def _build_dlc_group(self) -> QGroupBox: processor_path_layout.addWidget(self.refresh_processors_button) form.addRow("Processor folder", processor_path_layout) - self.processor_combo = color_ui.ShrinkCurrentWidePopupComboBox() + self.processor_combo = color_ui.ShrinkCurrentWidePopupComboBox(sizing=color_ui.ComboSizing(max_width=100)) self.processor_combo.addItem("No Processor", None) # form.addRow("Processor", self.processor_combo) @@ -445,9 +464,10 @@ def _build_dlc_group(self) -> QGroupBox: # self.additional_options_edit.setPlaceholderText("") # self.additional_options_edit.setFixedHeight(40) # form.addRow("Additional options", self.additional_options_edit) - self.dlc_camera_combo = color_ui.ShrinkCurrentWidePopupComboBox() + self.dlc_camera_combo = color_ui.ShrinkCurrentWidePopupComboBox(sizing=color_ui.ComboSizing(max_width=180)) self.dlc_camera_combo.setToolTip("Select which camera to use for pose inference") # form.addRow("Inference camera", self.dlc_camera_combo) + self.dlc_camera_combo.setPlaceholderText("None") processing_sttgs = lyts.make_two_field_row( "Inference camera", self.dlc_camera_combo, @@ -455,6 +475,7 @@ def _build_dlc_group(self) -> QGroupBox: self.processor_combo, key_width=None, ) + self.dlc_camera_combo.update_shrink_width() form.addRow(processing_sttgs) # Wrap inference buttons in a widget to prevent shifting @@ -751,6 +772,7 @@ def _connect_signals(self) -> None: self._dlc.error.connect(self._on_dlc_error) self._dlc.initialized.connect(self._on_dlc_initialised) self.dlc_camera_combo.currentIndexChanged.connect(self._on_dlc_camera_changed) + self.dlc_camera_combo.currentTextChanged.connect(self.dlc_camera_combo.update_shrink_width) # Recording settings ## Session name persistence + preview updates @@ -1041,6 +1063,7 @@ def _refresh_processors(self) -> None: display_name = f"{info['name']} ({info['file']})" self.processor_combo.addItem(display_name, key) + self.processor_combo.update_shrink_width() self.statusBar().showMessage( f"Found {len(self._processor_keys)} processor(s) in package dlclivegui.processors", 3000 ) @@ -1250,10 +1273,12 @@ def _refresh_dlc_camera_list(self) -> None: self._inference_camera_id = self.dlc_camera_combo.currentData() self.dlc_camera_combo.blockSignals(False) + self.dlc_camera_combo.update_shrink_width() def _on_dlc_camera_changed(self, _index: int) -> None: """Track user selection of the inference camera.""" self._inference_camera_id = self.dlc_camera_combo.currentData() + self.dlc_camera_combo.update_shrink_width() # Force redraw so bbox/pose overlays switch to the new tile immediately if self._current_frame is not None: self._display_frame(self._current_frame, force=True) @@ -1973,6 +1998,9 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha if hasattr(self, "_metrics_timer"): self._metrics_timer.stop() + if hasattr(self, "_display_timer"): + self._display_timer.stop() + # Remember model path on exit self._model_path_store.save_if_valid(self.model_path_edit.text().strip()) From 60da7470d960fddb854a8a125158b254a1d11f71 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 16 Feb 2026 14:12:40 +0100 Subject: [PATCH 26/30] Make controls dock closable and transparent title Allow the left controls dock to be closed independently by adding QDockWidget.DockWidgetClosable to its features. Hide the docked title bar by applying a transparent stylesheet to the controls dock to improve visual integration. Also add a separator in the View menu before the Appearance submenu --- dlclivegui/gui/main_window.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 97403fe..9e0ca0e 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -297,7 +297,7 @@ def _setup_ui(self) -> None: self.controls_dock.setWidget(controls_widget) ### Dock features self.controls_dock.setFeatures( # must not close independently - QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable + QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable | QDockWidget.DockWidgetClosable ) self.addDockWidget(Qt.LeftDockWidgetArea, self.controls_dock) self.setDockOptions( @@ -306,6 +306,13 @@ def _setup_ui(self) -> None: | QMainWindow.DockOption.GroupedDragging | QMainWindow.DockOption.AnimatedDocks ) + self.controls_dock.setStyleSheet( + """/* Docked title bar: fully transparent */ + QDockWidget#ControlsDock::title { + background-color: rgba(0, 0, 0, 0); + } + """ + ) self.setStatusBar(QStatusBar()) self._build_menus() @@ -384,6 +391,7 @@ def _build_menus(self) -> None: # View menu view_menu = self.menuBar().addMenu("&View") view_menu.addAction(self.controls_dock.toggleViewAction()) + view_menu.addSeparator() appearance_menu = view_menu.addMenu("Appearance") ## Style actions self.action_dark_mode = QAction("Dark theme", self, checkable=True) From c29e8caa6b3cab4f42c0927e3f2d059314cdbd70 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 16 Feb 2026 14:19:58 +0100 Subject: [PATCH 27/30] Disable dock close; add Show controls action Prevent the Controls dock from being closed by the user (keep it movable/floatable) and replace the previous toggleViewAction with an explicit, checkable "Show controls" QAction in the View menu. The new action is synchronized with the dock's visibility (action toggled -> dock visibility; dock visibilityChanged -> action checked). Also minor reordering/cleanup of Appearance menu setup and comments. --- dlclivegui/gui/main_window.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 9e0ca0e..9f738a6 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -296,8 +296,9 @@ def _setup_ui(self) -> None: self.controls_dock.setObjectName("ControlsDock") # important for state saving self.controls_dock.setWidget(controls_widget) ### Dock features - self.controls_dock.setFeatures( # must not close independently - QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable | QDockWidget.DockWidgetClosable + self.controls_dock.setFeatures( + # must not be closable by user but visibility can be toggled from View -> Show controls + QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable # | QDockWidget.DockWidgetClosable ) self.addDockWidget(Qt.LeftDockWidgetArea, self.controls_dock) self.setDockOptions( @@ -390,10 +391,16 @@ def _build_menus(self) -> None: # View menu view_menu = self.menuBar().addMenu("&View") - view_menu.addAction(self.controls_dock.toggleViewAction()) + ## Show/hide controls dock + self.action_show_controls = QAction("Show controls", self, checkable=True) + self.action_show_controls.setChecked(True) + self.action_show_controls.toggled.connect(self.controls_dock.setVisible) + self.controls_dock.visibilityChanged.connect(self.action_show_controls.setChecked) + view_menu.addAction(self.action_show_controls) + ## -------------------- view_menu.addSeparator() - appearance_menu = view_menu.addMenu("Appearance") ## Style actions + appearance_menu = view_menu.addMenu("Appearance") self.action_dark_mode = QAction("Dark theme", self, checkable=True) self.action_light_mode = QAction("System theme", self, checkable=True) theme_group = QActionGroup(self) @@ -402,7 +409,7 @@ def _build_menus(self) -> None: theme_group.addAction(self.action_light_mode) self.action_dark_mode.triggered.connect(lambda: self._apply_theme(AppStyle.DARK)) self.action_light_mode.triggered.connect(lambda: self._apply_theme(AppStyle.SYS_DEFAULT)) - + # ---------------------- appearance_menu.addAction(self.action_light_mode) appearance_menu.addAction(self.action_dark_mode) self._apply_theme(self._current_style) From 0b2eeed6e58f06867f04c86f43af4fd87e1f9df7 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 16 Feb 2026 14:37:54 +0100 Subject: [PATCH 28/30] Run pyproject pre-commit hook --- pyproject.toml | 117 ++++++++++++++++++++----------------------------- 1 file changed, 48 insertions(+), 69 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 557ea49..9dac41d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,8 +8,7 @@ description = "PySide6-based GUI to run real time DeepLabCut experiments" readme = "README.md" keywords = [ "deep learning", "deeplabcut", "gui", "pose estimation", "real-time" ] license-files = [ "LICENSE" ] -authors = [ { name = "A. & M. Mathis Labs", email = "adim@deeplabcut.org" } ] -requires-python = ">=3.10,<3.13" +requires-python = ">=3.10" # don' forget to update tool.pyproject-fmt if this changes classifiers = [ "Development Status :: 4 - Beta", "Environment :: X11 Applications :: Qt", @@ -34,9 +33,13 @@ dependencies = [ "qdarkstyle", "vidgear[core]", ] -optional-dependencies.all = [ "harvesters", "pypylon" ] -optional-dependencies.basler = [ "pypylon" ] -optional-dependencies.dev = [ +[[project.authors]] +name = "A. & M. Mathis Labs" +email = "adim@deeplabcut.org" +[project.optional-dependencies] +all = [ "harvesters", "pypylon" ] +basler = [ "pypylon" ] +dev = [ "hypothesis>=6", "pre-commit", "pytest>=7", @@ -44,75 +47,59 @@ optional-dependencies.dev = [ "pytest-mock>=3.10", "pytest-qt>=4.2", ] -optional-dependencies.gentl = [ "harvesters" ] -optional-dependencies.pytorch = [ +gentl = [ "harvesters" ] +pytorch = [ "deeplabcut-live[pytorch]>=2", # this includes timm and scipy ] -<<<<<<< cy/upgrade-gentl-backend -dev = [ - "pytest>=7.0", - "pytest-cov>=4.0", - "pytest-mock>=3.10", - "pytest-qt>=4.2", - "pytest-timeout>=2.0", - "pre-commit", - "hypothesis>=6.0", -] test = [ - "pytest>=7.0", - "pytest-cov>=4.0", - "pytest-mock>=3.10", - "pytest-qt>=4.2", - "pytest-timeout>=2.0", - "hypothesis>=6.0", -======= -optional-dependencies.test = [ "hypothesis>=6", "pytest>=7", "pytest-cov>=4", "pytest-mock>=3.10", "pytest-qt>=4.2", ] -optional-dependencies.tf = [ +tf = [ "deeplabcut-live[tf]>=2", ->>>>>>> cy/pre-release-fixes-2.0 ] -urls."Bug Tracker" = "https://github.com/DeepLabCut/DeepLabCut-live-GUI/issues" -urls.Documentation = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" -urls.Homepage = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" -urls.Repository = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" -scripts.dlclivegui = "dlclivegui:main" +[project.scripts] +dlclivegui = "dlclivegui:main" +[project.urls] +"Bug Tracker" = "https://github.com/DeepLabCut/DeepLabCut-live-GUI/issues" +Documentation = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" +Homepage = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" +Repository = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" -[tool.setuptools] # [tool.setuptools] - # include-package-data = true -# This is more granular and explicit than include-package-data, - -# which can be too broad and include unwanted files. - -package-data."dlclivegui.assets" = [ "*.png" ] -packages.find.where = [ "." ] -packages.find.include = [ "dlclivegui*" ] -packages.find.exclude = [ "tests*", "docs*" ] +[tool.setuptools.package-data] +"dlclivegui.assets" = [ "*.png" ] +[tool.setuptools.packages] +find.where = [ "." ] +find.include = [ "dlclivegui*" ] +find.exclude = [ "tests*", "docs*" ] [tool.ruff] target-version = "py310" line-length = 120 fix = true -lint.select = [ "B", "E", "F", "I", "UP" ] -lint.ignore = [ "E741" ] +[tool.ruff.lint] +select = [ "B", "E", "F", "I", "UP" ] +ignore = [ "E741" ] [tool.pyproject-fmt] -generate-python-classifiers = false +max_supported_python = "3.12" +generate_python_version_classifiers = true +# Avoid collapsing tables to field.key = value format (less readable) +table_format = "long" [tool.pytest] -ini_options.testpaths = [ "tests" ] -ini_options.python_files = [ "test_*.py" ] -ini_options.python_classes = [ "Test*" ] -ini_options.python_functions = [ "test_*" ] -ini_options.addopts = [ +[tool.pytest.ini_options] +testpaths = [ "tests" ] +python_files = [ "test_*.py" ] +python_classes = [ "Test*" ] +python_functions = [ "test_*" ] +addopts = [ "--strict-markers", "--strict-config", "--disable-warnings", @@ -120,34 +107,19 @@ ini_options.addopts = [ "-ra", "-q", ] -<<<<<<< cy/upgrade-gentl-backend markers = [ - "unit: Unit tests for individual components", - "integration: Integration tests for component interaction", - "functional: Functional tests for end-to-end workflows", - "hardware: Tests that require specific hardware, notable camera backends", - # "slow: Tests that take a long time to run", - "gui: Tests that require GUI interaction", - "timeout: Test timeout in seconds (pytest-timeout)", -======= -ini_options.markers = [ "unit: Unit tests for individual components", "integration: Integration tests for component interaction", "functional: Functional tests for end-to-end workflows", "hardware: Tests that require specific hardware, notable camera backends", # "slow: Tests that take a long time to run", "gui: Tests that require GUI interaction", ->>>>>>> cy/pre-release-fixes-2.0 + "timeout: Test timeout in seconds (pytest-timeout)", ] [tool.coverage] -run.branch = true -run.omit = [ - "*/__pycache__/*", - "*/site-packages/*", -] -run.source = [ "dlclivegui", "tests" ] -report.exclude_lines = [ +[tool.coverage.report] +exclude_lines = [ "@abstract", "def __repr__", "if __name__ == .__main__.:", @@ -156,6 +128,13 @@ report.exclude_lines = [ "raise AssertionError", "raise NotImplementedError", ] -report.omit = [ +omit = [ "tests/*", ] +[tool.coverage.run] +branch = true +omit = [ + "*/__pycache__/*", + "*/site-packages/*", +] +source = [ "dlclivegui", "tests" ] From cbfbcc7f151a28bbf01418c1ea09f4a9a56587d3 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 16 Feb 2026 14:44:42 +0100 Subject: [PATCH 29/30] Revert "Merge branch 'cy/pre-release-fixes-2.0' into cy/upgrade-gentl-backend" This reverts commit 03af146de784bd8325890fc220bd14d2d2a0631c, reversing changes made to fe62907d0aaa5c1f9e340126cc283f8dbee0e0e3. --- tests/cameras/backends/conftest.py | 244 ------------------ .../backends/test_generic_contracts.py | 6 - 2 files changed, 250 deletions(-) diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index 5c6bed4..0fb3ded 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -514,27 +514,16 @@ def patch_basler_sdk(monkeypatch, fake_pylon_module): # ----------------------------------------------------------------------------- -<<<<<<< cy/upgrade-gentl-backend # Fake GenTL / harvesters SDK (SDK-free) + fixtures for strict lifecycle tests -======= -# Fake GenTL / harvesters SDK (open/read/close capable) + fixtures ->>>>>>> cy/pre-release-fixes-2.0 # ----------------------------------------------------------------------------- class FakeGenTLTimeoutException(TimeoutError): -<<<<<<< cy/upgrade-gentl-backend """Fake timeout/error type used as HarvesterTimeoutError in backend tests.""" -======= - """ - Representative timeout: Harvesters often surfaces GenTL TimeoutException semantics. - """ ->>>>>>> cy/pre-release-fixes-2.0 pass -<<<<<<< cy/upgrade-gentl-backend def _info_get(info: Any, key: str, default=None): """Read a device-info field from dict-like or attribute-like entries.""" try: @@ -555,46 +544,6 @@ def _info_get(info: Any, key: str, default=None): class _FakeNode: """Minimal GenICam node: .value plus optional constraints and symbolics.""" -======= -class _DeviceInfoAdapter: - """ - Make device_info_list entries behave whether they're dict-like or object-like. - """ - - def __init__(self, payload): - self._payload = payload - - def get(self, key, default=None): - if isinstance(self._payload, dict): - return self._payload.get(key, default) - return getattr(self._payload, key, default) - - @property - def serial_number(self): - return self.get("serial_number", "") - - @property - def vendor(self): - return self.get("vendor", "") - - @property - def model(self): - return self.get("model", "") - - @property - def display_name(self): - return self.get("display_name", "") - - -class _FakeNode: - """ - Minimal GenICam-style node with .value and optional constraints. - Harvesters exposes nodes as objects; your backend uses: - - node.value - - node.min / node.max / node.inc (for Width/Height) - - PixelFormat.symbolics (for allowed formats) - """ ->>>>>>> cy/pre-release-fixes-2.0 def __init__(self, value=None, *, min=None, max=None, inc=1, symbolics=None): self.value = value @@ -605,7 +554,6 @@ def __init__(self, value=None, *, min=None, max=None, inc=1, symbolics=None): class _FakeNodeMap: -<<<<<<< cy/upgrade-gentl-backend """Node map with the attributes your GenTLCameraBackend touches.""" def __init__( @@ -627,23 +575,11 @@ def __init__( self.DeviceDisplayName = _FakeNode(display) # Pixel format node -======= - """Provides attribute access for nodes used by GenTLCameraBackend.""" - - def __init__(self, *, width=1920, height=1080, fps=30.0, exposure=10000.0, gain=0.0, pixel_format="Mono8"): - # Identification / label fields your _resolve_device_label() tries - self.DeviceModelName = _FakeNode("FakeGenTLModel") - self.DeviceSerialNumber = _FakeNode("FAKE-GENTL-0") - self.DeviceDisplayName = _FakeNode("FakeGenTLDisplay") - - # Format + acquisition nodes ->>>>>>> cy/pre-release-fixes-2.0 self.PixelFormat = _FakeNode( pixel_format, symbolics=["Mono8", "Mono16", "RGB8", "BGR8"], ) -<<<<<<< cy/upgrade-gentl-backend # Width/Height constraints for increment alignment logic self.Width = _FakeNode(int(width), min=64, max=4096, inc=2) self.Height = _FakeNode(int(height), min=64, max=4096, inc=2) @@ -654,19 +590,6 @@ def __init__(self, *, width=1920, height=1080, fps=30.0, exposure=10000.0, gain= self.ResultingFrameRate = _FakeNode(float(fps)) # Exposure / gain -======= - # Width/Height with constraints for increment alignment logic - self.Width = _FakeNode(int(width), min=64, max=4096, inc=2) - self.Height = _FakeNode(int(height), min=64, max=4096, inc=2) - - # FPS related nodes (backend may set AcquisitionFrameRate) - self.AcquisitionFrameRateEnable = _FakeNode(True) - self.AcquisitionFrameRate = _FakeNode(float(fps)) - # backend tries ResultingFrameRate for actual FPS; provide it - self.ResultingFrameRate = _FakeNode(float(fps)) - - # Exposure/Gain ->>>>>>> cy/pre-release-fixes-2.0 self.ExposureAuto = _FakeNode("Off") self.ExposureTime = _FakeNode(float(exposure)) self.GainAuto = _FakeNode("Off") @@ -679,39 +602,21 @@ def __init__(self, node_map: _FakeNodeMap): class _FakeComponent: -<<<<<<< cy/upgrade-gentl-backend """ Component with .data, .width, .height like Harvesters component2D image. Your backend does np.asarray(component.data) and reshape using height/width. """ -======= ->>>>>>> cy/pre-release-fixes-2.0 def __init__(self, width: int, height: int, channels: int, dtype=np.uint8): self.width = int(width) self.height = int(height) self._channels = int(channels) -<<<<<<< cy/upgrade-gentl-backend - -======= - self._dtype = dtype - # Create a deterministic image payload ->>>>>>> cy/pre-release-fixes-2.0 n = self.width * self.height * self._channels if dtype == np.uint8: arr = (np.arange(n) % 255).astype(np.uint8) else: -<<<<<<< cy/upgrade-gentl-backend - arr = (np.arange(n) % 65535).astype(np.uint16) -======= - # e.g., uint16 arr = (np.arange(n) % 65535).astype(np.uint16) - - # Harvesters often exposes component.data as a buffer-like object; - # your backend does np.asarray(component.data) and may fall back to frombuffer(bytes(...)). - # A numpy array works fine for both. ->>>>>>> cy/pre-release-fixes-2.0 self.data = arr @@ -721,14 +626,7 @@ def __init__(self, component: _FakeComponent): class _FakeFetchedBufferCtx: -<<<<<<< cy/upgrade-gentl-backend """Context manager returned by fetch(). Must have .payload.""" -======= - """ - Context manager returned by FakeImageAcquirer.fetch(). - Must provide .payload with components. - """ ->>>>>>> cy/pre-release-fixes-2.0 def __init__(self, payload: _FakePayload): self.payload = payload @@ -740,7 +638,6 @@ def __exit__(self, exc_type, exc, tb): return False -<<<<<<< cy/upgrade-gentl-backend @dataclass class FakeImageAcquirer: """ @@ -784,48 +681,11 @@ def _enqueue_default_frame(self): channels, dtype = 1, np.uint16 else: channels, dtype = 1, np.uint8 -======= -class FakeImageAcquirer: - """ - Minimal Harvesters image acquirer: - - remote_device.node_map - - start()/stop()/destroy() - - fetch(timeout=...) -> context manager - - node_map shortcut (your backend uses self._acquirer.node_map in read()) - """ - - def __init__(self, *, serial="FAKE-GENTL-0", width=1920, height=1080, pixel_format="Mono8"): - self.serial = serial - self._started = False - self._destroyed = False - - # Node map used by open() and read() - self.remote_device = _FakeRemoteDevice(_FakeNodeMap(width=width, height=height, pixel_format=pixel_format)) - self.node_map = self.remote_device.node_map - - # Simple FIFO of frames (buffers) - self._queue: list[_FakePayload] = [] - self._populate_default_frames() - - def _populate_default_frames(self): - # Make one frame available by default - pf = str(self.node_map.PixelFormat.value or "Mono8") - if pf in ("RGB8", "BGR8"): - channels = 3 - dtype = np.uint8 - elif pf == "Mono16": - channels = 1 - dtype = np.uint16 - else: - channels = 1 - dtype = np.uint8 ->>>>>>> cy/pre-release-fixes-2.0 comp = _FakeComponent(self.node_map.Width.value, self.node_map.Height.value, channels, dtype=dtype) self._queue.append(_FakePayload(comp)) def start(self): -<<<<<<< cy/upgrade-gentl-backend self.start_calls += 1 self._started = True @@ -846,22 +706,6 @@ def fetch(self, timeout: float = 2.0): if not self._queue: raise FakeGenTLTimeoutException(f"timeout after {timeout}s") -======= - self._started = True - - def stop(self): - self._started = False - - def destroy(self): - self._destroyed = True - - def fetch(self, timeout: float = 2.0): - if not self._started: - raise FakeGenTLTimeoutException("Acquirer not started") - - if not self._queue: - raise FakeGenTLTimeoutException(f"Timeout after {timeout}s") ->>>>>>> cy/pre-release-fixes-2.0 payload = self._queue.pop(0) return _FakeFetchedBufferCtx(payload) @@ -869,7 +713,6 @@ def fetch(self, timeout: float = 2.0): class FakeHarvester: """ -<<<<<<< cy/upgrade-gentl-backend Minimal Harvester: - add_file/update/reset - device_info_list @@ -1001,95 +844,11 @@ def patch_gentl_sdk(monkeypatch, fake_harvester_factory): monkeypatch.setattr(gb, "HarvesterTimeoutError", FakeGenTLTimeoutException, raising=False) # Avoid filesystem CTI searching -======= - Minimal fake for 'from harvesters.core import Harvester' supporting: - - add_file/update/reset - - device_info_list for enumeration - - create()/create_image_acquirer() returning FakeImageAcquirer - - This enables GenTLCameraBackend.open/read/close paths. - """ - - def __init__(self): - self.device_info_list = [] - self._files = [] - self._acquirers: list[FakeImageAcquirer] = [] - - def add_file(self, file_path: str): - self._files.append(str(file_path)) - - def update(self): - # Harvesters tutorial output shows dict-like device entries. - self.device_info_list = [ - { - "display_name": "TLSimuMono (FAKE-GENTL-0)", - "model": "FakeGenTLModel", - "vendor": "FakeVendor", - "serial_number": "FAKE-GENTL-0", - "id_": "FakeDeviceId", - "tl_type": "Custom", - "user_defined_name": "Center", - "version": "1.0.0", - } - ] - - def reset(self): - # "release" resources - self.device_info_list = [] - self._files = [] - self._acquirers = [] - - def create(self, selector=None, index: int | None = None, *args, **kwargs): - serial = None - - # Selector dict commonly used: {"serial_number": "..."} [1](https://github.com/genicam/harvesters/issues/454) - if isinstance(selector, dict): - serial = selector.get("serial_number") - - if serial is None and index is None: - index = 0 - - if not self.device_info_list: - self.update() - - if serial is None: - if index is None: - index = 0 - if index < 0 or index >= len(self.device_info_list): - raise RuntimeError("Index out of range") - info = _DeviceInfoAdapter(self.device_info_list[index]) - serial = info.serial_number or "FAKE-GENTL-0" - - acq = FakeImageAcquirer(serial=serial) - self._acquirers.append(acq) - return acq - - def create_image_acquirer(self, *args, **kwargs): - # Alias used by some Harvesters versions; just delegate to create() - return self.create(*args, **kwargs) - - -@pytest.fixture() -def fake_harvester_class(): - """Provides FakeHarvester class for patching GenTL backend.""" - return FakeHarvester - - -@pytest.fixture() -def patch_gentl_sdk(monkeypatch, fake_harvester_class): - import dlclivegui.cameras.backends.gentl_backend as gb - - monkeypatch.setattr(gb, "Harvester", fake_harvester_class, raising=False) - monkeypatch.setattr(gb, "HarvesterTimeoutError", FakeGenTLTimeoutException, raising=False) - - # Prevent CTI searching from blocking open/get_device_count ->>>>>>> cy/pre-release-fixes-2.0 monkeypatch.setattr(gb.GenTLCameraBackend, "_find_cti_file", lambda self: "dummy.cti", raising=False) monkeypatch.setattr( gb.GenTLCameraBackend, "_search_cti_file", staticmethod(lambda patterns: "dummy.cti"), raising=False ) -<<<<<<< cy/upgrade-gentl-backend return gb @@ -1128,9 +887,6 @@ def _make( ) return _make -======= - return fake_harvester_class ->>>>>>> cy/pre-release-fixes-2.0 # ----------------------------------------------------------------------------- diff --git a/tests/cameras/backends/test_generic_contracts.py b/tests/cameras/backends/test_generic_contracts.py index 66e2d2c..317a12c 100644 --- a/tests/cameras/backends/test_generic_contracts.py +++ b/tests/cameras/backends/test_generic_contracts.py @@ -25,15 +25,9 @@ def _try_import_gui_apply_identity(): try: -<<<<<<< cy/upgrade-gentl-backend from dlclivegui.gui.camera_config.camera_config_dialog import apply_detected_identity # type: ignore return apply_detected_identity -======= - from dlclivegui.gui.camera_config_dialog import _apply_detected_identity # type: ignore - - return _apply_detected_identity ->>>>>>> cy/pre-release-fixes-2.0 except Exception: return None From 5a78d68fb4def617bfd24c7aabfcbe0576c21c1c Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 16 Feb 2026 14:46:19 +0100 Subject: [PATCH 30/30] Run ruff on all files --- tests/gui/test_misc.py | 2 +- tests/services/test_video_recorder.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/gui/test_misc.py b/tests/gui/test_misc.py index 19d6f7d..d493533 100644 --- a/tests/gui/test_misc.py +++ b/tests/gui/test_misc.py @@ -118,7 +118,7 @@ def test_elides_when_narrow_and_restores_when_wide(label, qtbot): [ (Qt.ElideLeft, lambda s: s.startswith("…")), (Qt.ElideRight, lambda s: s.endswith("…")), - (Qt.ElideMiddle, lambda s: ("…" in s and not s.startswith("…") and not s.endswith("…"))), + (Qt.ElideMiddle, lambda s: "…" in s and not s.startswith("…") and not s.endswith("…")), ], ) def test_elide_modes_affect_ellipsis_position(qtbot, mode, assert_fn): diff --git a/tests/services/test_video_recorder.py b/tests/services/test_video_recorder.py index 5d23b8e..7665ae3 100644 --- a/tests/services/test_video_recorder.py +++ b/tests/services/test_video_recorder.py @@ -170,7 +170,7 @@ def test_queue_full_drops_frames(patch_writegear, output_path, rgb_frame): assert any(v is False for v in (ok1, ok2, ok3)) # stats should show dropped frames eventually - wait_until(lambda: (rec.get_stats() is not None)) + wait_until(lambda: rec.get_stats() is not None) stats = rec.get_stats() assert stats is not None assert stats.dropped_frames >= 1 @@ -214,7 +214,7 @@ def test_encoder_write_error_sets_encode_error_and_future_writes_raise(patch_wri # wait until encode error becomes visible wait_until(lambda: rec.get_stats() is not None) # ensures internals initialized - wait_until(lambda: (rec._current_error() is not None), timeout=2.0) + wait_until(lambda: rec._current_error() is not None, timeout=2.0) # further writes should raise with pytest.raises(RuntimeError):