Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
395352d
Add GenTL hardware trigger support
C-Achard May 28, 2026
5ea687b
Handle camera trigger defaults & trigger-aware startup
C-Achard May 28, 2026
9132ddf
Improve signal handling and graceful shutdown
C-Achard May 28, 2026
c9d2811
Add GenTL trigger tests and fake node map
C-Achard May 28, 2026
6d8824e
Respect user camera order for display/tiling
C-Achard May 28, 2026
df1a201
tests: preserve display order and add MultiCamera tests
C-Achard May 28, 2026
424fe16
Mock _maybe_allow_keyboard_interrupt in GUI tests
C-Achard May 28, 2026
c6ea7ad
Support strict GenTL trigger and defaults
C-Achard May 28, 2026
38a4166
Apply gentl trigger defaults when saving
C-Achard May 28, 2026
9fd218c
Don't sort available camera IDs
C-Achard May 28, 2026
e8ce43f
Preserve DLC config using model_copy
C-Achard May 28, 2026
d57f9c3
Warn when GenTL TriggerMode fails to enable
C-Achard May 28, 2026
5b02e2a
Improve GenTL trigger routing safety and tests
C-Achard May 28, 2026
a1e9da1
Cap hardware-trigger fetch timeout and update tests
C-Achard May 28, 2026
36ae680
Interruptible camera waits; simplify config save
C-Achard May 29, 2026
e5f77a1
Potential fix for pull request finding
C-Achard May 29, 2026
f4726b8
Fix broken suggestion
C-Achard May 29, 2026
b067c0e
Resolve 'auto' trigger source in GenTL backend
C-Achard May 29, 2026
ac865f4
Fix display tests
C-Achard May 29, 2026
88696c1
Update test_multicam_controller.py
C-Achard May 29, 2026
e597464
Fix duplication
C-Achard Jul 9, 2026
bc19066
Refine GenTL trigger timeout handling
C-Achard Jul 9, 2026
85b6de6
Use CameraTriggerSettings for trigger role
C-Achard Jul 9, 2026
f58279f
Fix incorrect test expectations
C-Achard Jul 9, 2026
ed74bd2
Update multi_camera_controller.py
C-Achard Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
330 changes: 323 additions & 7 deletions dlclivegui/cameras/backends/gentl_backend.py

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions dlclivegui/cameras/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ class SupportLevel(str, Enum):
"set_gain": SupportLevel.UNSUPPORTED,
"device_discovery": SupportLevel.UNSUPPORTED,
"stable_identity": SupportLevel.UNSUPPORTED,
"hardware_trigger": SupportLevel.UNSUPPORTED,
}


Expand Down
153 changes: 148 additions & 5 deletions dlclivegui/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
TileLayout = Literal["auto", "2x2", "1x4", "4x1"]
Precision = Literal["FP32", "FP16"]
ModelType = Literal["pytorch", "tensorflow"]
TriggerRole = Literal["off", "external", "master", "follower"]
TriggerActivation = Literal["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"]


class CameraSettings(BaseModel):
Expand Down Expand Up @@ -168,6 +170,137 @@ def check_diff(old: CameraSettings, new: CameraSettings) -> dict:
pass
return out

def backend_options(self, backend: str | None = None) -> dict[str, Any]:
key = backend or self.backend
props = self.properties if isinstance(self.properties, dict) else {}
ns = props.get(str(key).lower(), {})
return ns if isinstance(ns, dict) else {}

def get_trigger_settings(self, backend: str | None = None) -> CameraTriggerSettings:
ns = self.backend_options(backend)
return CameraTriggerSettings.from_any(ns.get("trigger"))

def set_trigger_settings(self, trigger: CameraTriggerSettings, backend: str | None = None) -> None:
key = backend or self.backend
if not isinstance(self.properties, dict):
self.properties = {}
ns = self.properties.setdefault(str(key).lower(), {})
if not isinstance(ns, dict):
ns = {}
self.properties[str(key).lower()] = ns
ns["trigger"] = trigger.to_properties()

def with_save_defaults(self) -> CameraSettings:
out = self.model_copy(deep=True)

backend = (out.backend or "").lower()
if backend != "gentl":
return out

if not isinstance(out.properties, dict):
out.properties = {}

