191 lines
6.7 KiB
Python
191 lines
6.7 KiB
Python
"""Telemetry Collector — measures video pipeline performance metrics."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
from collections import deque
|
||
from dataclasses import dataclass
|
||
|
||
import psutil
|
||
from PySide6.QtCore import QObject, QTimer, Signal
|
||
from PySide6.QtMultimedia import QVideoFrame
|
||
|
||
from app.config import TELEMETRY_UPDATE_INTERVAL_MS
|
||
|
||
|
||
@dataclass
|
||
class TelemetrySnapshot:
|
||
"""Immutable snapshot of current performance metrics."""
|
||
|
||
fps: float # actual frames received in the last second
|
||
target_fps: float | None # FPS requested from the camera (None = unknown)
|
||
frame_time_ms: float # average inter-frame time in ms
|
||
dropped_frames: int # cumulative dropped frames detected
|
||
cpu_percent_sys: float # process CPU as % of total system capacity
|
||
# (divided by cpu_count) — matches Task Manager
|
||
cpu_percent_core: float # process CPU per single core — can exceed 100%
|
||
memory_mb: float | None # process private working set in MB
|
||
timestamp: float # time.perf_counter() when snapshot was taken
|
||
|
||
|
||
class TelemetryCollector(QObject):
|
||
"""
|
||
Frame subscriber that measures pipeline performance.
|
||
|
||
Connect to FrameDispatcher:
|
||
dispatcher.subscribe(collector.on_frame, drop_if_busy=False)
|
||
|
||
Receive target FPS updates from CameraService:
|
||
camera_service.format_changed.connect(collector.set_target_fps)
|
||
|
||
Listen to metrics updates:
|
||
collector.metrics_updated.connect(my_slot)
|
||
"""
|
||
|
||
metrics_updated = Signal(object) # emits TelemetrySnapshot
|
||
|
||
def __init__(
|
||
self,
|
||
update_interval_ms: int = TELEMETRY_UPDATE_INTERVAL_MS,
|
||
parent: QObject | None = None,
|
||
) -> None:
|
||
super().__init__(parent)
|
||
|
||
self._update_interval_ms = update_interval_ms
|
||
self._target_fps: float | None = None
|
||
|
||
# frame timing ring-buffer (last 120 samples)
|
||
self._frame_times: deque[float] = deque(maxlen=120)
|
||
self._last_frame_time: float = 0.0
|
||
self._total_frames: int = 0
|
||
self._dropped_frames: int = 0
|
||
|
||
# FPS window — count frames in the last second
|
||
self._fps_window: deque[float] = deque()
|
||
self._fps_window_size_s: float = 1.0
|
||
|
||
# psutil — initialise baseline so first real reading is non-zero
|
||
self._process = psutil.Process()
|
||
self._process.cpu_percent() # first call always returns 0.0; discard
|
||
self._cpu_count: int = max(psutil.cpu_count(logical=True) or 1, 1)
|
||
|
||
# periodic snapshot timer
|
||
self._timer = QTimer(self)
|
||
self._timer.setInterval(update_interval_ms)
|
||
self._timer.timeout.connect(self._emit_snapshot)
|
||
self._timer.start()
|
||
|
||
self._latest: TelemetrySnapshot = self._make_empty_snapshot()
|
||
|
||
# ------------------------------------------------------------------
|
||
# Configuration
|
||
# ------------------------------------------------------------------
|
||
|
||
def set_target_fps(self, fps: float | None) -> None:
|
||
"""Record the FPS that was requested from the camera."""
|
||
self._target_fps = fps
|
||
|
||
# ------------------------------------------------------------------
|
||
# Frame subscriber callback
|
||
# ------------------------------------------------------------------
|
||
|
||
def on_frame(self, frame: QVideoFrame) -> None:
|
||
"""Called by FrameDispatcher for every frame. Must be fast."""
|
||
now = time.perf_counter()
|
||
|
||
if self._last_frame_time > 0:
|
||
delta = now - self._last_frame_time
|
||
self._frame_times.append(delta)
|
||
|
||
# drop detection: gap > 2.5× rolling average
|
||
if len(self._frame_times) >= 5:
|
||
avg = sum(self._frame_times) / len(self._frame_times)
|
||
if delta > avg * 2.5:
|
||
self._dropped_frames += 1
|
||
|
||
self._last_frame_time = now
|
||
self._total_frames += 1
|
||
|
||
self._fps_window.append(now)
|
||
cutoff = now - self._fps_window_size_s
|
||
while self._fps_window and self._fps_window[0] < cutoff:
|
||
self._fps_window.popleft()
|
||
|
||
# ------------------------------------------------------------------
|
||
# Snapshot
|
||
# ------------------------------------------------------------------
|
||
|
||
def latest_snapshot(self) -> TelemetrySnapshot:
|
||
return self._latest
|
||
|
||
def reset_counters(self) -> None:
|
||
"""Reset cumulative counters (e.g. after camera switch)."""
|
||
self._frame_times.clear()
|
||
self._fps_window.clear()
|
||
self._last_frame_time = 0.0
|
||
self._total_frames = 0
|
||
self._dropped_frames = 0
|
||
|
||
# ------------------------------------------------------------------
|
||
# Internal
|
||
# ------------------------------------------------------------------
|
||
|
||
def _emit_snapshot(self) -> None:
|
||
snapshot = self._compute_snapshot()
|
||
self._latest = snapshot
|
||
self.metrics_updated.emit(snapshot)
|
||
|
||
def _compute_snapshot(self) -> TelemetrySnapshot:
|
||
now = time.perf_counter()
|
||
|
||
# FPS — prune stale entries before counting
|
||
cutoff = now - self._fps_window_size_s
|
||
while self._fps_window and self._fps_window[0] < cutoff:
|
||
self._fps_window.popleft()
|
||
fps = float(len(self._fps_window))
|
||
|
||
# average frame time
|
||
avg_frame_time_ms = (
|
||
(sum(self._frame_times) / len(self._frame_times)) * 1000.0
|
||
if self._frame_times
|
||
else 0.0
|
||
)
|
||
|
||
# CPU — per-core reading, then derive system-normalised value
|
||
try:
|
||
cpu_core = self._process.cpu_percent()
|
||
except Exception:
|
||
cpu_core = 0.0
|
||
cpu_sys = cpu_core / self._cpu_count
|
||
|
||
# Memory — private working set (Windows) or RSS (macOS/Linux)
|
||
try:
|
||
mem_info = self._process.memory_info()
|
||
mem_bytes = getattr(mem_info, "wset", None) or mem_info.rss
|
||
mem_mb: float | None = mem_bytes / (1024 * 1024)
|
||
except Exception:
|
||
mem_mb = None
|
||
|
||
return TelemetrySnapshot(
|
||
fps=round(fps, 1),
|
||
target_fps=self._target_fps,
|
||
frame_time_ms=round(avg_frame_time_ms, 2),
|
||
dropped_frames=self._dropped_frames,
|
||
cpu_percent_sys=round(cpu_sys, 1),
|
||
cpu_percent_core=round(cpu_core, 1),
|
||
memory_mb=round(mem_mb, 1) if mem_mb is not None else None,
|
||
timestamp=now,
|
||
)
|
||
|
||
def _make_empty_snapshot(self) -> TelemetrySnapshot:
|
||
return TelemetrySnapshot(
|
||
fps=0.0,
|
||
target_fps=self._target_fps,
|
||
frame_time_ms=0.0,
|
||
dropped_frames=0,
|
||
cpu_percent_sys=0.0,
|
||
cpu_percent_core=0.0,
|
||
memory_mb=None,
|
||
timestamp=time.perf_counter(),
|
||
)
|