357 lines
12 KiB
Python
357 lines
12 KiB
Python
"""Discover cameras with optional platform metadata and filters."""
|
|
|
|
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
|
|
|
|
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, ...] = (
|
|
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: int | None = None
|
|
product_id: int | None = 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) -> 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))
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CameraFilter:
|
|
"""Optional filters for ``list_cameras`` / ``pick_default_camera``."""
|
|
|
|
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: 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:
|
|
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 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:
|
|
"""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, 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, encoding="utf-8") as fh:
|
|
meta["vendor_id"] = int(fh.read().strip(), 16)
|
|
with open(prod, 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: 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:
|
|
# 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) -> CameraInfo | None:
|
|
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: CameraFilter | None = None,
|
|
exclude_laptop_webcams: bool = False,
|
|
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.
|
|
|
|
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(max_index + 1):
|
|
meta = _device_metadata(index, windows_names)
|
|
for backend_name, backend_api in backends:
|
|
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:
|
|
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
|