Skip to content

Commit 36a3d56

Browse files
C-AchardCopilot
andauthored
Upgrade & fix GenTL multi-camera mode (#76)
* 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. * 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. * WIP Working multi CTI implementation * Clean up file * 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. * 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. * 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. * 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. * 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. * 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). * Merge failed CTI files instead of overwrite Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Avoid overwriting failed CTIs Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * pre-commit * Revert display name change * 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. * 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. * 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. * 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. * Restore clean up of stopped camera threads before restart When starting a camera, the controller again detects stale worker entries whose thread is missing or no longer running, logs a warning, and cleans up that camera state before continuing. * Improve consistency in camera identity fallback logic Update `camera_identity_key` to better preserve camera identity across sessions. The backend namespace lookup is now guarded to avoid non-dict values, `device_id` is normalized to string, and serial-based identifiers (`serial_number`, `device_serial_number`, `serial`) are used before falling back to index. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 5cdaa09 commit 36a3d56

10 files changed

Lines changed: 1589 additions & 1164 deletions

File tree

‎dlclivegui/cameras/backends/gentl_backend.py‎

Lines changed: 858 additions & 1024 deletions
Large diffs are not rendered by default.

‎dlclivegui/cameras/backends/utils/gentl_discovery.py‎

Lines changed: 129 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
from __future__ import annotations
66

77
import glob
8+
import logging
89
import os
10+
import threading
911
from collections.abc import Iterable, Sequence
1012
from dataclasses import dataclass, field
1113
from enum import Enum, auto
@@ -18,6 +20,125 @@ class GenTLDiscoveryPolicy(Enum):
1820
RAISE_IF_MULTIPLE = auto() # if > N candidates, raise an error to avoid ambiguity (forces explicit config)
1921

2022

23+
try: # pragma: no cover - optional dependency
24+
from harvesters.core import Harvester # type: ignore
25+
except Exception: # pragma: no cover - optional dependency
26+
Harvester = None # type: ignore
27+
28+
logger = logging.getLogger(__name__)
29+
30+
31+
class SharedHarvesterEntry:
32+
"""
33+
A shared Harvester instance keyed by a canonical tuple of CTI files.
34+
"""
35+
36+
def __init__(self, cti_files: list[str]):
37+
if Harvester is None: # pragma: no cover
38+
raise RuntimeError(
39+
"The 'harvesters' package is required for the GenTL backend. Install it via 'pip install harvesters'."
40+
)
41+
42+
self.lock = threading.RLock()
43+
self.key = tuple(sorted(_normalize_path(p, casefold_windows=True) for p in cti_files))
44+
self.refcount = 0
45+
self.harvester = Harvester()
46+
self.loaded_files: list[str] = []
47+
self.failed_files: dict[str, str] = {}
48+
49+
for cti in self.key:
50+
try:
51+
self.harvester.add_file(cti)
52+
self.loaded_files.append(cti)
53+
except Exception as e:
54+
logger.warning(f"Failed to load CTI file: {cti}. Skipping.")
55+
self.failed_files[cti] = str(e)
56+
57+
if not self.loaded_files:
58+
e = RuntimeError("No GenTL producer (.cti) could be loaded by shared Harvester.")
59+
self._raise_and_reset_harvester(e)
60+
61+
# Initial device enumeration.
62+
try:
63+
self.harvester.update()
64+
except Exception as e:
65+
self._raise_and_reset_harvester(e)
66+
67+
def _raise_and_reset_harvester(self, exc: Exception) -> None:
68+
exc.loaded_files = self.loaded_files[:]
69+
exc.failed_files = dict(self.failed_files)
70+
try:
71+
self.harvester.reset()
72+
except Exception:
73+
pass
74+
raise exc
75+
76+
77+
class SharedHarvesterPool:
78+
"""
79+
Process-local pool of shared Harvester instances.
80+
81+
Keyed by the canonicalized CTI file set.
82+
"""
83+
84+
_lock = threading.RLock()
85+
_entries: dict[tuple[str, ...], SharedHarvesterEntry] = {}
86+
87+
@classmethod
88+
def acquire(cls, cti_files: list[str]) -> SharedHarvesterEntry:
89+
key = tuple(sorted(_normalize_path(p, casefold_windows=True) for p in cti_files))
90+
with cls._lock:
91+
entry = cls._entries.get(key)
92+
if entry is None:
93+
entry = SharedHarvesterEntry(list(key))
94+
cls._entries[key] = entry
95+
entry.refcount += 1
96+
return entry
97+
98+
@classmethod
99+
def release(cls, entry: SharedHarvesterEntry | None) -> None:
100+
if entry is None:
101+
return
102+
103+
with cls._lock:
104+
current = cls._entries.get(entry.key)
105+
if current is None:
106+
# Already released/reset.
107+
return
108+
109+
current.refcount -= 1
110+
if current.refcount > 0:
111+
return
112+
113+
try:
114+
with current.lock:
115+
try:
116+
current.harvester.reset()
117+
except Exception:
118+
pass
119+
finally:
120+
cls._entries.pop(entry.key, None)
121+
122+
@classmethod
123+
def refresh(cls, entry: SharedHarvesterEntry | None) -> None:
124+
"""
125+
Optional helper when callers want to re-enumerate the device list
126+
on an already-shared Harvester instance.
127+
"""
128+
if entry is None:
129+
return
130+
with entry.lock:
131+
entry.harvester.update()
132+
133+
@classmethod
134+
def get_refcount(cls, entry: SharedHarvesterEntry | None) -> int:
135+
if entry is None:
136+
return 0
137+
with cls._lock:
138+
current = cls._entries.get(entry.key)
139+
return int(current.refcount) if current is not None else 0
140+
141+
21142
@dataclass
22143
class CTIDiscoveryDiagnostics:
23144
explicit_files: list[str] = field(default_factory=list)
@@ -81,18 +202,18 @@ def _expand_user_and_env(value: str) -> str:
81202
return s
82203

