Skip to content

Commit 97fa138

Browse files
committed
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.
1 parent 90241f6 commit 97fa138

3 files changed

Lines changed: 270 additions & 7 deletions

File tree

dlclivegui/cameras/backends/gentl_backend.py

Lines changed: 173 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
import threading
88
import time
99
from pathlib import Path
10-
from typing import ClassVar
10+
from typing import Any, ClassVar
1111

1212
import cv2
1313
import numpy as np
1414

15+
from ...config import CameraTriggerSettings
1516
from ..base import CameraBackend, SupportLevel, register_backend
1617
from ..factory import DetectedCamera
1718
from .utils import gentl_discovery as cti_finder
@@ -105,6 +106,16 @@ def __init__(self, settings):
105106
self._gain = self._positive_float(ns.get("gain", props.get("gain")))
106107

107108
self._timeout: float = float(ns.get("timeout", props.get("timeout", 2.0)))
109+
try:
110+
self._trigger = CameraTriggerSettings.from_any(ns.get("trigger", props.get("trigger")))
111+
except Exception as exc:
112+
LOG.warning("Invalid GenTL trigger config; falling back to trigger role=off: %s", exc)
113+
self._trigger = CameraTriggerSettings()
114+
115+
trigger_timeout = self._positive_float(self._trigger_attr(self._trigger, "timeout", None))
116+
if trigger_timeout is not None:
117+
self._timeout = float(trigger_timeout)
118+
108119
self._requested_resolution: tuple[int, int] | None = self._get_requested_resolution_or_none()
109120

110121
self._actual_width: int | None = None
@@ -154,6 +165,7 @@ def static_capabilities(cls) -> dict[str, SupportLevel]:
154165
"set_gain": SupportLevel.SUPPORTED,
155166
"device_discovery": SupportLevel.SUPPORTED,
156167
"stable_identity": SupportLevel.SUPPORTED,
168+
"hardware_trigger": SupportLevel.BEST_EFFORT,
157169
}
158170

159171
# ------------------------------------------------------------------
@@ -420,11 +432,12 @@ def open(self) -> None:
420432
self._device_label = self._resolve_device_label(node_map)
421433

422434
self._configure_pixel_format(node_map)
423-
self._configure_trigger(node_map)
424435
self._configure_resolution(node_map)
425436
self._configure_exposure(node_map)
426437
self._configure_gain(node_map)
427438
self._configure_frame_rate(node_map)
439+
self._configure_trigger(node_map) # keep low in the list
440+
self._ensure_settings_ns()["trigger_actual"] = self._trigger_to_dict(self._trigger)
428441
self._read_telemetry(node_map)
429442
self._persist_device_metadata(selected_info, selected_serial)
430443

@@ -449,6 +462,11 @@ def open(self) -> None:
449462
f"Failed to open GenTL camera.\n\nLoaded CTIs: {loaded}\nFailed CTIs: {failed}\nReason: {exc}"
450463
) from exc
451464

465+
@property
466+
def waits_for_hardware_trigger(self) -> bool:
467+
role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower()
468+
return role in {"external", "follower"}
469+
452470
def read(self) -> tuple[np.ndarray, float]:
453471
if self._acquirer is None:
454472
raise RuntimeError("GenTL image acquirer not initialised")
@@ -470,6 +488,9 @@ def read(self) -> tuple[np.ndarray, float]:
470488
except ValueError:
471489
frame = array.copy()
472490
except HarvesterTimeoutError as exc:
491+
role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower()
492+
if role in {"external", "follower"}:
493+
raise TimeoutError(str(exc) + " (GenTL timeout; waiting for hardware trigger?)") from exc
473494
raise TimeoutError(str(exc) + " (GenTL timeout)") from exc
474495

475496
frame = self._convert_frame(frame)
@@ -502,6 +523,12 @@ def close(self) -> None:
502523
except Exception:
503524
pass
504525