ns = out.properties.setdefault("gentl", {})
if not isinstance(ns, dict):
ns = {}
out.properties["gentl"] = ns

ns.setdefault("trigger", CameraTriggerSettings().to_properties())

return out


class CameraTriggerSettings(BaseModel):
"""
Generic hardware-trigger settings.

Backend-specific code may ignore fields that are unsupported by a given
camera/SDK. For GenTL, these map to common GenICam nodes such as:
TriggerMode, TriggerSelector, TriggerSource, TriggerActivation,
LineSelector, LineMode, and LineSource.
"""

role: TriggerRole = "off"

# Input trigger config: external/follower
selector: str = "FrameStart"
source: str = "auto"
activation: TriggerActivation | str = "RisingEdge"

# Output config: master
output_line: str = "Line2"
output_source: str = "ExposureActive"

# Runtime behavior
timeout: float | None = None
strict: bool = False

@field_validator("role", mode="before")
@classmethod
def _coerce_role(cls, v):
if v is None:
return "off"

s = str(v).strip().lower()
aliases = {
"": "off",
"none": "off",
"false": "off",
"disabled": "off",
"disable": "off",
"off": "off",
"true": "external",
"on": "external",
"trigger": "external",
"triggered": "external",
"external": "external",
"follower": "follower",
Comment on lines +256 to +257

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both seem to use the same GenTL path and start priority at the moment (only the role string differs) is that correct? Just double checking if this is intentional (I can imagine that it will be useful later)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, in e.g. Basler the distinction is more useful (though I skipped the software/external wiring in the Basler PR for now)

"slave": "follower",
"master": "master",
"main": "master",
}
return aliases.get(s, s)

@field_validator("timeout", mode="before")
@classmethod
def _coerce_timeout(cls, v):
if v in (None, ""):
return None
try:
fv = float(v)
except Exception:
return None
return fv if fv > 0 else None

@field_validator("source", mode="before")
@classmethod
def _coerce_source(cls, v):
if v is None:
return "auto"

s = str(v).strip()
if not s:
return "auto"

aliases = {
"default": "auto",
"automatic": "auto",
"device": "auto",
"camera": "auto",
}
return aliases.get(s.lower(), s)

@classmethod
def from_any(cls, value) -> CameraTriggerSettings:
if isinstance(value, cls):
return value
if isinstance(value, dict):
return cls(**value)
return cls()

def to_properties(self) -> dict[str, Any]:
return self.model_dump(exclude_none=True)


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

def to_dict(self) -> dict[str, Any]:
out = self.with_save_defaults()
return {
"cameras": [cam.model_dump() for cam in self.cameras],
"max_cameras": self.max_cameras,
"tile_layout": self.tile_layout,
"cameras": [cam.model_dump() for cam in out.cameras],
"max_cameras": out.max_cameras,
"tile_layout": out.tile_layout,
}

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


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

def to_dict(self) -> dict[str, Any]:
camera = self.camera.with_save_defaults()
multi_camera = self.multi_camera.with_save_defaults()

