feat: implement logging setup and CSV telemetry logging for performance metrics
This commit is contained in:
@@ -2,9 +2,49 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from PySide6.QtMultimedia import QCameraDevice, QMediaDevices
|
||||
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
|
||||
@@ -14,8 +54,7 @@ class CameraInfo:
|
||||
device: QCameraDevice
|
||||
name: str
|
||||
id: str
|
||||
formats: list[tuple[int, int, float]] = field(default_factory=list)
|
||||
# formats: list of (width, height, max_fps)
|
||||
formats: list[CameraFormat] = field(default_factory=list)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} [{self.id}]"
|
||||
@@ -31,30 +70,42 @@ class CameraEnumerator:
|
||||
cameras: list[CameraInfo] = []
|
||||
|
||||
for device in devices:
|
||||
formats: list[tuple[int, int, float]] = []
|
||||
for fmt in device.videoFormats():
|
||||
res = fmt.resolution()
|
||||
fps = fmt.maxFrameRate()
|
||||
formats.append((res.width(), res.height(), fps))
|
||||
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 and sort: largest resolution first, then fps descending
|
||||
seen: set[tuple[int, int, float]] = set()
|
||||
unique_formats: list[tuple[int, int, float]] = []
|
||||
for f in sorted(formats, key=lambda x: (x[0] * x[1], x[2]), reverse=True):
|
||||
if f not in seen:
|
||||
seen.add(f)
|
||||
unique_formats.append(f)
|
||||
# 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)
|
||||
|
||||
cameras.append(
|
||||
CameraInfo(
|
||||
device=device,
|
||||
name=device.description(),
|
||||
id=device.id().toStdString()
|
||||
if hasattr(device.id(), "toStdString")
|
||||
else device.id().data().decode("utf-8", errors="replace"),
|
||||
formats=unique_formats,
|
||||
)
|
||||
)
|
||||
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
|
||||
|
||||
@@ -65,16 +116,17 @@ class CameraEnumerator:
|
||||
if device.isNull():
|
||||
return None
|
||||
|
||||
cameras = CameraEnumerator.list_cameras()
|
||||
# find by id match
|
||||
default_id = (
|
||||
device.id().toStdString()
|
||||
if hasattr(device.id(), "toStdString")
|
||||
else device.id().data().decode("utf-8", errors="replace")
|
||||
)
|
||||
for cam in cameras:
|
||||
default_id = CameraEnumerator._device_id(device)
|
||||
for cam in CameraEnumerator.list_cameras():
|
||||
if cam.id == default_id:
|
||||
return cam
|
||||
|
||||
# fallback: wrap directly
|
||||
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")
|
||||
|
||||
@@ -12,7 +12,7 @@ from PySide6.QtMultimedia import (
|
||||
QVideoSink,
|
||||
)
|
||||
|
||||
from app.camera.camera_enumerator import CameraInfo
|
||||
from app.camera.camera_enumerator import CameraInfo, pixel_format_name
|
||||
from app.config import DEFAULT_FPS, DEFAULT_HEIGHT, DEFAULT_WIDTH
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -28,14 +28,13 @@ class CameraService(QObject):
|
||||
camera_stopped() — camera stopped (clean shutdown)
|
||||
camera_error(str) — camera error description
|
||||
format_changed(float) — actual FPS after format was applied
|
||||
(emitted after camera restarts with new format)
|
||||
"""
|
||||
|
||||
frame_ready = Signal(QVideoFrame)
|
||||
camera_started = Signal()
|
||||
camera_stopped = Signal()
|
||||
camera_error = Signal(str)
|
||||
format_changed = Signal(float) # actual FPS delivered by camera after format change
|
||||
format_changed = Signal(float)
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -44,8 +43,9 @@ class CameraService(QObject):
|
||||
self._session = QMediaCaptureSession(self)
|
||||
self._sink = QVideoSink(self)
|
||||
self._current_info: CameraInfo | None = None
|
||||
self._session_logged: bool = False
|
||||
|
||||
# Desired format — applied on next (re)start
|
||||
# Desired format — applied on every (re)start
|
||||
self._desired_width: int = DEFAULT_WIDTH
|
||||
self._desired_height: int = DEFAULT_HEIGHT
|
||||
self._desired_fps: float = float(DEFAULT_FPS)
|
||||
@@ -85,28 +85,18 @@ class CameraService(QObject):
|
||||
logger.warning("Reconnect requested but no camera was previously started")
|
||||
|
||||
def set_resolution(self, width: int, height: int) -> None:
|
||||
"""
|
||||
Request a new resolution.
|
||||
|
||||
The camera is stopped and restarted so the backend reliably applies
|
||||
the new format (QCamera.setCameraFormat on an active camera is often
|
||||
silently ignored by Media Foundation on Windows).
|
||||
"""
|
||||
"""Request a new resolution — restarts camera to apply reliably."""
|
||||
self._desired_width = width
|
||||
self._desired_height = height
|
||||
if self._current_info is not None:
|
||||
logger.info("Resolution change requested: %dx%d — restarting camera", width, height)
|
||||
logger.info("Resolution change: %dx%d — restarting camera", width, height)
|
||||
self.start(self._current_info)
|
||||
|
||||
def set_fps(self, fps: float) -> None:
|
||||
"""
|
||||
Request a new frame rate.
|
||||
|
||||
Same stop+start strategy as set_resolution().
|
||||
"""
|
||||
"""Request a new frame rate — restarts camera to apply reliably."""
|
||||
self._desired_fps = fps
|
||||
if self._current_info is not None:
|
||||
logger.info("FPS change requested: %.1f — restarting camera", fps)
|
||||
logger.info("FPS change: %.1f — restarting camera", fps)
|
||||
self.start(self._current_info)
|
||||
|
||||
@property
|
||||
@@ -117,10 +107,6 @@ class CameraService(QObject):
|
||||
def current_camera(self) -> CameraInfo | None:
|
||||
return self._current_info
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal video output accessors (kept for future use)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def video_sink(self) -> QVideoSink:
|
||||
return self._sink
|
||||
|
||||
@@ -132,7 +118,6 @@ class CameraService(QObject):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _stop_camera(self) -> None:
|
||||
"""Stop and destroy the QCamera object without clearing _current_info."""
|
||||
if self._camera is not None:
|
||||
self._camera.stop()
|
||||
self._camera.errorOccurred.disconnect()
|
||||
@@ -141,14 +126,7 @@ class CameraService(QObject):
|
||||
logger.debug("Camera stopped (internal)")
|
||||
|
||||
def _apply_format(self) -> None:
|
||||
"""
|
||||
Select the best matching QCameraFormat and apply it before start().
|
||||
|
||||
The format is chosen by score:
|
||||
+1000 exact resolution match
|
||||
+100 exact FPS match (within 1 fps)
|
||||
-|Δpixels| area proximity (tie-breaker)
|
||||
"""
|
||||
"""Select and apply the best matching QCameraFormat before start()."""
|
||||
if self._camera is None or self._current_info is None:
|
||||
return
|
||||
|
||||
@@ -172,9 +150,10 @@ class CameraService(QObject):
|
||||
if best is not None:
|
||||
self._camera.setCameraFormat(best)
|
||||
res = best.resolution()
|
||||
pf = pixel_format_name(best.pixelFormat())
|
||||
logger.info(
|
||||
"Camera format requested: %dx%d @ %.1f fps",
|
||||
res.width(), res.height(), best.maxFrameRate(),
|
||||
"Camera format requested: %s %dx%d @ %.1f fps",
|
||||
pf, res.width(), res.height(), best.maxFrameRate(),
|
||||
)
|
||||
|
||||
def _log_actual_format(self) -> None:
|
||||
@@ -184,18 +163,36 @@ class CameraService(QObject):
|
||||
fmt = self._camera.cameraFormat()
|
||||
res = fmt.resolution()
|
||||
actual_fps = fmt.maxFrameRate()
|
||||
pf = pixel_format_name(fmt.pixelFormat())
|
||||
|
||||
logger.info(
|
||||
"Camera format ACTUAL: %dx%d @ %.1f fps",
|
||||
res.width(), res.height(), actual_fps,
|
||||
"Camera format ACTUAL: %s %dx%d @ %.1f fps",
|
||||
pf, res.width(), res.height(), actual_fps,
|
||||
)
|
||||
if actual_fps != self._desired_fps:
|
||||
if abs(actual_fps - self._desired_fps) > 0.5:
|
||||
logger.warning(
|
||||
"Requested %.1f fps but camera is delivering %.1f fps "
|
||||
"(camera may not support this combination)",
|
||||
"Requested %.1f fps but camera delivering %.1f fps "
|
||||
"(camera may not support this resolution+fps combination)",
|
||||
self._desired_fps, actual_fps,
|
||||
)
|
||||
self.format_changed.emit(actual_fps)
|
||||
|
||||
def _log_qt_backend(self) -> None:
|
||||
"""Log the Qt multimedia backend in use (once per session)."""
|
||||
try:
|
||||
# QMediaDevices doesn't expose backend name directly in Qt6,
|
||||
# but we can infer it from platform
|
||||
import platform # noqa: PLC0415
|
||||
system = platform.system()
|
||||
backend = {
|
||||
"Darwin": "AVFoundation",
|
||||
"Windows": "Media Foundation (DirectShow fallback)",
|
||||
"Linux": "GStreamer / V4L2",
|
||||
}.get(system, system)
|
||||
logger.info("Qt multimedia backend: %s", backend)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_frame(self, frame: QVideoFrame) -> None:
|
||||
if frame.isValid():
|
||||
self.frame_ready.emit(frame)
|
||||
@@ -207,8 +204,11 @@ class CameraService(QObject):
|
||||
def _on_active_changed(self, active: bool) -> None:
|
||||
if active:
|
||||
name = self._current_info.name if self._current_info else "?"
|
||||
if not self._session_logged:
|
||||
self._log_qt_backend()
|
||||
self._session_logged = True
|
||||
logger.info("Camera active: %s", name)
|
||||
self._log_actual_format() # report what the camera actually accepted
|
||||
self._log_actual_format()
|
||||
self.camera_started.emit()
|
||||
else:
|
||||
logger.info("Camera inactive")
|
||||
|
||||
Reference in New Issue
Block a user