Skip to content

Commit 51ab19e

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

2 files changed

Lines changed: 104 additions & 7 deletions

File tree

dlclivegui/gui/main_window.py

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
ApplicationSettings,
5757
BoundingBoxSettings,
5858
CameraSettings,
59+
CameraTriggerSettings,
5960
DLCProcessorSettings,
6061
MultiCameraSettings,
6162
RecordingSettings,
@@ -854,15 +855,36 @@ def _apply_config(self, config: ApplicationSettings) -> None:
854855
# Update recording path preview
855856
self._update_recording_path_preview()
856857

857-
def _current_config(self) -> ApplicationSettings:
858+
def _with_camera_defaults_for_save(self, settings: MultiCameraSettings) -> MultiCameraSettings:
859+
out = settings.model_copy(deep=True)
860+
861+
for cam in out.cameras:
862+
backend = (cam.backend or "").lower()
863+
if backend != "gentl":
864+
continue
865+
866+
if not isinstance(cam.properties, dict):
867+
cam.properties = {}
868+
869+
ns = cam.properties.setdefault("gentl", {})
870+
if not isinstance(ns, dict):
871+
ns = {}
872+
cam.properties["gentl"] = ns
873+
874+
ns.setdefault("trigger", CameraTriggerSettings().model_dump(exclude_none=True))
875+
876+
return out
877+
878+
def _current_config(self, *, allow_empty_model_path=False) -> ApplicationSettings:
858879
# Get the first camera from multi-camera config for backward compatibility
880+
multi_camera = self._with_camera_defaults_for_save(self._config.multi_camera)
859881
active_cameras = self._config.multi_camera.get_active_cameras()
860882
camera = active_cameras[0] if active_cameras else CameraSettings()
861883

