feat: enhance telemetry metrics with target FPS tracking and logging
This commit is contained in:
@@ -27,12 +27,15 @@ class CameraService(QObject):
|
||||
camera_started() — camera successfully opened and streaming
|
||||
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
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -42,6 +45,11 @@ class CameraService(QObject):
|
||||
self._sink = QVideoSink(self)
|
||||
self._current_info: CameraInfo | None = None
|
||||
|
||||
# Desired format — applied on next (re)start
|
||||
self._desired_width: int = DEFAULT_WIDTH
|
||||
self._desired_height: int = DEFAULT_HEIGHT
|
||||
self._desired_fps: float = float(DEFAULT_FPS)
|
||||
|
||||
self._session.setVideoSink(self._sink)
|
||||
self._sink.videoFrameChanged.connect(self._on_frame)
|
||||
|
||||
@@ -51,7 +59,7 @@ class CameraService(QObject):
|
||||
|
||||
def start(self, camera_info: CameraInfo) -> None:
|
||||
"""Start streaming from the given camera device."""
|
||||
self.stop()
|
||||
self._stop_camera()
|
||||
|
||||
self._current_info = camera_info
|
||||
self._camera = QCamera(camera_info.device, self)
|
||||
@@ -59,22 +67,17 @@ class CameraService(QObject):
|
||||
self._camera.activeChanged.connect(self._on_active_changed)
|
||||
|
||||
self._session.setCamera(self._camera)
|
||||
self._apply_best_format(camera_info)
|
||||
self._apply_format()
|
||||
self._camera.start()
|
||||
logger.info("Camera start requested: %s", camera_info.name)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the current camera."""
|
||||
if self._camera is not None:
|
||||
self._camera.stop()
|
||||
self._camera.errorOccurred.disconnect()
|
||||
self._camera.activeChanged.disconnect()
|
||||
self._camera = None
|
||||
self._current_info = None
|
||||
logger.info("Camera stopped")
|
||||
"""Stop the current camera and forget the device."""
|
||||
self._stop_camera()
|
||||
self._current_info = None
|
||||
|
||||
def reconnect(self) -> None:
|
||||
"""Restart the current camera after an error or disconnect."""
|
||||
"""Restart the current camera (e.g. after an error or disconnect)."""
|
||||
if self._current_info is not None:
|
||||
logger.info("Reconnecting camera: %s", self._current_info.name)
|
||||
self.start(self._current_info)
|
||||
@@ -82,19 +85,29 @@ class CameraService(QObject):
|
||||
logger.warning("Reconnect requested but no camera was previously started")
|
||||
|
||||
def set_resolution(self, width: int, height: int) -> None:
|
||||
"""Request a specific resolution. Effective on next start() if camera is active."""
|
||||
if self._camera is None:
|
||||
return
|
||||
self._set_format(width, height, fps=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).
|
||||
"""
|
||||
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)
|
||||
self.start(self._current_info)
|
||||
|
||||
def set_fps(self, fps: float) -> None:
|
||||
"""Request a specific frame rate."""
|
||||
if self._camera is None or self._current_info is None:
|
||||
return
|
||||
# Get current resolution from active format
|
||||
fmt = self._camera.cameraFormat()
|
||||
res = fmt.resolution()
|
||||
self._set_format(res.width(), res.height(), fps=fps)
|
||||
"""
|
||||
Request a new frame rate.
|
||||
|
||||
Same stop+start strategy as set_resolution().
|
||||
"""
|
||||
self._desired_fps = fps
|
||||
if self._current_info is not None:
|
||||
logger.info("FPS change requested: %.1f — restarting camera", fps)
|
||||
self.start(self._current_info)
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
@@ -105,28 +118,37 @@ class CameraService(QObject):
|
||||
return self._current_info
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Video output accessor for direct QVideoWidget connection
|
||||
# Internal video output accessors (kept for future use)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def video_sink(self) -> QVideoSink:
|
||||
"""Return the internal QVideoSink (used by VideoRenderer)."""
|
||||
return self._sink
|
||||
|
||||
def capture_session(self) -> QMediaCaptureSession:
|
||||
"""Return the capture session (can be connected to QVideoWidget directly)."""
|
||||
return self._session
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _apply_best_format(self, info: CameraInfo) -> None:
|
||||
"""Pick the best matching format: prefer DEFAULT_WIDTH x DEFAULT_HEIGHT at DEFAULT_FPS."""
|
||||
if not info.formats:
|
||||
return
|
||||
self._set_format(DEFAULT_WIDTH, DEFAULT_HEIGHT, fps=float(DEFAULT_FPS))
|
||||
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()
|
||||
self._camera.activeChanged.disconnect()
|
||||
self._camera = None
|
||||
logger.debug("Camera stopped (internal)")
|
||||
|
||||
def _set_format(self, width: int, height: int, fps: float | None) -> None:
|
||||
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)
|
||||
"""
|
||||
if self._camera is None or self._current_info is None:
|
||||
return
|
||||
|
||||
@@ -138,11 +160,11 @@ class CameraService(QObject):
|
||||
w, h = res.width(), res.height()
|
||||
f = fmt.maxFrameRate()
|
||||
|
||||
res_match = int(w == width and h == height) * 1000
|
||||
fps_match = int(fps is not None and abs(f - fps) < 1) * 100
|
||||
area_score = -(abs(w * h - width * height))
|
||||
|
||||
score = res_match + fps_match + area_score
|
||||
score = (
|
||||
int(w == self._desired_width and h == self._desired_height) * 1000
|
||||
+ int(abs(f - self._desired_fps) < 1) * 100
|
||||
- abs(w * h - self._desired_width * self._desired_height)
|
||||
)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = fmt
|
||||
@@ -151,12 +173,29 @@ class CameraService(QObject):
|
||||
self._camera.setCameraFormat(best)
|
||||
res = best.resolution()
|
||||
logger.info(
|
||||
"Camera format set: %dx%d @ %.1f fps",
|
||||
res.width(),
|
||||
res.height(),
|
||||
best.maxFrameRate(),
|
||||
"Camera format requested: %dx%d @ %.1f fps",
|
||||
res.width(), res.height(), best.maxFrameRate(),
|
||||
)
|
||||
|
||||
def _log_actual_format(self) -> None:
|
||||
"""Log the format the camera actually started with and emit format_changed."""
|
||||
if self._camera is None:
|
||||
return
|
||||
fmt = self._camera.cameraFormat()
|
||||
res = fmt.resolution()
|
||||
actual_fps = fmt.maxFrameRate()
|
||||
logger.info(
|
||||
"Camera format ACTUAL: %dx%d @ %.1f fps",
|
||||
res.width(), res.height(), actual_fps,
|
||||
)
|
||||
if actual_fps != self._desired_fps:
|
||||
logger.warning(
|
||||
"Requested %.1f fps but camera is delivering %.1f fps "
|
||||
"(camera may not support this combination)",
|
||||
self._desired_fps, actual_fps,
|
||||
)
|
||||
self.format_changed.emit(actual_fps)
|
||||
|
||||
def _on_frame(self, frame: QVideoFrame) -> None:
|
||||
if frame.isValid():
|
||||
self.frame_ready.emit(frame)
|
||||
@@ -167,7 +206,9 @@ class CameraService(QObject):
|
||||
|
||||
def _on_active_changed(self, active: bool) -> None:
|
||||
if active:
|
||||
logger.info("Camera active: %s", self._current_info.name if self._current_info else "?")
|
||||
name = self._current_info.name if self._current_info else "?"
|
||||
logger.info("Camera active: %s", name)
|
||||
self._log_actual_format() # report what the camera actually accepted
|
||||
self.camera_started.emit()
|
||||
else:
|
||||
logger.info("Camera inactive")
|
||||
|
||||
Reference in New Issue
Block a user