refractor capture to a module

This commit is contained in:
ykcheungab
2026-08-23 15:53:02 +08:00
parent 4583dc3a40
commit 8a7760f12b
30 changed files with 1992 additions and 332 deletions
+55
View File
@@ -0,0 +1,55 @@
"""Capture subsystem: sources, buffering, reconnect, discovery, multi-camera."""
from .buffer import FrameBuffer, FramePacket
from .config import (
MAX_QUEUE_SIZE,
MAX_SIMULTANEOUS_CAPTURES,
CaptureConfig,
CaptureHealth,
CaptureMode,
CaptureStatus,
OverflowPolicy,
ReconnectPolicy,
)
from .discovery import (
DEFAULT_LAPTOP_WEBCAM_PATTERNS,
DEFAULT_MAX_CAMERA_INDEX,
CameraFilter,
CameraInfo,
list_cameras,
pick_default_camera,
)
from .manager import CaptureManager
from .source import (
CaptureSource,
is_live_camera,
normalize_source,
open_opencv_capture,
)
from .worker import CaptureWorker, open_capture
__all__ = [
"MAX_QUEUE_SIZE",
"MAX_SIMULTANEOUS_CAPTURES",
"DEFAULT_MAX_CAMERA_INDEX",
"DEFAULT_LAPTOP_WEBCAM_PATTERNS",
"CaptureConfig",
"CaptureHealth",
"CaptureMode",
"CaptureStatus",
"OverflowPolicy",
"ReconnectPolicy",
"FrameBuffer",
"FramePacket",
"CaptureSource",
"CaptureWorker",
"CaptureManager",
"CameraInfo",
"CameraFilter",
"list_cameras",
"pick_default_camera",
"normalize_source",
"is_live_camera",
"open_opencv_capture",
"open_capture",
]
+127
View File
@@ -0,0 +1,127 @@
"""Bounded frame transport for capture workers."""
from __future__ import annotations
import threading
import time
from collections import deque
from dataclasses import dataclass
from typing import Optional
import numpy as np
from .config import CaptureMode, OverflowPolicy
@dataclass
class FramePacket:
"""One captured frame with sequencing metadata."""
frame: np.ndarray
seq: int
timestamp: float
class FrameBuffer:
"""Thread-safe frame buffer supporting latest-only and bounded queue modes.
* ``latest``: always overwrite the single published frame (low latency).
* ``queue``: FIFO up to ``maxsize``; overflow controlled by ``OverflowPolicy``.
"""
def __init__(
self,
*,
mode: CaptureMode = CaptureMode.LATEST,
maxsize: int = 1,
overflow: OverflowPolicy = OverflowPolicy.DROP_OLDEST,
):
self.mode = CaptureMode(mode) if isinstance(mode, str) else mode
self.maxsize = max(1, int(maxsize))
self.overflow = OverflowPolicy(overflow) if isinstance(overflow, str) else overflow
self._lock = threading.Lock()
self._cond = threading.Condition(self._lock)
self._latest: Optional[FramePacket] = None
self._queue: deque[FramePacket] = deque()
self.dropped_frames = 0
self._seq = 0
def clear(self) -> None:
with self._cond:
self._latest = None
self._queue.clear()
def publish(self, frame: np.ndarray, *, timestamp: Optional[float] = None) -> int:
"""Publish a frame. Returns assigned sequence number."""
ts = time.perf_counter() if timestamp is None else float(timestamp)
with self._cond:
self._seq += 1
packet = FramePacket(frame=frame, seq=self._seq, timestamp=ts)
if self.mode == CaptureMode.LATEST:
self._latest = packet
self._cond.notify_all()
return packet.seq
if len(self._queue) >= self.maxsize:
if self.overflow == OverflowPolicy.DROP_NEWEST:
self.dropped_frames += 1
return packet.seq
if self.overflow == OverflowPolicy.BLOCK:
while len(self._queue) >= self.maxsize:
self._cond.wait(timeout=0.05)
# If still full after wait, drop oldest to avoid deadlock if
# consumer stopped; count as dropped.
if len(self._queue) >= self.maxsize:
self._queue.popleft()
self.dropped_frames += 1
break
else:
# drop_oldest
self._queue.popleft()
self.dropped_frames += 1
self._queue.append(packet)
self._latest = packet
self._cond.notify_all()
return packet.seq
def read_latest(self, *, copy: bool = True, wait_s: float = 0.0) -> tuple[bool, Optional[np.ndarray], int]:
"""Return the newest frame without consuming a queue slot."""
deadline = time.perf_counter() + max(0.0, wait_s)
with self._cond:
while self._latest is None:
remaining = deadline - time.perf_counter()
if remaining <= 0:
return False, None, 0
self._cond.wait(timeout=remaining)
packet = self._latest
frame = packet.frame.copy() if copy else packet.frame
return True, frame, packet.seq
def read_next(self, *, copy: bool = True, wait_s: float = 0.0) -> tuple[bool, Optional[np.ndarray], int]:
"""Pop the next queued frame (queue mode) or return latest (latest mode)."""
if self.mode == CaptureMode.LATEST:
return self.read_latest(copy=copy, wait_s=wait_s)
deadline = time.perf_counter() + max(0.0, wait_s)
with self._cond:
while not self._queue:
remaining = deadline - time.perf_counter()
if remaining <= 0:
return False, None, 0
self._cond.wait(timeout=remaining)
packet = self._queue.popleft()
self._cond.notify_all()
frame = packet.frame.copy() if copy else packet.frame
return True, frame, packet.seq
@property
def last_seq(self) -> int:
with self._lock:
return self._seq
@property
def pending(self) -> int:
with self._lock:
if self.mode == CaptureMode.LATEST:
return 0 if self._latest is None else 1
return len(self._queue)
+158
View File
@@ -0,0 +1,158 @@
"""Capture configuration, health state, and reconnect policy."""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Optional, Sequence, Tuple, Union
MAX_SIMULTANEOUS_CAPTURES = 10
MAX_QUEUE_SIZE = 10
DEFAULT_QUEUE_SIZE = 1
class CaptureMode(str, Enum):
"""Frame delivery mode for a live capture worker."""
LATEST = "latest"
QUEUE = "queue"
class OverflowPolicy(str, Enum):
"""Behavior when a bounded queue is full."""
DROP_OLDEST = "drop_oldest"
DROP_NEWEST = "drop_newest"
BLOCK = "block"
class CaptureHealth(str, Enum):
"""Lifecycle / health state for a capture source."""
CLOSED = "closed"
CONNECTING = "connecting"
CONNECTED = "connected"
RECONNECTING = "reconnecting"
FAILED = "failed"
SourceType = Union[int, str]
@dataclass(frozen=True)
class ReconnectPolicy:
"""Auto-reconnect settings for live cameras.
Parameters
----------
enabled:
When False, failed reads do not attempt reopen.
max_attempts:
Maximum reopen attempts per disconnect episode. ``None`` means unlimited.
initial_backoff_s:
First wait before a reconnect attempt.
max_backoff_s:
Upper bound for exponential backoff.
fail_threshold:
Consecutive failed reads that trigger reconnect.
stale_timeout_s:
If no frame arrives for this many seconds while connected, treat as stale
and reconnect. ``None`` disables stale detection.
"""
enabled: bool = False
max_attempts: Optional[int] = None
initial_backoff_s: float = 0.25
max_backoff_s: float = 5.0
fail_threshold: int = 5
stale_timeout_s: Optional[float] = 2.0
def next_backoff(self, attempt: int) -> float:
"""Exponential backoff capped at ``max_backoff_s`` (attempt is 0-based)."""
delay = self.initial_backoff_s * (2 ** max(0, attempt))
return min(delay, self.max_backoff_s)
@dataclass
class CaptureConfig:
"""Configuration for opening and running a capture source."""
source: SourceType
backend: str = "auto"
width: Optional[int] = None
height: Optional[int] = None
fps: Optional[float] = None
fourcc: Optional[str] = "MJPG"
mode: CaptureMode = CaptureMode.LATEST
queue_size: int = DEFAULT_QUEUE_SIZE
overflow: OverflowPolicy = OverflowPolicy.DROP_OLDEST
reconnect: ReconnectPolicy = field(default_factory=ReconnectPolicy)
start_worker: bool = True
verbose: bool = False
# Optional discovery filters (used by list/open helpers, not open itself).
name_include: Optional[Sequence[str]] = None
name_exclude: Optional[Sequence[str]] = None
vid_pid_allowlist: Optional[Sequence[Tuple[int, int]]] = None
min_width: Optional[int] = None
min_height: Optional[int] = None
def __post_init__(self) -> None:
if isinstance(self.mode, str):
self.mode = CaptureMode(self.mode)
if isinstance(self.overflow, str):
self.overflow = OverflowPolicy(self.overflow)
if not isinstance(self.reconnect, ReconnectPolicy):
raise TypeError("reconnect must be a ReconnectPolicy")
size = int(self.queue_size)
if size < 1 or size > MAX_QUEUE_SIZE:
raise ValueError(f"queue_size must be in 1..{MAX_QUEUE_SIZE}, got {size}")
self.queue_size = size
if self.mode == CaptureMode.LATEST:
# Latest mode keeps only one published frame regardless of queue_size.
self.queue_size = 1
@classmethod
def from_camera_dict(
cls,
source: SourceType,
camera: dict[str, Any],
*,
mode: CaptureMode | str = CaptureMode.LATEST,
reconnect: Optional[ReconnectPolicy] = None,
verbose: bool = False,
**kwargs: Any,
) -> "CaptureConfig":
"""Build config from a sensor JSON ``camera`` section."""
res = camera.get("resolution_raw") or [None, None]
width = int(res[0]) if res and res[0] else None
height = int(res[1]) if res and res[1] else None
fps = camera.get("fps")
return cls(
source=source,
width=width,
height=height,
fps=float(fps) if fps is not None else None,
mode=mode,
reconnect=reconnect or ReconnectPolicy(),
verbose=verbose,
**kwargs,
)
@dataclass
class CaptureStatus:
"""Snapshot of capture health for diagnostics / multi-camera UIs."""
health: CaptureHealth = CaptureHealth.CLOSED
source: Optional[SourceType] = None
last_error: Optional[str] = None
last_frame_time: Optional[float] = None
last_seq: int = 0
dropped_frames: int = 0
reconnect_attempts: int = 0
consecutive_failures: int = 0
width: int = 0
height: int = 0
fps: float = 0.0
backend_name: str = ""
fourcc: str = ""
+334
View File
@@ -0,0 +1,334 @@
"""Discover cameras with optional platform metadata and filters."""
from __future__ import annotations
import contextlib
import os
import re
import sys
from dataclasses import dataclass, field
from typing import Iterable, Optional, Sequence, Tuple
import cv2
from .config import MAX_SIMULTANEOUS_CAPTURES
from .source import _backend_candidates
DEFAULT_MAX_CAMERA_INDEX = MAX_SIMULTANEOUS_CAPTURES
# Common built-in laptop webcam name fragments (case-insensitive).
DEFAULT_LAPTOP_WEBCAM_PATTERNS: Tuple[str, ...] = (
r"integrated",
r"built[- ]?in",
r"laptop",
r"facetime",
r"ir\s*camera",
r"rgb\s*camera",
r"hd\s*webcam",
r"hd\s*camera",
r"user[- ]facing",
r"front\s*camera",
r"dell\s*webcam",
r"lenovo\s*easycamera",
r"hp\s*(truevision|webcam|hd)",
r"microsoft\s*life(cam|studio)",
r"realtek",
r"chicony",
r"sonix",
)
@dataclass(frozen=True)
class CameraInfo:
"""Description of a probed camera device."""
index: int
backend_name: str
backend_api: int
width: int
height: int
name: str = ""
path: str = ""
vendor_id: Optional[int] = None
product_id: Optional[int] = None
bus: str = ""
@property
def source(self) -> str:
return str(self.index)
def label(self) -> str:
name = self.name.strip()
if name:
return f"{self.index}: {name} ({self.width}x{self.height}) [{self.backend_name}]"
return f"{self.index} ({self.width}x{self.height}) [{self.backend_name}]"
@property
def vid_pid(self) -> Optional[Tuple[int, int]]:
if self.vendor_id is None or self.product_id is None:
return None
return (int(self.vendor_id), int(self.product_id))
@dataclass(frozen=True)
class CameraFilter:
"""Optional filters for ``list_cameras`` / ``pick_default_camera``."""
name_include: Optional[Sequence[str]] = None
name_exclude: Optional[Sequence[str]] = None
exclude_laptop_webcams: bool = False
laptop_patterns: Sequence[str] = field(default_factory=lambda: DEFAULT_LAPTOP_WEBCAM_PATTERNS)
vid_pid_allowlist: Optional[Sequence[Tuple[int, int]]] = None
min_width: Optional[int] = None
min_height: Optional[int] = None
prefer_named: bool = True
def matches(self, cam: CameraInfo) -> bool:
name = (cam.name or "").lower()
if self.min_width is not None and cam.width < int(self.min_width):
return False
if self.min_height is not None and cam.height < int(self.min_height):
return False
if self.vid_pid_allowlist:
allowed = {(int(v), int(p)) for v, p in self.vid_pid_allowlist}
if cam.vid_pid is None or cam.vid_pid not in allowed:
return False
if self.name_include:
if not any(re.search(pat, name, re.I) for pat in self.name_include):
# If name unknown, keep the device unless prefer_named forces drop.
if name:
return False
if self.prefer_named:
return False
if self.name_exclude:
if name and any(re.search(pat, name, re.I) for pat in self.name_exclude):
return False
if self.exclude_laptop_webcams and name:
if any(re.search(pat, name, re.I) for pat in self.laptop_patterns):
return False
return True
@contextlib.contextmanager
def _suppress_opencv_noise():
"""Hide OpenCV VideoCapture probe warnings (stderr + OpenCV log)."""
prev_level = None
set_level = None
try:
logging = getattr(getattr(cv2, "utils", None), "logging", None)
if logging is not None:
set_level = getattr(logging, "setLogLevel", None)
get_level = getattr(logging, "getLogLevel", None)
silent = getattr(logging, "LOG_LEVEL_SILENT", 0)
if set_level is not None:
if get_level is not None:
prev_level = get_level()
set_level(silent)
except Exception:
prev_level = None
devnull = open(os.devnull, "w")
try:
with contextlib.redirect_stderr(devnull):
stderr_fd = sys.stderr.fileno()
saved_fd = os.dup(stderr_fd)
try:
os.dup2(devnull.fileno(), stderr_fd)
yield
finally:
os.dup2(saved_fd, stderr_fd)
os.close(saved_fd)
finally:
devnull.close()
if set_level is not None and prev_level is not None:
try:
set_level(prev_level)
except Exception:
pass
def _linux_device_metadata(index: int) -> dict:
"""Best-effort V4L2 name / USB VID:PID from sysfs."""
meta = {"name": "", "path": f"/dev/video{index}", "vendor_id": None, "product_id": None, "bus": ""}
name_path = f"/sys/class/video4linux/video{index}/name"
try:
with open(name_path, "r", encoding="utf-8", errors="ignore") as fh:
meta["name"] = fh.read().strip()
except OSError:
pass
# Walk up to a USB device directory containing idVendor/idProduct.
device_link = f"/sys/class/video4linux/video{index}/device"
try:
cur = os.path.realpath(device_link)
for _ in range(8):
vend = os.path.join(cur, "idVendor")
prod = os.path.join(cur, "idProduct")
if os.path.isfile(vend) and os.path.isfile(prod):
with open(vend, "r", encoding="utf-8") as fh:
meta["vendor_id"] = int(fh.read().strip(), 16)
with open(prod, "r", encoding="utf-8") as fh:
meta["product_id"] = int(fh.read().strip(), 16)
meta["bus"] = "usb"
break
parent = os.path.dirname(cur)
if parent == cur:
break
cur = parent
except (OSError, ValueError):
pass
return meta
def _windows_device_names() -> dict[int, dict]:
"""Best-effort mapping of camera index → name / hardware id via PowerShell.
Index order is not guaranteed to match OpenCV; names are matched later by
probing order best-effort. Returns ``{ordinal: meta}`` in enumeration order.
"""
if sys.platform != "win32":
return {}
try:
import json
import subprocess
# Camera devices from PnP; keep this soft-fail for machines without cameras.
cmd = [
"powershell",
"-NoProfile",
"-Command",
(
"Get-PnpDevice -Class Camera,Image -Status OK -ErrorAction SilentlyContinue | "
"Select-Object FriendlyName, InstanceId | ConvertTo-Json -Compress"
),
]
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=5, check=False)
if proc.returncode != 0 or not proc.stdout.strip():
return {}
data = json.loads(proc.stdout)
if isinstance(data, dict):
data = [data]
out: dict[int, dict] = {}
for i, item in enumerate(data):
name = str(item.get("FriendlyName") or "").strip()
instance = str(item.get("InstanceId") or "")
vid = pid = None
m = re.search(r"VID_([0-9A-Fa-f]{4}).*PID_([0-9A-Fa-f]{4})", instance)
if m:
vid = int(m.group(1), 16)
pid = int(m.group(2), 16)
out[i] = {
"name": name,
"path": "",
"vendor_id": vid,
"product_id": pid,
"bus": "usb" if vid is not None else "",
}
return out
except Exception:
return {}
def _device_metadata(index: int, windows_names: Optional[dict[int, dict]] = None) -> dict:
if sys.platform.startswith("linux"):
return _linux_device_metadata(index)
if sys.platform == "win32" and windows_names is not None:
# Best-effort: OpenCV index often matches enumeration order, but not always.
return windows_names.get(index, {"name": "", "path": "", "vendor_id": None, "product_id": None, "bus": ""})
return {"name": "", "path": "", "vendor_id": None, "product_id": None, "bus": ""}
def _try_open(index: int, backend_name: str, backend_api: int, meta: dict) -> Optional[CameraInfo]:
cap = cv2.VideoCapture(index, backend_api)
if not cap.isOpened():
cap.release()
return None
ok, frame = cap.read()
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
if not ok or frame is None:
if width <= 0 or height <= 0:
cap.release()
return None
else:
h, w = frame.shape[:2]
width = width or int(w)
height = height or int(h)
cap.release()
return CameraInfo(
index=index,
backend_name=backend_name,
backend_api=backend_api,
width=width,
height=height,
name=str(meta.get("name") or ""),
path=str(meta.get("path") or ""),
vendor_id=meta.get("vendor_id"),
product_id=meta.get("product_id"),
bus=str(meta.get("bus") or ""),
)
def list_cameras(
max_index: int = DEFAULT_MAX_CAMERA_INDEX,
*,
camera_filter: Optional[CameraFilter] = None,
exclude_laptop_webcams: bool = False,
name_include: Optional[Sequence[str]] = None,
name_exclude: Optional[Sequence[str]] = None,
vid_pid_allowlist: Optional[Sequence[Tuple[int, int]]] = None,
min_width: Optional[int] = None,
min_height: Optional[int] = None,
backend: str = "auto",
) -> list[CameraInfo]:
"""Probe indices ``0 .. max_index`` and return working cameras.
Laptop webcam filtering is best-effort and requires device names from the
OS. When names are unavailable, devices are kept unless other filters reject
them. Pass ``exclude_laptop_webcams=True`` or a ``CameraFilter`` to opt in.
"""
filt = camera_filter or CameraFilter(
name_include=name_include,
name_exclude=name_exclude,
exclude_laptop_webcams=exclude_laptop_webcams,
vid_pid_allowlist=vid_pid_allowlist,
min_width=min_width,
min_height=min_height,
)
found: list[CameraInfo] = []
seen_indices: set[int] = set()
backends = _backend_candidates(backend)
windows_names = _windows_device_names() if sys.platform == "win32" else None
with _suppress_opencv_noise():
for index in range(0, max_index + 1):
meta = _device_metadata(index, windows_names)
for backend_name, backend_api in backends:
info = _try_open(index, backend_name, backend_api, meta)
if info is None:
continue
if index in seen_indices:
break
if not filt.matches(info):
seen_indices.add(index)
break
found.append(info)
seen_indices.add(index)
break
return found
def pick_default_camera(
cameras: Iterable[CameraInfo],
preferred: str | None = None,
) -> str | None:
cameras = list(cameras)
if not cameras:
return None
preferred = (preferred or "").strip()
if preferred:
for cam in cameras:
if cam.source == preferred:
return cam.source
return cameras[0].source
+122
View File
@@ -0,0 +1,122 @@
"""Manage up to N simultaneous capture sources."""
from __future__ import annotations
from typing import Dict, List, Optional, Sequence
from .config import (
MAX_SIMULTANEOUS_CAPTURES,
CaptureConfig,
CaptureStatus,
SourceType,
)
from .source import normalize_source
from .worker import CaptureWorker, open_capture
class CaptureManager:
"""Own multiple ``CaptureWorker`` instances (default max 10).
Each camera is isolated: one failed USB device does not stop the others.
Hardware USB bandwidth may still limit how many high-res streams work in
practice even when this manager allows up to ``max_sources`` software slots.
"""
def __init__(self, *, max_sources: int = MAX_SIMULTANEOUS_CAPTURES):
if max_sources < 1 or max_sources > MAX_SIMULTANEOUS_CAPTURES:
raise ValueError(
f"max_sources must be in 1..{MAX_SIMULTANEOUS_CAPTURES}, got {max_sources}"
)
self.max_sources = int(max_sources)
self._workers: Dict[str, CaptureWorker] = {}
def __len__(self) -> int:
return len(self._workers)
def __contains__(self, key: SourceType) -> bool:
return self._key(key) in self._workers
@staticmethod
def _key(source: SourceType) -> str:
return str(normalize_source(source))
@property
def sources(self) -> List[str]:
return list(self._workers.keys())
def open(self, config: CaptureConfig, *, factory=None) -> CaptureWorker:
"""Open one capture; raises if capacity exceeded or source already open."""
key = self._key(config.source)
if key in self._workers:
raise RuntimeError(f"Capture already open for source {key!r}")
if len(self._workers) >= self.max_sources:
raise RuntimeError(
f"CaptureManager at capacity ({self.max_sources}). "
"Close a source before opening another."
)
worker = open_capture(config, factory=factory)
self._workers[key] = worker
return worker
def open_many(
self,
configs: Sequence[CaptureConfig],
*,
factory=None,
) -> List[CaptureWorker]:
"""Open several captures atomically (rolls back on failure)."""
if len(configs) + len(self._workers) > self.max_sources:
raise RuntimeError(
f"Opening {len(configs)} sources would exceed capacity "
f"{self.max_sources} (currently {len(self._workers)} open)."
)
opened: List[CaptureWorker] = []
try:
for cfg in configs:
opened.append(self.open(cfg, factory=factory))
except Exception:
for worker in opened:
key = self._key(worker.source.config.source)
worker.close()
self._workers.pop(key, None)
raise
return opened
def get(self, source: SourceType) -> Optional[CaptureWorker]:
return self._workers.get(self._key(source))
def read_latest(self, source: SourceType, *, wait_s: float = 0.0):
worker = self.get(source)
if worker is None:
return False, None
return worker.buffer.read_latest(wait_s=wait_s)[:2]
def read_latest_all(self, *, wait_s: float = 0.0) -> Dict[str, tuple]:
"""Return ``{source_key: (ok, frame)}`` for every open capture."""
out: Dict[str, tuple] = {}
for key, worker in self._workers.items():
ok, frame, _seq = worker.buffer.read_latest(wait_s=wait_s)
out[key] = (ok, frame)
return out
def status_all(self) -> Dict[str, CaptureStatus]:
return {key: worker.source.status for key, worker in self._workers.items()}
def close(self, source: SourceType) -> None:
key = self._key(source)
worker = self._workers.pop(key, None)
if worker is not None:
worker.close()
def close_all(self) -> None:
keys = list(self._workers.keys())
for key in keys:
worker = self._workers.pop(key, None)
if worker is not None:
worker.close()
def __enter__(self) -> "CaptureManager":
return self
def __exit__(self, exc_type, exc, tb) -> None:
self.close_all()
+349
View File
@@ -0,0 +1,349 @@
"""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
+124
View File
@@ -0,0 +1,124 @@
"""Background capture worker that feeds a FrameBuffer."""
from __future__ import annotations
import threading
import time
from typing import Optional
from .buffer import FrameBuffer
from .config import CaptureConfig, CaptureHealth, CaptureMode
from .source import CaptureSource, is_live_camera, normalize_source
class CaptureWorker:
"""One thread per live camera: read → publish → optional reconnect."""
def __init__(
self,
source: CaptureSource,
buffer: Optional[FrameBuffer] = None,
):
self.source = source
self.config: CaptureConfig = source.config
self.buffer = buffer or FrameBuffer(
mode=self.config.mode,
maxsize=self.config.queue_size,
overflow=self.config.overflow,
)
self._thread: Optional[threading.Thread] = None
self._running = False
self._last_consumed_seq = -1
@property
def running(self) -> bool:
return self._running
def start(self) -> None:
if self._thread is not None:
return
if not self.source.is_open():
self.source.open()
self._running = True
self._thread = threading.Thread(target=self._loop, daemon=True, name="orisys-capture")
self._thread.start()
if self.config.verbose:
mode = self.config.mode.value if isinstance(self.config.mode, CaptureMode) else self.config.mode
print(f"Capture worker started (mode={mode}).")
def stop(self, timeout: float = 1.0) -> None:
self._running = False
if self._thread is not None:
self._thread.join(timeout=timeout)
self._thread = None
self.buffer.clear()
def close(self) -> None:
self.stop()
self.source.release()
def read(self, *, copy: bool = True, wait_s: float = 1.0) -> tuple[bool, object]:
"""Read according to configured mode; mirrors Sensor._read_frame semantics."""
if not self._running:
ok, frame = self.source.read_raw()
return ok, frame
if self.config.mode == CaptureMode.QUEUE:
ok, frame, seq = self.buffer.read_next(copy=copy, wait_s=wait_s)
else:
# Startup race: wait briefly for first frame.
wait = wait_s if wait_s > 0 else 1.0
ok, frame, seq = self.buffer.read_latest(copy=copy, wait_s=wait)
if ok:
self._last_consumed_seq = seq
return ok, frame
def _loop(self) -> None:
while self._running:
if self.source.should_reconnect():
if not self.source.reconnect():
# Back off a bit before retrying the should_reconnect check.
time.sleep(0.05)
if self.source.status.health == CaptureHealth.FAILED:
time.sleep(0.2)
continue
ok, frame = self.source.read_raw()
if not ok or frame is None:
if self.source.should_reconnect():
continue
time.sleep(0.001)
continue
before_dropped = self.buffer.dropped_frames
seq = self.buffer.publish(frame)
after_dropped = self.buffer.dropped_frames
if after_dropped > before_dropped:
self.source.note_dropped(after_dropped - before_dropped)
self.source.note_published(seq)
def open_capture(
config: CaptureConfig,
*,
factory=None,
start_worker: Optional[bool] = None,
) -> CaptureWorker:
"""Open a capture source and optionally start its worker thread.
For live cameras, the worker starts by default (``config.start_worker``).
File/video sources stay synchronous unless ``start_worker=True``.
"""
source = CaptureSource(config, factory=factory)
source.open()
buffer = FrameBuffer(
mode=config.mode,
maxsize=config.queue_size,
overflow=config.overflow,
)
worker = CaptureWorker(source, buffer)
should_start = config.start_worker if start_worker is None else start_worker
live = is_live_camera(normalize_source(config.source))
if should_start and live:
worker.start()
return worker