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 71cb715..8e7b0e1 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 + # (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. + 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,289 @@ 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 + 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 - ) - if exposure is not None: + # Exposure + if getattr(self.settings, "exposure", 0) > 0: try: - self._camera.ExposureTime.SetValue(float(exposure)) - except Exception: - pass - - # 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: + 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(float(gain)) - except Exception: - pass - - # Resolution (device default if None) + 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) + # ---------------------------- + # 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 +398,71 @@ def open(self) -> None: except Exception: self._actual_gain = None - self._camera.StartGrabbing(pylon.GrabStrategy_LatestImageOnly) + # ---------------------------- + # Start acquisition (skip for fast probe) + # ---------------------------- + if not self._fast_start: + # --- 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 - 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") + + 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,19 +479,21 @@ 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] 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() + try: + self._camera.StopGrabbing() + except Exception: + pass if self._camera.IsOpen(): self._camera.Close() self._camera = None @@ -203,57 +507,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 +586,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 +610,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 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 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 7a247b3..645371c 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: @@ -130,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 52% rename from dlclivegui/gui/camera_config_dialog.py rename to dlclivegui/gui/camera_config/camera_config_dialog.py index 3c5acc0..aba582b 100644 --- a/dlclivegui/gui/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -1,202 +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) - - # 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 - - 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.""" @@ -206,6 +36,10 @@ class CameraConfigDialog(QDialog): scan_started = Signal(str) scan_finished = Signal() + # ------------------------------- + # Constructor, properties, Qt lifecycle + # ------------------------------- + def __init__( self, parent: QWidget | None = None, @@ -215,29 +49,24 @@ 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 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 - self._preview_timer: QTimer | None = None - self._preview_active: 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 @@ -256,445 +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()), - "gain": float(self.cam_gain.value()), - "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 - - # 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) - else: - target.properties[k] = v - - # Update UI list item text to reflect any changes - self._update_active_list_item(row, target) - self._load_camera_to_form(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) @@ -731,6 +121,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, @@ -748,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: @@ -773,19 +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)) - # ------------------------------- - # Signals / population + # Signal setup # ------------------------------- def _connect_signals(self) -> None: self.backend_combo.currentIndexChanged.connect(self._on_backend_changed) @@ -804,52 +261,162 @@ 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) + self._set_apply_dirty(True) + for sb in ( self.cam_fps, self.cam_crop_x0, self.cam_crop_y0, self.cam_crop_x1, self.cam_crop_y1, + self.cam_exposure, + self.cam_gain, self.cam_width, self.cam_height, ): if hasattr(sb, "valueChanged"): - sb.valueChanged.connect(lambda _=None: self.apply_settings_btn.setEnabled(True)) - 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) + sb.valueChanged.connect(_mark_dirty) - self._refresh_available_cameras() - self._update_button_states() + self.cam_rotation.currentIndexChanged.connect(lambda *_: _mark_dirty()) + self.cam_enabled_checkbox.stateChanged.connect(lambda *_: _mark_dirty()) - 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 _refresh_camera_labels(self) -> None: - cam_list = getattr(self, "active_cameras_list", None) - if cam_list: - 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)) + def _update_button_states(self) -> None: + scan_running = self._is_scan_running() - def _on_backend_changed(self, _index: int) -> None: - self._refresh_available_cameras() + 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) + 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 _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() @@ -883,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() @@ -902,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) @@ -945,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() @@ -964,16 +568,60 @@ 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_actions", False): + LOGGER.debug("[Selection] Suppressed currentRowChanged event at index %d.", row) + return + prev_row = self._current_edit_index + LOGGER.debug( + "[Select] row=%s prev=%s preview_state=%s", + row, + prev_row, + self._preview.state, + ) + 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: + 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"): + # 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: + if self._preview.state in (PreviewState.ACTIVE, PreviewState.LOADING): 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() @@ -986,138 +634,297 @@ 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 _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): - 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 - - return any( + 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( [ - 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), - ), + i + for i in range(self.active_cameras_list.count()) + if self.active_cameras_list.item(i).data(Qt.ItemDataRole.UserRole).enabled ] ) - - 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 - - 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 - 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): + 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() - # 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.") - return + 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)) - # 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.") - return + 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 - 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) + 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 _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 _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 - 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.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 _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) + 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 _write_form_to_cam(self, cam: CameraSettings) -> None: - cam.enabled = bool(self.cam_enabled_checkbox.isChecked()) - 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.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()) - cam.crop_x1 = int(self.cam_crop_x1.value()) - cam.crop_y1 = int(self.cam_crop_y1.value()) + 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 _clear_settings_form(self) -> 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, + 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) + + def _write_form_to_cam(self, cam: CameraSettings) -> None: + cam.enabled = bool(self.cam_enabled_checkbox.isChecked()) + 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() 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()) + 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.debug( + "[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.debug( + "[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("") self.cam_device_name_label.setText("") @@ -1138,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: - 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.""" @@ -1185,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] @@ -1220,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_*. @@ -1307,203 +1151,153 @@ def _on_probe_error(self, msg: str) -> None: def _on_probe_finished(self) -> None: self._probe_worker = None - def _add_selected_camera(self) -> None: - 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 + # Update index if backend rebinding occurred + try: + target.index = int(opened_settings.index) + except Exception: + pass - 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) + 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 - def _remove_selected_camera(self) -> None: - row = self.active_cameras_list.currentRow() - if row < 0: + # Update UI list item text to reflect any changes + self._update_active_list_item(row, target) + + 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.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() + interval_ms = max(15, int(1000.0 / min(max(fps, 1.0), 60.0))) + self._preview.timer.start(interval_ms) - def _move_camera_up(self) -> None: - row = self.active_cameras_list.currentRow() - if row <= 0: + def _reconcile_fps_from_backend(self, cam: CameraSettings) -> None: + """Reconcile preview cadence to actual FPS without overriding Auto request.""" + + # 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 - 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: - row = self.active_cameras_list.currentRow() - if row < 0 or row >= self.active_cameras_list.count() - 1: + # 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 - 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 _apply_camera_settings(self) -> None: - try: - for sb in ( - self.cam_fps, - self.cam_crop_x0, - self.cam_width, - self.cam_height, - 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 + 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) - current_model = self._working_settings.cameras[row] - new_model = self._build_model_from_form(current_model) + # --------------------------------- + # Preview lifecycle management (start/stop + loading state) + # --------------------------------- + def _toggle_preview(self) -> None: + if self._preview.state == PreviewState.LOADING: + self._cancel_loading() + return + if self._preview.state == PreviewState.ACTIVE: + self._stop_preview() + else: + self._start_preview() - cam = self._working_settings.cameras[row] - self._write_form_to_cam(cam) + 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.debug("[Preview] begin load reason=%s backend=%s idx=%s", reason, cam.backend, cam.index) - 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) + # If already loading, just coalesce restart/intention + if self._preview.state == PreviewState.LOADING: + self._preview.pending_restart = copy.deepcopy(cam) + return - if self._preview_active: - if must_reopen: - self._stop_preview() - self._start_preview() - else: - self._reconcile_fps_from_backend(new_model) - if not self._backend_actual_fps(): - self._append_status("[Info] FPS will reconcile automatically during preview.") + # Stop any existing backend/timer/loader cleanly + self._stop_preview_internal(reason="begin-load") - # Persist validated model back - self._working_settings.cameras[row] = new_model - self._update_active_list_item(row, new_model) + 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 - except Exception as exc: - LOGGER.exception("Apply camera settings failed") - QMessageBox.warning(self, "Apply Settings Error", str(exc)) + # 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 - 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) + loader = CameraLoadWorker(self._preview.requested_cam, self) + self._preview.loader = loader - def _on_ok_clicked(self) -> None: - 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.accept() + # 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)) - def reject(self) -> None: - """Handle dialog rejection (Cancel or close).""" - self._stop_preview() + # UI + self.preview_status.clear() + self._show_loading_overlay("Loading camera…") + self._sync_preview_ui() - 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 + loader.start() - 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) + 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.state in (PreviewState.ACTIVE, PreviewState.LOADING): + return - super().reject() + row = self._current_edit_index + if row is None or row < 0: + row = self.active_cameras_list.currentRow() - # ------------------------------- - # Preview start/stop (ASYNC) - # ------------------------------- - def _toggle_preview(self) -> None: - if self._loading_active: - self._cancel_loading() + if row is None or row < 0: + LOGGER.warning("[Preview] No camera selected to start preview.") return - if self._preview_active: - self._stop_preview() - else: - self._start_preview() - 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: - return + self._current_edit_index = row + LOGGER.debug( + "[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 @@ -1511,211 +1305,228 @@ def _start_preview(self) -> None: if not cam: return - # Ensure any existing preview or loader is stopped/canceled - self._stop_preview() - # if self._loader and self._loader.isRunning(): - # self._loader.request_cancel() - # 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._begin_preview_load(cam, reason="user-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) + def _stop_preview(self) -> None: + self._stop_preview_internal(reason="user-stop") + self._sync_preview_ui() - self._loader.start() + def _stop_preview_internal(self, *, reason: str) -> None: + """Tear down loader/backend/timer. Safe to call from anywhere.""" + LOGGER.debug("[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 - def _stop_preview(self) -> None: - """Stop camera preview and cancel any ongoing loading.""" - # Cancel loader if running - if self._loader and self._loader.isRunning(): - self._loader.request_cancel() - self._loader.wait(1500) - self._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: - 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…") - 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.exception("Failed to start preview") - 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): - self._loading_active = False - self._loader = None + def _on_loader_finished(self, e: int) -> None: + if not self._is_current_epoch(e): + return - # 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)) + pending = self._preview.pending_restart + self._preview.pending_restart = None + self._preview.restart_scheduled = False + self._preview.loader = None - # ALWAYS refresh button states - self._update_button_states() + 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") + + 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] @@ -1723,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) - 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..28395c0 --- /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 f38bb43..9f738a6 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 @@ -29,6 +31,7 @@ from PySide6.QtWidgets import ( QCheckBox, QComboBox, + QDockWidget, QFileDialog, QFormLayout, QGridLayout, @@ -71,7 +74,9 @@ 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 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 @@ -192,6 +197,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) @@ -226,28 +232,94 @@ 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 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( + self.dockOptions() + | QMainWindow.DockOption.AllowTabbedDocks + | 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() + 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 @@ -290,49 +362,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_bbox_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 @@ -360,8 +391,16 @@ def _build_menus(self) -> None: # View menu view_menu = self.menuBar().addMenu("&View") - appearance_menu = view_menu.addMenu("Appearance") + ## 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() ## 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) @@ -370,14 +409,14 @@ 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) 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 @@ -396,7 +435,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() @@ -432,17 +471,27 @@ 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(sizing=color_ui.ComboSizing(max_width=100)) 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(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) + # 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, + "Processor", + 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 inference_button_widget = QWidget() @@ -460,9 +509,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) @@ -494,7 +543,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) @@ -502,7 +551,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 @@ -574,7 +627,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() @@ -617,24 +670,57 @@ 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 = 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), + ) + + keypoints_settings = lyts.make_two_field_row( + "Keypoint colormap: ", + self.cmap_combo, + None, + self.show_predictions_checkbox, + key_width=120, + 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.setCurrentIndex(0) - checkbox_layout.addWidget(self.bbox_color_combo) - checkbox_layout.addStretch(1) - form.addRow(row_widget) + 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=120, + left_stretch=0, + right_stretch=0, + ) + form.addRow(bbox_settings) bbox_layout = QHBoxLayout() self.bbox_x0_spin = ScrubSpinBox() @@ -678,7 +764,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) @@ -698,6 +787,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 @@ -759,9 +849,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() @@ -986,6 +1078,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 ) @@ -1049,11 +1142,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) @@ -1190,10 +1288,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) @@ -1453,7 +1553,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() @@ -1909,6 +2013,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()) diff --git a/dlclivegui/gui/misc/color_dropdowns.py b/dlclivegui/gui/misc/color_dropdowns.py new file mode 100644 index 0000000..bb0f0ac --- /dev/null +++ b/dlclivegui/gui/misc/color_dropdowns.py @@ -0,0 +1,415 @@ +""" +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 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, + QSizePolicy, + QStyle, + QStyleOptionComboBox, +) + +BGR = tuple[int, int, int] +TEnum = TypeVar("TEnum") + + +# ----------------------------------------------------------------------------- +# 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) + p.fillRect(0, 0, width, height, Qt.black) # border background + p.fillRect(border, border, width - 2 * border, height - 2 * border, Qt.white) + + 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[TEnum], + *, + current_bgr: BGR | None = None, + include_icons: bool = True, +) -> None: + """ + Populate a QComboBox with bbox colors from an enum (e.g. BBoxColors). + Stores the enum item 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: + combo.addItem(make_bgr_swatch_icon(bgr), name, enum_item) + else: + combo.addItem(name, enum_item) + + 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()): + 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 helpers +# ----------------------------------------------------------------------------- +def _safe_mpl_colormaps_registry(): + """Return matplotlib.colormaps registry, or None if matplotlib isn't available.""" + try: + from matplotlib import colormaps + + return colormaps + except Exception: + return None + + +def list_colormap_names( + *, + exclude_reversed: bool = True, + favorites_first: Sequence[str] | None = None, + filters: dict[str, int] | None = None, +) -> list[str]: + """ + List Matplotlib-registered colormap names. + + 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 = _safe_mpl_colormaps_registry() + if registry is None: + return [] + + 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)] + 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.""" + registry = _safe_mpl_colormaps_registry() + if 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) + rgb = rgba[:, :3] # (width,3) + img = np.repeat(rgb[np.newaxis, :, :], height, axis=0) # (height,width,3) + + qimg = QImage(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, + editable_if_no_mpl: bool = True, +) -> None: + """ + Populate a QComboBox with Matplotlib colormap names. + + Stores the cmap name string as itemData for each entry. + """ + names = list_colormap_names( + exclude_reversed=exclude_reversed, + favorites_first=favorites_first, + filters=filters, + ) + + combo.blockSignals(True) + combo.clear() + + # 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.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 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 + 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 + 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 diff --git a/dlclivegui/main.py b/dlclivegui/main.py index 69f0e92..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 @@ -21,14 +22,46 @@ ) +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: + 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.) + 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 +86,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()) 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/pyproject.toml b/pyproject.toml index 4651f4c..9dac41d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,14 +69,9 @@ 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. [tool.setuptools.package-data] "dlclivegui.assets" = [ "*.png" ] [tool.setuptools.packages] @@ -119,6 +114,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] 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_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/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 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 9e23f04..160bf12 100644 --- a/tests/gui/camera_config/test_cam_dialog_e2e.py +++ b/tests/gui/camera_config/test_cam_dialog_e2e.py @@ -1,103 +1,418 @@ # 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 - -# ---------------- Fake backend ---------------- - - -class FakeBackend(CameraBackend): - def __init__(self, settings): - super().__init__(settings) - self._opened = False - - def open(self): - self._opened = True +from dlclivegui.gui.camera_config.camera_config_dialog import CameraConfigDialog, CameraLoadWorker +from dlclivegui.gui.camera_config.preview import PreviewState + +# --------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------- + + +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): - monkeypatch.setattr(CameraFactory, "create", lambda s: FakeBackend(s)) +def patch_detect_cameras(monkeypatch): + """ + Make discovery deterministic for these tests. + (GUI conftest patches create(), but not necessarily detect_cameras().) + """ 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: 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) 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() + except Exception: + d.close() + + 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) +# --------------------------------------------------------------------- +# 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 + qtbot.waitUntil( + lambda: dialog._preview.loader is None and dialog._preview.state == PreviewState.ACTIVE, timeout=2000 + ) + assert dialog._preview.backend is not None - # 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: dialog._preview.state == PreviewState.IDLE, timeout=2000) + + assert dialog._preview.backend is None + assert dialog._preview.timer is None + - assert dialog._preview_active is False - assert dialog._preview_backend 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 + 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", staticmethod(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) + qtbot.waitUntil( + lambda: dialog._preview.loader is None and dialog._preview.state == PreviewState.ACTIVE, timeout=2000 + ) - # 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.state == PreviewState.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 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", staticmethod(lambda s: CountingBackend(s))) + + dialog.active_cameras_list.setCurrentRow(0) + qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton) + qtbot.waitUntil( + lambda: dialog._preview.loader is None and dialog._preview.state == PreviewState.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.state == PreviewState.ACTIVE + + +@pytest.mark.gui +def test_e2e_selection_change_auto_commits(dialog, qtbot): + """ + 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) + + 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): + 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 [DetectedCamera(index=0, label=f"{backend}-X")] + + 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) + + qtbot.mouseClick(dialog.scan_cancel_btn, Qt.LeftButton) + + with qtbot.waitSignal(dialog.scan_finished, timeout=3000): + pass + + assert dialog.refresh_btn.isEnabled() + assert dialog.backend_combo.isEnabled() + + +@pytest.mark.gui +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): + calls["n"] += 1 + return QMessageBox.Ok + + monkeypatch.setattr(QMessageBox, "warning", staticmethod(_warn)) + + backend = _select_backend_for_active_cam(dialog, cam_row=0) + + initial_count = dialog.active_cameras_list.count() + + # 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) + + assert dialog.active_cameras_list.count() == initial_count + assert calls["n"] >= 1 + + +@pytest.mark.gui +def test_max_cameras_prevented(qtbot, monkeypatch, patch_detect_cameras): + """ + Dialog enforces MAX_CAMERAS enabled cameras. + """ + 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="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) + qtbot.addWidget(d) + d.show() + qtbot.waitExposed(d) + + try: + _select_backend_for_active_cam(d, cam_row=0) + + 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) + + assert d.active_cameras_list.count() == initial_count + assert calls["n"] >= 1 + finally: + 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): + """ + 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) + 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._preview.state == PreviewState.LOADING, timeout=1000) + + # Click again => Cancel Loading + qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton) + + 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 ecf543c..fc73f75 100644 --- a/tests/gui/camera_config/test_cam_dialog_unit.py +++ b/tests/gui/camera_config/test_cam_dialog_unit.py @@ -3,105 +3,336 @@ import pytest from PySide6.QtCore import Qt +from PySide6.QtWidgets import QMessageBox 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) +# ---------------------------- @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: [ - DetectedCamera(index=0, label=f"{backend}-X"), - DetectedCamera(index=1, label=f"{backend}-Y"), - ], - ) +def dialog_unit(qtbot, monkeypatch): + """ + Unit fixture: disable async scan + probe to keep tests deterministic. + + 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) + + # 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), - 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 ---------------------- +# ---------------------- +# 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} + + def _warn(parent, title, text, *args, **kwargs): + calls["n"] += 1 + calls["title"] = title + calls["text"] = text + return QMessageBox.Ok + + # 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 "") + + +@pytest.mark.gui +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) + + # 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].fps == 77.0 + + @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_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.add_camera_btn, Qt.LeftButton) + qtbot.mouseClick(dialog_unit.add_camera_btn, Qt.LeftButton) - added = dialog._working_settings.cameras[-1] + 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_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_backend_control_disables_exposure_gain_for_opencv(dialog, monkeypatch): +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 {} + 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]) - monkeypatch.setattr( - "dlclivegui.cameras.CameraFactory.backend_capabilities", - lambda backend_name: fake_caps(backend_name), - raising=False, - ) + 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 2e8ff16..51b1cf2 100644 --- a/tests/gui/conftest.py +++ b/tests/gui/conftest.py @@ -2,28 +2,20 @@ from __future__ import annotations import pytest +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 @@ -45,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) @@ -73,3 +61,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)) 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):