feat: implement logging setup and CSV telemetry logging for performance metrics

This commit is contained in:
2026-05-12 22:15:50 +02:00
parent aec286c5ec
commit d62416db4e
9 changed files with 426 additions and 106 deletions

View File

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

View File

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