526+
try:
527+
node_map = self._acquirer.remote_device.node_map
528+
self._call_with_optional_lock(self._restore_trigger_idle, node_map)
529+
except Exception:
530+
pass
531+
505532
try:
506533
destroy = getattr(self._acquirer, "destroy", None)
507534
if destroy is not None:
@@ -949,6 +976,62 @@ def _create_acquirer(self, serial: str | None, index: int):
949976
# ------------------------------------------------------------------
950977
# Camera configuration helpers
951978
# ------------------------------------------------------------------
979+
@staticmethod
980+
def _node(node_map, name: str):
981+
try:
982+
return getattr(node_map, name)
983+
except Exception:
984+
return None
985+
986+
@staticmethod
987+
def _node_symbolics(node) -> list[str]:
988+
try:
989+
return list(getattr(node, "symbolics", []) or [])
990+
except Exception:
991+
return []
992+
993+
def _set_enum_node(self, node_map, name: str, value: str, *, strict: bool = False) -> bool:
994+
node = self._node(node_map, name)
995+
if node is None:
996+
if strict:
997+
raise RuntimeError(f"GenICam node '{name}' is not available")
998+
LOG.debug("GenICam node '%s' is not available; skipping", name)
999+
return False
1000+
1001+
symbolics = self._node_symbolics(node)
1002+
if symbolics and value not in symbolics:
1003+
if strict:
1004+
raise RuntimeError(f"GenICam node '{name}' does not support '{value}'. Available: {symbolics}")
1005+
LOG.warning("GenICam node '%s' does not support '%s'. Available: %s", name, value, symbolics)
1006+
return False
1007+
1008+
try:
1009+
node.value = value
1010+
return True
1011+
except Exception as exc:
1012+
if strict:
1013+
raise RuntimeError(f"Failed to set GenICam node '{name}' to '{value}': {exc}") from exc
1014+
LOG.warning("Failed to set GenICam node '%s' to '%s': %s", name, value, exc)
1015+
return False
1016+
1017+
@staticmethod
1018+
def _trigger_attr(trigger, name: str, default=None):
1019+
if isinstance(trigger, dict):
1020+
return trigger.get(name, default)
1021+
return getattr(trigger, name, default)
1022+
1023+
@staticmethod
1024+
def _trigger_to_dict(trigger) -> dict[str, Any]:
1025+
if trigger is None:
1026+
return {}
1027+
if isinstance(trigger, dict):
1028+
return dict(trigger)
1029+
if hasattr(trigger, "model_dump"):
1030+
try:
1031+
return trigger.model_dump(exclude_none=True)
1032+
except Exception:
1033+
pass
1034+
return {}
9521035

9531036
def _configure_pixel_format(self, node_map) -> None:
9541037
try:
@@ -1004,12 +1087,95 @@ def _configure_pixel_format(self, node_map) -> None:
10041087
LOG.warning("Failed to configure pixel format '%s': %s", self._pixel_format, e)
10051088

