Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ab1a4d9
Add frame timestamp metadata helper class
C-Achard Jun 29, 2026
29141d9
Propagate hardware timestamp metadata
C-Achard Jun 29, 2026
66deeac
Update aravis_backend.py
C-Achard Jun 29, 2026
0b7e19c
Update basler_backend.py
C-Achard Jun 29, 2026
093de4e
Update gentl_backend.py
C-Achard Jun 29, 2026
e1fa1e3
Update opencv_backend.py
C-Achard Jun 29, 2026
e4fdb49
Update tests for CapturedFrame read API
C-Achard Jun 29, 2026
797ee04
Rename metadata key to frame_timestamps
C-Achard Jun 29, 2026
2953f41
Add timestamp metadata coverage across tests
C-Achard Jun 29, 2026
b196776
Harden Basler timestamp metadata handling
C-Achard Jun 29, 2026
d3a0ffa
Drop legacy timestamps assertions in tests
C-Achard Jun 29, 2026
09154c6
Guard default timestamp field
C-Achard Jun 29, 2026
3d3e75c
Lock processor settings during DLC inference
C-Achard Jun 30, 2026
647b1cc
Improve processor discovery and logging
C-Achard Jul 1, 2026
2176e3f
Add processors package exports
C-Achard Jul 1, 2026
fd19171
Move example socket processors to examples module
C-Achard Jul 1, 2026
5f457b5
Update plugin docs for processor examples
C-Achard Jul 1, 2026
1f8c1e7
Skip socket base module in processor scan
C-Achard Jul 1, 2026
0c219fa
Update processor_utils.py
C-Achard Jul 1, 2026
b216123
Warn on duplicate processor registration
C-Achard Jul 1, 2026
45d07d0
Update examples.py
C-Achard Jul 1, 2026
da000ca
Refine processor package scan typing
C-Achard Jul 1, 2026
cc2b84d
Update examples.py
C-Achard Jul 1, 2026
00f7ccb
Fix dlclive Processor import paths
C-Achard Jul 1, 2026
6f336c5
Extract processor registry into new module
C-Achard Jul 1, 2026
1d34a6a
Fix dlclive mock structure in processor tests
C-Achard Jul 1, 2026
0a12128
Make Engine a str enum and normalize model_type
C-Achard Jul 1, 2026
c3f8854
Persist custom processor folder in settings
C-Achard Jul 1, 2026
a800c08
Improve recorder error logging and handling
C-Achard Jul 1, 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
6 changes: 3 additions & 3 deletions dlclivegui/cameras/backends/aravis_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import numpy as np

from ...config import CameraSettings
from ..base import CameraBackend, SupportLevel, register_backend
from ..base import CameraBackend, CapturedFrame, SupportLevel, register_backend
from ..factory import DetectedCamera

LOG = logging.getLogger(__name__)
Expand Down Expand Up @@ -372,7 +372,7 @@ def open(self) -> None:

self._camera.start_acquisition()

def read(self) -> tuple[np.ndarray, float]:
def read(self) -> CapturedFrame:
"""Read a frame from the camera."""
if self._camera is None or self._stream is None:
raise RuntimeError("Aravis camera not initialized")
Expand Down Expand Up @@ -430,7 +430,7 @@ def read(self) -> tuple[np.ndarray, float]:
# Always push buffer back to stream
self._stream.push_buffer(buffer)

return frame, timestamp
return CapturedFrame(frame=frame, software_timestamp=timestamp, timestamp_metadata=None)

def stop(self) -> None:
"""Stop camera acquisition."""
Expand Down
78 changes: 70 additions & 8 deletions dlclivegui/cameras/backends/basler_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@
import time
from typing import ClassVar

import numpy as np

from ...config import BASLER_DO_LOG_TIMING, CameraTriggerSettings
from ...utils.stats import WorkerTimingStats
from ..base import CameraBackend, SupportLevel, register_backend
from ...utils.timestamps import FrameTimestampMetadata
from ..base import CameraBackend, CapturedFrame, SupportLevel, register_backend

LOG = logging.getLogger(__name__)

Expand Down Expand Up @@ -57,6 +56,8 @@ def __init__(self, settings):
# (may skip StartGrabbing and converter setup for faster capability probing; not suitable for normal capture)
self._fast_start: bool = bool(self.ns.get("fast_start", False))
self._retrieve_timeout_ms: int = 100 # default; may be overridden by trigger settings
self._timestamp_tick_frequency_hz: float | None = None
self._timestamp_tick_frequency_source: str | None = None

