Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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, DEBUG_TRIGGER_LOGS, 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 @@ -56,6 +55,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 @@ -178,6 +179,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 @@ -481,6 +483,7 @@ def _configure_frame_rate(self) -> None:
"BslResultingAcquisitionFrameRate",
"ExposureAuto",
"ExposureTime",
"ExposureTimeAbs",
"Width",
"Height",
"PixelFormat",
Expand Down Expand Up @@ -550,7 +553,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 @@ -661,9 +667,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 @@ -676,7 +701,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
Comment thread
C-Achard marked this conversation as resolved.

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 @@ -705,6 +759,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 @@ -731,7 +789,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 DEBUG_TRIGGER_LOGS, 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 @@ -624,7 +624,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 @@ -664,7 +664,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
19 changes: 18 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 @@ -171,7 +188,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 @@ -1403,7 +1403,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 @@ -1419,7 +1421,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