Skip to content

Commit 7f8b7e6

Browse files
authored
Merge pull request #78 from DeepLabCut/cy/gentl-trigger-config
Add config fields for Trigger mode for GenTL
2 parents 36a3d56 + ed74bd2 commit 7f8b7e6

13 files changed

Lines changed: 1580 additions & 72 deletions

File tree

‎dlclivegui/cameras/backends/gentl_backend.py‎

Lines changed: 323 additions & 7 deletions
Large diffs are not rendered by default.

‎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: 148 additions & 5 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,137 @@ 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+
def with_save_defaults(self) -> CameraSettings:
194+
out = self.model_copy(deep=True)
195+
196+
backend = (out.backend or "").lower()
197+
if backend != "gentl":
198+
return out
199+
200+
if not isinstance(out.properties, dict):
201+
out.properties = {}
202+
203+
ns = out.properties.setdefault("gentl", {})
204+
if not isinstance(ns, dict):
205+
ns = {}
206+
out.properties["gentl"] = ns
207+
208+
ns.setdefault("trigger", CameraTriggerSettings().to_properties())
209+
210+
return out
211+
212+
213+
class CameraTriggerSettings(BaseModel):
214+
"""
215+
Generic hardware-trigger settings.
216+
217+
Backend-specific code may ignore fields that are unsupported by a given
218+
camera/SDK. For GenTL, these map to common GenICam nodes such as:
219+
TriggerMode, TriggerSelector, TriggerSource, TriggerActivation,
220+
LineSelector, LineMode, and LineSource.
221+
"""
222+
223+
role: TriggerRole = "off"
224+
225+
# Input trigger config: external/follower
226+
selector: str = "FrameStart"
227+
source: str = "auto"
228+
activation: TriggerActivation | str = "RisingEdge"
229+
230+
# Output config: master
231+
output_line: str = "Line2"
232+
output_source: str = "ExposureActive"
233+
234+
# Runtime behavior
235+
timeout: float | None = None
236+
strict: bool = False
237+
238+
@field_validator("role", mode="before")
239+
@classmethod
240+
def _coerce_role(cls, v):
241+
if v is None:
242+
return "off"
243+
244+
s = str(v).strip().lower()
245+
aliases = {
246+
"": "off",
247+
"none": "off",
248+
"false": "off",
249+
"disabled": "off",
250+
"disable": "off",
251+
"off": "off",
252+
"true": "external",
253+
"on": "external",
254+
"trigger": "external",
255+
"triggered": "external",
256+
"external": "external",
257+
"follower": "follower",
258+
"slave": "follower",
259+
"master": "master",
260+
"main": "master",
261+
}
262+
return aliases.get(s, s)
263+
264+
@field_validator("timeout", mode="before")
265+
@classmethod
266+
def _coerce_timeout(cls, v):
267+
if v in (None, ""):
268+
return None
269+
try:
270+
fv = float(v)
271+
except Exception:
272+
return None
273+
return fv if fv > 0 else None
274+
275+
@field_validator("source", mode="before")
276+
@classmethod
277+
def _coerce_source(cls, v):
278+
if v is None:
279+
return "auto"
280+
281+
s = str(v).strip()
282+
if not s:
283+
return "auto"
284+
285+
aliases = {
286+
"default": "auto",
287+
"automatic": "auto",
288+
"device": "auto",
289+
"camera": "auto",
290+
}
291+
return aliases.get(s.lower(), s)
292+
293+
@classmethod
294+
def from_any(cls, value) -> CameraTriggerSettings:
295+
if isinstance(value, cls):
296+
return value
297+
if isinstance(value, dict):
298+
return cls(**value)
299+
return cls()
300+
301+
def to_properties(self) -> dict[str, Any]:
302+
return self.model_dump(exclude_none=True)
303+
171304

172305
class MultiCameraSettings(BaseModel):
173306
cameras: list[CameraSettings] = Field(default_factory=list)
@@ -206,12 +339,19 @@ def from_dict(cls, data: dict[str, Any]) -> MultiCameraSettings:
206339
return cls(cameras=cameras, max_cameras=max_cameras, tile_layout=tile_layout)
207340

