Skip to content
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
6 changes: 4 additions & 2 deletions dlclivegui/gui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -1396,7 +1396,9 @@ def _render_overlays_for_recording(self, cam_id, frame):
)
return output

def _on_recording_frame_ready(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None:
def _on_recording_frame_ready(
self, camera_id: str, frame: np.ndarray, timestamp: float, timestamp_metadata: object | None = None
) -> None:
"""Handle full-rate per-camera frames for recording only.

Intentionally lean:
Expand All @@ -1412,7 +1414,7 @@ def _on_recording_frame_ready(self, camera_id: str, frame: np.ndarray, timestamp
if self.record_with_overlays_checkbox.isChecked():
frame = self._render_overlays_for_recording(camera_id, frame)

self._rec_manager.write_frame(camera_id, frame, timestamp)
self._rec_manager.write_frame(camera_id, frame, timestamp, timestamp_metadata=timestamp_metadata)

def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None:
"""Handle frames from multiple cameras.
Expand Down
10 changes: 8 additions & 2 deletions dlclivegui/gui/recording_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,12 +202,18 @@ def stop_all(self) -> None:
self._session_dir = None
self._run_dir = None

def write_frame(self, cam_id: str, frame: np.ndarray, timestamp: float | None = None) -> None:
def write_frame(
self, cam_id: str, frame: np.ndarray, timestamp: float | None = None, timestamp_metadata: object | None = None
) -> None:
rec = self._recorders.get(cam_id)
if not rec or not rec.is_running:
return
try:
rec.write(frame, timestamp=timestamp if timestamp is not None else time.time())
rec.write(
frame,
timestamp=timestamp if timestamp is not None else time.time(),
timestamp_metadata=timestamp_metadata,
)
except Exception as exc:
log.warning("Failed to write frame for %s: %s", cam_id, exc)
try:
Expand Down
Loading