10061089
def _configure_trigger(self, node_map) -> None:
1090+
cfg = self._trigger
1091+
role = str(self._trigger_attr(cfg, "role", "off") or "off").strip().lower()
1092+
strict = bool(self._trigger_attr(cfg, "strict", False))
1093+
1094+
if role in {"off", "disabled"}:
1095+
self._configure_trigger_off(node_map, strict=strict)
1096+
return
1097+
1098+
if role in {"external", "follower"}:
1099+
self._configure_trigger_input(node_map, cfg, strict=strict)
1100+
return
1101+
1102+
if role == "master":
1103+
self._configure_trigger_master(node_map, cfg, strict=strict)
1104+
return
1105+
1106+
if strict:
1107+
raise RuntimeError(f"Unsupported GenTL trigger role: {role!r}")
1108+
1109+
LOG.warning("Unsupported GenTL trigger role '%s'; disabling trigger.", role)
1110+
self._configure_trigger_off(node_map, strict=False)
1111+
1112+
def _configure_trigger_off(self, node_map, *, strict: bool = False) -> None:
1113+
self._set_enum_node(node_map, "TriggerMode", "Off", strict=strict)
1114+
1115+
def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> None:
1116+
selector = str(self._trigger_attr(cfg, "selector", "FrameStart") or "FrameStart")
1117+
source = str(self._trigger_attr(cfg, "source", "Line0") or "Line0")
1118+
activation = str(self._trigger_attr(cfg, "activation", "RisingEdge") or "RisingEdge")
1119+
1120+
# Disable trigger while changing trigger-related nodes.
1121+
self._set_enum_node(node_map, "TriggerMode", "Off", strict=False)
1122+
1123+
self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict)
1124+
self._set_enum_node(node_map, "TriggerSource", source, strict=strict)
1125+
self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict)
1126+
1127+
self._set_enum_node(node_map, "AcquisitionMode", "Continuous", strict=False)
1128+
1129+
if not self._set_enum_node(node_map, "TriggerMode", "On", strict=strict):
1130+
if strict:
1131+
raise RuntimeError("Could not enable GenTL TriggerMode=On")
1132+
1133+
LOG.info(
1134+
"GenTL trigger input configured: role=%s selector=%s source=%s activation=%s",
1135+
self._trigger_attr(cfg, "role", "external"),
1136+
selector,
1137+
source,
1138+
activation,
1139+
)
1140+
1141+
def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> None:
1142+
output_line = str(self._trigger_attr(cfg, "output_line", "Line2") or "Line2")
1143+
output_source = str(self._trigger_attr(cfg, "output_source", "ExposureActive") or "ExposureActive")
1144+
1145+
# Master camera runs freerun and exposes an output signal.
1146+
self._configure_trigger_off(node_map, strict=False)
1147+
1148+
self._set_enum_node(node_map, "LineSelector", output_line, strict=strict)
1149+
self._set_enum_node(node_map, "LineMode", "Output", strict=strict)
1150+
self._set_enum_node(node_map, "LineSource", output_source, strict=strict)
1151+
1152+
LOG.info(
1153+
"GenTL trigger master configured: output_line=%s output_source=%s",
1154+
output_line,
1155+
output_source,
1156+
)
1157+
1158+
def _restore_trigger_idle(self, node_map) -> None:
1159+
"""Best-effort restore to a safe non-triggering state after acquisition stops.
1160+
1161+
Important:
1162+
- This should be called after acquirer.stop(), not while acquisition is active.
1163+
- It is intentionally non-strict because shutdown should not fail if a node
1164+
is missing or read-only.
1165+
"""
1166+
role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower()
1167+
10071168
try:
1008-
trigger_mode = getattr(node_map, "TriggerMode", None)
1009-
if trigger_mode is not None and "Off" in getattr(trigger_mode, "symbolics", []):
1010-
trigger_mode.value = "Off"
1011-
except Exception as e:
1012-
LOG.warning("Failed to disable trigger mode: %s", e)
1169+
if role in {"external", "follower"}:
1170+
self._set_enum_node(node_map, "TriggerMode", "Off", strict=False)
1171+
1172+
elif role == "master":
1173+
# Stop driving output if the camera exposes these nodes.
1174+
self._set_enum_node(node_map, "LineSource", "Off", strict=False)
1175+
self._set_enum_node(node_map, "LineMode", "Input", strict=False)
1176+
1177+
except Exception:
1178+
LOG.debug("Best-effort GenTL trigger restore failed", exc_info=True)
10131179

10141180
def _configure_resolution(self, node_map) -> None:
10151181
if self._requested_resolution is None:

dlclivegui/cameras/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ class SupportLevel(str, Enum):
7070
"set_gain": SupportLevel.UNSUPPORTED,
7171
"device_discovery": SupportLevel.UNSUPPORTED,
7272
"stable_identity": SupportLevel.UNSUPPORTED,
73+
"hardware_trigger": SupportLevel.UNSUPPORTED,
7374
}
7475

