120b8c2cf6
采集时间随图像穿过处理队列,并修正未读帧的丢帧计数。通过采集与流水线针对性测试。
630 lines
22 KiB
Python
630 lines
22 KiB
Python
"""OpenCV-backed capture source with optional background thread and reconnect."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
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
|
|
import numpy as np
|
|
|
|
from .config import (
|
|
CaptureConfig,
|
|
CaptureHealth,
|
|
CaptureStatus,
|
|
ReconnectPolicy,
|
|
SourceType,
|
|
)
|
|
|
|
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."""
|
|
|
|
def isOpened(self) -> bool: ...
|
|
def read(self) -> tuple[bool, Any]: ...
|
|
def release(self) -> None: ...
|
|
def get(self, propId: int) -> float: ...
|
|
def set(self, propId: int, value: float) -> bool: ...
|
|
def grab(self) -> bool: ...
|
|
|
|
|
|
def normalize_source(source: SourceType | None) -> SourceType:
|
|
"""Accept camera index, numeric string, V4L path, or filesystem path."""
|
|
if source is None:
|
|
return 0
|
|
if isinstance(source, str):
|
|
stripped = source.strip()
|
|
if not stripped:
|
|
return 0
|
|
if stripped.isdigit():
|
|
return int(stripped)
|
|
return stripped.rstrip("/\\") or stripped
|
|
return source
|
|
|
|
|
|
def is_live_camera(source: SourceType) -> bool:
|
|
"""True for camera indices and Linux V4L2 device paths."""
|
|
if isinstance(source, int):
|
|
return True
|
|
if isinstance(source, str) and source.startswith("/dev/video"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _backend_candidates(preferred: str = "auto") -> list[tuple[str, int]]:
|
|
preferred = (preferred or "auto").lower()
|
|
candidates: list[tuple[str, int]] = []
|
|
|
|
def add(name: str, attr: str) -> None:
|
|
if hasattr(cv2, attr):
|
|
candidates.append((name, getattr(cv2, attr)))
|
|
|
|
if preferred in ("dshow", "directshow") and sys.platform == "win32":
|
|
add("DSHOW", "CAP_DSHOW")
|
|
elif preferred in ("msmf",) and sys.platform == "win32":
|
|
add("MSMF", "CAP_MSMF")
|
|
elif preferred in ("v4l2", "v4l") and sys.platform.startswith("linux"):
|
|
add("V4L2", "CAP_V4L2")
|
|
elif preferred not in ("auto", "any", ""):
|
|
pass
|
|
|
|
if not candidates:
|
|
if sys.platform == "win32":
|
|
add("DSHOW", "CAP_DSHOW")
|
|
add("MSMF", "CAP_MSMF")
|
|
elif sys.platform.startswith("linux"):
|
|
add("V4L2", "CAP_V4L2")
|
|
if not candidates:
|
|
candidates.append(("ANY", cv2.CAP_ANY))
|
|
return candidates
|
|
|
|
|
|
def _fourcc_code(fourcc: str | None) -> int | None:
|
|
if not fourcc:
|
|
return None
|
|
text = str(fourcc).strip()
|
|
if len(text) != 4:
|
|
return None
|
|
return cv2.VideoWriter.fourcc(*text)
|
|
|
|
|
|
def _decode_fourcc(value: float) -> str:
|
|
fourcc_int = int(value)
|
|
if fourcc_int <= 0:
|
|
return "N/A"
|
|
bytes_ = [(fourcc_int >> (8 * i)) & 0xFF for i in range(4)]
|
|
return "".join(chr(b) if 32 <= b <= 126 else "." for b in bytes_)
|
|
|
|
|
|
def _read_stream_meta(cap: VideoCaptureLike) -> tuple[int, int, float, str]:
|
|
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
|
|
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
|
|
fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0)
|
|
fourcc = _decode_fourcc(cap.get(cv2.CAP_PROP_FOURCC) or 0)
|
|
return width, height, fps, fourcc
|
|
|
|
|
|
def _apply_stream_props(
|
|
cap: VideoCaptureLike,
|
|
*,
|
|
width: int | None,
|
|
height: int | None,
|
|
fps: float | None,
|
|
fourcc: str | None,
|
|
) -> None:
|
|
"""Apply live-camera stream properties (width/height before fourcc on DSHOW)."""
|
|
code = _fourcc_code(fourcc)
|
|
if width is not None:
|
|
cap.set(cv2.CAP_PROP_FRAME_WIDTH, float(width))
|
|
if height is not None:
|
|
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, float(height))
|
|
if code is not None:
|
|
cap.set(cv2.CAP_PROP_FOURCC, code)
|
|
if fps is not None:
|
|
cap.set(cv2.CAP_PROP_FPS, float(fps))
|
|
# DSHOW often resets fourcc after size negotiation.
|
|
if code is not None:
|
|
cap.set(cv2.CAP_PROP_FOURCC, code)
|
|
|
|
|
|
def _warmup_capture(cap: VideoCaptureLike, *, grabs: int = 3) -> None:
|
|
for _ in range(max(0, grabs)):
|
|
try:
|
|
cap.grab()
|
|
except Exception:
|
|
break
|
|
|
|
|
|
def open_opencv_capture(
|
|
source: SourceType,
|
|
*,
|
|
backend: str = "auto",
|
|
width: int | None = None,
|
|
height: int | None = None,
|
|
fps: float | None = None,
|
|
fourcc: str | None = "MJPG",
|
|
apply_stream_props: bool = True,
|
|
) -> tuple[VideoCaptureLike, str]:
|
|
"""Open a ``cv2.VideoCapture`` trying preferred backends in order.
|
|
|
|
Returns ``(capture, backend_name)``.
|
|
"""
|
|
if sys.platform == "win32":
|
|
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
|
|
|
source = normalize_source(source)
|
|
live = is_live_camera(source)
|
|
last_error = "Failed to open video source."
|
|
requested_fourcc = (fourcc or "").strip().upper()
|
|
|
|
backends = _backend_candidates(backend) if live else [("ANY", cv2.CAP_ANY)]
|
|
for backend_name, backend_api in backends:
|
|
try:
|
|
if live and backend_api != cv2.CAP_ANY:
|
|
cap = cv2.VideoCapture(source, backend_api)
|
|
else:
|
|
cap = cv2.VideoCapture(source)
|
|
except Exception as exc: # pragma: no cover - OpenCV quirks
|
|
last_error = str(exc)
|
|
continue
|
|
if not cap.isOpened():
|
|
cap.release()
|
|
last_error = f"Backend {backend_name} failed to open {source!r}"
|
|
continue
|
|
if live and apply_stream_props:
|
|
_apply_stream_props(
|
|
cap,
|
|
width=width,
|
|
height=height,
|
|
fps=fps,
|
|
fourcc=fourcc,
|
|
)
|
|
_warmup_capture(cap)
|
|
_, _, _, negotiated = _read_stream_meta(cap)
|
|
if requested_fourcc and negotiated.upper() != requested_fourcc:
|
|
# Retry once: some drivers need a second fourcc set after warmup.
|
|
code = _fourcc_code(fourcc)
|
|
if code is not None:
|
|
cap.set(cv2.CAP_PROP_FOURCC, code)
|
|
_warmup_capture(cap, grabs=2)
|
|
return cap, backend_name
|
|
|
|
raise RuntimeError(last_error)
|
|
|
|
|
|
class CaptureSource:
|
|
"""Owns a video capture handle, optional background reader, and reconnect logic.
|
|
|
|
Optionally accepts an injected ``factory`` for tests::
|
|
|
|
CaptureSource(config, factory=lambda cfg: (FakeCap(), "FAKE"))
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
config: CaptureConfig,
|
|
*,
|
|
factory=None,
|
|
):
|
|
self.config = config
|
|
self._factory = factory or self._default_factory
|
|
self._cap: VideoCaptureLike | None = None
|
|
self._backend_name = ""
|
|
self._status = CaptureStatus(source=normalize_source(config.source))
|
|
self._reconnect_attempt = 0
|
|
self._lock = threading.Lock()
|
|
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
|
|
|
|
@staticmethod
|
|
def _default_factory(config: CaptureConfig) -> tuple[VideoCaptureLike, str]:
|
|
return open_opencv_capture(
|
|
config.source,
|
|
backend=config.backend,
|
|
width=config.width,
|
|
height=config.height,
|
|
fps=config.fps,
|
|
fourcc=config.fourcc,
|
|
apply_stream_props=is_live_camera(normalize_source(config.source)),
|
|
)
|
|
|
|
# Compatibility with older CaptureWorker API.
|
|
@property
|
|
def source(self) -> CaptureSource:
|
|
return self
|
|
|
|
@property
|
|
def running(self) -> bool:
|
|
return self._background_running
|
|
|
|
@property
|
|
def status(self) -> CaptureStatus:
|
|
with self._lock:
|
|
return CaptureStatus(
|
|
health=self._status.health,
|
|
source=self._status.source,
|
|
last_error=self._status.last_error,
|
|
last_frame_time=self._status.last_frame_time,
|
|
last_seq=self._status.last_seq,
|
|
dropped_frames=self._status.dropped_frames,
|
|
reconnect_attempts=self._status.reconnect_attempts,
|
|
consecutive_failures=self._status.consecutive_failures,
|
|
width=self._status.width,
|
|
height=self._status.height,
|
|
fps=self._status.fps,
|
|
backend_name=self._status.backend_name,
|
|
fourcc=self._status.fourcc,
|
|
)
|
|
|
|
@property
|
|
def cap(self) -> VideoCaptureLike | None:
|
|
"""Raw capture handle (compat with Sensor.cap)."""
|
|
return self._cap
|
|
|
|
def is_open(self) -> bool:
|
|
cap = self._cap
|
|
return cap is not None and cap.isOpened()
|
|
|
|
def open(self) -> None:
|
|
with self._lock:
|
|
self._status.health = CaptureHealth.CONNECTING
|
|
self._status.last_error = None
|
|
try:
|
|
cap, backend_name = self._factory(self.config)
|
|
except Exception as exc:
|
|
with self._lock:
|
|
self._status.health = CaptureHealth.FAILED
|
|
self._status.last_error = str(exc)
|
|
raise
|
|
with self._lock:
|
|
self._release_unlocked()
|
|
self._cap = cap
|
|
self._backend_name = backend_name
|
|
self._refresh_stream_meta_unlocked()
|
|
self._status.health = CaptureHealth.CONNECTED
|
|
self._status.backend_name = backend_name
|
|
self._status.consecutive_failures = 0
|
|
self._reconnect_attempt = 0
|
|
if self.config.verbose:
|
|
st = self.status
|
|
print(
|
|
f"OpenCV negotiated stream: FOURCC='{st.fourcc}', "
|
|
f"size={st.width}x{st.height}, fps={st.fps:.2f}, backend={st.backend_name}"
|
|
)
|
|
requested = (self.config.fourcc or "").strip().upper()
|
|
if (
|
|
requested
|
|
and is_live_camera(normalize_source(self.config.source))
|
|
and st.fourcc.upper() != requested
|
|
):
|
|
print(
|
|
f"Warning: requested FOURCC '{self.config.fourcc}' but camera "
|
|
f"negotiated '{st.fourcc}'."
|
|
)
|
|
|
|
def release(self) -> None:
|
|
self.stop_background()
|
|
with self._lock:
|
|
self._release_unlocked()
|
|
self._status.health = CaptureHealth.CLOSED
|
|
|
|
def close(self) -> None:
|
|
"""Stop background thread and release the capture handle."""
|
|
self.release()
|
|
|
|
def _release_unlocked(self) -> None:
|
|
if self._cap is not None:
|
|
try:
|
|
self._cap.release()
|
|
except Exception as exc:
|
|
# A failed release is not fatal, but it must not be invisible: the
|
|
# handle below is dropped either way, so a live-camera handle that
|
|
# OpenCV refused to free would otherwise leak silently until the
|
|
# process exits. Report it and keep tearing down.
|
|
logger.warning(
|
|
"capture: %r.release() failed: %s", self._backend_name or "capture", exc
|
|
)
|
|
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:
|
|
cap = self._cap
|
|
if cap is None:
|
|
return
|
|
try:
|
|
width, height, fps, fourcc = _read_stream_meta(cap)
|
|
self._status.width = width
|
|
self._status.height = height
|
|
self._status.fps = fps
|
|
self._status.fourcc = fourcc
|
|
except Exception as exc:
|
|
# Metadata is advisory: a backend that cannot answer these get() calls
|
|
# still delivers frames, so do not fail the open. Record why the
|
|
# reported stream geometry is left at its previous/default values.
|
|
logger.warning("capture: could not read stream metadata: %s", exc)
|
|
|
|
def warmup_frames(self, count: int = 3) -> None:
|
|
"""Discard frames so the driver/format stabilizes (sync reads only)."""
|
|
if self._background_running:
|
|
return
|
|
for _ in range(max(0, int(count))):
|
|
self.read_raw()
|
|
|
|
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 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 None
|
|
if not ok or frame is None:
|
|
with self._lock:
|
|
self._status.consecutive_failures += 1
|
|
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 = host_mono_ns / 1e9
|
|
if self._status.health != CaptureHealth.CONNECTED:
|
|
self._status.health = CaptureHealth.CONNECTED
|
|
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,
|
|
*,
|
|
copy: bool = True,
|
|
wait_s: float = 0.0,
|
|
) -> tuple[bool, np.ndarray | None]:
|
|
"""Return the newest frame from the background thread, or read sync."""
|
|
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."""
|
|
wait = wait_s if wait_s > 0 else 1.0
|
|
return self.read_latest(copy=copy, wait_s=wait)
|
|
|
|
def start_background(self) -> None:
|
|
"""Start a daemon thread that keeps only the latest frame."""
|
|
if self._thread is not None:
|
|
return
|
|
if not self.is_open():
|
|
self.open()
|
|
self._background_running = True
|
|
self._thread = threading.Thread(
|
|
target=self._background_loop,
|
|
daemon=True,
|
|
name="orisys-capture",
|
|
)
|
|
self._thread.start()
|
|
if self.config.verbose:
|
|
print("Capture background thread started.")
|
|
|
|
def start(self) -> None:
|
|
"""Compat alias for ``start_background``."""
|
|
self.start_background()
|
|
|
|
def stop_background(self, timeout: float = 1.0) -> None:
|
|
self._background_running = False
|
|
if self._thread is not None:
|
|
self._thread.join(timeout=timeout)
|
|
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``."""
|
|
self.stop_background(timeout=timeout)
|
|
|
|
def _background_loop(self) -> None:
|
|
while self._background_running:
|
|
if self.should_reconnect():
|
|
if not self.reconnect():
|
|
time.sleep(0.05)
|
|
if self.status.health == CaptureHealth.FAILED:
|
|
time.sleep(0.2)
|
|
continue
|
|
|
|
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_captured is not None
|
|
and self._latest_captured.sequence > self._last_delivered_seq
|
|
):
|
|
with self._lock:
|
|
self._status.dropped_frames += 1
|
|
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()
|
|
|
|
def note_published(self, seq: int) -> None:
|
|
with self._lock:
|
|
self._status.last_seq = int(seq)
|
|
|
|
def note_dropped(self, n: int = 1) -> None:
|
|
with self._lock:
|
|
self._status.dropped_frames += int(n)
|
|
|
|
def should_reconnect(self) -> bool:
|
|
policy = self.config.reconnect
|
|
if not policy.enabled:
|
|
return False
|
|
with self._lock:
|
|
if self._status.health == CaptureHealth.CLOSED:
|
|
return False
|
|
if not self.is_open():
|
|
return True
|
|
if self._status.consecutive_failures >= max(1, policy.fail_threshold):
|
|
return True
|
|
if policy.stale_timeout_s is not None and self._status.last_frame_time is not None:
|
|
age = time.perf_counter() - self._status.last_frame_time
|
|
if age >= policy.stale_timeout_s and self._status.health == CaptureHealth.CONNECTED:
|
|
return True
|
|
return False
|
|
|
|
def reconnect(self) -> bool:
|
|
"""Attempt to reopen the capture. Returns True on success."""
|
|
policy: ReconnectPolicy = self.config.reconnect
|
|
if not policy.enabled:
|
|
return False
|
|
if policy.max_attempts is not None and self._reconnect_attempt >= policy.max_attempts:
|
|
with self._lock:
|
|
self._status.health = CaptureHealth.FAILED
|
|
self._status.last_error = "Reconnect attempts exhausted"
|
|
return False
|
|
|
|
with self._lock:
|
|
self._status.health = CaptureHealth.RECONNECTING
|
|
self._status.reconnect_attempts += 1
|
|
attempt = self._reconnect_attempt
|
|
self._reconnect_attempt += 1
|
|
self._release_unlocked()
|
|
|
|
delay = policy.next_backoff(attempt)
|
|
if self.config.verbose:
|
|
print(
|
|
f"Reconnecting to {self.config.source!r} "
|
|
f"(attempt {attempt + 1}, wait {delay:.2f}s)..."
|
|
)
|
|
time.sleep(delay)
|
|
try:
|
|
self.open()
|
|
return True
|
|
except Exception as exc:
|
|
with self._lock:
|
|
self._status.last_error = str(exc)
|
|
if policy.max_attempts is not None and self._reconnect_attempt >= policy.max_attempts:
|
|
self._status.health = CaptureHealth.FAILED
|
|
else:
|
|
self._status.health = CaptureHealth.RECONNECTING
|
|
return False
|
|
|
|
|
|
# Backward-compatible alias for one release cycle.
|
|
CaptureWorker = CaptureSource
|
|
|
|
|
|
def open_capture(
|
|
config: CaptureConfig,
|
|
*,
|
|
factory=None,
|
|
start_background: bool | None = None,
|
|
start_worker: bool | None = None,
|
|
) -> CaptureSource:
|
|
"""Open a capture source and optionally start its background thread.
|
|
|
|
For live cameras, the background thread starts by default (``config.background``).
|
|
File/video sources stay synchronous unless ``start_background=True``.
|
|
"""
|
|
source = CaptureSource(config, factory=factory)
|
|
source.open()
|
|
should_start = config.background
|
|
if start_background is not None:
|
|
should_start = start_background
|
|
elif start_worker is not None:
|
|
should_start = start_worker
|
|
live = is_live_camera(normalize_source(config.source))
|
|
if should_start and live:
|
|
source.start_background()
|
|
return source
|