208341
def to_dict(self) -> dict[str, Any]:
342+
out = self.with_save_defaults()
209343
return {
210-
"cameras": [cam.model_dump() for cam in self.cameras],
211-
"max_cameras": self.max_cameras,
212-
"tile_layout": self.tile_layout,
344+
"cameras": [cam.model_dump() for cam in out.cameras],
345+
"max_cameras": out.max_cameras,
346+
"tile_layout": out.tile_layout,
213347
}
214348

349+
def with_save_defaults(self) -> MultiCameraSettings:
350+
"""Return a copy with save defaults applied to all cameras."""
351+
out = self.model_copy(deep=True)
352+
out.cameras = [cam.with_save_defaults() for cam in out.cameras]
353+
return out
354+
215355

216356
class DynamicCropModel(BaseModel):
217357
enabled: bool = False
@@ -377,10 +517,13 @@ def from_dict(cls, data: dict[str, Any]) -> ApplicationSettings:
377517
)
378518

379519
def to_dict(self) -> dict[str, Any]:
520+
camera = self.camera.with_save_defaults()
521+
multi_camera = self.multi_camera.with_save_defaults()
522+
380523
return {
381524
"version": self.version,
382-
"camera": self.camera.model_dump(),
383-
"multi_camera": self.multi_camera.to_dict(),
525+
"camera": camera.model_dump(),
526+
"multi_camera": multi_camera.to_dict(),
384527
"dlc": self.dlc.model_dump(),
385528
"recording": self.recording.model_dump(),
386529
"bbox": self.bbox.model_dump(),

‎dlclivegui/gui/main_window.py‎

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -857,15 +857,23 @@ def _apply_config(self, config: ApplicationSettings) -> None:
857857
# Update recording path preview
858858
self._update_recording_path_preview()
859859

860-
def _current_config(self) -> ApplicationSettings:
861-
# Get the first camera from multi-camera config for backward compatibility
862-
active_cameras = self._config.multi_camera.get_active_cameras()
863-
camera = active_cameras[0] if active_cameras else CameraSettings()
860+
def _current_config(self, *, allow_empty_model_path=False) -> ApplicationSettings:
861+
multi_camera = self._config.multi_camera
862+
active_cameras = multi_camera.get_active_cameras()
863+
camera = (
864+
active_cameras[0].model_copy(deep=True)
865+
if active_cameras
866+
else (
867+
multi_camera.cameras[0].model_copy(deep=True)
868+
if multi_camera.cameras
869+
else self._config.camera.model_copy(deep=True)
870+
)
871+
)
864872

865873
return ApplicationSettings(
866874
camera=camera,
867-
multi_camera=self._config.multi_camera,
868-
dlc=self._dlc_settings_from_ui(),
875+
multi_camera=multi_camera,
876+
dlc=self._dlc_settings_from_ui(allow_empty_model_path=allow_empty_model_path),
869877
recording=self._recording_settings_from_ui(),
870878
bbox=self._bbox_settings_from_ui(),
871879
visualization=self._visualization_settings_from_ui(),
@@ -877,14 +885,29 @@ def _parse_json(self, value: str) -> dict:
877885
return {}
878886
return json.loads(text)
879887

880-
def _dlc_settings_from_ui(self) -> DLCProcessorSettings:
888+
def _dlc_settings_from_ui(self, *, allow_empty_model_path=False) -> DLCProcessorSettings:
881889
model_path = self.model_path_edit.text().strip()
882890
if Path(model_path).exists() and Path(model_path).suffix == ".pb":
883891
# IMPORTANT NOTE: DLClive expects a directory for TensorFlow models,
884892
# so if user selects a .pb file, we should pass the parent directory to DLCLive
885893
model_path = str(Path(model_path).parent)
886-
if model_path == "":
894+
895+
existing_dlc = ( # explicitly init from default if unset
896+
self._config.dlc.model_copy(deep=True)
897+
if getattr(self._config, "dlc", None) is not None
898+
else DEFAULT_CONFIG.dlc.model_copy(deep=True)
899+
)
900+
if not model_path:
901+
if allow_empty_model_path:
902+
# Preserve all existing DLC settings and only clear the model path.
903+
return existing_dlc.model_copy(
904+
update={
905+
"model_path": "",
906+
}
907+
)
908+
887909
raise ValueError("Model path cannot be empty. Please enter a valid path to a DLCLive model file.")
910+
888911
try:
889912
model_bknd = DLCLiveProcessor.get_model_backend(model_path)
890913
except Exception as e:
@@ -893,15 +916,13 @@ def _dlc_settings_from_ui(self) -> DLCProcessorSettings:
893916
"Please ensure the model file is valid and has an appropriate extension "
894917
"(.pt, .pth for PyTorch or model directory for TensorFlow)."
895918
) from e
896-
return DLCProcessorSettings(
897-
model_path=model_path,
898-
model_directory=self._config.dlc.model_directory, # Preserve from config
899-
device=self._config.dlc.device, # Preserve from config
900-
dynamic=self._config.dlc.dynamic, # Preserve from config
901-
resize=self._config.dlc.resize, # Preserve from config
902-
precision=self._config.dlc.precision, # Preserve from config
903-
model_type=model_bknd,
904-
# additional_options=self._parse_json(self.additional_options_edit.toPlainText()),
919+
920+
# Preserve all unchanged DLC settings and only update values derived from the UI.
921+
return existing_dlc.model_copy(
922+
update={
923+
"model_path": model_path,
924+
"model_type": model_bknd,
925+
}
905926
)
906927

907928
def _recording_settings_from_ui(self) -> RecordingSettings:
@@ -968,7 +989,7 @@ def _action_save_config_as(self) -> None:
968989

969990
def _save_config_to_path(self, path: Path) -> None:
970991
try:
971-
config = self._current_config()
992+
config = self._current_config(allow_empty_model_path=True)
972993
config.save(path)
973994
self._settings_store.set_last_config_path(str(path))
974995
self._settings_store.save_full_config_snapshot(config)
@@ -1268,8 +1289,10 @@ def _refresh_dlc_camera_list_running(self) -> None:
12681289
"""Populate the inference camera dropdown from currently running cameras."""
12691290
self.dlc_camera_combo.blockSignals(True)
12701291
self.dlc_camera_combo.clear()
1271-
for cam_id in sorted(self._running_cams_ids):
1272-
self.dlc_camera_combo.addItem(self._label_for_cam_id(cam_id), cam_id)
1292+
for cam in self._config.multi_camera.get_active_cameras():
1293+
cam_id = get_camera_id(cam)
1294+
if cam_id in self._running_cams_ids:
1295+
self.dlc_camera_combo.addItem(self._label_for_cam_id(cam_id), cam_id)
12731296

12741297
# Keep current selection if still present, else select first running
12751298
if self._inference_camera_id in self._running_cams_ids:
@@ -1371,7 +1394,7 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None:
13711394

13721395
# Determine DLC camera (first active camera)
13731396
selected_id = self._inference_camera_id
1374-
available_ids = sorted(frame_data.frames.keys())
1397+
available_ids = list(frame_data.frames.keys())
13751398
if selected_id in frame_data.frames:
13761399
dlc_cam_id = selected_id
13771400
else:
@@ -1614,7 +1637,7 @@ def _stop_preview(self) -> None:
16141637
def _configure_dlc(self) -> bool:
16151638
try:
16161639
settings = self._dlc_settings_from_ui()
1617-
except (ValueError, json.JSONDecodeError) as exc:
1640+
except (ValueError, RuntimeError, json.JSONDecodeError) as exc:
16181641
self._show_error(f"Invalid DLCLive settings: {exc}")
16191642
return False
16201643
if not settings.model_path:

0 commit comments

Comments
 (0)