Files
orisys-sdk/src/orisys/capture/source.py
T
2026-08-23 15:53:02 +08:00

350 lines
12 KiB
Python

"""OpenCV-backed capture source with optional reconnect."""
from __future__ import annotations
import os
import sys
import time
from typing import Any, Optional, Protocol, runtime_checkable
import cv2
import numpy as np
from .config import (
CaptureConfig,
CaptureHealth,
CaptureStatus,
ReconnectPolicy,
SourceType,
)
@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 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)
# Only strip trailing separators (Windows path quirk); keep leading '/'.
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", ""):
# Unknown preference: fall through to platform defaults.
pass
if not candidates:
if sys.platform == "win32":
# Prefer DSHOW to avoid MSMF HW-transform stalls on many USB cameras.
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: Optional[str]) -> Optional[int]:
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 open_opencv_capture(
source: SourceType,
*,
backend: str = "auto",
width: Optional[int] = None,
height: Optional[int] = None,
fps: Optional[float] = None,
fourcc: Optional[str] = "MJPG",
apply_stream_props: bool = True,
) -> tuple[VideoCaptureLike, str]:
"""Open a ``cv2.VideoCapture`` trying preferred backends in order.
Returns ``(capture, backend_name)``.
"""
# Avoid MSMF HW transform stalls on Windows camera capture.
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."
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:
code = _fourcc_code(fourcc)
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))
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))
return cap, backend_name
raise RuntimeError(last_error)
class CaptureSource:
"""Owns a video capture handle, health state, 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: Optional[VideoCaptureLike] = None
self._backend_name = ""
self._status = CaptureStatus(source=normalize_source(config.source))
self._reconnect_attempt = 0
self._lock = __import__("threading").Lock()
@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)),
)
@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) -> Optional[VideoCaptureLike]:
"""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}"
)
def release(self) -> None:
with self._lock:
self._release_unlocked()
self._status.health = CaptureHealth.CLOSED
def _release_unlocked(self) -> None:
if self._cap is not None:
try:
self._cap.release()
except Exception:
pass
self._cap = None
def _refresh_stream_meta_unlocked(self) -> None:
cap = self._cap
if cap is None:
return
try:
self._status.width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
self._status.height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
self._status.fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0)
self._status.fourcc = _decode_fourcc(cap.get(cv2.CAP_PROP_FOURCC) or 0)
except Exception:
pass
def read_raw(self) -> tuple[bool, Optional[np.ndarray]]:
"""Read one frame from the underlying capture (no reconnect)."""
cap = self._cap
if cap is None:
return False, 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
if not ok or frame is None:
with self._lock:
self._status.consecutive_failures += 1
return False, None
with self._lock:
self._status.consecutive_failures = 0
self._status.last_frame_time = time.perf_counter()
if self._status.health != CaptureHealth.CONNECTED:
self._status.health = CaptureHealth.CONNECTED
return True, frame
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} (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