862884
return ApplicationSettings(
863885
camera=camera,
864-
multi_camera=self._config.multi_camera,
865-
dlc=self._dlc_settings_from_ui(),
886+
multi_camera=multi_camera,
887+
dlc=self._dlc_settings_from_ui(allow_empty_model_path=allow_empty_model_path),
866888
recording=self._recording_settings_from_ui(),
867889
bbox=self._bbox_settings_from_ui(),
868890
visualization=self._visualization_settings_from_ui(),
@@ -874,13 +896,24 @@ def _parse_json(self, value: str) -> dict:
874896
return {}
875897
return json.loads(text)
876898

877-
def _dlc_settings_from_ui(self) -> DLCProcessorSettings:
899+
def _dlc_settings_from_ui(self, *, allow_empty_model_path=False) -> DLCProcessorSettings:
878900
model_path = self.model_path_edit.text().strip()
879901
if Path(model_path).exists() and Path(model_path).suffix == ".pb":
880902
# IMPORTANT NOTE: DLClive expects a directory for TensorFlow models,
881903
# so if user selects a .pb file, we should pass the parent directory to DLCLive
882904
model_path = str(Path(model_path).parent)
883-
if model_path == "":
905+
if not model_path:
906+
if allow_empty_model_path:
907+
return DLCProcessorSettings(
908+
model_path="",
909+
model_directory=self._config.dlc.model_directory, # Preserve from config
910+
device=self._config.dlc.device, # Preserve from config
911+
dynamic=self._config.dlc.dynamic, # Preserve from config
912+
resize=self._config.dlc.resize, # Preserve from config
913+
precision=self._config.dlc.precision, # Preserve from config
914+
model_type=None,
915+
# additional_options=self._parse_json(self.additional_options_edit.toPlainText()),
916+
)
884917
raise ValueError("Model path cannot be empty. Please enter a valid path to a DLCLive model file.")
885918
try:
886919
model_bknd = DLCLiveProcessor.get_model_backend(model_path)
@@ -965,7 +998,7 @@ def _action_save_config_as(self) -> None:
965998

966999
def _save_config_to_path(self, path: Path) -> None:
9671000
try:
968-
config = self._current_config()
1001+
config = self._current_config(allow_empty_model_path=True)
9691002
config.save(path)
9701003
self._settings_store.set_last_config_path(str(path))
9711004
self._settings_store.save_full_config_snapshot(config)
@@ -1611,7 +1644,7 @@ def _stop_preview(self) -> None:
16111644
def _configure_dlc(self) -> bool:
16121645
try:
16131646
settings = self._dlc_settings_from_ui()
1614-
except (ValueError, json.JSONDecodeError) as exc:
1647+
except (ValueError, RuntimeError, json.JSONDecodeError) as exc:
16151648
self._show_error(f"Invalid DLCLive settings: {exc}")
16161649
return False
16171650
if not settings.model_path:

dlclivegui/services/multi_camera_controller.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,29 @@ def run(self) -> None:
9999
consecutive_errors = 0
100100
self.frame_captured.emit(self._camera_id, frame, timestamp)
101101

102+
except TimeoutError as exc:
103+
if self._stop_event.is_set():
104+
break
105+
106+
# In hardware-trigger mode, a timeout usually means:
107+
# "no trigger pulse arrived during this poll interval".
108+
# This is expected and should not count as a camera failure.
109+
if bool(getattr(self._backend, "waits_for_hardware_trigger", False)):
110+
LOGGER.debug(
111+
"[Worker %s] waiting for hardware trigger: %s",
112+
self._camera_id,
113+
exc,
114+
)
115+
consecutive_errors = 0
116+
continue
117+
118+
consecutive_errors += 1
119+
if consecutive_errors >= self._max_consecutive_errors:
120+
self.error_occurred.emit(self._camera_id, f"Camera read timeout: {exc}")
121+
break
122+
time.sleep(self._retry_delay)
123+
continue
124+
102125
except Exception as exc:
103126
consecutive_errors += 1
104127
if self._stop_event.is_set():
@@ -138,6 +161,46 @@ def get_camera_id(settings: CameraSettings) -> str:
138161
return f"{backend}:index:{int(settings.index)}"
139162

140163

164+
def _trigger_role_from_settings(settings: CameraSettings) -> str:
165+
backend = (settings.backend or "").lower()
166+
props = settings.properties if isinstance(settings.properties, dict) else {}
167+
ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {}
168+
169+
trigger = ns.get("trigger", {})
170+
if not isinstance(trigger, dict):
171+
return "off"
172+
173+
role = str(trigger.get("role", "off") or "off").strip().lower()
174+
175+
# Match CameraTriggerSettings aliases enough for controller ordering.
176+
if role in {"true", "on", "trigger", "triggered"}:
177+
return "external"
178+
if role in {"slave"}:
179+
return "follower"
180+
if role in {"main"}:
181+
return "master"
182+
if role in {"false", "none", "disabled", "disable", ""}:
183+
return "off"
184+
185+
return role
186+
187+
188+
def _camera_start_priority(settings: CameraSettings) -> int:
189+
"""Start trigger-waiting cameras before trigger-generating cameras.
190+
191+
Priority:
192+
0: external/follower cameras, which should be armed first
193+
1: normal/free-run cameras
194+
2: master cameras, which may generate trigger pulses
195+
"""
196+
role = _trigger_role_from_settings(settings)
197+
if role in {"external", "follower"}:
198+
return 0
199+
if role == "master":
200+
return 2
201+
return 1
202+
203+
141204
class MultiCameraController(QObject):
142205
"""Controller for managing multiple cameras simultaneously."""
143206

@@ -180,6 +243,7 @@ def start(self, camera_settings: list[CameraSettings]) -> None:
180243
return
181244

182245
active_settings = [s for s in camera_settings if s.enabled][: self.MAX_CAMERAS]
246+
active_settings = sorted(active_settings, key=_camera_start_priority)
183247
if not active_settings:
184248
LOGGER.warning("No active cameras to start")
185249
return

0 commit comments

Comments
 (0)