83204

84-
def _normalize_path(p: str) -> str:
85-
"""
86-
Normalize a filesystem path in a cross-platform way:
87-
- expands ~ and environment variables
88-
- resolves to absolute where possible (without requiring existence)
89-
"""
205+
def _normalize_path(p: str, *, casefold_windows: bool = False) -> str:
90206
expanded = _expand_user_and_env(p)
91207
pp = Path(expanded)
92208
try:
93-
return str(pp.resolve(strict=False))
209+
out = str(pp.resolve(strict=False))
94210
except Exception:
95-
return str(pp.absolute())
211+
out = str(pp.absolute())
212+
213+
if casefold_windows:
214+
out = os.path.normcase(out)
215+
216+
return out
96217

97218

98219
def _iter_cti_files_in_dir(directory: str, recursive: bool = False) -> Iterable[str]:

‎dlclivegui/cameras/factory.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -454,10 +454,14 @@ def apply_detected_identity(cam: CameraSettings, detected: DetectedCamera, backe
454454
def camera_identity_key(cam: CameraSettings) -> tuple:
455455
backend = (cam.backend or "").lower()
456456
props = cam.properties if isinstance(cam.properties, dict) else {}
457-
ns = props.get(backend, {}) if isinstance(props, dict) else {}
458-
device_id = ns.get("device_id")
457+
ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {}
459458

460-
# Prefer stable identity if present, otherwise fallback
459+
device_id = ns.get("device_id")
461460
if device_id:
462-
return (backend, "device_id", device_id)
461+
return (backend, "device_id", str(device_id))
462+
463+
serial = ns.get("serial_number") or ns.get("device_serial_number") or ns.get("serial")
464+
if serial:
465+
return (backend, "serial", str(serial))
466+
463467
return (backend, "index", int(cam.index))

‎dlclivegui/main.py‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import argparse
55
import logging
6+
import os
67
import signal
78
import sys
89

@@ -54,6 +55,32 @@ def _sigint_handler(_signum, _frame) -> None:
5455
app._sig_timer = sig_timer # Store on app to keep it alive and allow cleanup on exit
5556

5657

58+
def configure_logging(debug: bool = False) -> None:
59+
"""Configure local application logging."""
60+
env_debug = os.environ.get("DLCLIVEGUI_DEBUG_LOGGING", "").strip().lower() in (
61+
"1",
62+
"true",
63+
"yes",
64+
"on",
65+
"debug",
66+
)
67+
68+
enabled = bool(debug or env_debug)
69+
level = logging.DEBUG if enabled else logging.INFO
70+
71+
logging.basicConfig(
72+
level=level,
73+
format="%(asctime)s.%(msecs)03d %(levelname)-8s [%(threadName)s] %(name)s:%(lineno)d - %(message)s",
74+
datefmt="%H:%M:%S",
75+
force=True,
76+
)
77+
78+
logging.getLogger("dlclivegui").setLevel(level)
79+
80+
if enabled:
81+
logging.debug("Debug logging enabled.")
82+
83+
5784
def parse_args(argv=None):
5885
if argv is None:
5986
argv = sys.argv[1:]
@@ -77,12 +104,13 @@ def parse_args(argv=None):
77104
formatter_class=argparse.RawDescriptionHelpFormatter,
78105
)
79106
parser.add_argument("--no-art", action="store_true", help="Disable ASCII art in help and when launching.")
107+
parser.add_argument("--debug-log", action="store_true", help="Enable debug logging.")
80108
return parser.parse_known_args(argv)
81109

82110

83111
def main() -> None:
84112
args, _unknown = parse_args()
85-
113+
configure_logging(debug=args.debug_log)
86114
logging.info("Starting DeepLabCut-Live GUI...")
87115

88116
# If you want a startup banner, PRINT it (not log), and only in TTY contexts.

0 commit comments

Comments
 (0)