added ruff code reivew
This commit is contained in:
@@ -25,21 +25,21 @@ from .source import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MAX_SIMULTANEOUS_CAPTURES",
|
||||
"DEFAULT_MAX_CAMERA_INDEX",
|
||||
"DEFAULT_LAPTOP_WEBCAM_PATTERNS",
|
||||
"DEFAULT_MAX_CAMERA_INDEX",
|
||||
"MAX_SIMULTANEOUS_CAPTURES",
|
||||
"CameraFilter",
|
||||
"CameraInfo",
|
||||
"CaptureConfig",
|
||||
"CaptureHealth",
|
||||
"CaptureStatus",
|
||||
"ReconnectPolicy",
|
||||
"CaptureSource",
|
||||
"CaptureStatus",
|
||||
"CaptureWorker",
|
||||
"CameraInfo",
|
||||
"CameraFilter",
|
||||
"list_cameras",
|
||||
"pick_default_camera",
|
||||
"normalize_source",
|
||||
"ReconnectPolicy",
|
||||
"is_live_camera",
|
||||
"open_opencv_capture",
|
||||
"list_cameras",
|
||||
"normalize_source",
|
||||
"open_capture",
|
||||
"open_opencv_capture",
|
||||
"pick_default_camera",
|
||||
]
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Optional, Sequence, Tuple, Union
|
||||
from typing import Any, Union
|
||||
|
||||
MAX_SIMULTANEOUS_CAPTURES = 10
|
||||
|
||||
@@ -44,11 +45,11 @@ class ReconnectPolicy:
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
max_attempts: Optional[int] = None
|
||||
max_attempts: int | None = None
|
||||
initial_backoff_s: float = 0.25
|
||||
max_backoff_s: float = 5.0
|
||||
fail_threshold: int = 5
|
||||
stale_timeout_s: Optional[float] = 2.0
|
||||
stale_timeout_s: float | None = 2.0
|
||||
|
||||
def next_backoff(self, attempt: int) -> float:
|
||||
"""Exponential backoff capped at ``max_backoff_s`` (attempt is 0-based)."""
|
||||
@@ -62,19 +63,19 @@ class CaptureConfig:
|
||||
|
||||
source: SourceType
|
||||
backend: str = "auto"
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
fps: Optional[float] = None
|
||||
fourcc: Optional[str] = "MJPG"
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
fps: float | None = None
|
||||
fourcc: str | None = "MJPG"
|
||||
background: bool = True
|
||||
reconnect: ReconnectPolicy = field(default_factory=ReconnectPolicy)
|
||||
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
|
||||
name_include: Sequence[str] | None = None
|
||||
name_exclude: Sequence[str] | None = None
|
||||
vid_pid_allowlist: Sequence[tuple[int, int]] | None = None
|
||||
min_width: int | None = None
|
||||
min_height: int | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.reconnect, ReconnectPolicy):
|
||||
@@ -86,10 +87,10 @@ class CaptureConfig:
|
||||
source: SourceType,
|
||||
camera: dict[str, Any],
|
||||
*,
|
||||
reconnect: Optional[ReconnectPolicy] = None,
|
||||
reconnect: ReconnectPolicy | None = None,
|
||||
verbose: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> "CaptureConfig":
|
||||
) -> CaptureConfig:
|
||||
"""Build config from a sensor JSON ``camera`` section."""
|
||||
# Accept deprecated queue/worker kwargs from older callers without error.
|
||||
kwargs.pop("mode", None)
|
||||
@@ -117,9 +118,9 @@ 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
|
||||
source: SourceType | None = None
|
||||
last_error: str | None = None
|
||||
last_frame_time: float | None = None
|
||||
last_seq: int = 0
|
||||
dropped_frames: int = 0
|
||||
reconnect_attempts: int = 0
|
||||
|
||||
@@ -3,21 +3,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Iterable, Sequence
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MAX_CAMERA_INDEX = MAX_SIMULTANEOUS_CAPTURES
|
||||
|
||||
# Common built-in laptop webcam name fragments (case-insensitive).
|
||||
DEFAULT_LAPTOP_WEBCAM_PATTERNS: Tuple[str, ...] = (
|
||||
DEFAULT_LAPTOP_WEBCAM_PATTERNS: tuple[str, ...] = (
|
||||
r"integrated",
|
||||
r"built[- ]?in",
|
||||
r"laptop",
|
||||
@@ -49,8 +52,8 @@ class CameraInfo:
|
||||
height: int
|
||||
name: str = ""
|
||||
path: str = ""
|
||||
vendor_id: Optional[int] = None
|
||||
product_id: Optional[int] = None
|
||||
vendor_id: int | None = None
|
||||
product_id: int | None = None
|
||||
bus: str = ""
|
||||
|
||||
@property
|
||||
@@ -64,7 +67,7 @@ class CameraInfo:
|
||||
return f"{self.index} ({self.width}x{self.height}) [{self.backend_name}]"
|
||||
|
||||
@property
|
||||
def vid_pid(self) -> Optional[Tuple[int, int]]:
|
||||
def vid_pid(self) -> tuple[int, int] | None:
|
||||
if self.vendor_id is None or self.product_id is None:
|
||||
return None
|
||||
return (int(self.vendor_id), int(self.product_id))
|
||||
@@ -74,13 +77,13 @@ class CameraInfo:
|
||||
class CameraFilter:
|
||||
"""Optional filters for ``list_cameras`` / ``pick_default_camera``."""
|
||||
|
||||
name_include: Optional[Sequence[str]] = None
|
||||
name_exclude: Optional[Sequence[str]] = None
|
||||
name_include: Sequence[str] | None = None
|
||||
name_exclude: Sequence[str] | None = 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
|
||||
vid_pid_allowlist: Sequence[tuple[int, int]] | None = None
|
||||
min_width: int | None = None
|
||||
min_height: int | None = None
|
||||
prefer_named: bool = True
|
||||
|
||||
def matches(self, cam: CameraInfo) -> bool:
|
||||
@@ -143,8 +146,15 @@ def _suppress_opencv_noise():
|
||||
if set_level is not None and prev_level is not None:
|
||||
try:
|
||||
set_level(prev_level)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
# Restoring OpenCV's log level is best-effort, but a silent failure
|
||||
# here leaves probing noise suppressed for the rest of the process,
|
||||
# which hides exactly the diagnostics a failed enumeration needs.
|
||||
logger.warning(
|
||||
"capture discovery: could not restore OpenCV log level %r: %s",
|
||||
prev_level,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
def _linux_device_metadata(index: int) -> dict:
|
||||
@@ -152,7 +162,7 @@ def _linux_device_metadata(index: int) -> dict:
|
||||
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:
|
||||
with open(name_path, encoding="utf-8", errors="ignore") as fh:
|
||||
meta["name"] = fh.read().strip()
|
||||
except OSError:
|
||||
pass
|
||||
@@ -165,9 +175,9 @@ def _linux_device_metadata(index: int) -> dict:
|
||||
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:
|
||||
with open(vend, encoding="utf-8") as fh:
|
||||
meta["vendor_id"] = int(fh.read().strip(), 16)
|
||||
with open(prod, "r", encoding="utf-8") as fh:
|
||||
with open(prod, encoding="utf-8") as fh:
|
||||
meta["product_id"] = int(fh.read().strip(), 16)
|
||||
meta["bus"] = "usb"
|
||||
break
|
||||
@@ -229,7 +239,7 @@ def _windows_device_names() -> dict[int, dict]:
|
||||
return {}
|
||||
|
||||
|
||||
def _device_metadata(index: int, windows_names: Optional[dict[int, dict]] = None) -> dict:
|
||||
def _device_metadata(index: int, windows_names: dict[int, dict] | None = None) -> dict:
|
||||
if sys.platform.startswith("linux"):
|
||||
return _linux_device_metadata(index)
|
||||
if sys.platform == "win32" and windows_names is not None:
|
||||
@@ -238,7 +248,7 @@ def _device_metadata(index: int, windows_names: Optional[dict[int, dict]] = None
|
||||
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]:
|
||||
def _try_open(index: int, backend_name: str, backend_api: int, meta: dict) -> CameraInfo | None:
|
||||
cap = cv2.VideoCapture(index, backend_api)
|
||||
if not cap.isOpened():
|
||||
cap.release()
|
||||
@@ -272,13 +282,13 @@ def _try_open(index: int, backend_name: str, backend_api: int, meta: dict) -> Op
|
||||
def list_cameras(
|
||||
max_index: int = DEFAULT_MAX_CAMERA_INDEX,
|
||||
*,
|
||||
camera_filter: Optional[CameraFilter] = None,
|
||||
camera_filter: CameraFilter | None = 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,
|
||||
name_include: Sequence[str] | None = None,
|
||||
name_exclude: Sequence[str] | None = None,
|
||||
vid_pid_allowlist: Sequence[tuple[int, int]] | None = None,
|
||||
min_width: int | None = None,
|
||||
min_height: int | None = None,
|
||||
backend: str = "auto",
|
||||
) -> list[CameraInfo]:
|
||||
"""Probe indices ``0 .. max_index`` and return working cameras.
|
||||
@@ -302,10 +312,22 @@ def list_cameras(
|
||||
windows_names = _windows_device_names() if sys.platform == "win32" else None
|
||||
|
||||
with _suppress_opencv_noise():
|
||||
for index in range(0, max_index + 1):
|
||||
for index in range(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)
|
||||
try:
|
||||
info = _try_open(index, backend_name, backend_api, meta)
|
||||
except Exception as exc:
|
||||
# One bad backend/device must not abort the whole probe: an
|
||||
# escaping error here made every *other* camera invisible, not
|
||||
# just this index. Skip the candidate and keep enumerating.
|
||||
logger.warning(
|
||||
"capture discovery: probing index %d via %s failed: %s",
|
||||
index,
|
||||
backend_name,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
if info is None:
|
||||
continue
|
||||
if index in seen_indices:
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional, Protocol, runtime_checkable
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -19,6 +20,8 @@ from .config import (
|
||||
SourceType,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class VideoCaptureLike(Protocol):
|
||||
@@ -83,7 +86,7 @@ def _backend_candidates(preferred: str = "auto") -> list[tuple[str, int]]:
|
||||
return candidates
|
||||
|
||||
|
||||
def _fourcc_code(fourcc: Optional[str]) -> Optional[int]:
|
||||
def _fourcc_code(fourcc: str | None) -> int | None:
|
||||
if not fourcc:
|
||||
return None
|
||||
text = str(fourcc).strip()
|
||||
@@ -111,10 +114,10 @@ def _read_stream_meta(cap: VideoCaptureLike) -> tuple[int, int, float, str]:
|
||||
def _apply_stream_props(
|
||||
cap: VideoCaptureLike,
|
||||
*,
|
||||
width: Optional[int],
|
||||
height: Optional[int],
|
||||
fps: Optional[float],
|
||||
fourcc: Optional[str],
|
||||
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)
|
||||
@@ -143,10 +146,10 @@ 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",
|
||||
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.
|
||||
@@ -212,16 +215,16 @@ class CaptureSource:
|
||||
):
|
||||
self.config = config
|
||||
self._factory = factory or self._default_factory
|
||||
self._cap: Optional[VideoCaptureLike] = None
|
||||
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: Optional[np.ndarray] = None
|
||||
self._latest_frame: np.ndarray | None = None
|
||||
self._latest_seq = 0
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._background_running = False
|
||||
|
||||
@staticmethod
|
||||
@@ -238,7 +241,7 @@ class CaptureSource:
|
||||
|
||||
# Compatibility with older CaptureWorker API.
|
||||
@property
|
||||
def source(self) -> "CaptureSource":
|
||||
def source(self) -> CaptureSource:
|
||||
return self
|
||||
|
||||
@property
|
||||
@@ -265,7 +268,7 @@ class CaptureSource:
|
||||
)
|
||||
|
||||
@property
|
||||
def cap(self) -> Optional[VideoCaptureLike]:
|
||||
def cap(self) -> VideoCaptureLike | None:
|
||||
"""Raw capture handle (compat with Sensor.cap)."""
|
||||
return self._cap
|
||||
|
||||
@@ -324,8 +327,14 @@ class CaptureSource:
|
||||
if self._cap is not None:
|
||||
try:
|
||||
self._cap.release()
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
@@ -341,8 +350,11 @@ class CaptureSource:
|
||||
self._status.height = height
|
||||
self._status.fps = fps
|
||||
self._status.fourcc = fourcc
|
||||
except Exception:
|
||||
pass
|
||||
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)."""
|
||||
@@ -351,7 +363,7 @@ class CaptureSource:
|
||||
for _ in range(max(0, int(count))):
|
||||
self.read_raw()
|
||||
|
||||
def read_raw(self) -> tuple[bool, Optional[np.ndarray]]:
|
||||
def read_raw(self) -> tuple[bool, np.ndarray | None]:
|
||||
"""Read one frame directly from the underlying capture (no reconnect)."""
|
||||
cap = self._cap
|
||||
if cap is None:
|
||||
@@ -379,7 +391,7 @@ class CaptureSource:
|
||||
*,
|
||||
copy: bool = True,
|
||||
wait_s: float = 0.0,
|
||||
) -> tuple[bool, Optional[np.ndarray]]:
|
||||
) -> 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()
|
||||
@@ -397,7 +409,7 @@ class CaptureSource:
|
||||
frame = self._latest_frame.copy() if copy else self._latest_frame
|
||||
return True, frame
|
||||
|
||||
def read(self, *, copy: bool = True, wait_s: float = 1.0) -> tuple[bool, Optional[np.ndarray]]:
|
||||
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)
|
||||
@@ -531,8 +543,8 @@ def open_capture(
|
||||
config: CaptureConfig,
|
||||
*,
|
||||
factory=None,
|
||||
start_background: Optional[bool] = None,
|
||||
start_worker: Optional[bool] = None,
|
||||
start_background: bool | None = None,
|
||||
start_worker: bool | None = None,
|
||||
) -> CaptureSource:
|
||||
"""Open a capture source and optionally start its background thread.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user