# ---- Trigger settings ----
raw_trigger = self.ns.get("trigger", self._props.get("trigger"))
Expand Down Expand Up @@ -179,6 +180,7 @@ def static_capabilities(cls) -> dict[str, SupportLevel]:
"stable_identity": SupportLevel.SUPPORTED,
"hardware_trigger": SupportLevel.BEST_EFFORT,
"preserve_mono": SupportLevel.SUPPORTED,
"hardware_frame_timestamps": SupportLevel.BEST_EFFORT,
}
)
return caps
Expand Down Expand Up @@ -472,6 +474,7 @@ def _configure_frame_rate(self) -> None:
"BslResultingAcquisitionFrameRate",
"ExposureAuto",
"ExposureTime",
"ExposureTimeAbs",
"Width",
"Height",
"PixelFormat",
Expand Down Expand Up @@ -541,7 +544,10 @@ def open(self) -> None:
try:
if hasattr(self._camera, "ExposureAuto"):
self._camera.ExposureAuto.SetValue("Off")
self._camera.ExposureTime.SetValue(float(self.settings.exposure))
if hasattr(self._camera, "ExposureTime"):
self._camera.ExposureTime.SetValue(float(self.settings.exposure))
if hasattr(self._camera, "ExposureTimeAbs"):
self._camera.ExposureTimeAbs.SetValue(float(self.settings.exposure))
LOG.info("[Basler] Exposure set to %s us (auto off)", self.settings.exposure)
except Exception as exc:
LOG.warning("[Basler] Failed to set exposure: %s", exc)
Expand Down Expand Up @@ -652,9 +658,28 @@ def open(self) -> None:
getattr(self.settings, "gain", None),
)

# ----------------------------
# Get hardware tick frequency for timestamp conversion
try:
node = getattr(self._camera, "GevTimestampTickFrequency", None)
if node is not None and node.IsReadable():
self._timestamp_tick_frequency_hz = float(node.GetValue())
self._timestamp_tick_frequency_source = "GevTimestampTickFrequency"
LOG.info(
"[Basler] timestamp tick frequency: %.3f Hz from GevTimestampTickFrequency",
self._timestamp_tick_frequency_hz,
)
except Exception:
LOG.debug("[Basler] Could not read GevTimestampTickFrequency", exc_info=True)

if not self._timestamp_tick_frequency_hz or self._timestamp_tick_frequency_hz <= 0:
self._timestamp_tick_frequency_hz = 1_000_000_000.0
self._timestamp_tick_frequency_source = "assumed_default_1ghz"
LOG.info(
"[Basler] timestamp tick frequency unavailable; assuming %.3f Hz",
self._timestamp_tick_frequency_hz,
)

# Persist stable identity into namespace
# ----------------------------
try:
serial = device.GetSerialNumber()
if serial:
Expand All @@ -667,7 +692,36 @@ def open(self) -> None:
except Exception:
pass

def read(self) -> tuple[np.ndarray, float]:
def _make_timestamp_metadata(self, grab_result) -> FrameTimestampMetadata | None:
try:
ticks = int(grab_result.GetTimeStamp())
except Exception:
return None

if ticks == 0:
# Basler returns 0 if the timestamp is not available (e.g. for some GigE cameras)
return None

freq = getattr(self, "_timestamp_tick_frequency_hz", None)
seconds = ticks / freq if freq and freq > 0 else None

return FrameTimestampMetadata(
source="grab_result.GetTimeStamp",
backend="basler",
default_reported="seconds" if seconds is not None else "raw_value",
seconds=seconds,
wall_clock_time=None,
raw_value=ticks,
raw_unit="ticks",
tick_frequency_hz=freq,
timebase="Basler camera timestamp counter",
kind="camera_clock",
extra={
"tick_frequency_source": self._timestamp_tick_frequency_source,
},
)

def read(self) -> CapturedFrame:
if self._camera is None:
raise RuntimeError("Basler camera not opened")
if self._converter is None:
Expand Down Expand Up @@ -696,6 +750,10 @@ def read(self) -> tuple[np.ndarray, float]:
with self._timing.measure("Basler.get_array"):
frame = image.GetArray()

with self._timing.measure("Basler.timestamp"):
software_timestamp = time.time()
timestamp_metadata = self._make_timestamp_metadata(grab_result)

