Files
orisys-sdk/src/orisys/capture/discovery.py
T
2026-09-27 19:21:55 +08:00

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