7576

dlclivegui/config.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
TileLayout = Literal["auto", "2x2", "1x4", "4x1"]
1313
Precision = Literal["FP32", "FP16"]
1414
ModelType = Literal["pytorch", "tensorflow"]
15+
TriggerRole = Literal["off", "external", "master", "follower"]
16+
TriggerActivation = Literal["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"]
1517

1618

1719
class CameraSettings(BaseModel):
@@ -168,6 +170,100 @@ def check_diff(old: CameraSettings, new: CameraSettings) -> dict:
168170
pass
169171
return out
170172

173+
def backend_options(self, backend: str | None = None) -> dict[str, Any]:
174+
key = backend or self.backend
175+
props = self.properties if isinstance(self.properties, dict) else {}
176+
ns = props.get(str(key).lower(), {})
177+
return ns if isinstance(ns, dict) else {}
178+
179+
def get_trigger_settings(self, backend: str | None = None) -> CameraTriggerSettings:
180+
ns = self.backend_options(backend)
181+
return CameraTriggerSettings.from_any(ns.get("trigger"))
182+
183+
def set_trigger_settings(self, trigger: CameraTriggerSettings, backend: str | None = None) -> None:
184+
key = backend or self.backend
185+
if not isinstance(self.properties, dict):
186+
self.properties = {}
187+
ns = self.properties.setdefault(str(key).lower(), {})
188+
if not isinstance(ns, dict):
189+
ns = {}
190+
self.properties[str(key).lower()] = ns
191+
ns["trigger"] = trigger.to_properties()
192+
193+
194+
class CameraTriggerSettings(BaseModel):
195+
"""
196+
Generic hardware-trigger settings.
197+
198+
Backend-specific code may ignore fields that are unsupported by a given
199+
camera/SDK. For GenTL, these map to common GenICam nodes such as:
200+
TriggerMode, TriggerSelector, TriggerSource, TriggerActivation,
201+
LineSelector, LineMode, and LineSource.
202+
"""
203+
204+
role: TriggerRole = "off"
205+
206+
# Input trigger config: external/follower
207+
selector: str = "FrameStart"
208+
source: str = "Line0"
209+
activation: TriggerActivation | str = "RisingEdge"
210+
211+
# Output config: master
212+
output_line: str = "Line2"
213+
output_source: str = "ExposureActive"
214+
215+
# Runtime behavior
216+
timeout: float | None = None
217+
strict: bool = False
218+
219+
@field_validator("role", mode="before")
220+
@classmethod
221+
def _coerce_role(cls, v):
222+
if v is None:
223+
return "off"
224+
225+
s = str(v).strip().lower()
226+
aliases = {
227+
"": "off",
228+
"none": "off",
229+
"false": "off",
230+
"disabled": "off",
231+
"disable": "off",
232+
"off": "off",
233+
"true": "external",
234+
"on": "external",
235+
"trigger": "external",
236+
"triggered": "external",
237+
"external": "external",
238+
"follower": "follower",
239+
"slave": "follower",
240+
"master": "master",
241+
"main": "master",
242+
}
243+
return aliases.get(s, s)
244+
245+
@field_validator("timeout", mode="before")
246+
@classmethod
247+
def _coerce_timeout(cls, v):
248+
if v in (None, ""):
249+
return None
250+
try:
251+
fv = float(v)
252+
except Exception:
253+
return None
254+
return fv if fv > 0 else None
255+
256+
@classmethod
257+
def from_any(cls, value) -> CameraTriggerSettings:
258+
if isinstance(value, cls):
259+
return value
260+
if isinstance(value, dict):
261+
return cls(**value)
262+
return cls()
263+
264+
def to_properties(self) -> dict[str, Any]:
265+
return self.model_dump(exclude_none=True)
266+
171267

172268
class MultiCameraSettings(BaseModel):
173269
cameras: list[CameraSettings] = Field(default_factory=list)

0 commit comments

Comments
 (0)