if not self._logged_first_frame:
self._logged_first_frame = True
LOG.info(
Expand All @@ -722,7 +780,11 @@ def read(self) -> tuple[np.ndarray, float]:
self._timing.note_frame()
self._timing.maybe_log()

return frame, time.time()
return CapturedFrame(
frame=frame,
software_timestamp=software_timestamp,
timestamp_metadata=timestamp_metadata,
)

except Exception as exc:
if grab_result is not None:
Expand Down
10 changes: 7 additions & 3 deletions dlclivegui/cameras/backends/gentl_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import numpy as np

from ...config import CameraTriggerSettings
from ..base import CameraBackend, SupportLevel, register_backend
from ..base import CameraBackend, CapturedFrame, SupportLevel, register_backend
from ..factory import DetectedCamera
from .utils import gentl_discovery as cti_finder

Expand Down Expand Up @@ -615,7 +615,7 @@ def _output_format_for_frame(frame: np.ndarray) -> str:
return f"{channels}ch-{frame.dtype}"
return str(frame.dtype)

def read(self) -> tuple[np.ndarray, float]:
def read(self) -> CapturedFrame:
if self._acquirer is None:
raise RuntimeError("GenTL image acquirer not initialised")

Expand Down Expand Up @@ -655,7 +655,11 @@ def read(self) -> tuple[np.ndarray, float]:
pass
self._actual_output_format = self._output_format_for_frame(frame)

return frame, timestamp
return CapturedFrame(
frame=frame,
software_timestamp=timestamp,
timestamp_metadata=None,
)

def stop(self) -> None:
if self._acquirer is not None:
Expand Down
43 changes: 33 additions & 10 deletions dlclivegui/cameras/backends/opencv_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,9 @@
from typing import TYPE_CHECKING, Literal

import cv2
import numpy as np
from pydantic import BaseModel, Field, model_validator

from ..base import CameraBackend, SupportLevel, register_backend
from ..base import CameraBackend, CapturedFrame, SupportLevel, register_backend
from ..factory import DetectedCamera
from .utils.opencv_discovery import (
ModeRequest,
Expand Down Expand Up @@ -199,21 +198,45 @@ def open(self) -> None:

self._configure_capture()

def read(self) -> tuple[np.ndarray | None, float]:
"""Robust frame read: return (None, ts) on transient failures; never raises."""
def read(self) -> CapturedFrame:
"""Robust frame read: return CapturedFrame(frame=None, ...) on transient failures; never raises."""
if self._capture is None:
logger.warning("OpenCVCameraBackend.read() called before open()")
return None, time.time()
return CapturedFrame(
frame=None,
software_timestamp=time.time(),
timestamp_metadata=None,
)

try:
if not self._capture.grab():
return None, time.time()
return CapturedFrame(
frame=None,
software_timestamp=time.time(),
timestamp_metadata=None,
)

success, frame = self._capture.retrieve()
if not success or frame is None or frame.size == 0:
return None, time.time()
return frame, time.time()
return CapturedFrame(
frame=None,
software_timestamp=time.time(),
timestamp_metadata=None,
)

return CapturedFrame(
frame=frame,
software_timestamp=time.time(),
timestamp_metadata=None,
)

except Exception as exc:
logger.debug(f"OpenCV read transient error: {exc}")
return None, time.time()
logger.debug("OpenCV read transient error: %s", exc)
return CapturedFrame(
frame=None,
software_timestamp=time.time(),
timestamp_metadata=None,
)

def close(self) -> None:
self._release_capture()
Expand Down
24 changes: 23 additions & 1 deletion dlclivegui/cameras/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Any, ClassVar

Expand All @@ -11,6 +12,7 @@
from ..config import CameraSettings

if TYPE_CHECKING:
from ..utils.timestamps import FrameTimestampMetadata
from .factory import DetectedCamera

_BACKEND_REGISTRY: dict[str, type[CameraBackend]] = {}
Expand Down Expand Up @@ -72,9 +74,24 @@ class SupportLevel(str, Enum):
"device_discovery": SupportLevel.UNSUPPORTED,
"stable_identity": SupportLevel.UNSUPPORTED,
"hardware_trigger": SupportLevel.UNSUPPORTED,
"hardware_frame_timestamps": SupportLevel.UNSUPPORTED,
}


@dataclass(frozen=True)
class CapturedFrame:
"""Frame plus software timestamp and optional backend timestamp metadata."""

frame: np.ndarray | None
software_timestamp: float
timestamp_metadata: FrameTimestampMetadata | None = None

def __iter__(self):
"""Backwards-compatible unpacking: frame, software_timestamp = backend.read()"""
yield self.frame
yield self.software_timestamp


class CameraBackend(ABC):
"""Abstract base class for camera backends."""

Expand Down Expand Up @@ -107,6 +124,11 @@ def actual_pixel_format(self) -> str | None:
def recommended_preserve_mono(self) -> bool | None:
return None

@property
def last_frame_timestamp_metadata(self) -> FrameTimestampMetadata | None:
"""Return backend-provided timestamp metadata for the last read frame."""
return None

@classmethod
def options_key(cls) -> str:
"""Return the key used to store this backend's options in CameraSettings."""
Expand Down Expand Up @@ -171,7 +193,7 @@ def open(self) -> None:
raise NotImplementedError

@abstractmethod
def read(self) -> tuple[np.ndarray, float]:
def read(self) -> CapturedFrame:
"""Read a frame and return the image with a timestamp."""
raise NotImplementedError

Expand Down
Loading