为采集帧绑定主机时间与来源信息
采集时间随图像穿过处理队列,并修正未读帧的丢帧计数。通过采集与流水线针对性测试。
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user