return {
"version": self.version,
"camera": self.camera.model_dump(),
"multi_camera": self.multi_camera.to_dict(),
"camera": camera.model_dump(),
"multi_camera": multi_camera.to_dict(),
"dlc": self.dlc.model_dump(),
"recording": self.recording.model_dump(),
"bbox": self.bbox.model_dump(),
Expand Down
67 changes: 45 additions & 22 deletions dlclivegui/gui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -857,15 +857,23 @@ def _apply_config(self, config: ApplicationSettings) -> None:
# Update recording path preview
self._update_recording_path_preview()

def _current_config(self) -> ApplicationSettings:
# Get the first camera from multi-camera config for backward compatibility
active_cameras = self._config.multi_camera.get_active_cameras()
camera = active_cameras[0] if active_cameras else CameraSettings()
def _current_config(self, *, allow_empty_model_path=False) -> ApplicationSettings:
multi_camera = self._config.multi_camera
active_cameras = multi_camera.get_active_cameras()
camera = (
active_cameras[0].model_copy(deep=True)
if active_cameras
else (
multi_camera.cameras[0].model_copy(deep=True)
if multi_camera.cameras
else self._config.camera.model_copy(deep=True)
)
)

return ApplicationSettings(
camera=camera,
multi_camera=self._config.multi_camera,
dlc=self._dlc_settings_from_ui(),
multi_camera=multi_camera,
dlc=self._dlc_settings_from_ui(allow_empty_model_path=allow_empty_model_path),
recording=self._recording_settings_from_ui(),
bbox=self._bbox_settings_from_ui(),
visualization=self._visualization_settings_from_ui(),
Expand All @@ -877,14 +885,29 @@ def _parse_json(self, value: str) -> dict:
return {}
return json.loads(text)

def _dlc_settings_from_ui(self) -> DLCProcessorSettings:
def _dlc_settings_from_ui(self, *, allow_empty_model_path=False) -> DLCProcessorSettings:
model_path = self.model_path_edit.text().strip()
if Path(model_path).exists() and Path(model_path).suffix == ".pb":
# IMPORTANT NOTE: DLClive expects a directory for TensorFlow models,
# so if user selects a .pb file, we should pass the parent directory to DLCLive
model_path = str(Path(model_path).parent)
if model_path == "":

existing_dlc = ( # explicitly init from default if unset
self._config.dlc.model_copy(deep=True)
if getattr(self._config, "dlc", None) is not None
else DEFAULT_CONFIG.dlc.model_copy(deep=True)
)
if not model_path:
if allow_empty_model_path:
# Preserve all existing DLC settings and only clear the model path.
return existing_dlc.model_copy(
update={
"model_path": "",
}
)

raise ValueError("Model path cannot be empty. Please enter a valid path to a DLCLive model file.")

try:
model_bknd = DLCLiveProcessor.get_model_backend(model_path)
except Exception as e:
Expand All @@ -893,15 +916,13 @@ def _dlc_settings_from_ui(self) -> DLCProcessorSettings:
"Please ensure the model file is valid and has an appropriate extension "
"(.pt, .pth for PyTorch or model directory for TensorFlow)."
) from e
return DLCProcessorSettings(
model_path=model_path,
model_directory=self._config.dlc.model_directory, # Preserve from config
device=self._config.dlc.device, # Preserve from config
dynamic=self._config.dlc.dynamic, # Preserve from config
resize=self._config.dlc.resize, # Preserve from config
precision=self._config.dlc.precision, # Preserve from config
model_type=model_bknd,
# additional_options=self._parse_json(self.additional_options_edit.toPlainText()),

# Preserve all unchanged DLC settings and only update values derived from the UI.
return existing_dlc.model_copy(
update={
"model_path": model_path,
"model_type": model_bknd,
}
)

def _recording_settings_from_ui(self) -> RecordingSettings:
Expand Down Expand Up @@ -968,7 +989,7 @@ def _action_save_config_as(self) -> None:

def _save_config_to_path(self, path: Path) -> None:
try:
config = self._current_config()
config = self._current_config(allow_empty_model_path=True)
config.save(path)
self._settings_store.set_last_config_path(str(path))
self._settings_store.save_full_config_snapshot(config)
Expand Down Expand Up @@ -1268,8 +1289,10 @@ def _refresh_dlc_camera_list_running(self) -> None:
"""Populate the inference camera dropdown from currently running cameras."""
self.dlc_camera_combo.blockSignals(True)
self.dlc_camera_combo.clear()
for cam_id in sorted(self._running_cams_ids):
self.dlc_camera_combo.addItem(self._label_for_cam_id(cam_id), cam_id)
for cam in self._config.multi_camera.get_active_cameras():
cam_id = get_camera_id(cam)
if cam_id in self._running_cams_ids:
self.dlc_camera_combo.addItem(self._label_for_cam_id(cam_id), cam_id)

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

# Determine DLC camera (first active camera)
selected_id = self._inference_camera_id
available_ids = sorted(frame_data.frames.keys())
available_ids = list(frame_data.frames.keys())
if selected_id in frame_data.frames:
dlc_cam_id = selected_id
else:
Expand Down Expand Up @@ -1614,7 +1637,7 @@ def _stop_preview(self) -> None:
def _configure_dlc(self) -> bool:
try:
settings = self._dlc_settings_from_ui()
except (ValueError, json.JSONDecodeError) as exc:
except (ValueError, RuntimeError, json.JSONDecodeError) as exc:
self._show_error(f"Invalid DLCLive settings: {exc}")
return False
if not settings.model_path:
Expand Down
Loading