-
Notifications
You must be signed in to change notification settings - Fork 28
Expand file tree
/
Copy pathgentl_backend.py
More file actions
1937 lines (1624 loc) · 73.3 KB
/
Copy pathgentl_backend.py
File metadata and controls
1937 lines (1624 loc) · 73.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""GenTL backend implemented using the Harvesters library."""
# dlclivegui/cameras/backends/gentl_backend.py
from __future__ import annotations
import logging
import threading
import time
from pathlib import Path
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
LOG = logging.getLogger(__name__)
try: # pragma: no cover - optional dependency
from harvesters.core import Harvester # type: ignore
try:
from harvesters.core import HarvesterTimeoutError # type: ignore
except Exception: # pragma: no cover - optional dependency
HarvesterTimeoutError = TimeoutError # type: ignore
except Exception: # pragma: no cover - optional dependency
Harvester = None # type: ignore
HarvesterTimeoutError = TimeoutError # type: ignore
@register_backend("gentl")
class GenTLCameraBackend(CameraBackend):
"""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: 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",
)
_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.
# user: explicitly set by user; strict if stale/missing.
_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)
props = settings.properties if isinstance(settings.properties, dict) else {}
ns = props.get(self.OPTIONS_KEY, {})
if not isinstance(ns, dict):
ns = {}
self._fast_start: bool = bool(ns.get("fast_start", False))
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)
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")))
self._exposure: float | None = self._positive_float(getattr(settings, "exposure", 0))
if self._exposure is None:
self._exposure = self._positive_float(ns.get("exposure", props.get("exposure")))
self._gain: float | None = self._positive_float(getattr(settings, "gain", 0.0))
if self._gain is None:
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(raw_trigger)
except Exception as 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))
if trigger_timeout is not None:
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()
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
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:
return (self._actual_width, self._actual_height)
return None
@property
def actual_fps(self) -> float | None:
return self._actual_fps
@property
def actual_exposure(self) -> float | None:
return self._actual_exposure
@property
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
@classmethod
def static_capabilities(cls) -> dict[str, SupportLevel]:
return {
"set_resolution": SupportLevel.SUPPORTED,
"set_fps": SupportLevel.SUPPORTED,
"set_exposure": SupportLevel.SUPPORTED,
"set_gain": SupportLevel.SUPPORTED,
"device_discovery": SupportLevel.SUPPORTED,
"stable_identity": SupportLevel.SUPPORTED,
"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
# ------------------------------------------------------------------
@classmethod
def get_device_count(cls) -> int:
"""Return the number of GenTL devices, or -1 if detection fails."""
if Harvester is None:
return -1
harvester = None
try:
harvester, _, _ = cls._build_harvester_for_discovery(strict_single=False)
if harvester is None:
return -1
return len(harvester.device_info_list or [])
except Exception:
return -1
finally:
cls._safe_reset_harvester(harvester)
@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 []
def _canceled() -> bool:
return bool(should_cancel and should_cancel())
harvester = None
try:
if progress_cb:
progress_cb("Initializing GenTL discovery…")
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 []
if progress_cb:
progress_cb(f"Loaded {len(loaded)} GenTL producer(s). Scanning devices…")
infos = list(harvester.device_info_list or [])
limit = min(len(infos), max_devices if max_devices > 0 else len(infos))
out: list[DetectedCamera] = []
for idx in range(limit):
if _canceled():
break
info = infos[idx]
label = cls._label_from_info(info, idx)
device_id = cls._device_id_from_info(info)
out.append(
DetectedCamera(
index=idx,
label=label,
device_id=device_id,
vid=None,
pid=None,
path=None,
backend_hint=None,
)
)
if progress_cb:
progress_cb(f"Found: {label}")
out.sort(key=lambda c: c.index)
return out
except Exception:
LOG.debug("GenTL rich discovery failed", exc_info=True)
return []
finally:
cls._safe_reset_harvester(harvester)
@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
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:
cls._safe_reset_harvester(harvester)
@classmethod
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
candidates, diag = cti_finder.discover_cti_files(
include_env=True,
cti_search_paths=list(cls._DEFAULT_CTI_PATTERNS),
must_exist=True,
)
if not candidates:
return None, [], diag
cti_files = list(candidates)
if strict_single:
cti_files = cti_finder.choose_cti_files(
cti_files,
policy=cti_finder.GenTLDiscoveryPolicy.RAISE_IF_MULTIPLE,
max_files=1,
)
harvester = Harvester()
loaded: list[str] = []
for cti in cti_files:
ok, reason = cls._cti_preflight(cti)
if not ok:
LOG.warning("Skipping CTI '%s' during discovery preflight: %s", cti, reason)
continue
try:
harvester.add_file(cti)
loaded.append(cti)
except Exception as exc:
LOG.warning("Failed to load CTI '%s' during discovery: %s", cti, exc)
if not loaded:
cls._safe_reset_harvester(harvester)
return None, [], diag
try:
harvester.update()
except Exception as exc:
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
# ------------------------------------------------------------------
# Settings rebinding
# ------------------------------------------------------------------
@classmethod
def rebind_settings(cls, settings):
"""Map stable identity to current index when necessary.
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
props = settings.properties if isinstance(settings.properties, dict) else {}
ns = props.get(cls.OPTIONS_KEY, {})
if not isinstance(ns, dict):
ns = {}
target_id = ns.get("device_id") or ns.get("serial_number") or ns.get("serial")
if not target_id:
return settings
target_id_str = str(target_id).strip()
if target_id_str.startswith("serial:"):
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:
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
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,
)
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:
harvester.add_file(cti)
loaded.append(cti)
except Exception:
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()
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."
)
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_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._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)
if self._fast_start:
LOG.info("GenTL open() in fast_start probe mode: acquisition not started.")
return
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,
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
@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 _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")
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:
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
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 or self._actual_gain is None:
try:
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
def stop(self) -> None:
if self._acquirer is not None:
try:
self._call_with_optional_lock(self._acquirer.stop)
except Exception:
pass
def close(self) -> None:
if self._acquirer is not None:
try:
self._call_with_optional_lock(self._acquirer.stop)
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:
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()
self._device_label = None
# ------------------------------------------------------------------
# CTI / shared Harvester helpers
# ------------------------------------------------------------------
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)
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]] = []
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)
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])
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."
)
return loaded, failed
def _acquire_shared_harvester(self, loaded: list[str]) -> list:
ns = self._ensure_settings_ns()
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 = dict(getattr(self._shared_entry, "failed_files", {}))
ns["cti_files_loaded"] = actual_loaded
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())
ns["cti_files_failed"] = merged_failed
with self._shared_entry.lock:
infos = list(self._harvester.device_info_list or [])
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 = dict(getattr(exc, "failed_files", {}))
if exc_loaded or exc_failed:
ns["cti_files_loaded"] = [str(p) for p in exc_loaded]
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:
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 _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
@staticmethod
def _reset_select_harvester(harvester) -> None:
GenTLCameraBackend._safe_reset_harvester(harvester)
@staticmethod
def _safe_reset_harvester(harvester) -> None:
if harvester is not None:
try:
harvester.reset()
except Exception:
pass
@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}"
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 = {}
source = ns.get("cti_files_source")
source = str(source).strip().lower() if source is not None else None
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")
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 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.")
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)
)
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)
# ------------------------------------------------------------------
# Device selection / identity helpers
# ------------------------------------------------------------------
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")
selected_index: int | None = None
selected_serial: str | None = None
if target_device_id:
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
serial = self._info_get(infos[selected_index], "serial_number", "")
selected_serial = str(serial).strip() if serial else None
return int(selected_index), selected_serial, infos[int(selected_index)]
@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]}"
)
return None, None
@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}"
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
@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
@staticmethod