Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 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
65712ca
Add trigger settings dialog and UI button
C-Achard May 28, 2026
d51c8f7
Add per-camera hardware trigger settings
C-Achard May 28, 2026
9739824
Update trigger_config_dialog.py
C-Achard May 28, 2026
358ebcf
Use _is_preview_live and clear trigger timeout
C-Achard May 29, 2026
a1a139b
Restart active previews; ignore bad trigger
C-Achard May 29, 2026
2043e57
Improve trigger dialog defaults and error handling
C-Achard May 29, 2026
18446c2
Add worker timing logs to camera worker
C-Achard May 29, 2026
b5c586f
Add per-camera timing to MultiCameraController
C-Achard May 29, 2026
60077f2
Add pretty/str/repr to CameraSettings
C-Achard May 29, 2026
e605c80
Reduce lock scope and refactor frame handling
C-Achard May 29, 2026
fa31121
Add GenTL strobe support and trigger debugging
C-Achard Jun 2, 2026
c0b6e58
Add _node_value helper for safe node reads
C-Achard Jun 19, 2026
715f270
Refactor GenTL trigger source handling and logging
C-Achard Jun 19, 2026
2925176
Update multi_camera_controller.py
C-Achard Jun 29, 2026
10957b0
pre-commit
C-Achard Jul 13, 2026
c5e044c
Add GenTL telemetry and frame-rate debugging
C-Achard May 29, 2026
a773c9a
Allow zero node values and reduce log level
C-Achard Jun 1, 2026
a33f847
Split multi-camera frame signals; throttle display
C-Achard May 29, 2026
e625cfd
Remove temp debug block
C-Achard May 29, 2026
2a4b4d5
Fix missing signal wiring
C-Achard May 29, 2026
c58586d
Revert debug logging to disabled
C-Achard Aug 10, 2026
a460f91
Update main_window.py
C-Achard Aug 10, 2026
cbcb38a
Fix DLC model_type regression from detected backend
C-Achard Aug 11, 2026
4832525
Add Basler trigger support and timeout handling
C-Achard Jun 19, 2026
d9d3d01
Remove PYLON_CAMEMU test comment
C-Achard Jun 19, 2026
e155969
Make trigger config dialog backend-aware
C-Achard Jun 19, 2026
ef06c35
Support basler backend in trigger defaults
C-Achard Jun 19, 2026
9ccb8d8
Throttle hardware-trigger wait logs
C-Achard Jun 19, 2026
2813cc5
Enhance FakePylon and add Basler backend tests
C-Achard Jun 19, 2026
acbf23f
Throttle trigger-wait logs, mark Basler test xfail
C-Achard Jun 19, 2026
11e6b0f
Update multi_camera_controller.py
C-Achard Jun 19, 2026
fdf3584
Basler backend: add timing and FPS config
C-Achard Jun 23, 2026
0b83a64
style: apply pre-commit
C-Achard Jul 14, 2026
ea39ecd
Update basler_backend.py
C-Achard Jul 16, 2026
48df4ef
Fix multi-camera shutdown synchronization
C-Achard Jul 16, 2026
0d58b7b
Handle only PyPylon timeout errors
C-Achard Jul 16, 2026
73e4bdd
Test Basler timeout mapping for trigger reads
C-Achard Jul 16, 2026
4d99c74
Harden multi-camera shutdown lifecycle
C-Achard Jul 16, 2026
72d12ad
Do not use software as auto mode for source
C-Achard Jul 16, 2026
9cf6ef1
Preserve runtime info when clearing workers
C-Achard Jul 16, 2026
490d9b5
Stabilize Basler timeout mapping test
C-Achard Jul 16, 2026
acf9a4e
Stop injecting GenTL trigger save defaults
C-Achard Aug 11, 2026
d04bca9
Stop auto-initializing camera trigger config
C-Achard Aug 11, 2026
2e79d7b
Revise trigger config behavior tests
C-Achard Aug 11, 2026
42bbb1f
Disable CAMEMU debug setting
C-Achard Aug 11, 2026
f909592
Fix model_type coercion in Engine parser
C-Achard Aug 12, 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
607 changes: 580 additions & 27 deletions dlclivegui/cameras/backends/basler_backend.py

Large diffs are not rendered by default.

729 changes: 709 additions & 20 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
198 changes: 193 additions & 5 deletions dlclivegui/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@
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"]
TriggerStrobePolarity = Literal["ActiveHigh", "ActiveLow"]
TriggerStrobeOperation = Literal["Exposure", "FixedDuration"]

# Global settings
## GUI
GUI_MAX_DISPLAY_FPS: float = 30.0


