From f360b7377f6cba0cc9fa8a31f3b10199ec8b4a54 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 11 May 2026 13:25:10 +0200 Subject: [PATCH 001/133] Add shared Harvester pool and path normalization Introduce thread-safe SharedHarvesterEntry and SharedHarvesterPool to share Harvester instances keyed by canonical CTI file sets. The pool provides acquire/release/refcount/refresh semantics with RLock protection, reference counting, initial device enumeration, and safe reset on final release. Add optional import handling for harvesters (raising a clear error when missing). Enhance _normalize_path with a casefold_windows flag to produce a canonical path form for pool keys (used when sorting/canonicalizing CTI file lists) and import threading to support locking. --- .../cameras/backends/utils/gentl_discovery.py | 113 ++++++++++++++++-- 1 file changed, 105 insertions(+), 8 deletions(-) diff --git a/dlclivegui/cameras/backends/utils/gentl_discovery.py b/dlclivegui/cameras/backends/utils/gentl_discovery.py index ebce73448..9cc24dcb2 100644 --- a/dlclivegui/cameras/backends/utils/gentl_discovery.py +++ b/dlclivegui/cameras/backends/utils/gentl_discovery.py @@ -6,6 +6,7 @@ import glob import os +import threading from collections.abc import Iterable, Sequence from dataclasses import dataclass, field from enum import Enum, auto @@ -18,6 +19,102 @@ class GenTLDiscoveryPolicy(Enum): RAISE_IF_MULTIPLE = auto() # if > N candidates, raise an error to avoid ambiguity (forces explicit config) +try: # pragma: no cover - optional dependency + from harvesters.core import Harvester # type: ignore +except Exception: # pragma: no cover - optional dependency + Harvester = None # type: ignore + + +class SharedHarvesterEntry: + """ + A shared Harvester instance keyed by a canonical tuple of CTI files. + """ + + def __init__(self, cti_files: list[str]): + if Harvester is None: # pragma: no cover + raise RuntimeError( + "The 'harvesters' package is required for the GenTL backend. Install it via 'pip install harvesters'." + ) + + self.lock = threading.RLock() + self.key = tuple(sorted(_normalize_path(p, casefold_windows=True) for p in cti_files)) + self.refcount = 0 + self.harvester = Harvester() + self.loaded_files: list[str] = [] + + for cti in self.key: + self.harvester.add_file(cti) + self.loaded_files.append(cti) + + # Initial device enumeration. + self.harvester.update() + + +class SharedHarvesterPool: + """ + Process-local pool of shared Harvester instances. + + Keyed by the canonicalized CTI file set. + """ + + _lock = threading.RLock() + _entries: dict[tuple[str, ...], SharedHarvesterEntry] = {} + + @classmethod + def acquire(cls, cti_files: list[str]) -> SharedHarvesterEntry: + key = tuple(sorted(_normalize_path(p, casefold_windows=True) for p in cti_files)) + with cls._lock: + entry = cls._entries.get(key) + if entry is None: + entry = SharedHarvesterEntry(list(key)) + cls._entries[key] = entry + entry.refcount += 1 + return entry + + @classmethod + def release(cls, entry: SharedHarvesterEntry | None) -> None: + if entry is None: + return + + with cls._lock: + current = cls._entries.get(entry.key) + if current is None: + # Already released/reset. + return + + current.refcount -= 1 + if current.refcount > 0: + return + + try: + with current.lock: + try: + current.harvester.reset() + except Exception: + pass + finally: + cls._entries.pop(entry.key, None) + + @classmethod + def refresh(cls, entry: SharedHarvesterEntry | None) -> None: + """ + Optional helper when callers want to re-enumerate the device list + on an already-shared Harvester instance. + """ + if entry is None: + return + with entry.lock: + entry.harvester.update() + + @classmethod + def get_refcount(cls, entry: SharedHarvesterEntry | None) -> int: + if entry is None: + return 0 + with cls._lock: + current = cls._entries.get(entry.key) + return int(current.refcount) if current is not None else 0 + + @dataclass class CTIDiscoveryDiagnostics: explicit_files: list[str] = field(default_factory=list) @@ -81,18 +178,18 @@ def _expand_user_and_env(value: str) -> str: return s -def _normalize_path(p: str) -> str: - """ - Normalize a filesystem path in a cross-platform way: - - expands ~ and environment variables - - resolves to absolute where possible (without requiring existence) - """ +def _normalize_path(p: str, *, casefold_windows: bool = False) -> str: expanded = _expand_user_and_env(p) pp = Path(expanded) try: - return str(pp.resolve(strict=False)) + out = str(pp.resolve(strict=False)) except Exception: - return str(pp.absolute()) + out = str(pp.absolute()) + + if casefold_windows: + out = os.path.normcase(out) + + return out def _iter_cti_files_in_dir(directory: str, recursive: bool = False) -> Iterable[str]: From 971f6293e1df8cf367d1c4205d4dcaac75804438 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 11 May 2026 13:26:24 +0200 Subject: [PATCH 002/133] Use shared Harvester pool for GenTL producers Introduce a process-shared Harvester (SharedHarvesterPool) and per-backend _shared_entry to manage GenTL producer state and locking. CTI preflight now records CTIs that passed validation and acquires a shared Harvester instance; device list refresh and acquirer creation/start/stop/destroy are performed under the shared lock to avoid race conditions/hotplug issues. On acquire failure the shared entry is released and a descriptive RuntimeError is raised. _reset_harvester now releases the shared entry when present. Minor diagnostics updates: persist CTI lists and adjust reporting of loaded CTIs. --- dlclivegui/cameras/backends/gentl_backend.py | 97 +++++++++++++------- 1 file changed, 64 insertions(+), 33 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index eb28aea4d..474bd53ac 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -133,6 +133,7 @@ def __init__(self, settings): # --- Harvesters resources --- self._harvester = None self._acquirer = None + self._shared_entry = None self._device_label: str | None = None self._cti_files_source_used: str | None = None @@ -435,8 +436,6 @@ def open(self) -> None: self._cti_files_source_used or ns.get("cti_files_source") or self._CTI_FILES_SOURCE_AUTO ) - self._harvester = Harvester() - loaded: list[str] = [] failed: list[tuple[str, str]] = [] @@ -447,17 +446,12 @@ def open(self) -> None: LOG.warning("Skipping CTI '%s': %s", cti, reason) continue - try: - self._harvester.add_file(cti) - loaded.append(cti) - except Exception as exc: - failed.append((str(cti), str(exc))) - LOG.warning("Failed to load CTI '%s': %s", cti, exc) + loaded.append(str(cti)) # Persist diagnostics for UI / debugging ns["cti_files"] = [str(p) for p in cti_files] # all resolved candidates - ns["cti_files_loaded"] = [str(p) for p in loaded] # successfully added to harvester - ns["cti_files_failed"] = [{"cti": c, "error": e} for c, e in failed] # load failures + ns["cti_files_loaded"] = [str(p) for p in loaded] # CTIs that passed initial checks + ns["cti_files_failed"] = [{"cti": c, "error": e} for c, e in failed] # Keep single-cti convenience key for backward compatibility / display if loaded: @@ -475,10 +469,32 @@ def open(self) -> None: "set properties.gentl.cti_file to a known working producer." ) - # Update device list after loading producers - self._harvester.update() + # Acquire a process-shared Harvester instance for this CTI set + try: + self._shared_entry = cti_finder.SharedHarvesterPool.acquire(loaded) + self._harvester = self._shared_entry.harvester + + with self._shared_entry.lock: + # Refresh device list on open; safer for hotplug cases + self._harvester.update() + infos = list(self._harvester.device_info_list or []) + + # Now that shared loading succeeded, persist the actual loaded list + ns["cti_files_loaded"] = list(getattr(self._shared_entry, "loaded_files", loaded)) + + except Exception as exc: + if self._shared_entry is not None: + try: + cti_finder.SharedHarvesterPool.release(self._shared_entry) + except Exception: + pass + self._shared_entry = None + self._harvester = None + raise RuntimeError( + f"Failed to initialize shared GenTL producer state.\n\nCTIs: {loaded}\nReason: {exc}" + ) from exc - if not self._harvester.device_info_list: + if not infos: self._reset_harvester() raise RuntimeError( "No GenTL cameras detected via Harvesters after loading producers.\n\n" @@ -487,8 +503,6 @@ def open(self) -> None: "Fix: ensure your camera vendor's GenTL producer is installed and working." ) - infos = list(self._harvester.device_info_list) - # Helper: robustly read device_info fields (dict-like or attribute-like) def _info_get(info, key: str, default=None): try: @@ -604,15 +618,17 @@ def _info_get(info, key: str, default=None): # Create ImageAcquirer via 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)) + with self._shared_entry.lock: + if selected_serial: + self._acquirer = self._harvester.create({"serial_number": str(selected_serial)}) + else: + self._acquirer = self._harvester.create(int(selected_index)) except TypeError: - if selected_serial: - self._acquirer = self._harvester.create({"serial_number": str(selected_serial)}) - else: - self._acquirer = self._harvester.create(index=int(selected_index)) + with self._shared_entry.lock: + 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 @@ -681,7 +697,8 @@ def _info_get(info, key: str, default=None): LOG.info("GenTL open() in fast_start probe mode: acquisition not started.") return - self._acquirer.start() + with self._shared_entry.lock: + self._acquirer.start() @staticmethod def _device_id_from_info(info) -> str | None: @@ -1042,7 +1059,11 @@ def read(self) -> tuple[np.ndarray, float]: def stop(self) -> None: if self._acquirer is not None: try: - self._acquirer.stop() + if self._shared_entry is not None: + with self._shared_entry.lock: + self._acquirer.stop() + else: + self._acquirer.stop() except Exception: pass @@ -1056,28 +1077,38 @@ def _reset_select_harvester(harvester) -> None: def _reset_harvester(self) -> None: try: - self._reset_select_harvester(self._harvester) + if self._shared_entry is not None: + cti_finder.SharedHarvesterPool.release(self._shared_entry) + self._shared_entry = None + else: + self._reset_select_harvester(self._harvester) finally: self._harvester = None def close(self) -> None: if self._acquirer is not None: try: - self._acquirer.stop() + if self._shared_entry is not None: + with self._shared_entry.lock: + self._acquirer.stop() + else: + self._acquirer.stop() except Exception: pass + try: destroy = getattr(self._acquirer, "destroy", None) if destroy is not None: - destroy() + if self._shared_entry is not None: + with self._shared_entry.lock: + destroy() + else: + destroy() finally: self._acquirer = None - if self._harvester is not None: - try: - self._harvester.reset() - finally: - self._harvester = None + if self._harvester is not None or self._shared_entry is not None: + self._reset_harvester() self._device_label = None From 60b76073542ecc773382b58b0f2aeac1c7a60eea Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 11:31:35 +0200 Subject: [PATCH 003/133] WIP Working multi CTI implementation --- dlclivegui/cameras/backends/gentl_backend.py | 673 +++++++++++------- dlclivegui/main.py | 30 +- .../services/multi_camera_controller.py | 53 +- 3 files changed, 496 insertions(+), 260 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 474bd53ac..e409c4e7a 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -3,7 +3,10 @@ # dlclivegui/cameras/backends/gentl_backend.py from __future__ import annotations +import itertools import logging +import os +import threading import time from pathlib import Path from typing import ClassVar @@ -34,17 +37,19 @@ class GenTLCameraBackend(CameraBackend): """Capture frames from GenTL-compatible devices via Harvesters.""" OPTIONS_KEY: ClassVar[str] = "gentl" - _LEGACY_DEFAULT_CTI_PATTERNS: tuple[str, ...] = ( # Windows-only, ignored on other platforms - r"C:\\Program Files\\The Imaging Source Europe GmbH\\IC4 GenTL Driver for USB3Vision Devices *\\bin\\*.cti", - r"C:\\Program Files\\The Imaging Source Europe GmbH\\TIS Grabber\\bin\\win64_x64\\*.cti", - r"C:\\Program Files\\The Imaging Source Europe GmbH\\TIS Camera SDK\\bin\\win64_x64\\*.cti", - r"C:\\Program Files (x86)\\The Imaging Source Europe GmbH\\TIS Grabber\\bin\\win64_x64\\*.cti", + _OPEN_LOCK: ClassVar[threading.RLock] = threading.RLock() + _DEFAULT_CTI_PATTERNS: tuple[str, ...] = ( # Windows-only, ignored on other platforms + r"C:\Program Files\The Imaging Source Europe GmbH\IC4 GenTL Driver for USB3Vision Devices *\bin\*.cti", + r"C:\Program Files\The Imaging Source Europe GmbH\TIS Grabber\bin\win64_x64\*.cti", + r"C:\Program Files\The Imaging Source Europe GmbH\TIS Camera SDK\bin\win64_x64\*.cti", + r"C:\Program Files (x86)\The Imaging Source Europe GmbH\TIS Grabber\bin\win64_x64\*.cti", ) # Source marker stored in properties["gentl"]["cti_files_source"] # auto : persisted by auto-discovery (env vars, patterns, etc.). Cache, may be stale, re-discover if missing. # user : explicitly set by user via properties.gentl.cti_file(s). Cache, strict raise if missing. _CTI_FILES_SOURCE_AUTO: ClassVar[str] = "auto" _CTI_FILES_SOURCE_USER: ClassVar[str] = "user" + _OPEN_SEQ: ClassVar[itertools.count] = itertools.count(1) def __init__(self, settings): super().__init__(settings) @@ -309,15 +314,20 @@ def _resolve_cti_files_for_settings(self) -> list[str]: ) # ------------------------------------------------------------ - # 3) Discovery path: env vars + patterns/dirs (source = "auto") + # 3) Discovery path: env vars + patterns/dirs + built-in defaults # ------------------------------------------------------------ self._cti_files_source_used = self._CTI_FILES_SOURCE_AUTO search_paths = ns.get("cti_search_paths", props.get("cti_search_paths")) extra_dirs = ns.get("cti_dirs", props.get("cti_dirs")) + if search_paths is not None: + search_patterns = cti_finder.cti_files_as_list(search_paths) + else: + search_patterns = list(self._DEFAULT_CTI_PATTERNS) + candidates, diag = cti_finder.discover_cti_files( - cti_search_paths=cti_finder.cti_files_as_list(search_paths) if search_paths is not None else None, + cti_search_paths=search_patterns, include_env=True, extra_dirs=cti_finder.cti_files_as_list(extra_dirs) if extra_dirs is not None else None, recursive_env_search=False, @@ -332,11 +342,28 @@ def _resolve_cti_files_for_settings(self) -> list[str]: " - Set camera.properties.gentl.cti_file to the full path of a .cti file\n" " - Or set GENICAM_GENTL64_PATH / GENICAM_GENTL32_PATH to include the producer directory\n" " - Or provide camera.properties.gentl.cti_search_paths with glob patterns\n\n" - f"Discovery details:\n{diag.summarize()}" + f"Discovery details:\n{diag.summarize(redact_env=False)}" ) return list(candidates) + def _configure_trigger(self, node_map) -> None: + """ + Disable external trigger by default unless user explicitly configured otherwise. + This prevents fetch() timeouts on cameras left in trigger mode. + """ + try: + trigger_mode = getattr(node_map, "TriggerMode", None) + if trigger_mode is None: + return + + symbolics = getattr(trigger_mode, "symbolics", []) + if "Off" in symbolics: + trigger_mode.value = "Off" + LOG.info("TriggerMode set to Off") + except Exception as e: + LOG.warning("Failed to disable trigger mode: %s", e) + @classmethod def _build_harvester_for_discovery( cls, @@ -354,7 +381,7 @@ def _build_harvester_for_discovery( candidates, diag = cti_finder.discover_cti_files( include_env=True, - cti_search_paths=list(cls._LEGACY_DEFAULT_CTI_PATTERNS), + cti_search_paths=list(cls._DEFAULT_CTI_PATTERNS), must_exist=True, ) @@ -416,142 +443,269 @@ def _build_harvester_for_discovery( return harvester, loaded, diag def open(self) -> None: - if Harvester is None: # pragma: no cover - raise RuntimeError( - "The 'harvesters' package is required for the GenTL backend. Install it via 'pip install harvesters'." + with type(self)._OPEN_LOCK: + open_id = next(type(self)._OPEN_SEQ) + thread_name = threading.current_thread().name + target_for_log = self._device_id or self._serial_number or getattr(self.settings, "index", None) + + LOG.debug( + "[GenTL:%s] open() ENTER pid=%s thread=%s settings_id=%s target=%s index=%s props=%s", + open_id, + os.getpid(), + thread_name, + id(self.settings), + target_for_log, + getattr(self.settings, "index", None), + self.settings.properties, ) + if Harvester is None: # pragma: no cover + raise RuntimeError( + "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 + # 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 + + # Ensure GenTL defalts are present + ns.setdefault("cti_search_paths", list(self._DEFAULT_CTI_PATTERNS)) + ns.setdefault("cti_files_source", self._CTI_FILES_SOURCE_AUTO) + + # Resolve CTIs (may return many). This no longer raises just because there are multiple. + cti_files = self._resolve_cti_files_for_settings() + ns["cti_files_source"] = ( + self._cti_files_source_used or ns.get("cti_files_source") or self._CTI_FILES_SOURCE_AUTO + ) - # Resolve CTIs (may return many). This no longer raises just because there are multiple. - cti_files = self._resolve_cti_files_for_settings() - ns["cti_files_source"] = ( - self._cti_files_source_used or ns.get("cti_files_source") or self._CTI_FILES_SOURCE_AUTO - ) + loaded: list[str] = [] + failed: list[tuple[str, str]] = [] - loaded: list[str] = [] - failed: list[tuple[str, str]] = [] + for cti in cti_files: + ok, reason = self._cti_preflight(cti) + if not ok: + failed.append((str(cti), reason or "preflight failed")) + LOG.warning("Skipping CTI '%s': %s", cti, reason) + continue - for cti in cti_files: - ok, reason = self._cti_preflight(cti) - if not ok: - failed.append((str(cti), reason or "preflight failed")) - LOG.warning("Skipping CTI '%s': %s", cti, reason) - continue + loaded.append(str(cti)) - loaded.append(str(cti)) + # Persist diagnostics for UI / debugging + ns["cti_files"] = [str(p) for p in cti_files] # all resolved candidates + ns["cti_files_loaded"] = [str(p) for p in loaded] # CTIs that passed initial checks + ns["cti_files_failed"] = [{"cti": c, "error": e} for c, e in failed] - # Persist diagnostics for UI / debugging - ns["cti_files"] = [str(p) for p in cti_files] # all resolved candidates - ns["cti_files_loaded"] = [str(p) for p in loaded] # CTIs that passed initial checks - ns["cti_files_failed"] = [{"cti": c, "error": e} for c, e in failed] + # Keep single-cti convenience key for backward compatibility / display + if loaded: + ns["cti_file"] = str(loaded[0]) + elif cti_files: + ns["cti_file"] = str(cti_files[0]) # best effort - # Keep single-cti convenience key for backward compatibility / display - if loaded: - ns["cti_file"] = str(loaded[0]) - elif cti_files: - ns["cti_file"] = str(cti_files[0]) # best effort + if not loaded: + self._reset_harvester() + raise RuntimeError( + "No GenTL producer (.cti) could be loaded.\n\n" + f"Resolved CTIs: {cti_files}\n" + f"Failures: {failed}\n" + "Fix: remove/repair incompatible producers or " + "set properties.gentl.cti_file to a known working producer." + ) - if not loaded: - self._reset_harvester() - raise RuntimeError( - "No GenTL producer (.cti) could be loaded.\n\n" - f"Resolved CTIs: {cti_files}\n" - f"Failures: {failed}\n" - "Fix: remove/repair incompatible producers or " - "set properties.gentl.cti_file to a known working producer." - ) + # Use a per-backend Harvester instance. + # + # Important for multi-camera: + # Sharing one Harvester instance across camera workers can cause one open/update + # to disturb another. The Imaging Source U3V GenTL producer also appears sensitive + # to concurrent initialization, so serialize init/open but keep read() concurrent. + try: + LOG.debug("[GenTL:%s] waiting for _OPEN_LOCK", open_id) + t_lock_wait = time.monotonic() - # Acquire a process-shared Harvester instance for this CTI set - try: - self._shared_entry = cti_finder.SharedHarvesterPool.acquire(loaded) - self._harvester = self._shared_entry.harvester + LOG.debug( + "[GenTL:%s] acquired _OPEN_LOCK after %.3fs", + open_id, + time.monotonic() - t_lock_wait, + ) + LOG.debug("[GenTL:%s] creating Harvester()", open_id) + # ------------------------------------------------------------ + # Shared Harvester per CTI set. + # + # Important: + # - SharedHarvesterEntry.__init__() performs the initial update(). + # - Do NOT call update() again here. Calling update() while another + # camera is already open/streaming can make the TIS U3V producer + # report zero devices. + # ------------------------------------------------------------ + try: + LOG.debug("[GenTL:%s] acquiring shared Harvester for CTIs=%s", open_id, loaded) + self._shared_entry = cti_finder.SharedHarvesterPool.acquire(loaded) + self._harvester = self._shared_entry.harvester - with self._shared_entry.lock: - # Refresh device list on open; safer for hotplug cases - self._harvester.update() - infos = list(self._harvester.device_info_list or []) + with self._shared_entry.lock: + infos = list(self._harvester.device_info_list or []) + + LOG.debug( + "[GenTL:%s] shared Harvester acquired harvester_id=%s refcount=%s infos=%d", + open_id, + id(self._harvester), + cti_finder.SharedHarvesterPool.get_refcount(self._shared_entry), + len(infos), + ) - # Now that shared loading succeeded, persist the actual loaded list - ns["cti_files_loaded"] = list(getattr(self._shared_entry, "loaded_files", loaded)) + ns["cti_files_loaded"] = list(getattr(self._shared_entry, "loaded_files", loaded)) - except Exception as exc: - if self._shared_entry is not None: + except Exception as exc: + if self._shared_entry is not None: + try: + cti_finder.SharedHarvesterPool.release(self._shared_entry) + except Exception: + pass + self._shared_entry = None + self._harvester = None + raise RuntimeError( + f"Failed to initialize shared GenTL producer state.\n\nCTIs: {loaded}\nReason: {exc}" + ) from exc + + def _debug_info(info, i): + def g(key, default=""): + try: + if hasattr(info, "get"): + v = info.get(key) + if v is not None: + return v + except Exception: + pass + try: + return getattr(info, key, default) + except Exception: + return default + + return { + "index": i, + "serial": str(g("serial_number", "")), + "display": str(g("display_name", "")), + "model": str(g("model", "")), + "vendor": str(g("vendor", "")), + "tl_type": str(g("tl_type", "")), + "access_status": str(g("access_status", "")), + "id": str(g("id_", "")), + } + + LOG.debug( + "[GenTL:%s] enumeration target=%s count=%d devices=%s", + open_id, + self._device_id or ns.get("device_id") or props.get("device_id"), + len(infos), + [_debug_info(info, i) for i, info in enumerate(infos)], + ) + + except Exception as exc: + self._shared_entry = None + self._harvester = None + raise RuntimeError( + f"Failed to initialize GenTL producer state.\n\nCTIs: {loaded}\nReason: {exc}" + ) from exc + + if not infos: + LOG.exception( + "[GenTL:%s] open() FAILED target=%s harvester_id=%s acquirer_id=%s", + open_id, + target_for_log, + id(self._harvester) if self._harvester is not None else None, + id(self._acquirer) if self._acquirer is not None else None, + ) + self._reset_harvester() + raise RuntimeError( + "No GenTL cameras detected via Harvesters after loading producers.\n\n" + f"Loaded CTIs: {loaded}\n" + f"Failed CTIs: {failed}\n" + "Fix: ensure your camera vendor's GenTL producer is installed and working." + ) + + # Helper: robustly read device_info fields (dict-like or attribute-like) + def _info_get(info, key: str, default=None): try: - cti_finder.SharedHarvesterPool.release(self._shared_entry) + if hasattr(info, "get"): + v = info.get(key) + if v is not None: + return v except Exception: pass - self._shared_entry = None - self._harvester = None - raise RuntimeError( - f"Failed to initialize shared GenTL producer state.\n\nCTIs: {loaded}\nReason: {exc}" - ) from exc - - if not infos: - self._reset_harvester() - raise RuntimeError( - "No GenTL cameras detected via Harvesters after loading producers.\n\n" - f"Loaded CTIs: {loaded}\n" - f"Failed CTIs: {failed}\n" - "Fix: ensure your camera vendor's GenTL producer is installed and working." - ) - - # Helper: robustly read device_info fields (dict-like or attribute-like) - def _info_get(info, key: str, default=None): - try: - if hasattr(info, "get"): - v = info.get(key) + try: + v = getattr(info, key, None) 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 + 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 + # ------------------------------------------------------------------ + # 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 - 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() + 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() - # Exact match against computed device_id - 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 + # Exact match against computed device_id + 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: + # 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}" + ) + + # Legacy serial selection fallback + 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_target: + if sn == serial: exact.append((idx, sn)) if exact: selected_index = exact[0][0] @@ -561,144 +715,119 @@ def _info_get(info, key: str, default=None): 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: + 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"Ambiguous GenTL serial match for '{serial_target}'. Candidates: {candidates}" + f"Camera with serial '{serial}' not found. Available cameras: {available}" ) - # Legacy serial selection fallback - 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}") - - # Index fallback - 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 actual selected index (UI stability) - self.settings.index = int(selected_index) - selected_info = infos[int(selected_index)] - - # Create ImageAcquirer via Harvester.create(...) - try: - with self._shared_entry.lock: - if selected_serial: - self._acquirer = self._harvester.create({"serial_number": str(selected_serial)}) - else: - self._acquirer = self._harvester.create(int(selected_index)) - except TypeError: - with self._shared_entry.lock: - 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 + # Index fallback + 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 - self._device_label = self._resolve_device_label(node_map) + # Update settings.index to actual selected index (UI stability) + self.settings.index = int(selected_index) + selected_info = infos[int(selected_index)] - # Apply configuration - 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) + # Create ImageAcquirer via Harvester.create(...) + with self._shared_entry.lock: + self._acquirer = self._create_image_acquirer(selected_serial, selected_index) - # Read back telemetry - try: - self._actual_width = int(node_map.Width.value) - self._actual_height = int(node_map.Height.value) - except Exception: - pass + remote = self._acquirer.remote_device + node_map = remote.node_map - try: - self._actual_fps = float(node_map.ResultingFrameRate.value) - except Exception: - self._actual_fps = None + self._device_label = self._resolve_device_label(node_map) - try: - self._actual_exposure = float(node_map.ExposureTime.value) - except Exception: - self._actual_exposure = None + # Apply configuration + self._configure_pixel_format(node_map) + self._configure_trigger(node_map) + self._configure_resolution(node_map) + self._configure_exposure(node_map) + self._configure_gain(node_map) + self._configure_frame_rate(node_map) - try: - self._actual_gain = float(node_map.Gain.value) - except Exception: - self._actual_gain = None + # Read back telemetry + try: + self._actual_width = int(node_map.Width.value) + self._actual_height = int(node_map.Height.value) + except Exception: + pass - # Persist identity + metadata - computed_id = None - try: - computed_id = self._device_id_from_info(selected_info) - except Exception: - computed_id = None + try: + self._actual_fps = float(node_map.ResultingFrameRate.value) + except Exception: + self._actual_fps = None - if computed_id: - ns["device_id"] = computed_id - elif selected_serial: - ns["device_id"] = f"serial:{selected_serial}" + if self._actual_exposure is None: + try: + self._actual_exposure = float(self._acquirer.remote_device.node_map.ExposureTime.value) + except Exception: + self._actual_exposure = None - if selected_serial: - ns["serial_number"] = str(selected_serial) - ns["device_serial_number"] = str(selected_serial) + if self._actual_gain is None: + try: + self._actual_gain = float(self._acquirer.remote_device.node_map.Gain.value) + except Exception: + self._actual_gain = None - if self._device_label: - ns["device_name"] = str(self._device_label) - - 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) - - # Start acquisition unless fast_start - if getattr(self, "_fast_start", False): - LOG.info("GenTL open() in fast_start probe mode: acquisition not started.") - return + # Persist identity + metadata + 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}" + + if selected_serial: + ns["serial_number"] = str(selected_serial) + ns["device_serial_number"] = str(selected_serial) + + if self._device_label: + ns["device_name"] = str(self._device_label) + + 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) + + # Start acquisition unless fast_start + if getattr(self, "_fast_start", False): + LOG.info("GenTL open() in fast_start probe mode: acquisition not started.") + return - with self._shared_entry.lock: - self._acquirer.start() + with self._shared_entry.lock: + self._acquirer.start() + + LOG.debug( + "[GenTL:%s] open() SUCCESS harvester_id=%s acquirer_id=%s device_label=%s", + open_id, + id(self._harvester), + id(self._acquirer), + self._device_label, + ) @staticmethod def _device_id_from_info(info) -> str | None: @@ -883,6 +1012,22 @@ def rebind_settings(cls, settings): target_id = ns.get("device_id") or ns.get("serial_number") or ns.get("serial") if not target_id: return settings + # For serial-based GenTL devices, open() can select by serial directly. + # Avoid doing Harvester enumeration during CameraFactory.create(), because + # multi-camera startup calls create() concurrently from multiple threads. + target_id_str = str(target_id).strip() + if target_id_str.startswith("serial:"): + serial = target_id_str.split("serial:", 1)[1].strip() + if serial: + 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 + ns2["device_id"] = target_id_str + ns2["serial_number"] = serial + return settings source = ns.get("cti_files_source") source = str(source).strip().lower() if source is not None else None @@ -1012,6 +1157,33 @@ def quick_ping(cls, index: int, _unused=None) -> bool: except Exception: pass + def _call_with_optional_lock(self, func, *args, **kwargs): + """ + Call func under the shared Harvester lock if a shared entry exists. + In per-instance Harvester mode, call directly. + """ + if self._shared_entry is not None: + with self._shared_entry.lock: + return func(*args, **kwargs) + return func(*args, **kwargs) + + def _create_image_acquirer(self, selected_serial: str | None, selected_index: int): + """ + Create a Harvester ImageAcquirer using serial when available. + Supports both create(arg) and create(index=...) API variants. + """ + if self._harvester is None: + raise RuntimeError("Harvester is not initialized") + + try: + if selected_serial: + return self._harvester.create({"serial_number": str(selected_serial)}) + return self._harvester.create(int(selected_index)) + except TypeError: + if selected_serial: + return self._harvester.create({"serial_number": str(selected_serial)}) + return self._harvester.create(index=int(selected_index)) + def read(self) -> tuple[np.ndarray, float]: if self._acquirer is None: raise RuntimeError("GenTL image acquirer not initialised") @@ -1044,13 +1216,13 @@ def read(self) -> tuple[np.ndarray, float]: if self._actual_exposure is None: try: - self._actual_exposure = float(self._acquirer.node_map.ExposureTime.value) + self._actual_exposure = float(self._acquirer.remote_device.node_map.ExposureTime.value) except Exception: self._actual_exposure = None if self._actual_gain is None: try: - self._actual_gain = float(self._acquirer.node_map.Gain.value) + self._actual_gain = float(self._acquirer.remote_device.node_map.Gain.value) except Exception: self._actual_gain = None @@ -1059,11 +1231,7 @@ def read(self) -> tuple[np.ndarray, float]: def stop(self) -> None: if self._acquirer is not None: try: - if self._shared_entry is not None: - with self._shared_entry.lock: - self._acquirer.stop() - else: - self._acquirer.stop() + self._call_with_optional_lock(self._acquirer.stop) except Exception: pass @@ -1078,6 +1246,11 @@ def _reset_select_harvester(harvester) -> None: def _reset_harvester(self) -> None: try: if self._shared_entry is not None: + LOG.debug( + "GenTL releasing shared Harvester harvester_id=%s refcount_before=%s", + id(self._shared_entry.harvester), + cti_finder.SharedHarvesterPool.get_refcount(self._shared_entry), + ) cti_finder.SharedHarvesterPool.release(self._shared_entry) self._shared_entry = None else: @@ -1088,22 +1261,14 @@ def _reset_harvester(self) -> None: def close(self) -> None: if self._acquirer is not None: try: - if self._shared_entry is not None: - with self._shared_entry.lock: - self._acquirer.stop() - else: - self._acquirer.stop() + self._call_with_optional_lock(self._acquirer.stop) except Exception: pass try: destroy = getattr(self._acquirer, "destroy", None) if destroy is not None: - if self._shared_entry is not None: - with self._shared_entry.lock: - destroy() - else: - destroy() + self._call_with_optional_lock(destroy) finally: self._acquirer = None diff --git a/dlclivegui/main.py b/dlclivegui/main.py index eb444aa08..eace8bda6 100644 --- a/dlclivegui/main.py +++ b/dlclivegui/main.py @@ -3,6 +3,7 @@ import argparse import logging +import os import signal import sys @@ -54,6 +55,32 @@ def _sigint_handler(_signum, _frame) -> None: app._sig_timer = sig_timer # Store on app to keep it alive and allow cleanup on exit +def configure_logging(debug: bool = False) -> None: + """Configure local application logging.""" + env_debug = os.environ.get("DLCLIVEGUI_DEBUG_LOGGING", "").strip().lower() in ( + "1", + "true", + "yes", + "on", + "debug", + ) + + enabled = bool(debug or env_debug) + level = logging.DEBUG if enabled else logging.INFO + + logging.basicConfig( + level=level, + format="%(asctime)s.%(msecs)03d %(levelname)-8s [%(threadName)s] %(name)s:%(lineno)d - %(message)s", + datefmt="%H:%M:%S", + force=True, + ) + + logging.getLogger("dlclivegui").setLevel(level) + + if enabled: + logging.debug("Debug logging enabled.") + + def parse_args(argv=None): if argv is None: argv = sys.argv[1:] @@ -77,12 +104,13 @@ def parse_args(argv=None): formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("--no-art", action="store_true", help="Disable ASCII art in help and when launching.") + parser.add_argument("--debug-log", action="store_true", help="Enable debug logging.") return parser.parse_known_args(argv) def main() -> None: args, _unknown = parse_args() - + configure_logging(debug=args.debug_log) logging.info("Starting DeepLabCut-Live GUI...") # If you want a startup banner, PRINT it (not log), and only in TTY contexts. diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 8db469f0b..7d5b56693 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import logging import time from dataclasses import dataclass @@ -14,6 +15,7 @@ from dlclivegui.cameras import CameraFactory from dlclivegui.cameras.base import CameraBackend +from dlclivegui.cameras.factory import camera_identity_key # from dlclivegui.config import CameraSettings from dlclivegui.config import CameraSettings @@ -42,7 +44,7 @@ class SingleCameraWorker(QObject): def __init__(self, camera_id: str, settings: CameraSettings): super().__init__() self._camera_id = camera_id - self._settings = settings + self._settings = copy.deepcopy(settings) self._stop_event = Event() self._backend: CameraBackend | None = None self._max_consecutive_errors = 5 @@ -53,7 +55,24 @@ def run(self) -> None: self._stop_event.clear() try: + LOGGER.debug( + "[Worker %s] before create: backend=%s index=%s properties=%s", + self._camera_id, + self._settings.backend, + self._settings.index, + self._settings.properties, + ) + self._backend = CameraFactory.create(self._settings) + + LOGGER.debug( + "[Worker %s] after create: backend=%s index=%s properties=%s", + self._camera_id, + self._backend.settings.backend, + self._backend.settings.index, + self._backend.settings.properties, + ) + self._backend.open() except Exception as exc: LOGGER.exception(f"Failed to initialize camera {self._camera_id}", exc_info=exc) @@ -103,8 +122,20 @@ def stop(self) -> None: def get_camera_id(settings: CameraSettings) -> str: - """Generate a unique camera ID from settings.""" - return f"{settings.backend}:{settings.index}" + """Generate a unique camera ID from stable backend identity.""" + backend = (settings.backend or "").lower() + props = settings.properties if isinstance(settings.properties, dict) else {} + ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {} + + device_id = ns.get("device_id") + if device_id: + return f"{backend}:{device_id}" + + serial = ns.get("serial_number") or ns.get("device_serial_number") or ns.get("serial") + if serial: + return f"{backend}:serial:{serial}" + + return f"{backend}:index:{int(settings.index)}" class MultiCameraController(QObject): @@ -153,6 +184,15 @@ def start(self, camera_settings: list[CameraSettings]) -> None: LOGGER.warning("No active cameras to start") return + # Check for dupes + seen = set() + for s in active_settings: + key = camera_identity_key(s) + if key in seen: + self.initialization_failed.emit([(key, "Duplicate camera configuration")]) + return + seen.add(key) + self._running = True self._frames.clear() self._timestamps.clear() @@ -165,13 +205,16 @@ def start(self, camera_settings: list[CameraSettings]) -> None: def _start_camera(self, settings: CameraSettings) -> None: """Start a single camera.""" - cam_id = get_camera_id(settings) + settings_copy = copy.deepcopy(settings) + cam_id = get_camera_id(settings_copy) if cam_id in self._workers: LOGGER.warning(f"Camera {cam_id} already has a worker") return + LOGGER.info(f"[MultiCameraController] Starting {cam_id} with settings: {settings_copy}") + # Normalize and store the dataclass once - self._settings[cam_id] = settings + self._settings[cam_id] = settings_copy dc = self._settings[cam_id] worker = SingleCameraWorker(cam_id, dc) thread = QThread() From 40324021b4adfab3a304b1f8712c064de380caef Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 11:37:50 +0200 Subject: [PATCH 004/133] Clean up file --- dlclivegui/cameras/backends/gentl_backend.py | 1917 +++++++----------- 1 file changed, 720 insertions(+), 1197 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index e409c4e7a..8a06b2d68 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1,11 +1,9 @@ """GenTL backend implemented using the Harvesters library.""" -# dlclivegui/cameras/backends/gentl_backend.py +# dlclivegui/cameras/backends/gentl_backend.py from __future__ import annotations -import itertools import logging -import os import threading import time from pathlib import Path @@ -34,115 +32,82 @@ @register_backend("gentl") class GenTLCameraBackend(CameraBackend): - """Capture frames from GenTL-compatible devices via Harvesters.""" + """Capture frames from GenTL-compatible devices via Harvesters. + + Notes + ----- + Multi-camera operation uses a shared Harvester per CTI set. Some GenTL + producers, including the Imaging Source USB3 Vision producer, can report no + devices if a second independent Harvester enumerates while another camera is + already open/streaming. Therefore open() acquires a shared Harvester and + never calls Harvester.update() during runtime open; initial enumeration is + handled by SharedHarvesterPool when the shared Harvester is created. + """ OPTIONS_KEY: ClassVar[str] = "gentl" _OPEN_LOCK: ClassVar[threading.RLock] = threading.RLock() - _DEFAULT_CTI_PATTERNS: tuple[str, ...] = ( # Windows-only, ignored on other platforms + + _DEFAULT_CTI_PATTERNS: ClassVar[tuple[str, ...]] = ( + # Windows-only defaults; harmless/no-op on other platforms. r"C:\Program Files\The Imaging Source Europe GmbH\IC4 GenTL Driver for USB3Vision Devices *\bin\*.cti", r"C:\Program Files\The Imaging Source Europe GmbH\TIS Grabber\bin\win64_x64\*.cti", r"C:\Program Files\The Imaging Source Europe GmbH\TIS Camera SDK\bin\win64_x64\*.cti", r"C:\Program Files (x86)\The Imaging Source Europe GmbH\TIS Grabber\bin\win64_x64\*.cti", ) - # Source marker stored in properties["gentl"]["cti_files_source"] - # auto : persisted by auto-discovery (env vars, patterns, etc.). Cache, may be stale, re-discover if missing. - # user : explicitly set by user via properties.gentl.cti_file(s). Cache, strict raise if missing. + + # Source marker stored in properties["gentl"]["cti_files_source"]. + # auto: persisted by auto-discovery; may be stale and can fall back. + # user: explicitly set by user; strict if stale/missing. _CTI_FILES_SOURCE_AUTO: ClassVar[str] = "auto" _CTI_FILES_SOURCE_USER: ClassVar[str] = "user" - _OPEN_SEQ: ClassVar[itertools.count] = itertools.count(1) 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 = {} - # --- 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 + self._serial_number: str | None = self._serial_from_identity(self._device_id, legacy_serial) - # 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) --- - exp_val = getattr(settings, "exposure", 0) - gain_val = getattr(settings, "gain", 0.0) - - self._exposure: float | None = ( - float(exp_val) if isinstance(exp_val, (int, float)) and float(exp_val) > 0 else None - ) + self._exposure: float | None = self._positive_float(getattr(settings, "exposure", 0)) if self._exposure is None: - v = ns.get("exposure", props.get("exposure")) - try: - self._exposure = float(v) if v is not None and float(v) > 0 else None - except Exception: - self._exposure = None + self._exposure = self._positive_float(ns.get("exposure", props.get("exposure"))) - self._gain: float | None = ( - float(gain_val) if isinstance(gain_val, (int, float)) and float(gain_val) > 0 else None - ) + self._gain: float | None = self._positive_float(getattr(settings, "gain", 0.0)) if self._gain is None: - v = ns.get("gain", props.get("gain")) - try: - self._gain = float(v) if v is not None and float(v) > 0 else None - except Exception: - self._gain = None + self._gain = self._positive_float(ns.get("gain", props.get("gain"))) - # --- Acquisition timeout --- self._timeout: float = float(ns.get("timeout", props.get("timeout", 2.0))) - - # --- 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 --- 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._shared_entry = None self._device_label: str | None = None - self._cti_files_source_used: str | None = None + # ------------------------------------------------------------------ + # Public telemetry / capabilities + # ------------------------------------------------------------------ + @property def actual_resolution(self) -> tuple[int, int] | None: if self._actual_width and self._actual_height: @@ -176,12 +141,13 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "stable_identity": SupportLevel.SUPPORTED, } + # ------------------------------------------------------------------ + # Discovery + # ------------------------------------------------------------------ + @classmethod def get_device_count(cls) -> int: - """Get the number of GenTL devices detected by Harvester. - - Returns the number of devices found, or -1 if detection fails. - """ + """Return the number of GenTL devices, or -1 if detection fails.""" if Harvester is None: return -1 @@ -190,192 +156,97 @@ def get_device_count(cls) -> int: harvester, _, _ = cls._build_harvester_for_discovery(strict_single=False) if harvester is None: return -1 - infos = harvester.device_info_list or [] - return len(infos) + return len(harvester.device_info_list or []) except Exception: return -1 finally: - if harvester is not None: - try: - harvester.reset() - except Exception: - pass - - @staticmethod - def _cti_preflight(path: str) -> tuple[bool, str | None]: - """ - Best-effort check right before calling Harvester.add_file(). - Still subject to race conditions (e.g. file deleted after this check), - but helps diagnose common issues like missing files or permission errors more gracefully and early. - Returns (ok, reason_if_not_ok). - """ - p = Path(str(path)) - try: - if not p.exists(): - return False, "missing at load time" - if not p.is_file(): - return False, "not a file at load time" - # Optional: try opening for read to detect permission/locking issues early - with p.open("rb"): - pass - return True, None - except PermissionError: - return False, "permission denied at load time" - except OSError as e: - return False, f"os error at load time: {e}" - - def _resolve_cti_files_for_settings(self) -> list[str]: - """ - Resolve CTI files to load. - - - User override (properties.gentl.cti_file/cti_files OR legacy properties.cti_file/cti_files): - * strict: must exist, otherwise raise - * source = "user" - - Auto-persisted cache (properties.gentl.cti_files_source == "auto"): - * try persisted ctis first - * if stale/missing, fall back to discovery - * source = "auto" - - Default: discovery (env + configured patterns/dirs) => source = "auto" - - NOTE : legacy properties.cti_file(s) always take strict precedence as user override if present, - even if source marker says "auto". - Never raise just because multiple CTIs exist. - Raise only when none are found (after allowed fallback). - """ - props = self.settings.properties if isinstance(self.settings.properties, dict) else {} - ns = props.get(self.OPTIONS_KEY, {}) - if not isinstance(ns, dict): - ns = {} + cls._safe_reset_harvester(harvester) - # Read source marker - source = ns.get("cti_files_source") - source = str(source).strip().lower() if source is not None else None + @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().""" + if Harvester is None: + return [] - # Explicit CTIs (namespace first, then legacy top-level) - ns_cti_files = ns.get("cti_files") - ns_cti_file = ns.get("cti_file") - legacy_cti_files = props.get("cti_files") - legacy_cti_file = props.get("cti_file") + def _canceled() -> bool: + return bool(should_cancel and should_cancel()) - # ------------------------------------------------------------ - # 1) Legacy explicit CTIs: always treat as user override (strict) - # ------------------------------------------------------------ - if legacy_cti_files or legacy_cti_file: - self._cti_files_source_used = self._CTI_FILES_SOURCE_USER + harvester = None + try: + if progress_cb: + progress_cb("Initializing GenTL discovery…") - candidates, diag = cti_finder.discover_cti_files( - cti_file=str(legacy_cti_file) if legacy_cti_file else None, - cti_files=cti_finder.cti_files_as_list(legacy_cti_files) if legacy_cti_files else None, - include_env=False, - must_exist=True, - ) - if not candidates: - raise RuntimeError( - "No valid GenTL producer (.cti) found from properties.cti_file/cti_files.\n\n" - f"Discovery details:\n{diag.summarize()}" - ) - return list(candidates) + harvester, loaded, _ = cls._build_harvester_for_discovery(strict_single=False) + if harvester is None or not loaded: + if progress_cb: + progress_cb("No GenTL producers could be loaded.") + return [] - # ------------------------------------------------------------------------ - # 2) Namespace explicit CTIs: behavior depends on cti_files_source marker - # - source=="auto": treat as cache, stale => fallback to discovery - # - otherwise: strict user override - # ------------------------------------------------------------------------ - if ns_cti_files or ns_cti_file: - is_auto_cache = source == self._CTI_FILES_SOURCE_AUTO + if progress_cb: + progress_cb(f"Loaded {len(loaded)} GenTL producer(s). Scanning devices…") - # Default to "user" if the marker is missing/unknown. - self._cti_files_source_used = self._CTI_FILES_SOURCE_AUTO if is_auto_cache else self._CTI_FILES_SOURCE_USER + infos = list(harvester.device_info_list or []) + limit = min(len(infos), max_devices if max_devices > 0 else len(infos)) + out: list[DetectedCamera] = [] - candidates, diag = cti_finder.discover_cti_files( - cti_file=str(ns_cti_file) if ns_cti_file else None, - cti_files=cti_finder.cti_files_as_list(ns_cti_files) if ns_cti_files else None, - include_env=False, - must_exist=True, - ) + for idx in range(limit): + if _canceled(): + break - if candidates: - return list(candidates) + info = infos[idx] + label = cls._label_from_info(info, idx) + device_id = cls._device_id_from_info(info) - # If auto cache is stale, fall back to discovery - if is_auto_cache: - LOG.info( - "Auto-persisted GenTL CTIs appear stale/missing; falling back to discovery. " - "Persisted cti_file=%s cti_files=%s", - ns_cti_file, - ns_cti_files, - ) - # Fall through to discovery (below) - else: - # User override: strict failure - raise RuntimeError( - "No valid GenTL producer (.cti) found from properties.gentl.cti_file/cti_files.\n\n" - f"Discovery details:\n{diag.summarize()}" + out.append( + DetectedCamera( + index=idx, + label=label, + device_id=device_id, + vid=None, + pid=None, + path=None, + backend_hint=None, + ) ) - # ------------------------------------------------------------ - # 3) Discovery path: env vars + patterns/dirs + built-in defaults - # ------------------------------------------------------------ - self._cti_files_source_used = self._CTI_FILES_SOURCE_AUTO - - search_paths = ns.get("cti_search_paths", props.get("cti_search_paths")) - extra_dirs = ns.get("cti_dirs", props.get("cti_dirs")) - - if search_paths is not None: - search_patterns = cti_finder.cti_files_as_list(search_paths) - else: - search_patterns = list(self._DEFAULT_CTI_PATTERNS) - - candidates, diag = cti_finder.discover_cti_files( - cti_search_paths=search_patterns, - include_env=True, - extra_dirs=cti_finder.cti_files_as_list(extra_dirs) if extra_dirs is not None else None, - recursive_env_search=False, - recursive_extra_search=False, - must_exist=True, - ) + if progress_cb: + progress_cb(f"Found: {label}") - if not candidates: - raise RuntimeError( - "Could not locate any GenTL producer (.cti) file.\n\n" - "Fix options:\n" - " - Set camera.properties.gentl.cti_file to the full path of a .cti file\n" - " - Or set GENICAM_GENTL64_PATH / GENICAM_GENTL32_PATH to include the producer directory\n" - " - Or provide camera.properties.gentl.cti_search_paths with glob patterns\n\n" - f"Discovery details:\n{diag.summarize(redact_env=False)}" - ) + out.sort(key=lambda c: c.index) + return out + except Exception: + LOG.debug("GenTL rich discovery failed", exc_info=True) + return [] + finally: + cls._safe_reset_harvester(harvester) - return list(candidates) + @classmethod + def quick_ping(cls, index: int, _unused=None) -> bool: + """Fast presence check by index using a temporary discovery Harvester.""" + if Harvester is None: + return False - def _configure_trigger(self, node_map) -> None: - """ - Disable external trigger by default unless user explicitly configured otherwise. - This prevents fetch() timeouts on cameras left in trigger mode. - """ + harvester = None try: - trigger_mode = getattr(node_map, "TriggerMode", None) - if trigger_mode is None: - return - - symbolics = getattr(trigger_mode, "symbolics", []) - if "Off" in symbolics: - trigger_mode.value = "Off" - LOG.info("TriggerMode set to Off") - except Exception as e: - LOG.warning("Failed to disable trigger mode: %s", e) + harvester, _, _ = cls._build_harvester_for_discovery(strict_single=False) + if harvester is None: + return False + infos = harvester.device_info_list or [] + return 0 <= int(index) < len(infos) + except Exception: + return False + finally: + cls._safe_reset_harvester(harvester) @classmethod - def _build_harvester_for_discovery( - cls, - *, - strict_single: bool = False, # retained for optional future use - ): - """ - Build a Harvester instance and load CTI producers for class-level operations - (discover_devices, quick_ping, get_device_count, rebind_settings). - - Default policy: try to load ALL discovered producers. - """ + def _build_harvester_for_discovery(cls, *, strict_single: bool = False): + """Build a temporary Harvester for discovery-only operations.""" if Harvester is None: return None, [], None @@ -384,241 +255,138 @@ def _build_harvester_for_discovery( cti_search_paths=list(cls._DEFAULT_CTI_PATTERNS), must_exist=True, ) - if not candidates: return None, [], diag - # Default: load all candidates cti_files = list(candidates) - - # Optional strict mode (off by default) if strict_single: - # If you ever want strict, use choose_cti_files here; otherwise ignore. cti_files = cti_finder.choose_cti_files( - cti_files, policy=cti_finder.GenTLDiscoveryPolicy.RAISE_IF_MULTIPLE, max_files=1 + cti_files, + policy=cti_finder.GenTLDiscoveryPolicy.RAISE_IF_MULTIPLE, + max_files=1, ) harvester = Harvester() loaded: list[str] = [] - failures: list[tuple[str, str]] = [] for cti in cti_files: ok, reason = cls._cti_preflight(cti) if not ok: - failures.append((str(cti), reason or "Check failed")) LOG.warning("Skipping CTI '%s' during discovery preflight: %s", cti, reason) continue - try: harvester.add_file(cti) loaded.append(cti) except Exception as exc: - failures.append((str(cti), str(exc))) LOG.warning("Failed to load CTI '%s' during discovery: %s", cti, exc) if not loaded: - try: - harvester.reset() - except Exception: - pass + cls._safe_reset_harvester(harvester) return None, [], diag try: harvester.update() except Exception as exc: - LOG.error( - "Harvester.update() failed during discovery: %s" - " Device list not usable, treating as discovery failure." - " CTIs loaded before failure : %s", - exc, - loaded, - ) - try: - harvester.reset() - except Exception: - pass - # Update failure + LOG.error("Harvester.update() failed during discovery: %s. CTIs loaded: %s", exc, loaded) + cls._safe_reset_harvester(harvester) return None, [], diag return harvester, loaded, diag - def open(self) -> None: - with type(self)._OPEN_LOCK: - open_id = next(type(self)._OPEN_SEQ) - thread_name = threading.current_thread().name - target_for_log = self._device_id or self._serial_number or getattr(self.settings, "index", None) - - LOG.debug( - "[GenTL:%s] open() ENTER pid=%s thread=%s settings_id=%s target=%s index=%s props=%s", - open_id, - os.getpid(), - thread_name, - id(self.settings), - target_for_log, - getattr(self.settings, "index", None), - self.settings.properties, - ) - if Harvester is None: # pragma: no cover - raise RuntimeError( - "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 - - # Ensure GenTL defalts are present - ns.setdefault("cti_search_paths", list(self._DEFAULT_CTI_PATTERNS)) - ns.setdefault("cti_files_source", self._CTI_FILES_SOURCE_AUTO) - - # Resolve CTIs (may return many). This no longer raises just because there are multiple. - cti_files = self._resolve_cti_files_for_settings() - ns["cti_files_source"] = ( - self._cti_files_source_used or ns.get("cti_files_source") or self._CTI_FILES_SOURCE_AUTO - ) - - loaded: list[str] = [] - failed: list[tuple[str, str]] = [] + # ------------------------------------------------------------------ + # Settings rebinding + # ------------------------------------------------------------------ - for cti in cti_files: - ok, reason = self._cti_preflight(cti) - if not ok: - failed.append((str(cti), reason or "preflight failed")) - LOG.warning("Skipping CTI '%s': %s", cti, reason) - continue + @classmethod + def rebind_settings(cls, settings): + """Map stable identity to current index when necessary. - loaded.append(str(cti)) + Serial identities are stable enough for open() to select directly, so + they intentionally avoid extra Harvester enumeration during multi-camera + startup. + """ + if Harvester is None: + return settings - # Persist diagnostics for UI / debugging - ns["cti_files"] = [str(p) for p in cti_files] # all resolved candidates - ns["cti_files_loaded"] = [str(p) for p in loaded] # CTIs that passed initial checks - ns["cti_files_failed"] = [{"cti": c, "error": e} for c, e in failed] + props = settings.properties if isinstance(settings.properties, dict) else {} + ns = props.get(cls.OPTIONS_KEY, {}) + if not isinstance(ns, dict): + ns = {} - # Keep single-cti convenience key for backward compatibility / display - if loaded: - ns["cti_file"] = str(loaded[0]) - elif cti_files: - ns["cti_file"] = str(cti_files[0]) # best effort + target_id = ns.get("device_id") or ns.get("serial_number") or ns.get("serial") + if not target_id: + return settings - if not loaded: - self._reset_harvester() - raise RuntimeError( - "No GenTL producer (.cti) could be loaded.\n\n" - f"Resolved CTIs: {cti_files}\n" - f"Failures: {failed}\n" - "Fix: remove/repair incompatible producers or " - "set properties.gentl.cti_file to a known working producer." - ) + target_id_str = str(target_id).strip() + if target_id_str.startswith("serial:"): + cls._persist_serial_identity(settings, target_id_str) + return settings - # Use a per-backend Harvester instance. - # - # Important for multi-camera: - # Sharing one Harvester instance across camera workers can cause one open/update - # to disturb another. The Imaging Source U3V GenTL producer also appears sensitive - # to concurrent initialization, so serialize init/open but keep read() concurrent. - try: - LOG.debug("[GenTL:%s] waiting for _OPEN_LOCK", open_id) - t_lock_wait = time.monotonic() + # Non-serial fallback retained for older configs / fingerprint IDs. + harvester = None + try: + explicit_files = ns.get("cti_files") or props.get("cti_files") + explicit_file = ns.get("cti_file") or props.get("cti_file") + source = str(ns.get("cti_files_source", "")).strip().lower() + is_auto_cache = source == cls._CTI_FILES_SOURCE_AUTO - LOG.debug( - "[GenTL:%s] acquired _OPEN_LOCK after %.3fs", - open_id, - time.monotonic() - t_lock_wait, + if explicit_files or explicit_file: + candidates, _ = cti_finder.discover_cti_files( + cti_file=explicit_file, + cti_files=cti_finder.cti_files_as_list(explicit_files), + include_env=False, + must_exist=True, ) - LOG.debug("[GenTL:%s] creating Harvester()", open_id) - # ------------------------------------------------------------ - # Shared Harvester per CTI set. - # - # Important: - # - SharedHarvesterEntry.__init__() performs the initial update(). - # - Do NOT call update() again here. Calling update() while another - # camera is already open/streaming can make the TIS U3V producer - # report zero devices. - # ------------------------------------------------------------ - try: - LOG.debug("[GenTL:%s] acquiring shared Harvester for CTIs=%s", open_id, loaded) - self._shared_entry = cti_finder.SharedHarvesterPool.acquire(loaded) - self._harvester = self._shared_entry.harvester - - with self._shared_entry.lock: - infos = list(self._harvester.device_info_list or []) - - LOG.debug( - "[GenTL:%s] shared Harvester acquired harvester_id=%s refcount=%s infos=%d", - open_id, - id(self._harvester), - cti_finder.SharedHarvesterPool.get_refcount(self._shared_entry), - len(infos), - ) - - ns["cti_files_loaded"] = list(getattr(self._shared_entry, "loaded_files", loaded)) - - except Exception as exc: - if self._shared_entry is not None: - try: - cti_finder.SharedHarvesterPool.release(self._shared_entry) - except Exception: - pass - self._shared_entry = None - self._harvester = None - raise RuntimeError( - f"Failed to initialize shared GenTL producer state.\n\nCTIs: {loaded}\nReason: {exc}" - ) from exc - - def _debug_info(info, i): - def g(key, default=""): - try: - if hasattr(info, "get"): - v = info.get(key) - if v is not None: - return v - except Exception: - pass + if not candidates and is_auto_cache: + harvester, _, _ = cls._build_harvester_for_discovery(strict_single=False) + elif candidates: + harvester = Harvester() + loaded = [] + for cti in candidates: try: - return getattr(info, key, default) + harvester.add_file(cti) + loaded.append(cti) except Exception: - return default - - return { - "index": i, - "serial": str(g("serial_number", "")), - "display": str(g("display_name", "")), - "model": str(g("model", "")), - "vendor": str(g("vendor", "")), - "tl_type": str(g("tl_type", "")), - "access_status": str(g("access_status", "")), - "id": str(g("id_", "")), - } - - LOG.debug( - "[GenTL:%s] enumeration target=%s count=%d devices=%s", - open_id, - self._device_id or ns.get("device_id") or props.get("device_id"), - len(infos), - [_debug_info(info, i) for i, info in enumerate(infos)], - ) - - except Exception as exc: - self._shared_entry = None - self._harvester = None - raise RuntimeError( - f"Failed to initialize GenTL producer state.\n\nCTIs: {loaded}\nReason: {exc}" - ) from exc + continue + if not loaded: + return settings + harvester.update() + else: + harvester, _, _ = cls._build_harvester_for_discovery(strict_single=False) + + if harvester is None: + return settings + + infos = list(harvester.device_info_list or []) + match_index, match_serial = cls._match_device(infos, target_id_str) + if match_index is None: + return settings + + settings.index = int(match_index) + ns2 = cls._ensure_ns_for_settings(settings) + ns2["device_id"] = target_id_str + if match_serial: + ns2["serial_number"] = str(match_serial) + return settings + except Exception: + return settings + finally: + cls._safe_reset_harvester(harvester) + + # ------------------------------------------------------------------ + # Open / read / close + # ------------------------------------------------------------------ + def open(self) -> None: + if Harvester is None: # pragma: no cover + raise RuntimeError( + "The 'harvesters' package is required for the GenTL backend. Install it via 'pip install harvesters'." + ) + + with type(self)._OPEN_LOCK: + loaded, failed = self._resolve_and_persist_ctis() + infos = self._acquire_shared_harvester(loaded) if not infos: - LOG.exception( - "[GenTL:%s] open() FAILED target=%s harvester_id=%s acquirer_id=%s", - open_id, - target_for_log, - id(self._harvester) if self._harvester is not None else None, - id(self._acquirer) if self._acquirer is not None else None, - ) self._reset_harvester() raise RuntimeError( "No GenTL cameras detected via Harvesters after loading producers.\n\n" @@ -627,554 +395,477 @@ def g(key, default=""): "Fix: ensure your camera vendor's GenTL producer is installed and working." ) - # Helper: robustly read device_info fields (dict-like or attribute-like) - def _info_get(info, key: str, default=None): - 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 - - # ------------------------------------------------------------------ - # 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 - - 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() - - # Exact match against computed device_id - 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}" - ) - - # Legacy serial selection fallback - 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}" - ) - - # Index fallback - 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 actual selected index (UI stability) + selected_index, selected_serial, selected_info = self._select_device(infos) self.settings.index = int(selected_index) - selected_info = infos[int(selected_index)] - # Create ImageAcquirer via Harvester.create(...) with self._shared_entry.lock: - self._acquirer = self._create_image_acquirer(selected_serial, selected_index) + self._acquirer = self._create_image_acquirer(selected_serial, int(selected_index)) + node_map = self._acquirer.remote_device.node_map + self._device_label = self._resolve_device_label(node_map) + + self._configure_pixel_format(node_map) + self._configure_trigger(node_map) + self._configure_resolution(node_map) + self._configure_exposure(node_map) + self._configure_gain(node_map) + self._configure_frame_rate(node_map) + self._read_telemetry(node_map) + self._persist_device_metadata(selected_info, selected_serial) + + if self._fast_start: + LOG.info("GenTL open() in fast_start probe mode: acquisition not started.") + return - remote = self._acquirer.remote_device - node_map = remote.node_map - - self._device_label = self._resolve_device_label(node_map) + self._acquirer.start() - # Apply configuration - self._configure_pixel_format(node_map) - self._configure_trigger(node_map) - self._configure_resolution(node_map) - self._configure_exposure(node_map) - self._configure_gain(node_map) - self._configure_frame_rate(node_map) + LOG.debug( + "Opened GenTL camera index=%s serial=%s label=%s", selected_index, selected_serial, self._device_label + ) - # Read back telemetry - try: - self._actual_width = int(node_map.Width.value) - self._actual_height = int(node_map.Height.value) - except Exception: - pass + def read(self) -> tuple[np.ndarray, float]: + if self._acquirer is None: + raise RuntimeError("GenTL image acquirer not initialised") - try: - self._actual_fps = float(node_map.ResultingFrameRate.value) - except Exception: - self._actual_fps = None + try: + with self._acquirer.fetch(timeout=self._timeout) as buffer: + component = buffer.payload.components[0] + channels = 3 if self._pixel_format in {"RGB8", "BGR8"} else 1 + array = np.asarray(component.data) + expected = component.height * component.width * channels + if array.size != expected: + array = np.frombuffer(bytes(component.data), dtype=array.dtype) - if self._actual_exposure is None: try: - self._actual_exposure = float(self._acquirer.remote_device.node_map.ExposureTime.value) - except Exception: - self._actual_exposure = None + if channels > 1: + frame = array.reshape(component.height, component.width, channels).copy() + else: + frame = array.reshape(component.height, component.width).copy() + except ValueError: + frame = array.copy() + except HarvesterTimeoutError as exc: + raise TimeoutError(str(exc) + " (GenTL timeout)") from exc - if self._actual_gain is None: - try: - self._actual_gain = float(self._acquirer.remote_device.node_map.Gain.value) - except Exception: - self._actual_gain = None + frame = self._convert_frame(frame) + timestamp = time.time() + + if self._actual_width is None or self._actual_height is None: + h, w = frame.shape[:2] + self._actual_width = int(w) + self._actual_height = int(h) - # Persist identity + metadata - computed_id = None + if self._actual_exposure is None or self._actual_gain is None: try: - computed_id = self._device_id_from_info(selected_info) + self._read_telemetry(self._acquirer.remote_device.node_map) except Exception: - computed_id = None - - if computed_id: - ns["device_id"] = computed_id - elif selected_serial: - ns["device_id"] = f"serial:{selected_serial}" - - if selected_serial: - ns["serial_number"] = str(selected_serial) - ns["device_serial_number"] = str(selected_serial) - - if self._device_label: - ns["device_name"] = str(self._device_label) - - 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) - - # Start acquisition unless fast_start - if getattr(self, "_fast_start", False): - LOG.info("GenTL open() in fast_start probe mode: acquisition not started.") - return - - with self._shared_entry.lock: - self._acquirer.start() - - LOG.debug( - "[GenTL:%s] open() SUCCESS harvester_id=%s acquirer_id=%s device_label=%s", - open_id, - id(self._harvester), - id(self._acquirer), - self._device_label, - ) + pass - @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. - """ + return frame, timestamp - def _read(name: str): - # dict-like + def stop(self) -> None: + if self._acquirer is not None: try: - if hasattr(info, "get"): - v = info.get(name) # type: ignore[attr-defined] - if v is not None: - return v + self._call_with_optional_lock(self._acquirer.stop) except Exception: pass - # attribute-like + + def close(self) -> None: + if self._acquirer is not None: try: - return getattr(info, name, None) + self._call_with_optional_lock(self._acquirer.stop) 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 + pass - # 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}" + try: + destroy = getattr(self._acquirer, "destroy", None) + if destroy is not None: + self._call_with_optional_lock(destroy) + finally: + self._acquirer = None - # 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") + if self._harvester is not None or self._shared_entry is not None: + self._reset_harvester() - unique = _get("id_", "id", "device_id", "uid", "guid", "mac_address", "interface_id", "display_name") + self._device_label = None - parts = [] - for k, v in (("vendor", vendor), ("model", model), ("user", user_id), ("tl", tl_type), ("uid", unique)): - if v: - parts.append(f"{k}={v}") + # ------------------------------------------------------------------ + # CTI / shared Harvester helpers + # ------------------------------------------------------------------ - if not parts: - return None + def _resolve_and_persist_ctis(self) -> tuple[list[str], list[tuple[str, str]]]: + ns = self._ensure_settings_ns() + ns.setdefault("cti_search_paths", list(self._DEFAULT_CTI_PATTERNS)) + ns.setdefault("cti_files_source", self._CTI_FILES_SOURCE_AUTO) - return "fp:" + "|".join(parts) + cti_files = self._resolve_cti_files_for_settings() + ns["cti_files_source"] = ( + self._cti_files_source_used or ns.get("cti_files_source") or self._CTI_FILES_SOURCE_AUTO + ) - @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. + loaded: list[str] = [] + failed: list[tuple[str, str]] = [] + for cti in cti_files: + ok, reason = self._cti_preflight(cti) + if ok: + loaded.append(str(cti)) + else: + failed.append((str(cti), reason or "preflight failed")) + LOG.warning("Skipping CTI '%s': %s", cti, reason) - Cross-platform CTI discovery: - - Uses GENICAM_GENTL64_PATH / GENICAM_GENTL32_PATH when available - - Falls back to built-in Windows patterns - - Best-effort loads multiple CTI producers - """ - if Harvester is None: - return [] + ns["cti_files"] = [str(p) for p in cti_files] + ns["cti_files_loaded"] = loaded[:] + ns["cti_files_failed"] = [{"cti": c, "error": e} for c, e in failed] + if loaded: + ns["cti_file"] = loaded[0] + elif cti_files: + ns["cti_file"] = str(cti_files[0]) - def _canceled() -> bool: - return bool(should_cancel and should_cancel()) + if not loaded: + self._reset_harvester() + raise RuntimeError( + "No GenTL producer (.cti) could be loaded.\n\n" + f"Resolved CTIs: {cti_files}\n" + f"Failures: {failed}\n" + "Fix: remove/repair incompatible producers " + "or set properties.gentl.cti_file to a known working producer." + ) - harvester = None + return loaded, failed + + def _acquire_shared_harvester(self, loaded: list[str]) -> list: + ns = self._ensure_settings_ns() try: - if progress_cb: - progress_cb("Initializing GenTL discovery…") + self._shared_entry = cti_finder.SharedHarvesterPool.acquire(loaded) + self._harvester = self._shared_entry.harvester + with self._shared_entry.lock: + infos = list(self._harvester.device_info_list or []) + ns["cti_files_loaded"] = list(getattr(self._shared_entry, "loaded_files", loaded)) + LOG.debug( + "Using shared GenTL Harvester for %d device(s), refcount=%s", + len(infos), + cti_finder.SharedHarvesterPool.get_refcount(self._shared_entry), + ) + return infos + except Exception as exc: + if self._shared_entry is not None: + try: + cti_finder.SharedHarvesterPool.release(self._shared_entry) + except Exception: + pass + self._shared_entry = None + self._harvester = None + raise RuntimeError( + f"Failed to initialize shared GenTL producer state.\n\nCTIs: {loaded}\nReason: {exc}" + ) from exc - harvester, loaded, _ = cls._build_harvester_for_discovery(strict_single=False) + def _reset_harvester(self) -> None: + try: + if self._shared_entry is not None: + cti_finder.SharedHarvesterPool.release(self._shared_entry) + self._shared_entry = None + else: + self._reset_select_harvester(self._harvester) + finally: + self._harvester = None - if harvester is None or not loaded: - if progress_cb: - progress_cb("No GenTL producers could be loaded.") - return [] + @staticmethod + def _reset_select_harvester(harvester) -> None: + GenTLCameraBackend._safe_reset_harvester(harvester) - if progress_cb: - progress_cb(f"Loaded {len(loaded)} GenTL producer(s). Scanning devices…") + @staticmethod + def _safe_reset_harvester(harvester) -> None: + if harvester is not None: + try: + harvester.reset() + except Exception: + pass - infos = list(harvester.device_info_list or []) - if not infos: - return [] + @staticmethod + def _cti_preflight(path: str) -> tuple[bool, str | None]: + p = Path(str(path)) + try: + if not p.exists(): + return False, "missing at load time" + if not p.is_file(): + return False, "not a file at load time" + with p.open("rb"): + pass + return True, None + except PermissionError: + return False, "permission denied at load time" + except OSError as e: + return False, f"os error at load time: {e}" - out: list[DetectedCamera] = [] - limit = min(len(infos), max_devices if max_devices > 0 else len(infos)) + def _resolve_cti_files_for_settings(self) -> list[str]: + """Resolve CTI files using explicit user overrides, auto cache, then discovery.""" + props = self.settings.properties if isinstance(self.settings.properties, dict) else {} + ns = props.get(self.OPTIONS_KEY, {}) + if not isinstance(ns, dict): + ns = {} - for idx in range(limit): - if _canceled(): - break + source = ns.get("cti_files_source") + source = str(source).strip().lower() if source is not None else None - info = infos[idx] + ns_cti_files = ns.get("cti_files") + ns_cti_file = ns.get("cti_file") + legacy_cti_files = props.get("cti_files") + legacy_cti_file = props.get("cti_file") - 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 legacy_cti_files or legacy_cti_file: + self._cti_files_source_used = self._CTI_FILES_SOURCE_USER + candidates, diag = cti_finder.discover_cti_files( + cti_file=str(legacy_cti_file) if legacy_cti_file else None, + cti_files=cti_finder.cti_files_as_list(legacy_cti_files) if legacy_cti_files else None, + include_env=False, + must_exist=True, + ) + if not candidates: + raise RuntimeError( + "No valid GenTL producer (.cti) found from properties.cti_file/cti_files.\n\n" + f"Discovery details:\n{diag.summarize()}" + ) + return list(candidates) - 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, model, serial = str(vendor).strip(), str(model).strip(), str(serial).strip() - label = f"{vendor} {model}".strip() if (vendor or model) else f"GenTL device {idx}" - if serial: - label = f"{label} ({serial})" + if ns_cti_files or ns_cti_file: + is_auto_cache = source == self._CTI_FILES_SOURCE_AUTO + self._cti_files_source_used = self._CTI_FILES_SOURCE_AUTO if is_auto_cache else self._CTI_FILES_SOURCE_USER + candidates, diag = cti_finder.discover_cti_files( + cti_file=str(ns_cti_file) if ns_cti_file else None, + cti_files=cti_finder.cti_files_as_list(ns_cti_files) if ns_cti_files else None, + include_env=False, + must_exist=True, + ) + if candidates: + return list(candidates) + if not is_auto_cache: + raise RuntimeError( + "No valid GenTL producer (.cti) found from properties.gentl.cti_file/cti_files.\n\n" + f"Discovery details:\n{diag.summarize()}" + ) + LOG.info("Auto-persisted GenTL CTIs stale/missing; falling back to discovery.") - device_id = cls._device_id_from_info(info) + self._cti_files_source_used = self._CTI_FILES_SOURCE_AUTO + search_paths = ns.get("cti_search_paths", props.get("cti_search_paths")) + extra_dirs = ns.get("cti_dirs", props.get("cti_dirs")) + search_patterns = ( + cti_finder.cti_files_as_list(search_paths) if search_paths is not None else list(self._DEFAULT_CTI_PATTERNS) + ) - out.append( - DetectedCamera( - index=idx, - label=label, - device_id=device_id, - vid=None, - pid=None, - path=None, - backend_hint=None, - ) - ) + candidates, diag = cti_finder.discover_cti_files( + cti_search_paths=search_patterns, + include_env=True, + extra_dirs=cti_finder.cti_files_as_list(extra_dirs) if extra_dirs is not None else None, + recursive_env_search=False, + recursive_extra_search=False, + must_exist=True, + ) + if not candidates: + raise RuntimeError( + "Could not locate any GenTL producer (.cti) file.\n\n" + "Fix options:\n" + " - Set camera.properties.gentl.cti_file to the full path of a .cti file\n" + " - Or set GENICAM_GENTL64_PATH / GENICAM_GENTL32_PATH to include the producer directory\n" + " - Or provide camera.properties.gentl.cti_search_paths with glob patterns\n\n" + f"Discovery details:\n{diag.summarize(redact_env=False)}" + ) + return list(candidates) - if progress_cb: - progress_cb(f"Found: {label}") + # ------------------------------------------------------------------ + # Device selection / identity helpers + # ------------------------------------------------------------------ - out.sort(key=lambda c: c.index) - return out + def _select_device(self, infos: list) -> tuple[int, str | None, object]: + requested_index = int(self.settings.index or 0) + target_device_id = self._device_id or self._ensure_settings_ns().get("device_id") - except Exception: - return [] - finally: - if harvester is not None: - try: - harvester.reset() - except Exception: - pass + selected_index: int | None = None + selected_serial: str | None = None - @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). - - Strategy: - - If CTIs were persisted: - * if source == "auto" and they are stale -> fall back to discovery - * otherwise use them (best stability) - - Otherwise, fall back to env-var + pattern discovery (best-effort). - """ - if Harvester is None: - return settings + if target_device_id: + selected_index, selected_serial = self._match_device(infos, str(target_device_id).strip()) - props = settings.properties if isinstance(settings.properties, dict) else {} - ns = props.get(cls.OPTIONS_KEY, {}) - if not isinstance(ns, dict): - ns = {} + if selected_index is None and self._serial_number: + selected_index, selected_serial = self._match_device(infos, str(self._serial_number).strip()) - target_id = ns.get("device_id") or ns.get("serial_number") or ns.get("serial") - if not target_id: - return settings - # For serial-based GenTL devices, open() can select by serial directly. - # Avoid doing Harvester enumeration during CameraFactory.create(), because - # multi-camera startup calls create() concurrently from multiple threads. - target_id_str = str(target_id).strip() - if target_id_str.startswith("serial:"): - serial = target_id_str.split("serial:", 1)[1].strip() - if serial: - 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 - ns2["device_id"] = target_id_str - ns2["serial_number"] = serial - return settings + if selected_index is None: + if requested_index < 0 or requested_index >= len(infos): + raise RuntimeError(f"Camera index {requested_index} out of range for {len(infos)} GenTL device(s)") + selected_index = requested_index + serial = self._info_get(infos[selected_index], "serial_number", "") + selected_serial = str(serial).strip() if serial else None - source = ns.get("cti_files_source") - source = str(source).strip().lower() if source is not None else None - is_auto_cache = source == cls._CTI_FILES_SOURCE_AUTO + return int(selected_index), selected_serial, infos[int(selected_index)] - harvester = None - try: - explicit_files = ns.get("cti_files") or props.get("cti_files") - explicit_file = ns.get("cti_file") or props.get("cti_file") + @classmethod + def _match_device(cls, infos: list, target: str) -> tuple[int | None, str | None]: + if not target: + return None, None + + serial_target = target.split("serial:", 1)[1].strip() if target.startswith("serial:") else target + + for idx, info in enumerate(infos): + if cls._device_id_from_info(info) == target: + serial = cls._info_get(info, "serial_number", None) + return idx, str(serial).strip() if serial else None + + exact: list[tuple[int, str]] = [] + for idx, info in enumerate(infos): + sn = str(cls._info_get(info, "serial_number", "") or "").strip() + if sn == serial_target: + exact.append((idx, sn)) + if exact: + return exact[0] + + partial = [] + for idx, info in enumerate(infos): + sn = str(cls._info_get(info, "serial_number", "") or "").strip() + if serial_target and serial_target in sn: + partial.append((idx, sn)) + if len(partial) == 1: + return partial[0] + if len(partial) > 1: + raise RuntimeError( + f"Ambiguous GenTL serial match for '{serial_target}'. Candidates: {[sn for _, sn in partial]}" + ) - if explicit_files or explicit_file: - candidates, _diag = cti_finder.discover_cti_files( - cti_file=explicit_file, - cti_files=cti_finder.cti_files_as_list(explicit_files), - include_env=False, - must_exist=True, - ) + return None, None - if not candidates and is_auto_cache: - # Auto cache stale -> fallback to discovery - harvester, _loaded, _diag2 = cls._build_harvester_for_discovery(strict_single=False) - if harvester is None: - return settings - elif not candidates: - # User override stale or unknown -> no rebind - return settings - else: - harvester = Harvester() - loaded: list[str] = [] - for cti in candidates: - try: - harvester.add_file(cti) - loaded.append(cti) - except Exception: - continue - if not loaded: - cls._reset_select_harvester(harvester) - if is_auto_cache: - harvester, _loaded, _diag2 = cls._build_harvester_for_discovery(strict_single=False) - if harvester is None: - return settings - else: - return settings - else: - harvester.update() - else: - harvester, _loaded, _diag = cls._build_harvester_for_discovery(strict_single=False) - if harvester is None: - return settings + @staticmethod + def _device_id_from_info(info) -> str | None: + serial = GenTLCameraBackend._first_info_value( + info, + "serial_number", + "SerialNumber", + "device_serial_number", + "sn", + "serial", + ) + if serial: + return f"serial:{serial}" - infos = list(harvester.device_info_list or []) - if not infos: - return settings + parts = [] + for key, names in ( + ("vendor", ("vendor", "vendor_name", "manufacturer", "DeviceVendorName")), + ("model", ("model", "model_name", "DeviceModelName")), + ("user", ("user_defined_name", "user_id", "DeviceUserID", "DeviceUserId", "device_user_id")), + ("tl", ("tl_type", "transport_layer_type", "DeviceTLType")), + ("uid", ("id_", "id", "device_id", "uid", "guid", "mac_address", "interface_id", "display_name")), + ): + value = GenTLCameraBackend._first_info_value(info, *names) + if value: + parts.append(f"{key}={value}") + return "fp:" + "|".join(parts) if parts else None - target_id_str = str(target_id).strip() - match_index = None - match_serial = None + @staticmethod + def _first_info_value(info, *names: str) -> str | None: + for name in names: + value = GenTLCameraBackend._info_get(info, name, None) + if value is not None and str(value).strip(): + return str(value).strip() + return None - # 1) Exact match by computed device_id - 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 + @staticmethod + def _info_get(info, key: str, default=None): + try: + if hasattr(info, "get"): + value = info.get(key) + if value is not None: + return value + except Exception: + pass + try: + value = getattr(info, key, None) + if value is not None: + return value + except Exception: + pass + return default - # 2) Fallback: treat target as serial-ish substring - 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 + @staticmethod + def _label_from_info(info, index: int) -> str: + display = GenTLCameraBackend._info_get(info, "display_name", None) + if display: + return str(display).strip() - if match_index is None: - return settings + vendor = str(GenTLCameraBackend._info_get(info, "vendor", "") or "").strip() + model = str(GenTLCameraBackend._info_get(info, "model", "") or "").strip() + serial = str(GenTLCameraBackend._info_get(info, "serial_number", "") or "").strip() + label = f"{vendor} {model}".strip() if (vendor or model) else f"GenTL device {index}" + return f"{label} ({serial})" if serial else label - # Apply rebinding - settings.index = int(match_index) + @staticmethod + def _serial_from_identity(device_id: str | None, legacy_serial) -> str | None: + if device_id: + did = str(device_id).strip() + if did.startswith("serial:"): + return did.split("serial:", 1)[1].strip() or None + if not did.startswith("fp:"): + return did + return str(legacy_serial).strip() if legacy_serial else None - # Ensure namespace exists - 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 + @classmethod + def _persist_serial_identity(cls, settings, device_id: str) -> None: + serial = device_id.split("serial:", 1)[1].strip() + if not serial: + return + ns = cls._ensure_ns_for_settings(settings) + ns["device_id"] = device_id + ns["serial_number"] = serial - if match_serial: - ns2["serial_number"] = str(match_serial) - ns2["device_id"] = target_id_str + def _persist_device_metadata(self, selected_info, selected_serial: str | None) -> None: + ns = self._ensure_settings_ns() + computed_id = self._device_id_from_info(selected_info) - return settings + if computed_id: + ns["device_id"] = computed_id + elif selected_serial: + ns["device_id"] = f"serial:{selected_serial}" - except Exception: - return settings - finally: - if harvester is not None: - try: - harvester.reset() - except Exception: - pass + if selected_serial: + ns["serial_number"] = str(selected_serial) + ns["device_serial_number"] = str(selected_serial) - @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 + if self._device_label: + ns["device_name"] = str(self._device_label) + + for out_key, info_key in ( + ("device_display_name", "display_name"), + ("device_info_id", "id_"), + ("device_vendor", "vendor"), + ("device_model", "model"), + ("device_tl_type", "tl_type"), + ("device_user_defined_name", "user_defined_name"), + ("device_version", "version"), + ("device_access_status", "access_status"), + ): + value = self._info_get(selected_info, info_key, "") + ns[out_key] = value if out_key == "device_access_status" else str(value or "") - harvester = None - try: - harvester, _, _ = cls._build_harvester_for_discovery(strict_single=False) - if harvester is None: - return False - 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 + @classmethod + def _ensure_ns_for_settings(cls, settings) -> dict: + if not isinstance(settings.properties, dict): + settings.properties = {} + ns = settings.properties.get(cls.OPTIONS_KEY, {}) + if not isinstance(ns, dict): + ns = {} + settings.properties[cls.OPTIONS_KEY] = ns + return ns + + def _ensure_settings_ns(self) -> dict: + return self._ensure_ns_for_settings(self.settings) + + # ------------------------------------------------------------------ + # Existing compatibility helpers + # ------------------------------------------------------------------ def _call_with_optional_lock(self, func, *args, **kwargs): - """ - Call func under the shared Harvester lock if a shared entry exists. - In per-instance Harvester mode, call directly. - """ if self._shared_entry is not None: with self._shared_entry.lock: return func(*args, **kwargs) return func(*args, **kwargs) def _create_image_acquirer(self, selected_serial: str | None, selected_index: int): - """ - Create a Harvester ImageAcquirer using serial when available. - Supports both create(arg) and create(index=...) API variants. - """ if self._harvester is None: raise RuntimeError("Harvester is not initialized") - try: if selected_serial: return self._harvester.create({"serial_number": str(selected_serial)}) @@ -1184,355 +875,160 @@ def _create_image_acquirer(self, selected_serial: str | None, selected_index: in return self._harvester.create({"serial_number": str(selected_serial)}) return self._harvester.create(index=int(selected_index)) - def read(self) -> tuple[np.ndarray, float]: - if self._acquirer is None: - raise RuntimeError("GenTL image acquirer not initialised") - - try: - with self._acquirer.fetch(timeout=self._timeout) as buffer: - component = buffer.payload.components[0] - channels = 3 if self._pixel_format in {"RGB8", "BGR8"} else 1 - array = np.asarray(component.data) - expected = component.height * component.width * channels - if array.size != expected: - array = np.frombuffer(bytes(component.data), dtype=array.dtype) - try: - if channels > 1: - frame = array.reshape(component.height, component.width, channels).copy() - else: - frame = array.reshape(component.height, component.width).copy() - except ValueError: - frame = array.copy() - except HarvesterTimeoutError as exc: - raise TimeoutError(str(exc) + " (GenTL timeout)") from exc - - frame = self._convert_frame(frame) - timestamp = time.time() - - if self._actual_width is None or self._actual_height is None: - h, w = frame.shape[:2] - self._actual_width = int(w) - self._actual_height = int(h) - - if self._actual_exposure is None: - try: - self._actual_exposure = float(self._acquirer.remote_device.node_map.ExposureTime.value) - except Exception: - self._actual_exposure = None - - if self._actual_gain is None: - try: - self._actual_gain = float(self._acquirer.remote_device.node_map.Gain.value) - except Exception: - self._actual_gain = None - - return frame, timestamp - - def stop(self) -> None: - if self._acquirer is not None: - try: - self._call_with_optional_lock(self._acquirer.stop) - except Exception: - pass - - @staticmethod - def _reset_select_harvester(harvester) -> None: - if harvester is not None: - try: - harvester.reset() - except Exception: - pass - - def _reset_harvester(self) -> None: - try: - if self._shared_entry is not None: - LOG.debug( - "GenTL releasing shared Harvester harvester_id=%s refcount_before=%s", - id(self._shared_entry.harvester), - cti_finder.SharedHarvesterPool.get_refcount(self._shared_entry), - ) - cti_finder.SharedHarvesterPool.release(self._shared_entry) - self._shared_entry = None - else: - self._reset_select_harvester(self._harvester) - finally: - self._harvester = None - - def close(self) -> None: - if self._acquirer is not None: - try: - self._call_with_optional_lock(self._acquirer.stop) - except Exception: - pass - - try: - destroy = getattr(self._acquirer, "destroy", None) - if destroy is not None: - self._call_with_optional_lock(destroy) - finally: - self._acquirer = None - - if self._harvester is not None or self._shared_entry is not None: - self._reset_harvester() + def _available_serials(self) -> list[str]: + assert self._harvester is not None + return [ + str(s).strip() + for s in (self._info_get(i, "serial_number", "") for i in self._harvester.device_info_list) + if s + ] - self._device_label = None + def _create_acquirer(self, serial: str | None, index: int): + """Compatibility wrapper for older code/tests.""" + return self._create_image_acquirer(serial, index) # ------------------------------------------------------------------ - # Helpers + # Camera configuration helpers # ------------------------------------------------------------------ - def _parse_crop(self, crop) -> tuple[int, int, int, int] | None: - if isinstance(crop, (list, tuple)) and len(crop) == 4: - return tuple(int(v) for v in crop) - return None - - def _get_requested_resolution_or_none(self) -> tuple[int, int] | None: - """ - Return (w, h) if user explicitly requested a resolution. - Return None to keep device defaults. - """ - props = self.settings.properties if isinstance(self.settings.properties, dict) else {} - - 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 - + def _configure_pixel_format(self, node_map) -> None: 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 + if self._pixel_format in node_map.PixelFormat.symbolics: + node_map.PixelFormat.value = self._pixel_format + actual = node_map.PixelFormat.value + if actual != self._pixel_format: + LOG.warning("Pixel format mismatch: requested '%s', got '%s'", self._pixel_format, actual) + else: + LOG.warning( + "Pixel format '%s' not in available formats: %s", self._pixel_format, node_map.PixelFormat.symbolics + ) + except Exception as e: + LOG.warning("Failed to set pixel format '%s': %s", self._pixel_format, e) - return None + def _configure_trigger(self, node_map) -> None: + try: + trigger_mode = getattr(node_map, "TriggerMode", None) + if trigger_mode is not None and "Off" in getattr(trigger_mode, "symbolics", []): + trigger_mode.value = "Off" + except Exception as e: + LOG.warning("Failed to disable trigger mode: %s", e) def _configure_resolution(self, node_map) -> None: - """ - Configure camera resolution only if explicitly requested. - If None, keep device defaults. - """ - req = self._requested_resolution - if req is None: - LOG.info("Resolution: using device default.") + if self._requested_resolution is None: return - requested_width, requested_height = req + requested_width, requested_height = self._requested_resolution actual_width, actual_height = None, None - # Width try: node = node_map.Width - min_w, max_w = node.min, node.max - inc_w = getattr(node, "inc", 1) - width = self._adjust_to_increment(requested_width, min_w, max_w, inc_w) + width = self._adjust_to_increment(requested_width, node.min, node.max, getattr(node, "inc", 1)) node.value = int(width) - actual_width = node.value + actual_width = int(node.value) except Exception as e: - LOG.warning(f"Failed to set width: {e}") + LOG.warning("Failed to set width: %s", e) - # Height try: node = node_map.Height - min_h, max_h = node.min, node.max - inc_h = getattr(node, "inc", 1) - height = self._adjust_to_increment(requested_height, min_h, max_h, inc_h) + height = self._adjust_to_increment(requested_height, node.min, node.max, getattr(node, "inc", 1)) node.value = int(height) - actual_height = node.value + actual_height = int(node.value) except Exception as e: - LOG.warning(f"Failed to set height: {e}") + LOG.warning("Failed to set height: %s", e) if actual_width is not None and actual_height is not None: - self._actual_width = int(actual_width) - self._actual_height = int(actual_height) + self._actual_width = actual_width + self._actual_height = actual_height if (actual_width, actual_height) != (requested_width, 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}") - - def _available_serials(self) -> list[str]: - assert self._harvester is not None - serials: list[str] = [] - for info in self._harvester.device_info_list: - serial = getattr(info, "serial_number", "") - if serial: - serials.append(serial) - return serials - - def _create_acquirer(self, serial: str | None, index: int): - assert self._harvester is not None - methods = [ - getattr(self._harvester, "create", None), - getattr(self._harvester, "create_image_acquirer", None), - ] - methods = [m for m in methods if m is not None] - errors: list[str] = [] - device_info = None - if not serial: - device_list = self._harvester.device_info_list - if 0 <= index < len(device_list): - device_info = device_list[index] - for create in methods: - try: - if serial: - return create({"serial_number": serial}) - except Exception as exc: - errors.append(f"{create.__name__} serial: {exc}") - for create in methods: - try: - return create(index=index) - except TypeError: - try: - return create(index) - except Exception as exc: - errors.append(f"{create.__name__} index positional: {exc}") - except Exception as exc: - errors.append(f"{create.__name__} index: {exc}") - if device_info is not None: - for create in methods: - try: - return create(device_info) - except Exception as exc: - errors.append(f"{create.__name__} device_info: {exc}") - if not serial and index == 0: - for create in methods: - try: - return create() - except Exception as exc: - errors.append(f"{create.__name__} default: {exc}") - joined = "; ".join(errors) or "no creation methods available" - raise RuntimeError(f"Failed to initialise GenTL image acquirer ({joined})") - - def _configure_pixel_format(self, node_map) -> None: - try: - if self._pixel_format in node_map.PixelFormat.symbolics: - node_map.PixelFormat.value = self._pixel_format - actual = node_map.PixelFormat.value - if actual != self._pixel_format: - LOG.warning(f"Pixel format mismatch: requested '{self._pixel_format}', got '{actual}'") - else: - LOG.info(f"Pixel format set to '{actual}'") - else: - LOG.warning( - f"Pixel format '{self._pixel_format}' not in available formats: {node_map.PixelFormat.symbolics}" + "Resolution mismatch: requested %sx%s, got %sx%s", + requested_width, + requested_height, + actual_width, + actual_height, ) - except Exception as e: - LOG.warning(f"Failed to set pixel format '{self._pixel_format}': {e}") def _configure_exposure(self, node_map) -> None: if self._exposure is None: return - # Try to disable auto exposure first - for attr in ("ExposureAuto",): - try: - node = getattr(node_map, attr) - node.value = "Off" - LOG.info("Auto exposure disabled") - break - except AttributeError: - continue - except Exception as e: - LOG.warning(f"Failed to disable auto exposure: {e}") + try: + node_map.ExposureAuto.value = "Off" + except Exception: + pass - # Set exposure value for attr in ("ExposureTime", "Exposure"): try: node = getattr(node_map, attr) - except AttributeError: - continue - try: node.value = float(self._exposure) - actual = node.value - if abs(actual - self._exposure) > 1.0: # Allow 1μs tolerance - LOG.warning(f"Exposure mismatch: requested {self._exposure}μs, got {actual}μs") - else: - LOG.info(f"Exposure set to {actual}μs") return - except Exception as e: - LOG.warning(f"Failed to set exposure via {attr}: {e}") + except AttributeError: continue - - LOG.warning(f"Could not set exposure to {self._exposure}μs (no compatible attribute found)") + except Exception as e: + LOG.warning("Failed to set exposure via %s: %s", attr, e) + LOG.warning("Could not set exposure to %s µs", self._exposure) def _configure_gain(self, node_map) -> None: if self._gain is None: return - # Try to disable auto gain first - for attr in ("GainAuto",): - try: - node = getattr(node_map, attr) - node.value = "Off" - LOG.info("Auto gain disabled") - break - except AttributeError: - continue - except Exception as e: - LOG.warning(f"Failed to disable auto gain: {e}") - - # Set gain value - for attr in ("Gain",): - try: - node = getattr(node_map, attr) - except AttributeError: - continue - try: - node.value = float(self._gain) - actual = node.value - if abs(actual - self._gain) > 0.1: # Allow 0.1 tolerance - LOG.warning(f"Gain mismatch: requested {self._gain}, got {actual}") - else: - LOG.info(f"Gain set to {actual}") - return - except Exception as e: - LOG.warning(f"Failed to set gain via {attr}: {e}") - continue + try: + node_map.GainAuto.value = "Off" + except Exception: + pass - LOG.warning(f"Could not set gain to {self._gain} (no compatible attribute found)") + try: + node_map.Gain.value = float(self._gain) + except Exception as e: + LOG.warning("Could not set gain to %s: %s", self._gain, e) def _configure_frame_rate(self, node_map) -> None: if not self.settings.fps: return target = float(self.settings.fps) - - # Try to enable frame rate control for attr in ("AcquisitionFrameRateEnable", "AcquisitionFrameRateControlEnable"): try: getattr(node_map, attr).value = True - LOG.info(f"Frame rate control enabled via {attr}") break except Exception: - continue + pass - # Set frame rate value for attr in ("AcquisitionFrameRate", "ResultingFrameRate", "AcquisitionFrameRateAbs"): try: - node = getattr(node_map, attr) + getattr(node_map, attr).value = target + return except AttributeError: continue - try: - node.value = target - actual = node.value - if abs(actual - target) > 0.1: - LOG.warning(f"FPS mismatch: requested {target:.2f}, got {actual:.2f}") - else: - LOG.info(f"Frame rate set to {actual:.2f} FPS") - return except Exception as e: - LOG.warning(f"Failed to set frame rate via {attr}: {e}") - continue + LOG.warning("Failed to set frame rate via %s: %s", attr, e) + LOG.warning("Could not set frame rate to %s FPS", target) + + def _read_telemetry(self, node_map) -> None: + try: + self._actual_width = int(node_map.Width.value) + self._actual_height = int(node_map.Height.value) + except Exception: + pass + + try: + self._actual_fps = float(node_map.ResultingFrameRate.value) + except Exception: + self._actual_fps = None - LOG.warning(f"Could not set frame rate to {target} FPS (no compatible attribute found)") + try: + self._actual_exposure = float(node_map.ExposureTime.value) + except Exception: + self._actual_exposure = None + + try: + self._actual_gain = float(node_map.Gain.value) + except Exception: + self._actual_gain = None + + # ------------------------------------------------------------------ + # Frame conversion / local helpers + # ------------------------------------------------------------------ def _convert_frame(self, frame: np.ndarray) -> np.ndarray: if frame.dtype != np.uint8: @@ -1551,9 +1047,7 @@ def _convert_frame(self, frame: np.ndarray) -> np.ndarray: left = max(0, left) bottom = bottom if bottom > 0 else frame.shape[0] right = right if right > 0 else frame.shape[1] - bottom = min(frame.shape[0], bottom) - right = min(frame.shape[1], right) - frame = frame[top:bottom, left:right] + frame = frame[top : min(frame.shape[0], bottom), left : min(frame.shape[1], right)] if self._rotate in (90, 180, 270): rotations = { @@ -1566,11 +1060,10 @@ def _convert_frame(self, frame: np.ndarray) -> np.ndarray: return frame.copy() def _resolve_device_label(self, node_map) -> str | None: - candidates = [ + for name_attr, serial_attr in ( ("DeviceModelName", "DeviceSerialNumber"), ("DeviceDisplayName", "DeviceSerialNumber"), - ] - for name_attr, serial_attr in candidates: + ): try: model = getattr(node_map, name_attr).value except AttributeError: @@ -1581,18 +1074,48 @@ def _resolve_device_label(self, node_map) -> str | None: except AttributeError: pass if model: - model_str = str(model) - serial_str = str(serial) if serial else None - return f"{model_str} ({serial_str})" if serial_str else model_str + return f"{model} ({serial})" if serial else str(model) + return None + + def _parse_crop(self, crop) -> tuple[int, int, int, int] | None: + if isinstance(crop, (list, tuple)) and len(crop) == 4: + return tuple(int(v) for v in crop) + return None + + def _get_requested_resolution_or_none(self) -> tuple[int, int] | None: + props = self.settings.properties if isinstance(self.settings.properties, dict) else {} + 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: + 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 None - def _adjust_to_increment(self, value: int, minimum: int, maximum: int, increment: int) -> int: + @staticmethod + def _adjust_to_increment(value: int, minimum: int, maximum: int, increment: int) -> int: value = max(minimum, min(maximum, int(value))) if increment <= 0: return value - offset = value - minimum - steps = offset // increment - return minimum + steps * increment + return minimum + ((value - minimum) // increment) * increment + + @staticmethod + def _positive_float(value) -> float | None: + try: + number = float(value) + return number if number > 0 else None + except Exception: + return None def device_name(self) -> str: if self._device_label: From b2e53dc030fa97e79ac26879ec89d6e49dcfb16d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 11:40:40 +0200 Subject: [PATCH 005/133] Handle GenTL open errors & improve device matching Wrap harvester acquisition and camera initialization in a try/except to ensure resources are cleaned up on failure and to raise a clearer RuntimeError that includes loaded/failed CTIs and the original exception. Improve device selection logic: when a target_device_id or explicit serial is provided but not found, raise a descriptive error listing available serials; otherwise keep index-based selection and validate index range. --- dlclivegui/cameras/backends/gentl_backend.py | 102 +++++++++++-------- 1 file changed, 61 insertions(+), 41 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 8a06b2d68..728577a3d 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -385,42 +385,54 @@ def open(self) -> None: with type(self)._OPEN_LOCK: loaded, failed = self._resolve_and_persist_ctis() - infos = self._acquire_shared_harvester(loaded) - if not infos: - self._reset_harvester() - raise RuntimeError( - "No GenTL cameras detected via Harvesters after loading producers.\n\n" - f"Loaded CTIs: {loaded}\n" - f"Failed CTIs: {failed}\n" - "Fix: ensure your camera vendor's GenTL producer is installed and working." - ) - - selected_index, selected_serial, selected_info = self._select_device(infos) - self.settings.index = int(selected_index) - - with self._shared_entry.lock: - self._acquirer = self._create_image_acquirer(selected_serial, int(selected_index)) - node_map = self._acquirer.remote_device.node_map - self._device_label = self._resolve_device_label(node_map) - - self._configure_pixel_format(node_map) - self._configure_trigger(node_map) - self._configure_resolution(node_map) - self._configure_exposure(node_map) - self._configure_gain(node_map) - self._configure_frame_rate(node_map) - self._read_telemetry(node_map) - self._persist_device_metadata(selected_info, selected_serial) - - if self._fast_start: - LOG.info("GenTL open() in fast_start probe mode: acquisition not started.") - return - - self._acquirer.start() + try: + infos = self._acquire_shared_harvester(loaded) + if not infos: + self._reset_harvester() + raise RuntimeError( + "No GenTL cameras detected via Harvesters after loading producers.\n\n" + f"Loaded CTIs: {loaded}\n" + f"Failed CTIs: {failed}\n" + "Fix: ensure your camera vendor's GenTL producer is installed and working." + ) - LOG.debug( - "Opened GenTL camera index=%s serial=%s label=%s", selected_index, selected_serial, self._device_label - ) + selected_index, selected_serial, selected_info = self._select_device(infos) + self.settings.index = int(selected_index) + + with self._shared_entry.lock: + self._acquirer = self._create_image_acquirer(selected_serial, int(selected_index)) + node_map = self._acquirer.remote_device.node_map + self._device_label = self._resolve_device_label(node_map) + + self._configure_pixel_format(node_map) + self._configure_trigger(node_map) + self._configure_resolution(node_map) + self._configure_exposure(node_map) + self._configure_gain(node_map) + self._configure_frame_rate(node_map) + self._read_telemetry(node_map) + self._persist_device_metadata(selected_info, selected_serial) + + if self._fast_start: + LOG.info("GenTL open() in fast_start probe mode: acquisition not started.") + return + + self._acquirer.start() + + LOG.debug( + "Opened GenTL camera index=%s serial=%s label=%s", + selected_index, + selected_serial, + self._device_label, + ) + except Exception as exc: + try: + self.close() + except Exception: + pass + raise RuntimeError( + f"Failed to open GenTL camera.\n\nLoaded CTIs: {loaded}\nFailed CTIs: {failed}\nReason: {exc}" + ) from exc def read(self) -> tuple[np.ndarray, float]: if self._acquirer is None: @@ -681,12 +693,20 @@ def _select_device(self, infos: list) -> tuple[int, str | None, object]: selected_serial: str | None = None if target_device_id: - selected_index, selected_serial = self._match_device(infos, str(target_device_id).strip()) - - if selected_index is None and self._serial_number: - selected_index, selected_serial = self._match_device(infos, str(self._serial_number).strip()) - - if selected_index is None: + target = str(target_device_id).strip() + selected_index, selected_serial = self._match_device(infos, target) + if selected_index is None: + available = [str(self._info_get(i, "serial_number", "") or "").strip() for i in infos] + raise RuntimeError(f"GenTL device '{target}' not found. Available serials: {available}") + + elif self._serial_number: + serial = str(self._serial_number).strip() + selected_index, selected_serial = self._match_device(infos, serial) + if selected_index is None: + available = [str(self._info_get(i, "serial_number", "") or "").strip() for i in infos] + raise RuntimeError(f"GenTL camera with serial '{serial}' not found. Available serials: {available}") + + else: if requested_index < 0 or requested_index >= len(infos): raise RuntimeError(f"Camera index {requested_index} out of range for {len(infos)} GenTL device(s)") selected_index = requested_index From ab2598e77944ea3d4cbba5a67ab22a4ec473ec1d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 11:48:37 +0200 Subject: [PATCH 006/133] Improve GenTL pixel format selection and conversion Add explicit lists of color and mono GenTL pixel formats and default pixel_format to "auto" with normalization. Rewrite _configure_pixel_format to handle missing PixelFormat node, choose a suitable format when "auto" is requested (prefer color formats, then mono, then first available), warn and fallback when a requested format is unavailable, and persist the selected format. Update frame postprocessing to use the normalized pixel format for proper Bayer demosaicing (BayerRG/GB/GR/BG) and correct RGB->BGR conversion while leaving BGR8 native. Minor logging message tweaks and added defensive checks to improve robustness. --- dlclivegui/cameras/backends/gentl_backend.py | 94 +++++++++++++++++--- 1 file changed, 81 insertions(+), 13 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 728577a3d..8b52e49ed 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -54,6 +54,20 @@ class GenTLCameraBackend(CameraBackend): r"C:\Program Files\The Imaging Source Europe GmbH\TIS Camera SDK\bin\win64_x64\*.cti", r"C:\Program Files (x86)\The Imaging Source Europe GmbH\TIS Grabber\bin\win64_x64\*.cti", ) + _COLOR_PIXEL_FORMATS: ClassVar[tuple[str, ...]] = ( + "BGR8", + "RGB8", + "BayerRG8", + "BayerGB8", + "BayerGR8", + "BayerBG8", + ) + _MONO_PIXEL_FORMATS: ClassVar[tuple[str, ...]] = ( + "Mono8", + "Mono10", + "Mono12", + "Mono16", + ) # Source marker stored in properties["gentl"]["cti_files_source"]. # auto: persisted by auto-discovery; may be stale and can fall back. @@ -77,7 +91,8 @@ def __init__(self, settings): self._device_id: str | None = str(raw_device_id).strip() if raw_device_id else None self._serial_number: str | None = self._serial_from_identity(self._device_id, legacy_serial) - self._pixel_format: str = ns.get("pixel_format") or props.get("pixel_format", "Mono8") + self._pixel_format: str = ns.get("pixel_format") or props.get("pixel_format", "auto") + self._pixel_format = str(self._pixel_format).strip() 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"))) @@ -913,17 +928,56 @@ def _create_acquirer(self, serial: str | None, index: int): def _configure_pixel_format(self, node_map) -> None: try: - if self._pixel_format in node_map.PixelFormat.symbolics: - node_map.PixelFormat.value = self._pixel_format - actual = node_map.PixelFormat.value - if actual != self._pixel_format: - LOG.warning("Pixel format mismatch: requested '%s', got '%s'", self._pixel_format, actual) + pixel_format_node = getattr(node_map, "PixelFormat", None) + if pixel_format_node is None: + return + + available = list(getattr(pixel_format_node, "symbolics", []) or []) + if not available: + return + + requested = str(self._pixel_format or "auto").strip() + + if requested.lower() == "auto": + selected = None + + for fmt in self._COLOR_PIXEL_FORMATS: + if fmt in available: + selected = fmt + break + + if selected is None: + for fmt in self._MONO_PIXEL_FORMATS: + if fmt in available: + selected = fmt + break + + if selected is None: + selected = available[0] + else: - LOG.warning( - "Pixel format '%s' not in available formats: %s", self._pixel_format, node_map.PixelFormat.symbolics - ) + selected = requested + if selected not in available: + LOG.warning( + "Pixel format '%s' not available. Available formats: %s. Falling back to auto.", + selected, + available, + ) + selected = None + for fmt in self._COLOR_PIXEL_FORMATS + self._MONO_PIXEL_FORMATS: + if fmt in available: + selected = fmt + break + if selected is None: + selected = available[0] + + pixel_format_node.value = selected + self._pixel_format = str(pixel_format_node.value) + + LOG.debug("GenTL pixel format selected: %s", self._pixel_format) + except Exception as e: - LOG.warning("Failed to set pixel format '%s': %s", self._pixel_format, e) + LOG.warning("Failed to configure pixel format '%s': %s", self._pixel_format, e) def _configure_trigger(self, node_map) -> None: try: @@ -1056,10 +1110,24 @@ def _convert_frame(self, frame: np.ndarray) -> np.ndarray: scale = 255.0 / max_val if max_val > 0.0 else 1.0 frame = np.clip(frame * scale, 0, 255).astype(np.uint8) + fmt = str(self._pixel_format or "").strip() + if frame.ndim == 2: - frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) - elif frame.ndim == 3 and frame.shape[2] == 3 and self._pixel_format == "RGB8": - frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) + if fmt == "BayerRG8": + frame = cv2.cvtColor(frame, cv2.COLOR_BayerRG2BGR) + elif fmt == "BayerGB8": + frame = cv2.cvtColor(frame, cv2.COLOR_BayerGB2BGR) + elif fmt == "BayerGR8": + frame = cv2.cvtColor(frame, cv2.COLOR_BayerGR2BGR) + elif fmt == "BayerBG8": + frame = cv2.cvtColor(frame, cv2.COLOR_BayerBG2BGR) + else: + frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) + + elif frame.ndim == 3 and frame.shape[2] == 3: + if fmt == "RGB8": + frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) + # BGR8 is already OpenCV-native. if self._crop is not None: top, bottom, left, right = (int(v) for v in self._crop) From 95c864716690171e5e108752eedd2a4145fcb90a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 12:00:52 +0200 Subject: [PATCH 007/133] Expose shared GenTL harvester diagnostics Capture and surface CTI load diagnostics when acquiring the shared GenTL Harvester: record loaded and failed CTI files from the shared entry and from acquire-time exceptions, and slightly reformat the open() error message. Add a FakeSharedHarvesterPool test double (with FakeSharedEntry and custom acquire/release/refcount behavior) and integrate it into the test fixture patch_gentl_sdk so tests can exercise shared-harvester reuse, update counting and failure release semantics. Also adjust FakeImageAcquirer to clear its queue on start and to synthesize payloads when the queue is empty, wrap FakeHarvester.update to track update calls, and update tests to reflect new rebind/open behavior and to add coverage for shared harvester reuse and error propagation. --- dlclivegui/cameras/backends/gentl_backend.py | 20 +- tests/cameras/backends/conftest.py | 180 ++++++++++++++- tests/cameras/backends/test_gentl_backend.py | 231 +++++++++++-------- 3 files changed, 319 insertions(+), 112 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 8b52e49ed..0952dac31 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -563,23 +563,41 @@ def _acquire_shared_harvester(self, loaded: list[str]) -> list: try: self._shared_entry = cti_finder.SharedHarvesterPool.acquire(loaded) self._harvester = self._shared_entry.harvester + + actual_loaded = list(getattr(self._shared_entry, "loaded_files", loaded)) + actual_failed = list(getattr(self._shared_entry, "failed_files", [])) + + ns["cti_files_loaded"] = actual_loaded + if actual_failed: + ns["cti_files_failed"] = [{"cti": str(cti), "error": str(error)} for cti, error in actual_failed] + with self._shared_entry.lock: infos = list(self._harvester.device_info_list or []) - ns["cti_files_loaded"] = list(getattr(self._shared_entry, "loaded_files", loaded)) + LOG.debug( "Using shared GenTL Harvester for %d device(s), refcount=%s", len(infos), cti_finder.SharedHarvesterPool.get_refcount(self._shared_entry), ) return infos + except Exception as exc: + exc_loaded = list(getattr(exc, "loaded_files", [])) + exc_failed = list(getattr(exc, "failed_files", [])) + + if exc_loaded or exc_failed: + ns["cti_files_loaded"] = [str(p) for p in exc_loaded] + ns["cti_files_failed"] = [{"cti": str(cti), "error": str(error)} for cti, error in exc_failed] + if self._shared_entry is not None: try: cti_finder.SharedHarvesterPool.release(self._shared_entry) except Exception: pass + self._shared_entry = None self._harvester = None + raise RuntimeError( f"Failed to initialize shared GenTL producer state.\n\nCTIs: {loaded}\nReason: {exc}" ) from exc diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index 2d965a2e0..8b6d08fa6 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -4,6 +4,7 @@ import importlib import logging import os +import threading from dataclasses import dataclass from typing import Any @@ -641,6 +642,122 @@ def __exit__(self, exc_type, exc, tb): return False +class FakeSharedHarvesterPoolAcquireError(RuntimeError): + """Raised by the fake shared pool when no CTI can be loaded.""" + + def __init__(self, message: str, *, loaded_files=None, failed_files=None): + super().__init__(message) + self.loaded_files = list(loaded_files or []) + self.failed_files = list(failed_files or []) + + +class FakeSharedEntry: + def __init__(self, harvester, loaded_files, failed_files=None): + self.harvester = harvester + self.loaded_files = list(loaded_files or []) + self.failed_files = list(failed_files or []) + self.lock = threading.RLock() + + +class FakeSharedHarvesterPool: + """ + Test double for cti_finder.SharedHarvesterPool. + + Important behavior: + - Reuses one Harvester per normalized CTI set. + - Calls update() only when creating the shared Harvester. + - Does not call update() when reusing an existing shared Harvester. + - Tracks loaded_files/failed_files so backend diagnostics can be tested. + """ + + _entries: dict[tuple[str, ...], FakeSharedEntry] = {} + _refcounts: dict[tuple[str, ...], int] = {} + _harvester_factory = None + + @classmethod + def configure(cls, harvester_factory): + cls.reset() + cls._harvester_factory = harvester_factory + + @staticmethod + def _key(cti_files) -> tuple[str, ...]: + # Stable across case/path spelling on Windows while preserving loaded_files separately. + return tuple(os.path.normcase(os.path.abspath(str(p))) for p in cti_files) + + @classmethod + def acquire(cls, cti_files): + key = cls._key(cti_files) + + if key in cls._entries: + cls._refcounts[key] += 1 + return cls._entries[key] + + if cls._harvester_factory is None: + raise RuntimeError("FakeSharedHarvesterPool is not configured") + + h = cls._harvester_factory() + + loaded: list[str] = [] + failed: list[tuple[str, str]] = [] + + for cti in cti_files: + cti_str = str(cti) + try: + h.add_file(cti_str) + loaded.append(cti_str) + except Exception as exc: + failed.append((cti_str, str(exc))) + + if not loaded: + try: + h.reset() + except Exception: + pass + raise FakeSharedHarvesterPoolAcquireError( + "No fake CTIs could be loaded", + loaded_files=[], + failed_files=failed, + ) + + h.update() + + entry = FakeSharedEntry(h, loaded_files=loaded, failed_files=failed) + cls._entries[key] = entry + cls._refcounts[key] = 1 + return entry + + @classmethod + def release(cls, entry): + for key, value in list(cls._entries.items()): + if value is entry: + cls._refcounts[key] -= 1 + if cls._refcounts[key] <= 0: + try: + entry.harvester.reset() + except Exception: + pass + del cls._entries[key] + del cls._refcounts[key] + return + + @classmethod + def get_refcount(cls, entry): + for key, value in cls._entries.items(): + if value is entry: + return cls._refcounts[key] + return 0 + + @classmethod + def reset(cls): + for entry in list(cls._entries.values()): + try: + entry.harvester.reset() + except Exception: + pass + cls._entries.clear() + cls._refcounts.clear() + + @dataclass class FakeImageAcquirer: """ @@ -691,6 +808,7 @@ def _enqueue_default_frame(self): def start(self): self.start_calls += 1 self._started = True + self._queue.clear() def stop(self): self.stop_calls += 1 @@ -707,10 +825,28 @@ def fetch(self, timeout: float = 2.0): if not self._started: raise FakeGenTLTimeoutException("fetch called while not started") - if not self._queue: - raise FakeGenTLTimeoutException(f"timeout after {timeout}s") + if self._queue: + payload = self._queue.pop(0) + else: + # Generate from the current node map, because backend may have changed + # PixelFormat/Width/Height during open(). + pf = str(self.node_map.PixelFormat.value or "Mono8") + if pf in ("RGB8", "BGR8"): + channels, dtype = 3, np.uint8 + elif pf in ("Mono16", "Mono12", "Mono10"): + channels, dtype = 1, np.uint16 + else: + # Mono8 and Bayer*8 are single-channel uint8 + channels, dtype = 1, np.uint8 + + comp = _FakeComponent( + int(self.node_map.Width.value), + int(self.node_map.Height.value), + channels, + dtype=dtype, + ) + payload = _FakePayload(comp) - payload = self._queue.pop(0) return _FakeFetchedBufferCtx(payload) @@ -854,18 +990,37 @@ def gentl_fail_add_file_for(): def patch_gentl_sdk(monkeypatch, fake_harvester_factory, gentl_fail_add_file_for, tmp_path): """ Patch dlclivegui.cameras.backends.gentl_backend to use FakeHarvester + Fake timeout. - Ensure CTI discovery succeeds for classmethods by creating a real dummy .cti and - exposing it via GENICAM_GENTL64_PATH. + + Important: + The production backend now uses cti_finder.SharedHarvesterPool.acquire() + during open(), so tests must patch that pool too. """ import dlclivegui.cameras.backends.gentl_backend as gb - # Patch Harvester symbol (the backend calls Harvester() directly) + # Reset and expose test counters/state. + gb.update_count = 0 + gb.fail_add_file_for = gentl_fail_add_file_for + + # Patch Harvester symbol for discovery/rebind paths. monkeypatch.setattr(gb, "Harvester", lambda: fake_harvester_factory(), raising=False) - # Keep timeout contract + # Count all fake update() calls. + original_update = FakeHarvester.update + + def update_with_count(self): + gb.update_count += 1 + return original_update(self) + + monkeypatch.setattr(FakeHarvester, "update", update_with_count, raising=True) + + # Keep timeout contract. monkeypatch.setattr(gb, "HarvesterTimeoutError", FakeGenTLTimeoutException, raising=False) - # Create a real CTI file and advertise it via env var + # Patch the shared pool used by open(). + FakeSharedHarvesterPool.configure(fake_harvester_factory) + monkeypatch.setattr(gb.cti_finder, "SharedHarvesterPool", FakeSharedHarvesterPool, raising=False) + + # Create a real CTI file and advertise it via env var. cti_file = tmp_path / "dummy.cti" if not cti_file.exists(): cti_file.write_text("fake", encoding="utf-8") @@ -873,10 +1028,11 @@ def patch_gentl_sdk(monkeypatch, fake_harvester_factory, gentl_fail_add_file_for monkeypatch.setenv("GENICAM_GENTL64_PATH", str(tmp_path)) monkeypatch.delenv("GENICAM_GENTL32_PATH", raising=False) - # OPTIONAL: expose failure control so tests can do gb.fail_add_file_for.add(...) - gb.fail_add_file_for = gentl_fail_add_file_for - - return gb + try: + yield gb + finally: + FakeSharedHarvesterPool.reset() + gb.fail_add_file_for = set() @pytest.fixture() diff --git a/tests/cameras/backends/test_gentl_backend.py b/tests/cameras/backends/test_gentl_backend.py index ecef1b7af..3ffdab204 100644 --- a/tests/cameras/backends/test_gentl_backend.py +++ b/tests/cameras/backends/test_gentl_backend.py @@ -318,11 +318,12 @@ def test_quick_ping_true_for_existing_false_for_missing(patch_gentl_sdk, gentl_i assert gb.GenTLCameraBackend.quick_ping(1) is False -def test_rebind_settings_updates_index_using_device_id_with_attribute_entries( +def test_rebind_settings_serial_device_id_persists_serial_without_enumeration( patch_gentl_sdk, gentl_settings_factory, gentl_inventory ): """ - rebind_settings has some getattr(...) usage; feed attribute-like entries to match that path. + Serial GenTL IDs are handled directly by open(), so rebind_settings() + intentionally avoids Harvester enumeration and leaves index unchanged. """ gb = patch_gentl_sdk @@ -338,9 +339,12 @@ def test_rebind_settings_updates_index_using_device_id_with_attribute_entries( settings = gentl_settings_factory(index=0, properties={"gentl": {"device_id": "serial:SER1"}}) out = gb.GenTLCameraBackend.rebind_settings(settings) - assert int(out.index) == 1 + # New behavior: no enumeration/rebind for serial IDs. + assert int(out.index) == 0 + ns = out.properties.get("gentl", {}) assert ns.get("device_id") == "serial:SER1" + assert ns.get("serial_number") == "SER1" def test_rebind_settings_no_device_id_no_change(patch_gentl_sdk, gentl_settings_factory, gentl_inventory): @@ -470,102 +474,6 @@ def create(self, *args, **kwargs): 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 - - # ---------------------------------- # CTI discovery and selection logic # ---------------------------------- @@ -767,3 +675,128 @@ def test_open_persists_cti_load_diagnostics_complete_failure(patch_gentl_sdk, ge assert sorted(d["cti"] for d in failed) == sorted([str(b1), str(b2)]) for d in failed: assert isinstance(d.get("error"), str) and d["error"] + + +def test_two_gentl_backends_share_same_harvester(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"}, + ] + + s0 = gentl_settings_factory(index=0, properties={"gentl": {"device_id": "serial:SER0"}}) + s1 = gentl_settings_factory(index=1, properties={"gentl": {"device_id": "serial:SER1"}}) + + b0 = gb.GenTLCameraBackend(s0) + b1 = gb.GenTLCameraBackend(s1) + + b0.open() + b1.open() + + assert b0._harvester is b1._harvester + assert b0._shared_entry is b1._shared_entry + + b1.close() + b0.close() + + +def test_second_open_reuses_shared_harvester_without_update(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"}, + ] + + s0 = gentl_settings_factory(index=0, properties={"gentl": {"device_id": "serial:SER0"}}) + s1 = gentl_settings_factory(index=1, properties={"gentl": {"device_id": "serial:SER1"}}) + + b0 = gb.GenTLCameraBackend(s0) + b1 = gb.GenTLCameraBackend(s1) + + b0.open() + update_count_after_first = gb.update_count + + b1.open() + + assert gb.update_count == update_count_after_first + + b1.close() + b0.close() + + +def test_open_failure_releases_shared_harvester(patch_gentl_sdk, gentl_settings_factory, gentl_inventory): + gb = patch_gentl_sdk + + gentl_inventory[:] = [{"display_name": "Dev0", "serial_number": "SER0"}] + + settings = gentl_settings_factory(properties={"gentl": {"device_id": "serial:DOES_NOT_EXIST"}}) + be = gb.GenTLCameraBackend(settings) + + with pytest.raises(RuntimeError): + be.open() + + assert be._harvester is None + assert be._shared_entry is None + assert be._acquirer is None + + +def test__create_acquirer_serial_create_runtimeerror_propagates(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + be = gb.GenTLCameraBackend(gentl_settings_factory()) + + class H: + device_info_list = [{"serial_number": "SER0"}] + + def create(self, *args, **kwargs): + raise RuntimeError("create fails") + + def create_image_acquirer(self, *args, **kwargs): + return "SHOULD_NOT_BE_USED" + + be._harvester = H() + + with pytest.raises(RuntimeError, match="create fails"): + be._create_acquirer("SERX", 1) + + +def test__create_acquirer_index_runtimeerror_propagates(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + be = gb.GenTLCameraBackend(gentl_settings_factory()) + + class H: + device_info_list = [{"serial_number": "SER0"}] + + def create(self, *args, **kwargs): + if len(args) == 1 and args[0] == 0: + raise RuntimeError("index path fails") + return "UNEXPECTED" + + be._harvester = H() + + with pytest.raises(RuntimeError, match="index path fails"): + be._create_acquirer(None, 0) + + +def test__create_acquirer_positional_typeerror_falls_back_to_index_keyword(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + be = gb.GenTLCameraBackend(gentl_settings_factory()) + + class H: + device_info_list = [{"serial_number": "SER0"}] + + def create(self, *args, **kwargs): + if args: + raise TypeError("positional index not supported") + if kwargs.get("index") == 2: + return "ACQ_KW_INDEX" + raise RuntimeError("unexpected call") + + be._harvester = H() + + acq = be._create_acquirer(None, 2) + assert acq == "ACQ_KW_INDEX" From f2d0cc765c0ad0e1a2fc4803a08fd73229aca833 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 14:14:11 +0200 Subject: [PATCH 008/133] Handle CTI load failures and track failed files Treat failed CTI loads as a mapping of CTI->error and record failures when initializing the shared Harvester. gentl_discovery.py now logs exceptions, captures failed_files as a dict, and raises a runtime error if no CTI producers were successfully loaded; it also attaches loaded_files and failed_files to raised exceptions and attempts to reset the harvester. gentl_backend.py updated to consume failed_files as a dict (using .items()) when building reporting data. Added a helper to reset the harvester and propagate context for callers. --- dlclivegui/cameras/backends/gentl_backend.py | 10 ++++--- .../cameras/backends/utils/gentl_discovery.py | 30 +++++++++++++++++-- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 0952dac31..1bcce321b 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -565,11 +565,13 @@ def _acquire_shared_harvester(self, loaded: list[str]) -> list: self._harvester = self._shared_entry.harvester actual_loaded = list(getattr(self._shared_entry, "loaded_files", loaded)) - actual_failed = list(getattr(self._shared_entry, "failed_files", [])) + actual_failed = dict(getattr(self._shared_entry, "failed_files", {})) ns["cti_files_loaded"] = actual_loaded if actual_failed: - ns["cti_files_failed"] = [{"cti": str(cti), "error": str(error)} for cti, error in actual_failed] + ns["cti_files_failed"] = [ + {"cti": str(cti), "error": str(error)} for cti, error in actual_failed.items() + ] with self._shared_entry.lock: infos = list(self._harvester.device_info_list or []) @@ -583,11 +585,11 @@ def _acquire_shared_harvester(self, loaded: list[str]) -> list: except Exception as exc: exc_loaded = list(getattr(exc, "loaded_files", [])) - exc_failed = list(getattr(exc, "failed_files", [])) + exc_failed = dict(getattr(exc, "failed_files", {})) if exc_loaded or exc_failed: ns["cti_files_loaded"] = [str(p) for p in exc_loaded] - ns["cti_files_failed"] = [{"cti": str(cti), "error": str(error)} for cti, error in exc_failed] + ns["cti_files_failed"] = [{"cti": str(cti), "error": str(error)} for cti, error in exc_failed.items()] if self._shared_entry is not None: try: diff --git a/dlclivegui/cameras/backends/utils/gentl_discovery.py b/dlclivegui/cameras/backends/utils/gentl_discovery.py index 9cc24dcb2..9d15a829c 100644 --- a/dlclivegui/cameras/backends/utils/gentl_discovery.py +++ b/dlclivegui/cameras/backends/utils/gentl_discovery.py @@ -5,6 +5,7 @@ from __future__ import annotations import glob +import logging import os import threading from collections.abc import Iterable, Sequence @@ -24,6 +25,8 @@ class GenTLDiscoveryPolicy(Enum): except Exception: # pragma: no cover - optional dependency Harvester = None # type: ignore +logger = logging.getLogger(__name__) + class SharedHarvesterEntry: """ @@ -41,13 +44,34 @@ def __init__(self, cti_files: list[str]): self.refcount = 0 self.harvester = Harvester() self.loaded_files: list[str] = [] + self.failed_files: dict[str, str] = {} for cti in self.key: - self.harvester.add_file(cti) - self.loaded_files.append(cti) + try: + self.harvester.add_file(cti) + self.loaded_files.append(cti) + except Exception as e: + logger.exception(f"Failed to load CTI file: {cti}. Skipping.") + self.failed_files[cti] = str(e) + + if not self.loaded_files: + e = RuntimeError("No GenTL producer (.cti) could be loaded by shared Harvester.") + self._raise_and_reset_harvester(e) # Initial device enumeration. - self.harvester.update() + try: + self.harvester.update() + except Exception as e: + self._raise_and_reset_harvester(e) + + def _raise_and_reset_harvester(self, exc: Exception) -> None: + exc.loaded_files = self.loaded_files[:] + exc.failed_files = dict(self.failed_files) + try: + self.harvester.reset() + except Exception: + pass + raise exc class SharedHarvesterPool: From 02bcbb9ebe2fe95625f6f9f98740151b28b82b81 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 14:16:16 +0200 Subject: [PATCH 009/133] Improve duplicate camera detection and reporting Change seen from a set to a mapping of identity key -> camera_id to record which camera produced each key. Add get_camera_id and fallback to camera_id if camera_identity_key raises, logging the exception. Emit a more informative initialization_failed message that includes the camera_id and the conflicting camera, improving diagnostics and robustness when computing identity keys. --- .../services/multi_camera_controller.py | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 7d5b56693..7058d59ac 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -185,13 +185,30 @@ def start(self, camera_settings: list[CameraSettings]) -> None: return # Check for dupes - seen = set() + seen = {} for s in active_settings: - key = camera_identity_key(s) + camera_id = get_camera_id(s) + try: + key = camera_identity_key(s) + except Exception: + LOGGER.exception( + "Failed to compute camera identity key for %s; falling back to camera_id", + camera_id, + ) + key = camera_id + if key in seen: - self.initialization_failed.emit([(key, "Duplicate camera configuration")]) + self.initialization_failed.emit( + [ + ( + camera_id, + f"Duplicate camera configuration. Conflicts with {seen[key]}", + ) + ] + ) return - seen.add(key) + + seen[key] = camera_id self._running = True self._frames.clear() From 92dd6240c582c088b545bb230046922522bac1f4 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 14:17:47 +0200 Subject: [PATCH 010/133] Sort file paths in FakeSharedHarvesterPool._key Make the pool key order-independent by sorting normalized absolute file paths before forming the tuple. This prevents different keys for the same set of CTI files when they are provided in a different order (while preserving normcase/abspath normalization). --- tests/cameras/backends/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index 8b6d08fa6..9459c35ef 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -682,7 +682,7 @@ def configure(cls, harvester_factory): @staticmethod def _key(cti_files) -> tuple[str, ...]: # Stable across case/path spelling on Windows while preserving loaded_files separately. - return tuple(os.path.normcase(os.path.abspath(str(p))) for p in cti_files) + return tuple(sorted(os.path.normcase(os.path.abspath(str(p))) for p in cti_files)) @classmethod def acquire(cls, cti_files): From 5f56a5f43506a1ca38be1c7f3dd97995fbf07ecf Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 14:35:24 +0200 Subject: [PATCH 011/133] Merge failed CTI files instead of overwrite Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- dlclivegui/cameras/backends/gentl_backend.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 1bcce321b..ad86f8c74 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -569,9 +569,12 @@ def _acquire_shared_harvester(self, loaded: list[str]) -> list: ns["cti_files_loaded"] = actual_loaded if actual_failed: - ns["cti_files_failed"] = [ + existing_failed = ns.get("cti_files_failed") + merged_failed = list(existing_failed) if isinstance(existing_failed, list) else [] + merged_failed.extend( {"cti": str(cti), "error": str(error)} for cti, error in actual_failed.items() - ] + ) + ns["cti_files_failed"] = merged_failed with self._shared_entry.lock: infos = list(self._harvester.device_info_list or []) From 9b77c42e05d4ea4f74d8fb3cd9c0e8d1555a8117 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 14:35:51 +0200 Subject: [PATCH 012/133] Avoid overwriting failed CTIs Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- dlclivegui/cameras/backends/gentl_backend.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index ad86f8c74..3ba2985fa 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -592,7 +592,10 @@ def _acquire_shared_harvester(self, loaded: list[str]) -> list: if exc_loaded or exc_failed: ns["cti_files_loaded"] = [str(p) for p in exc_loaded] - ns["cti_files_failed"] = [{"cti": str(cti), "error": str(error)} for cti, error in exc_failed.items()] + existing_failed = ns.get("cti_files_failed") + merged_failed = list(existing_failed) if isinstance(existing_failed, list) else [] + merged_failed.extend({"cti": str(cti), "error": str(error)} for cti, error in exc_failed.items()) + ns["cti_files_failed"] = merged_failed if self._shared_entry is not None: try: From 90241f67d94517ce93d9dfb984be8556fd0fca7d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 16:12:54 +0200 Subject: [PATCH 013/133] pre-commit --- dlclivegui/cameras/backends/gentl_backend.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 3ba2985fa..b55abdf74 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -571,9 +571,7 @@ def _acquire_shared_harvester(self, loaded: list[str]) -> list: if actual_failed: existing_failed = ns.get("cti_files_failed") merged_failed = list(existing_failed) if isinstance(existing_failed, list) else [] - merged_failed.extend( - {"cti": str(cti), "error": str(error)} for cti, error in actual_failed.items() - ) + merged_failed.extend({"cti": str(cti), "error": str(error)} for cti, error in actual_failed.items()) ns["cti_files_failed"] = merged_failed with self._shared_entry.lock: From 760825f35dd0774a42d9af049baa4f6be77dcf55 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 11:11:16 +0200 Subject: [PATCH 014/133] Revert display name change --- dlclivegui/services/multi_camera_controller.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 7058d59ac..ad1a8636b 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -121,6 +121,10 @@ def stop(self) -> None: self._stop_event.set() +def get_display_id(settings: CameraSettings) -> str: + return f"{settings.backend}:{settings.index}" + + def get_camera_id(settings: CameraSettings) -> str: """Generate a unique camera ID from stable backend identity.""" backend = (settings.backend or "").lower() @@ -223,7 +227,7 @@ def start(self, camera_settings: list[CameraSettings]) -> None: def _start_camera(self, settings: CameraSettings) -> None: """Start a single camera.""" settings_copy = copy.deepcopy(settings) - cam_id = get_camera_id(settings_copy) + cam_id = get_display_id(settings_copy) if cam_id in self._workers: LOGGER.warning(f"Camera {cam_id} already has a worker") return From 2e54469c3e4f923a9be5a44a1cdc054a32b155b6 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 13:41:35 +0200 Subject: [PATCH 015/133] Use get_display_id in multicam test Update tests/services/test_multicam_controller.py to import and use get_display_id instead of get_camera_id when accessing mfd.frames. This aligns the test with the frames dict keys (display IDs) and the updated multi_camera_controller API. --- tests/services/test_multicam_controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index eca502187..0893bd9f6 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -5,7 +5,7 @@ # from dlclivegui.config import CameraSettings from dlclivegui.config import CameraSettings -from dlclivegui.services.multi_camera_controller import MultiCameraController, get_camera_id +from dlclivegui.services.multi_camera_controller import MultiCameraController, get_display_id @pytest.mark.unit @@ -60,7 +60,7 @@ def test_rotation_and_crop(qtbot, patch_factory): last_shape = {"shape": None} def on_ready(mfd): - f = mfd.frames.get(get_camera_id(cam)) + f = mfd.frames.get(get_display_id(cam)) if f is not None: last_shape["shape"] = f.shape From ece0005d734e98e29cca4ff1feb41b62fcdee313 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:13:16 +0200 Subject: [PATCH 016/133] Add GenTL hardware trigger support Add generic hardware-trigger support for GenTL cameras and expose trigger settings in CameraSettings. - Introduce CameraTriggerSettings model (config.py) with role/input/output/timeout/strict and helpers to coerce and serialize. - Allow CameraSettings to read/write backend-specific trigger options. - GenTLCameraBackend: import and use trigger settings, apply trigger timeout override, persist actual trigger config, and mark hardware_trigger capability as BEST_EFFORT. - Implement trigger configuration paths: off, external/follower (input), and master (output). Add helper methods (_set_enum_node, _node, _node_symbolics, _trigger_attr, _trigger_to_dict) to safely interact with GenICam nodes. - Improve lifecycle handling: configure trigger after other settings, restore a safe non-triggering state on stop, and provide waits_for_hardware_trigger property and clearer timeout messages when waiting for hardware triggers. - Add DEFAULT_CAPABILITIES entry for hardware_trigger in base defaults. These changes enable safer and configurable hardware-trigger operation for GenTL backends and improve error reporting and shutdown behavior. --- dlclivegui/cameras/backends/gentl_backend.py | 180 ++++++++++++++++++- dlclivegui/cameras/base.py | 1 + dlclivegui/config.py | 96 ++++++++++ 3 files changed, 270 insertions(+), 7 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index b55abdf74..18c17d23c 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -7,11 +7,12 @@ import threading import time from pathlib import Path -from typing import ClassVar +from typing import Any, ClassVar import cv2 import numpy as np +from ...config import CameraTriggerSettings from ..base import CameraBackend, SupportLevel, register_backend from ..factory import DetectedCamera from .utils import gentl_discovery as cti_finder @@ -105,6 +106,16 @@ def __init__(self, settings): self._gain = self._positive_float(ns.get("gain", props.get("gain"))) self._timeout: float = float(ns.get("timeout", props.get("timeout", 2.0))) + try: + self._trigger = CameraTriggerSettings.from_any(ns.get("trigger", props.get("trigger"))) + except Exception as exc: + LOG.warning("Invalid GenTL trigger config; falling back to trigger role=off: %s", exc) + self._trigger = CameraTriggerSettings() + + trigger_timeout = self._positive_float(self._trigger_attr(self._trigger, "timeout", None)) + if trigger_timeout is not None: + self._timeout = float(trigger_timeout) + self._requested_resolution: tuple[int, int] | None = self._get_requested_resolution_or_none() self._actual_width: int | None = None @@ -154,6 +165,7 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "set_gain": SupportLevel.SUPPORTED, "device_discovery": SupportLevel.SUPPORTED, "stable_identity": SupportLevel.SUPPORTED, + "hardware_trigger": SupportLevel.BEST_EFFORT, } # ------------------------------------------------------------------ @@ -420,11 +432,12 @@ def open(self) -> None: self._device_label = self._resolve_device_label(node_map) self._configure_pixel_format(node_map) - self._configure_trigger(node_map) self._configure_resolution(node_map) self._configure_exposure(node_map) self._configure_gain(node_map) self._configure_frame_rate(node_map) + self._configure_trigger(node_map) # keep low in the list + self._ensure_settings_ns()["trigger_actual"] = self._trigger_to_dict(self._trigger) self._read_telemetry(node_map) self._persist_device_metadata(selected_info, selected_serial) @@ -449,6 +462,11 @@ def open(self) -> None: f"Failed to open GenTL camera.\n\nLoaded CTIs: {loaded}\nFailed CTIs: {failed}\nReason: {exc}" ) from exc + @property + def waits_for_hardware_trigger(self) -> bool: + role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() + return role in {"external", "follower"} + def read(self) -> tuple[np.ndarray, float]: if self._acquirer is None: raise RuntimeError("GenTL image acquirer not initialised") @@ -470,6 +488,9 @@ def read(self) -> tuple[np.ndarray, float]: except ValueError: frame = array.copy() except HarvesterTimeoutError as exc: + role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() + if role in {"external", "follower"}: + raise TimeoutError(str(exc) + " (GenTL timeout; waiting for hardware trigger?)") from exc raise TimeoutError(str(exc) + " (GenTL timeout)") from exc frame = self._convert_frame(frame) @@ -502,6 +523,12 @@ def close(self) -> None: except Exception: pass + try: + node_map = self._acquirer.remote_device.node_map + self._call_with_optional_lock(self._restore_trigger_idle, node_map) + except Exception: + pass + try: destroy = getattr(self._acquirer, "destroy", None) if destroy is not None: @@ -949,6 +976,62 @@ def _create_acquirer(self, serial: str | None, index: int): # ------------------------------------------------------------------ # Camera configuration helpers # ------------------------------------------------------------------ + @staticmethod + def _node(node_map, name: str): + try: + return getattr(node_map, name) + except Exception: + return None + + @staticmethod + def _node_symbolics(node) -> list[str]: + try: + return list(getattr(node, "symbolics", []) or []) + except Exception: + return [] + + def _set_enum_node(self, node_map, name: str, value: str, *, strict: bool = False) -> bool: + node = self._node(node_map, name) + if node is None: + if strict: + raise RuntimeError(f"GenICam node '{name}' is not available") + LOG.debug("GenICam node '%s' is not available; skipping", name) + return False + + symbolics = self._node_symbolics(node) + if symbolics and value not in symbolics: + if strict: + raise RuntimeError(f"GenICam node '{name}' does not support '{value}'. Available: {symbolics}") + LOG.warning("GenICam node '%s' does not support '%s'. Available: %s", name, value, symbolics) + return False + + try: + node.value = value + return True + except Exception as exc: + if strict: + raise RuntimeError(f"Failed to set GenICam node '{name}' to '{value}': {exc}") from exc + LOG.warning("Failed to set GenICam node '%s' to '%s': %s", name, value, exc) + return False + + @staticmethod + def _trigger_attr(trigger, name: str, default=None): + if isinstance(trigger, dict): + return trigger.get(name, default) + return getattr(trigger, name, default) + + @staticmethod + def _trigger_to_dict(trigger) -> dict[str, Any]: + if trigger is None: + return {} + if isinstance(trigger, dict): + return dict(trigger) + if hasattr(trigger, "model_dump"): + try: + return trigger.model_dump(exclude_none=True) + except Exception: + pass + return {} def _configure_pixel_format(self, node_map) -> None: try: @@ -1004,12 +1087,95 @@ def _configure_pixel_format(self, node_map) -> None: LOG.warning("Failed to configure pixel format '%s': %s", self._pixel_format, e) def _configure_trigger(self, node_map) -> None: + cfg = self._trigger + role = str(self._trigger_attr(cfg, "role", "off") or "off").strip().lower() + strict = bool(self._trigger_attr(cfg, "strict", False)) + + if role in {"off", "disabled"}: + self._configure_trigger_off(node_map, strict=strict) + return + + if role in {"external", "follower"}: + self._configure_trigger_input(node_map, cfg, strict=strict) + return + + if role == "master": + self._configure_trigger_master(node_map, cfg, strict=strict) + return + + if strict: + raise RuntimeError(f"Unsupported GenTL trigger role: {role!r}") + + LOG.warning("Unsupported GenTL trigger role '%s'; disabling trigger.", role) + self._configure_trigger_off(node_map, strict=False) + + def _configure_trigger_off(self, node_map, *, strict: bool = False) -> None: + self._set_enum_node(node_map, "TriggerMode", "Off", strict=strict) + + def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> None: + selector = str(self._trigger_attr(cfg, "selector", "FrameStart") or "FrameStart") + source = str(self._trigger_attr(cfg, "source", "Line0") or "Line0") + activation = str(self._trigger_attr(cfg, "activation", "RisingEdge") or "RisingEdge") + + # Disable trigger while changing trigger-related nodes. + self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) + + self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) + self._set_enum_node(node_map, "TriggerSource", source, strict=strict) + self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict) + + self._set_enum_node(node_map, "AcquisitionMode", "Continuous", strict=False) + + if not self._set_enum_node(node_map, "TriggerMode", "On", strict=strict): + if strict: + raise RuntimeError("Could not enable GenTL TriggerMode=On") + + LOG.info( + "GenTL trigger input configured: role=%s selector=%s source=%s activation=%s", + self._trigger_attr(cfg, "role", "external"), + selector, + source, + activation, + ) + + def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> None: + output_line = str(self._trigger_attr(cfg, "output_line", "Line2") or "Line2") + output_source = str(self._trigger_attr(cfg, "output_source", "ExposureActive") or "ExposureActive") + + # Master camera runs freerun and exposes an output signal. + self._configure_trigger_off(node_map, strict=False) + + self._set_enum_node(node_map, "LineSelector", output_line, strict=strict) + self._set_enum_node(node_map, "LineMode", "Output", strict=strict) + self._set_enum_node(node_map, "LineSource", output_source, strict=strict) + + LOG.info( + "GenTL trigger master configured: output_line=%s output_source=%s", + output_line, + output_source, + ) + + def _restore_trigger_idle(self, node_map) -> None: + """Best-effort restore to a safe non-triggering state after acquisition stops. + + Important: + - This should be called after acquirer.stop(), not while acquisition is active. + - It is intentionally non-strict because shutdown should not fail if a node + is missing or read-only. + """ + role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() + try: - trigger_mode = getattr(node_map, "TriggerMode", None) - if trigger_mode is not None and "Off" in getattr(trigger_mode, "symbolics", []): - trigger_mode.value = "Off" - except Exception as e: - LOG.warning("Failed to disable trigger mode: %s", e) + if role in {"external", "follower"}: + self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) + + elif role == "master": + # Stop driving output if the camera exposes these nodes. + self._set_enum_node(node_map, "LineSource", "Off", strict=False) + self._set_enum_node(node_map, "LineMode", "Input", strict=False) + + except Exception: + LOG.debug("Best-effort GenTL trigger restore failed", exc_info=True) def _configure_resolution(self, node_map) -> None: if self._requested_resolution is None: diff --git a/dlclivegui/cameras/base.py b/dlclivegui/cameras/base.py index bc91ce9bf..fefedd1d5 100644 --- a/dlclivegui/cameras/base.py +++ b/dlclivegui/cameras/base.py @@ -70,6 +70,7 @@ class SupportLevel(str, Enum): "set_gain": SupportLevel.UNSUPPORTED, "device_discovery": SupportLevel.UNSUPPORTED, "stable_identity": SupportLevel.UNSUPPORTED, + "hardware_trigger": SupportLevel.UNSUPPORTED, } diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 6d9e1de76..154eabf40 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -12,6 +12,8 @@ TileLayout = Literal["auto", "2x2", "1x4", "4x1"] Precision = Literal["FP32", "FP16"] ModelType = Literal["pytorch", "tensorflow"] +TriggerRole = Literal["off", "external", "master", "follower"] +TriggerActivation = Literal["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"] class CameraSettings(BaseModel): @@ -168,6 +170,100 @@ def check_diff(old: CameraSettings, new: CameraSettings) -> dict: pass return out + def backend_options(self, backend: str | None = None) -> dict[str, Any]: + key = backend or self.backend + props = self.properties if isinstance(self.properties, dict) else {} + ns = props.get(str(key).lower(), {}) + return ns if isinstance(ns, dict) else {} + + def get_trigger_settings(self, backend: str | None = None) -> CameraTriggerSettings: + ns = self.backend_options(backend) + return CameraTriggerSettings.from_any(ns.get("trigger")) + + def set_trigger_settings(self, trigger: CameraTriggerSettings, backend: str | None = None) -> None: + key = backend or self.backend + if not isinstance(self.properties, dict): + self.properties = {} + ns = self.properties.setdefault(str(key).lower(), {}) + if not isinstance(ns, dict): + ns = {} + self.properties[str(key).lower()] = ns + ns["trigger"] = trigger.to_properties() + + +class CameraTriggerSettings(BaseModel): + """ + Generic hardware-trigger settings. + + Backend-specific code may ignore fields that are unsupported by a given + camera/SDK. For GenTL, these map to common GenICam nodes such as: + TriggerMode, TriggerSelector, TriggerSource, TriggerActivation, + LineSelector, LineMode, and LineSource. + """ + + role: TriggerRole = "off" + + # Input trigger config: external/follower + selector: str = "FrameStart" + source: str = "Line0" + activation: TriggerActivation | str = "RisingEdge" + + # Output config: master + output_line: str = "Line2" + output_source: str = "ExposureActive" + + # Runtime behavior + timeout: float | None = None + strict: bool = False + + @field_validator("role", mode="before") + @classmethod + def _coerce_role(cls, v): + if v is None: + return "off" + + s = str(v).strip().lower() + aliases = { + "": "off", + "none": "off", + "false": "off", + "disabled": "off", + "disable": "off", + "off": "off", + "true": "external", + "on": "external", + "trigger": "external", + "triggered": "external", + "external": "external", + "follower": "follower", + "slave": "follower", + "master": "master", + "main": "master", + } + return aliases.get(s, s) + + @field_validator("timeout", mode="before") + @classmethod + def _coerce_timeout(cls, v): + if v in (None, ""): + return None + try: + fv = float(v) + except Exception: + return None + return fv if fv > 0 else None + + @classmethod + def from_any(cls, value) -> CameraTriggerSettings: + if isinstance(value, cls): + return value + if isinstance(value, dict): + return cls(**value) + return cls() + + def to_properties(self) -> dict[str, Any]: + return self.model_dump(exclude_none=True) + class MultiCameraSettings(BaseModel): cameras: list[CameraSettings] = Field(default_factory=list) From de99825ab04fbf96dbf7376c4920afae65c75918 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:13:44 +0200 Subject: [PATCH 017/133] Handle camera trigger defaults & trigger-aware startup Populate gentl trigger defaults on save and allow saving configs with empty DLCLive model path. Add _with_camera_defaults_for_save to ensure CameraTriggerSettings are present for gentl cameras and thread through allow_empty_model_path in _dlc_settings_from_ui/_current_config so configs can be saved without a model while preserving existing DLC fields. Improve multi-camera runtime robustness: treat TimeoutError from hardware-trigger backends as an expected 'no trigger' event (don't count as a camera failure) and add _trigger_role_from_settings/_camera_start_priority helpers. Start active cameras sorted by trigger role so trigger-waiting (external/follower) devices are armed before masters. Also extend DLCLive configuration error handling to include RuntimeError. --- dlclivegui/gui/main_window.py | 47 ++++++++++++-- .../services/multi_camera_controller.py | 64 +++++++++++++++++++ 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 0e3beb0e6..8a66e6ab2 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -56,6 +56,7 @@ ApplicationSettings, BoundingBoxSettings, CameraSettings, + CameraTriggerSettings, DLCProcessorSettings, MultiCameraSettings, RecordingSettings, @@ -854,15 +855,36 @@ def _apply_config(self, config: ApplicationSettings) -> None: # Update recording path preview self._update_recording_path_preview() - def _current_config(self) -> ApplicationSettings: + def _with_camera_defaults_for_save(self, settings: MultiCameraSettings) -> MultiCameraSettings: + out = settings.model_copy(deep=True) + + for cam in out.cameras: + backend = (cam.backend or "").lower() + if backend != "gentl": + continue + + if not isinstance(cam.properties, dict): + cam.properties = {} + + ns = cam.properties.setdefault("gentl", {}) + if not isinstance(ns, dict): + ns = {} + cam.properties["gentl"] = ns + + ns.setdefault("trigger", CameraTriggerSettings().model_dump(exclude_none=True)) + + return out + + def _current_config(self, *, allow_empty_model_path=False) -> ApplicationSettings: # Get the first camera from multi-camera config for backward compatibility + multi_camera = self._with_camera_defaults_for_save(self._config.multi_camera) active_cameras = self._config.multi_camera.get_active_cameras() camera = active_cameras[0] if active_cameras else CameraSettings() return ApplicationSettings( camera=camera, - multi_camera=self._config.multi_camera, - dlc=self._dlc_settings_from_ui(), + multi_camera=multi_camera, + dlc=self._dlc_settings_from_ui(allow_empty_model_path=allow_empty_model_path), recording=self._recording_settings_from_ui(), bbox=self._bbox_settings_from_ui(), visualization=self._visualization_settings_from_ui(), @@ -874,13 +896,24 @@ def _parse_json(self, value: str) -> dict: return {} return json.loads(text) - def _dlc_settings_from_ui(self) -> DLCProcessorSettings: + def _dlc_settings_from_ui(self, *, allow_empty_model_path=False) -> DLCProcessorSettings: model_path = self.model_path_edit.text().strip() if Path(model_path).exists() and Path(model_path).suffix == ".pb": # IMPORTANT NOTE: DLClive expects a directory for TensorFlow models, # so if user selects a .pb file, we should pass the parent directory to DLCLive model_path = str(Path(model_path).parent) - if model_path == "": + if not model_path: + if allow_empty_model_path: + return DLCProcessorSettings( + model_path="", + model_directory=self._config.dlc.model_directory, # Preserve from config + device=self._config.dlc.device, # Preserve from config + dynamic=self._config.dlc.dynamic, # Preserve from config + resize=self._config.dlc.resize, # Preserve from config + precision=self._config.dlc.precision, # Preserve from config + model_type=None, + # additional_options=self._parse_json(self.additional_options_edit.toPlainText()), + ) raise ValueError("Model path cannot be empty. Please enter a valid path to a DLCLive model file.") try: model_bknd = DLCLiveProcessor.get_model_backend(model_path) @@ -965,7 +998,7 @@ def _action_save_config_as(self) -> None: def _save_config_to_path(self, path: Path) -> None: try: - config = self._current_config() + config = self._current_config(allow_empty_model_path=True) config.save(path) self._settings_store.set_last_config_path(str(path)) self._settings_store.save_full_config_snapshot(config) @@ -1611,7 +1644,7 @@ def _stop_preview(self) -> None: def _configure_dlc(self) -> bool: try: settings = self._dlc_settings_from_ui() - except (ValueError, json.JSONDecodeError) as exc: + except (ValueError, RuntimeError, json.JSONDecodeError) as exc: self._show_error(f"Invalid DLCLive settings: {exc}") return False if not settings.model_path: diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index ad1a8636b..778a86e9f 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -99,6 +99,29 @@ def run(self) -> None: consecutive_errors = 0 self.frame_captured.emit(self._camera_id, frame, timestamp) + except TimeoutError as exc: + if self._stop_event.is_set(): + break + + # In hardware-trigger mode, a timeout usually means: + # "no trigger pulse arrived during this poll interval". + # This is expected and should not count as a camera failure. + if bool(getattr(self._backend, "waits_for_hardware_trigger", False)): + LOGGER.debug( + "[Worker %s] waiting for hardware trigger: %s", + self._camera_id, + exc, + ) + consecutive_errors = 0 + continue + + consecutive_errors += 1 + if consecutive_errors >= self._max_consecutive_errors: + self.error_occurred.emit(self._camera_id, f"Camera read timeout: {exc}") + break + time.sleep(self._retry_delay) + continue + except Exception as exc: consecutive_errors += 1 if self._stop_event.is_set(): @@ -142,6 +165,46 @@ def get_camera_id(settings: CameraSettings) -> str: return f"{backend}:index:{int(settings.index)}" +def _trigger_role_from_settings(settings: CameraSettings) -> str: + backend = (settings.backend or "").lower() + props = settings.properties if isinstance(settings.properties, dict) else {} + ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {} + + trigger = ns.get("trigger", {}) + if not isinstance(trigger, dict): + return "off" + + role = str(trigger.get("role", "off") or "off").strip().lower() + + # Match CameraTriggerSettings aliases enough for controller ordering. + if role in {"true", "on", "trigger", "triggered"}: + return "external" + if role in {"slave"}: + return "follower" + if role in {"main"}: + return "master" + if role in {"false", "none", "disabled", "disable", ""}: + return "off" + + return role + + +def _camera_start_priority(settings: CameraSettings) -> int: + """Start trigger-waiting cameras before trigger-generating cameras. + + Priority: + 0: external/follower cameras, which should be armed first + 1: normal/free-run cameras + 2: master cameras, which may generate trigger pulses + """ + role = _trigger_role_from_settings(settings) + if role in {"external", "follower"}: + return 0 + if role == "master": + return 2 + return 1 + + class MultiCameraController(QObject): """Controller for managing multiple cameras simultaneously.""" @@ -184,6 +247,7 @@ def start(self, camera_settings: list[CameraSettings]) -> None: return active_settings = [s for s in camera_settings if s.enabled][: self.MAX_CAMERAS] + active_settings = sorted(active_settings, key=_camera_start_priority) if not active_settings: LOGGER.warning("No active cameras to start") return From a27a1f26e40ee7f6ca0e267ddc2164031ef78fe6 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:13:55 +0200 Subject: [PATCH 018/133] Improve signal handling and graceful shutdown Enhance Qt app signal handling to support SIGTERM and SIGBREAK, and make Ctrl+C shutdown more robust. Adds a quitting flag and a two-stage interrupt: first interrupt triggers window close and schedules app.quit, second interrupt forces immediate exit (os._exit(130)). Parents the keepalive QTimer to the QApplication, sets a 100ms interval, and safely stops any previous timer to avoid duplicates. Adds logging and exception handling around window close and timer cleanup, and uses QTimer.singleShot to ensure Qt leaves its event loop even if closeEvent cleanup is asynchronous. --- dlclivegui/main.py | 60 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/dlclivegui/main.py b/dlclivegui/main.py index eace8bda6..9e38a08cf 100644 --- a/dlclivegui/main.py +++ b/dlclivegui/main.py @@ -27,32 +27,70 @@ def _maybe_allow_keyboard_interrupt(app: QApplication) -> None: """ - Gracefully handle Ctrl+C (SIGINT) by closing the main window and quitting Qt. + Gracefully handle Ctrl+C/SIGTERM by closing the main window and quitting Qt. + + Notes: + - The small timer keeps Python signal handling responsive while Qt owns the event loop. + - First Ctrl+C tries graceful cleanup via closeEvent(). + - Second Ctrl+C exits immediately with code 130. """ + quitting = {"requested": False} def _request_quit() -> None: + if quitting["requested"]: + return + + quitting["requested"] = True 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() + try: + # Trigger existing closeEvent cleanup: + # camera stop, controller shutdown, timers, DLC shutdown, etc. + win.close() + except Exception: + logging.exception("Error while closing main window after Ctrl+C") + + # Explicitly ask Qt to leave app.exec(). + # Do this even after win.close(), because closeEvent cleanup can be async + # and relying only on quitOnLastWindowClosed can be fragile. + QTimer.singleShot(0, app.quit) + + def _force_exit() -> None: + logging.warning("Second interrupt received, forcing process exit.") + os._exit(130) def _sigint_handler(_signum, _frame) -> None: + if quitting["requested"]: + _force_exit() 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() - sig_timer.setInterval(100) # 50–200ms typical; keep low overhead + # Ctrl+Break on Windows. + if hasattr(signal, "SIGBREAK"): + signal.signal(signal.SIGBREAK, _sigint_handler) + + # Useful when process is terminated from shells/process managers. + if hasattr(signal, "SIGTERM"): + signal.signal(signal.SIGTERM, _sigint_handler) + + # Parent the timer to app so Qt owns its lifetime. + sig_timer = QTimer(app) + sig_timer.setInterval(100) sig_timer.timeout.connect(lambda: None) sig_timer.start() - if hasattr(app, "_sig_timer"): - app._sig_timer.stop() # Stop any existing timer to avoid duplicates - app._sig_timer = sig_timer # Store on app to keep it alive and allow cleanup on exit + old_timer = getattr(app, "_sig_timer", None) + if old_timer is not None: + try: + old_timer.stop() + except Exception: + pass + + app._sig_timer = sig_timer def configure_logging(debug: bool = False) -> None: From 4e6dac0ff7801de836690ad7ab0f236d4f6c9a67 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:14:12 +0200 Subject: [PATCH 019/133] Add GenTL trigger tests and fake node map Add a comprehensive test suite for GenTL hardware trigger handling (tests/cameras/backends/test_gentl_trigger.py). Tests cover trigger roles (off/external/follower/master), selector/source/activation settings, strict vs non-strict behavior for invalid sources, master output configuration, alias mapping, timeout handling and error messaging, and persistence of trigger_actual for debugging. Update the test conftest fake node map (tests/cameras/backends/conftest.py) to include Trigger* and Line* nodes (AcquisitionMode, TriggerSelector, TriggerMode, TriggerSource, TriggerActivation, LineSelector, LineMode, LineSource) so the tests can exercise trigger and GPIO-related configuration. --- tests/cameras/backends/conftest.py | 12 + tests/cameras/backends/test_gentl_trigger.py | 333 +++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 tests/cameras/backends/test_gentl_trigger.py diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index 9459c35ef..f21bf4819 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -599,6 +599,18 @@ def __init__( self.GainAuto = _FakeNode("Off") self.Gain = _FakeNode(float(gain)) + # Trigger input nodes + self.AcquisitionMode = _FakeNode("Continuous", symbolics=["Continuous", "SingleFrame"]) + self.TriggerSelector = _FakeNode("FrameStart", symbolics=["FrameStart"]) + self.TriggerMode = _FakeNode("Off", symbolics=["Off", "On"]) + self.TriggerSource = _FakeNode("Line0", symbolics=["Line0", "Line1", "Software"]) + self.TriggerActivation = _FakeNode("RisingEdge", symbolics=["RisingEdge", "FallingEdge"]) + + # GPIO output nodes for master/follower setups + self.LineSelector = _FakeNode("Line0", symbolics=["Line0", "Line1", "Line2"]) + self.LineMode = _FakeNode("Input", symbolics=["Input", "Output"]) + self.LineSource = _FakeNode("Off", symbolics=["Off", "ExposureActive", "AcquisitionActive"]) + class _FakeRemoteDevice: def __init__(self, node_map: _FakeNodeMap): diff --git a/tests/cameras/backends/test_gentl_trigger.py b/tests/cameras/backends/test_gentl_trigger.py new file mode 100644 index 000000000..f0d5f4528 --- /dev/null +++ b/tests/cameras/backends/test_gentl_trigger.py @@ -0,0 +1,333 @@ +# tests/cameras/backends/test_gentl_trigger.py +from __future__ import annotations + +import pytest + +# --------------------------------------------------------------------- +# GenTL hardware trigger configuration +# --------------------------------------------------------------------- + + +def _gentl_trigger_settings(gentl_settings_factory, trigger: dict, **kwargs): + """Build CameraSettings with a GenTL trigger block.""" + return gentl_settings_factory(properties={"gentl": {"trigger": trigger}}, **kwargs) + + +def test_gentl_capabilities_advertise_hardware_trigger_best_effort(patch_gentl_sdk): + gb = patch_gentl_sdk + + caps = gb.GenTLCameraBackend.static_capabilities() + + assert caps.get("hardware_trigger") == gb.SupportLevel.BEST_EFFORT + + +def test_trigger_default_off_configures_trigger_mode_off(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = gentl_settings_factory() + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerMode.value == "Off" + + ns = settings.properties.get("gentl", {}) + assert ns.get("trigger_actual", {}).get("role") == "off" + + be.close() + + +def test_trigger_explicit_off_configures_trigger_mode_off(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings(gentl_settings_factory, {"role": "off"}) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerMode.value == "Off" + assert settings.properties["gentl"]["trigger_actual"]["role"] == "off" + + be.close() + + +def test_trigger_external_configures_input_line_and_timeout(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "selector": "FrameStart", + "source": "Line0", + "activation": "RisingEdge", + "timeout": 10.0, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerSelector.value == "FrameStart" + assert nm.TriggerSource.value == "Line0" + assert nm.TriggerActivation.value == "RisingEdge" + assert nm.TriggerMode.value == "On" + assert be._timeout == pytest.approx(10.0) + + ns = settings.properties["gentl"] + assert ns["trigger_actual"]["role"] == "external" + assert ns["trigger_actual"]["source"] == "Line0" + + be.close() + + +def test_trigger_follower_configures_input_line(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "follower", + "selector": "FrameStart", + "source": "Line1", + "activation": "FallingEdge", + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerSelector.value == "FrameStart" + assert nm.TriggerSource.value == "Line1" + assert nm.TriggerActivation.value == "FallingEdge" + assert nm.TriggerMode.value == "On" + + ns = settings.properties["gentl"] + assert ns["trigger_actual"]["role"] == "follower" + + be.close() + + +def test_trigger_master_configures_output_line_and_keeps_trigger_off(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "master", + "output_line": "Line2", + "output_source": "ExposureActive", + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerMode.value == "Off" + assert nm.LineSelector.value == "Line2" + assert nm.LineMode.value == "Output" + assert nm.LineSource.value == "ExposureActive" + + ns = settings.properties["gentl"] + assert ns["trigger_actual"]["role"] == "master" + assert ns["trigger_actual"]["output_line"] == "Line2" + + be.close() + + +def test_trigger_invalid_source_non_strict_does_not_crash(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "source": "LineDoesNotExist", + "strict": False, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + # Source was unsupported, so the fake node should retain its default. + assert nm.TriggerSource.value == "Line0" + # Non-strict mode should still allow opening; TriggerMode may be enabled + # because TriggerSource failure is best-effort in this mode. + assert be._acquirer is not None + + be.close() + + +def test_trigger_invalid_source_strict_raises_and_cleans_up(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "source": "LineDoesNotExist", + "strict": True, + }, + ) + be = gb.GenTLCameraBackend(settings) + + with pytest.raises(RuntimeError): + be.open() + + assert be._harvester is None + assert be._shared_entry is None + assert be._acquirer is None + + +def test_trigger_invalid_master_output_source_non_strict_does_not_crash(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "master", + "output_line": "Line2", + "output_source": "NotARealLineSource", + "strict": False, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerMode.value == "Off" + assert nm.LineSelector.value == "Line2" + assert nm.LineMode.value == "Output" + # Unsupported source should not be applied in non-strict mode. + assert nm.LineSource.value == "Off" + + be.close() + + +def test_trigger_invalid_master_output_source_strict_raises_and_cleans_up(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "master", + "output_line": "Line2", + "output_source": "NotARealLineSource", + "strict": True, + }, + ) + be = gb.GenTLCameraBackend(settings) + + with pytest.raises(RuntimeError): + be.open() + + assert be._harvester is None + assert be._shared_entry is None + assert be._acquirer is None + + +def test_trigger_alias_on_maps_to_external(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "on", + "source": "Line1", + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerMode.value == "On" + assert nm.TriggerSource.value == "Line1" + assert settings.properties["gentl"]["trigger_actual"]["role"] == "external" + + be.close() + + +def test_trigger_timeout_overrides_default_fetch_timeout(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "timeout": 7.5, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + assert be._timeout == pytest.approx(7.5) + + # Fake acquisition is started, so read should pass and record the timeout. + frame, _ = be.read() + assert frame is not None + assert be._acquirer.fetch_calls[-1] == pytest.approx(7.5) + + be.close() + + +def test_trigger_timeout_error_mentions_hardware_trigger_when_waiting(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "timeout": 3.0, + }, + # fast_start keeps acquisition stopped; fake fetch then raises timeout. + # This lets us assert the backend timeout message without hardware. + ) + settings.properties["gentl"]["fast_start"] = True + be = gb.GenTLCameraBackend(settings) + + be.open() + + with pytest.raises(TimeoutError) as ei: + be.read() + + msg = str(ei.value).lower() + assert "gentl timeout" in msg + assert "hardware trigger" in msg or "trigger" in msg + + be.close() + + +def test_trigger_actual_is_persisted_for_debugging(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "follower", + "source": "Line1", + "activation": "FallingEdge", + "timeout": 9.0, + "strict": False, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + + actual = settings.properties["gentl"].get("trigger_actual") + assert isinstance(actual, dict) + assert actual["role"] == "follower" + assert actual["source"] == "Line1" + assert actual["activation"] == "FallingEdge" + assert actual["timeout"] == pytest.approx(9.0) + + be.close() From 3e40d948349b1c1550ac8fc1cfce69ce534e05a5 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:01:03 +0200 Subject: [PATCH 020/133] Respect user camera order for display/tiling Use the user-configured camera order for display/tiling and ensure tiling geometry matches displayed frames. Changes: - GUI: populate the inference camera dropdown from active cameras in the configured order and only add entries for cameras that are actually running. - MultiCameraController: store the user display order, derive startup order by sorting for trigger safety (followers/external first, master last), emit frames/timestamps in display order (with any unexpected IDs appended deterministically), clear display order on stop, and handle no-active-cameras early. - Utils: compute_tiling_geometry now uses the frames' display order (insertion order) rather than sorted keys so tile dimensions and overlays align with the tiled frame; updated docstrings to reflect this. These changes ensure consistent tiling, overlay transforms, and UI behavior that follow the user's configured ordering. --- dlclivegui/gui/main_window.py | 6 ++- .../services/multi_camera_controller.py | 38 ++++++++++++++++--- dlclivegui/utils/display.py | 10 ++--- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 8a66e6ab2..def609413 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1298,8 +1298,10 @@ def _refresh_dlc_camera_list_running(self) -> None: """Populate the inference camera dropdown from currently running cameras.""" self.dlc_camera_combo.blockSignals(True) self.dlc_camera_combo.clear() - for cam_id in sorted(self._running_cams_ids): - self.dlc_camera_combo.addItem(self._label_for_cam_id(cam_id), cam_id) + for cam in self._config.multi_camera.get_active_cameras(): + cam_id = get_camera_id(cam) + if cam_id in self._running_cams_ids: + self.dlc_camera_combo.addItem(self._label_for_cam_id(cam_id), cam_id) # Keep current selection if still present, else select first running if self._inference_camera_id in self._running_cams_ids: diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 778a86e9f..17af14e61 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -229,6 +229,7 @@ def __init__(self): self._frame_lock = Lock() self._running = False self._started_cameras: set = set() + self._camera_display_order: list[str] = [] self._failed_cameras: dict[str, str] = {} # camera_id -> error message self._expected_cameras: int = 0 # Number of cameras we're trying to start @@ -246,8 +247,17 @@ def start(self, camera_settings: list[CameraSettings]) -> None: LOGGER.warning("Multi-camera controller already running") return - active_settings = [s for s in camera_settings if s.enabled][: self.MAX_CAMERAS] - active_settings = sorted(active_settings, key=_camera_start_priority) + active_settings_user_order = [s for s in camera_settings if s.enabled][: self.MAX_CAMERAS] + if not active_settings_user_order: + LOGGER.warning("No active cameras to start") + return + + # Display/tile order follows the user-configured camera order. + self._camera_display_order = [get_camera_id(s) for s in active_settings_user_order] + + # Startup order may differ for trigger safety: + # followers/external first, master last. + active_settings = sorted(active_settings_user_order, key=_camera_start_priority) if not active_settings: LOGGER.warning("No active cameras to start") return @@ -339,6 +349,7 @@ def stop(self, wait: bool = True) -> None: self._settings.clear() self._started_cameras.clear() self._failed_cameras.clear() + self._camera_display_order.clear() self._expected_cameras = 0 self.all_stopped.emit() @@ -361,10 +372,27 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float # Emit frame data without tiling (tiling done in GUI for performance) if self._frames: + ordered_frames: dict[str, np.ndarray] = {} + ordered_timestamps: dict[str, float] = {} + + for cam_id in self._camera_display_order: + if cam_id in self._frames: + ordered_frames[cam_id] = self._frames[cam_id] + if cam_id in self._timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] + + # Any unexpected/legacy IDs, appended deterministically. + for cam_id in self._frames: + if cam_id not in ordered_frames: + ordered_frames[cam_id] = self._frames[cam_id] + for cam_id in self._timestamps: + if cam_id not in ordered_timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] + frame_data = MultiFrameData( - frames=dict(self._frames), - timestamps=dict(self._timestamps), - source_camera_id=camera_id, # Track which camera triggered this + frames=ordered_frames, + timestamps=ordered_timestamps, + source_camera_id=camera_id, tiled_frame=None, ) self.frame_ready.emit(frame_data) diff --git a/dlclivegui/utils/display.py b/dlclivegui/utils/display.py index 0eac657cc..01b0be0bb 100644 --- a/dlclivegui/utils/display.py +++ b/dlclivegui/utils/display.py @@ -38,10 +38,10 @@ def compute_tiling_geometry( """Compute consistent tiling geometry for both tiling and overlay transforms. Returns: - (sorted_cam_ids, rows, cols, tile_w, tile_h) + (cam_ids, rows, cols, tile_w, tile_h) Notes: - - We intentionally base tile aspect on the first frame in sorted_cam_ids, + - We intentionally base tile aspect on the first frame in cam_ids, because create_tiled_frame uses the same ordering. This guarantees that compute_tile_info() and create_tiled_frame() agree on tile_w/tile_h. - If frames have different aspect ratios, they will be resized (possibly distorted) @@ -50,7 +50,7 @@ def compute_tiling_geometry( if not frames: return ([], 1, 1, 640, 480) - cam_ids = sorted(frames.keys()) + cam_ids = list(frames.keys()) frames_list = [frames[cid] for cid in cam_ids] num_frames = len(frames_list) @@ -63,7 +63,7 @@ def compute_tiling_geometry( max_w, max_h = max_canvas - # Reference aspect is based on the first frame in sorted order (matches tiler). + # Reference aspect is based on the first frame in display order (matches tiler). h0, w0 = frames_list[0].shape[:2] frame_aspect = (w0 / h0) if h0 > 0 else 1.0 @@ -135,7 +135,7 @@ def compute_tile_info( Critical robustness fix: - Tile dimensions are computed from the same reference used by create_tiled_frame() - (first frame in sorted order), so offsets/scales match the actual tiling. + (first frame in display order), so offsets/scales match the actual tiling. """ if not frames: return (0, 0), (1.0, 1.0) From d602c1ecb6970a8041c9925f590f672701952b7a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:01:30 +0200 Subject: [PATCH 021/133] tests: preserve display order and add MultiCamera tests Update display tests to assert that tiling and tile computations preserve frame insertion/display order (no longer sorting by camera ID) and add coverage for tile offsets, scaling, and tiled frame content. Add a suite of unit tests for MultiCameraController utilities and behavior: get_camera_id, trigger role aliasing, camera start priority, preserving user display order on start, frame_ready emission order, clearing display order on stop, hardware trigger timeouts (non-fatal), and non-trigger timeouts (fatal). Also import newly-tested helper functions from multi_camera_controller. --- tests/services/test_multicam_controller.py | 300 +++++++++++++++++++++ tests/utils/test_display.py | 111 +++++++- 2 files changed, 398 insertions(+), 13 deletions(-) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 0893bd9f6..e8f94f6b2 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -6,6 +6,12 @@ # from dlclivegui.config import CameraSettings from dlclivegui.config import CameraSettings from dlclivegui.services.multi_camera_controller import MultiCameraController, get_display_id +from dlclivegui.services.multi_camera_controller import ( + MultiCameraController, + _camera_start_priority, + _trigger_role_from_settings, + get_camera_id, +) @pytest.mark.unit @@ -95,3 +101,297 @@ def _create(_settings): # Expect initialization_failed with the camera id with qtbot.waitSignals([mc.initialization_failed, mc.all_stopped], timeout=2000) as _: mc.start([cam]) + + +@pytest.mark.unit +def test_get_camera_id_prefers_stable_device_id(): + cam = CameraSettings( + name="GenTL Cam", + backend="gentl", + index=0, + properties={ + "gentl": { + "device_id": "serial:30220469", + "serial_number": "30220469", + } + }, + ).apply_defaults() + + assert get_camera_id(cam) == "gentl:serial:30220469" + + +@pytest.mark.unit +def test_get_camera_id_falls_back_to_index_without_stable_identity(): + cam = CameraSettings( + name="Cam", + backend="opencv", + index=2, + ).apply_defaults() + + assert get_camera_id(cam) == "opencv:index:2" + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("role", "expected"), + [ + ("off", "off"), + ("disabled", "off"), + ("on", "external"), + ("triggered", "external"), + ("external", "external"), + ("follower", "follower"), + ("slave", "follower"), + ("master", "master"), + ("main", "master"), + ], +) +def test_trigger_role_from_settings_aliases(role, expected): + cam = CameraSettings( + name="C", + backend="gentl", + index=0, + properties={ + "gentl": { + "trigger": { + "role": role, + } + } + }, + ).apply_defaults() + + assert _trigger_role_from_settings(cam) == expected + + +@pytest.mark.unit +def test_camera_start_priority_orders_trigger_roles(): + external = CameraSettings( + name="External", + backend="gentl", + index=0, + properties={"gentl": {"trigger": {"role": "external"}}}, + ).apply_defaults() + + normal = CameraSettings( + name="Normal", + backend="gentl", + index=1, + properties={"gentl": {"trigger": {"role": "off"}}}, + ).apply_defaults() + + master = CameraSettings( + name="Master", + backend="gentl", + index=2, + properties={"gentl": {"trigger": {"role": "master"}}}, + ).apply_defaults() + + assert _camera_start_priority(external) == 0 + assert _camera_start_priority(normal) == 1 + assert _camera_start_priority(master) == 2 + + +@pytest.mark.unit +def test_start_preserves_user_display_order_even_when_trigger_start_order_differs(qtbot, patch_factory): + mc = MultiCameraController() + + # User wants master first in tiled view, follower second. + # Startup order should still be follower first internally. + master = CameraSettings( + name="Master", + backend="opencv", + index=0, + enabled=True, + properties={ + "opencv": { + "device_id": "master-cam", + "trigger": {"role": "master"}, + } + }, + ).apply_defaults() + + follower = CameraSettings( + name="Follower", + backend="opencv", + index=1, + enabled=True, + properties={ + "opencv": { + "device_id": "follower-cam", + "trigger": {"role": "follower"}, + } + }, + ).apply_defaults() + + expected_display_order = [get_camera_id(master), get_camera_id(follower)] + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([master, follower]) + + assert mc._camera_display_order == expected_display_order + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) + + +@pytest.mark.unit +def test_frame_ready_emits_frames_in_user_configured_order(qtbot, patch_factory): + mc = MultiCameraController() + + cam_a = CameraSettings( + name="A", + backend="opencv", + index=0, + enabled=True, + properties={"opencv": {"device_id": "cam-a"}}, + ).apply_defaults() + + cam_b = CameraSettings( + name="B", + backend="opencv", + index=1, + enabled=True, + properties={"opencv": {"device_id": "cam-b"}}, + ).apply_defaults() + + expected_order = [get_camera_id(cam_a), get_camera_id(cam_b)] + seen_orders: list[list[str]] = [] + + def on_ready(mfd): + if len(mfd.frames) >= 2: + seen_orders.append(list(mfd.frames.keys())) + + mc.frame_ready.connect(on_ready) + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam_a, cam_b]) + + qtbot.waitUntil(lambda: bool(seen_orders), timeout=2500) + + assert seen_orders[-1] == expected_order + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) + + +@pytest.mark.unit +def test_display_order_is_cleared_on_stop(qtbot, patch_factory): + mc = MultiCameraController() + + cam = CameraSettings( + name="C", + backend="opencv", + index=0, + enabled=True, + properties={"opencv": {"device_id": "cam-0"}}, + ).apply_defaults() + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam]) + + assert mc._camera_display_order == [get_camera_id(cam)] + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) + + assert mc._camera_display_order == [] + + +@pytest.mark.unit +def test_hardware_trigger_timeouts_are_not_fatal(qtbot, monkeypatch): + class WaitingTriggerBackend: + waits_for_hardware_trigger = True + + def __init__(self, settings): + self.settings = settings + self.opened = False + self.closed = False + + def open(self): + self.opened = True + + def read(self): + raise TimeoutError("waiting for hardware trigger") + + def close(self): + self.closed = True + + def _create(settings): + return WaitingTriggerBackend(settings) + + monkeypatch.setattr(CameraFactory, "create", staticmethod(_create)) + + mc = MultiCameraController() + cam = CameraSettings( + name="Triggered", + backend="gentl", + index=0, + enabled=True, + properties={ + "gentl": { + "device_id": "serial:30220469", + "trigger": {"role": "external", "timeout": 0.1}, + } + }, + ).apply_defaults() + + errors: list[tuple[str, str]] = [] + mc.camera_error.connect(lambda cam_id, msg: errors.append((cam_id, msg))) + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam]) + + # Let several timeout cycles happen. + qtbot.wait(500) + + assert mc.is_running() + assert errors == [] + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) + + +@pytest.mark.unit +def test_non_trigger_timeouts_are_fatal_after_retries(qtbot, monkeypatch): + class TimeoutBackend: + waits_for_hardware_trigger = False + + def __init__(self, settings): + self.settings = settings + + def open(self): + pass + + def read(self): + raise TimeoutError("camera timeout") + + def close(self): + pass + + def _create(settings): + return TimeoutBackend(settings) + + monkeypatch.setattr(CameraFactory, "create", staticmethod(_create)) + + mc = MultiCameraController() + cam = CameraSettings(name="TimeoutCam", backend="opencv", index=0, enabled=True).apply_defaults() + + with qtbot.waitSignal(mc.camera_error, timeout=3000) as blocker: + mc.start([cam]) + + cam_id, msg = blocker.args + assert cam_id == get_camera_id(cam) + assert "Camera read timeout" in msg + + # Cleanup if still running. + if mc.is_running(): + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) diff --git a/tests/utils/test_display.py b/tests/utils/test_display.py index 9ce8d49e7..559aa1522 100644 --- a/tests/utils/test_display.py +++ b/tests/utils/test_display.py @@ -41,7 +41,9 @@ def test_compute_tiling_geometry_single_frame_respects_max_canvas_and_min_tile() def test_compute_tiling_geometry_two_frames_is_1x2(): frames = {"camB": _frame(480, 640, 3), "camA": _frame(480, 640, 3)} cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800)) - assert cam_ids == ["camA", "camB"] # sorted + + # Preserve insertion/display order, do not sort by camera ID. + assert cam_ids == ["camB", "camA"] assert (rows, cols) == (1, 2) assert tile_w >= 160 and tile_h >= 120 @@ -49,25 +51,106 @@ def test_compute_tiling_geometry_two_frames_is_1x2(): def test_compute_tiling_geometry_three_frames_is_2x2(): frames = {"c3": _frame(480, 640, 3), "c1": _frame(480, 640, 3), "c2": _frame(480, 640, 3)} cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800)) - assert cam_ids == ["c1", "c2", "c3"] + + # Preserve insertion/display order. + assert cam_ids == ["c3", "c1", "c2"] assert (rows, cols) == (2, 2) assert tile_w >= 160 and tile_h >= 120 -def test_compute_tiling_geometry_reference_aspect_is_first_sorted_cam(): - # camA has aspect 2.0 (w/h), camB has aspect 0.5 +def test_compute_tiling_geometry_reference_aspect_is_first_display_order_cam(): + # camB is first in insertion/display order and has aspect 0.5. + # camA has aspect 2.0. frames = { - "camB": _frame(400, 200, 3), - "camA": _frame(200, 400, 3), + "camB": _frame(400, 200, 3), # aspect = 200 / 400 = 0.5 + "camA": _frame(200, 400, 3), # aspect = 400 / 200 = 2.0 } + cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800)) - assert cam_ids == ["camA", "camB"] + + assert cam_ids == ["camB", "camA"] # For 2 cams, rows=1 cols=2 => initial tile_w=600 tile_h=800 => tile_aspect=0.75 - # frame_aspect for camA = 400/200 = 2.0 > 0.75 => tile_h adjusted to tile_w/frame_aspect = 600/2 = 300 + # frame_aspect for camB = 0.5 <= 0.75 => tile_w adjusted to tile_h * frame_aspect = 800 * 0.5 = 400 + assert (rows, cols) == (1, 2) + assert tile_w == 400 + assert tile_h == 800 + + +def test_compute_tiling_geometry_preserves_frame_insertion_order(): + frames = { + "gentl:serial:30220469": np.zeros((10, 20, 3), dtype=np.uint8), + "gentl:serial:10620051": np.zeros((10, 20, 3), dtype=np.uint8), + } + + cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames) + + assert cam_ids == ["gentl:serial:30220469", "gentl:serial:10620051"] + assert rows == 1 + assert cols == 2 + assert tile_w > 0 + assert tile_h > 0 + + +def test_compute_tiling_geometry_preserves_reversed_insertion_order(): + frames = { + "gentl:serial:10620051": np.zeros((10, 20, 3), dtype=np.uint8), + "gentl:serial:30220469": np.zeros((10, 20, 3), dtype=np.uint8), + } + + cam_ids, *_ = compute_tiling_geometry(frames) + + assert cam_ids == ["gentl:serial:10620051", "gentl:serial:30220469"] + + +def test_compute_tile_info_uses_display_order_for_offsets(): + cam0 = "gentl:serial:30220469" + cam1 = "gentl:serial:10620051" + + frames = { + cam0: np.zeros((100, 200, 3), dtype=np.uint8), + cam1: np.zeros((100, 200, 3), dtype=np.uint8), + } + + cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames) + + offset0, scale0 = compute_tile_info(cam0, frames[cam0], frames) + offset1, scale1 = compute_tile_info(cam1, frames[cam1], frames) + + assert cam_ids == [cam0, cam1] + assert offset0 == (0, 0) + assert offset1 == (tile_w, 0) + assert scale0[0] > 0 + assert scale0[1] > 0 + assert scale1[0] > 0 + assert scale1[1] > 0 + + +def test_create_tiled_frame_preserves_display_order_by_tile_content(): + # First frame is blue-ish, second is red-ish. + first = np.zeros((100, 100, 3), dtype=np.uint8) + first[:, :] = (255, 0, 0) # BGR blue + + second = np.zeros((100, 100, 3), dtype=np.uint8) + second[:, :] = (0, 0, 255) # BGR red + + frames = { + "gentl:serial:30220469": first, + "gentl:serial:10620051": second, + } + + cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(400, 200)) + out = create_tiled_frame(frames, max_canvas=(400, 200)) + + assert cam_ids == ["gentl:serial:30220469", "gentl:serial:10620051"] assert (rows, cols) == (1, 2) - assert tile_w == 600 - assert tile_h == 300 + + # Sample away from text label area. + left_sample = out[tile_h // 2, tile_w // 2] + right_sample = out[tile_h // 2, tile_w + tile_w // 2] + + assert left_sample[0] > left_sample[2] # blue tile first + assert right_sample[2] > right_sample[0] # red tile second def test_create_tiled_frame_empty_returns_default_canvas(): @@ -110,16 +193,18 @@ def test_create_tiled_frame_canvas_shape_matches_geometry(): def test_compute_tile_info_offset_and_scale_matches_tiling(): - # 2 frames => 1x2 tiling, cam ids sorted: ["cam1", "cam2"] + # 2 frames => 1x2 tiling, preserving insertion/display order: ["cam2", "cam1"] frames = {"cam2": _frame(200, 400, 3), "cam1": _frame(200, 400, 3)} cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800)) original = _frame(200, 400, 3) (ox, oy), (sx, sy) = compute_tile_info("cam2", original, frames, max_canvas=(1200, 800)) - # cam2 is index 1 -> row 0 col 1 + assert cam_ids == ["cam2", "cam1"] assert (rows, cols) == (1, 2) - assert ox == tile_w + + # cam2 is first in display order => row 0 col 0 + assert ox == 0 assert oy == 0 assert sx == pytest.approx(tile_w / 400) assert sy == pytest.approx(tile_h / 200) From cf7a23795b7f2106b95fcade860b945bdd13ed27 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:08:00 +0200 Subject: [PATCH 022/133] Mock _maybe_allow_keyboard_interrupt in GUI tests Patch tests/gui/test_app_entrypoint.py to monkeypatch appmod._maybe_allow_keyboard_interrupt with a MagicMock in both test_main_with_splash and test_main_without_splash. This prevents the real interrupt-handling helper from running during GUI tests and avoids side effects on global keyboard/signal handling. --- tests/gui/test_app_entrypoint.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/gui/test_app_entrypoint.py b/tests/gui/test_app_entrypoint.py index 0a68bb2ef..b6b1c6051 100644 --- a/tests/gui/test_app_entrypoint.py +++ b/tests/gui/test_app_entrypoint.py @@ -31,6 +31,7 @@ def set_use_splash_false(monkeypatch): @pytest.mark.gui def test_main_with_splash(monkeypatch, set_use_splash_true): appmod = _import_fresh() + monkeypatch.setattr(appmod, "_maybe_allow_keyboard_interrupt", MagicMock(name="_maybe_allow_keyboard_interrupt")) # --- Patch Qt app & icon in the entry module's namespace --- QApplication_cls = MagicMock(name="QApplication") @@ -101,6 +102,7 @@ def immediate_single_shot(ms, fn): @pytest.mark.gui def test_main_without_splash(monkeypatch, set_use_splash_false): appmod = _import_fresh() + monkeypatch.setattr(appmod, "_maybe_allow_keyboard_interrupt", MagicMock(name="_maybe_allow_keyboard_interrupt")) # Patch Qt app creation & window icon QApplication_cls = MagicMock(name="QApplication") From f56710362203ed66d3effc6096387785a2051528 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:21:09 +0200 Subject: [PATCH 023/133] Support strict GenTL trigger and defaults GenTLCameraBackend: add handling for a 'strict' flag when parsing GenTL trigger config so invalid configs raise in strict mode but fall back with a warning otherwise. Preserve the original exception when raising, and improve the warning text to mention strict mode. Return whether LineSelector was actually set and skip configuring trigger output if selection failed to avoid driving an unintended GPIO line. DLCLiveMainWindow: introduce _with_camera_trigger_defaults_for_save to ensure gentl trigger defaults are stored per camera, refactor _with_camera_defaults_for_save to apply that per-camera, and adjust _current_config to use the first camera with defaults applied. This ensures trigger settings are persisted and validated consistently. --- dlclivegui/cameras/backends/gentl_backend.py | 32 ++++++++++++++-- dlclivegui/gui/main_window.py | 39 ++++++++++++-------- 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 18c17d23c..d97da4925 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -106,10 +106,20 @@ def __init__(self, settings): self._gain = self._positive_float(ns.get("gain", props.get("gain"))) self._timeout: float = float(ns.get("timeout", props.get("timeout", 2.0))) + raw_trigger = ns.get("trigger", props.get("trigger")) + raw_trigger_strict = isinstance(raw_trigger, dict) and bool(raw_trigger.get("strict", False)) + try: - self._trigger = CameraTriggerSettings.from_any(ns.get("trigger", props.get("trigger"))) + self._trigger = CameraTriggerSettings.from_any(raw_trigger) except Exception as exc: - LOG.warning("Invalid GenTL trigger config; falling back to trigger role=off: %s", exc) + if raw_trigger_strict: + raise ValueError(f"Strict mode failure - Invalid GenTL trigger configuration: {exc}") from exc + + LOG.warning( + "Invalid GenTL trigger config; falling back to trigger role=off: %s. " + "Enable strict mode to force this to raise.", + exc, + ) self._trigger = CameraTriggerSettings() trigger_timeout = self._positive_float(self._trigger_attr(self._trigger, "timeout", None)) @@ -1145,7 +1155,23 @@ def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> N # Master camera runs freerun and exposes an output signal. self._configure_trigger_off(node_map, strict=False) - self._set_enum_node(node_map, "LineSelector", output_line, strict=strict) + line_selected = self._set_enum_node( + node_map, + "LineSelector", + output_line, + strict=strict, + ) + + # In non-strict mode, do not continue configuring output behavior if the + # requested line could not be selected. Otherwise we may accidentally drive + # whichever GPIO line the camera had selected previously/defaulted to. + if not line_selected: + LOG.warning( + "Could not select GenTL output line '%s'; skipping trigger output configuration.", + output_line, + ) + return + self._set_enum_node(node_map, "LineMode", "Output", strict=strict) self._set_enum_node(node_map, "LineSource", output_source, strict=strict) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index def609413..29516814b 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -855,31 +855,40 @@ def _apply_config(self, config: ApplicationSettings) -> None: # Update recording path preview self._update_recording_path_preview() - def _with_camera_defaults_for_save(self, settings: MultiCameraSettings) -> MultiCameraSettings: - out = settings.model_copy(deep=True) + def _with_camera_trigger_defaults_for_save(self, cam: CameraSettings) -> CameraSettings: + out = cam.model_copy(deep=True) - for cam in out.cameras: - backend = (cam.backend or "").lower() - if backend != "gentl": - continue + backend = (out.backend or "").lower() + if backend != "gentl": + return out - if not isinstance(cam.properties, dict): - cam.properties = {} + if not isinstance(out.properties, dict): + out.properties = {} - ns = cam.properties.setdefault("gentl", {}) - if not isinstance(ns, dict): - ns = {} - cam.properties["gentl"] = ns + ns = out.properties.setdefault("gentl", {}) + if not isinstance(ns, dict): + ns = {} + out.properties["gentl"] = ns - ns.setdefault("trigger", CameraTriggerSettings().model_dump(exclude_none=True)) + ns.setdefault("trigger", CameraTriggerSettings().model_dump(exclude_none=True)) + + return out + + def _with_camera_defaults_for_save(self, settings: MultiCameraSettings) -> MultiCameraSettings: + out = settings.model_copy(deep=True) + + out.cameras = [self._with_camera_trigger_defaults_for_save(cam) for cam in out.cameras] return out def _current_config(self, *, allow_empty_model_path=False) -> ApplicationSettings: # Get the first camera from multi-camera config for backward compatibility multi_camera = self._with_camera_defaults_for_save(self._config.multi_camera) - active_cameras = self._config.multi_camera.get_active_cameras() - camera = active_cameras[0] if active_cameras else CameraSettings() + camera = ( + multi_camera.cameras[0].model_copy(deep=True) + if multi_camera.cameras + else self._with_camera_trigger_defaults_for_save(self._config.camera) + ) return ApplicationSettings( camera=camera, From ea49e5abaa2f04852ec3d4de0fd64fd0ecc2831c Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:30:07 +0200 Subject: [PATCH 024/133] Apply gentl trigger defaults when saving Add with_save_defaults helpers to CameraSettings and MultiCameraSettings and use them when serializing ApplicationSettings so gentl "trigger" defaults are applied to saved configs. Remove duplicated camera-default helper logic from DLCLiveMainWindow and simplify _current_config to rely on model methods. Add unit tests to verify gentl trigger defaults are included for top-level and multi-camera cases. --- dlclivegui/config.py | 44 +++++++++++++++++++++++++++++++---- dlclivegui/gui/main_window.py | 32 ++----------------------- tests/test_config.py | 36 ++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 35 deletions(-) create mode 100644 tests/test_config.py diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 154eabf40..c7a474b8f 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -190,6 +190,25 @@ def set_trigger_settings(self, trigger: CameraTriggerSettings, backend: str | No self.properties[str(key).lower()] = ns ns["trigger"] = trigger.to_properties() + def with_save_defaults(self) -> CameraSettings: + out = self.model_copy(deep=True) + + backend = (out.backend or "").lower() + if backend != "gentl": + return out + + if not isinstance(out.properties, dict): + out.properties = {} + + ns = out.properties.setdefault("gentl", {}) + if not isinstance(ns, dict): + ns = {} + out.properties["gentl"] = ns + + ns.setdefault("trigger", CameraTriggerSettings().to_properties()) + + return out + class CameraTriggerSettings(BaseModel): """ @@ -302,12 +321,19 @@ def from_dict(cls, data: dict[str, Any]) -> MultiCameraSettings: return cls(cameras=cameras, max_cameras=max_cameras, tile_layout=tile_layout) def to_dict(self) -> dict[str, Any]: + out = self.with_save_defaults() return { - "cameras": [cam.model_dump() for cam in self.cameras], - "max_cameras": self.max_cameras, - "tile_layout": self.tile_layout, + "cameras": [cam.model_dump() for cam in out.cameras], + "max_cameras": out.max_cameras, + "tile_layout": out.tile_layout, } + def with_save_defaults(self) -> MultiCameraSettings: + """Return a copy with save defaults applied to all cameras.""" + out = self.model_copy(deep=True) + out.cameras = [cam.with_save_defaults() for cam in out.cameras] + return out + class DynamicCropModel(BaseModel): enabled: bool = False @@ -473,10 +499,18 @@ def from_dict(cls, data: dict[str, Any]) -> ApplicationSettings: ) def to_dict(self) -> dict[str, Any]: + multi_camera = self.multi_camera.with_save_defaults() + active_cameras = multi_camera.get_active_cameras() + + if active_cameras: + camera = active_cameras[0].model_copy(deep=True) + else: + camera = self.camera.with_save_defaults() + return { "version": self.version, - "camera": self.camera.model_dump(), - "multi_camera": self.multi_camera.to_dict(), + "camera": camera.model_dump(), + "multi_camera": multi_camera.to_dict(), "dlc": self.dlc.model_dump(), "recording": self.recording.model_dump(), "bbox": self.bbox.model_dump(), diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 29516814b..4dbf83050 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -56,7 +56,6 @@ ApplicationSettings, BoundingBoxSettings, CameraSettings, - CameraTriggerSettings, DLCProcessorSettings, MultiCameraSettings, RecordingSettings, @@ -855,39 +854,12 @@ def _apply_config(self, config: ApplicationSettings) -> None: # Update recording path preview self._update_recording_path_preview() - def _with_camera_trigger_defaults_for_save(self, cam: CameraSettings) -> CameraSettings: - out = cam.model_copy(deep=True) - - backend = (out.backend or "").lower() - if backend != "gentl": - return out - - if not isinstance(out.properties, dict): - out.properties = {} - - ns = out.properties.setdefault("gentl", {}) - if not isinstance(ns, dict): - ns = {} - out.properties["gentl"] = ns - - ns.setdefault("trigger", CameraTriggerSettings().model_dump(exclude_none=True)) - - return out - - def _with_camera_defaults_for_save(self, settings: MultiCameraSettings) -> MultiCameraSettings: - out = settings.model_copy(deep=True) - - out.cameras = [self._with_camera_trigger_defaults_for_save(cam) for cam in out.cameras] - - return out - def _current_config(self, *, allow_empty_model_path=False) -> ApplicationSettings: - # Get the first camera from multi-camera config for backward compatibility - multi_camera = self._with_camera_defaults_for_save(self._config.multi_camera) + multi_camera = self._config.multi_camera camera = ( multi_camera.cameras[0].model_copy(deep=True) if multi_camera.cameras - else self._with_camera_trigger_defaults_for_save(self._config.camera) + else self._config.camera.model_copy(deep=True) ) return ApplicationSettings( diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 000000000..f0165f45e --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,36 @@ +import pytest + +from dlclivegui.config import ApplicationSettings, CameraSettings, MultiCameraSettings + + +@pytest.mark.unit +def test_save_applies_gentl_trigger_defaults_to_top_level_camera(): + cam = CameraSettings( + backend="gentl", + properties={"gentl": {}}, + ) + + settings = ApplicationSettings( + camera=cam, + multi_camera=MultiCameraSettings(cameras=[cam]), + ) + + data = settings.to_dict() + + assert "trigger" in data["camera"]["properties"]["gentl"] + + +@pytest.mark.unit +def test_save_applies_gentl_trigger_defaults_to_multi_camera(): + cam = CameraSettings( + backend="gentl", + properties={"gentl": {}}, + ) + + settings = ApplicationSettings( + multi_camera=MultiCameraSettings(cameras=[cam]), + ) + + data = settings.to_dict() + + assert "trigger" in data["multi_camera"]["cameras"][0]["properties"]["gentl"] From b073ce3a27b39f905adbd8c5fa68d08826696d68 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:36:54 +0200 Subject: [PATCH 025/133] Don't sort available camera IDs Remove sorting when building available_ids so the original frame key order is preserved. The DLC camera selection relies on the first active camera in frame_data.frames; using list(...) keeps the insertion/order semantics (dict order) instead of reordering keys with sorted(). This avoids unintended changes to camera priority caused by alphabetical/numeric sorting. --- dlclivegui/gui/main_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 4dbf83050..0b8a215ef 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1384,7 +1384,7 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: # Determine DLC camera (first active camera) selected_id = self._inference_camera_id - available_ids = sorted(frame_data.frames.keys()) + available_ids = list(frame_data.frames.keys()) if selected_id in frame_data.frames: dlc_cam_id = selected_id else: From cd562d54b78e9755e1e2d225fc26b6887f5f0dea Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 16:02:13 +0200 Subject: [PATCH 026/133] Preserve DLC config using model_copy Initialize an existing DLC config (falling back to DEFAULT_CONFIG if unset) and use its model_copy(update=...) to return settings. This replaces explicit field-by-field construction so only model_path (and model_type when set) are changed, preserving other DLC options and avoiding duplication. --- dlclivegui/gui/main_window.py | 38 ++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 0b8a215ef..4f07bd7b3 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -883,19 +883,23 @@ def _dlc_settings_from_ui(self, *, allow_empty_model_path=False) -> DLCProcessor # IMPORTANT NOTE: DLClive expects a directory for TensorFlow models, # so if user selects a .pb file, we should pass the parent directory to DLCLive model_path = str(Path(model_path).parent) + + existing_dlc = ( # explicitly init from default if unset + self._config.dlc.model_copy(deep=True) + if getattr(self._config, "dlc", None) is not None + else DEFAULT_CONFIG.dlc.model_copy(deep=True) + ) if not model_path: if allow_empty_model_path: - return DLCProcessorSettings( - model_path="", - model_directory=self._config.dlc.model_directory, # Preserve from config - device=self._config.dlc.device, # Preserve from config - dynamic=self._config.dlc.dynamic, # Preserve from config - resize=self._config.dlc.resize, # Preserve from config - precision=self._config.dlc.precision, # Preserve from config - model_type=None, - # additional_options=self._parse_json(self.additional_options_edit.toPlainText()), + # Preserve all existing DLC settings and only clear the model path. + return existing_dlc.model_copy( + update={ + "model_path": "", + } ) + raise ValueError("Model path cannot be empty. Please enter a valid path to a DLCLive model file.") + try: model_bknd = DLCLiveProcessor.get_model_backend(model_path) except Exception as e: @@ -904,15 +908,13 @@ def _dlc_settings_from_ui(self, *, allow_empty_model_path=False) -> DLCProcessor "Please ensure the model file is valid and has an appropriate extension " "(.pt, .pth for PyTorch or model directory for TensorFlow)." ) from e - return DLCProcessorSettings( - model_path=model_path, - model_directory=self._config.dlc.model_directory, # Preserve from config - device=self._config.dlc.device, # Preserve from config - dynamic=self._config.dlc.dynamic, # Preserve from config - resize=self._config.dlc.resize, # Preserve from config - precision=self._config.dlc.precision, # Preserve from config - model_type=model_bknd, - # additional_options=self._parse_json(self.additional_options_edit.toPlainText()), + + # Preserve all unchanged DLC settings and only update values derived from the UI. + return existing_dlc.model_copy( + update={ + "model_path": model_path, + "model_type": model_bknd, + } ) def _recording_settings_from_ui(self) -> RecordingSettings: From 0cc318ba2a5d6839754fc80b97d815f7a1989d7b Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 16:56:14 +0200 Subject: [PATCH 027/133] Warn when GenTL TriggerMode fails to enable Add a warning log when setting GenTL TriggerMode to 'On' fails in non-strict mode. Previously the code only raised an exception in strict mode; now it emits a warning to inform users that the trigger mode may not be correctly configured when continuing without strict enforcement. --- dlclivegui/cameras/backends/gentl_backend.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index d97da4925..d7811fc7d 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1139,6 +1139,8 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No if not self._set_enum_node(node_map, "TriggerMode", "On", strict=strict): if strict: raise RuntimeError("Could not enable GenTL TriggerMode=On") + else: + LOG.warning("Could not enable GenTL TriggerMode=On; trigger mode may not be correctly configured.") LOG.info( "GenTL trigger input configured: role=%s selector=%s source=%s activation=%s", From 973d69fe67e709bdcbc91723d6632e8d67803879 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 28 May 2026 17:47:00 +0200 Subject: [PATCH 028/133] Improve GenTL trigger routing safety and tests Make GenTLCameraBackend trigger configuration more robust and adjust tests. - Use the waits_for_hardware_trigger property when converting GenTL timeouts to user-facing errors instead of inferring from the role string. - Read and record the configured trigger role early in _configure_trigger_input. - Check results when setting TriggerSelector, TriggerSource and TriggerActivation (selector_ok, source_ok, activation_ok). If selector/source routing fails in non-strict mode, disable the trigger, reset internal trigger state and log a warning to avoid arming the camera on a previous/default input line. If activation fails, warn and continue using the camera default. - If enabling TriggerMode=On fails, disable the trigger and reset internal state instead of only warning. - Validate LineMode and LineSource when configuring master output and log if configuration is incomplete. Tests updated to reflect safety changes: - Expect waits_for_hardware_trigger to be set for external input configuration. - Rename and change a non-strict invalid-source test to assert the trigger is disabled, waits_for_hardware_trigger is False, and trigger_actual is persisted as off. - Add a new test ensuring an invalid selector in non-strict mode disables the trigger and persists the off state. These changes prevent the camera from being left armed on an unintended/default input if routing nodes could not be applied, improving safety and predictability. --- dlclivegui/cameras/backends/gentl_backend.py | 64 ++++++++++++++++---- tests/cameras/backends/test_gentl_trigger.py | 49 +++++++++++++-- 2 files changed, 98 insertions(+), 15 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index d7811fc7d..a09a27376 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -498,8 +498,7 @@ def read(self) -> tuple[np.ndarray, float]: except ValueError: frame = array.copy() except HarvesterTimeoutError as exc: - role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() - if role in {"external", "follower"}: + if self.waits_for_hardware_trigger: raise TimeoutError(str(exc) + " (GenTL timeout; waiting for hardware trigger?)") from exc raise TimeoutError(str(exc) + " (GenTL timeout)") from exc @@ -1123,6 +1122,7 @@ def _configure_trigger_off(self, node_map, *, strict: bool = False) -> None: self._set_enum_node(node_map, "TriggerMode", "Off", strict=strict) def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> None: + role = str(self._trigger_attr(cfg, "role", "external") or "external").strip().lower() selector = str(self._trigger_attr(cfg, "selector", "FrameStart") or "FrameStart") source = str(self._trigger_attr(cfg, "source", "Line0") or "Line0") activation = str(self._trigger_attr(cfg, "activation", "RisingEdge") or "RisingEdge") @@ -1130,17 +1130,51 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No # Disable trigger while changing trigger-related nodes. self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) - self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) - self._set_enum_node(node_map, "TriggerSource", source, strict=strict) - self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict) + selector_ok = self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) + source_ok = self._set_enum_node(node_map, "TriggerSource", source, strict=strict) + activation_ok = self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict) + + # TriggerSelector and TriggerSource are required routing nodes. + # If either failed in non-strict mode, do not arm TriggerMode=On. + # Otherwise the camera may wait on a previous/default input line. + if not (selector_ok and source_ok): + LOG.warning( + "Could not apply GenTL trigger input routing " + "(selector_ok=%s, source_ok=%s); disabling trigger. " + "requested role=%s selector=%s source=%s activation=%s", + selector_ok, + source_ok, + role, + selector, + source, + activation, + ) + self._configure_trigger_off(node_map, strict=False) + self._trigger = CameraTriggerSettings() + return + + if not activation_ok: + LOG.warning( + "Could not apply GenTL TriggerActivation=%s; using camera default/current activation.", + activation, + ) self._set_enum_node(node_map, "AcquisitionMode", "Continuous", strict=False) if not self._set_enum_node(node_map, "TriggerMode", "On", strict=strict): - if strict: - raise RuntimeError("Could not enable GenTL TriggerMode=On") - else: - LOG.warning("Could not enable GenTL TriggerMode=On; trigger mode may not be correctly configured.") + LOG.warning("Could not enable GenTL TriggerMode=On; disabling trigger.") + self._configure_trigger_off(node_map, strict=False) + self._trigger = CameraTriggerSettings() + return + + LOG.info( + "GenTL trigger input configured: role=%s selector=%s source=%s activation=%s activation_ok=%s", + role, + selector, + source, + activation, + activation_ok, + ) LOG.info( "GenTL trigger input configured: role=%s selector=%s source=%s activation=%s", @@ -1174,8 +1208,16 @@ def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> N ) return - self._set_enum_node(node_map, "LineMode", "Output", strict=strict) - self._set_enum_node(node_map, "LineSource", output_source, strict=strict) + mode_ok = self._set_enum_node(node_map, "LineMode", "Output", strict=strict) + source_ok = self._set_enum_node(node_map, "LineSource", output_source, strict=strict) + + if not (mode_ok and source_ok): + LOG.warning( + "GenTL trigger master output configuration incomplete (LineMode ok=%s, LineSource ok=%s).", + mode_ok, + source_ok, + ) + return LOG.info( "GenTL trigger master configured: output_line=%s output_source=%s", diff --git a/tests/cameras/backends/test_gentl_trigger.py b/tests/cameras/backends/test_gentl_trigger.py index f0d5f4528..5d774af5e 100644 --- a/tests/cameras/backends/test_gentl_trigger.py +++ b/tests/cameras/backends/test_gentl_trigger.py @@ -75,6 +75,7 @@ def test_trigger_external_configures_input_line_and_timeout(patch_gentl_sdk, gen assert nm.TriggerSource.value == "Line0" assert nm.TriggerActivation.value == "RisingEdge" assert nm.TriggerMode.value == "On" + assert be.waits_for_hardware_trigger is True assert be._timeout == pytest.approx(10.0) ns = settings.properties["gentl"] @@ -140,7 +141,7 @@ def test_trigger_master_configures_output_line_and_keeps_trigger_off(patch_gentl be.close() -def test_trigger_invalid_source_non_strict_does_not_crash(patch_gentl_sdk, gentl_settings_factory): +def test_trigger_invalid_source_non_strict_disables_trigger(patch_gentl_sdk, gentl_settings_factory): gb = patch_gentl_sdk settings = _gentl_trigger_settings( @@ -158,9 +159,17 @@ def test_trigger_invalid_source_non_strict_does_not_crash(patch_gentl_sdk, gentl # Source was unsupported, so the fake node should retain its default. assert nm.TriggerSource.value == "Line0" - # Non-strict mode should still allow opening; TriggerMode may be enabled - # because TriggerSource failure is best-effort in this mode. - assert be._acquirer is not None + + # Safety behavior: do not arm TriggerMode on the previous/default source. + assert nm.TriggerMode.value == "Off" + + # Controller should not treat timeouts as expected trigger waits. + assert be.waits_for_hardware_trigger is False + + # trigger_actual is persisted after _configure_trigger(); since we reset + # self._trigger to off, the effective trigger state is off. + actual = settings.properties["gentl"]["trigger_actual"] + assert actual["role"] == "off" be.close() @@ -331,3 +340,35 @@ def test_trigger_actual_is_persisted_for_debugging(patch_gentl_sdk, gentl_settin assert actual["timeout"] == pytest.approx(9.0) be.close() + + +def test_trigger_invalid_selector_non_strict_disables_trigger(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "selector": "NotARealSelector", + "source": "Line1", + "strict": False, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + # Selector was unsupported, so the fake node should retain its default. + assert nm.TriggerSelector.value == "FrameStart" + + # Source may have been applied, but trigger must not be armed because + # the required selector routing failed. + assert nm.TriggerSource.value == "Line1" + assert nm.TriggerMode.value == "Off" + assert be.waits_for_hardware_trigger is False + + actual = settings.properties["gentl"]["trigger_actual"] + assert actual["role"] == "off" + + be.close() From 7942066c9067f0773f39775e93c1e547272b7c4f Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 28 May 2026 17:54:22 +0200 Subject: [PATCH 029/133] Cap hardware-trigger fetch timeout and update tests Introduce a MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT and cap Harvester.fetch() timeouts when the trigger role waits for external hardware (roles: external, follower) to keep individual fetch calls short and allow prompt shutdown. Preserve legacy behavior for non-waiting roles (e.g. master). Remove a noisy Trigger input LOG.info call. Update tests to expect the capped fetch timeout, verify the original requested timeout is still persisted in trigger_actual, add a test that master mode is not capped, and make test resource cleanup more robust (use try/finally around open/close). --- dlclivegui/cameras/backends/gentl_backend.py | 24 +++-- tests/cameras/backends/test_gentl_trigger.py | 106 ++++++++++++++----- 2 files changed, 93 insertions(+), 37 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index a09a27376..1c63c609b 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -76,6 +76,11 @@ class GenTLCameraBackend(CameraBackend): _CTI_FILES_SOURCE_AUTO: ClassVar[str] = "auto" _CTI_FILES_SOURCE_USER: ClassVar[str] = "user" + # Keep individual Harvester.fetch() calls short enough that controller + # shutdown can stop worker threads promptly. Hardware-trigger waits are + # handled by repeated polling in SingleCameraWorker. + _MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT: ClassVar[float] = 1.0 + def __init__(self, settings): super().__init__(settings) @@ -124,7 +129,16 @@ def __init__(self, settings): trigger_timeout = self._positive_float(self._trigger_attr(self._trigger, "timeout", None)) if trigger_timeout is not None: - self._timeout = float(trigger_timeout) + role = str(self._trigger_attr(self._trigger, "role", "off") or "off").strip().lower() + + if role in {"external", "follower"}: + # Do not let a long hardware-trigger wait block shutdown. + # SingleCameraWorker treats these fetch timeouts as expected + # polling misses while waits_for_hardware_trigger is true. + self._timeout = min(float(trigger_timeout), self._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT) + else: + # For non-trigger-waiting modes, preserve legacy behavior. + self._timeout = float(trigger_timeout) self._requested_resolution: tuple[int, int] | None = self._get_requested_resolution_or_none() @@ -1176,14 +1190,6 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No activation_ok, ) - LOG.info( - "GenTL trigger input configured: role=%s selector=%s source=%s activation=%s", - self._trigger_attr(cfg, "role", "external"), - selector, - source, - activation, - ) - def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> None: output_line = str(self._trigger_attr(cfg, "output_line", "Line2") or "Line2") output_source = str(self._trigger_attr(cfg, "output_source", "ExposureActive") or "ExposureActive") diff --git a/tests/cameras/backends/test_gentl_trigger.py b/tests/cameras/backends/test_gentl_trigger.py index 5d774af5e..e060035a4 100644 --- a/tests/cameras/backends/test_gentl_trigger.py +++ b/tests/cameras/backends/test_gentl_trigger.py @@ -63,7 +63,7 @@ def test_trigger_external_configures_input_line_and_timeout(patch_gentl_sdk, gen "selector": "FrameStart", "source": "Line0", "activation": "RisingEdge", - "timeout": 10.0, + "timeout": gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT, }, ) be = gb.GenTLCameraBackend(settings) @@ -76,7 +76,7 @@ def test_trigger_external_configures_input_line_and_timeout(patch_gentl_sdk, gen assert nm.TriggerActivation.value == "RisingEdge" assert nm.TriggerMode.value == "On" assert be.waits_for_hardware_trigger is True - assert be._timeout == pytest.approx(10.0) + assert be._timeout == pytest.approx(gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT) ns = settings.properties["gentl"] assert ns["trigger_actual"]["role"] == "external" @@ -265,8 +265,12 @@ def test_trigger_alias_on_maps_to_external(patch_gentl_sdk, gentl_settings_facto be.close() -def test_trigger_timeout_overrides_default_fetch_timeout(patch_gentl_sdk, gentl_settings_factory): +def test_trigger_timeout_is_capped_for_hardware_trigger_fetch_polling( + patch_gentl_sdk, + gentl_settings_factory, +): gb = patch_gentl_sdk + expected_fetch_timeout = gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT settings = _gentl_trigger_settings( gentl_settings_factory, @@ -277,18 +281,30 @@ def test_trigger_timeout_overrides_default_fetch_timeout(patch_gentl_sdk, gentl_ ) be = gb.GenTLCameraBackend(settings) - be.open() - assert be._timeout == pytest.approx(7.5) + try: + be.open() - # Fake acquisition is started, so read should pass and record the timeout. - frame, _ = be.read() - assert frame is not None - assert be._acquirer.fetch_calls[-1] == pytest.approx(7.5) + # Hardware-trigger fetch calls are intentionally capped so stop(wait=True) + # is not blocked by a long user trigger timeout. + assert be._timeout == pytest.approx(expected_fetch_timeout) - be.close() + # Fake acquisition is started, so read should pass and record the capped timeout. + frame, _ = be.read() + assert frame is not None + assert be._acquirer.fetch_calls[-1] == pytest.approx(expected_fetch_timeout) + + # The requested trigger timeout is still preserved in persisted trigger_actual. + actual = settings.properties["gentl"]["trigger_actual"] + assert actual["timeout"] == pytest.approx(7.5) + finally: + be.close() -def test_trigger_timeout_error_mentions_hardware_trigger_when_waiting(patch_gentl_sdk, gentl_settings_factory): + +def test_trigger_timeout_error_mentions_hardware_trigger_when_waiting( + patch_gentl_sdk, + gentl_settings_factory, +): gb = patch_gentl_sdk settings = _gentl_trigger_settings( @@ -297,22 +313,27 @@ def test_trigger_timeout_error_mentions_hardware_trigger_when_waiting(patch_gent "role": "external", "timeout": 3.0, }, - # fast_start keeps acquisition stopped; fake fetch then raises timeout. - # This lets us assert the backend timeout message without hardware. ) + # fast_start keeps acquisition stopped; fake fetch then raises timeout. + # This lets us assert the backend timeout message without hardware. settings.properties["gentl"]["fast_start"] = True + be = gb.GenTLCameraBackend(settings) - be.open() + try: + be.open() - with pytest.raises(TimeoutError) as ei: - be.read() + assert be._timeout == pytest.approx(gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT) - msg = str(ei.value).lower() - assert "gentl timeout" in msg - assert "hardware trigger" in msg or "trigger" in msg + with pytest.raises(TimeoutError) as ei: + be.read() - be.close() + msg = str(ei.value).lower() + assert "gentl timeout" in msg + assert "hardware trigger" in msg or "trigger" in msg + + finally: + be.close() def test_trigger_actual_is_persisted_for_debugging(patch_gentl_sdk, gentl_settings_factory): @@ -330,16 +351,22 @@ def test_trigger_actual_is_persisted_for_debugging(patch_gentl_sdk, gentl_settin ) be = gb.GenTLCameraBackend(settings) - be.open() + try: + be.open() - actual = settings.properties["gentl"].get("trigger_actual") - assert isinstance(actual, dict) - assert actual["role"] == "follower" - assert actual["source"] == "Line1" - assert actual["activation"] == "FallingEdge" - assert actual["timeout"] == pytest.approx(9.0) + # Requested timeout remains in trigger_actual for debugging/config visibility. + actual = settings.properties["gentl"].get("trigger_actual") + assert isinstance(actual, dict) + assert actual["role"] == "follower" + assert actual["source"] == "Line1" + assert actual["activation"] == "FallingEdge" + assert actual["timeout"] == pytest.approx(9.0) - be.close() + # But each blocking Harvester.fetch() call is capped for responsive shutdown. + assert be._timeout == pytest.approx(gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT) + + finally: + be.close() def test_trigger_invalid_selector_non_strict_disables_trigger(patch_gentl_sdk, gentl_settings_factory): @@ -372,3 +399,26 @@ def test_trigger_invalid_selector_non_strict_disables_trigger(patch_gentl_sdk, g assert actual["role"] == "off" be.close() + + +def test_trigger_timeout_not_capped_for_master_mode(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "master", + "timeout": 7.5, + }, + ) + be = gb.GenTLCameraBackend(settings) + + try: + be.open() + + # Master is free-running / trigger-generating, not waiting for hardware input. + assert be.waits_for_hardware_trigger is False + assert be._timeout == pytest.approx(7.5) + + finally: + be.close() From 93c2a730d794c834fbbbf7b19595a729390102e7 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 10:19:58 +0200 Subject: [PATCH 030/133] Interruptible camera waits; simplify config save Make SingleCameraWorker sleep calls interruptible by using self._stop_event.wait() instead of time.sleep(), and add a small _trigger_timeout_delay to allow early exit during trigger/wait cycles. This prevents the worker from being unresponsive to stop requests during retry and trigger waits. Also simplify ApplicationSettings.to_dict() to always start from self.camera.with_save_defaults() and stop overriding it with active multi-camera selection, ensuring consistent camera settings are saved. --- dlclivegui/config.py | 7 +------ dlclivegui/services/multi_camera_controller.py | 14 ++++++++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index c7a474b8f..6f204accc 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -499,13 +499,8 @@ def from_dict(cls, data: dict[str, Any]) -> ApplicationSettings: ) def to_dict(self) -> dict[str, Any]: + camera = self.camera.with_save_defaults() multi_camera = self.multi_camera.with_save_defaults() - active_cameras = multi_camera.get_active_cameras() - - if active_cameras: - camera = active_cameras[0].model_copy(deep=True) - else: - camera = self.camera.with_save_defaults() return { "version": self.version, diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 17af14e61..cd13ae03b 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -4,7 +4,6 @@ import copy import logging -import time from dataclasses import dataclass from threading import Event, Lock @@ -49,6 +48,7 @@ def __init__(self, camera_id: str, settings: CameraSettings): self._backend: CameraBackend | None = None self._max_consecutive_errors = 5 self._retry_delay = 0.1 + self._trigger_timeout_delay = 0.05 @Slot() def run(self) -> None: @@ -93,7 +93,8 @@ def run(self) -> None: self._camera_id, "Too many empty frames.\nWas the device disconnected ?" ) break - time.sleep(self._retry_delay) + if self._stop_event.wait(self._retry_delay): + break continue consecutive_errors = 0 @@ -113,13 +114,17 @@ def run(self) -> None: exc, ) consecutive_errors = 0 + + if self._stop_event.wait(self._trigger_timeout_delay): + break # Stop event set during wait continue consecutive_errors += 1 if consecutive_errors >= self._max_consecutive_errors: self.error_occurred.emit(self._camera_id, f"Camera read timeout: {exc}") break - time.sleep(self._retry_delay) + if self._stop_event.wait(self._retry_delay): + break continue except Exception as exc: @@ -129,7 +134,8 @@ def run(self) -> None: if consecutive_errors >= self._max_consecutive_errors: self.error_occurred.emit(self._camera_id, f"Camera read error: {exc}") break - time.sleep(self._retry_delay) + if self._stop_event.wait(self._retry_delay): + break continue # Cleanup From 96354ec363efcea931762fe45a7bcd14540eac3d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 10:32:12 +0200 Subject: [PATCH 031/133] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- dlclivegui/gui/main_window.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 4f07bd7b3..673e801d0 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -856,11 +856,15 @@ def _apply_config(self, config: ApplicationSettings) -> None: def _current_config(self, *, allow_empty_model_path=False) -> ApplicationSettings: multi_camera = self._config.multi_camera + active_cameras = multi_camera.get_active_cameras() camera = ( - multi_camera.cameras[0].model_copy(deep=True) - if multi_camera.cameras - else self._config.camera.model_copy(deep=True) - ) + active_cameras[0].model_copy(deep=True) + if active_cameras + else ( + multi_camera.cameras[0].model_copy(deep=True) + if multi_camera.cameras + else self._config.camera.model_copy(deep=True) + ) return ApplicationSettings( camera=camera, From fe826a1872a2774c8e79ef3341aa8a95e90b9c8d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 10:34:52 +0200 Subject: [PATCH 032/133] Fix broken suggestion --- dlclivegui/gui/main_window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 673e801d0..c16964bf7 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -865,6 +865,7 @@ def _current_config(self, *, allow_empty_model_path=False) -> ApplicationSetting if multi_camera.cameras else self._config.camera.model_copy(deep=True) ) + ) return ApplicationSettings( camera=camera, From d44bf18814d6c6786221df224680af2290e5f13b Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 10:55:11 +0200 Subject: [PATCH 033/133] Resolve 'auto' trigger source in GenTL backend Add resolution logic for trigger source: implement _resolve_trigger_source to map the model-level default 'auto' to the first supported camera line (Line0/Line1/Line2/Any), return whether the resolved value is supported, and log/warn or raise depending on strict mode. Integrate this into _configure_pixel_format/_configure trigger flow so TriggerSource is only set if resolution succeeded. Change CameraTriggerSettings default source to 'auto' and add a field validator to coerce common aliases (e.g. 'default', 'automatic', 'device', 'camera') to 'auto'. Update tests and fixtures: adjust fake node default TriggerSource to Line1, update expectations, and add unit tests for auto-selection behavior and strict-mode error. This makes trigger configuration more robust across cameras with differing GenICam enum symbolics. --- dlclivegui/cameras/backends/gentl_backend.py | 48 +++++++++++++++++++- dlclivegui/config.py | 20 +++++++- tests/cameras/backends/conftest.py | 2 +- tests/cameras/backends/test_gentl_trigger.py | 38 +++++++++++++++- tests/test_config.py | 9 +++- 5 files changed, 112 insertions(+), 5 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 1c63c609b..0992db01f 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1056,6 +1056,51 @@ def _trigger_to_dict(trigger) -> dict[str, Any]: pass return {} + def _resolve_trigger_source(self, node_map, requested: str, *, strict: bool) -> tuple[str, bool]: + """Resolve TriggerSource against the camera-supported GenICam enum values. + + Model-level default is "auto"; this backend maps it to the first preferred + source supported by the actual camera. + """ + requested = str(requested or "auto").strip() + node = self._node(node_map, "TriggerSource") + available = self._node_symbolics(node) + + if not available: + if strict: + raise RuntimeError("GenICam node 'TriggerSource' is not available or has no symbolics") + LOG.warning("GenICam node 'TriggerSource' is not available; disabling trigger input.") + return requested, False + + if requested in available: + return requested, True + + if requested.lower() == "auto": + for candidate in ("Line0", "Line1", "Line2", "Any"): + if candidate in available: + LOG.info( + "GenTL TriggerSource auto-selected '%s'. Available: %s", + candidate, + available, + ) + return candidate, True + + LOG.warning( + "Could not auto-select a GenTL TriggerSource. Available: %s", + available, + ) + return requested, False + + if strict: + raise RuntimeError(f"GenICam node 'TriggerSource' does not support '{requested}'. Available: {available}") + + LOG.warning( + "GenTL TriggerSource '%s' is not available. Available: %s", + requested, + available, + ) + return requested, False + def _configure_pixel_format(self, node_map) -> None: try: pixel_format_node = getattr(node_map, "PixelFormat", None) @@ -1145,7 +1190,8 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) selector_ok = self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) - source_ok = self._set_enum_node(node_map, "TriggerSource", source, strict=strict) + source, source_resolved = self._resolve_trigger_source(node_map, source, strict=strict) + source_ok = source_resolved and self._set_enum_node(node_map, "TriggerSource", source, strict=strict) activation_ok = self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict) # TriggerSelector and TriggerSource are required routing nodes. diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 6f204accc..115301626 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -224,7 +224,7 @@ class CameraTriggerSettings(BaseModel): # Input trigger config: external/follower selector: str = "FrameStart" - source: str = "Line0" + source: str = "auto" activation: TriggerActivation | str = "RisingEdge" # Output config: master @@ -272,6 +272,24 @@ def _coerce_timeout(cls, v): return None return fv if fv > 0 else None + @field_validator("source", mode="before") + @classmethod + def _coerce_source(cls, v): + if v is None: + return "auto" + + s = str(v).strip() + if not s: + return "auto" + + aliases = { + "default": "auto", + "automatic": "auto", + "device": "auto", + "camera": "auto", + } + return aliases.get(s.lower(), s) + @classmethod def from_any(cls, value) -> CameraTriggerSettings: if isinstance(value, cls): diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index f21bf4819..f0a0b6b23 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -603,7 +603,7 @@ def __init__( self.AcquisitionMode = _FakeNode("Continuous", symbolics=["Continuous", "SingleFrame"]) self.TriggerSelector = _FakeNode("FrameStart", symbolics=["FrameStart"]) self.TriggerMode = _FakeNode("Off", symbolics=["Off", "On"]) - self.TriggerSource = _FakeNode("Line0", symbolics=["Line0", "Line1", "Software"]) + self.TriggerSource = _FakeNode("Line1", symbolics=["Line0", "Line1", "Software"]) self.TriggerActivation = _FakeNode("RisingEdge", symbolics=["RisingEdge", "FallingEdge"]) # GPIO output nodes for master/follower setups diff --git a/tests/cameras/backends/test_gentl_trigger.py b/tests/cameras/backends/test_gentl_trigger.py index e060035a4..57339a103 100644 --- a/tests/cameras/backends/test_gentl_trigger.py +++ b/tests/cameras/backends/test_gentl_trigger.py @@ -158,7 +158,7 @@ def test_trigger_invalid_source_non_strict_disables_trigger(patch_gentl_sdk, gen nm = be._acquirer.remote_device.node_map # Source was unsupported, so the fake node should retain its default. - assert nm.TriggerSource.value == "Line0" + assert nm.TriggerSource.value == "Line1" # Safety behavior: do not arm TriggerMode on the previous/default source. assert nm.TriggerMode.value == "Off" @@ -422,3 +422,39 @@ def test_trigger_timeout_not_capped_for_master_mode(patch_gentl_sdk, gentl_setti finally: be.close() + + +def test_resolve_trigger_source_auto_selects_supported_line( + patch_gentl_sdk, + gentl_settings_factory, +): + gb = patch_gentl_sdk + be = gb.GenTLCameraBackend(gentl_settings_factory()) + + class Node: + symbolics = ["Line1", "Software", "Any"] + + class NodeMap: + TriggerSource = Node() + + source, ok = be._resolve_trigger_source(NodeMap(), "auto", strict=False) + + assert ok is True + assert source == "Line1" + + +def test_resolve_trigger_source_strict_raises_for_unsupported_explicit_line( + patch_gentl_sdk, + gentl_settings_factory, +): + gb = patch_gentl_sdk + be = gb.GenTLCameraBackend(gentl_settings_factory()) + + class Node: + symbolics = ["Line1", "Software", "Any"] + + class NodeMap: + TriggerSource = Node() + + with pytest.raises(RuntimeError, match="TriggerSource.*Line0"): + be._resolve_trigger_source(NodeMap(), "Line0", strict=True) diff --git a/tests/test_config.py b/tests/test_config.py index f0165f45e..9f82017ed 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,6 @@ import pytest -from dlclivegui.config import ApplicationSettings, CameraSettings, MultiCameraSettings +from dlclivegui.config import ApplicationSettings, CameraSettings, CameraTriggerSettings, MultiCameraSettings @pytest.mark.unit @@ -34,3 +34,10 @@ def test_save_applies_gentl_trigger_defaults_to_multi_camera(): data = settings.to_dict() assert "trigger" in data["multi_camera"]["cameras"][0]["properties"]["gentl"] + + +@pytest.mark.unit +def test_trigger_source_defaults_to_auto(): + trigger = CameraTriggerSettings() + + assert trigger.source == "auto" From 662313a3efa2c013b128c36625c1b5d7c9bc87eb Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 18:03:57 +0200 Subject: [PATCH 034/133] Fix display tests --- tests/services/test_multicam_controller.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index e8f94f6b2..99047be82 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -11,6 +11,7 @@ _camera_start_priority, _trigger_role_from_settings, get_camera_id, + get_display_id, ) @@ -256,7 +257,7 @@ def test_frame_ready_emits_frames_in_user_configured_order(qtbot, patch_factory) properties={"opencv": {"device_id": "cam-b"}}, ).apply_defaults() - expected_order = [get_camera_id(cam_a), get_camera_id(cam_b)] + expected_order = [get_display_id(cam_a), get_display_id(cam_b)] seen_orders: list[list[str]] = [] def on_ready(mfd): @@ -388,7 +389,7 @@ def _create(settings): mc.start([cam]) cam_id, msg = blocker.args - assert cam_id == get_camera_id(cam) + assert cam_id == get_display_id(cam) assert "Camera read timeout" in msg # Cleanup if still running. From 3edb48ce1f2f3f7ce16f5f34a6d71a03f5413a6d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 18:05:01 +0200 Subject: [PATCH 035/133] Update test_multicam_controller.py --- tests/services/test_multicam_controller.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 99047be82..d41befeb6 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -5,7 +5,6 @@ # from dlclivegui.config import CameraSettings from dlclivegui.config import CameraSettings -from dlclivegui.services.multi_camera_controller import MultiCameraController, get_display_id from dlclivegui.services.multi_camera_controller import ( MultiCameraController, _camera_start_priority, From 0af561594be7d3d846366543353b35ef127bc29f Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 1 Jun 2026 16:58:57 +0200 Subject: [PATCH 036/133] Propagate display IDs for camera labeling Add support for display_id labels throughout the multi-camera flow. MultiFrameData now includes display_ids; MultiCameraController stores per-camera display_ids (set on start, cleared on stop/reset) and emits them with frame_ready. Also switch to getting camera_id and display_id separately when starting workers. The tiled-frame helper create_tiled_frame gained an optional labels mapping and will draw the display label (falling back to camera_id) on each tile so the GUI can show friendly names. --- dlclivegui/services/multi_camera_controller.py | 11 ++++++++++- dlclivegui/utils/display.py | 7 +++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index ad1a8636b..99f92ddc7 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -31,6 +31,7 @@ class MultiFrameData: timestamps: dict[str, float] # camera_id -> timestamp source_camera_id: str = "" # ID of camera that triggered this emission tiled_frame: np.ndarray | None = None # Combined tiled frame (deprecated, done in GUI) + display_ids: dict[str, str] = None # camera_id -> display_id (for labeling) class SingleCameraWorker(QObject): @@ -166,6 +167,7 @@ def __init__(self): self._frame_lock = Lock() self._running = False self._started_cameras: set = set() + self._display_ids: dict[str, str] = {} # camera_id -> display_id (for labeling) self._failed_cameras: dict[str, str] = {} # camera_id -> error message self._expected_cameras: int = 0 # Number of cameras we're trying to start @@ -219,6 +221,7 @@ def start(self, camera_settings: list[CameraSettings]) -> None: self._timestamps.clear() self._started_cameras.clear() self._failed_cameras.clear() + self._display_ids.clear() self._expected_cameras = len(active_settings) for settings in active_settings: @@ -227,7 +230,9 @@ def start(self, camera_settings: list[CameraSettings]) -> None: def _start_camera(self, settings: CameraSettings) -> None: """Start a single camera.""" settings_copy = copy.deepcopy(settings) - cam_id = get_display_id(settings_copy) + cam_id = get_camera_id(settings_copy) + display_id = get_display_id(settings_copy) + if cam_id in self._workers: LOGGER.warning(f"Camera {cam_id} already has a worker") return @@ -236,6 +241,7 @@ def _start_camera(self, settings: CameraSettings) -> None: # Normalize and store the dataclass once self._settings[cam_id] = settings_copy + self._display_ids[cam_id] = display_id dc = self._settings[cam_id] worker = SingleCameraWorker(cam_id, dc) thread = QThread() @@ -275,6 +281,7 @@ def stop(self, wait: bool = True) -> None: self._settings.clear() self._started_cameras.clear() self._failed_cameras.clear() + self._display_ids.clear() self._expected_cameras = 0 self.all_stopped.emit() @@ -302,6 +309,7 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float timestamps=dict(self._timestamps), source_camera_id=camera_id, # Track which camera triggered this tiled_frame=None, + display_ids=dict(self._display_ids), ) self.frame_ready.emit(frame_data) @@ -489,6 +497,7 @@ def _on_camera_stopped(self, camera_id: str) -> None: # Check if this camera never started (initialization failure) was_started = camera_id in self._started_cameras self._started_cameras.discard(camera_id) + self._display_ids.pop(camera_id, None) self.camera_stopped.emit(camera_id) LOGGER.info(f"Camera {camera_id} stopped (was_started={was_started})") diff --git a/dlclivegui/utils/display.py b/dlclivegui/utils/display.py index 0eac657cc..00eae0d64 100644 --- a/dlclivegui/utils/display.py +++ b/dlclivegui/utils/display.py @@ -83,7 +83,9 @@ def compute_tiling_geometry( return cam_ids, rows, cols, tile_w, tile_h -def create_tiled_frame(frames: dict[str, np.ndarray], max_canvas: tuple[int, int] = (1200, 800)) -> np.ndarray: +def create_tiled_frame( + frames: dict[str, np.ndarray], max_canvas: tuple[int, int] = (1200, 800), labels: dict[str, str] = None +) -> np.ndarray: """Create a tiled canvas (1x1, 1x2, or 2x2) with camera-id labels. Uses compute_tiling_geometry() so tile_w/tile_h are consistent with compute_tile_info(). @@ -105,10 +107,11 @@ def create_tiled_frame(frames: dict[str, np.ndarray], max_canvas: tuple[int, int frame = cv2.cvtColor(frame, cv2.COLOR_BGRA2BGR) resized = cv2.resize(frame, (tile_w, tile_h), interpolation=cv2.INTER_AREA) + label = labels.get(cam_id, cam_id) if labels else cam_id cv2.putText( resized, - cam_id, + label, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, From ed1a9f59420cd5cf2cc29d686232d6f994d868f9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 1 Jun 2026 16:59:13 +0200 Subject: [PATCH 037/133] Support fp: targets and downgrade CTI error log Treat target IDs starting with "fp:" as valid and return settings early so open() can match devices by fingerprint via existing _select_device/_match_device logic. Also change CTI file load failures from logger.exception to logger.warning to reduce noisy stack traces while still recording failed files for diagnostics. --- dlclivegui/cameras/backends/gentl_backend.py | 3 +++ dlclivegui/cameras/backends/utils/gentl_discovery.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index b55abdf74..5c71cd287 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -337,6 +337,9 @@ def rebind_settings(cls, settings): cls._persist_serial_identity(settings, target_id_str) return settings + if target_id_str.startswith("fp:"): + return settings # open() will match by fingerprint via _select_device → _match_device + # Non-serial fallback retained for older configs / fingerprint IDs. harvester = None try: diff --git a/dlclivegui/cameras/backends/utils/gentl_discovery.py b/dlclivegui/cameras/backends/utils/gentl_discovery.py index 9d15a829c..3eed19975 100644 --- a/dlclivegui/cameras/backends/utils/gentl_discovery.py +++ b/dlclivegui/cameras/backends/utils/gentl_discovery.py @@ -51,7 +51,7 @@ def __init__(self, cti_files: list[str]): self.harvester.add_file(cti) self.loaded_files.append(cti) except Exception as e: - logger.exception(f"Failed to load CTI file: {cti}. Skipping.") + logger.warning(f"Failed to load CTI file: {cti}. Skipping.") self.failed_files[cti] = str(e) if not self.loaded_files: From 0d5e1dcdbdf17e749e555cb1f7a9ec439a78fcd6 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 1 Jun 2026 16:59:28 +0200 Subject: [PATCH 038/133] Add tests for stable camera ID usage Add unit tests to ensure services use stable camera IDs (get_camera_id) rather than GUI display IDs (get_display_id). Tests added to tests/gui/test_rec_manager.py verify RecordingManager routes frames by stable ID, doesn't accept frames keyed by display ID, and doesn't infer frame size from display-only keys. Tests added/updated in tests/services/test_multicam_controller.py assert MultiCameraController emits frames and timestamps keyed by stable IDs, exposes a display_id mapping, and no internal use of display IDs; also update an existing rotation test to reference the stable camera ID. Imported get_camera_id/get_display_id and CameraSettings where needed. --- tests/gui/test_rec_manager.py | 103 ++++++++++++++++++++- tests/services/test_multicam_controller.py | 83 ++++++++++++++++- 2 files changed, 180 insertions(+), 6 deletions(-) diff --git a/tests/gui/test_rec_manager.py b/tests/gui/test_rec_manager.py index 6a75d456d..c789078b0 100644 --- a/tests/gui/test_rec_manager.py +++ b/tests/gui/test_rec_manager.py @@ -3,8 +3,9 @@ import numpy as np import pytest +from dlclivegui.config import CameraSettings from dlclivegui.gui.recording_manager import RecordingManager -from dlclivegui.services.multi_camera_controller import get_camera_id +from dlclivegui.services.multi_camera_controller import get_camera_id, get_display_id from dlclivegui.services.video_recorder import RecorderStats @@ -277,3 +278,103 @@ def test_get_stats_summary_multi_aggregates( assert "30 frames" in summary # 10 + 20 assert "dropped 4" in summary # 1 + 3 assert "queue 6" in summary # 2 + 4 + + +@pytest.mark.unit +def test_recording_manager_uses_stable_camera_id_not_display_id( + recording_settings, + patch_video_recorder, + patch_build_run_dir, +): + mgr = RecordingManager() + + cam = CameraSettings( + name="GenTL cam", + backend="gentl", + index=0, + fps=30.0, + enabled=True, + properties={ + "gentl": { + "device_id": "serial:SER0", + "serial_number": "SER0", + } + }, + ).apply_defaults() + + stable_id = get_camera_id(cam) + display_id = get_display_id(cam) + + assert stable_id == "gentl:serial:SER0" + assert display_id == "gentl:0" + assert stable_id != display_id + + frame = np.zeros((480, 640, 3), dtype=np.uint8) + current_frames = {stable_id: frame} + + run_dir = mgr.start_all( + recording_settings, + [cam], + current_frames, + session_name="Sess", + ) + + assert run_dir is not None + assert stable_id in mgr.recorders + assert display_id not in mgr.recorders + + rec = mgr.recorders[stable_id] + assert rec.frame_size == (480, 640) + + mgr.write_frame(stable_id, frame, timestamp=123.0) + assert len(rec.write_calls) == 1 + assert rec.write_calls[-1][1] == 123.0 + + # Display ID is GUI-only and must not route frames internally. + mgr.write_frame(display_id, frame, timestamp=456.0) + assert len(rec.write_calls) == 1 + + +@pytest.mark.unit +def test_start_all_does_not_infer_frame_size_from_display_id( + recording_settings, + patch_video_recorder, + patch_build_run_dir, +): + mgr = RecordingManager() + + cam = CameraSettings( + name="GenTL cam", + backend="gentl", + index=0, + fps=30.0, + enabled=True, + properties={ + "gentl": { + "device_id": "serial:SER0", + "serial_number": "SER0", + } + }, + ).apply_defaults() + + stable_id = get_camera_id(cam) + display_id = get_display_id(cam) + + frame = np.zeros((480, 640, 3), dtype=np.uint8) + + # Simulate the buggy situation: frames are keyed by display ID. + current_frames = {display_id: frame} + + mgr.start_all( + recording_settings, + [cam], + current_frames, + session_name="Sess", + ) + + assert stable_id in mgr.recorders + assert display_id not in mgr.recorders + + # Since RecordingManager uses stable IDs internally, it should not find this frame. + rec = mgr.recorders[stable_id] + assert rec.frame_size is None diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 0893bd9f6..7c9e1f051 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -5,18 +5,23 @@ # from dlclivegui.config import CameraSettings from dlclivegui.config import CameraSettings -from dlclivegui.services.multi_camera_controller import MultiCameraController, get_display_id +from dlclivegui.services.multi_camera_controller import MultiCameraController, get_camera_id, get_display_id @pytest.mark.unit def test_start_and_frames(qtbot, patch_factory): mc = MultiCameraController() - # One dataclass + one dict (simulate mixed inputs) cam1 = CameraSettings(name="C1", backend="opencv", index=0, fps=25.0).apply_defaults() cam2 = {"name": "C2", "backend": "opencv", "index": 1, "fps": 30.0, "enabled": True} cam2 = CameraSettings.from_dict(cam2).apply_defaults() + cam1_id = get_camera_id(cam1) + cam2_id = get_camera_id(cam2) + + cam1_display = get_display_id(cam1) + cam2_display = get_display_id(cam2) + frames_seen = [] def on_ready(mfd): @@ -28,11 +33,24 @@ def on_ready(mfd): with qtbot.waitSignal(mc.all_started, timeout=1500): mc.start([cam1, cam2]) - # Wait for at least one composite emission qtbot.waitUntil(lambda: len(frames_seen) >= 1, timeout=2000) assert mc.is_running() - # We should have at least one entry with 1 or 2 frames (depending on timing) + + # Internal IDs should be used as frame keys. + seen_keys = set() + seen_sources = set() + for source_id, shape_map in frames_seen: + seen_sources.add(source_id) + seen_keys.update(shape_map.keys()) + + assert seen_keys <= {cam1_id, cam2_id} + assert seen_sources <= {cam1_id, cam2_id} + + # Display IDs should not be used as internal frame keys. + assert cam1_display not in seen_keys + assert cam2_display not in seen_keys + assert any(len(shape_map) >= 1 for _, shape_map in frames_seen) finally: @@ -60,7 +78,7 @@ def test_rotation_and_crop(qtbot, patch_factory): last_shape = {"shape": None} def on_ready(mfd): - f = mfd.frames.get(get_display_id(cam)) + f = mfd.frames.get(get_camera_id(cam)) if f is not None: last_shape["shape"] = f.shape @@ -95,3 +113,58 @@ def _create(_settings): # Expect initialization_failed with the camera id with qtbot.waitSignals([mc.initialization_failed, mc.all_stopped], timeout=2000) as _: mc.start([cam]) + + +@pytest.mark.unit +def test_controller_uses_stable_camera_id_not_display_id(qtbot, patch_factory): + mc = MultiCameraController() + + cam = CameraSettings( + name="C1", + backend="gentl", + index=0, + fps=30.0, + enabled=True, + properties={ + "gentl": { + "device_id": "serial:SER0", + "serial_number": "SER0", + } + }, + ).apply_defaults() + + stable_id = get_camera_id(cam) + display_id = get_display_id(cam) + + assert stable_id == "gentl:serial:SER0" + assert display_id == "gentl:0" + assert stable_id != display_id + + seen = [] + + def on_ready(mfd): + seen.append(mfd) + + mc.frame_ready.connect(on_ready) + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam]) + + qtbot.waitUntil(lambda: bool(seen), timeout=2000) + + mfd = seen[-1] + + assert mfd.source_camera_id == stable_id + assert stable_id in mfd.frames + assert stable_id in mfd.timestamps + + assert display_id not in mfd.frames + assert display_id not in mfd.timestamps + + assert mfd.display_ids is not None + assert mfd.display_ids[stable_id] == display_id + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) From f6894a027c30884a8dcd825907d529347e43cef3 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 15:13:34 -0500 Subject: [PATCH 039/133] Update test_multicam_controller.py --- tests/services/test_multicam_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index b1fd7f1d4..0e0d464b7 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -482,4 +482,4 @@ def _create(settings): # Cleanup if still running. if mc.is_running(): with qtbot.waitSignal(mc.all_stopped, timeout=2000): - mc.stop(wait=True) \ No newline at end of file + mc.stop(wait=True) From 3675751ee721b6b71a92dc8d57717d3c3c6d2f6c Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:01:30 +0200 Subject: [PATCH 040/133] tests: preserve display order and add MultiCamera tests Update display tests to assert that tiling and tile computations preserve frame insertion/display order (no longer sorting by camera ID) and add coverage for tile offsets, scaling, and tiled frame content. Add a suite of unit tests for MultiCameraController utilities and behavior: get_camera_id, trigger role aliasing, camera start priority, preserving user display order on start, frame_ready emission order, clearing display order on stop, hardware trigger timeouts (non-fatal), and non-trigger timeouts (fatal). Also import newly-tested helper functions from multi_camera_controller. --- tests/services/test_multicam_controller.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 0e0d464b7..e5bf60934 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -259,7 +259,6 @@ def test_start_preserves_user_display_order_even_when_trigger_start_order_differ with qtbot.waitSignal(mc.all_started, timeout=1500): mc.start([master, follower]) - # Display order follows user order, but stores stable IDs. assert mc._camera_display_order == expected_display_order finally: From ded0b554344ba271d3648f089fcc4c8aa665d675 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:26:15 +0200 Subject: [PATCH 041/133] Add trigger settings dialog and UI button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a TriggerConfigDialog to edit per-camera hardware trigger settings (CameraTriggerSettings). The dialog provides fields for role, selector, source, activation, output line/source, timeout and strict mode, and persists settings into camera.properties[]['trigger']. Also add a "Trigger Settings…" button to the camera settings UI (disabled by default) with tooltip and icon; wiring to open the dialog can be connected elsewhere. --- .../camera_config/trigger_config_dialog.py | 175 ++++++++++++++++++ dlclivegui/gui/camera_config/ui_blocks.py | 7 + 2 files changed, 182 insertions(+) create mode 100644 dlclivegui/gui/camera_config/trigger_config_dialog.py diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py new file mode 100644 index 000000000..d7546e95b --- /dev/null +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -0,0 +1,175 @@ +# dlclivegui/gui/camera_config/trigger_config_dialog.py +from __future__ import annotations + +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QGroupBox, + QLabel, + QLineEdit, + QVBoxLayout, + QWidget, +) + +from ...config import CameraSettings, CameraTriggerSettings + + +def _backend_namespace(cam: CameraSettings) -> dict: + backend = (cam.backend or "").lower() + if not isinstance(cam.properties, dict): + cam.properties = {} + ns = cam.properties.setdefault(backend, {}) + if not isinstance(ns, dict): + ns = {} + cam.properties[backend] = ns + return ns + + +class TriggerConfigDialog(QDialog): + """Small dialog for editing per-camera hardware trigger settings.""" + + def __init__(self, cam: CameraSettings, parent: QWidget | None = None): + super().__init__(parent) + self.setWindowTitle("Trigger Settings") + self.setMinimumWidth(420) + + self._cam = cam.model_copy(deep=True) + + ns = _backend_namespace(self._cam) + self._trigger = CameraTriggerSettings.from_any(ns.get("trigger")) + + self._setup_ui() + self._load_from_trigger(self._trigger) + self._sync_role_ui() + + @property + def camera_settings(self) -> CameraSettings: + return self._cam + + def _setup_ui(self) -> None: + root = QVBoxLayout(self) + + info = QLabel( + "Configure hardware trigger settings for this camera.\n" + "Unsupported fields are ignored by the backend unless strict mode is enabled." + ) + info.setWordWrap(True) + root.addWidget(info) + + group = QGroupBox("Hardware Trigger") + form = QFormLayout(group) + + self.role_combo = QComboBox() + self.role_combo.addItem("Off / Free-run", "off") + self.role_combo.addItem("External trigger", "external") + self.role_combo.addItem("Follower", "follower") + self.role_combo.addItem("Master", "master") + form.addRow("Role:", self.role_combo) + + self.selector_edit = QLineEdit() + self.selector_edit.setPlaceholderText("FrameStart") + form.addRow("Trigger selector:", self.selector_edit) + + self.source_edit = QLineEdit() + self.source_edit.setPlaceholderText("Line0") + form.addRow("Trigger source:", self.source_edit) + + self.activation_combo = QComboBox() + for value in ("RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"): + self.activation_combo.addItem(value, value) + form.addRow("Activation:", self.activation_combo) + + self.output_line_edit = QLineEdit() + self.output_line_edit.setPlaceholderText("Line2") + form.addRow("Output line:", self.output_line_edit) + + self.output_source_edit = QLineEdit() + self.output_source_edit.setPlaceholderText("ExposureActive") + form.addRow("Output source:", self.output_source_edit) + + self.timeout_spin = QDoubleSpinBox() + self.timeout_spin.setRange(0.0, 3600.0) + self.timeout_spin.setDecimals(3) + self.timeout_spin.setSingleStep(0.1) + self.timeout_spin.setSpecialValueText("Default") + self.timeout_spin.setToolTip( + "Fetch poll timeout in seconds. For triggered cameras, 0.2–0.5s is usually responsive." + ) + form.addRow("Read timeout:", self.timeout_spin) + + self.strict_checkbox = QCheckBox("Strict mode") + self.strict_checkbox.setToolTip("If enabled, missing/unsupported GenICam trigger nodes fail camera open.") + form.addRow(self.strict_checkbox) + + root.addWidget(group) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self._accept) + buttons.rejected.connect(self.reject) + root.addWidget(buttons) + + self.role_combo.currentIndexChanged.connect(self._sync_role_ui) + + def _load_from_trigger(self, trigger: CameraTriggerSettings) -> None: + role = str(getattr(trigger, "role", "off") or "off").lower() + idx = self.role_combo.findData(role) + self.role_combo.setCurrentIndex(idx if idx >= 0 else 0) + + self.selector_edit.setText(str(getattr(trigger, "selector", "FrameStart") or "FrameStart")) + self.source_edit.setText(str(getattr(trigger, "source", "Line0") or "Line0")) + + activation = str(getattr(trigger, "activation", "RisingEdge") or "RisingEdge") + idx = self.activation_combo.findData(activation) + self.activation_combo.setCurrentIndex(idx if idx >= 0 else 0) + + self.output_line_edit.setText(str(getattr(trigger, "output_line", "Line2") or "Line2")) + self.output_source_edit.setText(str(getattr(trigger, "output_source", "ExposureActive") or "ExposureActive")) + + timeout = getattr(trigger, "timeout", None) + self.timeout_spin.setValue(float(timeout) if timeout else 0.0) + + self.strict_checkbox.setChecked(bool(getattr(trigger, "strict", False))) + + def _sync_role_ui(self) -> None: + role = str(self.role_combo.currentData() or "off") + + input_enabled = role in {"external", "follower"} + output_enabled = role == "master" + + self.selector_edit.setEnabled(input_enabled) + self.source_edit.setEnabled(input_enabled) + self.activation_combo.setEnabled(input_enabled) + + self.output_line_edit.setEnabled(output_enabled) + self.output_source_edit.setEnabled(output_enabled) + + # Timeout is mostly useful for external/follower, but harmless for any role. + self.timeout_spin.setEnabled(role in {"external", "follower"}) + + def _accept(self) -> None: + role = str(self.role_combo.currentData() or "off") + + payload = { + "role": role, + "selector": self.selector_edit.text().strip() or "FrameStart", + "source": self.source_edit.text().strip() or "Line0", + "activation": str(self.activation_combo.currentData() or "RisingEdge"), + "output_line": self.output_line_edit.text().strip() or "Line2", + "output_source": self.output_source_edit.text().strip() or "ExposureActive", + "strict": bool(self.strict_checkbox.isChecked()), + } + + timeout = float(self.timeout_spin.value()) + if timeout > 0: + payload["timeout"] = timeout + + trigger = CameraTriggerSettings.from_any(payload) + + ns = _backend_namespace(self._cam) + ns["trigger"] = trigger.model_dump(exclude_none=True) + + self.accept() diff --git a/dlclivegui/gui/camera_config/ui_blocks.py b/dlclivegui/gui/camera_config/ui_blocks.py index 86e4f19d6..07a8025e3 100644 --- a/dlclivegui/gui/camera_config/ui_blocks.py +++ b/dlclivegui/gui/camera_config/ui_blocks.py @@ -354,6 +354,13 @@ def build_settings_group(dlg: CameraConfigDialog) -> QGroupBox: dlg.settings_form.addRow("Crop:", crop_widget) + # --- Trigger settings button --- + dlg.trigger_settings_btn = QPushButton("Trigger Settings…") + dlg.trigger_settings_btn.setIcon(dlg.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogDetailedView)) + dlg.trigger_settings_btn.setEnabled(False) + dlg.trigger_settings_btn.setToolTip("Configure hardware trigger / GPIO sync settings for this camera.") + dlg.settings_form.addRow("Sync:", dlg.trigger_settings_btn) + # Apply/Reset buttons row dlg.apply_settings_btn = QPushButton("Apply Settings") dlg.apply_settings_btn.setIcon(dlg.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton)) From bec049d1070605bd3417872d94b4d51833617357 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:29:36 +0200 Subject: [PATCH 042/133] Add per-camera hardware trigger settings Introduce support for per-camera hardware trigger configuration in the camera config dialog. Imports TriggerConfigDialog and CameraTriggerSettings, wires the trigger settings button to open a modal, and adds _open_trigger_settings_dialog to commit edits, show the dialog, apply updates, and restart the preview if needed. Adds helpers: _ensure_default_trigger_config to initialize gentl.trigger defaults, _trigger_role_for_label to show trigger role in camera list labels, and _trigger_dict_for_cam to compare trigger settings when deciding to restart previews. Also integrates the hardware trigger field into the settings summary and ensures new/loaded cameras get a default trigger config. --- .../gui/camera_config/camera_config_dialog.py | 82 ++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index 4c94fb701..af22842f2 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -18,9 +18,10 @@ ) from ...cameras.factory import CameraFactory, DetectedCamera, apply_detected_identity, camera_identity_key -from ...config import CameraSettings, MultiCameraSettings +from ...config import CameraSettings, CameraTriggerSettings, MultiCameraSettings from .loaders import CameraLoadWorker, CameraProbeWorker, CameraScanState, DetectCamerasWorker from .preview import PreviewSession, PreviewState, apply_crop, apply_rotation, resize_to_fit, to_display_pixmap +from .trigger_config_dialog import TriggerConfigDialog from .ui_blocks import setup_camera_config_dialog_ui LOGGER = logging.getLogger(__name__) @@ -328,6 +329,7 @@ def _connect_signals(self) -> None: self.active_cameras_list.currentRowChanged.connect(self._on_active_camera_selected) self.available_cameras_list.currentRowChanged.connect(self._on_available_camera_selected) self.available_cameras_list.itemDoubleClicked.connect(self._on_available_camera_double_clicked) + self.trigger_settings_btn.clicked.connect(self._open_trigger_settings_dialog) self.apply_settings_btn.clicked.connect(self._apply_camera_settings) self.reset_settings_btn.clicked.connect(self._reset_selected_camera) self.preview_btn.clicked.connect(self._toggle_preview) @@ -451,11 +453,24 @@ def _refresh_camera_labels(self) -> None: finally: cam_list.blockSignals(False) + def _trigger_role_for_label(self, cam: CameraSettings) -> str: + backend = (cam.backend or "").lower() + props = cam.properties if isinstance(cam.properties, dict) else {} + ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {} + trigger = ns.get("trigger", {}) + if not isinstance(trigger, dict): + return "off" + return str(trigger.get("role", "off") or "off").lower() + def _format_camera_label(self, cam: CameraSettings, index: int = -1) -> str: status = "✓" if cam.enabled else "○" this_id = f"{(cam.backend or '').lower()}:{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}" + + trigger_role = self._trigger_role_for_label(cam) + trigger_indicator = "" if trigger_role in {"off", "disabled"} else f" [{trigger_role}]" + + return f"{status} {cam.name} [{cam.backend}:{cam.index}]{trigger_indicator}{dlc_indicator}" def _selected_detected_camera(self) -> DetectedCamera | None: row = self.available_cameras_list.currentRow() @@ -514,6 +529,9 @@ 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") + # Hardware trigger / sync + apply(self.trigger_settings_btn, "hardware_trigger", "Hardware trigger") + def _set_preview_button_loading(self, loading: bool) -> None: if loading: self.preview_btn.setText("Cancel Loading") @@ -800,6 +818,21 @@ 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) + def _ensure_default_trigger_config(self, cam: CameraSettings) -> None: + backend = (cam.backend or "").lower() + if backend != "gentl": + return + + if not isinstance(cam.properties, dict): + cam.properties = {} + + ns = cam.properties.setdefault("gentl", {}) + if not isinstance(ns, dict): + ns = {} + cam.properties["gentl"] = ns + + ns.setdefault("trigger", CameraTriggerSettings().model_dump(exclude_none=True)) + def _add_selected_camera(self) -> None: if not self._commit_pending_edits(reason="before adding a new camera"): return @@ -850,6 +883,7 @@ def _add_selected_camera(self) -> None: properties={}, ) apply_detected_identity(new_cam, detected, backend) + self._ensure_default_trigger_config(new_cam) 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)) @@ -969,6 +1003,7 @@ def _load_camera_to_form(self, cam: CameraSettings) -> None: 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._ensure_default_trigger_config(cam) self.apply_settings_btn.setEnabled(True) self._set_detected_labels(cam) finally: @@ -1029,6 +1064,39 @@ def _enabled_count_with(self, row: int, new_enabled: bool) -> int: count += 1 return count + def _open_trigger_settings_dialog(self) -> None: + """Open per-camera hardware trigger settings dialog.""" + if self._current_edit_index is None: + return + + row = self._current_edit_index + if row < 0 or row >= len(self._working_settings.cameras): + return + + # Commit normal camera edits first so we do not lose pending UI changes. + if not self._commit_pending_edits(reason="before opening trigger settings"): + return + + cam = self._working_settings.cameras[row] + + dlg = TriggerConfigDialog(cam, self) + if dlg.exec() != QDialog.Accepted: + return + + updated = dlg.camera_settings + + self._working_settings.cameras[row] = updated + self._update_active_list_item(row, updated) + self._load_camera_to_form(updated) + + # Trigger changes require reopening the camera preview/backend. + if self._preview.state == PreviewState.ACTIVE: + self._append_status("[Trigger] Restarting preview to apply trigger settings.") + self._request_preview_restart(updated, reason="trigger-settings") + + self.apply_settings_btn.setEnabled(False) + self._set_apply_dirty(False) + def _apply_camera_settings(self) -> bool: try: for sb in ( @@ -1597,6 +1665,13 @@ def _bump_epoch(self) -> int: self._preview.epoch += 1 return self._preview.epoch + def _trigger_dict_for_cam(self, cam: CameraSettings) -> dict: + backend = (cam.backend or "").lower() + props = cam.properties if isinstance(cam.properties, dict) else {} + ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {} + trigger = ns.get("trigger", {}) + return trigger if isinstance(trigger, dict) else {} + def _should_restart_preview(self, old: CameraSettings, new: CameraSettings) -> bool: """ Fast UX policy: @@ -1612,6 +1687,9 @@ def _should_restart_preview(self, old: CameraSettings, new: CameraSettings) -> b except Exception: return True # safest: restart + if self._trigger_dict_for_cam(old) != self._trigger_dict_for_cam(new): + return True + # No restart needed if only rotation/crop/enabled changed return False From 89817887adb6f5d07779017a7538a92ec2e1f260 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:36:08 +0200 Subject: [PATCH 043/133] Update trigger_config_dialog.py --- dlclivegui/gui/camera_config/trigger_config_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py index d7546e95b..d4c4b19eb 100644 --- a/dlclivegui/gui/camera_config/trigger_config_dialog.py +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -34,7 +34,7 @@ class TriggerConfigDialog(QDialog): def __init__(self, cam: CameraSettings, parent: QWidget | None = None): super().__init__(parent) - self.setWindowTitle("Trigger Settings") + self.setWindowTitle("Configure trigger mode") self.setMinimumWidth(420) self._cam = cam.model_copy(deep=True) From 3cea2a350c7bde9525c3102859a390bb25a0f464 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 10:25:21 +0200 Subject: [PATCH 044/133] Use _is_preview_live and clear trigger timeout Replace direct checks of self._preview.state == PreviewState.ACTIVE with self._is_preview_live() in camera_config_dialog to centralize preview-active logic and ensure consistent behavior when restarting the preview after trigger or camera setting changes. In trigger_config_dialog, only set payload["timeout"] for external/follower roles when timeout > 0, and explicitly set payload["timeout"] = None when role == "off" to clear any stale timeout when disabling the trigger. --- dlclivegui/gui/camera_config/camera_config_dialog.py | 8 +++----- dlclivegui/gui/camera_config/trigger_config_dialog.py | 4 +++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index af22842f2..e005617ba 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -1090,7 +1090,7 @@ def _open_trigger_settings_dialog(self) -> None: self._load_camera_to_form(updated) # Trigger changes require reopening the camera preview/backend. - if self._preview.state == PreviewState.ACTIVE: + if self._is_preview_live(): self._append_status("[Trigger] Restarting preview to apply trigger settings.") self._request_preview_restart(updated, reason="trigger-settings") @@ -1153,9 +1153,7 @@ def _apply_camera_settings(self) -> bool: old_settings = current_model restart = False - should_consider_restart = self._preview.state == PreviewState.ACTIVE and isinstance( - old_settings, CameraSettings - ) + should_consider_restart = self._is_preview_live() and isinstance(old_settings, CameraSettings) if should_consider_restart: restart = self._should_restart_preview(old_settings, new_model) @@ -1167,7 +1165,7 @@ def _apply_camera_settings(self) -> bool: new_model.index, ) - if self._preview.state == PreviewState.ACTIVE and restart: + if self._is_preview_live() and restart: self._append_status("[Apply] Restarting preview to apply camera settings changes.") self._request_preview_restart(new_model, reason="apply-settings") diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py index d4c4b19eb..07e9570aa 100644 --- a/dlclivegui/gui/camera_config/trigger_config_dialog.py +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -164,8 +164,10 @@ def _accept(self) -> None: } timeout = float(self.timeout_spin.value()) - if timeout > 0: + if role in {"external", "follower"} and timeout > 0: payload["timeout"] = timeout + elif role == "off": + payload["timeout"] = None # ensure timeout is cleared when disabling trigger trigger = CameraTriggerSettings.from_any(payload) From d12c0982ea0eb9303acb0a230259d30a02f26d67 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 10:43:11 +0200 Subject: [PATCH 045/133] Restart active previews; ignore bad trigger Allow pending preview restarts to proceed when the preview is ACTIVE (previously only IDLE) and return early to avoid double UI sync in camera_config_dialog.py. Also wrap CameraTriggerSettings loading in a try/except and fall back to a default instance when parsing fails, making trigger_config_dialog.py tolerant of malformed or missing trigger data. --- dlclivegui/gui/camera_config/camera_config_dialog.py | 3 ++- dlclivegui/gui/camera_config/trigger_config_dialog.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index e005617ba..444f27387 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -1832,9 +1832,10 @@ def _on_loader_finished(self, e: int) -> None: self._preview.restart_scheduled = False self._preview.loader = None - if pending and self._preview.state == PreviewState.IDLE: + if pending and self._preview.state in (PreviewState.IDLE, PreviewState.ACTIVE): 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") + return # UI sync is already handled in _begin_preview_load self._sync_preview_ui() diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py index 07e9570aa..cd72701b1 100644 --- a/dlclivegui/gui/camera_config/trigger_config_dialog.py +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -40,7 +40,10 @@ def __init__(self, cam: CameraSettings, parent: QWidget | None = None): self._cam = cam.model_copy(deep=True) ns = _backend_namespace(self._cam) - self._trigger = CameraTriggerSettings.from_any(ns.get("trigger")) + try: + self._trigger = CameraTriggerSettings.from_any(ns.get("trigger")) + except Exception: + self._trigger = CameraTriggerSettings() self._setup_ui() self._load_from_trigger(self._trigger) From 2c387b5a509421df95fe6e8c8338ce56bcdd71c1 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 11:10:49 +0200 Subject: [PATCH 046/133] Improve trigger dialog defaults and error handling Make trigger dialog friendlier and more robust: recommend using 'auto' for trigger source and switch default/fallback source from "Line0" to "auto", update source placeholder and info/timeout tooltips, and import QMessageBox. Wrap CameraTriggerSettings.from_any in a try/except to show a critical error dialog on failure and abort apply. Store trigger settings via trigger.to_properties() instead of model_dump(exclude_none=True). These changes provide clearer defaults and better error feedback when applying trigger settings. --- .../camera_config/trigger_config_dialog.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py index cd72701b1..d1efa0362 100644 --- a/dlclivegui/gui/camera_config/trigger_config_dialog.py +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -11,6 +11,7 @@ QGroupBox, QLabel, QLineEdit, + QMessageBox, QVBoxLayout, QWidget, ) @@ -58,7 +59,8 @@ def _setup_ui(self) -> None: info = QLabel( "Configure hardware trigger settings for this camera.\n" - "Unsupported fields are ignored by the backend unless strict mode is enabled." + "Use 'auto' for trigger source unless you know the exact GenICam line name. " + "In strict mode, unsupported trigger nodes fail camera open." ) info.setWordWrap(True) root.addWidget(info) @@ -78,7 +80,7 @@ def _setup_ui(self) -> None: form.addRow("Trigger selector:", self.selector_edit) self.source_edit = QLineEdit() - self.source_edit.setPlaceholderText("Line0") + self.source_edit.setPlaceholderText("auto, Line0, Software, ...") form.addRow("Trigger source:", self.source_edit) self.activation_combo = QComboBox() @@ -100,7 +102,7 @@ def _setup_ui(self) -> None: self.timeout_spin.setSingleStep(0.1) self.timeout_spin.setSpecialValueText("Default") self.timeout_spin.setToolTip( - "Fetch poll timeout in seconds. For triggered cameras, 0.2–0.5s is usually responsive." + "Fetch poll timeout in seconds. The backend may cap individual fetches to keep preview shutdown responsive." ) form.addRow("Read timeout:", self.timeout_spin) @@ -123,7 +125,7 @@ def _load_from_trigger(self, trigger: CameraTriggerSettings) -> None: self.role_combo.setCurrentIndex(idx if idx >= 0 else 0) self.selector_edit.setText(str(getattr(trigger, "selector", "FrameStart") or "FrameStart")) - self.source_edit.setText(str(getattr(trigger, "source", "Line0") or "Line0")) + self.source_edit.setText(str(getattr(trigger, "source", "auto") or "auto")) activation = str(getattr(trigger, "activation", "RisingEdge") or "RisingEdge") idx = self.activation_combo.findData(activation) @@ -159,7 +161,7 @@ def _accept(self) -> None: payload = { "role": role, "selector": self.selector_edit.text().strip() or "FrameStart", - "source": self.source_edit.text().strip() or "Line0", + "source": self.source_edit.text().strip() or "auto", "activation": str(self.activation_combo.currentData() or "RisingEdge"), "output_line": self.output_line_edit.text().strip() or "Line2", "output_source": self.output_source_edit.text().strip() or "ExposureActive", @@ -172,9 +174,13 @@ def _accept(self) -> None: elif role == "off": payload["timeout"] = None # ensure timeout is cleared when disabling trigger - trigger = CameraTriggerSettings.from_any(payload) + try: + trigger = CameraTriggerSettings.from_any(payload) + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to apply trigger settings: {e}") + return ns = _backend_namespace(self._cam) - ns["trigger"] = trigger.model_dump(exclude_none=True) + ns["trigger"] = trigger.to_properties() self.accept() From d377d19fa40e28c4b24471899d4d4251327585f3 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 11:50:10 +0200 Subject: [PATCH 047/133] Add worker timing logs to camera worker Introduce WorkerTimingStats (utils/stats.py) to collect simple timing counters (per-named section totals, frame/timeouts/errors) and periodically emit debug logs. Integrate it into SingleCameraWorker: create a timing instance (configurable via new SINGLE_CAMERA_WORKER_DO_LOG_TIMING in config.py), wrap backend.read and frame emit calls with timed sections, and call note_frame/note_timeout/note_error + maybe_log to accumulate and flush stats. Also update imports accordingly and use a 1s default log interval. --- dlclivegui/config.py | 2 + .../services/multi_camera_controller.py | 21 +++- dlclivegui/utils/stats.py | 103 ++++++++++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 115301626..827fdfc6d 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -15,6 +15,8 @@ TriggerRole = Literal["off", "external", "master", "follower"] TriggerActivation = Literal["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"] +SINGLE_CAMERA_WORKER_DO_LOG_TIMING = True + class CameraSettings(BaseModel): name: str = "Camera 0" diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 4ec9a8229..b6260175c 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -18,7 +18,8 @@ from dlclivegui.cameras.factory import camera_identity_key # from dlclivegui.config import CameraSettings -from dlclivegui.config import CameraSettings +from dlclivegui.config import SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraSettings +from dlclivegui.utils.stats import WorkerTimingStats LOGGER = logging.getLogger(__name__) @@ -55,6 +56,11 @@ def __init__(self, camera_id: str, settings: CameraSettings): self._retry_delay = 0.1 self._trigger_timeout_delay = 0.05 + # Performance logs + self._timing = WorkerTimingStats( + camera_id, logger=LOGGER, log_interval=1.0, enabled=SINGLE_CAMERA_WORKER_DO_LOG_TIMING + ) + @Slot() def run(self) -> None: self._stop_event.clear() @@ -90,7 +96,8 @@ def run(self) -> None: while not self._stop_event.is_set(): try: - frame, timestamp = self._backend.read() + with self._timing.measure("GenTL.read"): + frame, timestamp = self._backend.read() if frame is None or frame.size == 0: consecutive_errors += 1 if consecutive_errors >= self._max_consecutive_errors: @@ -103,9 +110,15 @@ def run(self) -> None: continue consecutive_errors = 0 - self.frame_captured.emit(self._camera_id, frame, timestamp) + with self._timing.measure("GenTL.emit.frame_captured"): + self.frame_captured.emit(self._camera_id, frame, timestamp) + + self._timing.note_frame() + self._timing.maybe_log() except TimeoutError as exc: + self._timing.note_timeout() + self._timing.maybe_log() if self._stop_event.is_set(): break @@ -133,6 +146,8 @@ def run(self) -> None: continue except Exception as exc: + self._timing.note_error() + self._timing.maybe_log() consecutive_errors += 1 if self._stop_event.is_set(): break diff --git a/dlclivegui/utils/stats.py b/dlclivegui/utils/stats.py index 23e9d57f0..38e3798b7 100644 --- a/dlclivegui/utils/stats.py +++ b/dlclivegui/utils/stats.py @@ -1,10 +1,113 @@ # dlclivegui/utils/stats.py from __future__ import annotations +import logging +import time + from dlclivegui.services.dlc_processor import ProcessorStats from dlclivegui.services.video_recorder import RecorderStats +class WorkerTimingStats: + """Tiny timing accumulator for camera worker performance diagnostics. + + Usage: + with stats.measure("read"): + frame, ts = backend.read() + + Logs aggregate timings once per log_interval seconds. + """ + + def __init__( + self, camera_id: str, *, logger: logging.Logger | None = None, log_interval: float = 1.0, enabled: bool = True + ): + self.camera_id = camera_id + self.log_interval = float(log_interval) + self.enabled = bool(enabled) + self.logger = logger or logging.getLogger(__name__) + if self.enabled: # force logger to proper level + if not self.logger.isEnabledFor(logging.DEBUG): + self.logger.setLevel(logging.DEBUG) + + self._last_log = time.perf_counter() + self._frames = 0 + self._timeouts = 0 + self._errors = 0 + self._totals: dict[str, float] = {} + self._counts: dict[str, int] = {} + + class _Measure: + def __init__(self, parent: WorkerTimingStats, name: str): + self.parent = parent + self.name = name + self.t0 = 0.0 + + def __enter__(self): + if self.parent.enabled: + self.t0 = time.perf_counter() + return self + + def __exit__(self, exc_type, exc, tb): + if not self.parent.enabled: + return False + + dt = time.perf_counter() - self.t0 + self.parent._totals[self.name] = self.parent._totals.get(self.name, 0.0) + dt + self.parent._counts[self.name] = self.parent._counts.get(self.name, 0) + 1 + return False + + def measure(self, name: str): + return self._Measure(self, name) + + def note_frame(self) -> None: + if self.enabled: + self._frames += 1 + + def note_timeout(self) -> None: + if self.enabled: + self._timeouts += 1 + + def note_error(self) -> None: + if self.enabled: + self._errors += 1 + + def maybe_log(self) -> None: + if not self.enabled: + return + + now = time.perf_counter() + elapsed = now - self._last_log + if elapsed < self.log_interval: + return + + fps = self._frames / max(elapsed, 1e-9) + + parts = [ + f"[Worker {self.camera_id}]", + f"fps={fps:.1f}", + f"frames={self._frames}", + ] + + if self._timeouts: + parts.append(f"timeouts={self._timeouts}") + if self._errors: + parts.append(f"errors={self._errors}") + + for name in sorted(self._totals): + count = max(self._counts.get(name, 0), 1) + avg_ms = 1000.0 * self._totals[name] / count + parts.append(f"avg_{name}_ms={avg_ms:.3f}") + + self.logger.debug(" ".join(parts)) + + self._last_log = now + self._frames = 0 + self._timeouts = 0 + self._errors = 0 + self._totals.clear() + self._counts.clear() + + def format_recorder_stats(stats: RecorderStats) -> str: latency_ms = stats.last_latency * 1000.0 avg_ms = stats.average_latency * 1000.0 From 4003e91d2f7076c170d957d1f616c8d4412144cc Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 12:01:43 +0200 Subject: [PATCH 048/133] Add per-camera timing to MultiCameraController Introduce MULTI_CAMERA_WORKER_DO_LOG_TIMING and disable single-worker timing by default (SINGLE_CAMERA_WORKER_DO_LOG_TIMING=false). Add per-camera WorkerTimingStats storage and a _timing_for_camera factory that respects the new config. Wrap _on_frame_captured processing in timed sections (total, apply_transforms, update_latest), call note_frame/maybe_log per camera, and emit frames as before. Also adjust SingleCameraWorker timing labels from GenTL.* to Single.* for clearer logs. --- dlclivegui/config.py | 3 +- .../services/multi_camera_controller.py | 94 ++++++++++++------- 2 files changed, 61 insertions(+), 36 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 827fdfc6d..5793f5904 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -15,7 +15,8 @@ TriggerRole = Literal["off", "external", "master", "follower"] TriggerActivation = Literal["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"] -SINGLE_CAMERA_WORKER_DO_LOG_TIMING = True +SINGLE_CAMERA_WORKER_DO_LOG_TIMING = False +MULTI_CAMERA_WORKER_DO_LOG_TIMING = True class CameraSettings(BaseModel): diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index b6260175c..adca38aa0 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -18,7 +18,7 @@ from dlclivegui.cameras.factory import camera_identity_key # from dlclivegui.config import CameraSettings -from dlclivegui.config import SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraSettings +from dlclivegui.config import MULTI_CAMERA_WORKER_DO_LOG_TIMING, SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraSettings from dlclivegui.utils.stats import WorkerTimingStats LOGGER = logging.getLogger(__name__) @@ -96,7 +96,7 @@ def run(self) -> None: while not self._stop_event.is_set(): try: - with self._timing.measure("GenTL.read"): + with self._timing.measure("Single.read"): frame, timestamp = self._backend.read() if frame is None or frame.size == 0: consecutive_errors += 1 @@ -110,7 +110,7 @@ def run(self) -> None: continue consecutive_errors = 0 - with self._timing.measure("GenTL.emit.frame_captured"): + with self._timing.measure("Single.emit.frame_captured"): self.frame_captured.emit(self._camera_id, frame, timestamp) self._timing.note_frame() @@ -260,6 +260,9 @@ def __init__(self): self._failed_cameras: dict[str, str] = {} # camera_id -> error message self._expected_cameras: int = 0 # Number of cameras we're trying to start + # Performance logs + self._timing_per_cam: dict[str, WorkerTimingStats] = {} + def is_running(self) -> bool: """Check if any camera is currently running.""" return self._running and len(self._started_cameras) > 0 @@ -268,6 +271,20 @@ def get_active_count(self) -> int: """Get the number of active cameras.""" return len(self._started_cameras) + def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats: + if not MULTI_CAMERA_WORKER_DO_LOG_TIMING: + return WorkerTimingStats(camera_id, enabled=False) + timing = self._timing_per_cam.get(camera_id) + if timing is None: + timing = WorkerTimingStats( + f"Controller {camera_id}", + logger=LOGGER, + log_interval=1.0, + enabled=MULTI_CAMERA_WORKER_DO_LOG_TIMING, + ) + self._timing_per_cam[camera_id] = timing + return timing + def start(self, camera_settings: list[CameraSettings]) -> None: """Start multiple cameras.""" if self._running: @@ -453,38 +470,42 @@ def stop(self, wait: bool = True) -> None: def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: """Handle a frame from one camera.""" # Apply rotation if configured - settings = self._settings.get(camera_id) - if settings and 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 = MultiCameraController.apply_crop(frame, crop_region) - - with self._frame_lock: - self._frames[camera_id] = frame - self._timestamps[camera_id] = timestamp - - # Emit frame data without tiling (tiling done in GUI for performance) - if self._frames: - ordered_frames: dict[str, np.ndarray] = {} - ordered_timestamps: dict[str, float] = {} - - for cam_id in self._camera_display_order: - if cam_id in self._frames: - ordered_frames[cam_id] = self._frames[cam_id] - if cam_id in self._timestamps: - ordered_timestamps[cam_id] = self._timestamps[cam_id] - - # Any unexpected/legacy IDs, appended deterministically. - for cam_id in self._frames: - if cam_id not in ordered_frames: - ordered_frames[cam_id] = self._frames[cam_id] - for cam_id in self._timestamps: - if cam_id not in ordered_timestamps: - ordered_timestamps[cam_id] = self._timestamps[cam_id] + timing = self._timing_for_camera(camera_id) + + with timing.measure("Multi.slot.total"): + settings = self._settings.get(camera_id) + with timing.measure("Multi.slot.apply_transforms"): + if settings and 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 = MultiCameraController.apply_crop(frame, crop_region) + with timing.measure("Multi.update_latest"): + with self._frame_lock: + self._frames[camera_id] = frame + self._timestamps[camera_id] = timestamp + + # Emit frame data without tiling (tiling done in GUI for performance) + if self._frames: + ordered_frames: dict[str, np.ndarray] = {} + ordered_timestamps: dict[str, float] = {} + + for cam_id in self._camera_display_order: + if cam_id in self._frames: + ordered_frames[cam_id] = self._frames[cam_id] + if cam_id in self._timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] + + # Any unexpected/legacy IDs, appended deterministically. + for cam_id in self._frames: + if cam_id not in ordered_frames: + ordered_frames[cam_id] = self._frames[cam_id] + for cam_id in self._timestamps: + if cam_id not in ordered_timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] frame_data = MultiFrameData( frames=ordered_frames, @@ -495,6 +516,9 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float ) self.frame_ready.emit(frame_data) + timing.note_frame() + timing.maybe_log() + @staticmethod def apply_rotation(frame: np.ndarray, degrees: int) -> np.ndarray: """Apply rotation to frame.""" From 5cac636f9d6f0e5f969ad588c5d407db3e523faf Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 12:02:38 +0200 Subject: [PATCH 049/133] Add pretty/str/repr to CameraSettings Introduce a human-readable representation for CameraSettings by adding a pretty() method and overriding __str__ and __repr__. The pretty output formats key fields (name, index, backend, enabled, fps, size, exposure, gain, rotation) and displays a readable crop region (showing 'none' or coordinate range with 'edge' fallbacks). This aids debugging and logging without changing existing validation or behavior. --- dlclivegui/config.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 5793f5904..c7a9f431e 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -43,6 +43,27 @@ class CameraSettings(BaseModel): enabled: bool = True properties: dict[str, Any] = Field(default_factory=dict) + def pretty(self) -> str: + crop = ( + "none" + if self.get_crop_region() is None + else f"({self.crop_x0}, {self.crop_y0}) -> ({self.crop_x1 or 'edge'}, {self.crop_y1 or 'edge'})" + ) + return ( + f"CameraSettings[\n" + f" name={self.name!r}, index={self.index}, backend={self.backend!r}, enabled={self.enabled}\n" + f" fps={self.fps}, size={self.width or 'auto'}x{self.height or 'auto'}, " + f"exposure={self.exposure or 'auto'}, gain={self.gain or 'auto'}\n" + f" rotation={self.rotation}, crop={crop}\n" + f"]" + ) + + def __str__(self) -> str: + return self.pretty() + + def __repr__(self) -> str: + return self.pretty() + @field_validator("fps", mode="before") @classmethod def _coerce_fps(cls, v): From ff81a9e56c28320dfc15cce1d21a406b42d663ac Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 13:45:09 +0200 Subject: [PATCH 050/133] Reduce lock scope and refactor frame handling Refactor _on_frame_captured to minimize time spent under _frame_lock and improve timing granularity. Introduces a frame_data local, renames timing labels (e.g. Multi.apply_transforms, Multi.store_latest, Multi.build_ordered, Multi.construct_frame_data), and moves frame_data construction inside measured blocks. The frame_ready emit is now performed outside the lock and guarded by a None check to avoid holding the lock during signal emission. Also small whitespace and cleanup changes. --- .../services/multi_camera_controller.py | 69 ++++++++++--------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index adca38aa0..c88fbda51 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -469,52 +469,57 @@ def stop(self, wait: bool = True) -> None: def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: """Handle a frame from one camera.""" - # Apply rotation if configured timing = self._timing_for_camera(camera_id) + frame_data: MultiFrameData | None = None with timing.measure("Multi.slot.total"): settings = self._settings.get(camera_id) - with timing.measure("Multi.slot.apply_transforms"): + + with timing.measure("Multi.apply_transforms"): if settings and 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 = MultiCameraController.apply_crop(frame, crop_region) - with timing.measure("Multi.update_latest"): - with self._frame_lock: + + with self._frame_lock: + with timing.measure("Multi.store_latest"): self._frames[camera_id] = frame self._timestamps[camera_id] = timestamp - # Emit frame data without tiling (tiling done in GUI for performance) - if self._frames: - ordered_frames: dict[str, np.ndarray] = {} - ordered_timestamps: dict[str, float] = {} - - for cam_id in self._camera_display_order: - if cam_id in self._frames: - ordered_frames[cam_id] = self._frames[cam_id] - if cam_id in self._timestamps: - ordered_timestamps[cam_id] = self._timestamps[cam_id] - - # Any unexpected/legacy IDs, appended deterministically. - for cam_id in self._frames: - if cam_id not in ordered_frames: - ordered_frames[cam_id] = self._frames[cam_id] - for cam_id in self._timestamps: - if cam_id not in ordered_timestamps: - ordered_timestamps[cam_id] = self._timestamps[cam_id] - - frame_data = MultiFrameData( - frames=ordered_frames, - timestamps=ordered_timestamps, - source_camera_id=camera_id, - tiled_frame=None, - display_ids=dict(self._display_ids), - ) - self.frame_ready.emit(frame_data) + with timing.measure("Multi.build_ordered"): + ordered_frames: dict[str, np.ndarray] = {} + ordered_timestamps: dict[str, float] = {} + + for cam_id in self._camera_display_order: + if cam_id in self._frames: + ordered_frames[cam_id] = self._frames[cam_id] + if cam_id in self._timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] + + # Any unexpected/legacy IDs, appended deterministically. + for cam_id in self._frames: + if cam_id not in ordered_frames: + ordered_frames[cam_id] = self._frames[cam_id] + for cam_id in self._timestamps: + if cam_id not in ordered_timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] + + with timing.measure("Multi.construct_frame_data"): + frame_data = MultiFrameData( + frames=ordered_frames, + timestamps=ordered_timestamps, + source_camera_id=camera_id, + tiled_frame=None, + display_ids=dict(self._display_ids), + ) + + + if frame_data is not None: + with timing.measure("Multi.emit.frame_ready"): + self.frame_ready.emit(frame_data) timing.note_frame() timing.maybe_log() From 52feea97956a2a76247136df7c13c2dc428338d9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 2 Jun 2026 15:48:40 +0200 Subject: [PATCH 051/133] Add GenTL strobe support and trigger debugging Add debugging helpers for GenTL trigger and frame-rate nodes and log their values during camera configuration. Improve GenTL trigger input handling by treating TriggerSource as best-effort (supporting read-only/auto cases) and emitting clearer warnings. Implement a dedicated master/output path for TIS/DMK 37U cameras using Strobe* nodes (StrobeEnable/Polarity/Operation/Duration/Delay) with a fallback to generic Line* configuration; respect strict mode and surface informative errors. Extend CameraTriggerSettings with strobe fields (polarity, operation, duration, delay) and validation/coercion. Update the trigger configuration dialog to expose strobe controls, tooltips, UI sync logic, and include strobe values in the saved payload. --- dlclivegui/cameras/backends/gentl_backend.py | 279 +++++++++++++++--- dlclivegui/config.py | 31 +- .../camera_config/trigger_config_dialog.py | 89 +++++- 3 files changed, 350 insertions(+), 49 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 44d12708f..29d60a46e 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -192,6 +192,89 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "hardware_trigger": SupportLevel.BEST_EFFORT, } + def _debug_trigger_nodes(self, node_map, *, context: str = "") -> None: + names = ( + "TriggerMode", + "TriggerSelector", + "TriggerSource", + "TriggerActivation", + "AcquisitionMode", + # Generic line nodes, if available. + "LineSelector", + "LineMode", + "LineSource", + # TIS 37U / DMK 37BUX287 strobe/output nodes. + "GPIn", + "GPOut", + "StrobeEnable", + "StrobePolarity", + "StrobeOperation", + "StrobeDuration", + "StrobeDelay", + ) + + label = f"GenTL trigger debug {context}".strip() + + for name in names: + node = self._node(node_map, name) + if node is None: + continue + + value = self._node_value(node_map, name, None) + + extras = [] + + symbolics = self._node_symbolics(node) + if symbolics: + extras.append(f"symbolics={symbolics}") + + for attr in ("access_mode", "is_writable", "is_readable"): + try: + extras.append(f"{attr}={getattr(node, attr)}") + except Exception: + pass + + LOG.debug("%s: %s=%r %s", label, name, value, " ".join(extras)) + + def _debug_frame_rate_nodes(self, node_map, *, context: str = "") -> None: + names = ( + "AcquisitionFrameRateEnable", + "AcquisitionFrameRateControlEnable", + "AcquisitionFrameRate", + "AcquisitionFrameRateAbs", + "AcquisitionResultingFrameRate", + "ResultingFrameRate", + "AcquisitionFrameRateResulting", + "DeviceFrameRate", + "ExposureAuto", + "ExposureTime", + "ExposureTimeAbs", + "DeviceLinkThroughputLimit", + "DeviceLinkThroughputLimitMode", + "PayloadSize", + "Width", + "Height", + "PixelFormat", + ) + + label = f"GenTL FPS debug {context}".strip() + + for name in names: + node = self._node(node_map, name) + if node is None: + continue + + value = self._node_value(node_map, name, None) + + extras = [] + for attr in ("min", "max", "inc"): + try: + extras.append(f"{attr}={getattr(node, attr)}") + except Exception: + pass + + LOG.debug("%s: %s=%r %s", label, name, value, " ".join(extras)) + # ------------------------------------------------------------------ # Discovery # ------------------------------------------------------------------ @@ -464,6 +547,7 @@ def open(self) -> None: self._configure_gain(node_map) self._configure_frame_rate(node_map) self._configure_trigger(node_map) # keep low in the list + self._debug_trigger_nodes(node_map, context="after configuration before acquisition") self._ensure_settings_ns()["trigger_actual"] = self._trigger_to_dict(self._trigger) self._read_telemetry(node_map) self._persist_device_metadata(selected_info, selected_serial) @@ -1186,31 +1270,42 @@ def _configure_trigger_off(self, node_map, *, strict: bool = False) -> None: def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> None: role = str(self._trigger_attr(cfg, "role", "external") or "external").strip().lower() selector = str(self._trigger_attr(cfg, "selector", "FrameStart") or "FrameStart") - source = str(self._trigger_attr(cfg, "source", "Line0") or "Line0") activation = str(self._trigger_attr(cfg, "activation", "RisingEdge") or "RisingEdge") + source = str(self._trigger_attr(cfg, "source", "auto") or "auto").strip() # Disable trigger while changing trigger-related nodes. self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) selector_ok = self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) - source, source_resolved = self._resolve_trigger_source(node_map, source, strict=strict) - source_ok = source_resolved and self._set_enum_node(node_map, "TriggerSource", source, strict=strict) activation_ok = self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict) - # TriggerSelector and TriggerSource are required routing nodes. - # If either failed in non-strict mode, do not arm TriggerMode=On. - # Otherwise the camera may wait on a previous/default input line. - if not (selector_ok and source_ok): + source_ok = False + if source and source.lower() not in {"", "auto", "none"}: + source_node = self._node(node_map, "TriggerSource") + source_symbolics = self._node_symbolics(source_node) + + if source_node is not None: + if source in source_symbolics: + source_ok = self._set_enum_node(node_map, "TriggerSource", source, strict=False) + if not source_ok: + LOG.warning( + "GenTL TriggerSource=%s is supported but not writable; " + "continuing without changing TriggerSource. Available: %s", + source, + source_symbolics, + ) + else: + LOG.warning( + "Requested GenTL TriggerSource=%s not in available sources %s; " + "continuing without changing TriggerSource.", + source, + source_symbolics, + ) + + if not selector_ok: LOG.warning( - "Could not apply GenTL trigger input routing " - "(selector_ok=%s, source_ok=%s); disabling trigger. " - "requested role=%s selector=%s source=%s activation=%s", - selector_ok, - source_ok, - role, + "Could not apply GenTL TriggerSelector=%s; disabling trigger.", selector, - source, - activation, ) self._configure_trigger_off(node_map, strict=False) self._trigger = CameraTriggerSettings() @@ -1231,55 +1326,153 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No return LOG.info( - "GenTL trigger input configured: role=%s selector=%s source=%s activation=%s activation_ok=%s", + "GenTL trigger input configured: role=%s selector=%s activation=%s " + "selector_ok=%s activation_ok=%s source_requested=%s source_ok=%s", role, selector, - source, activation, + selector_ok, activation_ok, + source, + source_ok, ) def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> None: + """Configure this camera as a free-running master that emits STROBE_OUT pulses. + + For DMK 37BUX287 / TIS 37U series, the physical output is controlled by + StrobeEnable/StrobePolarity/StrobeOperation rather than SFNC LineSelector/ + LineMode/LineSource nodes. + """ output_line = str(self._trigger_attr(cfg, "output_line", "Line2") or "Line2") output_source = str(self._trigger_attr(cfg, "output_source", "ExposureActive") or "ExposureActive") - # Master camera runs freerun and exposes an output signal. + # Optional extra fields if present in trigger dict/model. + strobe_polarity = str(self._trigger_attr(cfg, "strobe_polarity", "ActiveHigh") or "ActiveHigh") + strobe_operation = str(self._trigger_attr(cfg, "strobe_operation", "Exposure") or "Exposure") + strobe_duration = self._trigger_attr(cfg, "strobe_duration", None) + strobe_delay = self._trigger_attr(cfg, "strobe_delay", None) + + # Master camera should be free-running. self._configure_trigger_off(node_map, strict=False) - line_selected = self._set_enum_node( - node_map, - "LineSelector", - output_line, - strict=strict, - ) + # ------------------------------------------------------------------ + # Preferred path for The Imaging Source 37U / DMK 37BUX287: + # StrobeEnable, StrobePolarity, StrobeOperation, StrobeDuration, StrobeDelay + # ------------------------------------------------------------------ + strobe_enable_node = self._node(node_map, "StrobeEnable") + + if strobe_enable_node is not None: + # Disable first while changing parameters. + self._set_enum_node(node_map, "StrobeEnable", "Off", strict=False) + + polarity_ok = self._set_enum_node( + node_map, + "StrobePolarity", + strobe_polarity, + strict=False, + ) - # In non-strict mode, do not continue configuring output behavior if the - # requested line could not be selected. Otherwise we may accidentally drive - # whichever GPIO line the camera had selected previously/defaulted to. - if not line_selected: - LOG.warning( - "Could not select GenTL output line '%s'; skipping trigger output configuration.", - output_line, + operation_ok = self._set_enum_node( + node_map, + "StrobeOperation", + strobe_operation, + strict=False, ) - return - mode_ok = self._set_enum_node(node_map, "LineMode", "Output", strict=strict) - source_ok = self._set_enum_node(node_map, "LineSource", output_source, strict=strict) + if strobe_duration is not None: + try: + node = self._node(node_map, "StrobeDuration") + if node is not None: + node.value = int(strobe_duration) + LOG.info("Configured GenTL StrobeDuration=%s", int(strobe_duration)) + except Exception as exc: + if strict: + raise RuntimeError(f"Failed to set StrobeDuration={strobe_duration}: {exc}") from exc + LOG.warning("Failed to set StrobeDuration=%s: %s", strobe_duration, exc) + + if strobe_delay is not None: + try: + node = self._node(node_map, "StrobeDelay") + if node is not None: + node.value = int(strobe_delay) + LOG.info("Configured GenTL StrobeDelay=%s", int(strobe_delay)) + except Exception as exc: + if strict: + raise RuntimeError(f"Failed to set StrobeDelay={strobe_delay}: {exc}") from exc + LOG.warning("Failed to set StrobeDelay=%s: %s", strobe_delay, exc) + + enable_ok = self._set_enum_node( + node_map, + "StrobeEnable", + "On", + strict=strict, + ) + + if enable_ok: + LOG.info( + "GenTL trigger master configured via Strobe*: " + "StrobeEnable=On StrobePolarity=%s polarity_ok=%s " + "StrobeOperation=%s operation_ok=%s", + strobe_polarity, + polarity_ok, + strobe_operation, + operation_ok, + ) + return + + if strict: + raise RuntimeError("Could not enable GenTL StrobeEnable=On") - if not (mode_ok and source_ok): LOG.warning( - "GenTL trigger master output configuration incomplete (LineMode ok=%s, LineSource ok=%s).", - mode_ok, - source_ok, + "StrobeEnable node exists but could not be enabled; falling back to generic Line* output configuration." ) - return - LOG.info( - "GenTL trigger master configured: output_line=%s output_source=%s", - output_line, - output_source, + # ------------------------------------------------------------------ + # Generic SFNC fallback for cameras that expose LineSelector/LineMode/LineSource. + # ------------------------------------------------------------------ + line_selector = self._node(node_map, "LineSelector") + if line_selector is not None: + line_selected = self._set_enum_node( + node_map, + "LineSelector", + output_line, + strict=strict, + ) + + if not line_selected: + LOG.warning( + "Could not select GenTL output line '%s'; skipping Line* output configuration.", + output_line, + ) + else: + mode_ok = self._set_enum_node(node_map, "LineMode", "Output", strict=strict) + source_ok = self._set_enum_node(node_map, "LineSource", output_source, strict=strict) + + if mode_ok and source_ok: + LOG.info( + "GenTL trigger master configured via Line*: output_line=%s output_source=%s", + output_line, + output_source, + ) + return + + LOG.warning( + "GenTL Line* trigger output configuration incomplete (LineMode ok=%s, LineSource ok=%s).", + mode_ok, + source_ok, + ) + + msg = ( + "Could not configure GenTL trigger master output. " + "No supported Strobe* or Line* output path was successfully configured." ) + if strict: + raise RuntimeError(msg) + + LOG.warning(msg) + def _restore_trigger_idle(self, node_map) -> None: """Best-effort restore to a safe non-triggering state after acquisition stops. diff --git a/dlclivegui/config.py b/dlclivegui/config.py index c7a9f431e..2d176224f 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -14,6 +14,8 @@ ModelType = Literal["pytorch", "tensorflow"] TriggerRole = Literal["off", "external", "master", "follower"] TriggerActivation = Literal["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"] +TriggerStrobePolarity = Literal["ActiveHigh", "ActiveLow"] +TriggerStrobeOperation = Literal["Exposure", "FixedDuration"] SINGLE_CAMERA_WORKER_DO_LOG_TIMING = False MULTI_CAMERA_WORKER_DO_LOG_TIMING = True @@ -239,9 +241,13 @@ class CameraTriggerSettings(BaseModel): Generic hardware-trigger settings. Backend-specific code may ignore fields that are unsupported by a given - camera/SDK. For GenTL, these map to common GenICam nodes such as: - TriggerMode, TriggerSelector, TriggerSource, TriggerActivation, - LineSelector, LineMode, and LineSource. + camera/SDK. + + For GenTL/TIS DMK 37BUX287: + - follower/external maps mainly to TriggerMode, TriggerSelector, + TriggerActivation. TriggerSource may be read-only and is best-effort. + - master output maps primarily to StrobeEnable, StrobePolarity, + StrobeOperation, StrobeDuration, and StrobeDelay. """ role: TriggerRole = "off" @@ -251,10 +257,16 @@ class CameraTriggerSettings(BaseModel): source: str = "auto" activation: TriggerActivation | str = "RisingEdge" - # Output config: master + # Generic/SFNC output config: master fallback for cameras exposing Line* nodes. output_line: str = "Line2" output_source: str = "ExposureActive" + # Strobe output config: master path for TIS/DMK 37U cameras. + strobe_polarity: TriggerStrobePolarity | str = "ActiveHigh" + strobe_operation: TriggerStrobeOperation | str = "Exposure" + strobe_duration: int | None = None # µs, used when strobe_operation=FixedDuration + strobe_delay: int | None = None # µs + # Runtime behavior timeout: float | None = None strict: bool = False @@ -296,6 +308,17 @@ def _coerce_timeout(cls, v): return None return fv if fv > 0 else None + @field_validator("strobe_duration", "strobe_delay", mode="before") + @classmethod + def _coerce_optional_nonnegative_int(cls, v): + if v in (None, ""): + return None + try: + iv = int(float(v)) + except Exception: + return None + return iv if iv >= 0 else None + @field_validator("source", mode="before") @classmethod def _coerce_source(cls, v): diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py index d1efa0362..c6f78140b 100644 --- a/dlclivegui/gui/camera_config/trigger_config_dialog.py +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -12,6 +12,7 @@ QLabel, QLineEdit, QMessageBox, + QSpinBox, QVBoxLayout, QWidget, ) @@ -59,7 +60,9 @@ def _setup_ui(self) -> None: info = QLabel( "Configure hardware trigger settings for this camera.\n" - "Use 'auto' for trigger source unless you know the exact GenICam line name. " + "Follower/external mode arms the camera and waits for electrical pulses on TRIGGER_IN.\n" + "Master mode enables STROBE_OUT pulses. For TIS/DMK 37U cameras this uses Strobe settings; " + "Line output settings are kept as a generic fallback.\n" "In strict mode, unsupported trigger nodes fail camera open." ) info.setWordWrap(True) @@ -90,12 +93,57 @@ def _setup_ui(self) -> None: self.output_line_edit = QLineEdit() self.output_line_edit.setPlaceholderText("Line2") + self.output_line_edit.setToolTip( + "Generic Line* output selector for cameras exposing LineSelector/LineSource. " + "Ignored by TIS/DMK 37U strobe-based output." + ) form.addRow("Output line:", self.output_line_edit) self.output_source_edit = QLineEdit() self.output_source_edit.setPlaceholderText("ExposureActive") + self.output_source_edit.setToolTip( + "Generic LineSource value for cameras exposing LineSource. " + "For TIS/DMK 37U cameras, use Strobe operation instead." + ) form.addRow("Output source:", self.output_source_edit) + self.strobe_polarity_combo = QComboBox() + self.strobe_polarity_combo.addItem("Active high", "ActiveHigh") + self.strobe_polarity_combo.addItem("Active low", "ActiveLow") + self.strobe_polarity_combo.setToolTip( + "Polarity of STROBE_OUT. If the follower does not trigger, also try changing the follower activation edge." + ) + form.addRow("Strobe polarity:", self.strobe_polarity_combo) + + self.strobe_operation_combo = QComboBox() + self.strobe_operation_combo.addItem("Exposure duration", "Exposure") + self.strobe_operation_combo.addItem("Fixed duration", "FixedDuration") + self.strobe_operation_combo.setToolTip( + "Exposure: strobe pulse length follows exposure time. " + "FixedDuration: strobe pulse length is set by Strobe duration." + ) + form.addRow("Strobe operation:", self.strobe_operation_combo) + + self.strobe_duration_spin = QSpinBox() + self.strobe_duration_spin.setRange(0, 32767) + self.strobe_duration_spin.setSingleStep(100) + self.strobe_duration_spin.setSuffix(" µs") + self.strobe_duration_spin.setSpecialValueText("Default") + self.strobe_duration_spin.setToolTip( + "Used only when Strobe operation is FixedDuration. 0 means backend/device default." + ) + form.addRow("Strobe duration:", self.strobe_duration_spin) + + self.strobe_delay_spin = QSpinBox() + self.strobe_delay_spin.setRange(0, 32767) + self.strobe_delay_spin.setSingleStep(100) + self.strobe_delay_spin.setSuffix(" µs") + self.strobe_delay_spin.setSpecialValueText("Default") + self.strobe_delay_spin.setToolTip( + "Delay between start of exposure and STROBE_OUT pulse. 0 means no delay/device default." + ) + form.addRow("Strobe delay:", self.strobe_delay_spin) + self.timeout_spin = QDoubleSpinBox() self.timeout_spin.setRange(0.0, 3600.0) self.timeout_spin.setDecimals(3) @@ -118,6 +166,7 @@ def _setup_ui(self) -> None: root.addWidget(buttons) self.role_combo.currentIndexChanged.connect(self._sync_role_ui) + self.strobe_operation_combo.currentIndexChanged.connect(self._sync_role_ui) def _load_from_trigger(self, trigger: CameraTriggerSettings) -> None: role = str(getattr(trigger, "role", "off") or "off").lower() @@ -134,6 +183,20 @@ def _load_from_trigger(self, trigger: CameraTriggerSettings) -> None: self.output_line_edit.setText(str(getattr(trigger, "output_line", "Line2") or "Line2")) self.output_source_edit.setText(str(getattr(trigger, "output_source", "ExposureActive") or "ExposureActive")) + strobe_polarity = str(getattr(trigger, "strobe_polarity", "ActiveHigh") or "ActiveHigh") + idx = self.strobe_polarity_combo.findData(strobe_polarity) + self.strobe_polarity_combo.setCurrentIndex(idx if idx >= 0 else 0) + + strobe_operation = str(getattr(trigger, "strobe_operation", "Exposure") or "Exposure") + idx = self.strobe_operation_combo.findData(strobe_operation) + self.strobe_operation_combo.setCurrentIndex(idx if idx >= 0 else 0) + + strobe_duration = getattr(trigger, "strobe_duration", None) + self.strobe_duration_spin.setValue(int(strobe_duration) if strobe_duration is not None else 0) + + strobe_delay = getattr(trigger, "strobe_delay", None) + self.strobe_delay_spin.setValue(int(strobe_delay) if strobe_delay is not None else 0) + timeout = getattr(trigger, "timeout", None) self.timeout_spin.setValue(float(timeout) if timeout else 0.0) @@ -143,15 +206,26 @@ def _sync_role_ui(self) -> None: role = str(self.role_combo.currentData() or "off") input_enabled = role in {"external", "follower"} - output_enabled = role == "master" self.selector_edit.setEnabled(input_enabled) self.source_edit.setEnabled(input_enabled) self.activation_combo.setEnabled(input_enabled) + output_enabled = role == "master" + # Generic Line* fallback fields. self.output_line_edit.setEnabled(output_enabled) self.output_source_edit.setEnabled(output_enabled) + # TIS/DMK 37U Strobe* fields. + self.strobe_polarity_combo.setEnabled(output_enabled) + self.strobe_operation_combo.setEnabled(output_enabled) + + fixed_duration = ( + output_enabled and str(self.strobe_operation_combo.currentData() or "Exposure") == "FixedDuration" + ) + self.strobe_duration_spin.setEnabled(fixed_duration) + self.strobe_delay_spin.setEnabled(output_enabled) + # Timeout is mostly useful for external/follower, but harmless for any role. self.timeout_spin.setEnabled(role in {"external", "follower"}) @@ -163,8 +237,12 @@ def _accept(self) -> None: "selector": self.selector_edit.text().strip() or "FrameStart", "source": self.source_edit.text().strip() or "auto", "activation": str(self.activation_combo.currentData() or "RisingEdge"), + # Generic/SFNC Line* fallback output settings. "output_line": self.output_line_edit.text().strip() or "Line2", "output_source": self.output_source_edit.text().strip() or "ExposureActive", + # Strobe output settings used by TIS/DMK 37U cameras. + "strobe_polarity": str(self.strobe_polarity_combo.currentData() or "ActiveHigh"), + "strobe_operation": str(self.strobe_operation_combo.currentData() or "Exposure"), "strict": bool(self.strict_checkbox.isChecked()), } @@ -173,6 +251,13 @@ def _accept(self) -> None: payload["timeout"] = timeout elif role == "off": payload["timeout"] = None # ensure timeout is cleared when disabling trigger + strobe_duration = int(self.strobe_duration_spin.value()) + if role == "master" and strobe_duration > 0: + payload["strobe_duration"] = strobe_duration + + strobe_delay = int(self.strobe_delay_spin.value()) + if role == "master" and strobe_delay > 0: + payload["strobe_delay"] = strobe_delay try: trigger = CameraTriggerSettings.from_any(payload) From 658cea61c72f234fed329b6235042a400bfe10c9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 15:46:42 -0500 Subject: [PATCH 052/133] Add _node_value helper for safe node reads Introduce a static _node_value(node_map, name, default) helper that performs a best-effort read of GenICam node values. It looks up the node, returns default if missing, then tries node.value and falls back to node.GetValue() while swallowing exceptions. This prevents debug helpers and open() from failing when test/SDK nodes expose different accessors or raise errors. --- dlclivegui/cameras/backends/gentl_backend.py | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 29d60a46e..cbd1632d7 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1093,6 +1093,32 @@ def _node(node_map, name: str): except Exception: return None + @staticmethod + def _node_value(node_map, name: str, default=None): + """Best-effort read of a GenICam node value. + + Debug helpers must not make open() fail just because a value cannot be read. + Harvesters-style fake/test nodes usually expose `.value`; some SDK-style + nodes may expose `GetValue()`. + """ + node = GenTLCameraBackend._node(node_map, name) + if node is None: + return default + + try: + return node.value + except Exception: + pass + + try: + getter = getattr(node, "GetValue", None) + if getter is not None: + return getter() + except Exception: + pass + + return default + @staticmethod def _node_symbolics(node) -> list[str]: try: From 732f5fd51b68e66b057ee17c9bd1b09009e3677d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 15:48:33 -0500 Subject: [PATCH 053/133] Refactor GenTL trigger source handling and logging Replace manual TriggerSource symbol checks with _resolve_trigger_source and only set TriggerSource when supported. Delay setting TriggerActivation until after source resolution and use non-strict writes for activation. Add safety check: do not arm TriggerMode=On unless both TriggerSelector and TriggerSource succeeded (avoids waiting on a previous/default input). Improve log messages to include selector_ok, source_ok, requested/ resolved source and activation for clearer diagnostics. --- dlclivegui/cameras/backends/gentl_backend.py | 66 +++++++++++--------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index cbd1632d7..260c55a8b 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1303,35 +1303,44 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) selector_ok = self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) - activation_ok = self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict) + + resolved_source, source_supported = self._resolve_trigger_source( + node_map, + source, + strict=strict, + ) source_ok = False - if source and source.lower() not in {"", "auto", "none"}: - source_node = self._node(node_map, "TriggerSource") - source_symbolics = self._node_symbolics(source_node) - - if source_node is not None: - if source in source_symbolics: - source_ok = self._set_enum_node(node_map, "TriggerSource", source, strict=False) - if not source_ok: - LOG.warning( - "GenTL TriggerSource=%s is supported but not writable; " - "continuing without changing TriggerSource. Available: %s", - source, - source_symbolics, - ) - else: - LOG.warning( - "Requested GenTL TriggerSource=%s not in available sources %s; " - "continuing without changing TriggerSource.", - source, - source_symbolics, - ) + if source_supported: + source_ok = self._set_enum_node( + node_map, + "TriggerSource", + resolved_source, + strict=strict, + ) - if not selector_ok: + activation_ok = self._set_enum_node( + node_map, + "TriggerActivation", + activation, + strict=False, + ) + + # TriggerSelector and TriggerSource are required routing nodes. + # If either failed in non-strict mode, do not arm TriggerMode=On. + # Otherwise the camera may wait on a previous/default input line. + if not (selector_ok and source_ok): LOG.warning( - "Could not apply GenTL TriggerSelector=%s; disabling trigger.", + "Could not apply GenTL trigger input routing " + "(selector_ok=%s, source_ok=%s); disabling trigger. " + "requested role=%s selector=%s source=%s resolved_source=%s activation=%s", + selector_ok, + source_ok, + role, selector, + source, + resolved_source, + activation, ) self._configure_trigger_off(node_map, strict=False) self._trigger = CameraTriggerSettings() @@ -1352,15 +1361,16 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No return LOG.info( - "GenTL trigger input configured: role=%s selector=%s activation=%s " - "selector_ok=%s activation_ok=%s source_requested=%s source_ok=%s", + "GenTL trigger input configured: role=%s selector=%s source_requested=%s " + "source=%s activation=%s selector_ok=%s source_ok=%s activation_ok=%s", role, selector, + source, + resolved_source, activation, selector_ok, - activation_ok, - source, source_ok, + activation_ok, ) def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> None: From 4a20506721b7a2f1988f3cc822c60d27115c1fad Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 12:39:50 -0500 Subject: [PATCH 054/133] Add Basler trigger support and timeout handling Implement comprehensive trigger support for the Basler backend: import CameraTriggerSettings, parse trigger config (roles: off, external/follower, software, master), and persist trigger_actual into the namespace. Add trigger configuration helpers (_configure_trigger*, _resolve_trigger_source, _restore_trigger_idle), software trigger execution (trigger_once), and many feature/enum/numeric helper methods with debug logging. Make RetrieveResult use a configurable _retrieve_timeout_ms (derived from trigger.timeout) and limit it for hardware-triggered cameras to allow prompt shutdown; raise a TimeoutError when waiting for hardware triggers. Expose hardware_trigger capability as BEST_EFFORT and add an env var-based pylon emulation toggle for testing. Misc: add debug dumps of trigger-related nodes and best-effort restore of trigger state on close. --- dlclivegui/cameras/backends/basler_backend.py | 453 +++++++++++++++++- 1 file changed, 452 insertions(+), 1 deletion(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 8e7b0e19b..30a5ea0de 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -9,10 +9,21 @@ import numpy as np +from ...config import CameraTriggerSettings from ..base import CameraBackend, SupportLevel, register_backend LOG = logging.getLogger(__name__) + +# NOTE @C-Achard: This could be added in settings eventually +# Forces pypylon to create N emulation virtual cameras, +# mostly for testing. This should not be enabled for release. +ENABLE_PYLON_EMU = True +if ENABLE_PYLON_EMU: + import os + + os.environ["PYLON_CAMEMU"] = "4" + try: # pragma: no cover - optional dependency from pypylon import pylon except Exception: # pragma: no cover - optional dependency @@ -25,6 +36,10 @@ class BaslerCameraBackend(CameraBackend): OPTIONS_KEY: ClassVar[str] = "basler" + # Keep RetrieveResult calls short enough that controller shutdown can stop + # worker threads promptly while waiting for external hardware triggers. + _MAX_HARDWARE_TRIGGER_RETRIEVE_TIMEOUT_MS: ClassVar[int] = 1000 + def __init__(self, settings): super().__init__(settings) @@ -33,6 +48,37 @@ def __init__(self, settings): # 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)) + self._retrieve_timeout_ms: int = 100 # default; may be overridden by trigger settings + + # ---- Trigger settings ---- + raw_trigger = self.ns.get("trigger", self._props.get("trigger")) + raw_trigger_strict = isinstance(raw_trigger, dict) and bool(raw_trigger.get("strict", False)) + + try: + self._trigger = CameraTriggerSettings.from_any(raw_trigger) + except Exception as exc: + if raw_trigger_strict: + raise ValueError(f"Strict mode failure - Invalid Basler trigger configuration: {exc}") from exc + + LOG.warning( + "Invalid Basler trigger config; falling back to trigger role=off: %s. " + "Enable strict mode to force this to raise.", + exc, + ) + self._trigger = CameraTriggerSettings() + + trigger_timeout = self._positive_float(self._trigger_attr(self._trigger, "timeout", None)) + if trigger_timeout is not None: + # pypylon RetrieveResult timeout is milliseconds. + self._retrieve_timeout_ms = max(1, int(float(trigger_timeout) * 1000.0)) + else: + self._retrieve_timeout_ms = 100 + + if self.waits_for_hardware_trigger: + self._retrieve_timeout_ms = min( + self._retrieve_timeout_ms, + self._MAX_HARDWARE_TRIGGER_RETRIEVE_TIMEOUT_MS, + ) # Stable identity (serial-based). Prefer new namespace; fall back to legacy keys read-only. self._device_id: str | None = None @@ -95,6 +141,7 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "set_gain": SupportLevel.SUPPORTED, "device_discovery": SupportLevel.BEST_EFFORT, "stable_identity": SupportLevel.SUPPORTED, + "hardware_trigger": SupportLevel.BEST_EFFORT, } ) return caps @@ -314,6 +361,26 @@ def _positive_float(value) -> float | None: except Exception: return None + def trigger_once(self) -> None: + if self._camera is None: + raise RuntimeError("Basler camera not opened") + + # pypylon commonly exposes ExecuteSoftwareTrigger on InstantCamera. + method = getattr(self._camera, "ExecuteSoftwareTrigger", None) + if method is not None: + method() + return + + command = self._feature("TriggerSoftware") + if command is not None: + try: + command.Execute() + return + except Exception as exc: + raise RuntimeError(f"Failed to execute Basler software trigger: {exc}") from exc + + raise RuntimeError("Basler software trigger command is not available") + def open(self) -> None: if pylon is None: raise RuntimeError("pypylon is required for the Basler backend but is not installed") @@ -374,6 +441,19 @@ def open(self) -> None: except Exception: LOG.debug("Frame rate not writable or not supported", exc_info=True) + # ---------------------------- + # Trigger configuration + # ---------------------------- + self._debug_trigger_nodes(context="before configuration") + self._configure_trigger() + self._debug_trigger_nodes(context="after configuration") + + try: + ns = self._ensure_mutable_ns() + ns["trigger_actual"] = self._trigger_to_dict(self._trigger) + except Exception: + pass + # ---------------------------- # Read back actual values (telemetry for GUI / probe) # ---------------------------- @@ -443,6 +523,7 @@ def open(self) -> None: getattr(self.settings, "exposure", None), getattr(self.settings, "gain", None), ) + # ---------------------------- # Persist stable identity into namespace (migration-safe) # ---------------------------- @@ -464,8 +545,13 @@ def read(self) -> tuple[np.ndarray, float]: 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) + grab_result = self._camera.RetrieveResult( + int(getattr(self, "_retrieve_timeout_ms", 100)), + pylon.TimeoutHandling_ThrowException, + ) except Exception as exc: + if self.waits_for_hardware_trigger: + raise TimeoutError(f"Basler timeout while waiting for hardware trigger: {exc}") from exc raise RuntimeError("Failed to retrieve image from Basler camera.") from exc if not grab_result.GrabSucceeded(): grab_result.Release() @@ -494,7 +580,13 @@ def close(self) -> None: self._camera.StopGrabbing() except Exception: pass + if self._camera.IsOpen(): + try: + self._restore_trigger_idle() + except Exception: + pass + self._camera.Close() self._camera = None self._converter = None @@ -571,6 +663,365 @@ def _snap_to_node(value: int, node) -> int: return int(v) + @property + def waits_for_hardware_trigger(self) -> bool: + role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() + return role in {"external", "follower"} + + @staticmethod + def _trigger_attr(trigger, name: str, default=None): + if isinstance(trigger, dict): + return trigger.get(name, default) + return getattr(trigger, name, default) + + @staticmethod + def _trigger_to_dict(trigger) -> dict: + if trigger is None: + return {} + if isinstance(trigger, dict): + return dict(trigger) + if hasattr(trigger, "model_dump"): + try: + return trigger.model_dump(exclude_none=True) + except Exception: + pass + return {} + + def _feature(self, name: str): + if self._camera is None: + return None + try: + return getattr(self._camera, name) + except Exception: + return None + + @staticmethod + def _feature_value(feature, default=None): + if feature is None: + return default + try: + return feature.GetValue() + except Exception: + return default + + @staticmethod + def _feature_symbolics(feature) -> list[str]: + if feature is None: + return [] + + for method_name in ("GetSymbolics", "GetEntries"): + try: + method = getattr(feature, method_name, None) + if method is None: + continue + + values = method() + out = [] + + for value in values: + try: + if hasattr(value, "GetSymbolic"): + out.append(str(value.GetSymbolic())) + else: + out.append(str(value)) + except Exception: + continue + + return [v for v in out if v] + except Exception: + continue + + return [] + + def _set_enum_feature(self, name: str, value: str, *, strict: bool = False) -> bool: + feature = self._feature(name) + + if feature is None: + if strict: + raise RuntimeError(f"Basler feature '{name}' is not available") + LOG.debug("Basler feature '%s' is not available; skipping", name) + return False + + symbolics = self._feature_symbolics(feature) + if symbolics and value not in symbolics: + if strict: + raise RuntimeError(f"Basler feature '{name}' does not support '{value}'. Available: {symbolics}") + LOG.warning("Basler feature '%s' does not support '%s'. Available: %s", name, value, symbolics) + return False + + try: + feature.SetValue(value) + return True + except Exception as exc: + if strict: + raise RuntimeError(f"Failed to set Basler feature '{name}' to '{value}': {exc}") from exc + LOG.warning("Failed to set Basler feature '%s' to '%s': %s", name, value, exc) + return False + + def _set_numeric_feature(self, name: str, value, *, strict: bool = False) -> bool: + feature = self._feature(name) + + if feature is None: + if strict: + raise RuntimeError(f"Basler feature '{name}' is not available") + LOG.debug("Basler feature '%s' is not available; skipping", name) + return False + + try: + feature.SetValue(value) + return True + except Exception as exc: + if strict: + raise RuntimeError(f"Failed to set Basler feature '{name}' to '{value}': {exc}") from exc + LOG.warning("Failed to set Basler feature '%s' to '%s': %s", name, value, exc) + return False + + def _debug_trigger_nodes(self, *, context: str = "") -> None: + names = ( + "TriggerSelector", + "TriggerMode", + "TriggerSource", + "TriggerActivation", + "TriggerDelay", + "TriggerDelayAbs", + "AcquisitionMode", + "LineSelector", + "LineMode", + "LineSource", + "LineInverter", + ) + + label = f"Basler trigger debug {context}".strip() + + for name in names: + feature = self._feature(name) + if feature is None: + continue + + value = self._feature_value(feature, None) + symbolics = self._feature_symbolics(feature) + + extras = [] + if symbolics: + extras.append(f"symbolics={symbolics}") + + for method_name in ("IsReadable", "IsWritable"): + try: + method = getattr(feature, method_name, None) + if method is not None: + extras.append(f"{method_name}={method()}") + except Exception: + pass + + LOG.debug("%s: %s=%r %s", label, name, value, " ".join(extras)) + + def _resolve_trigger_source(self, requested: str, *, strict: bool) -> tuple[str, bool]: + requested = str(requested or "auto").strip() + feature = self._feature("TriggerSource") + available = self._feature_symbolics(feature) + + if not available: + if strict: + raise RuntimeError("Basler feature 'TriggerSource' is not available or has no symbolics") + LOG.warning("Basler feature 'TriggerSource' is not available; disabling trigger input.") + return requested, False + + if requested in available: + return requested, True + + if requested.lower() == "auto": + for candidate in ("Line1", "Line2", "Line3", "Line4", "Line0", "Software", "Action1"): + if candidate in available: + LOG.info("Basler TriggerSource auto-selected '%s'. Available: %s", candidate, available) + return candidate, True + + LOG.warning("Could not auto-select a Basler TriggerSource. Available: %s", available) + return requested, False + + if strict: + raise RuntimeError(f"Basler feature 'TriggerSource' does not support '{requested}'. Available: {available}") + + LOG.warning("Basler TriggerSource '%s' is not available. Available: %s", requested, available) + return requested, False + + def _configure_trigger(self) -> None: + cfg = getattr(self, "_trigger", CameraTriggerSettings()) + self._trigger = cfg + role = str(self._trigger_attr(cfg, "role", "off") or "off").strip().lower() + strict = bool(self._trigger_attr(cfg, "strict", False)) + + if role in {"off", "disabled"}: + self._configure_trigger_off(strict=strict) + return + + if role in {"external", "follower"}: + self._configure_trigger_input(cfg, strict=strict) + return + + if role == "software": + self._configure_trigger_software(cfg, strict=strict) + return + + if role == "master": + self._configure_trigger_master(cfg, strict=strict) + return + + if strict: + raise RuntimeError(f"Unsupported Basler trigger role: {role!r}") + + LOG.warning("Unsupported Basler trigger role '%s'; disabling trigger.", role) + self._configure_trigger_off(strict=False) + + def _configure_trigger_off(self, *, strict: bool = False) -> None: + # Select FrameStart first when possible so TriggerMode=Off applies to + # the frame-start trigger path. + self._set_enum_feature("TriggerSelector", "FrameStart", strict=False) + self._set_enum_feature("TriggerMode", "Off", strict=strict) + + def _configure_trigger_input(self, cfg, *, strict: bool = False) -> None: + role = str(self._trigger_attr(cfg, "role", "external") or "external").strip().lower() + selector = str(self._trigger_attr(cfg, "selector", "FrameStart") or "FrameStart") + activation = str(self._trigger_attr(cfg, "activation", "RisingEdge") or "RisingEdge") + source = str(self._trigger_attr(cfg, "source", "auto") or "auto").strip() + delay = self._trigger_attr(cfg, "delay", None) + + # Disable trigger while changing trigger-related parameters. + self._set_enum_feature("TriggerMode", "Off", strict=False) + + selector_ok = self._set_enum_feature("TriggerSelector", selector, strict=strict) + + resolved_source, source_supported = self._resolve_trigger_source(source, strict=strict) + source_ok = False + if source_supported: + source_ok = self._set_enum_feature("TriggerSource", resolved_source, strict=strict) + + activation_ok = self._set_enum_feature("TriggerActivation", activation, strict=False) + + if delay is not None: + delay_value = float(delay) + if not self._set_numeric_feature("TriggerDelay", delay_value, strict=False): + self._set_numeric_feature("TriggerDelayAbs", delay_value, strict=False) + + self._set_enum_feature("AcquisitionMode", "Continuous", strict=False) + + if not selector_ok: + LOG.warning("Could not apply Basler TriggerSelector=%s; disabling trigger.", selector) + self._configure_trigger_off(strict=False) + self._trigger = CameraTriggerSettings() + return + + if not source_ok: + LOG.warning( + "Could not apply Basler TriggerSource=%s resolved=%s; disabling trigger.", + source, + resolved_source, + ) + self._configure_trigger_off(strict=False) + self._trigger = CameraTriggerSettings() + return + + if not self._set_enum_feature("TriggerMode", "On", strict=strict): + LOG.warning("Could not enable Basler TriggerMode=On; disabling trigger.") + self._configure_trigger_off(strict=False) + self._trigger = CameraTriggerSettings() + return + + LOG.info( + "Basler trigger input configured: role=%s selector=%s source=%s activation=%s " + "selector_ok=%s source_ok=%s activation_ok=%s", + role, + selector, + resolved_source, + activation, + selector_ok, + source_ok, + activation_ok, + ) + + def _configure_trigger_software(self, cfg, *, strict: bool = False) -> None: + selector = str(self._trigger_attr(cfg, "selector", "FrameStart") or "FrameStart") + delay = self._trigger_attr(cfg, "delay", None) + + self._set_enum_feature("TriggerMode", "Off", strict=False) + + selector_ok = self._set_enum_feature("TriggerSelector", selector, strict=strict) + source_ok = self._set_enum_feature("TriggerSource", "Software", strict=strict) + + if delay is not None: + delay_value = float(delay) + if not self._set_numeric_feature("TriggerDelay", delay_value, strict=False): + self._set_numeric_feature("TriggerDelayAbs", delay_value, strict=False) + + self._set_enum_feature("AcquisitionMode", "Continuous", strict=False) + + if not selector_ok or not source_ok: + LOG.warning( + "Could not configure Basler software trigger selector_ok=%s source_ok=%s; disabling trigger.", + selector_ok, + source_ok, + ) + self._configure_trigger_off(strict=False) + self._trigger = CameraTriggerSettings() + return + + if not self._set_enum_feature("TriggerMode", "On", strict=strict): + LOG.warning("Could not enable Basler software TriggerMode=On; disabling trigger.") + self._configure_trigger_off(strict=False) + self._trigger = CameraTriggerSettings() + return + + LOG.info("Basler software trigger configured: selector=%s source=Software", selector) + + def _configure_trigger_master(self, cfg, *, strict: bool = False) -> None: + output_line = str(self._trigger_attr(cfg, "output_line", "Line2") or "Line2") + output_source = str(self._trigger_attr(cfg, "output_source", "ExposureActive") or "ExposureActive") + + # Master camera should acquire freely. + self._configure_trigger_off(strict=False) + + selected = self._set_enum_feature("LineSelector", output_line, strict=strict) + if not selected: + msg = f"Could not select Basler output line '{output_line}'" + if strict: + raise RuntimeError(msg) + LOG.warning("%s; skipping master output configuration.", msg) + return + + mode_ok = self._set_enum_feature("LineMode", "Output", strict=strict) + source_ok = self._set_enum_feature("LineSource", output_source, strict=strict) + + if mode_ok and source_ok: + LOG.info( + "Basler trigger master configured via Line*: output_line=%s output_source=%s", + output_line, + output_source, + ) + return + + msg = ( + "Could not configure Basler trigger master output completely " + f"(LineMode ok={mode_ok}, LineSource ok={source_ok})." + ) + + if strict: + raise RuntimeError(msg) + + LOG.warning(msg) + + def _restore_trigger_idle(self) -> None: + role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() + + try: + if role in {"external", "follower", "software"}: + self._set_enum_feature("TriggerMode", "Off", strict=False) + + elif role == "master": + self._set_enum_feature("LineSource", "Off", strict=False) + self._set_enum_feature("LineMode", "Input", strict=False) + + except Exception: + LOG.debug("Best-effort Basler trigger restore failed", exc_info=True) + def _configure_resolution(self) -> None: """ Apply width/height only if explicitly requested (GUI or override). From c163a1b0b96d3c6f194f173670724a4eae992342 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 12:40:05 -0500 Subject: [PATCH 055/133] Remove PYLON_CAMEMU test comment Delete commented-out code that forced pypylon to create emulation virtual cameras (PYLON_CAMEMU), which was only intended for testing and should not be enabled for release. Also remove an extraneous blank line to tidy up the file. --- dlclivegui/gui/main_window.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 6fddba6b2..37e4bf45c 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -9,10 +9,6 @@ import time from pathlib import Path -# 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 From 192fb8bd3993811937003abc64b8963c9ad89497 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 12:40:26 -0500 Subject: [PATCH 056/133] Make trigger config dialog backend-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a TriggerUiProfile dataclass and trigger_ui_profile_for_backend() to drive dialog presentation per backend. Replace free-text source field with an editable QComboBox providing backend suggestions and defaults. Add profile-driven visibility/enabling for input, master, software and strobe/line output fields, plus helper methods to manage form rows and combo text. Only include strobe-related payload fields when the backend profile exposes them. Misc: expand info/help text, show backend in group title, increase dialog min-width, refine tooltips, and improve model↔UI mapping and payload construction. --- .../camera_config/trigger_config_dialog.py | 345 ++++++++++++++---- 1 file changed, 267 insertions(+), 78 deletions(-) diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py index c6f78140b..bda9caebe 100644 --- a/dlclivegui/gui/camera_config/trigger_config_dialog.py +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -1,6 +1,8 @@ # dlclivegui/gui/camera_config/trigger_config_dialog.py from __future__ import annotations +from dataclasses import dataclass + from PySide6.QtWidgets import ( QCheckBox, QComboBox, @@ -31,15 +33,92 @@ def _backend_namespace(cam: CameraSettings) -> dict: return ns +@dataclass(frozen=True) +class TriggerUiProfile: + supports_input: bool = True + supports_master: bool = False + supports_software: bool = False + + show_strobe_fields: bool = False + show_line_output_fields: bool = False + + source_suggestions: tuple[str, ...] = ("auto",) + default_source: str = "auto" + + default_output_line: str = "Line2" + default_output_source: str = "ExposureActive" + + help_text: str = "" + + +def trigger_ui_profile_for_backend(backend: str) -> TriggerUiProfile: + """Return GUI-only trigger presentation profile for a backend. + + This intentionally does not perform backend/runtime validation. + Backends still own actual GenICam/pypylon/Harvesters configuration. + """ + backend = (backend or "").lower() + + if backend == "gentl": + return TriggerUiProfile( + supports_input=True, + supports_master=True, + supports_software=False, + show_strobe_fields=True, + show_line_output_fields=True, + source_suggestions=("auto", "Line0", "Line1", "Line2", "Any", "Software"), + default_source="auto", + default_output_line="Line2", + default_output_source="ExposureActive", + help_text=( + "GenTL trigger support is best-effort and depends on the camera's GenICam nodes. " + "Some cameras expose generic Line* output nodes; TIS/DMK 37U cameras may expose Strobe* nodes." + ), + ) + + if backend == "basler": + return TriggerUiProfile( + supports_input=True, + supports_master=True, + supports_software=False, # enable later when controller supports trigger_once() + show_strobe_fields=False, + show_line_output_fields=True, + source_suggestions=("auto", "Line1", "Line2", "Line3", "Line4", "Software"), + default_source="auto", + default_output_line="Line2", + default_output_source="ExposureActive", + help_text=( + "Basler trigger support uses pylon camera features when available. " + "The available trigger sources and output lines depend on the camera model." + ), + ) + + return TriggerUiProfile( + supports_input=False, + supports_master=False, + supports_software=False, + show_strobe_fields=False, + show_line_output_fields=False, + source_suggestions=("auto",), + help_text="This backend does not expose trigger configuration.", + ) + + class TriggerConfigDialog(QDialog): - """Small dialog for editing per-camera hardware trigger settings.""" + """Dialog for editing per-camera trigger settings. + + The dialog is backend-aware only for presentation. + Actual trigger configuration remains backend-owned. + """ def __init__(self, cam: CameraSettings, parent: QWidget | None = None): super().__init__(parent) self.setWindowTitle("Configure trigger mode") - self.setMinimumWidth(420) + self.setMinimumWidth(460) self._cam = cam.model_copy(deep=True) + self._backend = (self._cam.backend or "").lower() + self._profile = trigger_ui_profile_for_backend(self._backend) ns = _backend_namespace(self._cam) try: @@ -58,71 +137,106 @@ def camera_settings(self) -> CameraSettings: def _setup_ui(self) -> None: root = QVBoxLayout(self) - info = QLabel( - "Configure hardware trigger settings for this camera.\n" - "Follower/external mode arms the camera and waits for electrical pulses on TRIGGER_IN.\n" - "Master mode enables STROBE_OUT pulses. For TIS/DMK 37U cameras this uses Strobe settings; " - "Line output settings are kept as a generic fallback.\n" + info_text = ( + "Configure per-camera trigger settings.\n" + "External/follower mode arms the camera and waits for trigger pulses on a selected input source.\n" + "Master mode configures an output signal if the backend/camera exposes compatible output-line features.\n" + "Some fields are backend- or camera-model-specific and may be ignored unless strict mode is enabled.\n" "In strict mode, unsupported trigger nodes fail camera open." ) - info.setWordWrap(True) - root.addWidget(info) + if self._profile.help_text: + info_text += f"\n\n{self._profile.help_text}" - group = QGroupBox("Hardware Trigger") - form = QFormLayout(group) + self.info_label = QLabel(info_text) + self.info_label.setWordWrap(True) + root.addWidget(self.info_label) + group_title = f"Trigger Settings ({self._backend or 'unknown'})" + group = QGroupBox(group_title) + self.form = QFormLayout(group) + + # ---------------------------- + # Role + # ---------------------------- self.role_combo = QComboBox() self.role_combo.addItem("Off / Free-run", "off") - self.role_combo.addItem("External trigger", "external") - self.role_combo.addItem("Follower", "follower") - self.role_combo.addItem("Master", "master") - form.addRow("Role:", self.role_combo) + if self._profile.supports_input: + self.role_combo.addItem("External trigger", "external") + self.role_combo.addItem("Follower", "follower") + + if self._profile.supports_master: + self.role_combo.addItem("Master output", "master") + + if self._profile.supports_software: + self.role_combo.addItem("Software trigger", "software") + + self.form.addRow("Role:", self.role_combo) + + # ---------------------------- + # Input trigger fields + # ---------------------------- self.selector_edit = QLineEdit() self.selector_edit.setPlaceholderText("FrameStart") - form.addRow("Trigger selector:", self.selector_edit) - - self.source_edit = QLineEdit() - self.source_edit.setPlaceholderText("auto, Line0, Software, ...") - form.addRow("Trigger source:", self.source_edit) + self.selector_edit.setToolTip("TriggerSelector value. Most area-scan cameras use FrameStart.") + self.form.addRow("Trigger selector:", self.selector_edit) + + self.source_combo = QComboBox() + self.source_combo.setEditable(True) + for value in self._profile.source_suggestions: + self.source_combo.addItem(value, value) + self.source_combo.setToolTip( + "TriggerSource value. Suggestions are backend defaults only; " + "the backend validates the actual camera-supported values when opening." + ) + if self.source_combo.lineEdit() is not None: + self.source_combo.lineEdit().setPlaceholderText("auto, Line1, Software, ...") + self.form.addRow("Trigger source:", self.source_combo) self.activation_combo = QComboBox() for value in ("RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"): self.activation_combo.addItem(value, value) - form.addRow("Activation:", self.activation_combo) + self.activation_combo.setToolTip( + "TriggerActivation value. Some software/internal trigger sources may ignore this." + ) + self.form.addRow("Activation:", self.activation_combo) + # ---------------------------- + # Generic output line fields + # ---------------------------- self.output_line_edit = QLineEdit() - self.output_line_edit.setPlaceholderText("Line2") + self.output_line_edit.setPlaceholderText(self._profile.default_output_line) self.output_line_edit.setToolTip( - "Generic Line* output selector for cameras exposing LineSelector/LineSource. " - "Ignored by TIS/DMK 37U strobe-based output." + "Generic LineSelector value for cameras exposing LineSelector/LineSource. " + "Ignored if the backend/camera does not support generic line output." ) - form.addRow("Output line:", self.output_line_edit) + self.form.addRow("Output line:", self.output_line_edit) self.output_source_edit = QLineEdit() - self.output_source_edit.setPlaceholderText("ExposureActive") + self.output_source_edit.setPlaceholderText(self._profile.default_output_source) self.output_source_edit.setToolTip( - "Generic LineSource value for cameras exposing LineSource. " - "For TIS/DMK 37U cameras, use Strobe operation instead." + "Generic LineSource value for cameras exposing LineSource, e.g. ExposureActive." ) - form.addRow("Output source:", self.output_source_edit) + self.form.addRow("Output source:", self.output_source_edit) + # ---------------------------- + # Strobe fields, mainly useful for specific GenTL/TIS devices + # ---------------------------- self.strobe_polarity_combo = QComboBox() self.strobe_polarity_combo.addItem("Active high", "ActiveHigh") self.strobe_polarity_combo.addItem("Active low", "ActiveLow") self.strobe_polarity_combo.setToolTip( - "Polarity of STROBE_OUT. If the follower does not trigger, also try changing the follower activation edge." + "Strobe output polarity. Only used by backends/cameras exposing compatible Strobe* nodes." ) - form.addRow("Strobe polarity:", self.strobe_polarity_combo) + self.form.addRow("Strobe polarity:", self.strobe_polarity_combo) self.strobe_operation_combo = QComboBox() self.strobe_operation_combo.addItem("Exposure duration", "Exposure") self.strobe_operation_combo.addItem("Fixed duration", "FixedDuration") self.strobe_operation_combo.setToolTip( - "Exposure: strobe pulse length follows exposure time. " - "FixedDuration: strobe pulse length is set by Strobe duration." + "Strobe operation. Only used by backends/cameras exposing compatible Strobe* nodes." ) - form.addRow("Strobe operation:", self.strobe_operation_combo) + self.form.addRow("Strobe operation:", self.strobe_operation_combo) self.strobe_duration_spin = QSpinBox() self.strobe_duration_spin.setRange(0, 32767) @@ -130,33 +244,34 @@ def _setup_ui(self) -> None: self.strobe_duration_spin.setSuffix(" µs") self.strobe_duration_spin.setSpecialValueText("Default") self.strobe_duration_spin.setToolTip( - "Used only when Strobe operation is FixedDuration. 0 means backend/device default." + "Used only when strobe operation is FixedDuration. 0 means backend/device default." ) - form.addRow("Strobe duration:", self.strobe_duration_spin) + self.form.addRow("Strobe duration:", self.strobe_duration_spin) self.strobe_delay_spin = QSpinBox() self.strobe_delay_spin.setRange(0, 32767) self.strobe_delay_spin.setSingleStep(100) self.strobe_delay_spin.setSuffix(" µs") self.strobe_delay_spin.setSpecialValueText("Default") - self.strobe_delay_spin.setToolTip( - "Delay between start of exposure and STROBE_OUT pulse. 0 means no delay/device default." - ) - form.addRow("Strobe delay:", self.strobe_delay_spin) + self.strobe_delay_spin.setToolTip("Delay before strobe output. 0 means no explicit delay/device default.") + self.form.addRow("Strobe delay:", self.strobe_delay_spin) + # ---------------------------- + # Common options + # ---------------------------- self.timeout_spin = QDoubleSpinBox() self.timeout_spin.setRange(0.0, 3600.0) self.timeout_spin.setDecimals(3) self.timeout_spin.setSingleStep(0.1) self.timeout_spin.setSpecialValueText("Default") self.timeout_spin.setToolTip( - "Fetch poll timeout in seconds. The backend may cap individual fetches to keep preview shutdown responsive." + "Read/fetch timeout in seconds. The backend may cap individual waits to keep preview shutdown responsive." ) - form.addRow("Read timeout:", self.timeout_spin) + self.form.addRow("Read timeout:", self.timeout_spin) self.strict_checkbox = QCheckBox("Strict mode") - self.strict_checkbox.setToolTip("If enabled, missing/unsupported GenICam trigger nodes fail camera open.") - form.addRow(self.strict_checkbox) + self.strict_checkbox.setToolTip("If enabled, missing/unsupported trigger features fail camera open.") + self.form.addRow(self.strict_checkbox) root.addWidget(group) @@ -168,20 +283,86 @@ def _setup_ui(self) -> None: self.role_combo.currentIndexChanged.connect(self._sync_role_ui) self.strobe_operation_combo.currentIndexChanged.connect(self._sync_role_ui) + # Hide backend-irrelevant rows immediately. + self._apply_profile_visibility() + + # ------------------------------------------------------------------ + # UI helpers + # ------------------------------------------------------------------ + + def _set_form_row_visible(self, widget: QWidget, visible: bool) -> None: + """Hide/show a QFormLayout field and its label.""" + widget.setVisible(visible) + try: + label = self.form.labelForField(widget) + if label is not None: + label.setVisible(visible) + except Exception: + pass + + def _set_combo_text(self, combo: QComboBox, text: str) -> None: + text = str(text or "") + idx = combo.findText(text) + if idx >= 0: + combo.setCurrentIndex(idx) + else: + combo.setCurrentText(text) + + def _combo_text(self, combo: QComboBox, fallback: str) -> str: + text = str(combo.currentText() or "").strip() + return text or fallback + + def _apply_profile_visibility(self) -> None: + """Apply static backend-profile visibility. + + Role-specific enablement is handled separately by _sync_role_ui(). + """ + # Input trigger fields are only meaningful for input/software roles. + self._set_form_row_visible(self.selector_edit, self._profile.supports_input or self._profile.supports_software) + self._set_form_row_visible(self.source_combo, self._profile.supports_input or self._profile.supports_software) + self._set_form_row_visible( + self.activation_combo, + self._profile.supports_input, + ) + + # Output fields depend on backend presentation profile. + self._set_form_row_visible(self.output_line_edit, self._profile.show_line_output_fields) + self._set_form_row_visible(self.output_source_edit, self._profile.show_line_output_fields) + + # Strobe fields should not appear for Basler. + self._set_form_row_visible(self.strobe_polarity_combo, self._profile.show_strobe_fields) + self._set_form_row_visible(self.strobe_operation_combo, self._profile.show_strobe_fields) + self._set_form_row_visible(self.strobe_duration_spin, self._profile.show_strobe_fields) + self._set_form_row_visible(self.strobe_delay_spin, self._profile.show_strobe_fields) + + # ------------------------------------------------------------------ + # Model <-> UI + # ------------------------------------------------------------------ + def _load_from_trigger(self, trigger: CameraTriggerSettings) -> None: role = str(getattr(trigger, "role", "off") or "off").lower() idx = self.role_combo.findData(role) self.role_combo.setCurrentIndex(idx if idx >= 0 else 0) self.selector_edit.setText(str(getattr(trigger, "selector", "FrameStart") or "FrameStart")) - self.source_edit.setText(str(getattr(trigger, "source", "auto") or "auto")) + + source = str(getattr(trigger, "source", self._profile.default_source) or self._profile.default_source) + self._set_combo_text(self.source_combo, source) activation = str(getattr(trigger, "activation", "RisingEdge") or "RisingEdge") idx = self.activation_combo.findData(activation) self.activation_combo.setCurrentIndex(idx if idx >= 0 else 0) - self.output_line_edit.setText(str(getattr(trigger, "output_line", "Line2") or "Line2")) - self.output_source_edit.setText(str(getattr(trigger, "output_source", "ExposureActive") or "ExposureActive")) + output_line = str( + getattr(trigger, "output_line", self._profile.default_output_line) or self._profile.default_output_line + ) + self.output_line_edit.setText(output_line) + + output_source = str( + getattr(trigger, "output_source", self._profile.default_output_source) + or self._profile.default_output_source + ) + self.output_source_edit.setText(output_source) strobe_polarity = str(getattr(trigger, "strobe_polarity", "ActiveHigh") or "ActiveHigh") idx = self.strobe_polarity_combo.findData(strobe_polarity) @@ -205,29 +386,34 @@ def _load_from_trigger(self, trigger: CameraTriggerSettings) -> None: def _sync_role_ui(self) -> None: role = str(self.role_combo.currentData() or "off") - input_enabled = role in {"external", "follower"} + input_enabled = role in {"external", "follower", "software"} + hw_input_enabled = role in {"external", "follower"} + output_enabled = role == "master" + # Input fields. self.selector_edit.setEnabled(input_enabled) - self.source_edit.setEnabled(input_enabled) - self.activation_combo.setEnabled(input_enabled) + self.source_combo.setEnabled(input_enabled) + self.activation_combo.setEnabled(hw_input_enabled) - output_enabled = role == "master" - # Generic Line* fallback fields. - self.output_line_edit.setEnabled(output_enabled) - self.output_source_edit.setEnabled(output_enabled) + # Generic Line* output fields. + line_output_active = output_enabled and self._profile.show_line_output_fields + self.output_line_edit.setEnabled(line_output_active) + self.output_source_edit.setEnabled(line_output_active) - # TIS/DMK 37U Strobe* fields. - self.strobe_polarity_combo.setEnabled(output_enabled) - self.strobe_operation_combo.setEnabled(output_enabled) + # Strobe fields. + strobe_active = output_enabled and self._profile.show_strobe_fields + self.strobe_polarity_combo.setEnabled(strobe_active) + self.strobe_operation_combo.setEnabled(strobe_active) fixed_duration = ( - output_enabled and str(self.strobe_operation_combo.currentData() or "Exposure") == "FixedDuration" + strobe_active and str(self.strobe_operation_combo.currentData() or "Exposure") == "FixedDuration" ) self.strobe_duration_spin.setEnabled(fixed_duration) - self.strobe_delay_spin.setEnabled(output_enabled) + self.strobe_delay_spin.setEnabled(strobe_active) - # Timeout is mostly useful for external/follower, but harmless for any role. - self.timeout_spin.setEnabled(role in {"external", "follower"}) + # Timeout is useful for trigger-waiting modes. Keep it available for + # software too if software support is later enabled. + self.timeout_spin.setEnabled(role in {"external", "follower", "software"}) def _accept(self) -> None: role = str(self.role_combo.currentData() or "off") @@ -235,34 +421,37 @@ def _accept(self) -> None: payload = { "role": role, "selector": self.selector_edit.text().strip() or "FrameStart", - "source": self.source_edit.text().strip() or "auto", + "source": self._combo_text(self.source_combo, self._profile.default_source), "activation": str(self.activation_combo.currentData() or "RisingEdge"), - # Generic/SFNC Line* fallback output settings. - "output_line": self.output_line_edit.text().strip() or "Line2", - "output_source": self.output_source_edit.text().strip() or "ExposureActive", - # Strobe output settings used by TIS/DMK 37U cameras. - "strobe_polarity": str(self.strobe_polarity_combo.currentData() or "ActiveHigh"), - "strobe_operation": str(self.strobe_operation_combo.currentData() or "Exposure"), + "output_line": self.output_line_edit.text().strip() or self._profile.default_output_line, + "output_source": self.output_source_edit.text().strip() or self._profile.default_output_source, "strict": bool(self.strict_checkbox.isChecked()), } timeout = float(self.timeout_spin.value()) - if role in {"external", "follower"} and timeout > 0: + if role in {"external", "follower", "software"} and timeout > 0: payload["timeout"] = timeout elif role == "off": - payload["timeout"] = None # ensure timeout is cleared when disabling trigger - strobe_duration = int(self.strobe_duration_spin.value()) - if role == "master" and strobe_duration > 0: - payload["strobe_duration"] = strobe_duration + payload["timeout"] = None + + # Only include strobe-specific settings for profiles that expose them. + # This avoids cluttering Basler trigger configs with TIS-specific fields. + if self._profile.show_strobe_fields: + payload["strobe_polarity"] = str(self.strobe_polarity_combo.currentData() or "ActiveHigh") + payload["strobe_operation"] = str(self.strobe_operation_combo.currentData() or "Exposure") + + strobe_duration = int(self.strobe_duration_spin.value()) + if role == "master" and strobe_duration > 0: + payload["strobe_duration"] = strobe_duration - strobe_delay = int(self.strobe_delay_spin.value()) - if role == "master" and strobe_delay > 0: - payload["strobe_delay"] = strobe_delay + strobe_delay = int(self.strobe_delay_spin.value()) + if role == "master" and strobe_delay > 0: + payload["strobe_delay"] = strobe_delay try: trigger = CameraTriggerSettings.from_any(payload) - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to apply trigger settings: {e}") + except Exception as exc: + QMessageBox.critical(self, "Error", f"Failed to apply trigger settings: {exc}") return ns = _backend_namespace(self._cam) From 17211a8ba4b43c28b98210e9b39161c893278d2b Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 12:40:39 -0500 Subject: [PATCH 057/133] Support basler backend in trigger defaults Previously the default trigger configuration was only applied for backend 'gentl' and always stored under the 'gentl' properties key. This change treats the backend name dynamically (accepting both 'gentl' and 'basler'), and stores the default trigger settings under the actual backend key in cam.properties. Also preserves behavior when cam.properties or the backend namespace is not a dict. --- dlclivegui/gui/camera_config/camera_config_dialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index 444f27387..d8defb45e 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -820,16 +820,16 @@ def _on_active_camera_selected(self, row: int) -> None: def _ensure_default_trigger_config(self, cam: CameraSettings) -> None: backend = (cam.backend or "").lower() - if backend != "gentl": + if backend not in {"gentl", "basler"}: return if not isinstance(cam.properties, dict): cam.properties = {} - ns = cam.properties.setdefault("gentl", {}) + ns = cam.properties.setdefault(backend, {}) if not isinstance(ns, dict): ns = {} - cam.properties["gentl"] = ns + cam.properties[backend] = ns ns.setdefault("trigger", CameraTriggerSettings().model_dump(exclude_none=True)) From d329c6643b413a47e8edf2b44c0dcc7614db5522 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 12:40:53 -0500 Subject: [PATCH 058/133] Throttle hardware-trigger wait logs Reduce log flooding when waiting for hardware triggers by adding throttled logging. Changes in dlclivegui/services/multi_camera_controller.py: - Import time. - Add a new _log_interval_while_waiting_for_trigger_s attribute to SingleCameraWorker. - Replace the direct LOGGER.debug call for expected trigger wait timeouts with a call to _log_trigger_wait_throttled. - Implement _log_trigger_wait_throttled to suppress repeated timeout messages, emit a consolidated debug message, and report how many repeated logs were suppressed. This prevents high-frequency expected poll-timeout logs (common in trigger-waiting modes) from overwhelming the logs. --- .../services/multi_camera_controller.py | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index c88fbda51..ba474a082 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -4,6 +4,7 @@ import copy import logging +import time from dataclasses import dataclass from functools import partial from threading import Event, Lock @@ -55,6 +56,7 @@ def __init__(self, camera_id: str, settings: CameraSettings): self._max_consecutive_errors = 5 self._retry_delay = 0.1 self._trigger_timeout_delay = 0.05 + self._log_interval_while_waiting_for_trigger_s = 2.0 # Performance logs self._timing = WorkerTimingStats( @@ -126,11 +128,7 @@ def run(self) -> None: # "no trigger pulse arrived during this poll interval". # This is expected and should not count as a camera failure. if bool(getattr(self._backend, "waits_for_hardware_trigger", False)): - LOGGER.debug( - "[Worker %s] waiting for hardware trigger: %s", - self._camera_id, - exc, - ) + self._log_trigger_wait_throttled(exc) consecutive_errors = 0 if self._stop_event.wait(self._trigger_timeout_delay): @@ -285,6 +283,36 @@ def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats: self._timing_per_cam[camera_id] = timing return timing + def _log_trigger_wait_throttled(self, exc: BaseException) -> None: + """Log hardware-trigger wait timeouts at a controlled rate. + + In trigger-waiting modes, read timeouts are expected polling misses. + Without throttling, the log can be flooded at ~10-20 messages/sec/camera. + """ + now = time.monotonic() + + if now - self._last_trigger_wait_log < self._trigger_wait_log_interval: + self._trigger_wait_suppressed_count += 1 + return + + suppressed = self._trigger_wait_suppressed_count + self._trigger_wait_suppressed_count = 0 + self._last_trigger_wait_log = now + + if suppressed: + LOGGER.debug( + "[Worker %s] waiting for hardware trigger: %s (suppressed %d repeated timeout logs)", + self._camera_id, + exc, + suppressed, + ) + else: + LOGGER.debug( + "[Worker %s] waiting for hardware trigger: %s", + self._camera_id, + exc, + ) + def start(self, camera_settings: list[CameraSettings]) -> None: """Start multiple cameras.""" if self._running: From ab3d764fff915b7cd8cf0a51128b0eb11f7451f5 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 14:52:00 -0500 Subject: [PATCH 059/133] Enhance FakePylon and add Basler backend tests Extend the test conftest FakePylon to better emulate pypylon: add FakePylonTimeoutException, richer _Feature (symbolics, min/max/inc, read/write checks, call tracking), _EnumEntry, expanded _DeviceInfo, GrabResult.release tracking, and a more complete InstantCamera (timeouts, software trigger, trigger/line features, buffer and grab controls, test knobs). Reset the fake factory and provide default fake devices and a basler_settings_factory fixture. Patch the basler SDK fixture to use FakePylon. Add new test suite tests/cameras/backends/test_basler_backend.py covering lifecycle (open/read/close, fast-start, idempotent close), discovery/rebind, resolution/exposure/gain/fps handling, and comprehensive trigger behavior (follower/master/software/external) to validate backend logic. --- tests/cameras/backends/conftest.py | 228 ++++++++- tests/cameras/backends/test_basler_backend.py | 463 ++++++++++++++++++ 2 files changed, 672 insertions(+), 19 deletions(-) create mode 100644 tests/cameras/backends/test_basler_backend.py diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index f0a0b6b23..dfec64fe2 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -381,32 +381,115 @@ def _make(buffers): # ----------------------------------------------------------------------------- +class FakePylonTimeoutException(RuntimeError): + pass + + class FakePylon: - """Minimal fake for 'from pypylon import pylon' usage in basler_backend.""" + """Fake for 'from pypylon import pylon' used by BaslerCameraBackend.""" - # Constants used by Basler backend GrabStrategy_LatestImageOnly = 1 TimeoutHandling_ThrowException = 1 - PixelType_BGR8packed = 0x02180014 # arbitrary token + PixelType_BGR8packed = 0x02180014 OutputBitAlignment_MsbAligned = 1 + class _EnumEntry: + def __init__(self, symbolic: str): + self._symbolic = symbolic + + def GetSymbolic(self): + return self._symbolic + class _Feature: - def __init__(self, value=0): + def __init__( + self, + value=0, + *, + symbolics: list[str] | None = None, + minimum=None, + maximum=None, + increment=1, + writable=True, + readable=True, + ): self._value = value + self._symbolics = list(symbolics or []) + self._min = minimum + self._max = maximum + self._inc = increment + self._writable = writable + self._readable = readable + self.set_calls: list[object] = [] def SetValue(self, v): + if not self._writable: + raise RuntimeError("feature is not writable") + if self._symbolics and v not in self._symbolics: + raise RuntimeError(f"unsupported symbolic {v!r}; available={self._symbolics}") self._value = v + self.set_calls.append(v) def GetValue(self): + if not self._readable: + raise RuntimeError("feature is not readable") return self._value + def GetSymbolics(self): + return list(self._symbolics) + + def GetEntries(self): + return [FakePylon._EnumEntry(s) for s in self._symbolics] + + def IsWritable(self): + return bool(self._writable) + + def IsReadable(self): + return bool(self._readable) + + def GetMin(self): + if self._min is None: + raise RuntimeError("no min") + return self._min + + def GetMax(self): + if self._max is None: + raise RuntimeError("no max") + return self._max + + def GetInc(self): + return self._inc + class _DeviceInfo: - def __init__(self, serial: str): + def __init__( + self, + serial: str, + *, + vendor: str = "Basler", + model: str = "FakeBasler", + friendly: str | None = None, + full_name: str | None = None, + ): self._serial = serial + self._vendor = vendor + self._model = model + self._friendly = friendly or f"{vendor} {model} ({serial})" + self._full_name = full_name or f"FakeFullName-{serial}" def GetSerialNumber(self): return self._serial + def GetVendorName(self): + return self._vendor + + def GetModelName(self): + return self._model + + def GetFriendlyName(self): + return self._friendly + + def GetFullName(self): + return self._full_name + class _Device: def __init__(self, info): self.info = info @@ -433,12 +516,13 @@ class _GrabResult: def __init__(self, ok=True, array=None): self._ok = ok self._array = array + self.released = False def GrabSucceeded(self): return bool(self._ok) def Release(self): - return None + self.released = True class InstantCamera: def __init__(self, device): @@ -446,36 +530,106 @@ def __init__(self, device): self._open = False self._grabbing = False - # Feature nodes the backend uses + self.retrieve_calls: list[int] = [] + self.start_calls = 0 + self.stop_calls = 0 + self.close_calls = 0 + self.software_trigger_calls = 0 + self._software_trigger_pending = 0 + + # General camera controls. + self.ExposureAuto = FakePylon._Feature("Off", symbolics=["Off", "Once", "Continuous"]) self.ExposureTime = FakePylon._Feature(1000.0) + self.GainAuto = FakePylon._Feature("Off", symbolics=["Off", "Once", "Continuous"]) self.Gain = FakePylon._Feature(0.0) - self.Width = FakePylon._Feature(1920) - self.Height = FakePylon._Feature(1080) + + self.Width = FakePylon._Feature(1920, minimum=64, maximum=4096, increment=2) + self.Height = FakePylon._Feature(1080, minimum=64, maximum=4096, increment=2) self.AcquisitionFrameRateEnable = FakePylon._Feature(False) self.AcquisitionFrameRate = FakePylon._Feature(30.0) + self.MaxNumBuffer = FakePylon._Feature(10) + + # Basler/pypylon trigger features. + self.AcquisitionMode = FakePylon._Feature("Continuous", symbolics=["Continuous", "SingleFrame"]) + self.TriggerSelector = FakePylon._Feature("FrameStart", symbolics=["FrameStart"]) + self.TriggerMode = FakePylon._Feature("Off", symbolics=["Off", "On"]) + self.TriggerSource = FakePylon._Feature( + "Software", + symbolics=[ + "Software", + "Line1", + "Line2", + "Line3", + "PeriodicSignal1", + "Action1", + ], + ) + self.TriggerActivation = FakePylon._Feature( + "RisingEdge", + symbolics=["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"], + ) + self.TriggerDelay = FakePylon._Feature(0.0) + + # Generic output line features. + self.LineSelector = FakePylon._Feature("Line1", symbolics=["Line1", "Line2", "Line3"]) + self.LineMode = FakePylon._Feature("Input", symbolics=["Input", "Output"]) + self.LineSource = FakePylon._Feature( + "Off", + symbolics=["Off", "ExposureActive", "AcquisitionActive"], + ) + self.LineInverter = FakePylon._Feature(False) + + # Test knobs. + self.allow_hardware_trigger_frame = False + self.force_failed_grab = False + def Open(self): self._open = True def Close(self): + self.close_calls += 1 self._open = False def IsOpen(self): return bool(self._open) def StartGrabbing(self, *_args, **_kwargs): + self.start_calls += 1 self._grabbing = True def StopGrabbing(self): + self.stop_calls += 1 self._grabbing = False def IsGrabbing(self): return bool(self._grabbing) - def RetrieveResult(self, *_args, **_kwargs): - # Always succeed with a small dummy image (BGR) - import numpy as np + def ExecuteSoftwareTrigger(self): + self.software_trigger_calls += 1 + self._software_trigger_pending += 1 + + def RetrieveResult(self, timeout_ms, *_args, **_kwargs): + self.retrieve_calls.append(int(timeout_ms)) + + if not self._grabbing: + raise FakePylonTimeoutException("Grab timed out: acquisition not started") + + if self.force_failed_grab: + return FakePylon._GrabResult(ok=False, array=None) + + trigger_on = self.TriggerMode.GetValue() == "On" + source = self.TriggerSource.GetValue() + + if trigger_on: + if source == "Software": + if self._software_trigger_pending <= 0: + raise FakePylonTimeoutException("Grab timed out: waiting for software trigger") + self._software_trigger_pending -= 1 + else: + if not self.allow_hardware_trigger_frame: + raise FakePylonTimeoutException("Grab timed out: waiting for hardware trigger") frame = np.zeros((10, 10, 3), dtype=np.uint8) return FakePylon._GrabResult(ok=True, array=frame) @@ -498,25 +652,61 @@ def Convert(self, grab_result): @pytest.fixture() def fake_pylon_module(): - """ - Returns the FakePylon 'module' and resets singleton devices for isolation. - """ - # reset singleton factory so devices list resets per test + """Returns fake pylon module and resets fake device inventory.""" FakePylon.TlFactory._instance = None + factory = FakePylon.TlFactory.GetInstance() + factory._devices = [ + FakePylon._DeviceInfo("FAKE-BASLER-0"), + FakePylon._DeviceInfo("FAKE-BASLER-1"), + ] return FakePylon @pytest.fixture() def patch_basler_sdk(monkeypatch, fake_pylon_module): - """ - Patch Basler backend to behave as if pypylon is installed, using FakePylon. - """ + """Patch Basler backend to use FakePylon.""" import dlclivegui.cameras.backends.basler_backend as bb monkeypatch.setattr(bb, "pylon", fake_pylon_module, raising=False) return fake_pylon_module +@pytest.fixture() +def basler_settings_factory(): + from dlclivegui.config import CameraSettings + + def _make( + *, + index=0, + name="BaslerTestCam", + 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("basler", {}) + props["basler"] = dict(props["basler"]) + + return CameraSettings( + name=name, + index=index, + backend="basler", + width=width, + height=height, + fps=fps, + exposure=exposure, + gain=gain, + enabled=enabled, + properties=props, + ) + + return _make + + # ----------------------------------------------------------------------------- # Fake GenTL / harvesters SDK (SDK-free) + fixtures for strict lifecycle tests # ----------------------------------------------------------------------------- diff --git a/tests/cameras/backends/test_basler_backend.py b/tests/cameras/backends/test_basler_backend.py new file mode 100644 index 000000000..2edff9092 --- /dev/null +++ b/tests/cameras/backends/test_basler_backend.py @@ -0,0 +1,463 @@ +from __future__ import annotations + +import numpy as np +import pytest + +# --------------------------------------------------------------------- +# Core lifecycle +# --------------------------------------------------------------------- + + +def test_basler_open_starts_grabbing_and_read_returns_frame(patch_basler_sdk, basler_settings_factory): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory() + be = bb.BaslerCameraBackend(settings) + + be.open() + + assert be._camera is not None + assert be._camera.IsOpen() + assert be._camera.IsGrabbing() + assert be._converter is not None + + frame, ts = be.read() + assert isinstance(ts, float) + assert isinstance(frame, np.ndarray) + assert frame.shape == (10, 10, 3) + + be.close() + assert be._camera is None + assert be._converter is None + + +def test_basler_fast_start_does_not_start_grabbing_and_read_raises( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory(properties={"basler": {"fast_start": True}}) + be = bb.BaslerCameraBackend(settings) + + be.open() + + assert be._camera is not None + assert be._camera.IsOpen() + assert not be._camera.IsGrabbing() + assert be._converter is None + + with pytest.raises(RuntimeError, match="fast-start"): + be.read() + + be.close() + + +def test_basler_close_is_idempotent(patch_basler_sdk, basler_settings_factory): + import dlclivegui.cameras.backends.basler_backend as bb + + be = bb.BaslerCameraBackend(basler_settings_factory()) + be.open() + be.close() + be.close() + + +def test_basler_stop_before_open_and_after_close_is_safe(patch_basler_sdk, basler_settings_factory): + import dlclivegui.cameras.backends.basler_backend as bb + + be = bb.BaslerCameraBackend(basler_settings_factory()) + + be.stop() + + be.open() + be.stop() + + assert be._camera is not None + assert not be._camera.IsGrabbing() + + be.close() + be.stop() + + +def test_basler_read_before_open_raises_runtimeerror(patch_basler_sdk, basler_settings_factory): + import dlclivegui.cameras.backends.basler_backend as bb + + be = bb.BaslerCameraBackend(basler_settings_factory()) + + with pytest.raises(RuntimeError, match="not opened"): + be.read() + + +# --------------------------------------------------------------------- +# Discovery / identity / rebind +# --------------------------------------------------------------------- + + +def test_basler_discover_devices_returns_serial_identity_and_label( + patch_basler_sdk, +): + import dlclivegui.cameras.backends.basler_backend as bb + + cams = bb.BaslerCameraBackend.discover_devices(max_devices=10) + + assert len(cams) == 2 + assert cams[0].device_id == "FAKE-BASLER-0" + assert "Basler" in cams[0].label + assert "FAKE-BASLER-0" in cams[0].label + assert cams[0].path + + +def test_basler_quick_ping_true_for_existing_false_for_missing(patch_basler_sdk): + import dlclivegui.cameras.backends.basler_backend as bb + + assert bb.BaslerCameraBackend.quick_ping(0) is True + assert bb.BaslerCameraBackend.quick_ping(1) is True + assert bb.BaslerCameraBackend.quick_ping(2) is False + + +def test_basler_rebind_settings_uses_serial_device_id( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + index=0, + properties={"basler": {"device_id": "FAKE-BASLER-1"}}, + ) + + out = bb.BaslerCameraBackend.rebind_settings(settings) + + assert int(out.index) == 1 + ns = out.properties["basler"] + assert ns["device_id"] == "FAKE-BASLER-1" + assert ns["device_name"] + + +def test_basler_open_selects_device_id_and_persists_identity( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + index=0, + properties={"basler": {"device_id": "FAKE-BASLER-1"}}, + ) + + be = bb.BaslerCameraBackend(settings) + be.open() + + ns = settings.properties["basler"] + assert ns["device_id"] == "FAKE-BASLER-1" + assert ns["device_name"] + + be.close() + + +def test_basler_open_index_out_of_range_raises(patch_basler_sdk, basler_settings_factory): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory(index=99) + be = bb.BaslerCameraBackend(settings) + + with pytest.raises(RuntimeError, match="out of range"): + be.open() + + +# --------------------------------------------------------------------- +# Camera controls +# --------------------------------------------------------------------- + + +def test_basler_resolution_auto_does_not_modify_dimensions( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory(width=0, height=0) + be = bb.BaslerCameraBackend(settings) + + be.open() + + assert be._camera.Width.GetValue() == 1920 + assert be._camera.Height.GetValue() == 1080 + assert be.actual_resolution == (1920, 1080) + + be.close() + + +def test_basler_resolution_request_snaps_to_increment( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory(width=641, height=481) + be = bb.BaslerCameraBackend(settings) + + be.open() + + assert be._camera.Width.GetValue() == 640 + assert be._camera.Height.GetValue() == 480 + assert be.actual_resolution == (640, 480) + + be.close() + + +def test_basler_exposure_gain_fps_are_applied_when_nonzero( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory(exposure=20000, gain=2.5, fps=50.0) + be = bb.BaslerCameraBackend(settings) + + be.open() + + assert be._camera.ExposureAuto.GetValue() == "Off" + assert be._camera.ExposureTime.GetValue() == pytest.approx(20000.0) + assert be._camera.GainAuto.GetValue() == "Off" + assert be._camera.Gain.GetValue() == pytest.approx(2.5) + assert be._camera.AcquisitionFrameRateEnable.GetValue() is True + assert be._camera.AcquisitionFrameRate.GetValue() == pytest.approx(50.0) + + be.close() + + +# --------------------------------------------------------------------- +# Basler trigger behavior +# --------------------------------------------------------------------- + + +def test_basler_static_capabilities_advertises_hardware_trigger_best_effort( + patch_basler_sdk, +): + import dlclivegui.cameras.backends.basler_backend as bb + from dlclivegui.cameras.base import SupportLevel + + caps = bb.BaslerCameraBackend.static_capabilities() + assert caps["hardware_trigger"] == SupportLevel.BEST_EFFORT + + +def test_basler_default_trigger_is_off_and_free_runs( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory() + be = bb.BaslerCameraBackend(settings) + + be.open() + + assert be._camera.TriggerMode.GetValue() == "Off" + assert be.waits_for_hardware_trigger is False + + frame, _ = be.read() + assert frame.shape == (10, 10, 3) + + be.close() + + +def test_basler_follower_auto_selects_line1_and_times_out_waiting_for_trigger( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + properties={ + "basler": { + "trigger": { + "role": "follower", + "selector": "FrameStart", + "source": "auto", + "activation": "RisingEdge", + "timeout": 5.0, + "strict": False, + } + } + } + ) + + be = bb.BaslerCameraBackend(settings) + + # Timeout is configured in seconds but pypylon RetrieveResult uses ms; + # hardware-trigger waits should be capped for responsive shutdown. + assert be._retrieve_timeout_ms == 1000 + + be.open() + + assert be.waits_for_hardware_trigger is True + assert be._camera.TriggerSelector.GetValue() == "FrameStart" + assert be._camera.TriggerSource.GetValue() == "Line1" + assert be._camera.TriggerActivation.GetValue() == "RisingEdge" + assert be._camera.TriggerMode.GetValue() == "On" + + with pytest.raises(TimeoutError, match="waiting for hardware trigger"): + be.read() + + assert be._camera.retrieve_calls[-1] == 1000 + + be.close() + + +def test_basler_follower_strict_invalid_source_raises( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + properties={ + "basler": { + "trigger": { + "role": "follower", + "selector": "FrameStart", + "source": "NotARealSource", + "activation": "RisingEdge", + "strict": True, + } + } + } + ) + + be = bb.BaslerCameraBackend(settings) + + with pytest.raises(RuntimeError, match="TriggerSource"): + be.open() + + +def test_basler_follower_non_strict_invalid_source_disables_trigger( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + properties={ + "basler": { + "trigger": { + "role": "follower", + "source": "NotARealSource", + "strict": False, + } + } + } + ) + + be = bb.BaslerCameraBackend(settings) + be.open() + + assert be._camera.TriggerMode.GetValue() == "Off" + assert be.waits_for_hardware_trigger is False + + frame, _ = be.read() + assert frame.shape == (10, 10, 3) + + be.close() + + +def test_basler_master_configures_generic_line_output_and_restores_on_close( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + properties={ + "basler": { + "trigger": { + "role": "master", + "output_line": "Line2", + "output_source": "ExposureActive", + "strict": False, + } + } + } + ) + + be = bb.BaslerCameraBackend(settings) + be.open() + + cam = be._camera + assert cam.LineSelector.GetValue() == "Line2" + assert cam.LineMode.GetValue() == "Output" + assert cam.LineSource.GetValue() == "ExposureActive" + assert be.waits_for_hardware_trigger is False + + be.close() + + # Local reference remains valid after backend clears self._camera. + assert cam.LineSource.GetValue() == "Off" + assert cam.LineMode.GetValue() == "Input" + + +def test_basler_software_trigger_requires_trigger_once_before_read( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + properties={ + "basler": { + "trigger": { + "role": "software", + "selector": "FrameStart", + "strict": False, + } + } + } + ) + + be = bb.BaslerCameraBackend(settings) + be.open() + + assert be._camera.TriggerMode.GetValue() == "On" + assert be._camera.TriggerSource.GetValue() == "Software" + assert be.waits_for_hardware_trigger is False + + # No software trigger has been fired yet. + with pytest.raises(RuntimeError, match="Failed to retrieve image"): + be.read() + + be.trigger_once() + assert be._camera.software_trigger_calls == 1 + + frame, _ = be.read() + assert frame.shape == (10, 10, 3) + + be.close() + + +def test_basler_close_turns_input_trigger_off( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + properties={ + "basler": { + "trigger": { + "role": "external", + "source": "Line1", + "activation": "RisingEdge", + } + } + } + ) + + be = bb.BaslerCameraBackend(settings) + be.open() + + cam = be._camera + assert cam.TriggerMode.GetValue() == "On" + + be.close() + + assert cam.TriggerMode.GetValue() == "Off" From cbe6a118850025e6048cd0cae9af0058b55f980f Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 16:03:16 -0500 Subject: [PATCH 060/133] Throttle trigger-wait logs, mark Basler test xfail Add throttled logging for hardware-trigger wait timeouts in SingleCameraWorker to avoid noisy repeated timeout messages. Introduce _trigger_wait_log_interval, _last_trigger_wait_log and _trigger_wait_suppressed_count and move _log_trigger_wait_throttled into the worker; remove the duplicate implementation from MultiCameraController. Also mark the Basler software-trigger test as xfail because software trigger support is not implemented yet. --- .../services/multi_camera_controller.py | 68 ++++++++++--------- tests/cameras/backends/test_basler_backend.py | 1 + 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index ba474a082..faabfe614 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -56,7 +56,12 @@ def __init__(self, camera_id: str, settings: CameraSettings): self._max_consecutive_errors = 5 self._retry_delay = 0.1 self._trigger_timeout_delay = 0.05 - self._log_interval_while_waiting_for_trigger_s = 2.0 + + # Hardware-trigger wait logging can be noisy because timeouts are expected + # while no trigger pulse is arriving. Log only occasionally. + self._trigger_wait_log_interval = 2.0 + self._last_trigger_wait_log = 0.0 + self._trigger_wait_suppressed_count = 0 # Performance logs self._timing = WorkerTimingStats( @@ -167,6 +172,36 @@ def run(self) -> None: def stop(self) -> None: self._stop_event.set() + def _log_trigger_wait_throttled(self, exc: BaseException) -> None: + """Log hardware-trigger wait timeouts at a controlled rate. + + In trigger-waiting modes, read timeouts are expected polling misses. + Without throttling, the log can be flooded at ~10-20 messages/sec/camera. + """ + now = time.monotonic() + + if now - self._last_trigger_wait_log < self._trigger_wait_log_interval: + self._trigger_wait_suppressed_count += 1 + return + + suppressed = self._trigger_wait_suppressed_count + self._trigger_wait_suppressed_count = 0 + self._last_trigger_wait_log = now + + if suppressed: + LOGGER.debug( + "[Worker %s] waiting for hardware trigger: %s (suppressed %d repeated timeout logs)", + self._camera_id, + exc, + suppressed, + ) + else: + LOGGER.debug( + "[Worker %s] waiting for hardware trigger: %s", + self._camera_id, + exc, + ) + def get_display_id(settings: CameraSettings) -> str: return f"{settings.backend}:{settings.index}" @@ -283,36 +318,6 @@ def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats: self._timing_per_cam[camera_id] = timing return timing - def _log_trigger_wait_throttled(self, exc: BaseException) -> None: - """Log hardware-trigger wait timeouts at a controlled rate. - - In trigger-waiting modes, read timeouts are expected polling misses. - Without throttling, the log can be flooded at ~10-20 messages/sec/camera. - """ - now = time.monotonic() - - if now - self._last_trigger_wait_log < self._trigger_wait_log_interval: - self._trigger_wait_suppressed_count += 1 - return - - suppressed = self._trigger_wait_suppressed_count - self._trigger_wait_suppressed_count = 0 - self._last_trigger_wait_log = now - - if suppressed: - LOGGER.debug( - "[Worker %s] waiting for hardware trigger: %s (suppressed %d repeated timeout logs)", - self._camera_id, - exc, - suppressed, - ) - else: - LOGGER.debug( - "[Worker %s] waiting for hardware trigger: %s", - self._camera_id, - exc, - ) - def start(self, camera_settings: list[CameraSettings]) -> None: """Start multiple cameras.""" if self._running: @@ -544,7 +549,6 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float display_ids=dict(self._display_ids), ) - if frame_data is not None: with timing.measure("Multi.emit.frame_ready"): self.frame_ready.emit(frame_data) diff --git a/tests/cameras/backends/test_basler_backend.py b/tests/cameras/backends/test_basler_backend.py index 2edff9092..64496b948 100644 --- a/tests/cameras/backends/test_basler_backend.py +++ b/tests/cameras/backends/test_basler_backend.py @@ -396,6 +396,7 @@ def test_basler_master_configures_generic_line_output_and_restores_on_close( assert cam.LineMode.GetValue() == "Input" +@pytest.mark.xfail(reason="Software trigger support is not implemented yet.") def test_basler_software_trigger_requires_trigger_once_before_read( patch_basler_sdk, basler_settings_factory, From caa9aba48914aadacff5d99de983f1c910b8d7ca Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 16:06:19 -0500 Subject: [PATCH 061/133] Update multi_camera_controller.py --- dlclivegui/services/multi_camera_controller.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index faabfe614..4d8bf9c79 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -57,8 +57,6 @@ def __init__(self, camera_id: str, settings: CameraSettings): self._retry_delay = 0.1 self._trigger_timeout_delay = 0.05 - # Hardware-trigger wait logging can be noisy because timeouts are expected - # while no trigger pulse is arriving. Log only occasionally. self._trigger_wait_log_interval = 2.0 self._last_trigger_wait_log = 0.0 self._trigger_wait_suppressed_count = 0 From 89a98a2e4b753c658f6fe1097edd2c34e9f50860 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 23 Jun 2026 13:06:28 -0500 Subject: [PATCH 062/133] Basler backend: add timing and FPS config Integrates optional WorkerTimingStats into the Basler camera backend and refactors FPS handling and frame retrieval. Adds a _configure_frame_rate() helper to centralize AcquisitionFrameRate enabling, setting and readbacks (logs many related nodes and records actual_fps). Initializes a WorkerTimingStats instance (controlled by SINGLE_CAMERA_WORKER_DO_LOG_TIMING) and wraps RetrieveResult/convert/get array/release steps with timing measurements, improved error handling, proper grab_result release on exceptions, and frame counting/logging. Overall improves observability and robustness when setting frame rates and reading frames from Basler cameras. --- dlclivegui/cameras/backends/basler_backend.py | 152 ++++++++++++++---- 1 file changed, 121 insertions(+), 31 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 30a5ea0de..4ac36745d 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -9,7 +9,8 @@ import numpy as np -from ...config import CameraTriggerSettings +from ...config import SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraTriggerSettings +from ...utils.stats import WorkerTimingStats from ..base import CameraBackend, SupportLevel, register_backend LOG = logging.getLogger(__name__) @@ -108,6 +109,16 @@ def __init__(self, settings): self._actual_exposure: float | None = None self._actual_gain: float | None = None + # ---- Timing stats for logging (optional) ---- + msg = self._device_id or f"index:{getattr(settings, 'index', '?')}" + timing_id = f"Basler {msg}" + self._timing = WorkerTimingStats( + timing_id, + logger=LOG, + log_interval=1.0, + enabled=SINGLE_CAMERA_WORKER_DO_LOG_TIMING, + ) + @property def actual_resolution(self) -> tuple[int, int] | None: if self._actual_width and self._actual_height: @@ -381,6 +392,66 @@ def trigger_once(self) -> None: raise RuntimeError("Basler software trigger command is not available") + def _configure_frame_rate(self) -> None: + if self._camera is None: + return + + fps = self._positive_float(getattr(self.settings, "fps", 0.0)) + if fps is None: + LOG.info("[Basler] FPS: auto/free-run, not forcing AcquisitionFrameRate") + return + + enable = self._feature("AcquisitionFrameRateEnable") + rate = self._feature("AcquisitionFrameRate") + + try: + if enable is not None: + enable.SetValue(True) + + if rate is None: + LOG.warning("[Basler] AcquisitionFrameRate node not available; cannot set FPS=%s", fps) + return + + try: + min_v = rate.GetMin() + max_v = rate.GetMax() + LOG.info("[Basler] AcquisitionFrameRate range: min=%s max=%s requested=%s", min_v, max_v, fps) + except Exception: + pass + + rate.SetValue(float(fps)) + + except Exception as exc: + LOG.warning("[Basler] Failed to set AcquisitionFrameRate=%s: %s", fps, exc, exc_info=True) + + # Readbacks + readbacks = {} + for name in ( + "AcquisitionFrameRateEnable", + "AcquisitionFrameRate", + "ResultingFrameRate", + "ResultingAcquisitionFrameRate", + "AcquisitionResultingFrameRate", + "BslResultingAcquisitionFrameRate", + "ExposureAuto", + "ExposureTime", + "Width", + "Height", + "PixelFormat", + "TestImageSelector", + "ImageFileMode", + ): + feature = self._feature(name) + if feature is not None: + readbacks[name] = self._feature_value(feature, None) + + LOG.info("[Basler] FPS readback requested=%s values=%s", fps, readbacks) + + try: + self._actual_fps = float(readbacks.get("AcquisitionFrameRate")) + except Exception: + self._actual_fps = None + def open(self) -> None: if pylon is None: raise RuntimeError("pypylon is required for the Basler backend but is not installed") @@ -427,19 +498,7 @@ def open(self) -> None: # ---------------------------- # Frame rate (0.0 = Auto → do not set) # ---------------------------- - fps = self._positive_float(getattr(self.settings, "fps", 0.0)) - - if fps is not None: - try: - # 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: - LOG.debug("Frame rate not writable or not supported", exc_info=True) + self._configure_frame_rate() # ---------------------------- # Trigger configuration @@ -544,28 +603,59 @@ def read(self) -> tuple[np.ndarray, float]: raise RuntimeError("Basler camera not opened") if self._converter is None: raise RuntimeError("Basler camera opened in fast-start probe mode; cannot read frames") + + grab_result = None + try: - grab_result = self._camera.RetrieveResult( - int(getattr(self, "_retrieve_timeout_ms", 100)), - pylon.TimeoutHandling_ThrowException, - ) + with self._timing.measure("Basler.retrieve"): + grab_result = self._camera.RetrieveResult( + int(getattr(self, "_retrieve_timeout_ms", 100)), + pylon.TimeoutHandling_ThrowException, + ) + + with self._timing.measure("Basler.check_result"): + if not grab_result.GrabSucceeded(): + grab_result.Release() + grab_result = None + self._timing.note_error() + self._timing.maybe_log() + raise RuntimeError("Basler camera did not return an image") + + with self._timing.measure("Basler.convert"): + image = self._converter.Convert(grab_result) + + with self._timing.measure("Basler.get_array"): + frame = image.GetArray() + + with self._timing.measure("Basler.release"): + grab_result.Release() + grab_result = None + + if self._actual_width is None or self._actual_height is None: + h, w = frame.shape[:2] + self._actual_width = int(w) + self._actual_height = int(h) + + self._timing.note_frame() + self._timing.maybe_log() + + return frame, time.time() + except Exception as exc: + if grab_result is not None: + try: + grab_result.Release() + except Exception: + pass + if self.waits_for_hardware_trigger: + self._timing.note_timeout() + self._timing.maybe_log() raise TimeoutError(f"Basler timeout while waiting for hardware trigger: {exc}") from exc + + self._timing.note_error() + self._timing.maybe_log() raise RuntimeError("Failed to retrieve image from Basler camera.") from exc - if not grab_result.GrabSucceeded(): - grab_result.Release() - raise RuntimeError("Basler camera did not return an image") - image = self._converter.Convert(grab_result) - frame = image.GetArray() - grab_result.Release() - - if self._actual_width is None or self._actual_height is None: - h, w = frame.shape[:2] - self._actual_width = int(w) - self._actual_height = int(h) - - return frame, time.time() def close(self) -> None: LOG.info( From 0d13a4c1f60b118316f84996990cc690e2a19cf9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 15:08:22 +0200 Subject: [PATCH 063/133] Split multi-camera frame signals; throttle display Introduce a separate, throttled UI/display path for multi-camera frames. Add GUI_MAX_DISPLAY_FPS config and a new display_ready signal that is emitted at most at that rate, while frame_ready remains full-rate for recording/inference. Implement _should_emit_display_ready, wire the MultiCameraController to emit both signals, and reset throttling state when starting. Update the main window to handle processing and display paths via _on_multi_frame_processing_ready and _on_multi_frame_display_ready, and adjust tests accordingly. Also add GUI/debug timing config keys and a temporary timing-only early path in SingleCameraWorker (marked as FIXME). --- dlclivegui/config.py | 12 ++++- dlclivegui/gui/main_window.py | 15 ++++-- .../services/multi_camera_controller.py | 48 +++++++++++++++++-- tests/gui/test_pose_overlay.py | 6 +-- 4 files changed, 67 insertions(+), 14 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 2d176224f..936a74226 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -17,8 +17,16 @@ TriggerStrobePolarity = Literal["ActiveHigh", "ActiveLow"] TriggerStrobeOperation = Literal["Exposure", "FixedDuration"] -SINGLE_CAMERA_WORKER_DO_LOG_TIMING = False -MULTI_CAMERA_WORKER_DO_LOG_TIMING = True +# Global settings +## GUI +GUI_MAX_DISPLAY_FPS: float = 30.0 + + +## Debug +### Timing logs +SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = True +MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = True +MAIN_WINDOW_DO_LOG_TIMING: bool = False class CameraSettings(BaseModel): diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 6fddba6b2..69061e989 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -772,7 +772,8 @@ def _connect_signals(self) -> None: self.bbox_color_combo.currentIndexChanged.connect(self._on_bbox_color_changed) # Multi-camera controller signals (used for both single and multi-camera modes) - self.multi_camera_controller.frame_ready.connect(self._on_multi_frame_ready) + self.multi_camera_controller.frame_ready.connect(self._on_multi_frame_processing_ready) + self.multi_camera_controller.frame_ready.connect(self._on_multi_frame_display_ready) self.multi_camera_controller.all_started.connect(self._on_multi_camera_started) self.multi_camera_controller.all_stopped.connect(self._on_multi_camera_stopped) self.multi_camera_controller.camera_error.connect(self._on_multi_camera_error) @@ -1374,13 +1375,12 @@ def _render_overlays_for_recording(self, cam_id, frame): ) return output - def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: + def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: """Handle frames from multiple cameras. - Priority order for performance: + Priority: 1. DLC processing (highest priority - enqueue immediately, only for DLC camera) 2. Recording (queued writes, non-blocking) - 3. Display (lowest priority - tiled and updated on separate timer) """ self._multi_camera_frames = frame_data.frames src_id = frame_data.source_camera_id @@ -1437,7 +1437,12 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: ts = frame_data.timestamps.get(src_id, time.time()) self._rec_manager.write_frame(src_id, frame, ts) - # PRIORITY 3: Mark display dirty (tiling done in display timer) + def _on_multi_frame_display_ready(self, frame_data: MultiFrameData) -> None: + """Throttled UI/display path. + + Called at GUI_MAX_DISPLAY_FPS, not at camera capture FPS for performance reasons. + """ + self._multi_camera_frames = frame_data.frames self._display_dirty = True def _on_multi_camera_started(self) -> None: diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index c88fbda51..dc4024bd3 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -4,6 +4,7 @@ import copy import logging +import time from dataclasses import dataclass from functools import partial from threading import Event, Lock @@ -18,7 +19,12 @@ from dlclivegui.cameras.factory import camera_identity_key # from dlclivegui.config import CameraSettings -from dlclivegui.config import MULTI_CAMERA_WORKER_DO_LOG_TIMING, SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraSettings +from dlclivegui.config import ( + GUI_MAX_DISPLAY_FPS, + MULTI_CAMERA_WORKER_DO_LOG_TIMING, + SINGLE_CAMERA_WORKER_DO_LOG_TIMING, + CameraSettings, +) from dlclivegui.utils.stats import WorkerTimingStats LOGGER = logging.getLogger(__name__) @@ -110,6 +116,10 @@ def run(self) -> None: continue consecutive_errors = 0 + # if True: # TEMP FIXME REMOVE + # self._timing.note_frame() + # self._timing.maybe_log() + # continue with self._timing.measure("Single.emit.frame_captured"): self.frame_captured.emit(self._camera_id, frame, timestamp) @@ -235,7 +245,8 @@ class MultiCameraController(QObject): """Controller for managing multiple cameras simultaneously.""" # Signals - frame_ready = Signal(object) # MultiFrameData + frame_ready = Signal(object) # MultiFrameData (full cam FPS; recording and inference only) + display_ready = Signal(object) # MultiFrameData for GUI display (throttled to GUI_MAX_DISPLAY_FPS) camera_started = Signal(str, object) # camera_id, settings camera_stopped = Signal(str) # camera_id camera_error = Signal(str, str) # camera_id, error_message @@ -260,6 +271,9 @@ def __init__(self): self._failed_cameras: dict[str, str] = {} # camera_id -> error message self._expected_cameras: int = 0 # Number of cameras we're trying to start + # GUI display max FPS (for throttling display updates when many cameras are active) + self._gui_display_max_fps: float = GUI_MAX_DISPLAY_FPS + self._gui_display_last_emit: float = 0.0 # Performance logs self._timing_per_cam: dict[str, WorkerTimingStats] = {} @@ -272,8 +286,6 @@ def get_active_count(self) -> int: return len(self._started_cameras) def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats: - if not MULTI_CAMERA_WORKER_DO_LOG_TIMING: - return WorkerTimingStats(camera_id, enabled=False) timing = self._timing_per_cam.get(camera_id) if timing is None: timing = WorkerTimingStats( @@ -285,6 +297,24 @@ def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats: self._timing_per_cam[camera_id] = timing return timing + def _should_emit_display_ready(self) -> bool: + """Return True when the UI/display path should be updated. + + This only throttles display_ready. It must not throttle frame_ready, + because frame_ready is used for full-rate consumers such as recording. + """ + if self._gui_display_max_fps <= 0: + return True + + now = time.perf_counter() + min_interval = 1.0 / max(self._gui_display_max_fps, 1e-9) + + if now - self._gui_display_last_emit < min_interval: + return False + + self._gui_display_last_emit = now + return True + def start(self, camera_settings: list[CameraSettings]) -> None: """Start multiple cameras.""" if self._running: @@ -456,6 +486,11 @@ def stop(self, wait: bool = True) -> None: self.all_stopped.emit() return + self._timing_per_cam.clear() + self._gui_display_last_emit = 0.0 + self._workers.clear() + self._threads.clear() + self._settings.clear() self._started_cameras.clear() self._failed_cameras.clear() self._camera_display_order.clear() @@ -521,6 +556,11 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float with timing.measure("Multi.emit.frame_ready"): self.frame_ready.emit(frame_data) + # GUI-only path: throttled display updates + if self._should_emit_display_ready(): + with timing.measure("Multi.emit.display_ready"): + self.display_ready.emit(frame_data) + timing.note_frame() timing.maybe_log() diff --git a/tests/gui/test_pose_overlay.py b/tests/gui/test_pose_overlay.py index 369baf846..3af35308f 100644 --- a/tests/gui/test_pose_overlay.py +++ b/tests/gui/test_pose_overlay.py @@ -65,7 +65,7 @@ def test_record_overlay_toggle_affects_frames_sent_to_recorder(window, recording # Provide a frame raw = np.zeros((100, 100, 3), dtype=np.uint8) - # Build minimal frame_data to call _on_multi_frame_ready + # Build minimal frame_data to call _on_multi_frame_processing_ready from dlclivegui.services.multi_camera_controller import MultiFrameData frame_data = MultiFrameData( @@ -76,7 +76,7 @@ def test_record_overlay_toggle_affects_frames_sent_to_recorder(window, recording # 1) toggle OFF: should record raw window.record_with_overlays_checkbox.setChecked(False) - window._on_multi_frame_ready(frame_data) + window._on_multi_frame_processing_ready(frame_data) assert cam_id in recording_frame_spy recorded_off = recording_frame_spy[cam_id] @@ -84,7 +84,7 @@ def test_record_overlay_toggle_affects_frames_sent_to_recorder(window, recording # 2) toggle ON: should record overlay frame (different) window.record_with_overlays_checkbox.setChecked(True) - window._on_multi_frame_ready(frame_data) + window._on_multi_frame_processing_ready(frame_data) recorded_on = recording_frame_spy[cam_id] assert not np.array_equal(recorded_on, raw) From 126c1c3de10508492ec352b410c949cadda88824 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 15:25:39 +0200 Subject: [PATCH 064/133] Add GenTL telemetry and frame-rate debugging Add helpers and telemetry to better read and debug GenTL/GenICam cameras. - New debug helper _debug_frame_rate_nodes to log many common frame-rate/exposure/throughput nodes. - Added robust node accessors: _node_value, _node_float, _node_str to safely read various GenICam node types and try multiple node names. - Enhance frame-rate configuration to log before/after values when enabling rate control and when setting AcquisitionFrameRate/AcquisitionFrameRateAbs; record any accepted frame-rate as _actual_fps. - After starting acquisition, attempt to read telemetry and run FPS debug logging; warn (but continue) if telemetry read fails. - Improve _read_telemetry to prefer resulting frame-rate nodes over requested ones, and to populate actual_fps, actual_exposure, actual_gain and other useful properties (pixel format, throughput, resolution, etc.) into the settings namespace for GUI/debugging. These changes make frame-rate/exposure behavior more observable and more tolerant across different camera implementations. --- dlclivegui/cameras/backends/gentl_backend.py | 168 +++++++++++++++++-- 1 file changed, 155 insertions(+), 13 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 260c55a8b..01c495f20 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -558,6 +558,15 @@ def open(self) -> None: self._acquirer.start() + try: + self._read_telemetry(node_map) + self._debug_frame_rate_nodes(node_map, context="after starting acquisition") + except Exception: + LOG.warning( + "Failed to read telemetry after starting acquisition; some 'actual' values may be missing.", + exc_info=True, + ) + LOG.debug( "Opened GenTL camera index=%s serial=%s label=%s", selected_index, @@ -1126,6 +1135,48 @@ def _node_symbolics(node) -> list[str]: except Exception: return [] + @staticmethod + def _node_value(node_map, name: str, default=None): + """Best-effort read of a GenICam node value.""" + try: + node = getattr(node_map, name) + except Exception: + return default + + try: + return node.value + except Exception: + return default + + @classmethod + def _node_float(cls, node_map, *names: str) -> float | None: + """Return the first positive float value from a list of GenICam node names.""" + for name in names: + value = cls._node_value(node_map, name, None) + try: + fvalue = float(value) + except Exception: + continue + + if fvalue > 0: + return fvalue + + return None + + @classmethod + def _node_str(cls, node_map, *names: str) -> str | None: + """Return the first non-empty string value from a list of GenICam node names.""" + for name in names: + value = cls._node_value(node_map, name, None) + if value is None: + continue + + text = str(value).strip() + if text: + return text + + return None + def _set_enum_node(self, node_map, name: str, value: str, *, strict: bool = False) -> bool: node = self._node(node_map, name) if node is None: @@ -1605,21 +1656,48 @@ def _configure_frame_rate(self, node_map) -> None: return target = float(self.settings.fps) + LOG.info("Configuring GenTL frame rate: requested %.3f FPS", target) + for attr in ("AcquisitionFrameRateEnable", "AcquisitionFrameRateControlEnable"): try: - getattr(node_map, attr).value = True + node = getattr(node_map, attr) + before = getattr(node, "value", None) + node.value = True + after = getattr(node, "value", None) + LOG.info("Enabled GenTL %s: before=%r after=%r", attr, before, after) break except Exception: pass - for attr in ("AcquisitionFrameRate", "ResultingFrameRate", "AcquisitionFrameRateAbs"): + for attr in ("AcquisitionFrameRate", "AcquisitionFrameRateAbs"): try: - getattr(node_map, attr).value = target + node = getattr(node_map, attr) + before = getattr(node, "value", None) + node.value = target + after = getattr(node, "value", None) + + LOG.info( + "Set GenTL %s: before=%r requested=%.3f after=%r", + attr, + before, + target, + after, + ) + + try: + accepted = float(after) + if accepted > 0: + self._actual_fps = accepted + except Exception: + pass + return + except AttributeError: continue except Exception as e: LOG.warning("Failed to set frame rate via %s: %s", attr, e) + LOG.warning("Could not set frame rate to %s FPS", target) def _read_telemetry(self, node_map) -> None: @@ -1629,20 +1707,84 @@ def _read_telemetry(self, node_map) -> None: except Exception: pass - try: - self._actual_fps = float(node_map.ResultingFrameRate.value) - except Exception: - self._actual_fps = None + # Prefer true/resulting frame-rate readback nodes. + resulting_fps = self._node_float( + node_map, + "AcquisitionResultingFrameRate", + "ResultingFrameRate", + "AcquisitionFrameRateResulting", + "DeviceFrameRate", + ) - try: - self._actual_exposure = float(node_map.ExposureTime.value) - except Exception: - self._actual_exposure = None + # Fallback to requested/accepted frame-rate nodes only if no resulting node exists. + requested_fps = self._node_float( + node_map, + "AcquisitionFrameRate", + "AcquisitionFrameRateAbs", + ) + + if resulting_fps is not None: + self._actual_fps = resulting_fps + elif requested_fps is not None: + self._actual_fps = requested_fps + exposure = self._node_float( + node_map, + "ExposureTime", + "ExposureTimeAbs", + "Exposure", + ) + if exposure is not None: + self._actual_exposure = exposure + + gain = self._node_float( + node_map, + "Gain", + "GainRaw", + ) + if gain is not None: + self._actual_gain = gain + + # Persist useful telemetry into properties["gentl"] for GUI/debugging. try: - self._actual_gain = float(node_map.Gain.value) + ns = self._ensure_settings_ns() + + if self._actual_width and self._actual_height: + ns["actual_resolution"] = [int(self._actual_width), int(self._actual_height)] + + if self._actual_fps is not None: + ns["actual_fps"] = float(self._actual_fps) + + if resulting_fps is not None: + ns["actual_resulting_frame_rate"] = float(resulting_fps) + + if requested_fps is not None: + ns["actual_acquisition_frame_rate"] = float(requested_fps) + + if self._actual_exposure is not None: + ns["actual_exposure"] = float(self._actual_exposure) + + if self._actual_gain is not None: + ns["actual_gain"] = float(self._actual_gain) + + exposure_auto = self._node_str(node_map, "ExposureAuto") + if exposure_auto is not None: + ns["actual_exposure_auto"] = exposure_auto + + throughput = self._node_float(node_map, "DeviceLinkThroughputLimit") + if throughput is not None: + ns["actual_device_link_throughput_limit"] = float(throughput) + + throughput_mode = self._node_str(node_map, "DeviceLinkThroughputLimitMode") + if throughput_mode is not None: + ns["actual_device_link_throughput_limit_mode"] = throughput_mode + + pixel_format = self._node_str(node_map, "PixelFormat") + if pixel_format is not None: + ns["actual_pixel_format"] = pixel_format + except Exception: - self._actual_gain = None + pass # ------------------------------------------------------------------ # Frame conversion / local helpers From 0e7710fab5b7312bf8f6573e34b2b47b0aa8e4d5 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 17:13:56 +0200 Subject: [PATCH 065/133] Remove temp debug block --- dlclivegui/services/multi_camera_controller.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index dc4024bd3..ee3061f9f 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -116,10 +116,6 @@ def run(self) -> None: continue consecutive_errors = 0 - # if True: # TEMP FIXME REMOVE - # self._timing.note_frame() - # self._timing.maybe_log() - # continue with self._timing.measure("Single.emit.frame_captured"): self.frame_captured.emit(self._camera_id, frame, timestamp) From 4f09b21770eb7a48d3c4ba73597333aa348529be Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 17:26:52 +0200 Subject: [PATCH 066/133] Fix missing signal wiring Fix signal hookup and disable main-window timing flag. Replace the second connection of multi_camera_controller.frame_ready to _on_multi_frame_display_ready with multi_camera_controller.display_ready to separate processing-ready and display-ready events. Also comment out MAIN_WINDOW_DO_LOG_TIMING in config.py to disable main-window timing logging. --- dlclivegui/config.py | 2 +- dlclivegui/gui/main_window.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 936a74226..dac523da8 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -26,7 +26,7 @@ ### Timing logs SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = True MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = True -MAIN_WINDOW_DO_LOG_TIMING: bool = False +# MAIN_WINDOW_DO_LOG_TIMING: bool = False class CameraSettings(BaseModel): diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 69061e989..ef06a5d92 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -773,7 +773,7 @@ def _connect_signals(self) -> None: # Multi-camera controller signals (used for both single and multi-camera modes) self.multi_camera_controller.frame_ready.connect(self._on_multi_frame_processing_ready) - self.multi_camera_controller.frame_ready.connect(self._on_multi_frame_display_ready) + self.multi_camera_controller.display_ready.connect(self._on_multi_frame_display_ready) self.multi_camera_controller.all_started.connect(self._on_multi_camera_started) self.multi_camera_controller.all_stopped.connect(self._on_multi_camera_stopped) self.multi_camera_controller.camera_error.connect(self._on_multi_camera_error) From fb1b2ed2fe8d56ee3a3d8c8d954cd5e45119a1c7 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 1 Jun 2026 16:21:36 +0200 Subject: [PATCH 067/133] Allow zero node values and reduce log level Change LOG.info to LOG.debug to reduce noise when printing node information. Add an allow_zero parameter to _node_float so callers can accept zero as a valid value (previously only positive values were returned). Update callers for exposure, gain, and DeviceLinkThroughputLimit to pass allow_zero=True so reported zeros are treated as real readings while preserving the original behavior by default. --- dlclivegui/cameras/backends/gentl_backend.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 01c495f20..26d5c2f03 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1149,7 +1149,7 @@ def _node_value(node_map, name: str, default=None): return default @classmethod - def _node_float(cls, node_map, *names: str) -> float | None: + def _node_float(cls, node_map, *names: str, allow_zero: bool = False) -> float | None: """Return the first positive float value from a list of GenICam node names.""" for name in names: value = cls._node_value(node_map, name, None) @@ -1158,7 +1158,7 @@ def _node_float(cls, node_map, *names: str) -> float | None: except Exception: continue - if fvalue > 0: + if fvalue > 0 or (allow_zero and fvalue == 0): return fvalue return None @@ -1733,6 +1733,7 @@ def _read_telemetry(self, node_map) -> None: "ExposureTime", "ExposureTimeAbs", "Exposure", + allow_zero=True, ) if exposure is not None: self._actual_exposure = exposure @@ -1741,6 +1742,7 @@ def _read_telemetry(self, node_map) -> None: node_map, "Gain", "GainRaw", + allow_zero=True, ) if gain is not None: self._actual_gain = gain @@ -1771,7 +1773,7 @@ def _read_telemetry(self, node_map) -> None: if exposure_auto is not None: ns["actual_exposure_auto"] = exposure_auto - throughput = self._node_float(node_map, "DeviceLinkThroughputLimit") + throughput = self._node_float(node_map, "DeviceLinkThroughputLimit", allow_zero=True) if throughput is not None: ns["actual_device_link_throughput_limit"] = float(throughput) From 4b277572962deed977027d1d42d4c87a8afb0971 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 23 Jun 2026 13:30:24 -0500 Subject: [PATCH 068/133] Guard debug log to avoid expensive config calls --- dlclivegui/cameras/backends/basler_backend.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 4ac36745d..02e105288 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -867,6 +867,9 @@ def _set_numeric_feature(self, name: str, value, *, strict: bool = False) -> boo return False def _debug_trigger_nodes(self, *, context: str = "") -> None: + if not LOG.isEnabledFor(logging.DEBUG): + return + names = ( "TriggerSelector", "TriggerMode", From da4321cc9bb716200d36eb8020f242a32308d79c Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 14:25:03 -0500 Subject: [PATCH 069/133] Move RecorderStats to utils.stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the RecorderStats dataclass from dlclivegui/services/video_recorder.py into dlclivegui/utils/stats.py and add the needed dataclasses import. Update imports to reflect the new location in dlclivegui/gui/recording_manager.py, dlclivegui/services/video_recorder.py, and tests/tests/gui/test_rec_manager.py. Also add imports for REC_DO_LOG_TIMING and WorkerTimingStats in video_recorder.py. No behavioral changes intended—this is a refactor to centralize recorder-related stats. --- dlclivegui/gui/recording_manager.py | 3 ++- dlclivegui/services/video_recorder.py | 17 ++--------------- dlclivegui/utils/stats.py | 16 +++++++++++++++- tests/gui/test_rec_manager.py | 2 +- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index 49ac9934b..daf6f7d5d 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -8,7 +8,8 @@ from dlclivegui.config import CameraSettings, RecordingSettings from dlclivegui.services.multi_camera_controller import get_camera_id -from dlclivegui.services.video_recorder import RecorderStats, VideoRecorder +from dlclivegui.services.video_recorder import VideoRecorder +from dlclivegui.utils.stats import RecorderStats from dlclivegui.utils.utils import build_run_dir, sanitize_name log = logging.getLogger(__name__) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index e2ae15c9e..358af5645 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -9,12 +9,13 @@ import threading import time from collections import deque -from dataclasses import dataclass from pathlib import Path from typing import Any import numpy as np +from dlclivegui.utils.stats import RecorderStats + try: from vidgear.gears import WriteGear except ImportError: # pragma: no cover - handled at runtime @@ -26,20 +27,6 @@ STOP_JOIN_TIMEOUT = 5.0 # seconds -@dataclass -class RecorderStats: - """Snapshot of recorder throughput metrics.""" - - frames_enqueued: int = 0 - frames_written: int = 0 - dropped_frames: int = 0 - queue_size: int = 0 - average_latency: float = 0.0 - last_latency: float = 0.0 - write_fps: float = 0.0 - buffer_seconds: float = 0.0 - - _SENTINEL = object() diff --git a/dlclivegui/utils/stats.py b/dlclivegui/utils/stats.py index 38e3798b7..acc83a9a4 100644 --- a/dlclivegui/utils/stats.py +++ b/dlclivegui/utils/stats.py @@ -3,9 +3,23 @@ import logging import time +from dataclasses import dataclass from dlclivegui.services.dlc_processor import ProcessorStats -from dlclivegui.services.video_recorder import RecorderStats + + +@dataclass +class RecorderStats: + """Snapshot of recorder throughput metrics.""" + + frames_enqueued: int = 0 + frames_written: int = 0 + dropped_frames: int = 0 + queue_size: int = 0 + average_latency: float = 0.0 + last_latency: float = 0.0 + write_fps: float = 0.0 + buffer_seconds: float = 0.0 class WorkerTimingStats: diff --git a/tests/gui/test_rec_manager.py b/tests/gui/test_rec_manager.py index c789078b0..aa8b187c5 100644 --- a/tests/gui/test_rec_manager.py +++ b/tests/gui/test_rec_manager.py @@ -6,7 +6,7 @@ from dlclivegui.config import CameraSettings from dlclivegui.gui.recording_manager import RecordingManager from dlclivegui.services.multi_camera_controller import get_camera_id, get_display_id -from dlclivegui.services.video_recorder import RecorderStats +from dlclivegui.utils.stats import RecorderStats @pytest.fixture From e80357f18eccb217ca1bd6f656401049d85a82c7 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 14:26:16 -0500 Subject: [PATCH 070/133] Preserve mono frames & add recorder timing Basler backend: add preserve_mono support and configure the pypylon converter to output Mono8 when the camera source PixelFormat is Mono* and preserve_mono is enabled; fall back to BGR8 otherwise. Log the first decoded frame and expose per-backend timing flag BASLER_DO_LOG_TIMING. Update defaults for global timing flags and add REC_DO_LOG_TIMING. Camera settings: add preserve_mono flag and include it in __repr__. RecordingManager: pass convert_grayscale_to_rgb based on the camera preserve_mono setting. VideoRecorder: add convert_grayscale_to_rgb option, avoid unnecessary grayscale->RGB expansion when disabled, forward pixel-format/size hints to WriteGear, add WorkerTimingStats for recorder processing and writer, instrument preprocessing/queue/write steps, log the first frame, and improve frame-size mismatch handling and error reporting. These changes reduce memory/CPU overhead for mono cameras and add better timing/diagnostics for recording. --- dlclivegui/cameras/backends/basler_backend.py | 64 +++++++++- dlclivegui/config.py | 9 +- dlclivegui/gui/recording_manager.py | 1 + dlclivegui/services/video_recorder.py | 118 +++++++++++++----- 4 files changed, 151 insertions(+), 41 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 02e105288..cbc1916bb 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -9,7 +9,7 @@ import numpy as np -from ...config import SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraTriggerSettings +from ...config import BASLER_DO_LOG_TIMING, CameraTriggerSettings from ...utils.stats import WorkerTimingStats from ..base import CameraBackend, SupportLevel, register_backend @@ -45,6 +45,12 @@ def __init__(self, settings): super().__init__(settings) self._props: dict = settings.properties if isinstance(settings.properties, dict) else {} + self._preserve_mono: bool = bool( + getattr(settings, "preserve_mono", False) or self.ns.get("preserve_mono", False) + ) + self._output_is_mono: bool = False + self._source_pixel_format: str | None = None + self._logged_first_frame: bool = False # Optional fast-start hint for probe workers # (may skip StartGrabbing and converter setup for faster capability probing; not suitable for normal capture) @@ -116,7 +122,7 @@ def __init__(self, settings): timing_id, logger=LOG, log_interval=1.0, - enabled=SINGLE_CAMERA_WORKER_DO_LOG_TIMING, + enabled=BASLER_DO_LOG_TIMING, ) @property @@ -452,6 +458,42 @@ def _configure_frame_rate(self) -> None: except Exception: self._actual_fps = None + def _configure_converter(self) -> None: + """Configure pypylon image converter. + + Default behavior remains BGR8 for compatibility. + + If properties.basler.preserve_mono=true and the source PixelFormat is Mono*, + return Mono8 frames as 2D arrays to avoid 3x BGR expansion in the grab thread. + """ + if self._camera is None: + return + + pixel_format = self._feature_value(self._feature("PixelFormat"), "") + self._source_pixel_format = str(pixel_format or "") + + self._converter = pylon.ImageFormatConverter() + self._converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned + + is_mono_source = self._source_pixel_format.startswith("Mono") + + if self._preserve_mono and is_mono_source: + self._converter.OutputPixelFormat = pylon.PixelType_Mono8 + self._output_is_mono = True + LOG.info( + "[Basler] Converter configured for Mono8 output (source PixelFormat=%s preserve_mono=%s)", + self._source_pixel_format, + self._preserve_mono, + ) + else: + self._converter.OutputPixelFormat = pylon.PixelType_BGR8packed + self._output_is_mono = False + LOG.info( + "[Basler] Converter configured for BGR8 output (source PixelFormat=%s preserve_mono=%s)", + self._source_pixel_format, + self._preserve_mono, + ) + def open(self) -> None: if pylon is None: raise RuntimeError("pypylon is required for the Basler backend but is not installed") @@ -549,9 +591,7 @@ def open(self) -> None: pass # Converter BEFORE StartGrabbing - self._converter = pylon.ImageFormatConverter() - self._converter.OutputPixelFormat = pylon.PixelType_BGR8packed - self._converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned + self._configure_converter() # Force stream configuration reset try: @@ -627,6 +667,20 @@ def read(self) -> tuple[np.ndarray, float]: with self._timing.measure("Basler.get_array"): frame = image.GetArray() + if not self._logged_first_frame: + self._logged_first_frame = True + LOG.info( + "[Basler] first frame device_id=%s shape=%s dtype=%s nbytes=%.2f MB " + "source_pixel_format=%s output_is_mono=%s, preserve_mono=%s", + self._device_id, + frame.shape, + frame.dtype, + frame.nbytes / (1024 * 1024), + self._source_pixel_format, + self._output_is_mono, + self._preserve_mono, + ) + with self._timing.measure("Basler.release"): grab_result.Release() grab_result = None diff --git a/dlclivegui/config.py b/dlclivegui/config.py index dac523da8..4db739754 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -24,9 +24,12 @@ ## Debug ### Timing logs -SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = True -MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = True +SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False +MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False +REC_DO_LOG_TIMING: bool = True # MAIN_WINDOW_DO_LOG_TIMING: bool = False +#### Backends +BASLER_DO_LOG_TIMING: bool = True class CameraSettings(BaseModel): @@ -42,6 +45,7 @@ class CameraSettings(BaseModel): exposure: int = 0 # 0=auto else µs gain: float = 0.0 # 0.0=auto else value + preserve_mono: bool = False # if True, preserve mono images as mono (not BGR) when reading crop_x0: int = 0 crop_y0: int = 0 @@ -65,6 +69,7 @@ def pretty(self) -> str: f" fps={self.fps}, size={self.width or 'auto'}x{self.height or 'auto'}, " f"exposure={self.exposure or 'auto'}, gain={self.gain or 'auto'}\n" f" rotation={self.rotation}, crop={crop}\n" + f" preserve_mono={self.preserve_mono}, max_devices={self.max_devices}\n" f"]" ) diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index daf6f7d5d..51f79552a 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -104,6 +104,7 @@ def start_all( frame_rate=float(cam.fps), codec=recording.codec, crf=recording.crf, + convert_grayscale_to_rgb=not bool(getattr(cam, "preserve_mono", False)), ) try: recorder.start() diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index 358af5645..c92eb23cf 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -14,7 +14,8 @@ import numpy as np -from dlclivegui.utils.stats import RecorderStats +from dlclivegui.config import REC_DO_LOG_TIMING +from dlclivegui.utils.stats import RecorderStats, WorkerTimingStats try: from vidgear.gears import WriteGear @@ -41,6 +42,7 @@ def __init__( codec: str = "libx264", crf: int = 23, buffer_size: int = 240, + convert_grayscale_to_rgb: bool = True, ): # Config self._output = Path(output) @@ -50,6 +52,7 @@ def __init__( self._codec = codec self._crf = int(crf) self._buffer_size = max(1, int(buffer_size)) + self._convert_grayscale_to_rgb = bool(convert_grayscale_to_rgb) # Worker state self._queue: queue.Queue[Any] | None = None self._writer_thread: threading.Thread | None = None @@ -67,6 +70,14 @@ def __init__( self._encode_error: Exception | None = None self._last_log_time = 0.0 self._frame_timestamps: list[float] = [] + # Timing + self._process_timing = WorkerTimingStats( + f"RecorderProcess[{self._output.name}]", logger=logger, log_interval=1.0, enabled=REC_DO_LOG_TIMING + ) + self._writer_timing = WorkerTimingStats( + f"RecorderWriter[{self._output.name}]", logger=logger, log_interval=1.0, enabled=REC_DO_LOG_TIMING + ) + self._logged_first_frame = False @property def is_running(self) -> bool: @@ -107,7 +118,15 @@ def start(self) -> None: "-vcodec": (self._codec or "libx264").strip() or "libx264", "-crf": int(self._crf), } - # TODO deal with pixel format + if not self._convert_grayscale_to_rgb: + writer_kwargs.update( + { + "-pix_fmt": "yuv420p", + } + ) + if self._frame_size is not None: + h, w = self._frame_size + writer_kwargs["-output_dimensions"] = (int(w), int(h)) self._output.parent.mkdir(parents=True, exist_ok=True) self._writer = WriteGear(output=str(self._output), **writer_kwargs) @@ -147,41 +166,57 @@ def write(self, frame: np.ndarray, timestamp: float | None = None) -> bool: if timestamp is None: timestamp = time.time() - # Convert frame to uint8 if needed - if frame.dtype != np.uint8: - frame_float = frame.astype(np.float32, copy=False) - max_val = float(frame_float.max()) if frame_float.size else 0.0 - scale = 1.0 - if max_val > 0: - scale = 255.0 / max_val if max_val > 255.0 else (255.0 if max_val <= 1.0 else 1.0) - frame = np.clip(frame_float * scale, 0.0, 255.0).astype(np.uint8) - - # Convert grayscale to RGB if needed - if frame.ndim == 2: - frame = np.repeat(frame[:, :, None], 3, axis=2) - - # Ensure contiguous array - frame = np.ascontiguousarray(frame) - - # Check if frame size matches expected size - if self._frame_size is not None: - expected_h, expected_w = self._frame_size - actual_h, actual_w = frame.shape[:2] - if (actual_h, actual_w) != (expected_h, expected_w): - logger.warning( - f"Frame size mismatch: expected (h={expected_h}, w={expected_w}), " - f"got (h={actual_h}, w={actual_w}). " - "Stopping recorder to prevent encoding errors." + with self._process_timing.measure("Recorder.preprocess"): + # Convert frame to uint8 if needed + if frame.dtype != np.uint8: + frame_float = frame.astype(np.float32, copy=False) + max_val = float(frame_float.max()) if frame_float.size else 0.0 + scale = 1.0 + if max_val > 0: + scale = 255.0 / max_val if max_val > 255.0 else (255.0 if max_val <= 1.0 else 1.0) + frame = np.clip(frame_float * scale, 0.0, 255.0).astype(np.uint8) + + # Convert grayscale to RGB if needed + if self._convert_grayscale_to_rgb and frame.ndim == 2: + frame = np.repeat(frame[:, :, None], 3, axis=2) + + # Ensure contiguous array + frame = np.ascontiguousarray(frame) + + if not self._logged_first_frame: + self._logged_first_frame = True + logger.info( + "Recorder %s first frame: shape=%s dtype=%s " + "contiguous=%s nbytes=%.2f MB convert_grayscale_to_rgb=%s", + self._output.name, + frame.shape, + frame.dtype, + frame.flags.c_contiguous, + frame.nbytes / (1024 * 1024), + self._convert_grayscale_to_rgb, ) - # Set error to stop recording gracefully - with self._stats_lock: - self._encode_error = ValueError( - f"Frame size changed from (h={expected_h}, w={expected_w}) to (h={actual_h}, w={actual_w})" + + # Check if frame size matches expected size + if self._frame_size is not None: + expected_h, expected_w = self._frame_size + actual_h, actual_w = frame.shape[:2] + if (actual_h, actual_w) != (expected_h, expected_w): + logger.warning( + f"Frame size mismatch: expected (h={expected_h}, w={expected_w}), " + f"got (h={actual_h}, w={actual_w}). " + "Stopping recorder to prevent encoding errors." ) - return False + with self._stats_lock: + self._encode_error = ValueError( + f"Frame size changed from (h={expected_h}, w={expected_w}) to (h={actual_h}, w={actual_w})" + ) + self._process_timing.note_error() + self._process_timing.maybe_log() + return False try: - q.put((frame, timestamp), block=False) + with self._process_timing.measure("Recorder.queue_put"): + q.put((frame, timestamp), block=False) except queue.Full: with self._stats_lock: self._dropped_frames += 1 @@ -191,9 +226,16 @@ def write(self, frame: np.ndarray, timestamp: float | None = None) -> bool: queue_size, self._buffer_size, ) + self._process_timing.note_error() + self._process_timing.maybe_log() return False + with self._stats_lock: self._frames_enqueued += 1 + + self._process_timing.note_frame() + self._process_timing.maybe_log() + return True def stop(self) -> None: @@ -315,12 +357,17 @@ def _writer_loop(self) -> None: writer = self._writer if writer is None: raise RuntimeError("WriteGear writer is not initialized") - writer.write(frame) + + with self._writer_timing.measure("Recorder.writer_write"): + writer.write(frame) + except Exception as exc: with self._stats_lock: self._encode_error = exc logger.exception("Video encoding failed while writing frame", exc_info=exc) self._stop_event.set() + self._process_timing.note_error() + self._process_timing.maybe_log() break else: elapsed = time.perf_counter() - start @@ -335,6 +382,9 @@ def _writer_loop(self) -> None: self._compute_write_fps_locked() self._last_log_time = now + self._writer_timing.note_frame() + self._writer_timing.maybe_log() + finally: # Ensure queue accounting is correct for every item pulled from q try: From 526d42910f229d950cb7db3fff61c6f67144ccf3 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 15:00:15 -0500 Subject: [PATCH 071/133] Add preserve_mono camera option and UI support Introduce a preserve_mono capability and related properties to CameraBackend (actual_pixel_format, recommended_preserve_mono). Add a Preserve Mono checkbox to the camera config UI, persist/load its value, include it in probe detection logic (detect pixel format and apply recommended preserve_mono when supported), and treat changes to preserve_mono as restart-triggering. Update Basler static capabilities test to advertise preserve_mono support and add VideoRecorder tests to verify grayscale frames are preserved when requested and expanded by default. This enables preserving single-channel camera output to reduce bandwidth/overhead for monochrome cameras. --- dlclivegui/cameras/base.py | 9 ++++ .../gui/camera_config/camera_config_dialog.py | 33 ++++++++++++- dlclivegui/gui/camera_config/ui_blocks.py | 9 +++- tests/cameras/backends/test_basler_backend.py | 3 +- tests/services/test_video_recorder.py | 46 +++++++++++++++++++ 5 files changed, 96 insertions(+), 4 deletions(-) diff --git a/dlclivegui/cameras/base.py b/dlclivegui/cameras/base.py index fefedd1d5..f86f3d14b 100644 --- a/dlclivegui/cameras/base.py +++ b/dlclivegui/cameras/base.py @@ -68,6 +68,7 @@ class SupportLevel(str, Enum): "set_fps": SupportLevel.UNSUPPORTED, "set_exposure": SupportLevel.UNSUPPORTED, "set_gain": SupportLevel.UNSUPPORTED, + "preserve_mono": SupportLevel.UNSUPPORTED, "device_discovery": SupportLevel.UNSUPPORTED, "stable_identity": SupportLevel.UNSUPPORTED, "hardware_trigger": SupportLevel.UNSUPPORTED, @@ -98,6 +99,14 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: """Return a dict describing supported features for UI purposes.""" return DEFAULT_CAPABILITIES + @property + def actual_pixel_format(self) -> str | None: + return None + + @property + def recommended_preserve_mono(self) -> bool | None: + return None + @classmethod def options_key(cls) -> str: """Return the key used to store this backend's options in CameraSettings.""" diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index d8defb45e..e16df6e1d 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -359,6 +359,7 @@ def _mark_dirty(*_args): self.cam_rotation.currentIndexChanged.connect(lambda *_: _mark_dirty()) self.cam_enabled_checkbox.stateChanged.connect(lambda *_: _mark_dirty()) + self.cam_preserve_mono_checkbox.stateChanged.connect(lambda *_: _mark_dirty()) # ------------------------------- # UI state updates @@ -529,6 +530,9 @@ 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") + # Output format / preserve mono + apply(self.cam_preserve_mono_checkbox, "preserve_mono", "Preserve mono output") + # Hardware trigger / sync apply(self.trigger_settings_btn, "hardware_trigger", "Hardware trigger") @@ -959,6 +963,7 @@ def _build_model_from_form(self, base: CameraSettings) -> CameraSettings: "crop_y0": int(self.cam_crop_y0.value()), "crop_x1": int(self.cam_crop_x1.value()), "crop_y1": int(self.cam_crop_y1.value()), + "preserve_mono": bool(self.cam_preserve_mono_checkbox.isChecked()), } ) # Validate and coerce; if invalid, Pydantic will raise @@ -977,6 +982,7 @@ def _load_camera_to_form(self, cam: CameraSettings) -> None: self.cam_crop_y0, self.cam_crop_x1, self.cam_crop_y1, + self.cam_preserve_mono_checkbox, ] for widget in block: if hasattr(widget, "blockSignals"): @@ -991,6 +997,7 @@ def _load_camera_to_form(self, cam: CameraSettings) -> None: self.cam_index_label.setText(str(cam.index)) self.cam_backend_label.setText(cam.backend) self._update_controls_for_backend(cam.backend) + self.cam_preserve_mono_checkbox.setChecked(bool(getattr(cam, "preserve_mono", False))) self.cam_width.setValue(cam.width) self.cam_height.setValue(cam.height) self.cam_fps.setValue(cam.fps) @@ -1026,6 +1033,7 @@ def _write_form_to_cam(self, cam: CameraSettings) -> None: 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()) + cam.preserve_mono = bool(self.cam_preserve_mono_checkbox.isChecked()) def _commit_pending_edits(self, *, reason: str = "") -> bool: """ @@ -1196,6 +1204,7 @@ def _clear_settings_form(self) -> None: self.cam_crop_y0.setValue(0) self.cam_crop_x1.setValue(0) self.cam_crop_y1.setValue(0) + self.cam_preserve_mono_checkbox.setChecked(False) self.apply_settings_btn.setEnabled(False) self.reset_settings_btn.setEnabled(False) @@ -1394,6 +1403,8 @@ def _on_probe_success(self, payload) -> None: actual_res = getattr(be, "actual_resolution", None) actual_fps = getattr(be, "actual_fps", None) + actual_pixel_format = getattr(be, "actual_pixel_format", None) + recommended_preserve_mono = getattr(be, "recommended_preserve_mono", None) try: be.close() @@ -1421,7 +1432,23 @@ def _on_probe_success(self, payload) -> None: if isinstance(actual_fps, (int, float)) and float(actual_fps) > 0: ns["detected_fps"] = float(actual_fps) - self._append_status(f"[Probe] actual_res={actual_res}, actual_fps={actual_fps}") + + if actual_pixel_format: + ns["detected_pixel_format"] = str(actual_pixel_format) + self._append_status(f"[Probe] PixelFormat={actual_pixel_format}") + + if recommended_preserve_mono is not None: + ns["recommended_preserve_mono"] = bool(recommended_preserve_mono) + + # ---- Generic capability-driven recommendation ---- + caps = CameraFactory.backend_capabilities(backend) + preserve_mono_cap = caps.get("preserve_mono") + preserve_mono_supported = preserve_mono_cap is not None and preserve_mono_cap.value != "unsupported" + + if preserve_mono_supported and recommended_preserve_mono is True: + if not bool(getattr(c, "preserve_mono", False)): + c.preserve_mono = True + self._append_status("[Probe] Mono pixel format detected; enabled Preserve mono frames.") # ---- Apply detected -> requested (Reset behavior) ---- if self._probe_apply_to_requested and self._probe_target_row == i: @@ -1450,7 +1477,9 @@ def _on_probe_success(self, payload) -> None: # Always refresh detected labels if currently selected if self._current_edit_index == i: + self._load_camera_to_form(c) self._set_detected_labels(c) + break except Exception as exc: @@ -1678,7 +1707,7 @@ def _should_restart_preview(self, old: CameraSettings, new: CameraSettings) -> b Backend-agnostic for now (no OpenCV special casing). """ # Restart on these changes - for key in ("width", "height", "fps", "exposure", "gain"): + for key in ("width", "height", "fps", "exposure", "gain", "preserve_mono"): try: if getattr(old, key, None) != getattr(new, key, None): return True diff --git a/dlclivegui/gui/camera_config/ui_blocks.py b/dlclivegui/gui/camera_config/ui_blocks.py index 07a8025e3..90d17d025 100644 --- a/dlclivegui/gui/camera_config/ui_blocks.py +++ b/dlclivegui/gui/camera_config/ui_blocks.py @@ -276,7 +276,14 @@ def build_settings_group(dlg: CameraConfigDialog) -> QGroupBox: ) dlg.settings_form.addRow(detected_row) - # --- Requested resolution controls (Auto = 0) --- + # --- Requested resolution/output format controls (Auto = 0) --- + dlg.cam_preserve_mono_checkbox = QCheckBox("Preserve Mono output") + dlg.cam_preserve_mono_checkbox.setToolTip( + "For monochrome cameras, keep frames as single-channel Mono8 instead of converting to BGR. " + "This reduces memory bandwidth and recording overhead. Display/overlay/DLC may convert later if needed." + ) + dlg.settings_form.addRow(dlg.cam_preserve_mono_checkbox) + dlg.cam_width = QSpinBox() dlg.cam_width.setRange(0, 10000) dlg.cam_width.setValue(0) diff --git a/tests/cameras/backends/test_basler_backend.py b/tests/cameras/backends/test_basler_backend.py index 64496b948..18f49a11b 100644 --- a/tests/cameras/backends/test_basler_backend.py +++ b/tests/cameras/backends/test_basler_backend.py @@ -232,7 +232,7 @@ def test_basler_exposure_gain_fps_are_applied_when_nonzero( # --------------------------------------------------------------------- -def test_basler_static_capabilities_advertises_hardware_trigger_best_effort( +def test_basler_static_capabilities_advertises_hardware_trigger_best_effort_and_mono( patch_basler_sdk, ): import dlclivegui.cameras.backends.basler_backend as bb @@ -240,6 +240,7 @@ def test_basler_static_capabilities_advertises_hardware_trigger_best_effort( caps = bb.BaslerCameraBackend.static_capabilities() assert caps["hardware_trigger"] == SupportLevel.BEST_EFFORT + assert caps["preserve_mono"] == SupportLevel.SUPPORTED def test_basler_default_trigger_is_off_and_free_runs( diff --git a/tests/services/test_video_recorder.py b/tests/services/test_video_recorder.py index 28bb85646..efde6e2b9 100644 --- a/tests/services/test_video_recorder.py +++ b/tests/services/test_video_recorder.py @@ -372,3 +372,49 @@ def test_stop_timeout_marks_abandoned_and_prevents_restart( assert rec._abandoned is False rec.start() rec.stop() + + +def test_video_recorder_preserves_gray_when_requested(monkeypatch, tmp_path): + written = [] + + class FakeWriter: + def write(self, frame): + written.append(frame) + + def close(self): + pass + + monkeypatch.setattr("dlclivegui.services.video_recorder.WriteGear", lambda *a, **k: FakeWriter()) + + rec = vr_mod.VideoRecorder( + tmp_path / "out.mp4", + frame_size=(10, 20), + frame_rate=100, + convert_grayscale_to_rgb=False, + ) + rec.start() + rec.write(np.zeros((10, 20), dtype=np.uint8)) + rec.stop() + + assert written + assert written[0].shape == (10, 20) + + +def test_video_recorder_expands_gray_by_default(monkeypatch, tmp_path): + written = [] + + class FakeWriter: + def write(self, frame): + written.append(frame) + + def close(self): + pass + + monkeypatch.setattr("dlclivegui.services.video_recorder.WriteGear", lambda *a, **k: FakeWriter()) + + rec = vr_mod.VideoRecorder(tmp_path / "out.mp4", frame_size=(10, 20), frame_rate=100) + rec.start() + rec.write(np.zeros((10, 20), dtype=np.uint8)) + rec.stop() + + assert written[0].shape == (10, 20, 3) From 0649093353cedb5c7a38e8286e9ef950eaa4c2a6 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 15:00:44 -0500 Subject: [PATCH 072/133] Add actual_pixel_format and preserve_mono support Expose the camera source pixel format via actual_pixel_format and add recommended_preserve_mono to suggest preserving mono images when the source format starts with "Mono". Add "preserve_mono" to reported capability levels. Implement _read_source_pixel_format to centralize reading the PixelFormat feature and call it from _configure_converter when needed so the backend always knows the source format before configuring conversion. --- dlclivegui/cameras/backends/basler_backend.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index cbc1916bb..3f9be5c78 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -143,6 +143,16 @@ def actual_exposure(self) -> float | None: def actual_gain(self) -> float | None: return self._actual_gain + @property + def actual_pixel_format(self) -> str | None: + return self._source_pixel_format + + @property + def recommended_preserve_mono(self) -> bool | None: + if not self._source_pixel_format: + return None + return self._source_pixel_format.startswith("Mono") + @classmethod def is_available(cls) -> bool: return pylon is not None @@ -159,6 +169,7 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "device_discovery": SupportLevel.BEST_EFFORT, "stable_identity": SupportLevel.SUPPORTED, "hardware_trigger": SupportLevel.BEST_EFFORT, + "preserve_mono": SupportLevel.SUPPORTED, } ) return caps @@ -458,6 +469,10 @@ def _configure_frame_rate(self) -> None: except Exception: self._actual_fps = None + def _read_source_pixel_format(self) -> str: + pixel_format = self._feature_value(self._feature("PixelFormat"), "") + return str(pixel_format or "") + def _configure_converter(self) -> None: """Configure pypylon image converter. @@ -469,6 +484,9 @@ def _configure_converter(self) -> None: if self._camera is None: return + if not self._source_pixel_format: + self._read_source_pixel_format() + pixel_format = self._feature_value(self._feature("PixelFormat"), "") self._source_pixel_format = str(pixel_format or "") From aba6c3100696ae83d1396cb09885b13b510202d3 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 15:13:45 -0500 Subject: [PATCH 073/133] Basler: track camera pixel format and mono output Rename internal source pixel format to _camera_pixel_format and centralize pixel-format handling. Add actual_pixel_format and actual_output_format properties, plus helpers (_read_camera_pixel_format, _is_camera_mono, _should_output_mono) to determine if the camera is mono and whether the backend should output mono frames. Update _configure_converter to use these helpers (emit Mono8 when preserve_mono + mono camera), remove the _output_is_mono flag and the old _read_source_pixel_format, and improve log messages. Also ensure the camera pixel format is read during startup after gain detection. --- dlclivegui/cameras/backends/basler_backend.py | 64 +++++++++++-------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 3f9be5c78..0f54e97fb 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -48,8 +48,7 @@ def __init__(self, settings): self._preserve_mono: bool = bool( getattr(settings, "preserve_mono", False) or self.ns.get("preserve_mono", False) ) - self._output_is_mono: bool = False - self._source_pixel_format: str | None = None + self._camera_pixel_format: str | None = None self._logged_first_frame: bool = False # Optional fast-start hint for probe workers @@ -145,13 +144,21 @@ def actual_gain(self) -> float | None: @property def actual_pixel_format(self) -> str | None: - return self._source_pixel_format + """Camera/native pixel format reported by Basler, e.g. 'Mono8'.""" + return self._camera_pixel_format + + @property + def actual_output_format(self) -> str | None: + """Backend output frame format emitted to the app, e.g. 'Mono8' or 'BGR8'.""" + if not self._camera_pixel_format: + return None + return "Mono8" if self._should_output_mono() else "BGR8" @property def recommended_preserve_mono(self) -> bool | None: - if not self._source_pixel_format: + if not self._camera_pixel_format: return None - return self._source_pixel_format.startswith("Mono") + return self._is_camera_mono() @classmethod def is_available(cls) -> bool: @@ -196,6 +203,17 @@ def _ensure_mutable_ns(self) -> dict: self.settings.properties[self.OPTIONS_KEY] = ns return ns + def _read_camera_pixel_format(self) -> str: + pixel_format = self._feature_value(self._feature("PixelFormat"), "") + self._camera_pixel_format = str(pixel_format or "") + return self._camera_pixel_format + + def _is_camera_mono(self) -> bool: + return bool(self._camera_pixel_format and self._camera_pixel_format.startswith("Mono")) + + def _should_output_mono(self) -> bool: + return bool(self._preserve_mono and self._is_camera_mono()) + @classmethod def _enumerate_devices_cls(cls): """Enumerate DeviceInfo entries (unit-testable via monkeypatch).""" @@ -469,46 +487,34 @@ def _configure_frame_rate(self) -> None: except Exception: self._actual_fps = None - def _read_source_pixel_format(self) -> str: - pixel_format = self._feature_value(self._feature("PixelFormat"), "") - return str(pixel_format or "") - def _configure_converter(self) -> None: """Configure pypylon image converter. Default behavior remains BGR8 for compatibility. - If properties.basler.preserve_mono=true and the source PixelFormat is Mono*, - return Mono8 frames as 2D arrays to avoid 3x BGR expansion in the grab thread. + If preserve_mono=True and the camera PixelFormat is Mono*, + return Mono8 frames as 2D arrays to avoid 3x BGR expansion. """ if self._camera is None: return - if not self._source_pixel_format: - self._read_source_pixel_format() - - pixel_format = self._feature_value(self._feature("PixelFormat"), "") - self._source_pixel_format = str(pixel_format or "") + camera_pixel_format = self._camera_pixel_format or self._read_camera_pixel_format() self._converter = pylon.ImageFormatConverter() self._converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned - is_mono_source = self._source_pixel_format.startswith("Mono") - - if self._preserve_mono and is_mono_source: + if self._should_output_mono(): self._converter.OutputPixelFormat = pylon.PixelType_Mono8 - self._output_is_mono = True LOG.info( - "[Basler] Converter configured for Mono8 output (source PixelFormat=%s preserve_mono=%s)", - self._source_pixel_format, + "[Basler] Converter configured for Mono8 output (camera PixelFormat=%s preserve_mono=%s)", + camera_pixel_format, self._preserve_mono, ) else: self._converter.OutputPixelFormat = pylon.PixelType_BGR8packed - self._output_is_mono = False LOG.info( - "[Basler] Converter configured for BGR8 output (source PixelFormat=%s preserve_mono=%s)", - self._source_pixel_format, + "[Basler] Converter configured for BGR8 output (camera PixelFormat=%s preserve_mono=%s)", + camera_pixel_format, self._preserve_mono, ) @@ -597,6 +603,8 @@ def open(self) -> None: except Exception: self._actual_gain = None + self._read_camera_pixel_format() + # ---------------------------- # Start acquisition (skip for fast probe) # ---------------------------- @@ -689,13 +697,13 @@ def read(self) -> tuple[np.ndarray, float]: self._logged_first_frame = True LOG.info( "[Basler] first frame device_id=%s shape=%s dtype=%s nbytes=%.2f MB " - "source_pixel_format=%s output_is_mono=%s, preserve_mono=%s", + "camera_pixel_format=%s output_format=%s preserve_mono=%s", self._device_id, frame.shape, frame.dtype, frame.nbytes / (1024 * 1024), - self._source_pixel_format, - self._output_is_mono, + self._camera_pixel_format, + self.actual_output_format, self._preserve_mono, ) From 1b5a8af1af9d14133f592bcc48599deb3fbc40b0 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 15:28:52 -0500 Subject: [PATCH 074/133] Show detected output format & mono option Add UI and backend support for reporting the camera backend's detected output format and pixel format. Introduce a detected output label with tooltip and move/rename the "preserve mono" checkbox into an Output row. Store/clear detected_output_format and detected_pixel_format in camera props, read actual_output_format from backends during probing, and set detected_output_format when pixel format indicates Mono. Add a mono indicator to camera list entries and update probe early-return logic to require both resolution and output format before skipping probing. --- .../gui/camera_config/camera_config_dialog.py | 41 ++++++++++++++++--- dlclivegui/gui/camera_config/ui_blocks.py | 31 ++++++++++---- 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index e16df6e1d..617f80166 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -424,6 +424,8 @@ def _set_detected_labels(self, cam: CameraSettings) -> None: det_res = ns.get("detected_resolution") det_fps = ns.get("detected_fps") + det_output_format = ns.get("detected_output_format") + det_pixel_format = ns.get("detected_pixel_format") if isinstance(det_res, (list, tuple)) and len(det_res) == 2: try: @@ -439,6 +441,18 @@ def _set_detected_labels(self, cam: CameraSettings) -> None: else: self.detected_fps_label.setText("—") + self.detected_output_format_label.setText(str(det_output_format) if det_output_format else "—") + + tooltip_parts = [] + if det_output_format: + tooltip_parts.append(f"Backend output: {det_output_format}") + if det_pixel_format: + tooltip_parts.append(f"Camera PixelFormat: {det_pixel_format}") + + self.detected_output_format_label.setToolTip( + "\n".join(tooltip_parts) if tooltip_parts else "Backend-reported output frame format emitted to the app." + ) + def _refresh_camera_labels(self) -> None: cam_list = getattr(self, "active_cameras_list", None) if not cam_list: @@ -467,11 +481,12 @@ def _format_camera_label(self, cam: CameraSettings, index: int = -1) -> str: status = "✓" if cam.enabled else "○" this_id = f"{(cam.backend or '').lower()}:{cam.index}" dlc_indicator = " [DLC]" if this_id == self._dlc_camera_id and cam.enabled else "" + mono_indicator = " [Mono]" if getattr(cam, "preserve_mono", False) else "" trigger_role = self._trigger_role_for_label(cam) trigger_indicator = "" if trigger_role in {"off", "disabled"} else f" [{trigger_role}]" - return f"{status} {cam.name} [{cam.backend}:{cam.index}]{trigger_indicator}{dlc_indicator}" + return f"{status} {cam.name} [{cam.backend}:{cam.index}]{trigger_indicator}{dlc_indicator}{mono_indicator}" def _selected_detected_camera(self) -> DetectedCamera | None: row = self.available_cameras_list.currentRow() @@ -1194,6 +1209,8 @@ def _clear_settings_form(self) -> None: self.cam_backend_label.setText("") self.detected_resolution_label.setText("—") self.detected_fps_label.setText("—") + self.detected_output_format_label.setText("—") + self.detected_output_format_label.setToolTip("Backend-reported output frame format emitted to the app.") self.cam_width.setValue(0) self.cam_height.setValue(0) self.cam_fps.setValue(0.0) @@ -1301,6 +1318,8 @@ def _reset_selected_camera(self, *, clear_backend_cache: bool = False) -> None: else: ns.pop("detected_resolution", None) ns.pop("detected_fps", None) + ns.pop("detected_pixel_format", None) + ns.pop("detected_output_format", None) ns.pop("last_applied_resolution", None) # Update UI immediately to show "Auto" while probing @@ -1372,12 +1391,16 @@ def _start_probe_for_camera(self, cam: CameraSettings, *, apply_to_requested: bo ns = props.get(backend, {}) if isinstance(props.get(backend, None), dict) else {} if not apply_to_requested: det_res = ns.get("detected_resolution") + det_output = ns.get("detected_output_format") + has_res = False 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 + has_res = int(det_res[0]) > 0 and int(det_res[1]) > 0 except Exception: - pass + has_res = False + + if has_res and det_output: + return # Start probe worker (settings will be opened in GUI thread for safety) self._probe_worker = CameraProbeWorker(cam, self) @@ -1404,6 +1427,7 @@ def _on_probe_success(self, payload) -> None: actual_res = getattr(be, "actual_resolution", None) actual_fps = getattr(be, "actual_fps", None) actual_pixel_format = getattr(be, "actual_pixel_format", None) + actual_output_format = getattr(be, "actual_output_format", None) recommended_preserve_mono = getattr(be, "recommended_preserve_mono", None) try: @@ -1427,8 +1451,6 @@ def _on_probe_success(self, payload) -> None: # Store regardless of "set_*" support. This is just "what device reports". if actual_res and isinstance(actual_res, (list, tuple)) and len(actual_res) == 2: ns["detected_resolution"] = [int(actual_res[0]), int(actual_res[1])] - elif actual_res and isinstance(actual_res, tuple) and len(actual_res) == 2: - ns["detected_resolution"] = [int(actual_res[0]), int(actual_res[1])] if isinstance(actual_fps, (int, float)) and float(actual_fps) > 0: ns["detected_fps"] = float(actual_fps) @@ -1437,6 +1459,10 @@ def _on_probe_success(self, payload) -> None: ns["detected_pixel_format"] = str(actual_pixel_format) self._append_status(f"[Probe] PixelFormat={actual_pixel_format}") + if actual_output_format: + ns["detected_output_format"] = str(actual_output_format) + self._append_status(f"[Probe] OutputFormat={actual_output_format}") + if recommended_preserve_mono is not None: ns["recommended_preserve_mono"] = bool(recommended_preserve_mono) @@ -1450,6 +1476,9 @@ def _on_probe_success(self, payload) -> None: c.preserve_mono = True self._append_status("[Probe] Mono pixel format detected; enabled Preserve mono frames.") + if actual_pixel_format and str(actual_pixel_format).startswith("Mono"): + ns["detected_output_format"] = "Mono8" + # ---- Apply detected -> requested (Reset behavior) ---- if self._probe_apply_to_requested and self._probe_target_row == i: # Only apply resolution if we actually got it diff --git a/dlclivegui/gui/camera_config/ui_blocks.py b/dlclivegui/gui/camera_config/ui_blocks.py index 90d17d025..9c8d40dd2 100644 --- a/dlclivegui/gui/camera_config/ui_blocks.py +++ b/dlclivegui/gui/camera_config/ui_blocks.py @@ -277,13 +277,6 @@ def build_settings_group(dlg: CameraConfigDialog) -> QGroupBox: dlg.settings_form.addRow(detected_row) # --- Requested resolution/output format controls (Auto = 0) --- - dlg.cam_preserve_mono_checkbox = QCheckBox("Preserve Mono output") - dlg.cam_preserve_mono_checkbox.setToolTip( - "For monochrome cameras, keep frames as single-channel Mono8 instead of converting to BGR. " - "This reduces memory bandwidth and recording overhead. Display/overlay/DLC may convert later if needed." - ) - dlg.settings_form.addRow(dlg.cam_preserve_mono_checkbox) - dlg.cam_width = QSpinBox() dlg.cam_width.setRange(0, 10000) dlg.cam_width.setValue(0) @@ -297,6 +290,30 @@ def build_settings_group(dlg: CameraConfigDialog) -> QGroupBox: res_row = make_two_field_row("W", dlg.cam_width, "H", dlg.cam_height, key_width=30) dlg.settings_form.addRow("Resolution:", res_row) + # --- Output format controls --- + dlg.cam_preserve_mono_checkbox = QCheckBox("Preserve mono output") + dlg.cam_preserve_mono_checkbox.setToolTip( + "For monochrome cameras, keep frames as single-channel Mono8 instead of converting to BGR. " + "This reduces memory bandwidth and recording overhead. Display/overlay/DLC may convert later if needed." + ) + + dlg.detected_output_format_label = QLabel("—") + dlg.detected_output_format_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + dlg.detected_output_format_label.setToolTip( + "Backend-reported output frame format emitted to the app, for example Mono8 or BGR8." + ) + + output_widget = QWidget() + output_layout = QHBoxLayout(output_widget) + output_layout.setContentsMargins(0, 0, 0, 0) + output_layout.setSpacing(8) + output_layout.addWidget(dlg.cam_preserve_mono_checkbox) + output_layout.addStretch(1) + output_layout.addWidget(QLabel("Detected output:")) + output_layout.addWidget(dlg.detected_output_format_label) + + dlg.settings_form.addRow("Output:", output_widget) + # --- FPS + Rotation grouped --- dlg.cam_fps = QDoubleSpinBox() dlg.cam_fps.setRange(0.0, 240.0) From a4345b1d8347b650eb348214d0385e2d36f4736a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 16:30:25 -0500 Subject: [PATCH 075/133] Track actual pixel/output formats in camera backends Expose actual_pixel_format and actual_output_format across backends and track the camera-reported formats for UI/telemetry. Aravis: add actual_pixel_format/actual_output_format properties and record _camera_pixel_format when setting pixel format. GenTL: initialize _camera_pixel_format/_actual_output_format, add _output_format_for_frame to infer output format from numpy frames, populate _actual_output_format on read, and record detected camera pixel format in several places. OpenCV: add actual_pixel_format (None) and actual_output_format (BGR8). These changes provide a consistent way to report native and emitted pixel formats to callers. --- dlclivegui/cameras/backends/aravis_backend.py | 12 ++++++ dlclivegui/cameras/backends/gentl_backend.py | 38 +++++++++++++++++++ dlclivegui/cameras/backends/opencv_backend.py | 10 +++++ 3 files changed, 60 insertions(+) diff --git a/dlclivegui/cameras/backends/aravis_backend.py b/dlclivegui/cameras/backends/aravis_backend.py index b437c3c3f..a8ee67c13 100644 --- a/dlclivegui/cameras/backends/aravis_backend.py +++ b/dlclivegui/cameras/backends/aravis_backend.py @@ -69,6 +69,16 @@ def actual_fps(self) -> float | None: """Return the actual frame rate of the camera after opening.""" return self._actual_fps + @property + def actual_pixel_format(self) -> str | None: + """Camera/native pixel format requested/reported for Aravis.""" + return self._camera_pixel_format or self._pixel_format + + @property + def actual_output_format(self) -> str | None: + """Current Aravis backend emits BGR uint8 frames.""" + return self._actual_output_format or "BGR8" + @classmethod def is_available(cls) -> bool: """Check if Aravis is available on this system.""" @@ -615,10 +625,12 @@ def _configure_pixel_format(self) -> None: if self._pixel_format in format_map: self._camera.set_pixel_format(format_map[self._pixel_format]) + self._camera_pixel_format = self._pixel_format LOG.info(f"Pixel format set to '{self._pixel_format}'") else: # Try setting as string self._camera.set_pixel_format_from_string(self._pixel_format) + self._camera_pixel_format = self._pixel_format LOG.info(f"Pixel format set to '{self._pixel_format}' (from string)") except Exception as e: LOG.warning(f"Failed to set pixel format '{self._pixel_format}': {e}") diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 26d5c2f03..ae04dbf0f 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -99,6 +99,9 @@ def __init__(self, settings): self._pixel_format: str = ns.get("pixel_format") or props.get("pixel_format", "auto") self._pixel_format = str(self._pixel_format).strip() + self._camera_pixel_format: str | None = None + self._actual_output_format: str | None = None + 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"))) @@ -176,6 +179,16 @@ def actual_exposure(self) -> float | None: def actual_gain(self) -> float | None: return self._actual_gain + @property + def actual_pixel_format(self) -> str | None: + """Camera/native pixel format selected on the GenICam PixelFormat node.""" + return self._camera_pixel_format or (self._pixel_format if self._pixel_format != "auto" else None) + + @property + def actual_output_format(self) -> str | None: + """Current GenTL backend emits OpenCV-native BGR uint8 frames.""" + return self._actual_output_format or "BGR8" + @classmethod def is_available(cls) -> bool: return Harvester is not None @@ -587,6 +600,21 @@ def waits_for_hardware_trigger(self) -> bool: role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() return role in {"external", "follower"} + @staticmethod + def _output_format_for_frame(frame: np.ndarray) -> str: + if frame.ndim == 2: + if frame.dtype == np.uint8: + return "Mono8" + return f"Mono{frame.dtype}" + if frame.ndim == 3: + channels = frame.shape[2] + if channels == 3 and frame.dtype == np.uint8: + return "BGR8" + if channels == 4 and frame.dtype == np.uint8: + return "BGRA8" + return f"{channels}ch-{frame.dtype}" + return str(frame.dtype) + def read(self) -> tuple[np.ndarray, float]: if self._acquirer is None: raise RuntimeError("GenTL image acquirer not initialised") @@ -625,6 +653,7 @@ def read(self) -> tuple[np.ndarray, float]: self._read_telemetry(self._acquirer.remote_device.node_map) except Exception: pass + self._actual_output_format = self._output_format_for_frame(frame) return frame, timestamp @@ -1312,11 +1341,14 @@ def _configure_pixel_format(self, node_map) -> None: pixel_format_node.value = selected self._pixel_format = str(pixel_format_node.value) + self._actual_pixel_format = self._pixel_format LOG.debug("GenTL pixel format selected: %s", self._pixel_format) except Exception as e: LOG.warning("Failed to configure pixel format '%s': %s", self._pixel_format, e) + if self._pixel_format and self._pixel_format.lower() != "auto": + self._camera_pixel_format = self._pixel_format def _configure_trigger(self, node_map) -> None: cfg = self._trigger @@ -1783,7 +1815,13 @@ def _read_telemetry(self, node_map) -> None: pixel_format = self._node_str(node_map, "PixelFormat") if pixel_format is not None: + self._camera_pixel_format = pixel_format ns["actual_pixel_format"] = pixel_format + ns["detected_pixel_format"] = pixel_format + + output_format = self.actual_output_format + if output_format is not None: + ns["actual_output_format"] = output_format except Exception: pass diff --git a/dlclivegui/cameras/backends/opencv_backend.py b/dlclivegui/cameras/backends/opencv_backend.py index 74fdede98..869dde448 100644 --- a/dlclivegui/cameras/backends/opencv_backend.py +++ b/dlclivegui/cameras/backends/opencv_backend.py @@ -254,6 +254,16 @@ def actual_gain(self) -> None: """Not supported by OpenCV backend.""" return None + @property + def actual_pixel_format(self) -> str | None: + """OpenCV does not reliably expose native camera pixel format.""" + return None + + @property + def actual_output_format(self) -> str | None: + """OpenCV VideoCapture returns BGR frames in this backend.""" + return "BGR8" + # ---------------------------- # Internal helpers # ---------------------------- From 7c189d2a939398c043d8c28cb1099ce1b9476918 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 16:35:04 -0500 Subject: [PATCH 076/133] Use make_two_field_row for Output row Replace the manual QWidget/QHBoxLayout construction for the "Output" settings row with the reusable make_two_field_row helper. This simplifies and standardizes the layout while preserving the same widgets (cam_preserve_mono_checkbox and detected_output_format_label) and applies key_width=60 and gap=40 before adding the row to dlg.settings_form. --- dlclivegui/gui/camera_config/ui_blocks.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/dlclivegui/gui/camera_config/ui_blocks.py b/dlclivegui/gui/camera_config/ui_blocks.py index 9c8d40dd2..4c912753b 100644 --- a/dlclivegui/gui/camera_config/ui_blocks.py +++ b/dlclivegui/gui/camera_config/ui_blocks.py @@ -303,16 +303,10 @@ def build_settings_group(dlg: CameraConfigDialog) -> QGroupBox: "Backend-reported output frame format emitted to the app, for example Mono8 or BGR8." ) - output_widget = QWidget() - output_layout = QHBoxLayout(output_widget) - output_layout.setContentsMargins(0, 0, 0, 0) - output_layout.setSpacing(8) - output_layout.addWidget(dlg.cam_preserve_mono_checkbox) - output_layout.addStretch(1) - output_layout.addWidget(QLabel("Detected output:")) - output_layout.addWidget(dlg.detected_output_format_label) - - dlg.settings_form.addRow("Output:", output_widget) + output_row = make_two_field_row( + None, dlg.cam_preserve_mono_checkbox, "Detected:", dlg.detected_output_format_label, key_width=60, gap=40 + ) + dlg.settings_form.addRow("Output:", output_row) # --- FPS + Rotation grouped --- dlg.cam_fps = QDoubleSpinBox() From fa1365b7688b076e8a68b6b63169de26a937536d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 16:35:27 -0500 Subject: [PATCH 077/133] Use display labels for multi-camera GUI Add human-friendly display IDs for multi-camera support. Introduce get_display_id(settings) which prefers settings.name, then properties[backend].device_name, and falls back to backend:index. Main window now stores per-camera display IDs, clears them on stop, passes labels to create_tiled_frame, and uses the display label when building the compact camera status lines. This separates internal camera IDs from user-facing labels for clearer UI. --- dlclivegui/gui/main_window.py | 14 ++++++++++---- dlclivegui/services/multi_camera_controller.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index b6f1d9999..5a36ad9ad 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -65,7 +65,7 @@ scan_processor_package, ) from ..services.dlc_processor import DLCLiveProcessor, PoseResult -from ..services.multi_camera_controller import MultiCameraController, MultiFrameData, get_camera_id +from ..services.multi_camera_controller import MultiCameraController, MultiFrameData, get_camera_id, get_display_id from ..utils.display import BBoxColors, compute_tile_info, create_tiled_frame, draw_bbox, draw_pose from ..utils.settings_store import DLCLiveGUISettingsStore, ModelPathStore from ..utils.stats import format_dlc_stats @@ -164,6 +164,7 @@ def __init__(self, config: ApplicationSettings | None = None): # Multi-camera state self._multi_camera_mode = False self._multi_camera_frames: dict[str, np.ndarray] = {} + self._multi_camera_display_ids: dict[str, str] = {} # camera_id -> display_id (for labeling) # DLC pose rendering info for tiled view self._dlc_tile_offset: tuple[int, int] = (0, 0) # (x, y) offset in tiled frame self._dlc_tile_scale: tuple[float, float] = (1.0, 1.0) # (scale_x, scale_y) @@ -1379,6 +1380,7 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: 2. Recording (queued writes, non-blocking) """ self._multi_camera_frames = frame_data.frames + self._multi_camera_display_ids = frame_data.display_ids or {} src_id = frame_data.source_camera_id if src_id: self._fps_tracker.note_frame(src_id) # Track FPS @@ -1439,6 +1441,7 @@ def _on_multi_frame_display_ready(self, frame_data: MultiFrameData) -> None: Called at GUI_MAX_DISPLAY_FPS, not at camera capture FPS for performance reasons. """ self._multi_camera_frames = frame_data.frames + self._multi_camera_display_ids = frame_data.display_ids or {} self._display_dirty = True def _on_multi_camera_started(self) -> None: @@ -1459,6 +1462,7 @@ def _on_multi_camera_stopped(self) -> None: self.stop_preview_button.setEnabled(False) self._current_frame = None self._multi_camera_frames.clear() + self._multi_camera_display_ids.clear() self.video_label.setPixmap(QPixmap()) self.video_label.setText("Camera preview not started") self.statusBar().showMessage("Multi-camera preview stopped", 3000) @@ -1593,6 +1597,7 @@ def _start_preview(self) -> None: self._raw_frame = None self._last_pose = None self._multi_camera_frames.clear() + self._multi_camera_display_ids.clear() self._fps_tracker.clear() self._last_display_time = 0.0 @@ -1735,7 +1740,7 @@ def _update_display_from_pending(self) -> None: self._display_dirty = False # Create tiled frame on demand (moved from camera thread for performance) - tiled = create_tiled_frame(self._multi_camera_frames) + tiled = create_tiled_frame(self._multi_camera_frames, labels=self._multi_camera_display_ids) if tiled is not None: self._current_frame = tiled self._update_video_display(tiled) @@ -1751,10 +1756,11 @@ def _update_metrics(self) -> None: active_cams = self._config.multi_camera.get_active_cameras() lines = [] for cam in active_cams: - cam_id = get_camera_id(cam) # e.g., "opencv:0" or "pylon:1" + cam_id = get_camera_id(cam) + display_id = get_display_id(cam) fps = self._fps_tracker.fps(cam_id) # Make a compact label: name [backend:index] @ fps - label = f"{cam.name or cam_id} [{cam.backend}:{cam.index}]" + label = f"{display_id} [{cam.backend}:{cam.index}]" if fps > 0: lines.append(f"{label} @ {fps:.1f} fps") else: diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index aa5aaad6b..219738e4c 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -207,6 +207,22 @@ def _log_trigger_wait_throttled(self, exc: BaseException) -> None: def get_display_id(settings: CameraSettings) -> str: + """Return the human-friendly camera label used for GUI display. + Intentionally different from get_camera_id(), which should return a stable + internal, reliable and unambiguous identity and may contain serials or machine paths. + """ + name = str(getattr(settings, "name", "") or "").strip() + if name: + return name + + backend = (settings.backend or "").lower() + props = settings.properties if isinstance(settings.properties, dict) else {} + ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {} + + device_name = str(ns.get("device_name", "") or "").strip() + if device_name: + return device_name + return f"{settings.backend}:{settings.index}" From 6e28c30bd9db14d74c76eda0247d4473c5094dd9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 16:36:15 -0500 Subject: [PATCH 078/133] Update tests: display_id naming and fallback Adjust tests to reflect new human-friendly display_id values and add a fallback case. Updated expectations in tests to assert display_id equals "GenTL cam" / "GenTL Cam" / "C1" where applicable, and added a unit test to verify get_display_id falls back to the backend index (e.g. "gentl:3") when camera name is empty. Also added assertions in the controller test to ensure the stable camera id is present in frames and correctly mapped to the display id. Files changed: tests/gui/test_rec_manager.py, tests/services/test_multicam_controller.py. --- tests/gui/test_rec_manager.py | 2 +- tests/services/test_multicam_controller.py | 24 +++++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/tests/gui/test_rec_manager.py b/tests/gui/test_rec_manager.py index aa8b187c5..b3654a231 100644 --- a/tests/gui/test_rec_manager.py +++ b/tests/gui/test_rec_manager.py @@ -306,7 +306,7 @@ def test_recording_manager_uses_stable_camera_id_not_display_id( display_id = get_display_id(cam) assert stable_id == "gentl:serial:SER0" - assert display_id == "gentl:0" + assert display_id == "GenTL cam" assert stable_id != display_id frame = np.zeros((480, 640, 3), dtype=np.uint8) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index e5bf60934..855b01a1f 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -157,7 +157,7 @@ def test_get_display_id_is_human_index_label(): ).apply_defaults() assert get_camera_id(cam) == "gentl:serial:30220469" - assert get_display_id(cam) == "gentl:3" + assert get_display_id(cam) == "GenTL Cam" assert get_camera_id(cam) != get_display_id(cam) @@ -193,6 +193,23 @@ def test_trigger_role_from_settings_aliases(role, expected): assert _trigger_role_from_settings(cam) == expected +@pytest.mark.unit +def test_get_display_id_falls_back_to_backend_index_without_name(): + cam = CameraSettings( + name="", + backend="gentl", + index=3, + properties={ + "gentl": { + "device_id": "serial:30220469", + "serial_number": "30220469", + } + }, + ).apply_defaults() + + assert get_display_id(cam) == "gentl:3" + + @pytest.mark.unit def test_camera_start_priority_orders_trigger_roles(): external = CameraSettings( @@ -330,9 +347,8 @@ def test_controller_uses_stable_camera_id_not_display_id(qtbot, patch_factory): display_id = get_display_id(cam) assert stable_id == "gentl:serial:SER0" - assert display_id == "gentl:0" + assert display_id == "C1" assert stable_id != display_id - seen = [] def on_ready(mfd): @@ -348,6 +364,8 @@ def on_ready(mfd): mfd = seen[-1] + assert stable_id in mfd.frames + assert mfd.display_ids[stable_id] == "C1" assert mfd.source_camera_id == stable_id assert stable_id in mfd.frames assert stable_id in mfd.timestamps From 1856e96efcf8e541db8a4e613059f1842c1fa93a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 16:36:32 -0500 Subject: [PATCH 079/133] Remove tiled frame generation and accessors Delete the internal _create_tiled_frame implementation and the public frame accessors (get_frame, get_all_frames, get_tiled_frame) from MultiCameraController. This removes the tiled canvas construction logic and convenience getters for retrieving camera frames; update any callers to use the controller's new/alternate APIs or access frames via the updated code paths. --- .../services/multi_camera_controller.py | 109 ------------------ 1 file changed, 109 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 219738e4c..96f2ef3cb 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -677,98 +677,6 @@ def to_display_pixmap(frame: np.ndarray) -> QPixmap: 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. - - The tiled frame is scaled to fit within a maximum canvas size - while maintaining aspect ratio of individual camera frames. - """ - if not self._frames: - return np.zeros((480, 640, 3), dtype=np.uint8) - - frames_list = [self._frames[idx] for idx in sorted(self._frames.keys())] - num_frames = len(frames_list) - - if num_frames == 0: - return np.zeros((480, 640, 3), dtype=np.uint8) - - # Determine grid layout - if num_frames == 1: - rows, cols = 1, 1 - elif num_frames == 2: - rows, cols = 1, 2 - elif num_frames <= 4: - rows, cols = 2, 2 - else: - rows, cols = 2, 2 # Limit to 4 - - # Maximum canvas size to fit on screen (leaving room for UI elements) - max_canvas_width = 1200 - max_canvas_height = 800 - - # Calculate tile size based on frame aspect ratio and available space - first_frame = frames_list[0] - frame_h, frame_w = first_frame.shape[:2] - frame_aspect = frame_w / frame_h if frame_h > 0 else 1.0 - - # Calculate tile dimensions that fit within the canvas - tile_w = max_canvas_width // cols - tile_h = max_canvas_height // rows - - # Maintain aspect ratio of original frames - tile_aspect = tile_w / tile_h if tile_h > 0 else 1.0 - - if frame_aspect > tile_aspect: - # Frame is wider than tile slot - constrain by width - tile_h = int(tile_w / frame_aspect) - else: - # Frame is taller than tile slot - constrain by height - tile_w = int(tile_h * frame_aspect) - - # Ensure minimum size - tile_w = max(160, tile_w) - tile_h = max(120, tile_h) - - # Create canvas - canvas = np.zeros((rows * tile_h, cols * tile_w, 3), dtype=np.uint8) - - # Get sorted camera IDs for consistent ordering - cam_ids = sorted(self._frames.keys()) - frames_list = [self._frames[cam_id] for cam_id in cam_ids] - - # Place each frame in the grid - for idx, frame in enumerate(frames_list[: rows * cols]): - row = idx // cols - col = idx % cols - - # Ensure frame is 3-channel - frame = MultiCameraController.ensure_color_bgr(frame) - - # Resize to tile size - resized = MultiCameraController.apply_resize(frame, tile_w, tile_h, allow_upscale=True) - - # Add camera ID label - if idx < len(cam_ids): - label = cam_ids[idx] - cv2.putText( - resized, - label, - (10, 30), - cv2.FONT_HERSHEY_SIMPLEX, - 0.7, - (0, 255, 0), - 2, - ) - - # Place in canvas - y_start = row * tile_h - y_end = y_start + tile_h - x_start = col * tile_w - x_end = x_start + tile_w - canvas[y_start:y_end, x_start:x_end] = resized - - return canvas - def _on_camera_started(self, camera_id: str) -> None: """Handle camera start event.""" self._started_cameras.add(camera_id) @@ -831,20 +739,3 @@ def _on_camera_error(self, camera_id: str, message: str) -> None: if camera_id not in self._started_cameras: self._failed_cameras[camera_id] = message self.camera_error.emit(camera_id, message) - - def get_frame(self, camera_id: str) -> np.ndarray | None: - """Get the latest frame from a specific camera.""" - with self._frame_lock: - return self._frames.get(camera_id) - - def get_all_frames(self) -> dict[str, np.ndarray]: - """Get the latest frames from all cameras.""" - with self._frame_lock: - return dict(self._frames) - - def get_tiled_frame(self) -> np.ndarray | None: - """Get a tiled view of all camera frames.""" - with self._frame_lock: - if self._frames: - return self._create_tiled_frame() - return None From 4629c08f6b9969c26c1f70ce13c954ec3aee7d11 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 17:20:34 -0500 Subject: [PATCH 080/133] Use runtime FPS for recording; add fallbacks Collect and propagate runtime camera info to improve recorder FPS selection and logging. - Disabled some verbose timing flags in config (REC_DO_LOG_TIMING, BASLER_DO_LOG_TIMING). - MultiCameraController: added runtime_info signal, stores per-camera runtime info, logs it, and exposes actual_fps_by_camera_id(). Workers emit backend runtime properties on open. - MainWindow: pass actual_fps_by_camera to RecordingManager when starting recordings. - RecordingManager: added backend namespace helper and _resolve_recording_fps(cam, cam_id, frame_rates) to prefer measured FPS, then backend-detected FPS, then requested cam.fps (or auto). Use resolved recorder_fps when creating VideoRecorder and log chosen values. - VideoRecorder: if frame_rate is missing/zero, fall back to 30 FPS and emit a warning; added startup info log; removed/commented the old pix_fmt/output_dimensions branch. These changes make recording frame rates more accurate by preferring runtime-measured FPS and provide clearer logging and safe fallbacks when FPS is unknown. --- dlclivegui/config.py | 4 +- dlclivegui/gui/main_window.py | 2 + dlclivegui/gui/recording_manager.py | 63 ++++++++++++++++++- .../services/multi_camera_controller.py | 42 +++++++++++++ dlclivegui/services/video_recorder.py | 41 +++++++++--- 5 files changed, 139 insertions(+), 13 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 4db739754..6b3afacb7 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -26,10 +26,10 @@ ### Timing logs SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False -REC_DO_LOG_TIMING: bool = True +REC_DO_LOG_TIMING: bool = False # MAIN_WINDOW_DO_LOG_TIMING: bool = False #### Backends -BASLER_DO_LOG_TIMING: bool = True +BASLER_DO_LOG_TIMING: bool = False class CameraSettings(BaseModel): diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 5a36ad9ad..9609b5a67 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1500,11 +1500,13 @@ def _start_multi_camera_recording(self) -> None: session_name = self.session_name_edit.text().strip() if hasattr(self, "session_name_edit") else "" use_ts = self.use_timestamp_checkbox.isChecked() if hasattr(self, "use_timestamp_checkbox") else True + actual_fps_by_camera = self.multi_camera_controller.actual_fps_by_camera_id() run_dir = self._rec_manager.start_all( recording, active_cams, self._multi_camera_frames, + frame_rates=actual_fps_by_camera, session_name=session_name, use_timestamp=use_ts, all_or_nothing=False, diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index 51f79552a..d12b19ed3 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -39,6 +39,55 @@ def session_dir(self) -> Path | None: def run_dir(self) -> Path | None: return self._run_dir + @staticmethod + def _backend_ns(cam: CameraSettings) -> dict: + backend = (cam.backend or "").lower() + props = cam.properties if isinstance(cam.properties, dict) else {} + ns = props.get(backend, {}) + return ns if isinstance(ns, dict) else {} + + @classmethod + def _resolve_recording_fps( + cls, + cam: CameraSettings, + cam_id: str, + frame_rates: dict[str, float] | None, + ) -> float | None: + """Resolve writer FPS. + + Prefer runtime measured FPS, then backend-probed detected_fps, + then explicit requested cam.fps. Auto/unknown returns None. + """ + measured_fps = 0.0 + if frame_rates: + try: + measured_fps = float(frame_rates.get(cam_id, 0.0) or 0.0) + except Exception: + measured_fps = 0.0 + + if measured_fps > 0.0: + return measured_fps + + ns = cls._backend_ns(cam) + + try: + detected_fps = float(ns.get("detected_fps", 0.0) or 0.0) + except Exception: + detected_fps = 0.0 + + if detected_fps > 0.0: + return detected_fps + + try: + requested_fps = float(getattr(cam, "fps", 0.0) or 0.0) + except Exception: + requested_fps = 0.0 + + if requested_fps > 0.0: + return requested_fps + + return None + def pop(self, cam_id: str, default=None) -> VideoRecorder | None: return self._recorders.pop(cam_id, default) @@ -48,6 +97,7 @@ def start_all( active_cams: list[CameraSettings], current_frames: dict[str, np.ndarray], *, + frame_rates: dict[str, float] | None = None, session_name: str = "session", use_timestamp: bool = True, all_or_nothing: bool = False, @@ -97,11 +147,22 @@ def start_all( frame = current_frames.get(cam_id) frame_size = (frame.shape[0], frame.shape[1]) if frame is not None else None + recorder_fps = self._resolve_recording_fps(cam, cam_id, frame_rates) + + log.debug( + "Starting recorder %s -> %s frame_size=%s requested_fps=%s detected_fps=%s recorder_fps=%s", + cam_id, + cam_path, + frame_size, + getattr(cam, "fps", None), + self._backend_ns(cam).get("detected_fps"), + f"{recorder_fps:.3f}" if recorder_fps else "auto/fallback", + ) recorder = VideoRecorder( cam_path, frame_size=frame_size, - frame_rate=float(cam.fps), + frame_rate=recorder_fps, codec=recording.codec, crf=recording.crf, convert_grayscale_to_rgb=not bool(getattr(cam, "preserve_mono", False)), diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 96f2ef3cb..5ccf33c14 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -49,6 +49,7 @@ class SingleCameraWorker(QObject): frame_captured = Signal(str, object, float) # camera_id, frame, timestamp error_occurred = Signal(str, str) # camera_id, error_message + runtime_info = Signal(str, object) # camera_id, dict of runtime info started = Signal(str) # camera_id stopped = Signal(str) # camera_id @@ -95,6 +96,15 @@ def run(self) -> None: ) self._backend.open() + self.runtime_info.emit( + self._camera_id, + { + "actual_fps": getattr(self._backend, "actual_fps", None), + "actual_resolution": getattr(self._backend, "actual_resolution", None), + "actual_pixel_format": getattr(self._backend, "actual_pixel_format", None), + "actual_output_format": getattr(self._backend, "actual_output_format", None), + }, + ) except Exception as exc: LOGGER.exception(f"Failed to initialize camera {self._camera_id}", exc_info=exc) self.error_occurred.emit(self._camera_id, f"Failed to initialize camera: {exc}") @@ -303,6 +313,7 @@ def __init__(self): self._workers: dict[str, SingleCameraWorker] = {} self._threads: dict[str, QThread] = {} self._settings: dict[str, CameraSettings] = {} + self._runtime_info: dict[str, dict] = {} self._frames: dict[str, np.ndarray] = {} self._timestamps: dict[str, float] = {} self._frame_lock = Lock() @@ -437,6 +448,7 @@ def _start_camera(self, settings: CameraSettings) -> None: # Connections unchanged thread.started.connect(worker.run) + worker.runtime_info.connect(self._on_camera_runtime_info) worker.frame_captured.connect(self._on_frame_captured) worker.started.connect(self._on_camera_started) worker.stopped.connect(self._on_camera_stopped) @@ -605,6 +617,36 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float timing.note_frame() timing.maybe_log() + def _on_camera_runtime_info(self, camera_id: str, info: object) -> None: + if not isinstance(info, dict): + return + + self._runtime_info[camera_id] = dict(info) + + actual_fps = info.get("actual_fps") + LOGGER.info( + "Camera %s runtime info: actual_fps=%s actual_resolution=%s pixel_format=%s output_format=%s", + camera_id, + actual_fps, + info.get("actual_resolution"), + info.get("actual_pixel_format"), + info.get("actual_output_format"), + ) + + def actual_fps_by_camera_id(self) -> dict[str, float]: + out: dict[str, float] = {} + + for camera_id, info in self._runtime_info.items(): + try: + fps = float(info.get("actual_fps") or 0.0) + except Exception: + fps = 0.0 + + if fps > 0.0: + out[camera_id] = fps + + return out + @staticmethod def apply_rotation(frame: np.ndarray, degrees: int) -> np.ndarray: """Apply rotation to frame.""" diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index c92eb23cf..37ffe3dd4 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -109,7 +109,28 @@ def start(self) -> None: self._queue = None self._writer_thread = None - fps_value = float(self._frame_rate) if self._frame_rate else 30.0 + if self._frame_rate and float(self._frame_rate) > 0.0: + fps_value = float(self._frame_rate) + else: + fps_value = 30.0 + logger.warning( + "VideoRecorder frame_rate missing/zero for %s; falling back to %.3f FPS. " + "Video playback duration may not match capture timestamps.", + self._output.name, + fps_value, + ) + + logger.info( + "Starting VideoRecorder output=%s frame_size=%s frame_rate=%.3f " + "codec=%s crf=%s buffer_size=%s convert_grayscale_to_rgb=%s", + self._output, + self._frame_size, + fps_value, + self._codec, + self._crf, + self._buffer_size, + self._convert_grayscale_to_rgb, + ) writer_kwargs: dict[str, Any] = { "compression_mode": True, @@ -118,15 +139,15 @@ def start(self) -> None: "-vcodec": (self._codec or "libx264").strip() or "libx264", "-crf": int(self._crf), } - if not self._convert_grayscale_to_rgb: - writer_kwargs.update( - { - "-pix_fmt": "yuv420p", - } - ) - if self._frame_size is not None: - h, w = self._frame_size - writer_kwargs["-output_dimensions"] = (int(w), int(h)) + # if not self._convert_grayscale_to_rgb: + # writer_kwargs.update( + # { + # "-pix_fmt": "yuv420p", + # } + # ) + # if self._frame_size is not None: + # h, w = self._frame_size + # writer_kwargs["-output_dimensions"] = (int(w), int(h)) self._output.parent.mkdir(parents=True, exist_ok=True) self._writer = WriteGear(output=str(self._output), **writer_kwargs) From 3a0fe1168f2f9df3bbf8c1d1a5f6e6a28025e107 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 17:20:48 -0500 Subject: [PATCH 081/133] Track encoding errors with writer_timing Fix the video encoding error path in VideoRecorder by replacing _process_timing with _writer_timing so encoding failures are recorded and logged against the correct timing object. This ensures error timing and maybe_log are invoked on the writer timing tracker rather than the wrong object. --- dlclivegui/services/video_recorder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index 37ffe3dd4..76bfc1d16 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -387,8 +387,8 @@ def _writer_loop(self) -> None: self._encode_error = exc logger.exception("Video encoding failed while writing frame", exc_info=exc) self._stop_event.set() - self._process_timing.note_error() - self._process_timing.maybe_log() + self._writer_timing.note_error() + self._writer_timing.maybe_log() break else: elapsed = time.perf_counter() - start From dc04406f8a4bdc00242fbdff80a19d8958e088da Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 17:22:32 -0500 Subject: [PATCH 082/133] Use _camera_pixel_format in camera backends Add _camera_pixel_format and _actual_output_format attributes to the Aravis backend and update the GenTL backend to set _camera_pixel_format (replacing the previous _actual_pixel_format assignment). This unifies pixel-format state handling across camera backends and prepares for explicit output format tracking. --- dlclivegui/cameras/backends/aravis_backend.py | 2 ++ dlclivegui/cameras/backends/gentl_backend.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/dlclivegui/cameras/backends/aravis_backend.py b/dlclivegui/cameras/backends/aravis_backend.py index a8ee67c13..60059c464 100644 --- a/dlclivegui/cameras/backends/aravis_backend.py +++ b/dlclivegui/cameras/backends/aravis_backend.py @@ -52,6 +52,8 @@ def __init__(self, settings): self._actual_width: int | None = None self._actual_height: int | None = None self._actual_fps: float | None = None + self._camera_pixel_format: str | None = None + self._actual_output_format: str | None = None self._camera = None self._stream = None diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index ae04dbf0f..a433fb1b6 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1341,7 +1341,7 @@ def _configure_pixel_format(self, node_map) -> None: pixel_format_node.value = selected self._pixel_format = str(pixel_format_node.value) - self._actual_pixel_format = self._pixel_format + self._camera_pixel_format = self._pixel_format LOG.debug("GenTL pixel format selected: %s", self._pixel_format) From 39151ed899dcd8c3b65c162bc024441c79f34faa Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 17:22:46 -0500 Subject: [PATCH 083/133] Add TYPE_CHECKING in stats --- dlclivegui/utils/stats.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dlclivegui/utils/stats.py b/dlclivegui/utils/stats.py index acc83a9a4..3a00c02c6 100644 --- a/dlclivegui/utils/stats.py +++ b/dlclivegui/utils/stats.py @@ -4,8 +4,10 @@ import logging import time from dataclasses import dataclass +from typing import TYPE_CHECKING -from dlclivegui.services.dlc_processor import ProcessorStats +if TYPE_CHECKING: + from dlclivegui.services.dlc_processor import ProcessorStats @dataclass From af770434d4fecdd6a5667bd703a0bf0efd30253e Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:21:28 -0500 Subject: [PATCH 084/133] Use one-by-one Basler grab strategy Switch Basler camera startup to `pylon.GrabStrategy_OneByOne` instead of `LatestImageOnly`, and update the nearby identity-persistence comment for clarity. --- dlclivegui/cameras/backends/basler_backend.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 0f54e97fb..e2ea513e9 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -627,7 +627,8 @@ def open(self) -> None: pass self._camera.StartGrabbing( - pylon.GrabStrategy_LatestImageOnly, + # pylon.GrabStrategy_LatestImageOnly, + pylon.GrabStrategy_OneByOne, ) LOG.info( "[Basler] grabbing=%s max_buffers=%s", @@ -650,7 +651,7 @@ def open(self) -> None: ) # ---------------------------- - # Persist stable identity into namespace (migration-safe) + # Persist stable identity into namespace # ---------------------------- try: serial = device.GetSerialNumber() From a5d6dccb0afa3fc13fcb84fad88683a8c8484909 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:21:42 -0500 Subject: [PATCH 085/133] Ignore profiling output artifacts Add ignore patterns for generated profiling files (`profile*.svg`, `scalene*.json`, and `scalene*.html`) so local performance analysis outputs are not accidentally committed. --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 7c5b18d80..1782ab32c 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,8 @@ venv.bak/ !dlclivegui/config.py # uv package files uv.lock + +# profiling +profile*.svg +scalene*.json +scalene*.html From 5420f585014fbb23eeb39878e64aab864161a7f4 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:21:55 -0500 Subject: [PATCH 086/133] Add profiling extra with Scalene Introduce a new `profiling` optional dependency group in `pyproject.toml` and include `scalene` so profiling tools can be installed independently from test and framework extras. --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 265d9530c..da48c7820 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,9 @@ test = [ "tox", "tox-gh-actions", ] +profiling = [ + "scalene", +] tf = [ "deeplabcut-live[tf]>=1.1", ] From 5ffb1b0ba4f488acace2b0ddfa46514babe67d51 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:24:16 -0500 Subject: [PATCH 087/133] Optimize recording pipeline and encoder options Adds a dedicated full-rate `recording_frame_ready` signal path so recording is decoupled from the inference/display frame flow, reducing processing overhead during capture. The GUI now exposes a fast-encoding toggle and persists it into recording settings, and recorder startup passes codec-specific writer options through to `VideoRecorder`. Recording telemetry was expanded to report enqueued vs written frames, writer FPS, queue fill against buffer size, backlog, and drops, improving visibility into recording throughput and pressure. --- dlclivegui/gui/main_window.py | 66 ++++++++++++++--- dlclivegui/gui/recording_manager.py | 27 ++++++- .../services/multi_camera_controller.py | 18 ++++- dlclivegui/services/video_recorder.py | 71 +++++++++++++++++-- 4 files changed, 163 insertions(+), 19 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 9609b5a67..dfa64f610 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -632,13 +632,29 @@ def _build_recording_group(self) -> QGroupBox: form.addRow(grid) - # Record with overlays + # Recording options self.record_with_overlays_checkbox = QCheckBox("Record video with overlays") self.record_with_overlays_checkbox.setToolTip( "Enable to include pose overlays in recorded video (keypoints & bounding boxes)" ) self.record_with_overlays_checkbox.setChecked(False) - form.addRow(self.record_with_overlays_checkbox) + + self.fast_encoding_checkbox = QCheckBox("Use faster encoding parameters") + self.fast_encoding_checkbox.setToolTip( + "Use faster FFmpeg parameters for supported codecs.\n" + "For libx264/libx265 this uses preset=ultrafast and tune=zerolatency.\n" + "This can improve recording throughput but may increase file size." + ) + self.fast_encoding_checkbox.setChecked(False) + + recording_options = QWidget() + recording_options_layout = QHBoxLayout(recording_options) + recording_options_layout.setContentsMargins(0, 0, 0, 0) + recording_options_layout.addWidget(self.record_with_overlays_checkbox) + recording_options_layout.addWidget(self.fast_encoding_checkbox) + recording_options_layout.addStretch(1) + + form.addRow(recording_options) # Wrap recording buttons in a widget to prevent shifting recording_button_widget = QWidget() @@ -771,6 +787,7 @@ def _connect_signals(self) -> None: # Multi-camera controller signals (used for both single and multi-camera modes) self.multi_camera_controller.frame_ready.connect(self._on_multi_frame_processing_ready) self.multi_camera_controller.display_ready.connect(self._on_multi_frame_display_ready) + self.multi_camera_controller.recording_frame_ready.connect(self._on_recording_frame_ready) self.multi_camera_controller.all_started.connect(self._on_multi_camera_started) self.multi_camera_controller.all_stopped.connect(self._on_multi_camera_stopped) self.multi_camera_controller.camera_error.connect(self._on_multi_camera_error) @@ -821,6 +838,10 @@ def _apply_config(self, config: ApplicationSettings) -> None: self.codec_combo.addItem(recording.codec) self.codec_combo.setCurrentIndex(self.codec_combo.count() - 1) self.crf_spin.setValue(int(recording.crf)) + + if hasattr(self, "fast_encoding_checkbox"): + self.fast_encoding_checkbox.setChecked(bool(getattr(recording, "fast_encoding", False))) + ## Restore persisted session name if empty if hasattr(self, "session_name_edit"): if not self.session_name_edit.text().strip(): @@ -931,6 +952,9 @@ def _recording_settings_from_ui(self) -> RecordingSettings: container=self.container_combo.currentText().strip() or "mp4", codec=self.codec_combo.currentText().strip() or "libx264", crf=int(self.crf_spin.value()), + fast_encoding=bool( + getattr(self, "fast_encoding_checkbox", None) and self.fast_encoding_checkbox.isChecked() + ), ) def _bbox_settings_from_ui(self) -> BoundingBoxSettings: @@ -1372,6 +1396,24 @@ def _render_overlays_for_recording(self, cam_id, frame): ) return output + def _on_recording_frame_ready(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: + """Handle full-rate per-camera frames for recording only. + + Intentionally lean: + - no MultiFrameData processing + - no DLC routing + - no display state updates + - no FPS tracker + - optional overlays only if user requested recording overlays + """ + if not self._rec_manager.is_active: + return + + if self.record_with_overlays_checkbox.isChecked(): + frame = self._render_overlays_for_recording(camera_id, frame) + + self._rec_manager.write_frame(camera_id, frame, timestamp) + def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: """Handle frames from multiple cameras. @@ -1425,15 +1467,15 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: self._dlc.enqueue_frame(frame, timestamp) # PRIORITY 2: Recording (queued, non-blocking) - if self._rec_manager.is_active and src_id in frame_data.frames: - frame = frame_data.frames[src_id] + # if self._rec_manager.is_active and src_id in frame_data.frames: + # frame = frame_data.frames[src_id] - if self.record_with_overlays_checkbox.isChecked(): - # Draw overlays for recording - frame = self._render_overlays_for_recording(src_id, frame) + # if self.record_with_overlays_checkbox.isChecked(): + # # Draw overlays for recording + # frame = self._render_overlays_for_recording(src_id, frame) - ts = frame_data.timestamps.get(src_id, time.time()) - self._rec_manager.write_frame(src_id, frame, ts) + # ts = frame_data.timestamps.get(src_id, time.time()) + # self._rec_manager.write_frame(src_id, frame, ts) def _on_multi_frame_display_ready(self, frame_data: MultiFrameData) -> None: """Throttled UI/display path. @@ -1514,6 +1556,7 @@ def _start_multi_camera_recording(self) -> None: if run_dir is None: self._show_error("Failed to start recording.") return + self.multi_camera_controller.set_recording_frame_do_emit(True) self._settings_store.set_session_name(session_name) self.start_record_button.setEnabled(False) @@ -1524,6 +1567,9 @@ def _start_multi_camera_recording(self) -> None: def _stop_multi_camera_recording(self) -> None: if not self._rec_manager.is_active: return + + self.multi_camera_controller.set_recording_frame_do_emit(False) + self._rec_manager.stop_all() self.start_record_button.setEnabled(True) self.stop_record_button.setEnabled(False) @@ -1715,6 +1761,8 @@ def _update_camera_controls_enabled(self) -> None: recording_editable = not multi_cam_recording self.codec_combo.setEnabled(recording_editable) self.crf_spin.setEnabled(recording_editable) + if hasattr(self, "fast_encoding_checkbox"): + self.fast_encoding_checkbox.setEnabled(recording_editable) # Config cameras button should be available when not in preview/recording self.config_cameras_button.setEnabled(allow_changes) diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index d12b19ed3..f3509ac65 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -148,15 +148,19 @@ def start_all( frame = current_frames.get(cam_id) frame_size = (frame.shape[0], frame.shape[1]) if frame is not None else None recorder_fps = self._resolve_recording_fps(cam, cam_id, frame_rates) + writer_options = recording.writegear_options(recorder_fps) log.debug( - "Starting recorder %s -> %s frame_size=%s requested_fps=%s detected_fps=%s recorder_fps=%s", + "Starting recorder %s -> %s frame_size=%s requested_fps=%s detected_fps=%s " + "recorder_fps=%s fast_encoding=%s writer_options=%s", cam_id, cam_path, frame_size, getattr(cam, "fps", None), self._backend_ns(cam).get("detected_fps"), f"{recorder_fps:.3f}" if recorder_fps else "auto/fallback", + bool(getattr(recording, "fast_encoding", False)), + writer_options, ) recorder = VideoRecorder( @@ -166,6 +170,7 @@ def start_all( codec=recording.codec, crf=recording.crf, convert_grayscale_to_rgb=not bool(getattr(cam, "preserve_mono", False)), + writer_options=writer_options, ) try: recorder.start() @@ -213,9 +218,13 @@ def write_frame(self, cam_id: str, frame: np.ndarray, timestamp: float | None = def get_stats_summary(self) -> str: totals = { + "enqueued": 0, "written": 0, "dropped": 0, "queue": 0, + "buffer": 0, + "backlog": 0, + "write_fps": 0.0, "max_latency": 0.0, "avg_latencies": [], } @@ -223,9 +232,13 @@ def get_stats_summary(self) -> str: stats: RecorderStats | None = rec.get_stats() if not stats: continue + totals["enqueued"] += stats.frames_enqueued totals["written"] += stats.frames_written totals["dropped"] += stats.dropped_frames totals["queue"] += stats.queue_size + totals["buffer"] += stats.buffer_size + totals["backlog"] += stats.backlog_frames + totals["write_fps"] += stats.write_fps totals["max_latency"] = max(totals["max_latency"], stats.last_latency) totals["avg_latencies"].append(stats.average_latency) @@ -239,8 +252,16 @@ def get_stats_summary(self) -> str: return "Recording..." else: avg = sum(totals["avg_latencies"]) / len(totals["avg_latencies"]) if totals["avg_latencies"] else 0.0 + + buffer = totals["buffer"] + queue_text = f"{totals['queue']}/{buffer}" if buffer > 0 else str(totals["queue"]) + fill_pct = (100.0 * totals["queue"] / buffer) if buffer > 0 else 0.0 + return ( - f"{len(self._recorders)} cams | {totals['written']} frames | " + f"{len(self._recorders)} cams | {totals['written']}/{totals['enqueued']} frames | " + f"writer {totals['write_fps']:.1f} fps | " f"latency {totals['max_latency'] * 1000:.1f}ms (avg {avg * 1000:.1f}ms) | " - f"queue {totals['queue']} | dropped {totals['dropped']}" + f"queue {queue_text} ({fill_pct:.0f}%) | " + f"backlog {totals['backlog']} | " + f"dropped {totals['dropped']}" ) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 5ccf33c14..fe5b66990 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -297,7 +297,8 @@ class MultiCameraController(QObject): """Controller for managing multiple cameras simultaneously.""" # Signals - frame_ready = Signal(object) # MultiFrameData (full cam FPS; recording and inference only) + frame_ready = Signal(object) # MultiFrameData (full cam FPS; inference only) + recording_frame_ready = Signal(str, object, float) # camera_id, frame, timestamp (full cam FPS; for recording) display_ready = Signal(object) # MultiFrameData for GUI display (throttled to GUI_MAX_DISPLAY_FPS) camera_started = Signal(str, object) # camera_id, settings camera_stopped = Signal(str) # camera_id @@ -318,6 +319,7 @@ def __init__(self): self._timestamps: dict[str, float] = {} self._frame_lock = Lock() self._running = False + self._recording_frame_emission_enabled: bool = False self._started_cameras: set = set() self._camera_display_order: list[str] = [] self._display_ids: dict[str, str] = {} # camera_id -> display_id (for labeling) @@ -350,6 +352,14 @@ def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats: self._timing_per_cam[camera_id] = timing return timing + def set_recording_frame_do_emit(self, enabled: bool) -> None: + """Enable/disable the lightweight per-camera recording frame signal. + + This avoids sending recording-only traffic when the user is only previewing + or running DLC. + """ + self._recording_frame_emission_enabled = bool(enabled) + def _should_emit_display_ready(self) -> bool: """Return True when the UI/display path should be updated. @@ -416,6 +426,7 @@ def start(self, camera_settings: list[CameraSettings]) -> None: seen[key] = camera_id self._running = True + self._recording_frame_emission_enabled = False self._frames.clear() self._timestamps.clear() self._started_cameras.clear() @@ -481,6 +492,7 @@ def stop(self, wait: bool = True) -> None: return self._running = False + self._recording_frame_emission_enabled = False # Signal all workers to stop for worker in self._workers.values(): @@ -573,6 +585,10 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float if crop_region: frame = MultiCameraController.apply_crop(frame, crop_region) + if self._recording_frame_emission_enabled: + with timing.measure("Multi.emit.recording_frame_ready"): + self.recording_frame_ready.emit(camera_id, frame, timestamp) + with self._frame_lock: with timing.measure("Multi.store_latest"): self._frames[camera_id] = frame diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index 76bfc1d16..6c0afda3e 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -32,7 +32,52 @@ class VideoRecorder: - """Thin wrapper around :class:`vidgear.gears.WriteGear`.""" + """Asynchronous video recorder backed by VidGear/FFmpeg. + + `VideoRecorder` wraps VidGear's `WriteGear` writer with a bounded in-memory + queue and a dedicated writer thread. Calls to `write()` perform minimal frame + validation/preprocessing, enqueue accepted frames without blocking, and return + immediately. The writer thread consumes queued frames and writes them to disk, + while also recording timestamps for successfully written frames. + + The recorder is intended for high-throughput camera pipelines where frame + acquisition should not block on video encoding. If the internal queue fills, + incoming frames are dropped and counted in recorder statistics. Timestamp + sidecar files are written on `stop()` for frames that were actually written. + + Args: + output: Output video path. + frame_size: Expected frame size as `(height, width)`. If provided, + incoming frames with different dimensions are rejected and the + recorder enters an error state. + frame_rate: Output video frame rate. If missing or non-positive, the + recorder falls back to 30 FPS and logs a warning. + codec: FFmpeg video codec name passed to WriteGear, for example + `"libx264"`. + crf: Constant Rate Factor passed to compatible FFmpeg encoders. Lower + values generally increase quality and file size. + buffer_size: Maximum number of frames that may wait in the recorder + queue before new frames are dropped. + convert_grayscale_to_rgb: Whether 2D grayscale frames should be expanded + to 3-channel RGB before writing. Set to `False` to preserve mono + frames when supported by the chosen writer/codec path. + fast_encoding: Whether to apply faster FFmpeg encoder settings when + supported by the selected codec. This can improve throughput at the + cost of larger files and/or reduced compression efficiency. + + Attributes: + is_running: Whether the writer thread is currently alive. + + Raises: + RuntimeError: If VidGear is unavailable, if the recorder is abandoned + after a failed stop, or if a previous encoding error is detected + during `write()`. + + Notes: + This class does not guarantee that every submitted frame is written. + Frames may be dropped when the queue is full, and timestamps are only + saved for frames successfully consumed by the writer thread. + """ def __init__( self, @@ -43,6 +88,7 @@ def __init__( crf: int = 23, buffer_size: int = 240, convert_grayscale_to_rgb: bool = True, + writer_options: dict[str, Any] | None = None, ): # Config self._output = Path(output) @@ -53,6 +99,7 @@ def __init__( self._crf = int(crf) self._buffer_size = max(1, int(buffer_size)) self._convert_grayscale_to_rgb = bool(convert_grayscale_to_rgb) + self._writer_options = dict(writer_options) if writer_options is not None else None # Worker state self._queue: queue.Queue[Any] | None = None self._writer_thread: threading.Thread | None = None @@ -122,7 +169,7 @@ def start(self) -> None: logger.info( "Starting VideoRecorder output=%s frame_size=%s frame_rate=%.3f " - "codec=%s crf=%s buffer_size=%s convert_grayscale_to_rgb=%s", + "codec=%s crf=%s buffer_size=%s convert_grayscale_to_rgb=%s writer_options=%s", self._output, self._frame_size, fps_value, @@ -130,15 +177,26 @@ def start(self) -> None: self._crf, self._buffer_size, self._convert_grayscale_to_rgb, + self._writer_options, ) + codec_value = (self._codec or "libx264").strip() or "libx264" writer_kwargs: dict[str, Any] = { "compression_mode": True, "logging": False, - "-input_framerate": fps_value, - "-vcodec": (self._codec or "libx264").strip() or "libx264", - "-crf": int(self._crf), } + + if self._writer_options is not None: + writer_kwargs.update(self._writer_options) + else: + writer_kwargs.update( + { + "-input_framerate": fps_value, + "-vcodec": codec_value, + "-crf": int(self._crf), + } + ) + # if not self._convert_grayscale_to_rgb: # writer_kwargs.update( # { @@ -332,12 +390,13 @@ def get_stats(self) -> RecorderStats | None: avg_latency = self._total_latency / self._frames_written if self._frames_written else 0.0 last_latency = self._last_latency write_fps = self._compute_write_fps_locked() - buffer_seconds = queue_size * avg_latency if avg_latency > 0 else 0.0 + buffer_seconds = queue_size / write_fps if write_fps > 0 else 0.0 return RecorderStats( frames_enqueued=frames_enqueued, frames_written=frames_written, dropped_frames=dropped, queue_size=queue_size, + buffer_size=self._buffer_size, average_latency=avg_latency, last_latency=last_latency, write_fps=write_fps, From befe8819b5c9104233a0d8170ac1d59dd8feb1b3 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:25:31 -0500 Subject: [PATCH 088/133] Enable timing logs and enrich recorder stats Turns on timing logging for multi-camera worker, recorder, and Basler backend diagnostics. Recording settings now include a `fast_encoding` flag, with `writegear_options` made more robust for missing/invalid FPS and optional low-latency FFmpeg options (`ultrafast` + `zerolatency`) for x264/x265. Recorder stats were expanded with buffer capacity awareness (`buffer_size`), derived backlog/fill-ratio properties, and richer formatted output showing queue fill and backlog. --- dlclivegui/config.py | 44 ++++++++++++++++++++++++++++++++------- dlclivegui/utils/stats.py | 23 +++++++++++++++++++- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 6b3afacb7..c0befcb73 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -25,11 +25,11 @@ ## Debug ### Timing logs SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False -MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False -REC_DO_LOG_TIMING: bool = False +MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = True +REC_DO_LOG_TIMING: bool = True # MAIN_WINDOW_DO_LOG_TIMING: bool = False #### Backends -BASLER_DO_LOG_TIMING: bool = False +BASLER_DO_LOG_TIMING: bool = True class CameraSettings(BaseModel): @@ -515,6 +515,7 @@ class RecordingSettings(BaseModel): container: Literal["mp4", "avi", "mov"] = "mp4" codec: str = "libx264" crf: int = Field(default=23, ge=0, le=51) + fast_encoding: bool = False def output_path(self) -> Path: """Return the absolute output path for recordings.""" @@ -528,18 +529,47 @@ def output_path(self) -> Path: filename = name.with_suffix(f".{self.container}") return directory / filename - def writegear_options(self, fps: float) -> dict[str, Any]: - """Return compression parameters for WriteGear.""" + def writegear_options(self, fps: float | None) -> dict[str, Any]: + """Return FFmpeg/WriteGear compression parameters. + + The default settings prioritize compatibility and compression quality. If + ``fast_encoding`` is enabled, additional low-latency encoder options are + added for codecs that are known to support them. + + Args: + fps: Desired input frame rate. If missing or non-positive, falls back + to 30 FPS. + + Returns: + Dictionary of WriteGear/FFmpeg options. + """ + try: + fps_value = float(fps or 0.0) + except Exception: + fps_value = 0.0 + if fps_value <= 0.0: + fps_value = 30.0 - fps_value = float(fps) if fps else 30.0 codec_value = (self.codec or "libx264").strip() or "libx264" crf_value = int(self.crf) if self.crf is not None else 23 - return { + + opts: dict[str, Any] = { "-input_framerate": f"{fps_value:.6f}", "-vcodec": codec_value, "-crf": str(crf_value), } + if self.fast_encoding: + if codec_value in {"libx264", "libx265"}: + opts.update( + { + "-preset": "ultrafast", + "-tune": "zerolatency", + } + ) + + return opts + class ApplicationSettings(BaseModel): # optional: add a semantic version for migrations diff --git a/dlclivegui/utils/stats.py b/dlclivegui/utils/stats.py index 3a00c02c6..1edbf7890 100644 --- a/dlclivegui/utils/stats.py +++ b/dlclivegui/utils/stats.py @@ -18,11 +18,24 @@ class RecorderStats: frames_written: int = 0 dropped_frames: int = 0 queue_size: int = 0 + buffer_size: int = 0 average_latency: float = 0.0 last_latency: float = 0.0 write_fps: float = 0.0 buffer_seconds: float = 0.0 + @property + def backlog_frames(self) -> int: + """Frames accepted by recorder but not yet written.""" + return max(0, self.frames_enqueued - self.frames_written) + + @property + def queue_fill_ratio(self) -> float: + """Queue fill ratio in [0, 1], or 0 when capacity is unknown.""" + if self.buffer_size <= 0: + return 0.0 + return min(1.0, max(0.0, self.queue_size / self.buffer_size)) + class WorkerTimingStats: """Tiny timing accumulator for camera worker performance diagnostics. @@ -128,11 +141,19 @@ def format_recorder_stats(stats: RecorderStats) -> str: latency_ms = stats.last_latency * 1000.0 avg_ms = stats.average_latency * 1000.0 buffer_ms = stats.buffer_seconds * 1000.0 + + if stats.buffer_size > 0: + fill_pct = stats.queue_fill_ratio * 100.0 + queue_text = f"{stats.queue_size}/{stats.buffer_size} ({fill_pct:.0f}%, ~{buffer_ms:.0f} ms)" + else: + queue_text = f"{stats.queue_size} (~{buffer_ms:.0f} ms)" + return ( f"{stats.frames_written}/{stats.frames_enqueued} frames | " f"write {stats.write_fps:.1f} fps | " f"latency {latency_ms:.1f} ms (avg {avg_ms:.1f} ms) | " - f"queue {stats.queue_size} (~{buffer_ms:.0f} ms) | " + f"queue {queue_text} | " + f"backlog {stats.backlog_frames} | " f"dropped {stats.dropped_frames}" ) From faabf1a1aa9af4390d077b752d300a4fae6c724d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:26:34 -0500 Subject: [PATCH 089/133] Expand recording behavior test coverage Updates test fixtures and unit tests around recording flow changes: FakeVideoRecorder now mirrors new constructor/runtime fields, Basler fake includes one-by-one grab strategy, and GUI/controller tests validate recording-frame emission gating plus overlay recording via `_on_recording_frame_ready`. Tests also cover `RecordingSettings.writegear_options` (including fast x264 options and FPS fallback), RecordingManager writer option wiring, and richer recorder stats formatting/aggregation with backlog and queue capacity output. --- dlclivegui/cameras/backends/basler_backend.py | 4 +- tests/cameras/backends/conftest.py | 1 + tests/conftest.py | 19 +++++- tests/gui/test_pose_overlay.py | 13 +--- tests/gui/test_rec_manager.py | 54 +++++++++++++++-- tests/services/test_multicam_controller.py | 48 +++++++++++++++ tests/test_config.py | 50 +++++++++++++++- tests/utils/test_stats.py | 60 ++++++++++++------- 8 files changed, 208 insertions(+), 41 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index e2ea513e9..b1aff592b 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -30,6 +30,8 @@ except Exception: # pragma: no cover - optional dependency pylon = None # type: ignore +DEBUG_TRIGGER_LOGS = False + @register_backend("basler") class BaslerCameraBackend(CameraBackend): @@ -948,7 +950,7 @@ def _set_numeric_feature(self, name: str, value, *, strict: bool = False) -> boo return False def _debug_trigger_nodes(self, *, context: str = "") -> None: - if not LOG.isEnabledFor(logging.DEBUG): + if not LOG.isEnabledFor(logging.DEBUG) or not DEBUG_TRIGGER_LOGS: return names = ( diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index dfec64fe2..5bbcac31e 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -389,6 +389,7 @@ class FakePylon: """Fake for 'from pypylon import pylon' used by BaslerCameraBackend.""" GrabStrategy_LatestImageOnly = 1 + GrabStrategy_OneByOne = 2 TimeoutHandling_ThrowException = 1 PixelType_BGR8packed = 0x02180014 OutputBitAlignment_MsbAligned = 1 diff --git a/tests/conftest.py b/tests/conftest.py index 7d12a70a1..49cd1c66e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -349,12 +349,28 @@ def fake_processor(): class FakeVideoRecorder: """Lightweight test double for VideoRecorder (no threads/ffmpeg).""" - def __init__(self, output, frame_size=None, frame_rate=None, codec="libx264", crf=23, **kwargs): + def __init__( + self, + output, + frame_size=None, + frame_rate=None, + codec="libx264", + crf=23, + buffer_size=240, + convert_grayscale_to_rgb=True, + writer_options=None, + **kwargs, + ): self.output = Path(output) self.frame_size = frame_size self.frame_rate = frame_rate self.codec = codec self.crf = crf + self.buffer_size = buffer_size + self.convert_grayscale_to_rgb = convert_grayscale_to_rgb + self.writer_options = dict(writer_options) if writer_options is not None else None + self.extra_kwargs = dict(kwargs) + self.started = False self.stopped = False self.write_calls = [] @@ -370,6 +386,7 @@ def start(self): if self.raise_on_start: raise RuntimeError("start failed") self.started = True + self.stopped = False def stop(self): self.stopped = True diff --git a/tests/gui/test_pose_overlay.py b/tests/gui/test_pose_overlay.py index 3af35308f..511d44552 100644 --- a/tests/gui/test_pose_overlay.py +++ b/tests/gui/test_pose_overlay.py @@ -65,18 +65,9 @@ def test_record_overlay_toggle_affects_frames_sent_to_recorder(window, recording # Provide a frame raw = np.zeros((100, 100, 3), dtype=np.uint8) - # Build minimal frame_data to call _on_multi_frame_processing_ready - from dlclivegui.services.multi_camera_controller import MultiFrameData - - frame_data = MultiFrameData( - frames={cam_id: raw}, - timestamps={cam_id: 1.0}, - source_camera_id=cam_id, - ) - # 1) toggle OFF: should record raw window.record_with_overlays_checkbox.setChecked(False) - window._on_multi_frame_processing_ready(frame_data) + window._on_recording_frame_ready(cam_id, raw, 1.0) assert cam_id in recording_frame_spy recorded_off = recording_frame_spy[cam_id] @@ -84,7 +75,7 @@ def test_record_overlay_toggle_affects_frames_sent_to_recorder(window, recording # 2) toggle ON: should record overlay frame (different) window.record_with_overlays_checkbox.setChecked(True) - window._on_multi_frame_processing_ready(frame_data) + window._on_recording_frame_ready(cam_id, raw, 2.0) recorded_on = recording_frame_spy[cam_id] assert not np.array_equal(recorded_on, raw) diff --git a/tests/gui/test_rec_manager.py b/tests/gui/test_rec_manager.py index b3654a231..f97c43a57 100644 --- a/tests/gui/test_rec_manager.py +++ b/tests/gui/test_rec_manager.py @@ -266,18 +266,36 @@ def test_get_stats_summary_multi_aggregates( mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") ids = [get_camera_id(c) for c in _active_cams_two] + mgr.recorders[ids[0]]._stats = RecorderStats( - frames_written=10, dropped_frames=1, queue_size=2, average_latency=0.01, last_latency=0.02 + frames_enqueued=12, + frames_written=10, + dropped_frames=1, + queue_size=2, + buffer_size=10, + average_latency=0.01, + last_latency=0.02, + write_fps=25.0, ) mgr.recorders[ids[1]]._stats = RecorderStats( - frames_written=20, dropped_frames=3, queue_size=4, average_latency=0.03, last_latency=0.05 + frames_enqueued=24, + frames_written=20, + dropped_frames=3, + queue_size=4, + buffer_size=10, + average_latency=0.03, + last_latency=0.05, + write_fps=30.0, ) summary = mgr.get_stats_summary() + assert "2 cams" in summary - assert "30 frames" in summary # 10 + 20 - assert "dropped 4" in summary # 1 + 3 - assert "queue 6" in summary # 2 + 4 + assert "30/36 frames" in summary + assert "writer 55.0 fps" in summary + assert "dropped 4" in summary + assert "queue 6/20" in summary + assert "backlog 6" in summary @pytest.mark.unit @@ -378,3 +396,29 @@ def test_start_all_does_not_infer_frame_size_from_display_id( # Since RecordingManager uses stable IDs internally, it should not find this frame. rec = mgr.recorders[stable_id] assert rec.frame_size is None + + +@pytest.mark.unit +def test_start_all_passes_writegear_options( + recording_settings, + _active_cams_two, + current_frames, + patch_video_recorder, + patch_build_run_dir, +): + recording_settings.codec = "libx264" + recording_settings.crf = 23 + recording_settings.fast_encoding = True + + mgr = RecordingManager() + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") + + for cam in _active_cams_two: + cam_id = get_camera_id(cam) + rec = mgr.recorders[cam_id] + + assert rec.writer_options is not None + assert rec.writer_options["-vcodec"] == "libx264" + assert rec.writer_options["-crf"] == "23" + assert rec.writer_options["-preset"] == "ultrafast" + assert rec.writer_options["-tune"] == "zerolatency" diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 855b01a1f..747b5da42 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -500,3 +500,51 @@ def _create(settings): if mc.is_running(): with qtbot.waitSignal(mc.all_stopped, timeout=2000): mc.stop(wait=True) + + +@pytest.mark.unit +def test_recording_frame_ready_only_emits_when_enabled(qtbot, patch_factory): + mc = MultiCameraController() + + cam = CameraSettings( + name="C", + backend="opencv", + index=0, + enabled=True, + properties={"opencv": {"device_id": "cam-0"}}, + ).apply_defaults() + + cam_id = get_camera_id(cam) + seen: list[tuple[str, tuple, float]] = [] + + def on_recording_frame(camera_id, frame, timestamp): + seen.append((camera_id, frame.shape, timestamp)) + + mc.recording_frame_ready.connect(on_recording_frame) + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam]) + + # Disabled by default: should not emit recording frames. + qtbot.wait(300) + assert seen == [] + + mc.set_recording_frame_do_emit(True) + + qtbot.waitUntil(lambda: bool(seen), timeout=2000) + + camera_id, shape, timestamp = seen[-1] + assert camera_id == cam_id + assert isinstance(timestamp, float) + assert len(shape) in (2, 3) + + mc.set_recording_frame_do_emit(False) + count_after_disable = len(seen) + + qtbot.wait(300) + assert len(seen) == count_after_disable + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) diff --git a/tests/test_config.py b/tests/test_config.py index 9f82017ed..63b387bfb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,12 @@ import pytest -from dlclivegui.config import ApplicationSettings, CameraSettings, CameraTriggerSettings, MultiCameraSettings +from dlclivegui.config import ( + ApplicationSettings, + CameraSettings, + CameraTriggerSettings, + MultiCameraSettings, + RecordingSettings, +) @pytest.mark.unit @@ -41,3 +47,45 @@ def test_trigger_source_defaults_to_auto(): trigger = CameraTriggerSettings() assert trigger.source == "auto" + + +def test_recording_settings_writegear_options_default(): + settings = RecordingSettings(codec="libx264", crf=23, fast_encoding=False) + + opts = settings.writegear_options(100.0) + + assert opts["-input_framerate"] == "100.000000" + assert opts["-vcodec"] == "libx264" + assert opts["-crf"] == "23" + assert "-preset" not in opts + assert "-tune" not in opts + + +def test_recording_settings_writegear_options_fast_encoding_x264(): + settings = RecordingSettings(codec="libx264", crf=23, fast_encoding=True) + + opts = settings.writegear_options(100.0) + + assert opts["-input_framerate"] == "100.000000" + assert opts["-vcodec"] == "libx264" + assert opts["-crf"] == "23" + assert opts["-preset"] == "ultrafast" + assert opts["-tune"] == "zerolatency" + + +def test_recording_settings_writegear_options_fast_encoding_nvenc_no_x264_options(): + settings = RecordingSettings(codec="h264_nvenc", crf=23, fast_encoding=True) + + opts = settings.writegear_options(100.0) + + assert opts["-vcodec"] == "h264_nvenc" + assert "-preset" not in opts + assert "-tune" not in opts + + +def test_recording_settings_writegear_options_invalid_fps_falls_back_to_30(): + settings = RecordingSettings(codec="libx264", crf=23) + + opts = settings.writegear_options(None) + + assert opts["-input_framerate"] == "30.000000" diff --git a/tests/utils/test_stats.py b/tests/utils/test_stats.py index 1fa12400f..bd207cf9e 100644 --- a/tests/utils/test_stats.py +++ b/tests/utils/test_stats.py @@ -4,6 +4,7 @@ from hypothesis import given, settings from hypothesis import strategies as st +from dlclivegui.gui.recording_manager import RecorderStats from dlclivegui.utils.stats import format_dlc_stats, format_recorder_stats pytestmark = pytest.mark.unit @@ -14,19 +15,20 @@ def test_format_recorder_stats_exact(): - stats = SimpleNamespace( + stats = RecorderStats( frames_written=10, frames_enqueued=12, write_fps=29.94, - last_latency=0.01234, # 12.34 ms -> 12.3 - average_latency=0.05678, # 56.78 ms -> 56.8 - buffer_seconds=0.4321, # 432.1 ms -> 432 + last_latency=0.01234, + average_latency=0.05678, + buffer_seconds=0.4321, queue_size=3, + buffer_size=0, dropped_frames=2, ) assert format_recorder_stats(stats) == ( - "10/12 frames | write 29.9 fps | latency 12.3 ms (avg 56.8 ms) | queue 3 (~432 ms) | dropped 2" + "10/12 frames | write 29.9 fps | latency 12.3 ms (avg 56.8 ms) | queue 3 (~432 ms) | backlog 2 | dropped 2" ) @@ -115,6 +117,7 @@ def _fmt0(x: float) -> str: average_latency=finite_seconds_small, buffer_seconds=finite_seconds, queue_size=queue_size_int, + buffer_size=queue_size_int, dropped_frames=nonneg_int, ) def test_format_recorder_stats_properties( @@ -125,9 +128,10 @@ def test_format_recorder_stats_properties( average_latency, buffer_seconds, queue_size, + buffer_size, dropped_frames, ): - stats = SimpleNamespace( + stats = RecorderStats( frames_written=frames_written, frames_enqueued=frames_enqueued, write_fps=write_fps, @@ -135,28 +139,17 @@ def test_format_recorder_stats_properties( average_latency=average_latency, buffer_seconds=buffer_seconds, queue_size=queue_size, + buffer_size=buffer_size, dropped_frames=dropped_frames, ) s = format_recorder_stats(stats) - # Required structural tokens - assert " frames | write " in s - assert " fps | latency " in s - assert " ms (avg " in s - assert " ms) | queue " in s - assert " (~" in s - assert " ms) | dropped " in s - - # Exact numeric formatting expectations (substrings) - latency_ms = last_latency * 1000.0 - avg_ms = average_latency * 1000.0 - buffer_ms = buffer_seconds * 1000.0 - assert f"{frames_written}/{frames_enqueued} frames" in s - assert f"write {_fmt1(write_fps)} fps" in s - assert f"latency {_fmt1(latency_ms)} ms (avg {_fmt1(avg_ms)} ms)" in s - assert f"queue {queue_size} (~{_fmt0(buffer_ms)} ms)" in s + assert "write " in s + assert "latency " in s + assert "queue " in s + assert "backlog " in s assert f"dropped {dropped_frames}" in s @@ -251,3 +244,26 @@ def test_format_dlc_stats_profile_properties(stats): assert f"(GPU:{_fmt1(gpu_ms)}ms+proc:{_fmt1(proc_ms)}ms)" in s else: assert "GPU:" not in s + + +def test_format_recorder_stats_exact_with_buffer_capacity(): + stats = RecorderStats( + frames_written=10, + frames_enqueued=12, + write_fps=29.94, + last_latency=0.01234, + average_latency=0.05678, + buffer_seconds=0.4321, + queue_size=3, + buffer_size=10, + dropped_frames=2, + ) + + assert format_recorder_stats(stats) == ( + "10/12 frames | " + "write 29.9 fps | " + "latency 12.3 ms (avg 56.8 ms) | " + "queue 3/10 (30%, ~432 ms) | " + "backlog 2 | " + "dropped 2" + ) From ef37ca17bc0f5d453801c838ba1699caba4bb228 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 14:18:53 +0200 Subject: [PATCH 090/133] Add frame timestamp metadata helper class Introduce `FrameTimestampMetadata` in `dlclivegui/utils/timestamps.py` to standardize optional backend/hardware timestamp data for captured frames. The dataclass captures source/backend fields, converted and raw timestamp values, conversion metadata, and backend extras, and adds helper methods to serialize source-level data, per-frame values, full dictionaries, and the configured default reported timestamp. --- dlclivegui/utils/timestamps.py | 82 ++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 dlclivegui/utils/timestamps.py diff --git a/dlclivegui/utils/timestamps.py b/dlclivegui/utils/timestamps.py new file mode 100644 index 000000000..dea14ed2d --- /dev/null +++ b/dlclivegui/utils/timestamps.py @@ -0,0 +1,82 @@ +# dlclivegui/utils/timestamps.py +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class FrameTimestampMetadata: + """Optional backend-provided timestamp metadata for a captured frame. + + This supplements, but does not replace, the software timestamp. + + Notes: + - `seconds` is in the backend/hardware timebase. + - `wall_clock_time` should only be set if the backend can confidently + provide Unix/wall-clock seconds. + - `raw_value` preserves the original device-specific timestamp. + """ + + source: str + backend: str + + # Which value should downstream consumers use by default, if any. + # Expected values: "seconds", "wall_clock_time", or "raw_value". + default_reported: str | None = None + + # Device/hardware timebase value, if convertible to seconds + seconds: float | None = None + + # True Unix/wall-clock timestamp, if available + wall_clock_time: float | None = None + + # Raw backend value, e.g. device clock ticks + raw_value: int | float | str | None = None + raw_unit: str | None = None + + # Conversion metadata. + tick_frequency_hz: float | None = None + timebase: str | None = None + + # e.g. "camera_clock", "ptp_camera_clock", "hardware_wall_clock", + # "frame_counter", "unknown" + kind: str = "unknown" + + # Backend-specific extras. + extra: dict[str, Any] | None = None + + def to_source_dict(self) -> dict[str, Any]: + """Return metadata that should be written once per recording stream.""" + return { + "source": self.source, + "backend": self.backend, + "default_reported": self.default_reported, + "raw_unit": self.raw_unit, + "tick_frequency_hz": self.tick_frequency_hz, + "timebase": self.timebase, + "kind": self.kind, + "extra": self.extra or {}, + } + + def to_frame_dict(self) -> dict[str, Any]: + """Return defined per-frame timestamp values only.""" + ts = {} + for k in ["seconds", "wall_clock_time", "raw_value"]: + v = getattr(self, k) + if v is not None: + ts[k] = v + return ts + + def to_dict(self) -> dict[str, Any]: + """Return full representation, useful for logging/debugging.""" + return { + **self.to_source_dict(), + **self.to_frame_dict(), + } + + def get_default_reported(self) -> int | float | str | None: + """Return the value selected by `default_reported`, if configured.""" + if not self.default_reported: + return None + return self.to_frame_dict().get(self.default_reported) From d1ca6ca8d6860cf889fedfc86e3bcb2441ab4684 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 14:19:22 +0200 Subject: [PATCH 091/133] Propagate hardware timestamp metadata Adds end-to-end support for optional per-frame hardware timestamp metadata. Camera backends now return a `CapturedFrame` object (while preserving tuple unpacking), multi-camera signals and recording paths carry timestamp metadata, and `VideoRecorder` persists richer timestamp records. The timestamp JSON output is upgraded to schema v2 with backward-compatible software `timestamps` plus source metadata and per-frame hardware timestamp fields. --- dlclivegui/cameras/base.py | 24 +++++- dlclivegui/gui/main_window.py | 6 +- dlclivegui/gui/recording_manager.py | 10 ++- .../services/multi_camera_controller.py | 19 +++-- dlclivegui/services/video_recorder.py | 80 +++++++++++++++---- 5 files changed, 112 insertions(+), 27 deletions(-) diff --git a/dlclivegui/cameras/base.py b/dlclivegui/cameras/base.py index f86f3d14b..9217ad8e1 100644 --- a/dlclivegui/cameras/base.py +++ b/dlclivegui/cameras/base.py @@ -3,6 +3,7 @@ import logging from abc import ABC, abstractmethod +from dataclasses import dataclass from enum import Enum from typing import TYPE_CHECKING, Any, ClassVar @@ -11,6 +12,7 @@ from ..config import CameraSettings if TYPE_CHECKING: + from ..utils.timestamps import FrameTimestampMetadata from .factory import DetectedCamera _BACKEND_REGISTRY: dict[str, type[CameraBackend]] = {} @@ -72,9 +74,24 @@ class SupportLevel(str, Enum): "device_discovery": SupportLevel.UNSUPPORTED, "stable_identity": SupportLevel.UNSUPPORTED, "hardware_trigger": SupportLevel.UNSUPPORTED, + "hardware_frame_timestamps": SupportLevel.UNSUPPORTED, } +@dataclass(frozen=True) +class CapturedFrame: + """Frame plus software timestamp and optional backend timestamp metadata.""" + + frame: np.ndarray | None + software_timestamp: float + timestamp_metadata: FrameTimestampMetadata | None = None + + def __iter__(self): + """Backwards-compatible unpacking: frame, software_timestamp = backend.read()""" + yield self.frame + yield self.software_timestamp + + class CameraBackend(ABC): """Abstract base class for camera backends.""" @@ -107,6 +124,11 @@ def actual_pixel_format(self) -> str | None: def recommended_preserve_mono(self) -> bool | None: return None + @property + def last_frame_timestamp_metadata(self) -> FrameTimestampMetadata | None: + """Return backend-provided timestamp metadata for the last read frame.""" + return None + @classmethod def options_key(cls) -> str: """Return the key used to store this backend's options in CameraSettings.""" @@ -171,7 +193,7 @@ def open(self) -> None: raise NotImplementedError @abstractmethod - def read(self) -> tuple[np.ndarray, float]: + def read(self) -> CapturedFrame: """Read a frame and return the image with a timestamp.""" raise NotImplementedError diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index dfa64f610..1bfa88163 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1396,7 +1396,9 @@ def _render_overlays_for_recording(self, cam_id, frame): ) return output - def _on_recording_frame_ready(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: + def _on_recording_frame_ready( + self, camera_id: str, frame: np.ndarray, timestamp: float, timestamp_metadata: object | None = None + ) -> None: """Handle full-rate per-camera frames for recording only. Intentionally lean: @@ -1412,7 +1414,7 @@ def _on_recording_frame_ready(self, camera_id: str, frame: np.ndarray, timestamp if self.record_with_overlays_checkbox.isChecked(): frame = self._render_overlays_for_recording(camera_id, frame) - self._rec_manager.write_frame(camera_id, frame, timestamp) + self._rec_manager.write_frame(camera_id, frame, timestamp, timestamp_metadata=timestamp_metadata) def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: """Handle frames from multiple cameras. diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index f3509ac65..9be545c5f 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -202,12 +202,18 @@ def stop_all(self) -> None: self._session_dir = None self._run_dir = None - def write_frame(self, cam_id: str, frame: np.ndarray, timestamp: float | None = None) -> None: + def write_frame( + self, cam_id: str, frame: np.ndarray, timestamp: float | None = None, timestamp_metadata: object | None = None + ) -> None: rec = self._recorders.get(cam_id) if not rec or not rec.is_running: return try: - rec.write(frame, timestamp=timestamp if timestamp is not None else time.time()) + rec.write( + frame, + timestamp=timestamp if timestamp is not None else time.time(), + timestamp_metadata=timestamp_metadata, + ) except Exception as exc: log.warning("Failed to write frame for %s: %s", cam_id, exc) try: diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index fe5b66990..97c53dc76 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -47,7 +47,7 @@ class MultiFrameData: class SingleCameraWorker(QObject): """Worker for a single camera in multi-camera mode.""" - frame_captured = Signal(str, object, float) # camera_id, frame, timestamp + frame_captured = Signal(str, object, float, object) # camera_id, frame, timestamp, timestamp_metadata error_occurred = Signal(str, str) # camera_id, error_message runtime_info = Signal(str, object) # camera_id, dict of runtime info started = Signal(str) # camera_id @@ -117,7 +117,10 @@ def run(self) -> None: while not self._stop_event.is_set(): try: with self._timing.measure("Single.read"): - frame, timestamp = self._backend.read() + captured = self._backend.read() + frame = captured.frame + timestamp = captured.software_timestamp + timestamp_metadata = captured.timestamp_metadata if frame is None or frame.size == 0: consecutive_errors += 1 if consecutive_errors >= self._max_consecutive_errors: @@ -131,7 +134,7 @@ def run(self) -> None: consecutive_errors = 0 with self._timing.measure("Single.emit.frame_captured"): - self.frame_captured.emit(self._camera_id, frame, timestamp) + self.frame_captured.emit(self._camera_id, frame, timestamp, timestamp_metadata) self._timing.note_frame() self._timing.maybe_log() @@ -298,7 +301,9 @@ class MultiCameraController(QObject): # Signals frame_ready = Signal(object) # MultiFrameData (full cam FPS; inference only) - recording_frame_ready = Signal(str, object, float) # camera_id, frame, timestamp (full cam FPS; for recording) + recording_frame_ready = Signal( + str, object, float, object + ) # camera_id, frame, timestamp, timestamp_metadata (full cam FPS; for recording) display_ready = Signal(object) # MultiFrameData for GUI display (throttled to GUI_MAX_DISPLAY_FPS) camera_started = Signal(str, object) # camera_id, settings camera_stopped = Signal(str) # camera_id @@ -568,7 +573,9 @@ def stop(self, wait: bool = True) -> None: self.all_stopped.emit() - def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: + def _on_frame_captured( + self, camera_id: str, frame: np.ndarray, timestamp: float, timestamp_metadata: object | None = None + ) -> None: """Handle a frame from one camera.""" timing = self._timing_for_camera(camera_id) frame_data: MultiFrameData | None = None @@ -587,7 +594,7 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float if self._recording_frame_emission_enabled: with timing.measure("Multi.emit.recording_frame_ready"): - self.recording_frame_ready.emit(camera_id, frame, timestamp) + self.recording_frame_ready.emit(camera_id, frame, timestamp, timestamp_metadata) with self._frame_lock: with timing.measure("Multi.store_latest"): diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index 6c0afda3e..d9c164dbd 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -95,6 +95,7 @@ def __init__( self._writer: Any | None = None self._frame_size = frame_size self._frame_rate = frame_rate + self._hardware_timestamp_source: dict[str, Any] | None = None self._codec = codec self._crf = int(crf) self._buffer_size = max(1, int(buffer_size)) @@ -116,7 +117,7 @@ def __init__( self._written_times: deque[float] = deque(maxlen=600) self._encode_error: Exception | None = None self._last_log_time = 0.0 - self._frame_timestamps: list[float] = [] + self._frame_timestamps: list[dict[str, Any]] = [] # Timing self._process_timing = WorkerTimingStats( f"RecorderProcess[{self._output.name}]", logger=logger, log_interval=1.0, enabled=REC_DO_LOG_TIMING @@ -217,6 +218,7 @@ def start(self) -> None: self._last_latency = 0.0 self._written_times.clear() self._frame_timestamps.clear() + self._hardware_timestamp_source = None self._encode_error = None self._stop_event.clear() self._writer_thread = threading.Thread( @@ -230,7 +232,9 @@ def configure_stream(self, frame_size: tuple[int, int], frame_rate: float | None self._frame_size = frame_size self._frame_rate = frame_rate - def write(self, frame: np.ndarray, timestamp: float | None = None) -> bool: + def write( + self, frame: np.ndarray, timestamp: float | None = None, timestamp_metadata: object | None = None + ) -> bool: error = self._current_error() if error is not None: raise RuntimeError(f"Video encoding failed: {error}") from error @@ -295,7 +299,7 @@ def write(self, frame: np.ndarray, timestamp: float | None = None) -> bool: try: with self._process_timing.measure("Recorder.queue_put"): - q.put((frame, timestamp), block=False) + q.put((frame, timestamp, timestamp_metadata), block=False) except queue.Full: with self._stats_lock: self._dropped_frames += 1 @@ -430,7 +434,7 @@ def _writer_loop(self) -> None: if item is _SENTINEL: break else: - frame, timestamp = item + frame, timestamp, timestamp_metadata = item start = time.perf_counter() try: @@ -441,6 +445,30 @@ def _writer_loop(self) -> None: with self._writer_timing.measure("Recorder.writer_write"): writer.write(frame) + record: dict[str, Any] = { + "frame_index": self._frames_written, + "software_timestamp": float(timestamp), + } + + if timestamp_metadata is not None: + if ( + hasattr(timestamp_metadata, "to_source_dict") + and self._hardware_timestamp_source is None + ): + self._hardware_timestamp_source = timestamp_metadata.to_source_dict() + + if hasattr(timestamp_metadata, "to_frame_dict"): + record["hardware_timestamp"] = timestamp_metadata.to_frame_dict() + default_value = timestamp_metadata.get_default_reported() + if default_value is not None: + record["hardware_timestamp_default"] = default_value + elif isinstance(timestamp_metadata, dict): + record["hardware_timestamp"] = dict(timestamp_metadata) + else: + record["hardware_timestamp"] = repr(timestamp_metadata) + + self._frame_timestamps.append(record) + except Exception as exc: with self._stats_lock: self._encode_error = exc @@ -457,7 +485,6 @@ def _writer_loop(self) -> None: self._total_latency += elapsed self._last_latency = elapsed self._written_times.append(now) - self._frame_timestamps.append(timestamp) if now - self._last_log_time >= 1.0: self._compute_write_fps_locked() self._last_log_time = now @@ -504,27 +531,48 @@ def _save_timestamps(self) -> None: logger.info("No timestamps to save") return - # Create timestamps file path timestamp_file = self._output.with_suffix("").with_suffix(self._output.suffix + "_timestamps.json") try: with self._stats_lock: - timestamps = self._frame_timestamps.copy() + frame_timestamps = self._frame_timestamps.copy() + hardware_timestamp_source = ( + dict(self._hardware_timestamp_source) if self._hardware_timestamp_source is not None else None + ) + + software_timestamps = [ + float(rec["software_timestamp"]) for rec in frame_timestamps if "software_timestamp" in rec + ] - # Prepare metadata data = { + "schema_version": 2, "video_file": str(self._output.name), - "num_frames": len(timestamps), - "timestamps": timestamps, - "start_time": timestamps[0] if timestamps else None, - "end_time": timestamps[-1] if timestamps else None, - "duration_seconds": timestamps[-1] - timestamps[0] if len(timestamps) > 1 else 0.0, + "num_frames": len(frame_timestamps), + # Backward-compatible host/software timestamp list. + "timestamps": software_timestamps, + # New descriptive schema. + "timestamp_sources": { + "software_timestamp": { + "source": "host_time.time", + "backend": "host", + "kind": "software_wall_clock", + "timebase": "Unix epoch", + "unit": "seconds", + "description": "Host-side software timestamp captured during acquisition.", + }, + "hardware_timestamp": hardware_timestamp_source, + }, + "hardware_frame_timestamps": frame_timestamps, + "start_time": software_timestamps[0] if software_timestamps else None, + "end_time": software_timestamps[-1] if software_timestamps else None, + "duration_seconds": ( + software_timestamps[-1] - software_timestamps[0] if len(software_timestamps) > 1 else 0.0 + ), } - # Write to JSON with open(timestamp_file, "w") as f: json.dump(data, f, indent=2) - logger.info(f"Saved {len(timestamps)} frame timestamps to {timestamp_file}") + logger.info("Saved %d frame timestamps to %s", len(frame_timestamps), timestamp_file) except Exception as exc: - logger.exception(f"Failed to save timestamps to {timestamp_file}: {exc}") + logger.exception("Failed to save timestamps to %s: %s", timestamp_file, exc) From 31404af795a404aeca62ce2ca48d9e1c696dff19 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 14:19:33 +0200 Subject: [PATCH 092/133] Update aravis_backend.py --- dlclivegui/cameras/backends/aravis_backend.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dlclivegui/cameras/backends/aravis_backend.py b/dlclivegui/cameras/backends/aravis_backend.py index 60059c464..d1dd24ca1 100644 --- a/dlclivegui/cameras/backends/aravis_backend.py +++ b/dlclivegui/cameras/backends/aravis_backend.py @@ -11,7 +11,7 @@ import numpy as np from ...config import CameraSettings -from ..base import CameraBackend, SupportLevel, register_backend +from ..base import CameraBackend, CapturedFrame, SupportLevel, register_backend from ..factory import DetectedCamera LOG = logging.getLogger(__name__) @@ -372,7 +372,7 @@ def open(self) -> None: self._camera.start_acquisition() - def read(self) -> tuple[np.ndarray, float]: + def read(self) -> CapturedFrame: """Read a frame from the camera.""" if self._camera is None or self._stream is None: raise RuntimeError("Aravis camera not initialized") @@ -430,7 +430,7 @@ def read(self) -> tuple[np.ndarray, float]: # Always push buffer back to stream self._stream.push_buffer(buffer) - return frame, timestamp + return CapturedFrame(frame=frame, software_timestamp=timestamp, timestamp_metadata=None) def stop(self) -> None: """Stop camera acquisition.""" From c8504a5470e26c7df8a0939e8961f3a16c4fbda0 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 14:19:40 +0200 Subject: [PATCH 093/133] Update basler_backend.py --- dlclivegui/cameras/backends/basler_backend.py | 64 ++++++++++++++++--- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index b1aff592b..a6a689c3d 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -7,11 +7,10 @@ import time from typing import ClassVar -import numpy as np - from ...config import BASLER_DO_LOG_TIMING, CameraTriggerSettings from ...utils.stats import WorkerTimingStats -from ..base import CameraBackend, SupportLevel, register_backend +from ...utils.timestamps import FrameTimestampMetadata +from ..base import CameraBackend, CapturedFrame, SupportLevel, register_backend LOG = logging.getLogger(__name__) @@ -57,6 +56,8 @@ def __init__(self, settings): # (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)) self._retrieve_timeout_ms: int = 100 # default; may be overridden by trigger settings + self._timestamp_tick_frequency_hz: float | None = None + self._last_frame_timestamp_metadata: FrameTimestampMetadata | None = None # ---- Trigger settings ---- raw_trigger = self.ns.get("trigger", self._props.get("trigger")) @@ -156,6 +157,10 @@ def actual_output_format(self) -> str | None: return None return "Mono8" if self._should_output_mono() else "BGR8" + @property + def last_frame_timestamp_metadata(self) -> FrameTimestampMetadata | None: + return self._last_frame_timestamp_metadata + @property def recommended_preserve_mono(self) -> bool | None: if not self._camera_pixel_format: @@ -179,6 +184,7 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "stable_identity": SupportLevel.SUPPORTED, "hardware_trigger": SupportLevel.BEST_EFFORT, "preserve_mono": SupportLevel.SUPPORTED, + "hardware_frame_timestamps": SupportLevel.SUPPORTED, } ) return caps @@ -472,6 +478,7 @@ def _configure_frame_rate(self) -> None: "BslResultingAcquisitionFrameRate", "ExposureAuto", "ExposureTime", + "ExposureTimeAbs", "Width", "Height", "PixelFormat", @@ -541,7 +548,10 @@ def open(self) -> None: try: if hasattr(self._camera, "ExposureAuto"): self._camera.ExposureAuto.SetValue("Off") - self._camera.ExposureTime.SetValue(float(self.settings.exposure)) + if hasattr(self._camera, "ExposureTime"): + self._camera.ExposureTime.SetValue(float(self.settings.exposure)) + if hasattr(self._camera, "ExposureTimeAbs"): + self._camera.ExposureTimeAbs.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) @@ -652,9 +662,16 @@ def open(self) -> None: getattr(self.settings, "gain", None), ) - # ---------------------------- + # Get hardware tick frequency for timestamp conversion + try: + node = getattr(self._camera, "GevTimestampTickFrequency", None) + if node is not None and node.IsReadable(): + self._timestamp_tick_frequency_hz = float(node.GetValue()) + LOG.info("[Basler] timestamp tick frequency: %.3f Hz", self._timestamp_tick_frequency_hz) + except Exception: + LOG.debug("[Basler] Could not read GevTimestampTickFrequency", exc_info=True) + # Persist stable identity into namespace - # ---------------------------- try: serial = device.GetSerialNumber() if serial: @@ -667,7 +684,29 @@ def open(self) -> None: except Exception: pass - def read(self) -> tuple[np.ndarray, float]: + def _make_timestamp_metadata(self, grab_result) -> FrameTimestampMetadata | None: + try: + ticks = int(grab_result.GetTimeStamp()) + except Exception: + return None + + freq = getattr(self, "_timestamp_tick_frequency_hz", None) + seconds = ticks / freq if freq and freq > 0 else None + + return FrameTimestampMetadata( + source="grab_result.GetTimeStamp", + backend="basler", + default_reported="seconds" if seconds is not None else "raw_value", + seconds=seconds, + wall_clock_time=None, + raw_value=ticks, + raw_unit="ticks", + tick_frequency_hz=freq, + timebase="Basler camera timestamp counter", + kind="camera_clock", + ) + + def read(self) -> CapturedFrame: if self._camera is None: raise RuntimeError("Basler camera not opened") if self._converter is None: @@ -696,6 +735,11 @@ def read(self) -> tuple[np.ndarray, float]: with self._timing.measure("Basler.get_array"): frame = image.GetArray() + with self._timing.measure("Basler.timestamp"): + software_timestamp = time.time() + timestamp_metadata = self._make_timestamp_metadata(grab_result) + self._last_frame_timestamp_metadata = timestamp_metadata + if not self._logged_first_frame: self._logged_first_frame = True LOG.info( @@ -722,7 +766,11 @@ def read(self) -> tuple[np.ndarray, float]: self._timing.note_frame() self._timing.maybe_log() - return frame, time.time() + return CapturedFrame( + frame=frame, + software_timestamp=software_timestamp, + timestamp_metadata=timestamp_metadata, + ) except Exception as exc: if grab_result is not None: From d76ea2ff264970e7e02541e8a5b02ffe181268da Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 14:19:43 +0200 Subject: [PATCH 094/133] Update gentl_backend.py --- dlclivegui/cameras/backends/gentl_backend.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index a433fb1b6..e462a1dbb 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -13,7 +13,7 @@ import numpy as np from ...config import CameraTriggerSettings -from ..base import CameraBackend, SupportLevel, register_backend +from ..base import CameraBackend, CapturedFrame, SupportLevel, register_backend from ..factory import DetectedCamera from .utils import gentl_discovery as cti_finder @@ -615,7 +615,7 @@ def _output_format_for_frame(frame: np.ndarray) -> str: return f"{channels}ch-{frame.dtype}" return str(frame.dtype) - def read(self) -> tuple[np.ndarray, float]: + def read(self) -> CapturedFrame: if self._acquirer is None: raise RuntimeError("GenTL image acquirer not initialised") @@ -655,7 +655,11 @@ def read(self) -> tuple[np.ndarray, float]: pass self._actual_output_format = self._output_format_for_frame(frame) - return frame, timestamp + return CapturedFrame( + frame=frame, + software_timestamp=timestamp, + timestamp_metadata=None, + ) def stop(self) -> None: if self._acquirer is not None: From bcefaffda675e733a5f4db1b394a9503b3fd7312 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 14:19:51 +0200 Subject: [PATCH 095/133] Update opencv_backend.py --- dlclivegui/cameras/backends/opencv_backend.py | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/dlclivegui/cameras/backends/opencv_backend.py b/dlclivegui/cameras/backends/opencv_backend.py index 869dde448..1201749b7 100644 --- a/dlclivegui/cameras/backends/opencv_backend.py +++ b/dlclivegui/cameras/backends/opencv_backend.py @@ -10,10 +10,9 @@ from typing import TYPE_CHECKING, Literal import cv2 -import numpy as np from pydantic import BaseModel, Field, model_validator -from ..base import CameraBackend, SupportLevel, register_backend +from ..base import CameraBackend, CapturedFrame, SupportLevel, register_backend from ..factory import DetectedCamera from .utils.opencv_discovery import ( ModeRequest, @@ -199,21 +198,45 @@ def open(self) -> None: self._configure_capture() - def read(self) -> tuple[np.ndarray | None, float]: - """Robust frame read: return (None, ts) on transient failures; never raises.""" + def read(self) -> CapturedFrame: + """Robust frame read: return CapturedFrame(frame=None, ...) on transient failures; never raises.""" if self._capture is None: logger.warning("OpenCVCameraBackend.read() called before open()") - return None, time.time() + return CapturedFrame( + frame=None, + software_timestamp=time.time(), + timestamp_metadata=None, + ) + try: if not self._capture.grab(): - return None, time.time() + return CapturedFrame( + frame=None, + software_timestamp=time.time(), + timestamp_metadata=None, + ) + success, frame = self._capture.retrieve() if not success or frame is None or frame.size == 0: - return None, time.time() - return frame, time.time() + return CapturedFrame( + frame=None, + software_timestamp=time.time(), + timestamp_metadata=None, + ) + + return CapturedFrame( + frame=frame, + software_timestamp=time.time(), + timestamp_metadata=None, + ) + except Exception as exc: - logger.debug(f"OpenCV read transient error: {exc}") - return None, time.time() + logger.debug("OpenCV read transient error: %s", exc) + return CapturedFrame( + frame=None, + software_timestamp=time.time(), + timestamp_metadata=None, + ) def close(self) -> None: self._release_capture() From 78acc28b54e0830c1254911154b3b058d59793e6 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 14:20:51 +0200 Subject: [PATCH 096/133] Update tests for CapturedFrame read API Refactors backend tests to use the new `read()` return payload (`CapturedFrame`) instead of tuple unpacking, including frame/timestamp access updates and minor unused-variable cleanup. Test fixtures were also aligned with timestamp metadata support by returning `CapturedFrame` in the fake backend and extending fake recorder/frame callback signatures to accept `timestamp_metadata`. --- tests/cameras/backends/test_aravis_backend.py | 28 +++++++++---------- tests/cameras/backends/test_basler_backend.py | 11 +++++--- tests/cameras/backends/test_gentl_backend.py | 12 ++++---- tests/cameras/backends/test_gentl_trigger.py | 2 +- tests/cameras/backends/test_opencv_backend.py | 9 ++++-- tests/conftest.py | 9 +++--- tests/services/test_multicam_controller.py | 2 +- 7 files changed, 40 insertions(+), 33 deletions(-) diff --git a/tests/cameras/backends/test_aravis_backend.py b/tests/cameras/backends/test_aravis_backend.py index 797fd11cb..4f7ac55e8 100644 --- a/tests/cameras/backends/test_aravis_backend.py +++ b/tests/cameras/backends/test_aravis_backend.py @@ -243,7 +243,7 @@ def make_backend(settings, buffers): @pytest.mark.unit def test_device_name(): - be, cam, s = make_backend(Settings(), []) + be, _cam, s = make_backend(Settings(), []) assert be.device_name() == "FakeVendor FakeModel (12345)" @@ -253,9 +253,9 @@ def test_read_mono8(): data = (np.arange(w * h) % 256).astype(np.uint8).tobytes() buf = FakeAravis.Buffer(data, w, h, FakeAravis.PIXEL_FORMAT_MONO_8) - be, cam, s = make_backend(Settings(), [buf]) + be, _cam, s = make_backend(Settings(), [buf]) - frame, ts = be.read() + frame = be.read().frame assert frame.shape == (h, w, 3) assert frame.dtype == np.uint8 # Ensure grayscale expanded to 3 channels @@ -272,9 +272,9 @@ def test_read_rgb8_converts_to_bgr(): data = np.array([255, 0, 0, 0, 255, 0], dtype=np.uint8).tobytes() buf = FakeAravis.Buffer(data, w, h, FakeAravis.PIXEL_FORMAT_RGB_8_PACKED) - be, cam, s = make_backend(Settings(), [buf]) + be, _cam, s = make_backend(Settings(), [buf]) - frame, _ = be.read() + frame = be.read().frame assert frame.shape == (1, 2, 3) # BGR conversion: red → [0,0,255], green → [0,255,0] assert (frame[0, 0] == np.array([0, 0, 255])).all() @@ -288,9 +288,9 @@ def test_read_bgr8_passthrough(): data = np.array([10, 20, 30, 40, 50, 60], dtype=np.uint8).tobytes() buf = FakeAravis.Buffer(data, w, h, FakeAravis.PIXEL_FORMAT_BGR_8_PACKED) - be, cam, s = make_backend(Settings(), [buf]) + be, _cam, s = make_backend(Settings(), [buf]) - frame, _ = be.read() + frame = be.read().frame assert frame.shape == (1, 2, 3) assert (frame.flatten() == np.array([10, 20, 30, 40, 50, 60])).all() assert s.pushed >= 1 @@ -302,9 +302,9 @@ def test_read_mono16_scaling(): raw = np.array([0, 32768, 65535], dtype=np.uint16) buf = FakeAravis.Buffer(raw.tobytes(), w, h, FakeAravis.PIXEL_FORMAT_MONO_16) - be, cam, s = make_backend(Settings(), [buf]) + be, _cam, s = make_backend(Settings(), [buf]) - frame, _ = be.read() + frame = be.read().frame assert frame.shape == (1, 3, 3) # scaling: 0 → 0, max → 255, mid → ~128 @@ -320,9 +320,9 @@ def test_read_unknown_format_fallback_to_mono8(): data = (np.arange(w * h) % 256).astype(np.uint8).tobytes() # Unknown token buf = FakeAravis.Buffer(data, w, h, "SOME_UNKNOWN_FMT") - be, cam, s = make_backend(Settings(), [buf]) + be, _cam, s = make_backend(Settings(), [buf]) - frame, _ = be.read() + frame = be.read().frame assert frame.shape == (h, w, 3) assert np.all(frame[..., 0] == frame[..., 1]) assert np.all(frame[..., 1] == frame[..., 2]) @@ -331,7 +331,7 @@ def test_read_unknown_format_fallback_to_mono8(): @pytest.mark.unit def test_read_timeout_raises(): - be, cam, s = make_backend(Settings(), []) + be, _cam, s = make_backend(Settings(), []) with pytest.raises(TimeoutError): be.read() @@ -341,7 +341,7 @@ def test_read_status_error_raises_and_pushes_back(): w, h = 1, 1 data = b"\x00" buf = FakeAravis.Buffer(data, w, h, FakeAravis.PIXEL_FORMAT_MONO_8, status="ERROR") - be, cam, s = make_backend(Settings(), [buf]) + be, _cam, s = make_backend(Settings(), [buf]) with pytest.raises(TimeoutError): be.read() @@ -350,7 +350,7 @@ def test_read_status_error_raises_and_pushes_back(): @pytest.mark.unit def test_close_is_idempotent(): - be, cam, s = make_backend(Settings(), []) + be, _cam, s = make_backend(Settings(), []) be.close() be.close() # should not raise diff --git a/tests/cameras/backends/test_basler_backend.py b/tests/cameras/backends/test_basler_backend.py index 18f49a11b..211293867 100644 --- a/tests/cameras/backends/test_basler_backend.py +++ b/tests/cameras/backends/test_basler_backend.py @@ -21,7 +21,8 @@ def test_basler_open_starts_grabbing_and_read_returns_frame(patch_basler_sdk, ba assert be._camera.IsGrabbing() assert be._converter is not None - frame, ts = be.read() + payload = be.read() + frame, ts = payload.frame, payload.software_timestamp assert isinstance(ts, float) assert isinstance(frame, np.ndarray) assert frame.shape == (10, 10, 3) @@ -257,7 +258,8 @@ def test_basler_default_trigger_is_off_and_free_runs( assert be._camera.TriggerMode.GetValue() == "Off" assert be.waits_for_hardware_trigger is False - frame, _ = be.read() + payload = be.read() + frame = payload.frame assert frame.shape == (10, 10, 3) be.close() @@ -356,7 +358,8 @@ def test_basler_follower_non_strict_invalid_source_disables_trigger( assert be._camera.TriggerMode.GetValue() == "Off" assert be.waits_for_hardware_trigger is False - frame, _ = be.read() + payload = be.read() + frame = payload.frame assert frame.shape == (10, 10, 3) be.close() @@ -430,7 +433,7 @@ def test_basler_software_trigger_requires_trigger_once_before_read( be.trigger_once() assert be._camera.software_trigger_calls == 1 - frame, _ = be.read() + frame = be.read().frame assert frame.shape == (10, 10, 3) be.close() diff --git a/tests/cameras/backends/test_gentl_backend.py b/tests/cameras/backends/test_gentl_backend.py index 3ffdab204..3cb7d9ea2 100644 --- a/tests/cameras/backends/test_gentl_backend.py +++ b/tests/cameras/backends/test_gentl_backend.py @@ -54,12 +54,12 @@ def test_open_starts_stream_and_read_returns_frame(patch_gentl_sdk, gentl_settin 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 + captured = be.read() + assert isinstance(captured.software_timestamp, float) + assert isinstance(captured.frame, np.ndarray) + assert captured.frame.size > 0 # Backend converts to BGR; ensure 3-channel output - assert frame.ndim == 3 and frame.shape[2] == 3 + assert captured.frame.ndim == 3 and captured.frame.shape[2] == 3 be.close() assert be._harvester is None @@ -422,7 +422,7 @@ def test_pixel_format_unavailable_does_not_crash_open_and_streams(patch_gentl_sd be.open() # No fake-internal checks; just verify it can read - frame, _ = be.read() + frame = be.read().frame assert frame is not None and frame.size > 0 be.close() diff --git a/tests/cameras/backends/test_gentl_trigger.py b/tests/cameras/backends/test_gentl_trigger.py index 57339a103..b445f4ea8 100644 --- a/tests/cameras/backends/test_gentl_trigger.py +++ b/tests/cameras/backends/test_gentl_trigger.py @@ -289,7 +289,7 @@ def test_trigger_timeout_is_capped_for_hardware_trigger_fetch_polling( assert be._timeout == pytest.approx(expected_fetch_timeout) # Fake acquisition is started, so read should pass and record the capped timeout. - frame, _ = be.read() + frame = be.read().frame assert frame is not None assert be._acquirer.fetch_calls[-1] == pytest.approx(expected_fetch_timeout) diff --git a/tests/cameras/backends/test_opencv_backend.py b/tests/cameras/backends/test_opencv_backend.py index 2f1557824..5fff09910 100644 --- a/tests/cameras/backends/test_opencv_backend.py +++ b/tests/cameras/backends/test_opencv_backend.py @@ -124,7 +124,8 @@ def test_read_returns_none_on_grab_failure(fake_capture_factory): cap.grab_ok = False backend._capture = cap - frame, ts = backend.read() + payload = backend.read() + frame, ts = payload.frame, payload.software_timestamp assert frame is None assert isinstance(ts, float) @@ -135,7 +136,8 @@ def test_read_returns_none_on_retrieve_failure(fake_capture_factory): cap.retrieve_ok = False backend._capture = cap - frame, ts = backend.read() + payload = backend.read() + frame, ts = payload.frame, payload.software_timestamp assert frame is None assert isinstance(ts, float) @@ -150,7 +152,8 @@ def boom(): cap.grab = boom backend._capture = cap - frame, ts = backend.read() + payload = backend.read() + frame, ts = payload.frame, payload.software_timestamp assert frame is None assert isinstance(ts, float) diff --git a/tests/conftest.py b/tests/conftest.py index 49cd1c66e..f04941e6e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,7 @@ from dlclivegui.cameras import CameraFactory from dlclivegui.cameras.base import ( CameraBackend, + CapturedFrame, SupportLevel, register_backend_direct, unregister_backend, @@ -86,7 +87,7 @@ def read(self): raise RuntimeError("not opened") self._counter += 1 frame = np.zeros(frame_shape, dtype=np.uint8) - return frame, float(timestamp_fn()) + return CapturedFrame(frame=frame, software_timestamp=float(timestamp_fn()), timestamp_metadata=None) _TestBackend.__name__ = f"TestBackend_{name}" return _TestBackend @@ -391,10 +392,10 @@ def start(self): def stop(self): self.stopped = True - def write(self, frame, timestamp=None): + def write(self, frame, timestamp=None, timestamp_metadata=None): if self.raise_on_write: raise RuntimeError("write failed") - self.write_calls.append((frame, timestamp)) + self.write_calls.append((frame, timestamp, timestamp_metadata)) return True def get_stats(self): @@ -418,7 +419,7 @@ def patch_video_recorder(monkeypatch): def recording_frame_spy(monkeypatch, window): captured = {} - def _fake_write_frame(cam_id, frame, timestamp=None): + def _fake_write_frame(cam_id, frame, timestamp=None, timestamp_metadata=None): captured[cam_id] = frame.copy() monkeypatch.setattr(window._rec_manager, "write_frame", _fake_write_frame) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 747b5da42..1b4f35266 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -517,7 +517,7 @@ def test_recording_frame_ready_only_emits_when_enabled(qtbot, patch_factory): cam_id = get_camera_id(cam) seen: list[tuple[str, tuple, float]] = [] - def on_recording_frame(camera_id, frame, timestamp): + def on_recording_frame(camera_id, frame, timestamp, timestamp_metadata=None): seen.append((camera_id, frame.shape, timestamp)) mc.recording_frame_ready.connect(on_recording_frame) From 30696b3964ffde585e88f2316d7d5b4888a8f834 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 14:38:52 +0200 Subject: [PATCH 097/133] Rename metadata key to frame_timestamps Updates the recording metadata schema in `video_recorder.py` by renaming `hardware_frame_timestamps` to `frame_timestamps`. This aligns timestamp data with a more general key name while preserving the same underlying values. --- dlclivegui/services/video_recorder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index d9c164dbd..7492389ca 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -562,7 +562,7 @@ def _save_timestamps(self) -> None: }, "hardware_timestamp": hardware_timestamp_source, }, - "hardware_frame_timestamps": frame_timestamps, + "frame_timestamps": frame_timestamps, "start_time": software_timestamps[0] if software_timestamps else None, "end_time": software_timestamps[-1] if software_timestamps else None, "duration_seconds": ( From 75284651c026cf9d542ccdca4e37c704979e7221 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 14:39:09 +0200 Subject: [PATCH 098/133] Add timestamp metadata coverage across tests Expands test coverage for frame timestamp metadata end-to-end: Basler backend reads now validate hardware timestamp extraction, controller/recording manager tests assert metadata forwarding, and video recorder tests verify schema_version 2 sidecar output for both software-only and hardware-backed timestamps. Also adds focused unit tests for `FrameTimestampMetadata` source/frame field splitting and default-reported value behavior. --- tests/cameras/backends/conftest.py | 4 + tests/cameras/backends/test_basler_backend.py | 45 +++++++ tests/gui/test_rec_manager.py | 39 ++++++ tests/services/test_multicam_controller.py | 49 ++++++++ tests/services/test_video_recorder.py | 117 +++++++++++++++++- tests/utils/test_timestamps.py | 63 ++++++++++ 6 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 tests/utils/test_timestamps.py diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index 5bbcac31e..a74a0eec0 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -525,6 +525,9 @@ def GrabSucceeded(self): def Release(self): self.released = True + def GetTimeStamp(self): + return 123456789 + class InstantCamera: def __init__(self, device): self._device = device @@ -549,6 +552,7 @@ def __init__(self, device): self.AcquisitionFrameRateEnable = FakePylon._Feature(False) self.AcquisitionFrameRate = FakePylon._Feature(30.0) + self.GevTimestampTickFrequency = FakePylon._Feature(1_000_000_000.0) self.MaxNumBuffer = FakePylon._Feature(10) diff --git a/tests/cameras/backends/test_basler_backend.py b/tests/cameras/backends/test_basler_backend.py index 211293867..48b8a2989 100644 --- a/tests/cameras/backends/test_basler_backend.py +++ b/tests/cameras/backends/test_basler_backend.py @@ -3,6 +3,9 @@ import numpy as np import pytest +from dlclivegui.cameras.base import CapturedFrame +from dlclivegui.utils.timestamps import FrameTimestampMetadata + # --------------------------------------------------------------------- # Core lifecycle # --------------------------------------------------------------------- @@ -466,3 +469,45 @@ def test_basler_close_turns_input_trigger_off( be.close() assert cam.TriggerMode.GetValue() == "Off" + + +class TestBaslerFrameTimestamps: + @pytest.mark.unit + def test_read_returns_captured_frame_with_hardware_timestamp_metadata( + self, + patch_basler_sdk, + basler_settings_factory, + ): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory() + be = bb.BaslerCameraBackend(settings) + be.open() + + captured = be.read() + + assert isinstance(captured, CapturedFrame) + assert captured.frame is not None + assert isinstance(captured.software_timestamp, float) + + meta = captured.timestamp_metadata + assert isinstance(meta, FrameTimestampMetadata) + + assert meta.backend == "basler" + assert meta.source == "grab_result.GetTimeStamp" + assert meta.kind == "camera_clock" + assert meta.raw_unit == "ticks" + assert meta.raw_value == 123456789 + assert meta.tick_frequency_hz == pytest.approx(1_000_000_000.0) + assert meta.seconds == pytest.approx(0.123456789) + assert meta.default_reported == "seconds" + + source_dict = meta.to_source_dict() + assert source_dict["backend"] == "basler" + assert source_dict["source"] == "grab_result.GetTimeStamp" + + frame_dict = meta.to_frame_dict() + assert frame_dict["seconds"] == pytest.approx(0.123456789) + assert frame_dict["raw_value"] == 123456789 + + be.close() diff --git a/tests/gui/test_rec_manager.py b/tests/gui/test_rec_manager.py index f97c43a57..cf4bca299 100644 --- a/tests/gui/test_rec_manager.py +++ b/tests/gui/test_rec_manager.py @@ -7,6 +7,7 @@ from dlclivegui.gui.recording_manager import RecordingManager from dlclivegui.services.multi_camera_controller import get_camera_id, get_display_id from dlclivegui.utils.stats import RecorderStats +from dlclivegui.utils.timestamps import FrameTimestampMetadata @pytest.fixture @@ -422,3 +423,41 @@ def test_start_all_passes_writegear_options( assert rec.writer_options["-crf"] == "23" assert rec.writer_options["-preset"] == "ultrafast" assert rec.writer_options["-tune"] == "zerolatency" + + +class TestRecordingManagerTimestampMetadata: + @pytest.mark.unit + def test_write_frame_passes_timestamp_metadata( + self, + recording_settings, + _active_cams_two, + current_frames, + patch_video_recorder, + patch_build_run_dir, + ): + mgr = RecordingManager() + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") + + cam0_id = get_camera_id(_active_cams_two[0]) + frame = current_frames[cam0_id] + + meta = FrameTimestampMetadata( + source="grab_result.GetTimeStamp", + backend="basler", + default_reported="seconds", + seconds=0.001, + raw_value=1_000_000, + raw_unit="ticks", + tick_frequency_hz=1_000_000_000.0, + kind="camera_clock", + ) + + mgr.write_frame(cam0_id, frame, timestamp=123.0, timestamp_metadata=meta) + + rec = mgr.recorders[cam0_id] + assert len(rec.write_calls) == 1 + + written_frame, written_timestamp, written_metadata = rec.write_calls[0] + assert written_frame is frame + assert written_timestamp == 123.0 + assert written_metadata is meta diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 1b4f35266..4eafbdaee 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -1,3 +1,4 @@ +import numpy as np import pytest from dlclivegui.cameras.factory import CameraFactory @@ -9,6 +10,7 @@ get_camera_id, get_display_id, ) +from dlclivegui.utils.timestamps import FrameTimestampMetadata @pytest.mark.unit @@ -548,3 +550,50 @@ def on_recording_frame(camera_id, frame, timestamp, timestamp_metadata=None): finally: with qtbot.waitSignal(mc.all_stopped, timeout=2000): mc.stop(wait=True) + + +class TestRecordingFrameTimestamps: + @pytest.mark.unit + def test_recording_frame_ready_forwards_timestamp_metadata(self, qtbot): + mc = MultiCameraController() + mc._running = True + mc._recording_frame_emission_enabled = True + + cam_id = "basler:0815-0000" + mc._settings[cam_id] = CameraSettings( + name="C", + backend="basler", + index=0, + enabled=True, + ).apply_defaults() + mc._camera_display_order = [cam_id] + mc._display_ids[cam_id] = "C" + + frame = np.zeros((10, 10), dtype=np.uint8) + meta = FrameTimestampMetadata( + source="grab_result.GetTimeStamp", + backend="basler", + default_reported="seconds", + seconds=0.001, + raw_value=1_000_000, + raw_unit="ticks", + tick_frequency_hz=1_000_000_000.0, + kind="camera_clock", + ) + + seen = [] + + def on_recording_frame(camera_id, emitted_frame, timestamp, timestamp_metadata): + seen.append((camera_id, emitted_frame, timestamp, timestamp_metadata)) + + mc.recording_frame_ready.connect(on_recording_frame) + + mc._on_frame_captured(cam_id, frame, 123.0, meta) + + assert len(seen) == 1 + + camera_id, emitted_frame, timestamp, timestamp_metadata = seen[0] + assert camera_id == cam_id + assert emitted_frame is frame + assert timestamp == 123.0 + assert timestamp_metadata is meta diff --git a/tests/services/test_video_recorder.py b/tests/services/test_video_recorder.py index efde6e2b9..ff09c1e93 100644 --- a/tests/services/test_video_recorder.py +++ b/tests/services/test_video_recorder.py @@ -9,6 +9,7 @@ import pytest import dlclivegui.services.video_recorder as vr_mod +from dlclivegui.utils.timestamps import FrameTimestampMetadata # ---------------------------- # Helpers @@ -228,10 +229,14 @@ def test_stop_writes_timestamps_sidecar_json(patch_writegear, output_path, rgb_f data = json.loads(ts_path.read_text()) assert data["video_file"] == output_path.name assert data["num_frames"] == 2 - assert data["timestamps"] == [10.0, 12.0] assert data["start_time"] == 10.0 assert data["end_time"] == 12.0 assert data["duration_seconds"] == 2.0 + assert data["schema_version"] == 2 + assert data["timestamps"] == [10.0, 12.0] + assert data["timestamp_sources"]["hardware_timestamp"] is None + assert data["frame_timestamps"][0]["software_timestamp"] == 10.0 + assert data["frame_timestamps"][1]["software_timestamp"] == 12.0 def test_encoder_write_error_sets_encode_error_and_future_writes_raise(patch_writegear, output_path, rgb_frame): @@ -418,3 +423,113 @@ def close(self): rec.stop() assert written[0].shape == (10, 20, 3) + + +class TestVideoRecorderTimestampSidecar: + def test_stop_writes_software_only_timestamp_sidecar_json( + self, + patch_writegear, + output_path, + rgb_frame, + ): + rec = vr_mod.VideoRecorder(output_path, buffer_size=10) + rec.start() + + rec.write(rgb_frame, timestamp=10.0) + rec.write(rgb_frame, timestamp=12.0) + + wait_until(lambda: len(FakeWriteGear.instances[0].frames) >= 2) + rec.stop() + + ts_path = output_path.with_suffix("").with_suffix(output_path.suffix + "_timestamps.json") + assert ts_path.exists() + + data = json.loads(ts_path.read_text()) + + assert data["schema_version"] == 2 + assert data["video_file"] == output_path.name + assert data["num_frames"] == 2 + + # Backward-compatible list. + assert data["timestamps"] == [10.0, 12.0] + + assert data["timestamp_sources"]["software_timestamp"]["kind"] == "software_wall_clock" + assert data["timestamp_sources"]["hardware_timestamp"] is None + + assert data["frame_timestamps"] == [ + { + "frame_index": 0, + "software_timestamp": 10.0, + }, + { + "frame_index": 1, + "software_timestamp": 12.0, + }, + ] + + def test_stop_writes_hardware_timestamp_metadata_sidecar_json( + self, + patch_writegear, + output_path, + rgb_frame, + ): + rec = vr_mod.VideoRecorder(output_path, buffer_size=10) + rec.start() + + meta = FrameTimestampMetadata( + source="grab_result.GetTimeStamp", + backend="basler", + default_reported="seconds", + seconds=0.001, + raw_value=1_000_000, + raw_unit="ticks", + tick_frequency_hz=1_000_000_000.0, + timebase="Basler camera timestamp counter", + kind="camera_clock", + ) + + rec.write(rgb_frame, timestamp=10.0, timestamp_metadata=meta) + + wait_until(lambda: len(FakeWriteGear.instances[0].frames) >= 1) + rec.stop() + + ts_path = output_path.with_suffix("").with_suffix(output_path.suffix + "_timestamps.json") + assert ts_path.exists() + + data = json.loads(ts_path.read_text()) + + assert data["schema_version"] == 2 + assert data["video_file"] == output_path.name + assert data["num_frames"] == 1 + + # Backward-compatible software timestamp list. + assert data["timestamps"] == [10.0] + assert data["start_time"] == 10.0 + assert data["end_time"] == 10.0 + assert data["duration_seconds"] == 0.0 + + # Static hardware source metadata is written once. + hw_source = data["timestamp_sources"]["hardware_timestamp"] + assert hw_source == { + "source": "grab_result.GetTimeStamp", + "backend": "basler", + "default_reported": "seconds", + "raw_unit": "ticks", + "tick_frequency_hz": 1_000_000_000.0, + "timebase": "Basler camera timestamp counter", + "kind": "camera_clock", + "extra": {}, + } + + # Per-frame records contain only per-frame values. + frame_ts = data["frame_timestamps"] + assert len(frame_ts) == 1 + + rec0 = frame_ts[0] + assert rec0["frame_index"] == 0 + assert rec0["software_timestamp"] == 10.0 + assert rec0["hardware_timestamp"] == { + "seconds": 0.001, + "raw_value": 1_000_000, + } + assert rec0["hardware_timestamp_default"] == 0.001 diff --git a/tests/utils/test_timestamps.py b/tests/utils/test_timestamps.py new file mode 100644 index 000000000..560872930 --- /dev/null +++ b/tests/utils/test_timestamps.py @@ -0,0 +1,63 @@ +import pytest + +from dlclivegui.utils.timestamps import FrameTimestampMetadata + + +class TestFrameTimestampMetadata: + def test_splits_source_and_frame_values(self): + meta = FrameTimestampMetadata( + source="grab_result.GetTimeStamp", + backend="basler", + default_reported="seconds", + seconds=0.123456789, + wall_clock_time=None, + raw_value=123456789, + raw_unit="ticks", + tick_frequency_hz=1_000_000_000.0, + timebase="Basler camera timestamp counter", + kind="camera_clock", + ) + + assert meta.to_source_dict() == { + "source": "grab_result.GetTimeStamp", + "backend": "basler", + "default_reported": "seconds", + "raw_unit": "ticks", + "tick_frequency_hz": 1_000_000_000.0, + "timebase": "Basler camera timestamp counter", + "kind": "camera_clock", + "extra": {}, + } + + frame_dict = meta.to_frame_dict() + assert frame_dict["seconds"] == pytest.approx(0.123456789) + assert frame_dict["raw_value"] == 123456789 + assert "wall_clock_time" not in frame_dict + + assert meta.get_default_reported() == pytest.approx(0.123456789) + + def test_default_reported_raw_value(self): + meta = FrameTimestampMetadata( + source="device_counter", + backend="some_backend", + default_reported="raw_value", + raw_value=42, + raw_unit="frames", + kind="frame_counter", + ) + + assert meta.to_frame_dict() == {"raw_value": 42} + assert meta.get_default_reported() == 42 + + def test_unknown_default_field_returns_none(self): + meta = FrameTimestampMetadata( + source="device_counter", + backend="some_backend", + default_reported="seconds", + raw_value=42, + raw_unit="frames", + kind="frame_counter", + ) + + assert meta.to_frame_dict() == {"raw_value": 42} + assert meta.get_default_reported() is None From 358b37bdadac6f285ee72f233c067f1462860845 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 15:10:41 +0200 Subject: [PATCH 099/133] Harden Basler timestamp metadata handling Improve Basler hardware timestamp robustness by treating support as best-effort, recording the tick-frequency source, falling back to an assumed 1 GHz clock when frequency is unavailable, and ignoring zero-value camera timestamps as missing data. The frame timestamp metadata now includes the frequency source in `extra`, and unused last-frame timestamp state was removed. Video recorder metadata also drops the legacy top-level `timestamps` field in favor of the structured `timestamp_sources` schema. --- dlclivegui/cameras/backends/basler_backend.py | 30 ++++++++++++++----- dlclivegui/services/video_recorder.py | 4 +-- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index a6a689c3d..273982040 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -57,7 +57,7 @@ def __init__(self, settings): self._fast_start: bool = bool(self.ns.get("fast_start", False)) self._retrieve_timeout_ms: int = 100 # default; may be overridden by trigger settings self._timestamp_tick_frequency_hz: float | None = None - self._last_frame_timestamp_metadata: FrameTimestampMetadata | None = None + self._timestamp_tick_frequency_source: str | None = None # ---- Trigger settings ---- raw_trigger = self.ns.get("trigger", self._props.get("trigger")) @@ -157,10 +157,6 @@ def actual_output_format(self) -> str | None: return None return "Mono8" if self._should_output_mono() else "BGR8" - @property - def last_frame_timestamp_metadata(self) -> FrameTimestampMetadata | None: - return self._last_frame_timestamp_metadata - @property def recommended_preserve_mono(self) -> bool | None: if not self._camera_pixel_format: @@ -184,7 +180,7 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "stable_identity": SupportLevel.SUPPORTED, "hardware_trigger": SupportLevel.BEST_EFFORT, "preserve_mono": SupportLevel.SUPPORTED, - "hardware_frame_timestamps": SupportLevel.SUPPORTED, + "hardware_frame_timestamps": SupportLevel.BEST_EFFORT, } ) return caps @@ -667,10 +663,22 @@ def open(self) -> None: node = getattr(self._camera, "GevTimestampTickFrequency", None) if node is not None and node.IsReadable(): self._timestamp_tick_frequency_hz = float(node.GetValue()) - LOG.info("[Basler] timestamp tick frequency: %.3f Hz", self._timestamp_tick_frequency_hz) + self._timestamp_tick_frequency_source = "GevTimestampTickFrequency" + LOG.info( + "[Basler] timestamp tick frequency: %.3f Hz from GevTimestampTickFrequency", + self._timestamp_tick_frequency_hz, + ) except Exception: LOG.debug("[Basler] Could not read GevTimestampTickFrequency", exc_info=True) + if not self._timestamp_tick_frequency_hz or self._timestamp_tick_frequency_hz <= 0: + self._timestamp_tick_frequency_hz = 1_000_000_000.0 + self._timestamp_tick_frequency_source = "assumed_default_1ghz" + LOG.info( + "[Basler] timestamp tick frequency unavailable; assuming %.3f Hz", + self._timestamp_tick_frequency_hz, + ) + # Persist stable identity into namespace try: serial = device.GetSerialNumber() @@ -690,6 +698,10 @@ def _make_timestamp_metadata(self, grab_result) -> FrameTimestampMetadata | None except Exception: return None + if ticks == 0: + # Basler returns 0 if the timestamp is not available (e.g. for some GigE cameras) + return None + freq = getattr(self, "_timestamp_tick_frequency_hz", None) seconds = ticks / freq if freq and freq > 0 else None @@ -704,6 +716,9 @@ def _make_timestamp_metadata(self, grab_result) -> FrameTimestampMetadata | None tick_frequency_hz=freq, timebase="Basler camera timestamp counter", kind="camera_clock", + extra={ + "tick_frequency_source": self._timestamp_tick_frequency_source, + }, ) def read(self) -> CapturedFrame: @@ -738,7 +753,6 @@ def read(self) -> CapturedFrame: with self._timing.measure("Basler.timestamp"): software_timestamp = time.time() timestamp_metadata = self._make_timestamp_metadata(grab_result) - self._last_frame_timestamp_metadata = timestamp_metadata if not self._logged_first_frame: self._logged_first_frame = True diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index 7492389ca..cde723ec0 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -548,9 +548,7 @@ def _save_timestamps(self) -> None: "schema_version": 2, "video_file": str(self._output.name), "num_frames": len(frame_timestamps), - # Backward-compatible host/software timestamp list. - "timestamps": software_timestamps, - # New descriptive schema. + # "timestamps": software_timestamps, "timestamp_sources": { "software_timestamp": { "source": "host_time.time", From 9f84a5847a6d67cede6eddb17abbacf339130d4f Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 15:52:56 +0200 Subject: [PATCH 100/133] Drop legacy timestamps assertions in tests Updates `test_video_recorder.py` to stop asserting the top-level `timestamps` list in sidecar JSON fixtures. The tests now focus on schema v2 fields that remain authoritative (`frame_timestamps`, `start_time`, `end_time`, `duration_seconds`, and timestamp source metadata), aligning expectations with current sidecar output. --- tests/services/test_video_recorder.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/services/test_video_recorder.py b/tests/services/test_video_recorder.py index ff09c1e93..8389fbbb4 100644 --- a/tests/services/test_video_recorder.py +++ b/tests/services/test_video_recorder.py @@ -233,7 +233,6 @@ def test_stop_writes_timestamps_sidecar_json(patch_writegear, output_path, rgb_f assert data["end_time"] == 12.0 assert data["duration_seconds"] == 2.0 assert data["schema_version"] == 2 - assert data["timestamps"] == [10.0, 12.0] assert data["timestamp_sources"]["hardware_timestamp"] is None assert data["frame_timestamps"][0]["software_timestamp"] == 10.0 assert data["frame_timestamps"][1]["software_timestamp"] == 12.0 @@ -450,9 +449,6 @@ def test_stop_writes_software_only_timestamp_sidecar_json( assert data["video_file"] == output_path.name assert data["num_frames"] == 2 - # Backward-compatible list. - assert data["timestamps"] == [10.0, 12.0] - assert data["timestamp_sources"]["software_timestamp"]["kind"] == "software_wall_clock" assert data["timestamp_sources"]["hardware_timestamp"] is None @@ -503,7 +499,6 @@ def test_stop_writes_hardware_timestamp_metadata_sidecar_json( assert data["num_frames"] == 1 # Backward-compatible software timestamp list. - assert data["timestamps"] == [10.0] assert data["start_time"] == 10.0 assert data["end_time"] == 10.0 assert data["duration_seconds"] == 0.0 From 3e34e7bbb1dbeead71fc46307292101136fe1b91 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 16:34:31 +0200 Subject: [PATCH 101/133] Guard default timestamp field Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- dlclivegui/services/video_recorder.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index cde723ec0..e0b54c01a 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -459,9 +459,10 @@ def _writer_loop(self) -> None: if hasattr(timestamp_metadata, "to_frame_dict"): record["hardware_timestamp"] = timestamp_metadata.to_frame_dict() - default_value = timestamp_metadata.get_default_reported() - if default_value is not None: - record["hardware_timestamp_default"] = default_value + if hasattr(timestamp_metadata, "get_default_reported"): + default_value = timestamp_metadata.get_default_reported() + if default_value is not None: + record["hardware_timestamp_default"] = default_value elif isinstance(timestamp_metadata, dict): record["hardware_timestamp"] = dict(timestamp_metadata) else: From 6896f91e0ec79c626c325aa80ad6ca435ec8cd42 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 16:23:21 +0200 Subject: [PATCH 102/133] Persist fast encoding setting in GUI Wire the fast encoding checkbox to QSettings so its value is restored on startup and saved when toggled. This adds typed get/set helpers for `recording/fast_encoding` in `DLCLiveGUISettingsStore`, updates `main_window` to prefer persisted values over config defaults, and includes a roundtrip unit test for the new setting. It also removes an obsolete commented-out recording block in the frame processing path. --- dlclivegui/gui/main_window.py | 18 ++++++------------ dlclivegui/utils/settings_store.py | 9 +++++++++ tests/utils/test_settings_store.py | 11 +++++++++++ 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 1bfa88163..e7feffc8c 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -812,6 +812,7 @@ def _connect_signals(self) -> None: self.filename_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) if hasattr(self, "container_combo"): self.container_combo.currentTextChanged.connect(lambda _t: self._update_recording_path_preview()) + self.fast_encoding_checkbox.stateChanged.connect(self._on_fast_encoding_changed) # ------------------------------------------------------------------ # Config @@ -840,7 +841,8 @@ def _apply_config(self, config: ApplicationSettings) -> None: self.crf_spin.setValue(int(recording.crf)) if hasattr(self, "fast_encoding_checkbox"): - self.fast_encoding_checkbox.setChecked(bool(getattr(recording, "fast_encoding", False))) + config_fast_encoding = bool(getattr(recording, "fast_encoding", False)) + self.fast_encoding_checkbox.setChecked(self._settings_store.get_fast_encoding(default=config_fast_encoding)) ## Restore persisted session name if empty if hasattr(self, "session_name_edit"): @@ -1213,6 +1215,9 @@ 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_fast_encoding_changed(self, _state: int) -> None: + self._settings_store.set_fast_encoding(self.fast_encoding_checkbox.isChecked()) + 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: @@ -1468,17 +1473,6 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: timestamp = frame_data.timestamps.get(dlc_cam_id, time.time()) self._dlc.enqueue_frame(frame, timestamp) - # PRIORITY 2: Recording (queued, non-blocking) - # if self._rec_manager.is_active and src_id in frame_data.frames: - # frame = frame_data.frames[src_id] - - # if self.record_with_overlays_checkbox.isChecked(): - # # Draw overlays for recording - # frame = self._render_overlays_for_recording(src_id, frame) - - # ts = frame_data.timestamps.get(src_id, time.time()) - # self._rec_manager.write_frame(src_id, frame, ts) - def _on_multi_frame_display_ready(self, frame_data: MultiFrameData) -> None: """Throttled UI/display path. diff --git a/dlclivegui/utils/settings_store.py b/dlclivegui/utils/settings_store.py index fcf36fdd3..a0c5677f4 100644 --- a/dlclivegui/utils/settings_store.py +++ b/dlclivegui/utils/settings_store.py @@ -51,6 +51,15 @@ def get_use_timestamp(self, default: bool = True) -> bool: def set_use_timestamp(self, value: bool) -> None: self._s.setValue("recording/use_timestamp", bool(value)) + def get_fast_encoding(self, default: bool = False) -> bool: + value = self._s.value("recording/fast_encoding", default) + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + def set_fast_encoding(self, enabled: bool) -> None: + self._s.setValue("recording/fast_encoding", bool(enabled)) + # --- optional: snapshot full config as JSON in QSettings --- def save_full_config_snapshot(self, cfg: ApplicationSettings) -> None: self._s.setValue("app/config_json", cfg.model_dump_json()) diff --git a/tests/utils/test_settings_store.py b/tests/utils/test_settings_store.py index 7eba56aef..318dc49cd 100644 --- a/tests/utils/test_settings_store.py +++ b/tests/utils/test_settings_store.py @@ -95,6 +95,17 @@ def model_validate_json(raw: str): assert settstore.load_full_config_snapshot() is None +def test_qt_settings_store_fast_encoding_roundtrip(): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + settstore.set_fast_encoding(True) + assert settstore.get_fast_encoding(default=False) is True + + settstore.set_fast_encoding(False) + assert settstore.get_fast_encoding(default=True) is False + + # ----------------------------- # ModelPathStore helpers # ----------------------------- From 4bbf963c814f0ea59585277f5e01e9d83fd905f1 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 16:24:09 +0200 Subject: [PATCH 103/133] Always wire recording setting signal handlers Remove defensive `hasattr` checks when connecting recording settings signals in `DLCLiveMainWindow`. The widgets are expected to exist, so connecting unconditionally avoids silently skipping persistence and recording path preview updates if an expected widget is missing or renamed. --- dlclivegui/gui/main_window.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index e7feffc8c..0067873f1 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -802,16 +802,11 @@ def _connect_signals(self) -> None: # Recording settings ## Session name persistence + preview updates - if hasattr(self, "session_name_edit"): - self.session_name_edit.editingFinished.connect(self._on_session_name_editing_finished) - if hasattr(self, "use_timestamp_checkbox"): - self.use_timestamp_checkbox.stateChanged.connect(self._on_use_timestamp_changed) - if hasattr(self, "output_directory_edit"): - self.output_directory_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) - if hasattr(self, "filename_edit"): - self.filename_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) - if hasattr(self, "container_combo"): - self.container_combo.currentTextChanged.connect(lambda _t: self._update_recording_path_preview()) + self.session_name_edit.editingFinished.connect(self._on_session_name_editing_finished) + self.use_timestamp_checkbox.stateChanged.connect(self._on_use_timestamp_changed) + self.output_directory_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) + self.filename_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) + self.container_combo.currentTextChanged.connect(lambda _t: self._update_recording_path_preview()) self.fast_encoding_checkbox.stateChanged.connect(self._on_fast_encoding_changed) # ------------------------------------------------------------------ From 8cf943b1950800d7db2fc65f1a964a3c4a8385dc Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 16:33:20 +0200 Subject: [PATCH 104/133] Disable default timing logs and fix stats import Turns off multi-camera, recorder, and Basler timing logs by default to reduce debug noise in normal runs. Also cleans up stale priority wording in main window comments, updates VideoRecorder docstrings to reflect `writer_options`, and fixes stats tests to import `RecorderStats` from `dlclivegui.utils.stats`. --- dlclivegui/config.py | 6 +++--- dlclivegui/gui/main_window.py | 5 ++--- dlclivegui/services/video_recorder.py | 5 ++--- tests/utils/test_stats.py | 3 +-- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index c0befcb73..53629bbf4 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -25,11 +25,11 @@ ## Debug ### Timing logs SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False -MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = True -REC_DO_LOG_TIMING: bool = True +MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False +REC_DO_LOG_TIMING: bool = False # MAIN_WINDOW_DO_LOG_TIMING: bool = False #### Backends -BASLER_DO_LOG_TIMING: bool = True +BASLER_DO_LOG_TIMING: bool = False class CameraSettings(BaseModel): diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 0067873f1..c877f9827 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1420,8 +1420,7 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: """Handle frames from multiple cameras. Priority: - 1. DLC processing (highest priority - enqueue immediately, only for DLC camera) - 2. Recording (queued writes, non-blocking) + - DLC processing (highest priority - enqueue immediately, only for DLC camera) """ self._multi_camera_frames = frame_data.frames self._multi_camera_display_ids = frame_data.display_ids or {} @@ -1462,7 +1461,7 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: self._raw_frame = frame self._dlc_tile_offset, self._dlc_tile_scale = compute_tile_info(dlc_cam_id, frame, frame_data.frames) - # PRIORITY 1: DLC processing - only enqueue when DLC camera frame arrives! + # PRIORITY: DLC processing - only enqueue when DLC camera frame arrives! if self._dlc_active and is_dlc_camera_frame and dlc_cam_id in frame_data.frames: frame = frame_data.frames[dlc_cam_id] timestamp = frame_data.timestamps.get(dlc_cam_id, time.time()) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index e0b54c01a..cce1422b8 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -61,9 +61,8 @@ class VideoRecorder: convert_grayscale_to_rgb: Whether 2D grayscale frames should be expanded to 3-channel RGB before writing. Set to `False` to preserve mono frames when supported by the chosen writer/codec path. - fast_encoding: Whether to apply faster FFmpeg encoder settings when - supported by the selected codec. This can improve throughput at the - cost of larger files and/or reduced compression efficiency. + writer_options: Optional dictionary of additional keyword arguments passed + to `WriteGear`. If provided, this overrides the default options. Attributes: is_running: Whether the writer thread is currently alive. diff --git a/tests/utils/test_stats.py b/tests/utils/test_stats.py index bd207cf9e..bc1ae31f2 100644 --- a/tests/utils/test_stats.py +++ b/tests/utils/test_stats.py @@ -4,8 +4,7 @@ from hypothesis import given, settings from hypothesis import strategies as st -from dlclivegui.gui.recording_manager import RecorderStats -from dlclivegui.utils.stats import format_dlc_stats, format_recorder_stats +from dlclivegui.utils.stats import RecorderStats, format_dlc_stats, format_recorder_stats pytestmark = pytest.mark.unit From bd4f411df3d37adbc1264423c55167b768d8a8b9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 16:33:53 +0200 Subject: [PATCH 105/133] Fix writer defaults and buffer time fallback Ensure ffmpeg writer defaults (`-input_framerate`, `-vcodec`, `-crf`) are always applied, even when custom writer options are provided, while still allowing overrides via `writer_options`. Also improve recorder stats by estimating `buffer_seconds` from average or last frame latency when write FPS is unavailable, avoiding zero/underreported buffer duration. --- dlclivegui/services/video_recorder.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index cce1422b8..0b77a308c 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -186,16 +186,17 @@ def start(self) -> None: "logging": False, } + codec_value = (self._codec or "libx264").strip() or "libx264" + writer_kwargs: dict[str, Any] = { + "compression_mode": True, + "logging": False, + "-input_framerate": fps_value, + "-vcodec": codec_value, + "-crf": int(self._crf), + } + if self._writer_options is not None: writer_kwargs.update(self._writer_options) - else: - writer_kwargs.update( - { - "-input_framerate": fps_value, - "-vcodec": codec_value, - "-crf": int(self._crf), - } - ) # if not self._convert_grayscale_to_rgb: # writer_kwargs.update( @@ -393,7 +394,15 @@ def get_stats(self) -> RecorderStats | None: avg_latency = self._total_latency / self._frames_written if self._frames_written else 0.0 last_latency = self._last_latency write_fps = self._compute_write_fps_locked() - buffer_seconds = queue_size / write_fps if write_fps > 0 else 0.0 + + if write_fps > 0: + buffer_seconds = queue_size / write_fps + elif avg_latency > 0: + buffer_seconds = queue_size * avg_latency + elif last_latency > 0: + buffer_seconds = queue_size * last_latency + else: + buffer_seconds = 0.0 return RecorderStats( frames_enqueued=frames_enqueued, frames_written=frames_written, From 25e615ef190ed7653a9dfab95d754d3eed1cfd86 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 16:39:49 +0200 Subject: [PATCH 106/133] Update video_recorder.py --- dlclivegui/services/video_recorder.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index 0b77a308c..d8e3cb54a 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -180,12 +180,6 @@ def start(self) -> None: self._writer_options, ) - codec_value = (self._codec or "libx264").strip() or "libx264" - writer_kwargs: dict[str, Any] = { - "compression_mode": True, - "logging": False, - } - codec_value = (self._codec or "libx264").strip() or "libx264" writer_kwargs: dict[str, Any] = { "compression_mode": True, From 9f3ae98452b992097c340be2768e821e4989d1b6 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 30 Jun 2026 14:33:22 +0200 Subject: [PATCH 107/133] Defer recording until preview frames are ready Adds a pending-recording flow in the main window so "Start recording" while preview is stopped first starts preview, then begins recording only after all active cameras have produced frames. The pending state is cleared on stop/error/init failure to avoid stale triggers and duplicate starts. Adds GUI tests covering deferred start, waiting for all camera frames, frame-ready trigger behavior, and no double-starts. --- dlclivegui/gui/main_window.py | 33 ++++++ tests/gui/test_recording_gui.py | 173 ++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 tests/gui/test_recording_gui.py diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index c877f9827..13161ed10 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -137,6 +137,7 @@ def __init__(self, config: ApplicationSettings | None = None): self._raw_frame: np.ndarray | None = None self._last_pose: PoseResult | None = None self._dlc_active: bool = False + self._pending_recording_after_preview = False self._active_camera_settings: CameraSettings | None = None self._last_drop_warning = 0.0 self._last_recorder_summary = "Recorder idle" @@ -1424,6 +1425,7 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: """ self._multi_camera_frames = frame_data.frames self._multi_camera_display_ids = frame_data.display_ids or {} + self._try_start_pending_recording() src_id = frame_data.source_camera_id if src_id: self._fps_tracker.note_frame(src_id) # Track FPS @@ -1489,6 +1491,7 @@ def _on_multi_camera_stopped(self) -> None: """Handle all cameras stopped event.""" # Stop all multi-camera recorders self._stop_multi_camera_recording() + self._pending_recording_after_preview = False self.preview_button.setEnabled(True) self.stop_preview_button.setEnabled(False) @@ -1503,6 +1506,7 @@ def _on_multi_camera_stopped(self) -> None: def _on_multi_camera_error(self, camera_id: str, message: str) -> None: """Handle error from a camera in multi-camera mode.""" + self._pending_recording_after_preview = False self._show_warning(f"Camera {camera_id} error: {message}\nRecording stopped.") self._refresh_dlc_camera_list_running() if self.dlc_camera_combo.count() <= 1: @@ -1511,6 +1515,7 @@ def _on_multi_camera_error(self, camera_id: str, message: str) -> None: def _on_multi_camera_initialization_failed(self, failures: list) -> None: """Handle complete failure to initialize cameras.""" + self._pending_recording_after_preview = False # Build error message with details for each failed camera error_lines = ["Failed to initialize camera(s):"] for camera_id, error_msg in failures: @@ -1671,6 +1676,7 @@ def _stop_preview(self) -> None: self._stop_multi_camera_recording() self.multi_camera_controller.stop() + self._pending_recording_after_preview = False self._stop_inference(show_message=False) self._fps_tracker.clear() self._last_display_time = 0.0 @@ -1954,6 +1960,7 @@ def _start_recording(self) -> None: """Start recording from all active cameras.""" # Auto-start preview if not running if not self.multi_camera_controller.is_running(): + self._pending_recording_after_preview = True self._start_preview() # Wait a moment for cameras to initialize before recording # The recording will start after preview is confirmed running @@ -1965,6 +1972,32 @@ def _start_recording(self) -> None: # Preview already running, start recording immediately self._start_multi_camera_recording() + def _try_start_pending_recording(self) -> None: + if not self._pending_recording_after_preview: + return + + if self._rec_manager.is_active: + self._pending_recording_after_preview = False + return + + if not self.multi_camera_controller.is_running(): + return + + active_cams = self._config.multi_camera.get_active_cameras() + expected_ids = {get_camera_id(cam) for cam in active_cams} + + if not expected_ids: + self._pending_recording_after_preview = False + return + + available_ids = set(self._multi_camera_frames.keys()) + + if not expected_ids.issubset(available_ids): + return + + self._pending_recording_after_preview = False + self._start_multi_camera_recording() + def _stop_recording(self) -> None: """Stop recording from all cameras.""" self._stop_multi_camera_recording() diff --git a/tests/gui/test_recording_gui.py b/tests/gui/test_recording_gui.py new file mode 100644 index 000000000..9ef4c4c32 --- /dev/null +++ b/tests/gui/test_recording_gui.py @@ -0,0 +1,173 @@ +import numpy as np +import pytest + +from dlclivegui.services.multi_camera_controller import MultiFrameData, get_camera_id + + +@pytest.mark.gui +class TestPendingRecordingAfterPreview: + def test_start_recording_when_preview_stopped_defers_until_preview_frames( + self, + window, + monkeypatch, + ): + calls = { + "start_preview": 0, + "start_recording": 0, + } + + monkeypatch.setattr( + window.multi_camera_controller, + "is_running", + lambda: False, + ) + + def fake_start_preview(): + calls["start_preview"] += 1 + + def fake_start_multi_camera_recording(): + calls["start_recording"] += 1 + + monkeypatch.setattr(window, "_start_preview", fake_start_preview) + monkeypatch.setattr(window, "_start_multi_camera_recording", fake_start_multi_camera_recording) + + window._pending_recording_after_preview = False + + window._start_recording() + + assert calls["start_preview"] == 1 + assert calls["start_recording"] == 0 + assert window._pending_recording_after_preview is True + + def test_pending_recording_waits_until_all_active_cameras_have_frames( + self, + window, + monkeypatch, + ): + active_cams = window._config.multi_camera.get_active_cameras() + assert len(active_cams) >= 2 + + cam0_id = get_camera_id(active_cams[0]) + cam1_id = get_camera_id(active_cams[1]) + + calls = { + "start_recording": 0, + } + + monkeypatch.setattr( + window.multi_camera_controller, + "is_running", + lambda: True, + ) + + def fake_start_multi_camera_recording(): + calls["start_recording"] += 1 + + monkeypatch.setattr(window, "_start_multi_camera_recording", fake_start_multi_camera_recording) + + window._pending_recording_after_preview = True + window._multi_camera_frames = { + cam0_id: np.zeros((10, 10, 3), dtype=np.uint8), + } + + window._try_start_pending_recording() + + assert calls["start_recording"] == 0 + assert window._pending_recording_after_preview is True + + window._multi_camera_frames[cam1_id] = np.zeros((10, 10, 3), dtype=np.uint8) + + window._try_start_pending_recording() + + assert calls["start_recording"] == 1 + assert window._pending_recording_after_preview is False + + def test_pending_recording_is_triggered_from_multi_frame_processing_ready( + self, + window, + monkeypatch, + ): + active_cams = window._config.multi_camera.get_active_cameras() + assert len(active_cams) >= 2 + + cam0_id = get_camera_id(active_cams[0]) + cam1_id = get_camera_id(active_cams[1]) + + calls = { + "start_recording": 0, + } + + monkeypatch.setattr( + window.multi_camera_controller, + "is_running", + lambda: True, + ) + + def fake_start_multi_camera_recording(): + calls["start_recording"] += 1 + + monkeypatch.setattr(window, "_start_multi_camera_recording", fake_start_multi_camera_recording) + + window._pending_recording_after_preview = True + + frame0 = np.zeros((10, 10, 3), dtype=np.uint8) + frame1 = np.zeros((10, 10, 3), dtype=np.uint8) + + frame_data = MultiFrameData( + frames={ + cam0_id: frame0, + cam1_id: frame1, + }, + timestamps={ + cam0_id: 1.0, + cam1_id: 1.0, + }, + source_camera_id=cam0_id, + display_ids={ + cam0_id: "Cam0", + cam1_id: "Cam1", + }, + ) + + window._on_multi_frame_processing_ready(frame_data) + + assert calls["start_recording"] == 1 + assert window._pending_recording_after_preview is False + + def test_pending_recording_does_not_start_twice( + self, + window, + monkeypatch, + ): + active_cams = window._config.multi_camera.get_active_cameras() + assert len(active_cams) >= 2 + + cam0_id = get_camera_id(active_cams[0]) + cam1_id = get_camera_id(active_cams[1]) + + calls = { + "start_recording": 0, + } + + monkeypatch.setattr( + window.multi_camera_controller, + "is_running", + lambda: True, + ) + + def fake_start_multi_camera_recording(): + calls["start_recording"] += 1 + + monkeypatch.setattr(window, "_start_multi_camera_recording", fake_start_multi_camera_recording) + + window._pending_recording_after_preview = True + window._multi_camera_frames = { + cam0_id: np.zeros((10, 10, 3), dtype=np.uint8), + cam1_id: np.zeros((10, 10, 3), dtype=np.uint8), + } + + window._try_start_pending_recording() + window._try_start_pending_recording() + + assert calls["start_recording"] == 1 + assert window._pending_recording_after_preview is False From 26c576c6750eabd86c080d01d5f06d55627085ed Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 30 Jun 2026 16:15:53 +0200 Subject: [PATCH 108/133] Disable delayed auto-start of recording In the multi-camera recording flow, the `QTimer.singleShot` call that automatically triggered `_start_multi_camera_recording` after starting preview is commented out. This stops the delayed automatic recording start when preview is not yet running. --- dlclivegui/gui/main_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 13161ed10..24cf2cee9 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1966,7 +1966,7 @@ def _start_recording(self) -> None: # The recording will start after preview is confirmed running self.statusBar().showMessage("Starting preview before recording...", 3000) # Use a single-shot timer to start recording after preview starts - QTimer.singleShot(500, self._start_multi_camera_recording) + # QTimer.singleShot(500, self._start_multi_camera_recording) return # Preview already running, start recording immediately From 526b7eddbfb0d782ecd2d2ffeee6c1d9782e9bd8 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 30 Jun 2026 16:15:59 +0200 Subject: [PATCH 109/133] Update main_window.py --- dlclivegui/gui/main_window.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 24cf2cee9..478f888cd 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -800,6 +800,8 @@ def _connect_signals(self) -> None: 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) + self.allow_processor_ctrl_checkbox.stateChanged.connect(lambda _s: self._update_dlc_controls_enabled()) + self.allow_processor_ctrl_checkbox.stateChanged.connect(lambda _s: self._update_processor_status()) # Recording settings ## Session name persistence + preview updates @@ -1486,6 +1488,7 @@ def _on_multi_camera_started(self) -> None: self.statusBar().showMessage(f"Multi-camera preview started: {active_count} camera(s)", 5000) self._update_inference_buttons() self._update_camera_controls_enabled() + self._update_dlc_controls_enabled() def _on_multi_camera_stopped(self) -> None: """Handle all cameras stopped event.""" @@ -1503,6 +1506,7 @@ def _on_multi_camera_stopped(self) -> None: self.statusBar().showMessage("Multi-camera preview stopped", 3000) self._update_inference_buttons() self._update_camera_controls_enabled() + self._update_dlc_controls_enabled() def _on_multi_camera_error(self, camera_id: str, message: str) -> None: """Handle error from a camera in multi-camera mode.""" From bb4b7fda163c81503335a70998bff8f66de60dfe Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 30 Jun 2026 16:40:43 +0200 Subject: [PATCH 110/133] Lock processor settings during DLC inference Disable all DLC and processor configuration widgets consistently while inference is active, including the processor-control checkbox. Refactor processor discovery into shared helpers that detect direct and indirect `dlclive.Processor` subclasses, standardize metadata extraction, and reuse the same fallback logic for package scans and file-based loading. --- dlclivegui/gui/main_window.py | 10 ++- dlclivegui/processors/processor_utils.py | 96 +++++++++++++++--------- 2 files changed, 67 insertions(+), 39 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 478f888cd..bc1e2a649 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1730,24 +1730,28 @@ def _update_inference_buttons(self) -> None: def _update_dlc_controls_enabled(self) -> None: """Enable/disable DLC settings based on inference state.""" allow_changes = not self._dlc_active - processor_controls = allow_changes and self._processor_control_enabled() widgets = [ self.model_path_edit, self.browse_model_button, self.dlc_camera_combo, - # self.additional_options_edit, ] + processor_widgets = [ self.processor_folder_edit, self.browse_processor_folder_button, self.refresh_processors_button, self.processor_combo, ] + for widget in widgets: widget.setEnabled(allow_changes) + for widget in processor_widgets: - widget.setEnabled(processor_controls) + widget.setEnabled(allow_changes) + + if hasattr(self, "allow_processor_ctrl_checkbox"): + self.allow_processor_ctrl_checkbox.setEnabled(allow_changes) def _update_camera_controls_enabled(self) -> None: multi_cam_recording = self._rec_manager.is_active diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index b32445c38..58b48f415 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -17,6 +17,64 @@ def default_processors_dir() -> str: return str(path) +def _processor_base_class(): + from dlclive import Processor + + return Processor + + +def _is_processor_subclass(obj, *, include_base: bool = False) -> bool: + """Return True for dlclive.Processor subclasses, including indirect subclasses.""" + if not inspect.isclass(obj): + return False + + try: + processor_base = _processor_base_class() + except Exception: + logger.exception("Could not import dlclive.Processor") + return False + + try: + if obj is processor_base: + return bool(include_base) + return issubclass(obj, processor_base) + except TypeError: + return False + + +def _processor_info_from_class(cls, fallback_name: str) -> dict: + return { + "class": cls, + "name": getattr(cls, "PROCESSOR_NAME", fallback_name), + "description": getattr(cls, "PROCESSOR_DESCRIPTION", ""), + "params": getattr(cls, "PROCESSOR_PARAMS", {}), + } + + +def discover_processor_classes(module, *, only_defined_in_module: bool = True) -> dict[str, dict]: + """Discover dlclive.Processor subclasses in a module. + + Includes indirect subclasses of Processor. + + Args: + module: Imported Python module. + only_defined_in_module: If True, ignore Processor subclasses imported + from other modules to avoid duplicate registry entries. + """ + processors: dict[str, dict] = {} + + for name, obj in inspect.getmembers(module, inspect.isclass): + if only_defined_in_module and getattr(obj, "__module__", None) != module.__name__: + continue + + if not _is_processor_subclass(obj): + continue + + processors[name] = _processor_info_from_class(obj, name) + + return processors + + def scan_processor_folder(folder_path): all_processors = {} folder = Path(folder_path) @@ -65,22 +123,7 @@ def scan_processor_package(package_name: str = "dlclivegui.processors") -> dict[ processors = mod.get_available_processors() else: # Fallback: scan for dlclive.Processor subclasses - from dlclive import Processor - - processors = {} - for attr_name in dir(mod): - obj = getattr(mod, attr_name) - try: - if isinstance(obj, type) and obj is not Processor and issubclass(obj, Processor): - processors[attr_name] = { - "class": obj, - "name": getattr(obj, "PROCESSOR_NAME", attr_name), - "description": getattr(obj, "PROCESSOR_DESCRIPTION", ""), - "params": getattr(obj, "PROCESSOR_PARAMS", {}), - } - except Exception: - # Non-class or weird metaclass; ignore - pass + processors = discover_processor_classes(mod) # Normalize into your “file::class” shape module_file = mod.__name__.split(".")[-1] + ".py" @@ -131,26 +174,7 @@ def load_processors_from_file(file_path: str | Path): return processors # Fallback path: discover subclasses of dlclive.Processor - from dlclive import Processor - - processors: dict[str, dict] = {} - for name, obj in inspect.getmembers(module, inspect.isclass): - if obj is Processor: - continue - # Guard: module might define other classes; only include Processor subclasses - try: - if issubclass(obj, Processor): - processors[name] = { - "class": obj, - "name": getattr(obj, "PROCESSOR_NAME", name), - "description": getattr(obj, "PROCESSOR_DESCRIPTION", ""), - "params": getattr(obj, "PROCESSOR_PARAMS", {}), - } - except Exception: - # Some "classes" can fail issubclass checks; ignore safely - continue - - return processors + return discover_processor_classes(module) except Exception: # Full traceback helps a ton when a plugin fails to import From 136aafa98e999582fdec37bcc1a8f9660bc9d311 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 09:50:45 +0200 Subject: [PATCH 111/133] Improve processor discovery and logging Expand processor class discovery to include re-exported classes by disabling module-only filtering in package/file scans. Also broaden subclass-check error handling to catch unexpected exceptions and log full context when discovery encounters problematic objects. --- dlclivegui/processors/processor_utils.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index 58b48f415..8f606d8b5 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -38,7 +38,8 @@ def _is_processor_subclass(obj, *, include_base: bool = False) -> bool: if obj is processor_base: return bool(include_base) return issubclass(obj, processor_base) - except TypeError: + except Exception: + logger.exception(f"Error checking if {obj} is a subclass of dlclive.Processor") return False @@ -123,7 +124,7 @@ def scan_processor_package(package_name: str = "dlclivegui.processors") -> dict[ processors = mod.get_available_processors() else: # Fallback: scan for dlclive.Processor subclasses - processors = discover_processor_classes(mod) + processors = discover_processor_classes(mod, only_defined_in_module=False) # Normalize into your “file::class” shape module_file = mod.__name__.split(".")[-1] + ".py" @@ -174,7 +175,8 @@ def load_processors_from_file(file_path: str | Path): return processors # Fallback path: discover subclasses of dlclive.Processor - return discover_processor_classes(module) + # here module only is disabled to allow classes re-exported in other modules to be discovered + return discover_processor_classes(module, only_defined_in_module=False) except Exception: # Full traceback helps a ton when a plugin fails to import From cf92412bbc471ba243560b8601ccd86eb1451596 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:06:38 +0200 Subject: [PATCH 112/133] Add processors package exports Create `dlclivegui/processors/__init__.py` to re-export `register_processor`, `BaseProcessorSocket`, and `PROCESSOR_REGISTRY` from `dlc_processor_socket`, making these APIs available via package-level imports. --- dlclivegui/processors/__init__.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 dlclivegui/processors/__init__.py diff --git a/dlclivegui/processors/__init__.py b/dlclivegui/processors/__init__.py new file mode 100644 index 000000000..ee94194dd --- /dev/null +++ b/dlclivegui/processors/__init__.py @@ -0,0 +1,3 @@ +from .dlc_processor_socket import PROCESSOR_REGISTRY, BaseProcessorSocket, register_processor + +__all__ = ["register_processor", "BaseProcessorSocket", "PROCESSOR_REGISTRY"] From fc6c34fac6703d08c44156bac998c0180c6f26d4 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:08:11 +0200 Subject: [PATCH 113/133] Move example socket processors to examples module Refactors `dlc_processor_socket.py` by removing the in-file example processors and `OneEuroFilter`, and adds them to a new `dlclivegui/processors/examples.py` module. This separates demonstration/experiment-specific logic from the core socket processor implementation, improving maintainability while preserving existing example processor behavior. --- dlclivegui/processors/dlc_processor_socket.py | 377 ----------------- dlclivegui/processors/examples.py | 387 ++++++++++++++++++ 2 files changed, 387 insertions(+), 377 deletions(-) create mode 100644 dlclivegui/processors/examples.py diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index 8ded01069..b4f786f44 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -7,7 +7,6 @@ import sys import time from collections import deque -from math import acos, atan2, copysign, degrees, pi, sqrt from multiprocessing.connection import Client, Listener from pathlib import Path from threading import Event, Thread @@ -39,45 +38,6 @@ def register_processor(cls): return cls -class OneEuroFilter: # pragma: no cover - def __init__(self, t0, x0, dx0=None, min_cutoff=1.0, beta=0.0, d_cutoff=1.0): - self.min_cutoff = min_cutoff - self.beta = beta - self.d_cutoff = d_cutoff - self.x_prev = x0 - if dx0 is None: - dx0 = np.zeros_like(x0) - self.dx_prev = dx0 - self.t_prev = t0 - - @staticmethod - def smoothing_factor(t_e, cutoff): - r = 2 * pi * cutoff * t_e - return r / (r + 1) - - @staticmethod - def exponential_smoothing(alpha, x, x_prev): - return alpha * x + (1 - alpha) * x_prev - - def __call__(self, t, x): - t_e = t - self.t_prev - if t_e <= 0: - return x - a_d = self.smoothing_factor(t_e, self.d_cutoff) - dx = (x - self.x_prev) / t_e - dx_hat = self.exponential_smoothing(a_d, dx, self.dx_prev) - - cutoff = self.min_cutoff + self.beta * abs(dx_hat) - a = self.smoothing_factor(t_e, cutoff) - x_hat = self.exponential_smoothing(a, x, self.x_prev) - - self.x_prev = x_hat - self.dx_prev = dx_hat - self.t_prev = t - - return x_hat - - # pragma: cover class BaseProcessorSocket(Processor): """ @@ -476,343 +436,6 @@ def get_data(self): return save_dict -@register_processor -class ExampleProcessorSocketCalculateMousePose(BaseProcessorSocket): # pragma: no cover - """ - DLC Processor with pose calculations (center, heading, head angle) and optional filtering. - - Calculates: - - center: Weighted average of head keypoints - - heading: Body orientation (degrees) - - head_angle: Head rotation relative to body (radians) - - Broadcasts: [timestamp, center_x, center_y, heading, head_angle] - """ - - PROCESSOR_NAME = "Example Experiment Pose Processor" - PROCESSOR_DESCRIPTION = "Calculates mouse center, heading, and head angle with optional One-Euro filtering" - PROCESSOR_PARAMS = { - "bind": { - "type": "tuple", - "default": ("127.0.0.1", 6000), - "description": "Server address (host, port)", - }, - "authkey": { - "type": "bytes", - "default": b"secret password", - "description": "Authentication key for clients", - }, - "use_perf_counter": { - "type": "bool", - "default": False, - "description": "Use time.perf_counter() instead of time.time()", - }, - "use_filter": { - "type": "bool", - "default": False, - "description": "Apply One-Euro filter to calculated values", - }, - "filter_kwargs": { - "type": "dict", - "default": {"min_cutoff": 1.0, "beta": 0.02, "d_cutoff": 1.0}, - "description": "One-Euro filter parameters (min_cutoff, beta, d_cutoff)", - }, - "save_original": { - "type": "bool", - "default": False, - "description": "Save raw pose arrays for analysis", - }, - } - - def __init__( - self, - bind=("127.0.0.1", 6000), - authkey=b"secret password", - use_perf_counter=False, - use_filter=False, - filter_kwargs: dict | None = None, - save_original=False, - ): - super().__init__( - bind=bind, - authkey=authkey, - use_perf_counter=use_perf_counter, - save_original=save_original, - ) - - self.center_x = deque() - self.center_y = deque() - self.heading_direction = deque() - self.head_angle = deque() - - self.use_filter = use_filter - self.filter_kwargs = filter_kwargs if filter_kwargs is not None else {} - self.filters = None - - def _clear_data_queues(self): - super()._clear_data_queues() - self.center_x.clear() - self.center_y.clear() - self.heading_direction.clear() - self.head_angle.clear() - - def _initialize_filters(self, vals): - t0 = self.timing_func() - self.filters = { - "center_x": OneEuroFilter(t0, vals[0], **self.filter_kwargs), - "center_y": OneEuroFilter(t0, vals[1], **self.filter_kwargs), - "heading": OneEuroFilter(t0, vals[2], **self.filter_kwargs), - "head_angle": OneEuroFilter(t0, vals[3], **self.filter_kwargs), - } - logger.debug(f"Initialized One-Euro filters with parameters: {self.filter_kwargs}") - - def process(self, pose, **kwargs): - # Extract keypoints and confidence - xy = pose[:, :2] - conf = pose[:, 2] - - # Calculate weighted center from head keypoints - head_xy = xy[[0, 1, 2, 3, 4, 5, 6, 26], :] - head_conf = conf[[0, 1, 2, 3, 4, 5, 6, 26]] - center = np.average(head_xy, axis=0, weights=head_conf) - - # Calculate body axis (tail_base -> neck) - body_axis = xy[7] - xy[13] - body_axis /= sqrt(np.sum(body_axis**2)) - - # Calculate head axis (neck -> nose) - head_axis = xy[0] - xy[7] - head_axis /= sqrt(np.sum(head_axis**2)) - - # Calculate head angle relative to body - cross = body_axis[0] * head_axis[1] - head_axis[0] * body_axis[1] - sign = copysign(1, cross) # Positive when looking left - sign = copysign(1, cross) - try: - head_angle = acos(body_axis @ head_axis) * sign - except ValueError: - head_angle = 0 - - # Calculate heading (body orientation) - heading = degrees(atan2(body_axis[1], body_axis[0])) - - # Raw values (heading unwrapped for filtering) - vals = [center[0], center[1], heading, head_angle] - - # Apply filtering if enabled - curr_time = self.timing_func() - if self.use_filter: - if self.filters is None: - self._initialize_filters(vals) - - vals = [ - self.filters["center_x"](curr_time, vals[0]), - self.filters["center_y"](curr_time, vals[1]), - self.filters["heading"](curr_time, vals[2]), - self.filters["head_angle"](curr_time, vals[3]), - ] - - # Wrap heading to [0, 360) after filtering - vals[2] = vals[2] % 360 - # Update step counter - self.curr_step = self.curr_step + 1 - - # Store processed data (only if recording) - if self.recording: - if self.save_original and self.original_pose is not None: - self.original_pose.append(pose.copy()) - self.center_x.append(vals[0]) - self.center_y.append(vals[1]) - self.heading_direction.append(vals[2]) - self.head_angle.append(vals[3]) - self.time_stamp.append(curr_time) - self.step.append(self.curr_step) - self.frame_time.append(kwargs.get("frame_time", -1)) - if "pose_time" in kwargs: - self.pose_time.append(kwargs["pose_time"]) - - payload = [curr_time, vals[0], vals[1], vals[2], vals[3]] - self.broadcast(payload) - return pose - - def get_data(self): - save_dict = super().get_data() - save_dict["x_pos"] = np.array(self.center_x) - save_dict["y_pos"] = np.array(self.center_y) - save_dict["heading_direction"] = np.array(self.heading_direction) - save_dict["head_angle"] = np.array(self.head_angle) - save_dict["use_filter"] = self.use_filter - save_dict["filter_kwargs"] = self.filter_kwargs - return save_dict - - -@register_processor -class ExampleProcessorSocketFilterKeypoints(BaseProcessorSocket): # pragma: no cover - PROCESSOR_NAME = "Mouse Pose with less keypoints" - PROCESSOR_DESCRIPTION = "Calculates mouse center, heading, and head angle with optional One-Euro filtering" - PROCESSOR_PARAMS = { - "bind": { - "type": "tuple", - "default": ("127.0.0.1", 6000), - "description": "Server address (host, port)", - }, - "authkey": { - "type": "bytes", - "default": b"secret password", - "description": "Authentication key for clients", - }, - "use_perf_counter": { - "type": "bool", - "default": False, - "description": "Use time.perf_counter() instead of time.time()", - }, - "use_filter": { - "type": "bool", - "default": False, - "description": "Apply One-Euro filter to calculated values", - }, - "filter_kwargs": { - "type": "dict", - "default": {"min_cutoff": 1.0, "beta": 0.02, "d_cutoff": 1.0}, - "description": "One-Euro filter parameters (min_cutoff, beta, d_cutoff)", - }, - "save_original": { - "type": "bool", - "default": True, - "description": "Save raw pose arrays for analysis", - }, - } - - def __init__( - self, - bind=("127.0.0.1", 6000), - authkey=b"secret password", - use_perf_counter=False, - use_filter=False, - filter_kwargs: dict | None = None, - save_original=True, - p_cutoff=0.4, - ): - super().__init__( - bind=bind, - authkey=authkey, - use_perf_counter=use_perf_counter, - save_original=save_original, - ) - - self.center_x = deque() - self.center_y = deque() - self.heading_direction = deque() - self.head_angle = deque() - - self.p_cutoff = p_cutoff - - self.use_filter = use_filter - self.filter_kwargs = filter_kwargs if filter_kwargs is not None else {} - self.filters = None - - def _clear_data_queues(self): - super()._clear_data_queues() - self.center_x.clear() - self.center_y.clear() - self.heading_direction.clear() - self.head_angle.clear() - - def _initialize_filters(self, vals): - t0 = self.timing_func() - self.filters = { - "center_x": OneEuroFilter(t0, vals[0], **self.filter_kwargs), - "center_y": OneEuroFilter(t0, vals[1], **self.filter_kwargs), - "heading": OneEuroFilter(t0, vals[2], **self.filter_kwargs), - "head_angle": OneEuroFilter(t0, vals[3], **self.filter_kwargs), - } - logger.debug(f"Initialized One-Euro filters with parameters: {self.filter_kwargs}") - - def process(self, pose, **kwargs): - # Extract keypoints and confidence - xy = pose[:, :2] - conf = pose[:, 2] - - # Calculate weighted center from head keypoints - head_xy = xy[[0, 1, 2, 3, 5, 6, 7], :] - head_conf = conf[[0, 1, 2, 3, 5, 6, 7]] - # set low confidence keypoints to zero weight - head_conf = np.where(head_conf < self.p_cutoff, 0, head_conf) - try: - center = np.average(head_xy, axis=0, weights=head_conf) - except ZeroDivisionError: - # If all keypoints have zero weight, return without processing - return pose - - neck = np.average(xy[[2, 3, 6, 7], :], axis=0, weights=conf[[2, 3, 6, 7]]) - - # Calculate body axis (tail_base -> neck) - body_axis = neck - xy[9] - body_axis /= sqrt(np.sum(body_axis**2)) - - # Calculate head axis (neck -> nose) - head_axis = xy[0] - neck - head_axis /= sqrt(np.sum(head_axis**2)) - - # Calculate head angle relative to body - cross = body_axis[0] * head_axis[1] - head_axis[0] * body_axis[1] - sign = copysign(1, cross) # Positive when looking left - sign = copysign(1, cross) - try: - head_angle = acos(body_axis @ head_axis) * sign - except ValueError: - head_angle = 0 - - # Calculate heading (body orientation) - heading = degrees(atan2(body_axis[1], body_axis[0])) - vals = [center[0], center[1], heading, head_angle] - - curr_time = self.timing_func() - if self.use_filter: - if self.filters is None: - self._initialize_filters(vals) - - vals = [ - self.filters["center_x"](curr_time, vals[0]), - self.filters["center_y"](curr_time, vals[1]), - self.filters["heading"](curr_time, vals[2]), - self.filters["head_angle"](curr_time, vals[3]), - ] - - # Wrap heading to [0, 360) after filtering - vals[2] = vals[2] % 360 - # Update step counter - self.curr_step = self.curr_step + 1 - - # Store processed data (only if recording) - if self.recording: - if self.save_original and self.original_pose is not None: - self.original_pose.append(pose.copy()) - self.center_x.append(vals[0]) - self.center_y.append(vals[1]) - self.heading_direction.append(vals[2]) - self.head_angle.append(vals[3]) - self.time_stamp.append(curr_time) - self.step.append(self.curr_step) - self.frame_time.append(kwargs.get("frame_time", -1)) - if "pose_time" in kwargs: - self.pose_time.append(kwargs["pose_time"]) - - payload = [curr_time, vals[0], vals[1], vals[2], vals[3]] - self.broadcast(payload) - return pose - - def get_data(self): - save_dict = super().get_data() - save_dict["x_pos"] = np.array(self.center_x) - save_dict["y_pos"] = np.array(self.center_y) - save_dict["heading_direction"] = np.array(self.heading_direction) - save_dict["head_angle"] = np.array(self.head_angle) - save_dict["use_filter"] = self.use_filter - save_dict["filter_kwargs"] = self.filter_kwargs - return save_dict - - def get_available_processors(): """ Get list of available processor classes. diff --git a/dlclivegui/processors/examples.py b/dlclivegui/processors/examples.py new file mode 100644 index 000000000..feb6ac3c9 --- /dev/null +++ b/dlclivegui/processors/examples.py @@ -0,0 +1,387 @@ +from __future__ import annotations + +import logging +from collections import deque +from math import acos, atan2, copysign, degrees, pi, sqrt + +import numpy as np + +from dlclivegui.processors import BaseProcessorSocket, register_processor + +logger = logging.getLogger(__name__) + + +class OneEuroFilter: # pragma: no cover + def __init__(self, t0, x0, dx0=None, min_cutoff=1.0, beta=0.0, d_cutoff=1.0): + self.min_cutoff = min_cutoff + self.beta = beta + self.d_cutoff = d_cutoff + self.x_prev = x0 + if dx0 is None: + dx0 = np.zeros_like(x0) + self.dx_prev = dx0 + self.t_prev = t0 + + @staticmethod + def smoothing_factor(t_e, cutoff): + r = 2 * pi * cutoff * t_e + return r / (r + 1) + + @staticmethod + def exponential_smoothing(alpha, x, x_prev): + return alpha * x + (1 - alpha) * x_prev + + def __call__(self, t, x): + t_e = t - self.t_prev + if t_e <= 0: + return x + a_d = self.smoothing_factor(t_e, self.d_cutoff) + dx = (x - self.x_prev) / t_e + dx_hat = self.exponential_smoothing(a_d, dx, self.dx_prev) + + cutoff = self.min_cutoff + self.beta * abs(dx_hat) + a = self.smoothing_factor(t_e, cutoff) + x_hat = self.exponential_smoothing(a, x, self.x_prev) + + self.x_prev = x_hat + self.dx_prev = dx_hat + self.t_prev = t + + return x_hat + + +@register_processor +class ExampleProcessorSocketCalculateMousePose(BaseProcessorSocket): # pragma: no cover + """ + DLC Processor with pose calculations (center, heading, head angle) and optional filtering. + + Calculates: + - center: Weighted average of head keypoints + - heading: Body orientation (degrees) + - head_angle: Head rotation relative to body (radians) + + Broadcasts: [timestamp, center_x, center_y, heading, head_angle] + """ + + PROCESSOR_NAME = "Example Experiment Pose Processor" + PROCESSOR_DESCRIPTION = "Calculates mouse center, heading, and head angle with optional One-Euro filtering" + PROCESSOR_PARAMS = { + "bind": { + "type": "tuple", + "default": ("127.0.0.1", 6000), + "description": "Server address (host, port)", + }, + "authkey": { + "type": "bytes", + "default": b"secret password", + "description": "Authentication key for clients", + }, + "use_perf_counter": { + "type": "bool", + "default": False, + "description": "Use time.perf_counter() instead of time.time()", + }, + "use_filter": { + "type": "bool", + "default": False, + "description": "Apply One-Euro filter to calculated values", + }, + "filter_kwargs": { + "type": "dict", + "default": {"min_cutoff": 1.0, "beta": 0.02, "d_cutoff": 1.0}, + "description": "One-Euro filter parameters (min_cutoff, beta, d_cutoff)", + }, + "save_original": { + "type": "bool", + "default": False, + "description": "Save raw pose arrays for analysis", + }, + } + + def __init__( + self, + bind=("127.0.0.1", 6000), + authkey=b"secret password", + use_perf_counter=False, + use_filter=False, + filter_kwargs: dict | None = None, + save_original=False, + ): + super().__init__( + bind=bind, + authkey=authkey, + use_perf_counter=use_perf_counter, + save_original=save_original, + ) + + self.center_x = deque() + self.center_y = deque() + self.heading_direction = deque() + self.head_angle = deque() + + self.use_filter = use_filter + self.filter_kwargs = filter_kwargs if filter_kwargs is not None else {} + self.filters = None + + def _clear_data_queues(self): + super()._clear_data_queues() + self.center_x.clear() + self.center_y.clear() + self.heading_direction.clear() + self.head_angle.clear() + + def _initialize_filters(self, vals): + t0 = self.timing_func() + self.filters = { + "center_x": OneEuroFilter(t0, vals[0], **self.filter_kwargs), + "center_y": OneEuroFilter(t0, vals[1], **self.filter_kwargs), + "heading": OneEuroFilter(t0, vals[2], **self.filter_kwargs), + "head_angle": OneEuroFilter(t0, vals[3], **self.filter_kwargs), + } + logger.debug(f"Initialized One-Euro filters with parameters: {self.filter_kwargs}") + + def process(self, pose, **kwargs): + # Extract keypoints and confidence + xy = pose[:, :2] + conf = pose[:, 2] + + # Calculate weighted center from head keypoints + head_xy = xy[[0, 1, 2, 3, 4, 5, 6, 26], :] + head_conf = conf[[0, 1, 2, 3, 4, 5, 6, 26]] + center = np.average(head_xy, axis=0, weights=head_conf) + + # Calculate body axis (tail_base -> neck) + body_axis = xy[7] - xy[13] + body_axis /= sqrt(np.sum(body_axis**2)) + + # Calculate head axis (neck -> nose) + head_axis = xy[0] - xy[7] + head_axis /= sqrt(np.sum(head_axis**2)) + + # Calculate head angle relative to body + cross = body_axis[0] * head_axis[1] - head_axis[0] * body_axis[1] + sign = copysign(1, cross) # Positive when looking left + sign = copysign(1, cross) + try: + head_angle = acos(body_axis @ head_axis) * sign + except ValueError: + head_angle = 0 + + # Calculate heading (body orientation) + heading = degrees(atan2(body_axis[1], body_axis[0])) + + # Raw values (heading unwrapped for filtering) + vals = [center[0], center[1], heading, head_angle] + + # Apply filtering if enabled + curr_time = self.timing_func() + if self.use_filter: + if self.filters is None: + self._initialize_filters(vals) + + vals = [ + self.filters["center_x"](curr_time, vals[0]), + self.filters["center_y"](curr_time, vals[1]), + self.filters["heading"](curr_time, vals[2]), + self.filters["head_angle"](curr_time, vals[3]), + ] + + # Wrap heading to [0, 360) after filtering + vals[2] = vals[2] % 360 + # Update step counter + self.curr_step = self.curr_step + 1 + + # Store processed data (only if recording) + if self.recording: + if self.save_original and self.original_pose is not None: + self.original_pose.append(pose.copy()) + self.center_x.append(vals[0]) + self.center_y.append(vals[1]) + self.heading_direction.append(vals[2]) + self.head_angle.append(vals[3]) + self.time_stamp.append(curr_time) + self.step.append(self.curr_step) + self.frame_time.append(kwargs.get("frame_time", -1)) + if "pose_time" in kwargs: + self.pose_time.append(kwargs["pose_time"]) + + payload = [curr_time, vals[0], vals[1], vals[2], vals[3]] + self.broadcast(payload) + return pose + + def get_data(self): + save_dict = super().get_data() + save_dict["x_pos"] = np.array(self.center_x) + save_dict["y_pos"] = np.array(self.center_y) + save_dict["heading_direction"] = np.array(self.heading_direction) + save_dict["head_angle"] = np.array(self.head_angle) + save_dict["use_filter"] = self.use_filter + save_dict["filter_kwargs"] = self.filter_kwargs + return save_dict + + +@register_processor +class ExampleProcessorSocketFilterKeypoints(BaseProcessorSocket): # pragma: no cover + PROCESSOR_NAME = "Mouse Pose with less keypoints" + PROCESSOR_DESCRIPTION = "Calculates mouse center, heading, and head angle with optional One-Euro filtering" + PROCESSOR_PARAMS = { + "bind": { + "type": "tuple", + "default": ("127.0.0.1", 6000), + "description": "Server address (host, port)", + }, + "authkey": { + "type": "bytes", + "default": b"secret password", + "description": "Authentication key for clients", + }, + "use_perf_counter": { + "type": "bool", + "default": False, + "description": "Use time.perf_counter() instead of time.time()", + }, + "use_filter": { + "type": "bool", + "default": False, + "description": "Apply One-Euro filter to calculated values", + }, + "filter_kwargs": { + "type": "dict", + "default": {"min_cutoff": 1.0, "beta": 0.02, "d_cutoff": 1.0}, + "description": "One-Euro filter parameters (min_cutoff, beta, d_cutoff)", + }, + "save_original": { + "type": "bool", + "default": True, + "description": "Save raw pose arrays for analysis", + }, + } + + def __init__( + self, + bind=("127.0.0.1", 6000), + authkey=b"secret password", + use_perf_counter=False, + use_filter=False, + filter_kwargs: dict | None = None, + save_original=True, + p_cutoff=0.4, + ): + super().__init__( + bind=bind, + authkey=authkey, + use_perf_counter=use_perf_counter, + save_original=save_original, + ) + + self.center_x = deque() + self.center_y = deque() + self.heading_direction = deque() + self.head_angle = deque() + + self.p_cutoff = p_cutoff + + self.use_filter = use_filter + self.filter_kwargs = filter_kwargs if filter_kwargs is not None else {} + self.filters = None + + def _clear_data_queues(self): + super()._clear_data_queues() + self.center_x.clear() + self.center_y.clear() + self.heading_direction.clear() + self.head_angle.clear() + + def _initialize_filters(self, vals): + t0 = self.timing_func() + self.filters = { + "center_x": OneEuroFilter(t0, vals[0], **self.filter_kwargs), + "center_y": OneEuroFilter(t0, vals[1], **self.filter_kwargs), + "heading": OneEuroFilter(t0, vals[2], **self.filter_kwargs), + "head_angle": OneEuroFilter(t0, vals[3], **self.filter_kwargs), + } + logger.debug(f"Initialized One-Euro filters with parameters: {self.filter_kwargs}") + + def process(self, pose, **kwargs): + # Extract keypoints and confidence + xy = pose[:, :2] + conf = pose[:, 2] + + # Calculate weighted center from head keypoints + head_xy = xy[[0, 1, 2, 3, 5, 6, 7], :] + head_conf = conf[[0, 1, 2, 3, 5, 6, 7]] + # set low confidence keypoints to zero weight + head_conf = np.where(head_conf < self.p_cutoff, 0, head_conf) + try: + center = np.average(head_xy, axis=0, weights=head_conf) + except ZeroDivisionError: + # If all keypoints have zero weight, return without processing + return pose + + neck = np.average(xy[[2, 3, 6, 7], :], axis=0, weights=conf[[2, 3, 6, 7]]) + + # Calculate body axis (tail_base -> neck) + body_axis = neck - xy[9] + body_axis /= sqrt(np.sum(body_axis**2)) + + # Calculate head axis (neck -> nose) + head_axis = xy[0] - neck + head_axis /= sqrt(np.sum(head_axis**2)) + + # Calculate head angle relative to body + cross = body_axis[0] * head_axis[1] - head_axis[0] * body_axis[1] + sign = copysign(1, cross) # Positive when looking left + sign = copysign(1, cross) + try: + head_angle = acos(body_axis @ head_axis) * sign + except ValueError: + head_angle = 0 + + # Calculate heading (body orientation) + heading = degrees(atan2(body_axis[1], body_axis[0])) + vals = [center[0], center[1], heading, head_angle] + + curr_time = self.timing_func() + if self.use_filter: + if self.filters is None: + self._initialize_filters(vals) + + vals = [ + self.filters["center_x"](curr_time, vals[0]), + self.filters["center_y"](curr_time, vals[1]), + self.filters["heading"](curr_time, vals[2]), + self.filters["head_angle"](curr_time, vals[3]), + ] + + # Wrap heading to [0, 360) after filtering + vals[2] = vals[2] % 360 + # Update step counter + self.curr_step = self.curr_step + 1 + + # Store processed data (only if recording) + if self.recording: + if self.save_original and self.original_pose is not None: + self.original_pose.append(pose.copy()) + self.center_x.append(vals[0]) + self.center_y.append(vals[1]) + self.heading_direction.append(vals[2]) + self.head_angle.append(vals[3]) + self.time_stamp.append(curr_time) + self.step.append(self.curr_step) + self.frame_time.append(kwargs.get("frame_time", -1)) + if "pose_time" in kwargs: + self.pose_time.append(kwargs["pose_time"]) + + payload = [curr_time, vals[0], vals[1], vals[2], vals[3]] + self.broadcast(payload) + return pose + + def get_data(self): + save_dict = super().get_data() + save_dict["x_pos"] = np.array(self.center_x) + save_dict["y_pos"] = np.array(self.center_y) + save_dict["heading_direction"] = np.array(self.heading_direction) + save_dict["head_angle"] = np.array(self.head_angle) + save_dict["use_filter"] = self.use_filter + save_dict["filter_kwargs"] = self.filter_kwargs + return save_dict From bd3428c4afbb28ceb0c3fa5374d1aa535b6d8bc9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:11:43 +0200 Subject: [PATCH 114/133] Update plugin docs for processor examples Refines `PLUGIN_SYSTEM.md` to reflect the current processor structure: it now points to `examples.py` for sample implementations and keeps `dlc_processor_socket.py` focused on the socket base class. The registration example was also updated to import `register_processor` and `PROCESSOR_REGISTRY` from `dlclivegui.processors` instead of redefining them inline. --- dlclivegui/processors/PLUGIN_SYSTEM.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/dlclivegui/processors/PLUGIN_SYSTEM.md b/dlclivegui/processors/PLUGIN_SYSTEM.md index 9e975e01c..e6a143626 100644 --- a/dlclivegui/processors/PLUGIN_SYSTEM.md +++ b/dlclivegui/processors/PLUGIN_SYSTEM.md @@ -16,7 +16,8 @@ Processors are Python classes (typically subclasses of `dlclive.Processor`) that ### Useful files -- `dlclivegui/processors/dlc_processor_socket.py` — Example socket-based processor base class + examples +- `dlclivegui/processors/dlc_processor_socket.py` — Example socket-based processor base class +- `dlclivegui/processors/examples.py` — Example processor implementations (e.g., One-Euro filter) - `dlclivegui/processors/processor_utils.py` — Scanning + instantiation helpers used by the GUI --- @@ -204,12 +205,7 @@ The built-in `BaseProcessorSocket` (in `dlc_processor_socket.py`) demonstrates a ```python from dlclive import Processor - -PROCESSOR_REGISTRY = {} - -def register_processor(cls): - PROCESSOR_REGISTRY[getattr(cls, "PROCESSOR_ID", cls.__name__)] = cls - return cls +from dlclivegui.processors import register_processor, PROCESSOR_REGISTRY @register_processor class MyNewProcessor(Processor): From 5336b09dafbd75c3355abf1ba14871b5149cdb4e Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:12:22 +0200 Subject: [PATCH 115/133] Skip socket base module in processor scan Update processor package discovery to ignore `dlc_processor_socket` during namespace scanning, since it only provides the base class/registry and should not be listed as an available processor source. The package fallback scan now uses default class discovery behavior, and related outdated comments/docstring lines were cleaned up. --- dlclivegui/processors/processor_utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index 8f606d8b5..948f21a4c 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -101,8 +101,6 @@ def scan_processor_folder(folder_path): def scan_processor_package(package_name: str = "dlclivegui.processors") -> dict[str | dict]: """ Discover and load processor classes from a package namespace. - Returns a dict keyed as 'module.py::ClassName' with the same - structure you use today. """ all_processors: dict[str, dict] = {} @@ -118,13 +116,16 @@ def scan_processor_package(package_name: str = "dlclivegui.processors") -> dict[ continue try: mod = import_module(mod_name) + # Skip dlc_processor_socket.py as it's the base class and registry + if mod.__name__.endswith("dlc_processor_socket"): + continue # Prefer module-level registry function if present if hasattr(mod, "get_available_processors"): processors = mod.get_available_processors() else: # Fallback: scan for dlclive.Processor subclasses - processors = discover_processor_classes(mod, only_defined_in_module=False) + processors = discover_processor_classes(mod) # Normalize into your “file::class” shape module_file = mod.__name__.split(".")[-1] + ".py" @@ -175,7 +176,6 @@ def load_processors_from_file(file_path: str | Path): return processors # Fallback path: discover subclasses of dlclive.Processor - # here module only is disabled to allow classes re-exported in other modules to be discovered return discover_processor_classes(module, only_defined_in_module=False) except Exception: From 2d9a2545e13be355cf1881a2d20f10a806456d98 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:13:13 +0200 Subject: [PATCH 116/133] Update processor_utils.py --- dlclivegui/processors/processor_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index 948f21a4c..0692d77f6 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -176,7 +176,7 @@ def load_processors_from_file(file_path: str | Path): return processors # Fallback path: discover subclasses of dlclive.Processor - return discover_processor_classes(module, only_defined_in_module=False) + return discover_processor_classes(module) except Exception: # Full traceback helps a ton when a plugin fails to import From 1f566bcc1216f589b69c6573a74dc6a5c503b4ab Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:21:40 +0200 Subject: [PATCH 117/133] Warn on duplicate processor registration Change `register_processor` to log a warning instead of raising on duplicate `PROCESSOR_ID` keys, allowing later registrations to override earlier ones without import-time failures. Update subclass save tests to load processor classes from `dlclivegui.processors.examples` via a dedicated fixture, so the parametrized tests validate the concrete example processors against the correct module data path. --- dlclivegui/processors/dlc_processor_socket.py | 3 ++- .../custom_processors/test_base_processor.py | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index b4f786f44..ca9808f9d 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -30,10 +30,11 @@ def register_processor(cls): registry_key = getattr(cls, "PROCESSOR_ID", cls.__name__) if registry_key in PROCESSOR_REGISTRY: - raise ValueError( + msg = ( f"Duplicate processor registration key '{registry_key}': " f"{PROCESSOR_REGISTRY[registry_key].__name__} vs {cls.__name__}" ) + logger.warning(msg) PROCESSOR_REGISTRY[registry_key] = cls return cls diff --git a/tests/custom_processors/test_base_processor.py b/tests/custom_processors/test_base_processor.py index d38749b34..e881607f4 100644 --- a/tests/custom_processors/test_base_processor.py +++ b/tests/custom_processors/test_base_processor.py @@ -37,6 +37,19 @@ def socket_mod(monkeypatch): return importlib.import_module(mod_name) +@pytest.fixture +def example_processor_mod(monkeypatch): + """ + Import the example processor module with dlclive mocked. + Adjust module name if your file lives elsewhere. + """ + _mock_dlclive(monkeypatch) + mod_name = "dlclivegui.processors.examples" + if mod_name in sys.modules: + del sys.modules[mod_name] + return importlib.import_module(mod_name) + + def _module_data_dir(socket_mod) -> Path: """Compute the data/ directory where save() writes artifacts.""" return Path(socket_mod.__file__).parent.parent.parent / "data" @@ -233,12 +246,14 @@ def test_save_ignores_pre_recording_original_pose_frames(socket_mod): ("ExampleProcessorSocketFilterKeypoints", 10), ], ) -def test_subclass_save_ignores_pre_recording_original_pose_frames(socket_mod, class_name, n_keypoints): +def test_subclass_save_ignores_pre_recording_original_pose_frames( + socket_mod, example_processor_mod, class_name, n_keypoints +): """ Concrete processors must keep original_pose aligned with recorded metadata even when process() is called before recording starts. """ - processor_class = getattr(socket_mod, class_name) + processor_class = getattr(example_processor_mod, class_name) proc = processor_class(bind=("127.0.0.1", 0), save_original=True) try: From d4ea2a7345588fb80bcd36dda169c4dfb28c41d8 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:23:40 +0200 Subject: [PATCH 118/133] Update examples.py --- dlclivegui/processors/examples.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dlclivegui/processors/examples.py b/dlclivegui/processors/examples.py index feb6ac3c9..177adb390 100644 --- a/dlclivegui/processors/examples.py +++ b/dlclivegui/processors/examples.py @@ -161,7 +161,7 @@ def process(self, pose, **kwargs): # Calculate head angle relative to body cross = body_axis[0] * head_axis[1] - head_axis[0] * body_axis[1] sign = copysign(1, cross) # Positive when looking left - sign = copysign(1, cross) + try: head_angle = acos(body_axis @ head_axis) * sign except ValueError: @@ -331,7 +331,7 @@ def process(self, pose, **kwargs): # Calculate head angle relative to body cross = body_axis[0] * head_axis[1] - head_axis[0] * body_axis[1] sign = copysign(1, cross) # Positive when looking left - sign = copysign(1, cross) + try: head_angle = acos(body_axis @ head_axis) * sign except ValueError: From ae50dab5f5b752a19eab98622962983978aab838 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:27:22 +0200 Subject: [PATCH 119/133] Refine processor package scan typing Updates `scan_processor_package` to use a more precise return type annotation (`dict[str, dict]` --- dlclivegui/processors/processor_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index 0692d77f6..90b95dad6 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -98,7 +98,7 @@ def scan_processor_folder(folder_path): return all_processors -def scan_processor_package(package_name: str = "dlclivegui.processors") -> dict[str | dict]: +def scan_processor_package(package_name: str = "dlclivegui.processors") -> dict[str, dict]: """ Discover and load processor classes from a package namespace. """ From d8076537ec40737a6305c40c68e15433794f809d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:35:29 +0200 Subject: [PATCH 120/133] Update examples.py --- dlclivegui/processors/examples.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dlclivegui/processors/examples.py b/dlclivegui/processors/examples.py index 177adb390..d8fab0d2b 100644 --- a/dlclivegui/processors/examples.py +++ b/dlclivegui/processors/examples.py @@ -148,7 +148,10 @@ def process(self, pose, **kwargs): # Calculate weighted center from head keypoints head_xy = xy[[0, 1, 2, 3, 4, 5, 6, 26], :] head_conf = conf[[0, 1, 2, 3, 4, 5, 6, 26]] - center = np.average(head_xy, axis=0, weights=head_conf) + try: + center = np.average(head_xy, axis=0, weights=head_conf) + except ZeroDivisionError: + center = np.zeros(2) # Calculate body axis (tail_base -> neck) body_axis = xy[7] - xy[13] From 7fb2045d1d26cf6d04e7fff2c24108ae59ef8be7 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:40:46 +0200 Subject: [PATCH 121/133] Fix dlclive Processor import paths Update processor imports to use `from dlclive.processor import Processor` in runtime code to avoid torch import side effects --- dlclivegui/processors/dlc_processor_socket.py | 2 +- dlclivegui/processors/processor_utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index ca9808f9d..c649422ef 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -13,7 +13,7 @@ import numpy as np import pandas as pd -from dlclive import Processor # type: ignore +from dlclive.processor import Processor # type: ignore logger = logging.getLogger("dlc_processor_socket") diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index 90b95dad6..467792b03 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -18,7 +18,7 @@ def default_processors_dir() -> str: def _processor_base_class(): - from dlclive import Processor + from dlclive.processor import Processor return Processor From 4e377c31102ef2b3a246b65239c0be28c81a3f0a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:49:10 +0200 Subject: [PATCH 122/133] Extract processor registry into new module Moves processor registration and discovery helpers out of `dlc_processor_socket.py` into a new `registry.py` module so registry access no longer depends on importing socket logic. `dlc_processor_socket.py` now imports the shared registry helpers and adds a safe fallback when `dlclive` is unavailable, reducing import-time failures in environments without that dependency. Package exports were updated to expose registry APIs from the new module. --- dlclivegui/processors/__init__.py | 4 +- dlclivegui/processors/dlc_processor_socket.py | 56 ++----------------- dlclivegui/processors/examples.py | 3 +- dlclivegui/processors/registry.py | 53 ++++++++++++++++++ 4 files changed, 62 insertions(+), 54 deletions(-) create mode 100644 dlclivegui/processors/registry.py diff --git a/dlclivegui/processors/__init__.py b/dlclivegui/processors/__init__.py index ee94194dd..8e7717155 100644 --- a/dlclivegui/processors/__init__.py +++ b/dlclivegui/processors/__init__.py @@ -1,3 +1,3 @@ -from .dlc_processor_socket import PROCESSOR_REGISTRY, BaseProcessorSocket, register_processor +from .registry import PROCESSOR_REGISTRY, register_processor -__all__ = ["register_processor", "BaseProcessorSocket", "PROCESSOR_REGISTRY"] +__all__ = ["register_processor", "PROCESSOR_REGISTRY"] diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index c649422ef..594512c24 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -13,7 +13,11 @@ import numpy as np import pandas as pd -from dlclive.processor import Processor # type: ignore + +try: + from dlclive.processor import Processor # type: ignore +except ImportError: + Processor = object # Fallback for type checking if dlclive is not installed logger = logging.getLogger("dlc_processor_socket") @@ -23,21 +27,6 @@ _handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) logger.addHandler(_handler) -# Registry for GUI discovery -PROCESSOR_REGISTRY = {} - - -def register_processor(cls): - registry_key = getattr(cls, "PROCESSOR_ID", cls.__name__) - if registry_key in PROCESSOR_REGISTRY: - msg = ( - f"Duplicate processor registration key '{registry_key}': " - f"{PROCESSOR_REGISTRY[registry_key].__name__} vs {cls.__name__}" - ) - logger.warning(msg) - PROCESSOR_REGISTRY[registry_key] = cls - return cls - # pragma: cover class BaseProcessorSocket(Processor): @@ -435,38 +424,3 @@ def get_data(self): if self.dlc_cfg is not None: save_dict["dlc_cfg"] = self.dlc_cfg return save_dict - - -def get_available_processors(): - """ - Get list of available processor classes. - - Returns: - dict: Dictionary mapping registry keys to processor info. - """ - return { - name: { - "class": cls, - "name": getattr(cls, "PROCESSOR_NAME", name), - "description": getattr(cls, "PROCESSOR_DESCRIPTION", ""), - "params": getattr(cls, "PROCESSOR_PARAMS", {}), - } - for name, cls in PROCESSOR_REGISTRY.items() - } - - -def instantiate_processor(class_name, **kwargs): - """ - Instantiate a processor by class name with given parameters. - - Args: - class_name: Registry key (e.g., "MyProcessorSocket") - **kwargs: Constructor kwargs - - Raises: - ValueError: If class_name is not in registry - """ - if class_name not in PROCESSOR_REGISTRY: - available = ", ".join(PROCESSOR_REGISTRY.keys()) - raise ValueError(f"Unknown processor '{class_name}'. Available: {available}") - return PROCESSOR_REGISTRY[class_name](**kwargs) diff --git a/dlclivegui/processors/examples.py b/dlclivegui/processors/examples.py index d8fab0d2b..7ed769198 100644 --- a/dlclivegui/processors/examples.py +++ b/dlclivegui/processors/examples.py @@ -6,7 +6,8 @@ import numpy as np -from dlclivegui.processors import BaseProcessorSocket, register_processor +from dlclivegui.processors import register_processor +from dlclivegui.processors.dlc_processor_socket import BaseProcessorSocket logger = logging.getLogger(__name__) diff --git a/dlclivegui/processors/registry.py b/dlclivegui/processors/registry.py new file mode 100644 index 000000000..28892975e --- /dev/null +++ b/dlclivegui/processors/registry.py @@ -0,0 +1,53 @@ +import logging + +logger = logging.getLogger(__name__) + +# Registry for GUI discovery +PROCESSOR_REGISTRY = {} + + +def register_processor(cls): + registry_key = getattr(cls, "PROCESSOR_ID", cls.__name__) + if registry_key in PROCESSOR_REGISTRY: + msg = ( + f"Duplicate processor registration key '{registry_key}': " + f"{PROCESSOR_REGISTRY[registry_key].__name__} vs {cls.__name__}" + ) + logger.warning(msg) + PROCESSOR_REGISTRY[registry_key] = cls + return cls + + +def get_available_processors(): + """ + Get list of available processor classes. + + Returns: + dict: Dictionary mapping registry keys to processor info. + """ + return { + name: { + "class": cls, + "name": getattr(cls, "PROCESSOR_NAME", name), + "description": getattr(cls, "PROCESSOR_DESCRIPTION", ""), + "params": getattr(cls, "PROCESSOR_PARAMS", {}), + } + for name, cls in PROCESSOR_REGISTRY.items() + } + + +def instantiate_processor(class_name, **kwargs): + """ + Instantiate a processor by class name with given parameters. + + Args: + class_name: Registry key (e.g., "MyProcessorSocket") + **kwargs: Constructor kwargs + + Raises: + ValueError: If class_name is not in registry + """ + if class_name not in PROCESSOR_REGISTRY: + available = ", ".join(PROCESSOR_REGISTRY.keys()) + raise ValueError(f"Unknown processor '{class_name}'. Available: {available}") + return PROCESSOR_REGISTRY[class_name](**kwargs) From e3b4eec96ba2e4126ca1d83c70e963cf876cb63b Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:50:10 +0200 Subject: [PATCH 123/133] Fix dlclive mock structure in processor tests Update the base processor test helper to better mirror the real dlclive package layout by mocking both `dlclive` and `dlclive.processor`, and add a no-op `process` method on the dummy `Processor`. This prevents import/behavior mismatches in tests that rely on the processor interface. --- tests/custom_processors/test_base_processor.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/custom_processors/test_base_processor.py b/tests/custom_processors/test_base_processor.py index e881607f4..94dabab89 100644 --- a/tests/custom_processors/test_base_processor.py +++ b/tests/custom_processors/test_base_processor.py @@ -13,15 +13,21 @@ def _mock_dlclive(monkeypatch): - """Provide a dummy dlclive.Processor so the module can import in tests.""" - fake = types.ModuleType("dlclive") - class Processor: def __init__(self, *args, **kwargs): pass - fake.Processor = Processor - monkeypatch.setitem(sys.modules, "dlclive", fake) + def process(self, pose, **kwargs): + return pose + + dlclive_mod = types.ModuleType("dlclive") + processor_mod = types.ModuleType("dlclive.processor") + + dlclive_mod.Processor = Processor + processor_mod.Processor = Processor + + monkeypatch.setitem(sys.modules, "dlclive", dlclive_mod) + monkeypatch.setitem(sys.modules, "dlclive.processor", processor_mod) @pytest.fixture From b9eea26a83bc5ec7c9657a3b7088c770b9fd8d18 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 16:41:25 +0200 Subject: [PATCH 124/133] Make Engine a str enum and normalize model_type Update `Engine` to inherit from `str, Enum` so enum members behave like strings where needed. Also harden `from_model_type` by coercing non-string inputs (including enum-like values with `.value`) before lowercasing, and raise a clear `ValueError` when conversion is not possible. --- dlclivegui/temp/engine.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/dlclivegui/temp/engine.py b/dlclivegui/temp/engine.py index a6bb225eb..85c4755f7 100644 --- a/dlclivegui/temp/engine.py +++ b/dlclivegui/temp/engine.py @@ -6,7 +6,7 @@ # or if we update dlclive.Engine to have these methods and use that instead of a separate enum here. # The latter would be more cohesive but also creates a dependency from utils to dlclive, # pending release of dlclive -class Engine(Enum): +class Engine(str, Enum): TENSORFLOW = "tensorflow" PYTORCH = "pytorch" @@ -26,6 +26,12 @@ def is_tensorflow_model_dir_path(model_path: str | Path) -> bool: @classmethod def from_model_type(cls, model_type: str) -> "Engine": + if not isinstance(model_type, str): + try: + model_type = getattr(model_type, "value", str(model_type)) + except Exception as e: + raise ValueError(f"Could not convert model_type to string: {model_type}") from e + if model_type.lower() == "pytorch": return cls.PYTORCH elif model_type.lower() in ("tensorflow", "base", "tensorrt", "lite"): From db75330b2eb0cd679263f3f6ccf64bb5fc1e7d66 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 16:44:46 +0200 Subject: [PATCH 125/133] Persist custom processor folder in settings Remember the processor folder across sessions and use it when initializing the main window. The folder is now saved when browsing, during refresh (after resolving a valid directory), and on close. Processor refresh messaging was updated to show whether processors came from the selected folder or the built-in package. Settings store gained processor-folder get/set helpers that validate and normalize paths, with safe fallback to defaults when paths are missing or invalid. --- dlclivegui/gui/main_window.py | 23 +++++++++++++------ dlclivegui/utils/settings_store.py | 36 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index bc1e2a649..2677b8f92 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -445,7 +445,7 @@ def _build_dlc_group(self) -> QGroupBox: # Processor selection processor_path_layout = QHBoxLayout() self.processor_folder_edit = QLineEdit() - self.processor_folder_edit.setText(default_processors_dir()) + self.processor_folder_edit.setText(self._settings_store.get_processor_folder(default=default_processors_dir())) processor_path_layout.addWidget(self.processor_folder_edit) self.browse_processor_folder_button = QPushButton("Browse...") @@ -1081,10 +1081,11 @@ def _action_browse_directory(self) -> None: def _action_browse_processor_folder(self) -> None: """Browse for processor folder.""" - current_path = self.processor_folder_edit.text() or default_processors_dir() + current_path = self.processor_folder_edit.text().strip() or default_processors_dir() directory = QFileDialog.getExistingDirectory(self, "Select processor folder", current_path) if directory: self.processor_folder_edit.setText(directory) + self._settings_store.set_processor_folder(directory) self._refresh_processors() def _action_open_recording_folder(self) -> None: @@ -1138,10 +1139,17 @@ def _refresh_processors(self) -> None: self.processor_combo.addItem("No Processor", None) selected_folder = self.processor_folder_edit.text().strip() - if Path(selected_folder).exists(): - self._scanned_processors = scan_processor_folder(selected_folder) + selected_path = Path(selected_folder).expanduser() if selected_folder else None + + if selected_path is not None and selected_path.is_dir(): + resolved_folder = str(selected_path.resolve()) + self._settings_store.set_processor_folder(resolved_folder) + self._scanned_processors = scan_processor_folder(resolved_folder) + source_text = resolved_folder else: self._scanned_processors = scan_processor_package("dlclivegui.processors") + source_text = "package dlclivegui.processors" + self._processor_keys = list(self._scanned_processors.keys()) for key in self._processor_keys: @@ -1150,9 +1158,7 @@ def _refresh_processors(self) -> None: 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 - ) + self.statusBar().showMessage(f"Found {len(self._processor_keys)} processor(s) in {source_text}", 3000) # ------------------------------------------------------------------ # Recording path preview and session name persistence @@ -2157,6 +2163,9 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha # Remember model path on exit self._model_path_store.save_if_valid(self.model_path_edit.text().strip()) + # Remember processor folder on exit + if hasattr(self, "processor_folder_edit"): + self._settings_store.set_processor_folder(self.processor_folder_edit.text().strip()) # Close the window super().closeEvent(event) diff --git a/dlclivegui/utils/settings_store.py b/dlclivegui/utils/settings_store.py index a0c5677f4..0107afb1c 100644 --- a/dlclivegui/utils/settings_store.py +++ b/dlclivegui/utils/settings_store.py @@ -57,6 +57,42 @@ def get_fast_encoding(self, default: bool = False) -> bool: return value return str(value).strip().lower() in {"1", "true", "yes", "on"} + def get_processor_folder(self, default: str = "") -> str: + """ + Return the persisted processor folder if it still exists and is a directory. + Otherwise return default. + """ + value = self._s.value("dlc/processor_folder", default) + value = str(value).strip() if value is not None else "" + + if not value: + return default + + try: + path = Path(value).expanduser() + if path.is_dir(): + return str(path.resolve()) + except Exception: + logger.debug("Persisted processor folder is invalid: %s", value, exc_info=True) + + return default + + def set_processor_folder(self, folder: str) -> None: + """ + Persist processor folder only if it exists and is a directory. + Invalid folders are ignored. + """ + folder = str(folder).strip() if folder is not None else "" + if not folder: + return + + try: + path = Path(folder).expanduser() + if path.is_dir(): + self._s.setValue("dlc/processor_folder", str(path.resolve())) + except Exception: + logger.debug("Failed to persist processor folder: %s", folder, exc_info=True) + def set_fast_encoding(self, enabled: bool) -> None: self._s.setValue("recording/fast_encoding", bool(enabled)) From 52f9b92c3889fc3b090630322fcd8eb1e857daac Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 1 Jul 2026 18:11:37 +0200 Subject: [PATCH 126/133] Improve recorder error logging and handling Enhance error reporting and handling for video recording. recording_manager now logs exception type, message, and frame shape/dtype when a write fails. VideoRecorder adds detailed messages for frame-size mismatches, queue retrieval errors, and encoding failures (including frame description, expected size, frames_written/frames_enqueued/dropped, and queue_size) and stops the recorder to avoid FFmpeg pipe errors. Introduced _describe_frame to summarize frames and _set_encode_error to centralize creation of a RuntimeError (preserving original exception as __cause__) and set _encode_error under the stats lock. Minor test file newline fix. --- dlclivegui/gui/recording_manager.py | 9 +++- dlclivegui/services/video_recorder.py | 73 ++++++++++++++++++++++----- 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index 9be545c5f..ddcef47be 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -215,7 +215,14 @@ def write_frame( timestamp_metadata=timestamp_metadata, ) except Exception as exc: - log.warning("Failed to write frame for %s: %s", cam_id, exc) + log.warning( + "Failed to write frame for %s: %s: %s frame_shape=%s dtype=%s", + cam_id, + type(exc).__name__, + str(exc) or repr(exc), + getattr(frame, "shape", None), + getattr(frame, "dtype", None), + ) try: rec.stop() except Exception: diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index d8e3cb54a..44369a548 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -278,15 +278,16 @@ def write( expected_h, expected_w = self._frame_size actual_h, actual_w = frame.shape[:2] if (actual_h, actual_w) != (expected_h, expected_w): - logger.warning( - f"Frame size mismatch: expected (h={expected_h}, w={expected_w}), " - f"got (h={actual_h}, w={actual_w}). " - "Stopping recorder to prevent encoding errors." + message = ( + f"Frame size mismatch for recorder {self._output.name}: " + f"expected_hw=({expected_h}, {expected_w}) " + f"actual_hw=({actual_h}, {actual_w}) " + f"{self._describe_frame(frame)}. " + "Stopping recorder to prevent FFmpeg pipe errors." ) - with self._stats_lock: - self._encode_error = ValueError( - f"Frame size changed from (h={expected_h}, w={expected_w}) to (h={actual_h}, w={actual_w})" - ) + + logger.warning(message) + self._set_encode_error(message) self._process_timing.note_error() self._process_timing.maybe_log() return False @@ -426,9 +427,12 @@ def _writer_loop(self) -> None: break continue except Exception as exc: - with self._stats_lock: - self._encode_error = exc - logger.exception("Could not retrieve item from queue", exc_info=exc) + message = ( + f"Could not retrieve frame from recorder queue for {self._output.name}: " + f"{type(exc).__name__}: {exc!s}" + ) + self._set_encode_error(message, exc) + logger.exception(message) self._stop_event.set() break @@ -473,9 +477,28 @@ def _writer_loop(self) -> None: self._frame_timestamps.append(record) except Exception as exc: + queue_size = q.qsize() if q is not None else -1 + with self._stats_lock: - self._encode_error = exc - logger.exception("Video encoding failed while writing frame", exc_info=exc) + frames_enqueued = self._frames_enqueued + frames_written = self._frames_written + dropped_frames = self._dropped_frames + + message = ( + f"Video encoding failed for recorder {self._output.name}: " + f"{type(exc).__name__}: {exc!s}. " + f"{self._describe_frame(frame)} " + f"expected_frame_size={self._frame_size} " + f"frames_written={frames_written} " + f"frames_enqueued={frames_enqueued} " + f"dropped={dropped_frames} " + f"queue_size={queue_size}. " + "The FFmpeg/WriteGear pipe is no longer usable; stopping this recorder." + ) + + self._set_encode_error(message, exc) + + logger.exception(message) self._stop_event.set() self._writer_timing.note_error() self._writer_timing.maybe_log() @@ -524,10 +547,34 @@ def _compute_write_fps_locked(self) -> float: return 0.0 return (len(self._written_times) - 1) / duration + def _describe_frame(self, frame: np.ndarray | None) -> str: + if frame is None: + return "frame=None" + + try: + return ( + f"shape={frame.shape} " + f"dtype={frame.dtype} " + f"contiguous={frame.flags.c_contiguous} " + f"nbytes={frame.nbytes / (1024 * 1024):.2f}MB" + ) + except Exception: + return f"frame=" + def _current_error(self) -> Exception | None: with self._stats_lock: return self._encode_error + def _set_encode_error(self, message: str, exc: Exception | None = None) -> Exception: + error = RuntimeError(message) + if exc is not None: + error.__cause__ = exc + + with self._stats_lock: + self._encode_error = error + + return error + def _save_timestamps(self) -> None: """Save frame timestamps to a JSON file alongside the video.""" if not self._frame_timestamps: From 2a8816d7e560750e081666c59114322e665a69f3 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 2 Jul 2026 10:01:59 +0200 Subject: [PATCH 127/133] Normalize recording container handling Centralized recording container configuration by introducing shared allowed/default container constants and using them across settings, UI, and utilities. The recording UI now populates container options from config and keeps the filename extension aligned with the selected container when switching between known video formats, so saved settings and path previews stay consistent. --- dlclivegui/config.py | 5 ++- dlclivegui/gui/main_window.py | 81 ++++++++++++++++++++++++----------- dlclivegui/utils/utils.py | 4 +- 3 files changed, 62 insertions(+), 28 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 53629bbf4..339eb3ee4 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -20,6 +20,9 @@ # Global settings ## GUI GUI_MAX_DISPLAY_FPS: float = 30.0 +## Recording +ALLOWED_VIDEO_CONTAINERS: set[str] = {"mp4", "avi", "mov"} +DEFAULT_RECORDING_CONTAINER: str = "mp4" ## Debug @@ -512,7 +515,7 @@ class RecordingSettings(BaseModel): enabled: bool = False directory: str = Field(default_factory=lambda: str(Path.home() / "Videos" / "deeplabcut-live")) filename: str = "session.mp4" - container: Literal["mp4", "avi", "mov"] = "mp4" + container: Literal["mp4", "avi", "mov"] = DEFAULT_RECORDING_CONTAINER codec: str = "libx264" crf: int = Field(default=23, ge=0, le=51) fast_encoding: bool = False diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 2677b8f92..2a0e4fd8a 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -48,7 +48,9 @@ from dlclivegui.cameras import CameraFactory from dlclivegui.config import ( + ALLOWED_VIDEO_CONTAINERS, DEFAULT_CONFIG, + DEFAULT_RECORDING_CONTAINER, ApplicationSettings, BoundingBoxSettings, CameraSettings, @@ -593,7 +595,7 @@ def _build_recording_group(self) -> QGroupBox: self.container_combo.setToolTip("Select the video container/format") self.container_combo.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred) self.container_combo.setEditable(True) - self.container_combo.addItems(["mp4", "avi", "mov"]) + self.container_combo.addItems(sorted(ALLOWED_VIDEO_CONTAINERS)) # Ensure it never becomes unreadable: self.container_combo.setMinimumContentsLength(8) self.container_combo.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) @@ -809,7 +811,7 @@ def _connect_signals(self) -> None: self.use_timestamp_checkbox.stateChanged.connect(self._on_use_timestamp_changed) self.output_directory_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) self.filename_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) - self.container_combo.currentTextChanged.connect(lambda _t: self._update_recording_path_preview()) + self.container_combo.currentTextChanged.connect(self._on_container_changed) self.fast_encoding_checkbox.stateChanged.connect(self._on_fast_encoding_changed) # ------------------------------------------------------------------ @@ -945,11 +947,13 @@ def _dlc_settings_from_ui(self, *, allow_empty_model_path=False) -> DLCProcessor ) def _recording_settings_from_ui(self) -> RecordingSettings: + container = self.container_combo.currentText().strip() or DEFAULT_RECORDING_CONTAINER + filename = self._filename_matching_container(self.filename_edit.text().strip(), container) return RecordingSettings( enabled=True, # Always enabled - recording controlled by button directory=self.output_directory_edit.text().strip(), - filename=self.filename_edit.text().strip() or "session.mp4", - container=self.container_combo.currentText().strip() or "mp4", + filename=filename, + container=container, codec=self.codec_combo.currentText().strip() or "libx264", crf=int(self.crf_spin.value()), fast_encoding=bool( @@ -1162,31 +1166,52 @@ def _refresh_processors(self) -> None: # ------------------------------------------------------------------ # Recording path preview and session name persistence + def _known_recording_extensions(self) -> set[str]: + """Return known recording container extensions without leading dots.""" + known = ALLOWED_VIDEO_CONTAINERS.copy() + if hasattr(self, "container_combo"): + known.update( + self.container_combo.itemText(i).strip().lower().lstrip(".") + for i in range(self.container_combo.count()) + if self.container_combo.itemText(i).strip() + ) + return known + + def _filename_matching_container(self, filename: str, container: str) -> str: + """ + Adjust filename extension to match selected container, but only when + the existing extension is another known recording container. + """ + name = filename.strip() or "recording" + selected_ext = container.strip().lower().lstrip(".") + suffix = Path(name).suffix + + if not suffix or not selected_ext: + return name + + current_ext = suffix.lower().lstrip(".") + if current_ext in self._known_recording_extensions() and current_ext != selected_ext: + return str(Path(name).with_suffix(f".{selected_ext}")) + + return name + + def _on_container_changed(self, text: str) -> None: + """Keep filename extension aligned with selected container when safe.""" + if hasattr(self, "filename_edit"): + current = self.filename_edit.text() + updated = self._filename_matching_container(current, text) + if updated != current: + self.filename_edit.blockSignals(True) + self.filename_edit.setText(updated) + self.filename_edit.blockSignals(False) + + self._update_recording_path_preview() + def _on_session_name_editing_finished(self) -> None: name = self.session_name_edit.text().strip() self._settings_store.set_session_name(name) self._update_recording_path_preview() - # def _update_recording_path_preview(self) -> None: - # """Update the label showing where files will go (best-effort).""" - # if not hasattr(self, "recording_path_preview"): - # return - # out_dir = self.output_directory_edit.text().strip() - # sess = self.session_name_edit.text().strip() if hasattr(self, "session_name_edit") else "" - # base = self.filename_edit.text().strip() - # container = self.container_combo.currentText().strip() if hasattr(self, "container_combo") else "mp4" - # use_ts = self.use_timestamp_checkbox.isChecked() if hasattr(self, "use_timestamp_checkbox") else True - - # # Preview is approximate (since run index/time is decided at start). - # sess_safe = sess.strip() or "session" - # run_hint = "run_" if use_ts else "run_" - # stem_hint = Path(base).stem if base.strip() else "recording" # shows user-provided stem or default - # full_hint = str(Path(out_dir).expanduser() / sess_safe / run_hint / f"{stem_hint}_.{container}") - # self.recording_path_preview.setText(f"{full_hint}") - # self.recording_path_preview.setToolTip( - # f"Click to copy to clipboard :
{full_hint.replace('', '*')}" - # ) - def _update_recording_path_preview(self) -> None: """Update the label showing where files will go (best-effort).""" if not hasattr(self, "recording_path_preview"): @@ -1194,8 +1219,12 @@ def _update_recording_path_preview(self) -> None: out_dir = self.output_directory_edit.text().strip() sess = self.session_name_edit.text().strip() if hasattr(self, "session_name_edit") else "" - base = self.filename_edit.text().strip() - container = self.container_combo.currentText().strip() if hasattr(self, "container_combo") else "mp4" + container = ( + self.container_combo.currentText().strip() + if hasattr(self, "container_combo") + else DEFAULT_RECORDING_CONTAINER + ) + base = self._filename_matching_container(self.filename_edit.text(), container) use_ts = self.use_timestamp_checkbox.isChecked() if hasattr(self, "use_timestamp_checkbox") else True # Preview is approximate (since run index/time is decided at start). diff --git a/dlclivegui/utils/utils.py b/dlclivegui/utils/utils.py index 6af003dad..534e5732f 100644 --- a/dlclivegui/utils/utils.py +++ b/dlclivegui/utils/utils.py @@ -8,6 +8,8 @@ from datetime import datetime from pathlib import Path +from dlclivegui.config import DEFAULT_RECORDING_CONTAINER + _INVALID_CHARS = re.compile(r"[^A-Za-z0-9._-]+") @@ -36,7 +38,7 @@ def split_stem_ext(base_filename: str, container: str) -> tuple[str, str]: If user typed an extension, keep it. Else use container. """ base = (base_filename or "").strip() - container = (container or "mp4").strip().lstrip(".") or "mp4" + container = (container or DEFAULT_RECORDING_CONTAINER).strip().lstrip(".") or DEFAULT_RECORDING_CONTAINER if not base: base = "recording" From f97fba979c64598b37473775ce898042a5c3288b Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 2 Jul 2026 10:50:25 +0200 Subject: [PATCH 128/133] Add DLC timing instrumentation in GUI path Introduce a new `DLC_DO_LOG_TIMING` config flag and wire `WorkerTimingStats` into `DLCLiveMainWindow` for DLC enqueue and pose-ready callback timing. The pose callback now logs camera-to-GUI latency in debug mode, marks display state dirty instead of forcing an immediate redraw, and emits periodic timing stats via `maybe_log()`. --- dlclivegui/config.py | 1 + dlclivegui/gui/main_window.py | 34 ++++++++++++++++++++++++++++------ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 339eb3ee4..f1347af30 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -30,6 +30,7 @@ SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False REC_DO_LOG_TIMING: bool = False +DLC_DO_LOG_TIMING: bool = True # MAIN_WINDOW_DO_LOG_TIMING: bool = False #### Backends BASLER_DO_LOG_TIMING: bool = False diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 2a0e4fd8a..898540eb7 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -51,6 +51,7 @@ ALLOWED_VIDEO_CONTAINERS, DEFAULT_CONFIG, DEFAULT_RECORDING_CONTAINER, + DLC_DO_LOG_TIMING, ApplicationSettings, BoundingBoxSettings, CameraSettings, @@ -70,7 +71,7 @@ from ..services.multi_camera_controller import MultiCameraController, MultiFrameData, get_camera_id, get_display_id from ..utils.display import BBoxColors, compute_tile_info, create_tiled_frame, draw_bbox, draw_pose from ..utils.settings_store import DLCLiveGUISettingsStore, ModelPathStore -from ..utils.stats import format_dlc_stats +from ..utils.stats import WorkerTimingStats, format_dlc_stats from ..utils.utils import FPSTracker from .camera_config.camera_config_dialog import CameraConfigDialog from .misc import color_dropdowns as color_ui @@ -131,6 +132,10 @@ def __init__(self, config: ApplicationSettings | None = None): self._rec_manager = RecordingManager() self._dlc = DLCLiveProcessor() self.multi_camera_controller = MultiCameraController() + ### Time debug + self._dlc_timing = WorkerTimingStats( + "GUI - DLC Worker", logger=logger, log_interval=2.0, enabled=DLC_DO_LOG_TIMING + ) self._config = config self._inference_camera_id: str | None = None # Camera ID used for inference @@ -1504,7 +1509,11 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: if self._dlc_active and is_dlc_camera_frame and dlc_cam_id in frame_data.frames: frame = frame_data.frames[dlc_cam_id] timestamp = frame_data.timestamps.get(dlc_cam_id, time.time()) - self._dlc.enqueue_frame(frame, timestamp) + with self._dlc_timing.measure("enqueue_frame"): + self._dlc.enqueue_frame(frame, timestamp) + + self._dlc_timing.note_frame() + self._dlc_timing.maybe_log() def _on_multi_frame_display_ready(self, frame_data: MultiFrameData) -> None: """Throttled UI/display path. @@ -2048,10 +2057,23 @@ def _stop_recording(self) -> None: def _on_pose_ready(self, result: PoseResult) -> None: if not self._dlc_active: return - self._last_pose = result - # logger.debug(f"Pose result: {result.pose}, Timestamp: {result.timestamp}") - if self._current_frame is not None: - self._display_frame(self._current_frame, force=True) + + with self._dlc_timing.measure("DLC.pose_ready_callback"): + self._last_pose = result + + try: + latency_ms = (time.time() - float(result.timestamp)) * 1000.0 + if logger.isEnabledFor(logging.DEBUG): + logger.debug("DLC pose latency camera_timestamp_to_gui=%.2f ms", latency_ms) + except Exception: + pass + + if self._current_frame is not None: + self._display_dirty = True + # with self._dlc_timing.measure("DLC.display_after_pose"): + # self._display_frame(self._current_frame, force=True) + + self._dlc_timing.maybe_log() def _on_dlc_error(self, message: str) -> None: self._stop_inference(show_message=False) From b52f4d2147223b25d6f6d6904d313f6af0b30c3e Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 2 Jul 2026 10:54:05 +0200 Subject: [PATCH 129/133] Prioritize latest frame when queue is full Update `_enqueue_frame` to keep enqueueing the newest frame by removing one queued item when `put_nowait` hits `queue.Full`, instead of dropping the incoming frame. This makes processing more real-time under load and keeps enqueue/drop stats consistent, including safe `task_done()` handling. --- dlclivegui/services/dlc_processor.py | 29 +++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index b4476e116..0ecf8abca 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -258,13 +258,28 @@ def enqueue_frame(self, frame: np.ndarray, timestamp: float) -> None: if q is None: return - try: - q.put_nowait((frame_c, timestamp, enq_time)) - with self._stats_lock: - self._frames_enqueued += 1 - except queue.Full: - with self._stats_lock: - self._frames_dropped += 1 + item = (frame_c, timestamp, enq_time) + + while True: + try: + q.put_nowait(item) + with self._stats_lock: + self._frames_enqueued += 1 + return + + except queue.Full: + try: + q.get_nowait() + try: + q.task_done() + except ValueError: + pass + + with self._stats_lock: + self._frames_dropped += 1 + + except queue.Empty: + continue def get_stats(self) -> ProcessorStats: """Get current processing statistics.""" From 46bcf2338b246c9af9e10296215b491dc1188e7d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 2 Jul 2026 11:16:52 +0200 Subject: [PATCH 130/133] Lower camera backend logs to debug Demoted multiple verbose runtime messages from INFO to DEBUG in Basler and GenTL backends. This keeps normal logs cleaner by moving routine configuration/readback details (FPS setup, converter mode, exposure/gain settings, trigger configuration, and startup/close diagnostics) out of INFO-level output while preserving the diagnostics when DEBUG is enabled. --- dlclivegui/cameras/backends/basler_backend.py | 26 +++++++++---------- dlclivegui/cameras/backends/gentl_backend.py | 18 ++++++------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 273982040..e1177d975 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -437,7 +437,7 @@ def _configure_frame_rate(self) -> None: fps = self._positive_float(getattr(self.settings, "fps", 0.0)) if fps is None: - LOG.info("[Basler] FPS: auto/free-run, not forcing AcquisitionFrameRate") + LOG.debug("[Basler] FPS: auto/free-run, not forcing AcquisitionFrameRate") return enable = self._feature("AcquisitionFrameRateEnable") @@ -454,7 +454,7 @@ def _configure_frame_rate(self) -> None: try: min_v = rate.GetMin() max_v = rate.GetMax() - LOG.info("[Basler] AcquisitionFrameRate range: min=%s max=%s requested=%s", min_v, max_v, fps) + LOG.debug("[Basler] AcquisitionFrameRate range: min=%s max=%s requested=%s", min_v, max_v, fps) except Exception: pass @@ -485,7 +485,7 @@ def _configure_frame_rate(self) -> None: if feature is not None: readbacks[name] = self._feature_value(feature, None) - LOG.info("[Basler] FPS readback requested=%s values=%s", fps, readbacks) + LOG.debug("[Basler] Readback requested=%s values=%s", fps, readbacks) try: self._actual_fps = float(readbacks.get("AcquisitionFrameRate")) @@ -510,14 +510,14 @@ def _configure_converter(self) -> None: if self._should_output_mono(): self._converter.OutputPixelFormat = pylon.PixelType_Mono8 - LOG.info( + LOG.debug( "[Basler] Converter configured for Mono8 output (camera PixelFormat=%s preserve_mono=%s)", camera_pixel_format, self._preserve_mono, ) else: self._converter.OutputPixelFormat = pylon.PixelType_BGR8packed - LOG.info( + LOG.debug( "[Basler] Converter configured for BGR8 output (camera PixelFormat=%s preserve_mono=%s)", camera_pixel_format, self._preserve_mono, @@ -548,7 +548,7 @@ def open(self) -> None: self._camera.ExposureTime.SetValue(float(self.settings.exposure)) if hasattr(self._camera, "ExposureTimeAbs"): self._camera.ExposureTimeAbs.SetValue(float(self.settings.exposure)) - LOG.info("[Basler] Exposure set to %s us (auto off)", self.settings.exposure) + LOG.debug("[Basler] Exposure set to %s us (auto off)", self.settings.exposure) except Exception as exc: LOG.warning("[Basler] Failed to set exposure: %s", exc) @@ -558,7 +558,7 @@ def open(self) -> 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) + LOG.debug("[Basler] Gain set to %s dB (auto off)", self.settings.gain) except Exception as exc: LOG.warning("[Basler] Failed to set gain: %s", exc) @@ -638,7 +638,7 @@ def open(self) -> None: # pylon.GrabStrategy_LatestImageOnly, pylon.GrabStrategy_OneByOne, ) - LOG.info( + LOG.debug( "[Basler] grabbing=%s max_buffers=%s", self._camera.IsGrabbing(), self._camera.MaxNumBuffer.GetValue() if hasattr(self._camera, "MaxNumBuffer") else "N/A", @@ -646,7 +646,7 @@ def open(self) -> None: else: LOG.debug("Fast-start probe: skipping StartGrabbing and converter") - LOG.info( + LOG.debug( "[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), @@ -756,7 +756,7 @@ def read(self) -> CapturedFrame: if not self._logged_first_frame: self._logged_first_frame = True - LOG.info( + LOG.debug( "[Basler] first frame device_id=%s shape=%s dtype=%s nbytes=%.2f MB " "camera_pixel_format=%s output_format=%s preserve_mono=%s", self._device_id, @@ -803,7 +803,7 @@ def read(self) -> CapturedFrame: raise RuntimeError("Failed to retrieve image from Basler camera.") from exc def close(self) -> None: - LOG.info( + LOG.debug( "[Basler] close called camera_exists=%s grabbing=%s open=%s", self._camera is not None, bool(self._camera and self._camera.IsGrabbing()), @@ -1164,7 +1164,7 @@ def _configure_trigger_input(self, cfg, *, strict: bool = False) -> None: self._trigger = CameraTriggerSettings() return - LOG.info( + LOG.debug( "Basler trigger input configured: role=%s selector=%s source=%s activation=%s " "selector_ok=%s source_ok=%s activation_ok=%s", role, @@ -1229,7 +1229,7 @@ def _configure_trigger_master(self, cfg, *, strict: bool = False) -> None: source_ok = self._set_enum_feature("LineSource", output_source, strict=strict) if mode_ok and source_ok: - LOG.info( + LOG.debug( "Basler trigger master configured via Line*: output_line=%s output_source=%s", output_line, output_source, diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index e462a1dbb..f0eb6413e 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1275,7 +1275,7 @@ def _resolve_trigger_source(self, node_map, requested: str, *, strict: bool) -> if requested.lower() == "auto": for candidate in ("Line0", "Line1", "Line2", "Any"): if candidate in available: - LOG.info( + LOG.debug( "GenTL TriggerSource auto-selected '%s'. Available: %s", candidate, available, @@ -1447,7 +1447,7 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No self._trigger = CameraTriggerSettings() return - LOG.info( + LOG.debug( "GenTL trigger input configured: role=%s selector=%s source_requested=%s " "source=%s activation=%s selector_ok=%s source_ok=%s activation_ok=%s", role, @@ -1508,7 +1508,7 @@ def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> N node = self._node(node_map, "StrobeDuration") if node is not None: node.value = int(strobe_duration) - LOG.info("Configured GenTL StrobeDuration=%s", int(strobe_duration)) + LOG.debug("Configured GenTL StrobeDuration=%s", int(strobe_duration)) except Exception as exc: if strict: raise RuntimeError(f"Failed to set StrobeDuration={strobe_duration}: {exc}") from exc @@ -1519,7 +1519,7 @@ def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> N node = self._node(node_map, "StrobeDelay") if node is not None: node.value = int(strobe_delay) - LOG.info("Configured GenTL StrobeDelay=%s", int(strobe_delay)) + LOG.debug("Configured GenTL StrobeDelay=%s", int(strobe_delay)) except Exception as exc: if strict: raise RuntimeError(f"Failed to set StrobeDelay={strobe_delay}: {exc}") from exc @@ -1533,7 +1533,7 @@ def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> N ) if enable_ok: - LOG.info( + LOG.debug( "GenTL trigger master configured via Strobe*: " "StrobeEnable=On StrobePolarity=%s polarity_ok=%s " "StrobeOperation=%s operation_ok=%s", @@ -1573,7 +1573,7 @@ def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> N source_ok = self._set_enum_node(node_map, "LineSource", output_source, strict=strict) if mode_ok and source_ok: - LOG.info( + LOG.debug( "GenTL trigger master configured via Line*: output_line=%s output_source=%s", output_line, output_source, @@ -1692,7 +1692,7 @@ def _configure_frame_rate(self, node_map) -> None: return target = float(self.settings.fps) - LOG.info("Configuring GenTL frame rate: requested %.3f FPS", target) + LOG.debug("Configuring GenTL frame rate: requested %.3f FPS", target) for attr in ("AcquisitionFrameRateEnable", "AcquisitionFrameRateControlEnable"): try: @@ -1700,7 +1700,7 @@ def _configure_frame_rate(self, node_map) -> None: before = getattr(node, "value", None) node.value = True after = getattr(node, "value", None) - LOG.info("Enabled GenTL %s: before=%r after=%r", attr, before, after) + LOG.debug("Enabled GenTL %s: before=%r after=%r", attr, before, after) break except Exception: pass @@ -1712,7 +1712,7 @@ def _configure_frame_rate(self, node_map) -> None: node.value = target after = getattr(node, "value", None) - LOG.info( + LOG.debug( "Set GenTL %s: before=%r requested=%.3f after=%r", attr, before, From 2c6b6e3deb2be1afcbc3101c856ebece319a7c49 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 2 Jul 2026 13:30:00 +0200 Subject: [PATCH 131/133] Harden DLC worker startup and timing logs Adds fine-grained `WorkerTimingStats` instrumentation across enqueue, initialization, inference, and emit paths, with error/frame accounting and optional timing logging. It also makes worker startup/stop behavior safer by deferring queue creation until RUNNING, blocking enqueue during STARTING, normalizing input frames before inference, and adding richer debug diagnostics (CUDA/runner state and thread stack dumps on stuck shutdown). --- dlclivegui/services/dlc_processor.py | 286 ++++++++++++++++++++++----- dlclivegui/utils/stats.py | 5 +- dlclivegui/utils/utils.py | 16 ++ 3 files changed, 256 insertions(+), 51 deletions(-) diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index 0ecf8abca..4d4df7584 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -16,9 +16,11 @@ import numpy as np from PySide6.QtCore import QObject, Signal -from dlclivegui.config import DLCProcessorSettings, ModelType +from dlclivegui.config import DLC_DO_LOG_TIMING, DLCProcessorSettings, ModelType from dlclivegui.processors.processor_utils import instantiate_from_scan from dlclivegui.temp import Engine # type: ignore # TODO use main package enum when released +from dlclivegui.utils.stats import WorkerTimingStats +from dlclivegui.utils.utils import format_thread_stack logger = logging.getLogger(__name__) STOP_WORKER_TIMEOUT = 10.0 # # seconds to wait in STOPPING state before scheduling background reaping @@ -181,6 +183,13 @@ def __init__(self) -> None: self._gpu_inference_times: deque[float] = deque(maxlen=60) self._processor_overhead_times: deque[float] = deque(maxlen=60) + self._timing = WorkerTimingStats( + "DLCLiveProcessor", + logger=logger, + log_interval=1.0, + enabled=bool(DLC_DO_LOG_TIMING or ENABLE_PROFILING), + ) + @staticmethod def get_model_backend(model_path: str) -> Engine: return Engine.from_model_path(model_path) @@ -232,24 +241,40 @@ def shutdown(self) -> None: def enqueue_frame(self, frame: np.ndarray, timestamp: float) -> None: # Keep lifecycle lock held only for quick state checks and snapshots. with self._lifecycle_lock: - if self._state in (WorkerState.STOPPING, WorkerState.FAULTED) or self._stop_event.is_set(): + if ( + self._state in (WorkerState.STOPPING, WorkerState.FAULTED, WorkerState.STARTING) + or self._stop_event.is_set() + ): return t = self._worker_thread q = self._queue should_start = t is None or not t.is_alive() - frame_c = frame.copy() + with self._timing.measure("DLC.enqueue.copy_frame"): + frame_c = frame.copy() enq_time = time.perf_counter() if should_start: # Re-acquire the lifecycle lock to safely (re)start the worker if needed. with self._lifecycle_lock: # Re-check state in case it changed while we were copying the frame. - if self._state in (WorkerState.STOPPING, WorkerState.FAULTED) or self._stop_event.is_set(): + if ( + self._state in (WorkerState.STOPPING, WorkerState.FAULTED, WorkerState.STARTING) + or self._stop_event.is_set() + ): return t = self._worker_thread if t is None or not t.is_alive(): - # _start_worker_locked expects the lifecycle lock to be held. + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "Starting DLC worker from first frame: " + "shape=%s dtype=%s contiguous=%s strides=%s timestamp=%.6f", + frame_c.shape, + frame_c.dtype, + frame_c.flags["C_CONTIGUOUS"], + frame_c.strides, + timestamp, + ) self._start_worker_locked(frame_c, timestamp) return # Worker is now running; refresh queue snapshot. @@ -262,18 +287,20 @@ def enqueue_frame(self, frame: np.ndarray, timestamp: float) -> None: while True: try: - q.put_nowait(item) + with self._timing.measure("DLC.enqueue.put"): + q.put_nowait(item) with self._stats_lock: self._frames_enqueued += 1 return except queue.Full: try: - q.get_nowait() - try: - q.task_done() - except ValueError: - pass + with self._timing.measure("DLC.enqueue.drop_stale"): + q.get_nowait() + try: + q.task_done() + except ValueError: + pass with self._stats_lock: self._frames_dropped += 1 @@ -332,11 +359,91 @@ def get_stats(self) -> ProcessorStats: avg_processor_overhead=avg_proc_overhead, ) + def _debug_log_dlc_runner_device(self) -> None: + if not logger.isEnabledFor(logging.DEBUG): + return + + try: + import torch + + logger.debug( + "Torch CUDA state: available=%s built=%s device_count=%s current_device=%s device_name=%s " + "allocated=%.2fMB reserved=%.2fMB", + torch.cuda.is_available(), + torch.backends.cuda.is_built(), + torch.cuda.device_count(), + torch.cuda.current_device() if torch.cuda.is_available() else None, + torch.cuda.get_device_name(0) if torch.cuda.is_available() and torch.cuda.device_count() else None, + torch.cuda.memory_allocated(0) / (1024 * 1024) if torch.cuda.is_available() else 0.0, + torch.cuda.memory_reserved(0) / (1024 * 1024) if torch.cuda.is_available() else 0.0, + ) + except Exception: + logger.debug("Could not query torch CUDA state", exc_info=True) + + dlc = self._dlc + runner = getattr(dlc, "runner", None) + + logger.debug( + "DLCLive runner: type=%s runner.device=%r runner.model=%r runner.net=%r", + type(runner).__name__ if runner is not None else None, + getattr(runner, "device", None), + type(getattr(runner, "model", None)).__name__ if getattr(runner, "model", None) is not None else None, + type(getattr(runner, "net", None)).__name__ if getattr(runner, "net", None) is not None else None, + ) + + seen: set[int] = set() + + def walk(obj, path: str, depth: int = 0) -> None: + if obj is None or depth > 7: + return + + oid = id(obj) + if oid in seen: + return + seen.add(oid) + + try: + params = getattr(obj, "parameters", None) + if callable(params): + first_param = next(iter(params()), None) + if first_param is not None: + logger.debug( + "Torch module at %s: parameter device=%s is_cuda=%s dtype=%s shape=%s", + path, + first_param.device, + first_param.is_cuda, + first_param.dtype, + tuple(first_param.shape), + ) + except Exception: + pass + + for name in ( + "runner", + "model", + "net", + "pose_model", + "dlc_model", + "module", + "engine", + "predictor", + "detector", + "backbone", + ): + try: + child = getattr(obj, name, None) + except Exception: + child = None + if child is not None: + walk(child, f"{path}.{name}", depth + 1) + + walk(self._dlc, "self._dlc") + def _start_worker_locked(self, init_frame: np.ndarray, init_timestamp: float) -> None: # lifecycle_lock must already be held if self._worker_thread is not None and self._worker_thread.is_alive(): return - self._queue = queue.Queue(maxsize=1) + self._queue = None self._stop_event.clear() self._state = WorkerState.STARTING self._worker_thread = threading.Thread( @@ -364,7 +471,7 @@ def _stop_worker(self) -> bool: t.join(timeout=STOP_WORKER_TIMEOUT) if t.is_alive(): qsize = self._queue.qsize() if self._queue is not None else -1 - logger.warning("DLC worker thread did not terminate cleanly (qsize=%s)", qsize) + logger.warning("DLC worker thread did not terminate cleanly (qsize=%s)\n%s", qsize, format_thread_stack(t)) self._schedule_reap(t) return False @@ -427,7 +534,8 @@ def _timed_processor(self): def timed_process(pose, _op=original, _holder=holder, **kwargs): start = time.perf_counter() try: - return _op(pose, **kwargs) + with self._timing.measure("DLC.processor.process"): + return _op(pose, **kwargs) finally: _holder[0] = time.perf_counter() - start @@ -438,6 +546,24 @@ def timed_process(pose, _op=original, _holder=holder, **kwargs): # Restore even if inference/errors occur self._processor.process = original + @staticmethod + def _prepare_input_frame(frame: np.ndarray) -> np.ndarray: + """Normalize camera frames for DLCLive inference.""" + arr = np.asarray(frame) + + if arr.ndim == 2: + # Mono8 / grayscale -> 3-channel + arr = np.repeat(arr[:, :, None], 3, axis=2) + elif arr.ndim == 3 and arr.shape[2] == 4: + arr = arr[:, :, :3] + elif arr.ndim != 3 or arr.shape[2] != 3: + raise ValueError(f"Unsupported DLCLive input frame shape: {arr.shape}") + + if arr.dtype != np.uint8: + arr = np.clip(arr, 0, 255).astype(np.uint8, copy=False) + + return np.ascontiguousarray(arr) + def _process_frame( self, frame: np.ndarray, @@ -453,11 +579,23 @@ def _process_frame( if self._dlc is None: raise RuntimeError("DLCLive instance is not initialized.") # Time GPU inference (and processor overhead when present) + with self._timing.measure("DLC.prepare_frame"): + frame = self._prepare_input_frame(frame) with self._timed_processor() as proc_holder: inference_start = time.perf_counter() - raw_pose: Any = self._dlc.get_pose(frame, frame_time=timestamp) + + with self._timing.measure("DLC.process_frame"): + processed_frame = self._dlc.process_frame(frame) + + with self._timing.measure("DLC.runner.get_pose"): + self._dlc.pose = self._dlc.runner.get_pose(processed_frame) + + with self._timing.measure("DLC.post_process_pose"): + raw_pose: Any = self._dlc._post_process_pose(processed_frame, frame_time=timestamp) + inference_time = time.perf_counter() - inference_start - pose_arr: np.ndarray = validate_pose_array(raw_pose, source_backend=PoseBackends.DLC_LIVE) + with self._timing.measure("DLC.validate_pose"): + pose_arr: np.ndarray = validate_pose_array(raw_pose, source_backend=PoseBackends.DLC_LIVE) pose_packet = PosePacket( schema_version=0, keypoints=pose_arr, @@ -475,7 +613,8 @@ def _process_frame( # Emit pose (measure signal overhead) signal_start = time.perf_counter() - self.pose_ready.emit(PoseResult(pose=pose_packet.keypoints, timestamp=timestamp, packet=pose_packet)) + with self._timing.measure("DLC.emit.pose_ready"): + self.pose_ready.emit(PoseResult(pose=pose_packet.keypoints, timestamp=timestamp, packet=pose_packet)) signal_time = time.perf_counter() - signal_start end_ts = time.perf_counter() @@ -496,6 +635,8 @@ def _process_frame( self._gpu_inference_times.append(gpu_inference_time) self._processor_overhead_times.append(processor_overhead) + self._timing.note_frame() + self._timing.maybe_log() self.frame_processed.emit() def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: @@ -504,60 +645,98 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: if not self._settings.model_path: raise RuntimeError("No DLCLive model path configured.") - init_start = time.perf_counter() - dyn = self._settings.dynamic - if not isinstance(dyn, (list, tuple)) or len(dyn) != 3: - try: - dyn = dyn.to_tuple() - except Exception as e: - raise RuntimeError("Invalid dynamic crop settings format.") from e - enabled, margin, max_missing = dyn - - options = { - "model_path": self._settings.model_path, - "model_type": self._settings.model_type, - "processor": self._processor, - "dynamic": [enabled, margin, max_missing], - "resize": self._settings.resize, - "precision": self._settings.precision, - "single_animal": self._settings.single_animal, - } - if self._settings.device is not None: - options["device"] = self._settings.device + with self._timing.measure("DLC.build_options"): + dyn = self._settings.dynamic + if not isinstance(dyn, (list, tuple)) or len(dyn) != 3: + try: + dyn = dyn.to_tuple() + except Exception as e: + raise RuntimeError("Invalid dynamic crop settings format.") from e + enabled, margin, max_missing = dyn + + options = { + "model_path": self._settings.model_path, + "model_type": self._settings.model_type, + "processor": self._processor, + "dynamic": [enabled, margin, max_missing], + "resize": self._settings.resize, + "precision": self._settings.precision, + "single_animal": self._settings.single_animal, + } + if self._settings.device is not None: + options["device"] = self._settings.device + + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "DLC worker starting: model_path=%s model_type=%s device=%s " + "init_frame_shape=%s dtype=%s contiguous=%s", + self._settings.model_path, + self._settings.model_type, + self._settings.device, + init_frame.shape, + init_frame.dtype, + init_frame.flags["C_CONTIGUOUS"], + ) try: if DLCLive is None: raise RuntimeError( "DLCLive class is not available. Ensure the dlclive package is installed and can be imported." ) - self._dlc = DLCLive(**options) + with self._timing.measure("DLC.construct"): + self._dlc = DLCLive(**options) + self._timing.maybe_log() except Exception as exc: + self._timing.note_error() + self._timing.maybe_log() with self._lifecycle_lock: self._state = WorkerState.FAULTED raise RuntimeError( f"Failed to initialize DLCLive with model '{self._settings.model_path}': {exc}" ) from exc + if self._stop_event.is_set(): + logger.debug("DLC worker stop requested during construction; exiting before init_inference.") + return + + with self._timing.measure("DLC.prepare_init_frame"): + init_frame = self._prepare_input_frame(init_frame) + + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "Calling DLCLive.init_inference with frame shape=%s dtype=%s contiguous=%s", + init_frame.shape, + init_frame.dtype, + init_frame.flags["C_CONTIGUOUS"], + ) # First inference to initialize - init_inference_start = time.perf_counter() - self._dlc.init_inference(init_frame) - init_inference_time = time.perf_counter() - init_inference_start + with self._timing.measure("DLC.init_inference"): + self._dlc.init_inference(init_frame) + + self._debug_log_dlc_runner_device() + self._timing.note_frame() + self._timing.maybe_log() + + if self._stop_event.is_set(): + logger.debug("DLC worker stop requested after init_inference; exiting before RUNNING state.") + return # Pass DLCLive cfg to processor if available if hasattr(self._dlc, "processor") and hasattr(self._dlc.processor, "set_dlc_cfg"): - self._dlc.processor.set_dlc_cfg(getattr(self._dlc, "cfg", None)) + with self._timing.measure("DLC.processor.set_dlc_cfg"): + self._dlc.processor.set_dlc_cfg(getattr(self._dlc, "cfg", None)) self._initialized = True self.initialized.emit(True) with self._lifecycle_lock: + if self._stop_event.is_set(): + logger.debug("DLC worker stop requested before RUNNING state; exiting.") + return + + self._queue = queue.Queue(maxsize=1) self._state = WorkerState.RUNNING - total_init_time = time.perf_counter() - init_start - logger.info( - "DLCLive model initialized successfully (total: %.3fs, init_inference: %.3fs)", - total_init_time, - init_inference_time, - ) + logger.info("DLCLive model initialized successfully") # Emit pose for init frame & update stats (not dequeued) self._process_frame(init_frame, init_timestamp, time.perf_counter(), queue_wait_time=0.0) @@ -598,6 +777,8 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: try: self._process_frame(frame, ts, enq, queue_wait_time=0.0) except Exception as exc: + self._timing.note_error() + self._timing.maybe_log() logger.exception("Pose inference failed", exc_info=exc) self.error.emit(str(exc)) finally: @@ -610,11 +791,14 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: # Normal operation: timed get try: wait_start = time.perf_counter() - item = q.get(timeout=0.05) + with self._timing.measure("DLC.queue_get"): + item = q.get(timeout=0.05) queue_wait_time = time.perf_counter() - wait_start except queue.Empty: continue except Exception as exc: + self._timing.note_error() + self._timing.maybe_log() logger.exception("Error getting item from queue", exc_info=exc) with self._lifecycle_lock: self._state = WorkerState.FAULTED @@ -625,6 +809,8 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: frame, ts, enq = item self._process_frame(frame, ts, enq, queue_wait_time=queue_wait_time) except Exception as exc: + self._timing.note_error() + self._timing.maybe_log() logger.exception("Pose inference failed", exc_info=exc) self.error.emit(str(exc)) finally: @@ -635,6 +821,8 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: logger.info("DLC worker thread exiting") + self._timing.maybe_log() + class DLCService: """Wrap DLCLiveProcessor lifecycle & configuration.""" diff --git a/dlclivegui/utils/stats.py b/dlclivegui/utils/stats.py index 1edbf7890..0ef0528e2 100644 --- a/dlclivegui/utils/stats.py +++ b/dlclivegui/utils/stats.py @@ -70,6 +70,7 @@ def __init__(self, parent: WorkerTimingStats, name: str): self.parent = parent self.name = name self.t0 = 0.0 + self.elapsed = 0.0 def __enter__(self): if self.parent.enabled: @@ -80,8 +81,8 @@ def __exit__(self, exc_type, exc, tb): if not self.parent.enabled: return False - dt = time.perf_counter() - self.t0 - self.parent._totals[self.name] = self.parent._totals.get(self.name, 0.0) + dt + self.elapsed = time.perf_counter() - self.t0 + self.parent._totals[self.name] = self.parent._totals.get(self.name, 0.0) + self.elapsed self.parent._counts[self.name] = self.parent._counts.get(self.name, 0) + 1 return False diff --git a/dlclivegui/utils/utils.py b/dlclivegui/utils/utils.py index 534e5732f..bd72958cd 100644 --- a/dlclivegui/utils/utils.py +++ b/dlclivegui/utils/utils.py @@ -1,7 +1,10 @@ from __future__ import annotations import re +import sys +import threading import time +import traceback from collections import deque from collections.abc import Iterable from dataclasses import dataclass @@ -87,6 +90,19 @@ def build_run_dir(session_dir: Path, *, use_timestamp: bool) -> Path: return run_dir +def format_thread_stack(thread: threading.Thread) -> str: + ident = thread.ident + if ident is None: + return f"Thread {thread.name!r} has no ident." + + frame = sys._current_frames().get(ident) + if frame is None: + return f"No Python stack frame found for thread {thread.name!r} ident={ident}." + + stack = "".join(traceback.format_stack(frame)) + return f"Stack for thread {thread.name!r} ident={ident}:\n{stack}" + + @dataclass(frozen=True) class RecordingPlan: session_dir: Path From bada5de05e4c615893d5a8c6eb306d7d2b97b7b6 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 2 Jul 2026 13:30:37 +0200 Subject: [PATCH 132/133] Disable pose latency debug logging Comment out the camera-to-GUI pose latency calculation and debug log in `pose_ready_callback`. This removes the try/except-wrapped timing log path while leaving pose handling and display update behavior unchanged. --- dlclivegui/gui/main_window.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 898540eb7..bbd3ef70b 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -2061,12 +2061,12 @@ def _on_pose_ready(self, result: PoseResult) -> None: with self._dlc_timing.measure("DLC.pose_ready_callback"): self._last_pose = result - try: - latency_ms = (time.time() - float(result.timestamp)) * 1000.0 - if logger.isEnabledFor(logging.DEBUG): - logger.debug("DLC pose latency camera_timestamp_to_gui=%.2f ms", latency_ms) - except Exception: - pass + # try: + # latency_ms = (time.time() - float(result.timestamp)) * 1000.0 + # if logger.isEnabledFor(logging.DEBUG): + # logger.debug("DLC pose latency camera_timestamp_to_gui=%.2f ms", latency_ms) + # except Exception: + # pass if self._current_frame is not None: self._display_dirty = True From abaf2fbfb5570a75fd3a1bdcd4f070d40ac8eac9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 2 Jul 2026 13:31:01 +0200 Subject: [PATCH 133/133] Fix shutdown order in camera preview stop Reorders preview teardown so inference is stopped before stopping the multi-camera controller. This avoids stopping the controller while inference is still active and keeps shutdown state cleanup consistent. --- dlclivegui/gui/main_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index bbd3ef70b..2d6c3e0d1 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1723,9 +1723,9 @@ def _stop_preview(self) -> None: # Stop any active recording first self._stop_multi_camera_recording() - self.multi_camera_controller.stop() self._pending_recording_after_preview = False self._stop_inference(show_message=False) + self.multi_camera_controller.stop() self._fps_tracker.clear() self._last_display_time = 0.0 if hasattr(self, "camera_stats_label"):