added ruff code reivew

This commit is contained in:
ykcheungab
2026-09-27 19:21:55 +08:00
parent 07da372204
commit e2ac36269c
110 changed files with 890 additions and 426 deletions
+10 -10
View File
@@ -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",
]
+18 -17
View File
@@ -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
+47 -25
View File
@@ -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:
+36 -24
View File
@@ -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.