## Debug
### Timing logs
SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False
MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False
# MAIN_WINDOW_DO_LOG_TIMING: bool = False


class CameraSettings(BaseModel):
Expand All @@ -38,6 +53,27 @@ class CameraSettings(BaseModel):
enabled: bool = True
properties: dict[str, Any] = Field(default_factory=dict)

def pretty(self) -> str:
crop = (
"none"
if self.get_crop_region() is None
else f"({self.crop_x0}, {self.crop_y0}) -> ({self.crop_x1 or 'edge'}, {self.crop_y1 or 'edge'})"
)
return (
f"CameraSettings[\n"
f" name={self.name!r}, index={self.index}, backend={self.backend!r}, enabled={self.enabled}\n"
f" fps={self.fps}, size={self.width or 'auto'}x{self.height or 'auto'}, "
f"exposure={self.exposure or 'auto'}, gain={self.gain or 'auto'}\n"
f" rotation={self.rotation}, crop={crop}\n"
f"]"
)

def __str__(self) -> str:
return self.pretty()

def __repr__(self) -> str:
return self.pretty()

@field_validator("fps", mode="before")
@classmethod
def _coerce_fps(cls, v):
Expand Down Expand Up @@ -168,6 +204,148 @@ 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:
"""
Return a serializable deep copy.

Runtime defaults (incl. TriggerSettings) are applied
when settings are read and not inserted into the saved config.
"""
return self.model_copy(deep=True)


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

Backend-specific code may ignore fields that are unsupported by a given
camera/SDK.

