"""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 # Inference fields — None when inference is disabled / model not loaded inference_device: str | None = None # e.g. "cpu", "mps" inference_time_ms: float | None = None # rolling average of model() call time 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) # Inference stats (updated externally via set_inference_stats) self._inference_device: str | None = None self._inference_time_ms: float | None = None # 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 def set_inference_stats(self, device: str, avg_ms: float) -> None: """Update inference device and average inference time (called from MainWindow).""" self._inference_device: str | None = device self._inference_time_ms: float | None = avg_ms def clear_inference_stats(self) -> None: """Clear inference stats when inference is disabled.""" self._inference_device = None self._inference_time_ms = None # ------------------------------------------------------------------ # 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, inference_device=self._inference_device, inference_time_ms=( round(self._inference_time_ms, 1) if self._inference_time_ms is not None else None ), ) 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(), inference_device=None, inference_time_ms=None, )