Files
duck-preview/app/camera/camera_enumerator.py

133 lines
4.6 KiB
Python

"""Camera enumeration — discovers available video input devices."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from PySide6.QtMultimedia import QCameraDevice, QMediaDevices, QVideoFrameFormat
logger = logging.getLogger(__name__)
# Map Qt pixel format enum → human-readable string
_PIXEL_FORMAT_NAMES: dict[QVideoFrameFormat.PixelFormat, str] = {
QVideoFrameFormat.PixelFormat.Format_BGRA8888: "BGRA",
QVideoFrameFormat.PixelFormat.Format_BGRX8888: "BGRX",
QVideoFrameFormat.PixelFormat.Format_ABGR8888: "ABGR",
QVideoFrameFormat.PixelFormat.Format_ARGB8888: "ARGB",
QVideoFrameFormat.PixelFormat.Format_RGBA8888: "RGBA",
QVideoFrameFormat.PixelFormat.Format_RGBX8888: "RGBX",
QVideoFrameFormat.PixelFormat.Format_YUV420P: "YUV420P",
QVideoFrameFormat.PixelFormat.Format_YUV422P: "YUV422P",
QVideoFrameFormat.PixelFormat.Format_NV12: "NV12",
QVideoFrameFormat.PixelFormat.Format_NV21: "NV21",
QVideoFrameFormat.PixelFormat.Format_UYVY: "UYVY",
QVideoFrameFormat.PixelFormat.Format_YUYV: "YUY2",
QVideoFrameFormat.PixelFormat.Format_Jpeg: "MJPG",
QVideoFrameFormat.PixelFormat.Format_Y8: "GRAY8",
QVideoFrameFormat.PixelFormat.Format_Y16: "GRAY16",
}
def pixel_format_name(fmt: QVideoFrameFormat.PixelFormat) -> str:
"""Return a short human-readable name for a Qt pixel format."""
return _PIXEL_FORMAT_NAMES.get(fmt, f"fmt_{fmt.value if hasattr(fmt, 'value') else fmt}")
@dataclass
class CameraFormat:
"""One supported video format entry for a camera device."""
width: int
height: int
max_fps: float
pixel_format: str # e.g. "MJPG", "YUY2", "NV12", "BGRA"
def __str__(self) -> str:
return f"{self.pixel_format:6s} {self.width}x{self.height} @ {self.max_fps:.1f} fps"
@dataclass
class CameraInfo:
"""Lightweight descriptor of a detected camera."""
device: QCameraDevice
name: str
id: str
formats: list[CameraFormat] = field(default_factory=list)
def __str__(self) -> str:
return f"{self.name} [{self.id}]"
class CameraEnumerator:
"""Discovers available video input devices via QMediaDevices."""
@staticmethod
def list_cameras() -> list[CameraInfo]:
"""Return all available camera devices with their supported formats."""
devices = QMediaDevices.videoInputs()
cameras: list[CameraInfo] = []
for device in devices:
formats: list[CameraFormat] = []
for qfmt in device.videoFormats():
res = qfmt.resolution()
formats.append(CameraFormat(
width=res.width(),
height=res.height(),
max_fps=qfmt.maxFrameRate(),
pixel_format=pixel_format_name(qfmt.pixelFormat()),
))
# deduplicate; sort: largest area first, then fps descending
seen: set[tuple[int, int, float, str]] = set()
unique: list[CameraFormat] = []
for f in sorted(
formats,
key=lambda x: (x.width * x.height, x.max_fps),
reverse=True,
):
key = (f.width, f.height, f.max_fps, f.pixel_format)
if key not in seen:
seen.add(key)
unique.append(f)
cam_id = CameraEnumerator._device_id(device)
cameras.append(CameraInfo(
device=device,
name=device.description(),
id=cam_id,
formats=unique,
))
logger.info("Cameras found: %d", len(cameras))
for idx, cam in enumerate(cameras):
logger.info(" [%d] %s (id: %s)", idx, cam.name, cam.id)
for fmt in cam.formats:
logger.info(" %s", fmt)
return cameras
@staticmethod
def default_camera() -> CameraInfo | None:
"""Return the system default camera, or None if no camera is available."""
device = QMediaDevices.defaultVideoInput()
if device.isNull():
return None
default_id = CameraEnumerator._device_id(device)
for cam in CameraEnumerator.list_cameras():
if cam.id == default_id:
return cam
cameras = CameraEnumerator.list_cameras()
return cameras[0] if cameras else None
@staticmethod
def _device_id(device: QCameraDevice) -> str:
raw = device.id()
if hasattr(raw, "toStdString"):
return raw.toStdString()
return raw.data().decode("utf-8", errors="replace")