For GenTL/TIS DMK 37BUX287:
- follower/external maps mainly to TriggerMode, TriggerSelector,
TriggerActivation. TriggerSource may be read-only and is best-effort.
- master output maps primarily to StrobeEnable, StrobePolarity,
StrobeOperation, StrobeDuration, and StrobeDelay.
"""

role: TriggerRole = "off"

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

# Generic/SFNC output config: master fallback for cameras exposing Line* nodes.
output_line: str = "Line2"
output_source: str = "ExposureActive"

# Strobe output config: master path for TIS/DMK 37U cameras.
strobe_polarity: TriggerStrobePolarity | str = "ActiveHigh"
strobe_operation: TriggerStrobeOperation | str = "Exposure"
strobe_duration: int | None = None # µs, used when strobe_operation=FixedDuration
strobe_delay: int | None = None # µs

# 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",
"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("strobe_duration", "strobe_delay", mode="before")
@classmethod
def _coerce_optional_nonnegative_int(cls, v):
if v in (None, ""):
return None
try:
iv = int(float(v))
except Exception:
return None
return iv if iv >= 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 +384,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 +562,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
72 changes: 66 additions & 6 deletions dlclivegui/gui/camera_config/camera_config_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from ...config import CameraSettings, MultiCameraSettings
from .loaders import CameraLoadWorker, CameraProbeWorker, CameraScanState, DetectCamerasWorker
from .preview import PreviewSession, PreviewState, apply_crop, apply_rotation, resize_to_fit, to_display_pixmap
from .trigger_config_dialog import TriggerConfigDialog
from .ui_blocks import setup_camera_config_dialog_ui

LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -328,6 +329,7 @@ def _connect_signals(self) -> None:
self.active_cameras_list.currentRowChanged.connect(self._on_active_camera_selected)
self.available_cameras_list.currentRowChanged.connect(self._on_available_camera_selected)
self.available_cameras_list.itemDoubleClicked.connect(self._on_available_camera_double_clicked)
self.trigger_settings_btn.clicked.connect(self._open_trigger_settings_dialog)
self.apply_settings_btn.clicked.connect(self._apply_camera_settings)
self.reset_settings_btn.clicked.connect(self._reset_selected_camera)
self.preview_btn.clicked.connect(self._toggle_preview)
Expand Down Expand Up @@ -451,11 +453,24 @@ def _refresh_camera_labels(self) -> None:
finally:
cam_list.blockSignals(False)

def _trigger_role_for_label(self, cam: CameraSettings) -> str:
backend = (cam.backend or "").lower()
props = cam.properties if isinstance(cam.properties, dict) else {}
ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {}
trigger = ns.get("trigger", {})
if not isinstance(trigger, dict):
return "off"
return str(trigger.get("role", "off") or "off").lower()

def _format_camera_label(self, cam: CameraSettings, index: int = -1) -> str:
status = "✓" if cam.enabled else "○"
this_id = f"{(cam.backend or '').lower()}:{cam.index}"
dlc_indicator = " [DLC]" if this_id == self._dlc_camera_id and cam.enabled else ""
return f"{status} {cam.name} [{cam.backend}:{cam.index}]{dlc_indicator}"

trigger_role = self._trigger_role_for_label(cam)
trigger_indicator = "" if trigger_role in {"off", "disabled"} else f" [{trigger_role}]"

return f"{status} {cam.name} [{cam.backend}:{cam.index}]{trigger_indicator}{dlc_indicator}"

def _selected_detected_camera(self) -> DetectedCamera | None:
row = self.available_cameras_list.currentRow()
Expand Down Expand Up @@ -514,6 +529,9 @@ def apply(widget, feature: str, label: str, *, allow_best_effort: bool = True):
apply(self.cam_exposure, "set_exposure", "Exposure")
apply(self.cam_gain, "set_gain", "Gain")

# Hardware trigger / sync
apply(self.trigger_settings_btn, "hardware_trigger", "Hardware trigger")

def _set_preview_button_loading(self, loading: bool) -> None:
if loading:
self.preview_btn.setText("Cancel Loading")
Expand Down Expand Up @@ -1029,6 +1047,39 @@ def _enabled_count_with(self, row: int, new_enabled: bool) -> int:
count += 1
return count

def _open_trigger_settings_dialog(self) -> None:
"""Open per-camera hardware trigger settings dialog."""
if self._current_edit_index is None:
return

row = self._current_edit_index
if row < 0 or row >= len(self._working_settings.cameras):
return

# Commit normal camera edits first so we do not lose pending UI changes.
if not self._commit_pending_edits(reason="before opening trigger settings"):
return

cam = self._working_settings.cameras[row]

dlg = TriggerConfigDialog(cam, self)
if dlg.exec() != QDialog.Accepted:
return

updated = dlg.camera_settings

self._working_settings.cameras[row] = updated
self._update_active_list_item(row, updated)
self._load_camera_to_form(updated)

# Trigger changes require reopening the camera preview/backend.
if self._is_preview_live():
self._append_status("[Trigger] Restarting preview to apply trigger settings.")
self._request_preview_restart(updated, reason="trigger-settings")

self.apply_settings_btn.setEnabled(False)
self._set_apply_dirty(False)

def _apply_camera_settings(self) -> bool:
try:
for sb in (
Expand Down Expand Up @@ -1085,9 +1136,7 @@ def _apply_camera_settings(self) -> bool:
old_settings = current_model

restart = False
should_consider_restart = self._preview.state == PreviewState.ACTIVE and isinstance(
old_settings, CameraSettings
)
should_consider_restart = self._is_preview_live() and isinstance(old_settings, CameraSettings)
if should_consider_restart:
restart = self._should_restart_preview(old_settings, new_model)

Expand All @@ -1099,7 +1148,7 @@ def _apply_camera_settings(self) -> bool:
new_model.index,
)

if self._preview.state == PreviewState.ACTIVE and restart:
if self._is_preview_live() and restart:
self._append_status("[Apply] Restarting preview to apply camera settings changes.")
self._request_preview_restart(new_model, reason="apply-settings")

Expand Down Expand Up @@ -1597,6 +1646,13 @@ def _bump_epoch(self) -> int:
self._preview.epoch += 1
return self._preview.epoch

def _trigger_dict_for_cam(self, cam: CameraSettings) -> dict:
backend = (cam.backend or "").lower()
props = cam.properties if isinstance(cam.properties, dict) else {}
ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {}
trigger = ns.get("trigger", {})
return trigger if isinstance(trigger, dict) else {}

def _should_restart_preview(self, old: CameraSettings, new: CameraSettings) -> bool:
"""
Fast UX policy:
Expand All @@ -1612,6 +1668,9 @@ def _should_restart_preview(self, old: CameraSettings, new: CameraSettings) -> b
except Exception:
return True # safest: restart

if self._trigger_dict_for_cam(old) != self._trigger_dict_for_cam(new):
return True

# No restart needed if only rotation/crop/enabled changed
return False

Expand Down Expand Up @@ -1756,9 +1815,10 @@ def _on_loader_finished(self, e: int) -> None:
self._preview.restart_scheduled = False
self._preview.loader = None

if pending and self._preview.state == PreviewState.IDLE:
if pending and self._preview.state in (PreviewState.IDLE, PreviewState.ACTIVE):
LOGGER.debug("[Loader] finished with pending restart for backend=%s idx=%s", pending.backend, pending.index)
self._begin_preview_load(pending, reason="pending-restart-after-finish")
return # UI sync is already handled in _begin_preview_load

self._sync_preview_ui()

Expand Down
Loading
Loading