为采集帧绑定主机时间与来源信息

采集时间随图像穿过处理队列,并修正未读帧的丢帧计数。通过采集与流水线针对性测试。
This commit is contained in:
ykcheungab
2026-10-11 12:48:34 +08:00
parent 0784eacf51
commit 120b8c2cf6
7 changed files with 256 additions and 40 deletions
+2
View File
@@ -16,6 +16,7 @@ from .discovery import (
pick_default_camera,
)
from .source import (
CapturedFrame,
CaptureSource,
CaptureWorker,
is_live_camera,
@@ -31,6 +32,7 @@ __all__ = [
"CameraFilter",
"CameraInfo",
"CaptureConfig",
"CapturedFrame",
"CaptureHealth",
"CaptureSource",
"CaptureStatus",
+92 -27
View File
@@ -7,6 +7,8 @@ import os
import sys
import threading
import time
from dataclasses import dataclass
from math import isfinite
from typing import Any, Protocol, runtime_checkable
import cv2
@@ -23,6 +25,21 @@ from .config import (
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class CapturedFrame:
"""One image and the host clocks sampled when its capture read completed.
``sequence`` belongs to this source instance. ``media_time_ms`` is advisory
file metadata and is never substituted for the host acquisition clocks.
"""
image: np.ndarray
sequence: int
host_mono_ns: int
host_utc_ns: int
media_time_ms: float | None = None
@runtime_checkable
class VideoCaptureLike(Protocol):
"""Minimal protocol so tests can inject fake captures."""
@@ -223,7 +240,10 @@ class CaptureSource:
self._frame_lock = threading.Lock()
self._frame_cond = threading.Condition(self._frame_lock)
self._latest_frame: np.ndarray | None = None
self._latest_captured: CapturedFrame | None = None
self._latest_seq = 0
self._read_seq = 0
self._last_delivered_seq = 0
self._thread: threading.Thread | None = None
self._background_running = False
@@ -338,6 +358,7 @@ class CaptureSource:
self._cap = None
with self._frame_cond:
self._latest_frame = None
self._latest_captured = None
self._latest_seq = 0
def _refresh_stream_meta_unlocked(self) -> None:
@@ -363,28 +384,80 @@ class CaptureSource:
for _ in range(max(0, int(count))):
self.read_raw()
def read_raw(self) -> tuple[bool, np.ndarray | None]:
"""Read one frame directly from the underlying capture (no reconnect)."""
def read_raw_stamped(self) -> CapturedFrame | None:
"""Read a frame and bind its identity and clocks before another read."""
cap = self._cap
if cap is None:
return False, None
return None
try:
ok, frame = cap.read()
except Exception as exc:
with self._lock:
self._status.consecutive_failures += 1
self._status.last_error = str(exc)
return False, None
return None
if not ok or frame is None:
with self._lock:
self._status.consecutive_failures += 1
return False, None
return None
host_mono_ns = time.perf_counter_ns()
host_utc_ns = time.time_ns()
media_time_ms = None
if not is_live_camera(self._status.source):
try:
value = float(cap.get(cv2.CAP_PROP_POS_MSEC))
if isfinite(value) and value >= 0:
media_time_ms = value
except Exception as exc: # noqa: BLE001 - metadata must not stop capture
logger.debug("capture: media timestamp unavailable: %s", exc)
with self._lock:
self._read_seq += 1
sequence = self._read_seq
self._status.consecutive_failures = 0
self._status.last_frame_time = time.perf_counter()
self._status.last_frame_time = host_mono_ns / 1e9
if self._status.health != CaptureHealth.CONNECTED:
self._status.health = CaptureHealth.CONNECTED
return True, frame
return CapturedFrame(frame, sequence, host_mono_ns, host_utc_ns, media_time_ms)
def read_raw(self) -> tuple[bool, np.ndarray | None]:
"""Compatibility read without capture metadata."""
captured = self.read_raw_stamped()
return (captured is not None, None if captured is None else captured.image)
def read_latest_stamped(
self, *, copy: bool = True, wait_s: float = 0.0
) -> CapturedFrame | None:
"""Return a frame and its own stamp atomically from the background slot."""
if not self._background_running:
captured = self.read_raw_stamped()
if captured is not None and copy:
return CapturedFrame(
captured.image.copy(),
captured.sequence,
captured.host_mono_ns,
captured.host_utc_ns,
captured.media_time_ms,
)
return captured
deadline = time.perf_counter() + max(0.0, wait_s)
with self._frame_cond:
while self._latest_captured is None:
remaining = deadline - time.perf_counter()
if remaining <= 0:
return None
self._frame_cond.wait(timeout=remaining)
captured = self._latest_captured
self._last_delivered_seq = captured.sequence
if copy:
return CapturedFrame(
captured.image.copy(),
captured.sequence,
captured.host_mono_ns,
captured.host_utc_ns,
captured.media_time_ms,
)
return captured
def read_latest(
self,
@@ -393,21 +466,8 @@ class CaptureSource:
wait_s: float = 0.0,
) -> tuple[bool, np.ndarray | None]:
"""Return the newest frame from the background thread, or read sync."""
if not self._background_running:
ok, frame = self.read_raw()
if ok and copy and frame is not None:
return ok, frame.copy()
return ok, frame
deadline = time.perf_counter() + max(0.0, wait_s)
with self._frame_cond:
while self._latest_frame is None:
remaining = deadline - time.perf_counter()
if remaining <= 0:
return False, None
self._frame_cond.wait(timeout=remaining)
frame = self._latest_frame.copy() if copy else self._latest_frame
return True, frame
captured = self.read_latest_stamped(copy=copy, wait_s=wait_s)
return (captured is not None, None if captured is None else captured.image)
def read(self, *, copy: bool = True, wait_s: float = 1.0) -> tuple[bool, np.ndarray | None]:
"""Compat alias used by finger runtime and older worker callers."""
@@ -441,6 +501,7 @@ class CaptureSource:
self._thread = None
with self._frame_cond:
self._latest_frame = None
self._latest_captured = None
def stop(self, timeout: float = 1.0) -> None:
"""Compat alias for ``stop_background``."""
@@ -455,19 +516,23 @@ class CaptureSource:
time.sleep(0.2)
continue
ok, frame = self.read_raw()
if not ok or frame is None:
captured = self.read_raw_stamped()
if captured is None:
if self.should_reconnect():
continue
time.sleep(0.001)
continue
with self._frame_cond:
if self._latest_frame is not None:
if (
self._latest_captured is not None
and self._latest_captured.sequence > self._last_delivered_seq
):
with self._lock:
self._status.dropped_frames += 1
self._latest_seq += 1
self._latest_frame = frame
self._latest_seq = captured.sequence
self._latest_frame = captured.image
self._latest_captured = captured
with self._lock:
self._status.last_seq = self._latest_seq
self._frame_cond.notify_all()