"""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")