Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
97fa138
Add GenTL hardware trigger support
C-Achard May 28, 2026
51ab19e
Handle camera trigger defaults & trigger-aware startup
C-Achard May 28, 2026
5040aab
Improve signal handling and graceful shutdown
C-Achard May 28, 2026
a2eca8e
Add GenTL trigger tests and fake node map
C-Achard May 28, 2026
ec6c898
Respect user camera order for display/tiling
C-Achard May 28, 2026
ea9f2a9
tests: preserve display order and add MultiCamera tests
C-Achard May 28, 2026
cd9c5ab
Mock _maybe_allow_keyboard_interrupt in GUI tests
C-Achard May 28, 2026
53ea4c2
Support strict GenTL trigger and defaults
C-Achard May 28, 2026
e6aa601
Apply gentl trigger defaults when saving
C-Achard May 28, 2026
4dd3b4d
Don't sort available camera IDs
C-Achard May 28, 2026
9b61a3e
Preserve DLC config using model_copy
C-Achard May 28, 2026
e2c15b3
Warn when GenTL TriggerMode fails to enable
C-Achard May 28, 2026
65b33a3
Improve GenTL trigger routing safety and tests
C-Achard May 28, 2026
646ac99
Cap hardware-trigger fetch timeout and update tests
C-Achard May 28, 2026
e9789d8
Interruptible camera waits; simplify config save
C-Achard May 29, 2026
1f5bd21
Potential fix for pull request finding
C-Achard May 29, 2026
d4548c9
Fix broken suggestion
C-Achard May 29, 2026
6e7b3e8
Resolve 'auto' trigger source in GenTL backend
C-Achard May 29, 2026
7e72c0b
Add trigger settings dialog and UI button
C-Achard May 28, 2026
45fd768
Add per-camera hardware trigger settings
C-Achard May 28, 2026
9b6d31b
Update trigger_config_dialog.py
C-Achard May 28, 2026
0edfbbf
Use _is_preview_live and clear trigger timeout
C-Achard May 29, 2026
1780065
Restart active previews; ignore bad trigger
C-Achard May 29, 2026
9f6493d
Improve trigger dialog defaults and error handling
C-Achard May 29, 2026
a4d0714
Add worker timing logs to camera worker
C-Achard May 29, 2026
59a2449
Add per-camera timing to MultiCameraController
C-Achard May 29, 2026
f201917
Add pretty/str/repr to CameraSettings
C-Achard May 29, 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
302 changes: 295 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
177 changes: 172 additions & 5 deletions dlclivegui/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
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"]

SINGLE_CAMERA_WORKER_DO_LOG_TIMING = False
MULTI_CAMERA_WORKER_DO_LOG_TIMING = True


class CameraSettings(BaseModel):
Expand All @@ -38,6 +43,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 +194,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",
"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 +363,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 +541,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
Loading