feat: implement overlay architecture with IOverlayLayer interface and telemetry overlay

This commit is contained in:
2026-05-12 21:10:37 +02:00
parent 74a5dcd057
commit 4cc4f4bf6c
8 changed files with 334 additions and 342 deletions

View File

@@ -0,0 +1,54 @@
"""IOverlayLayer — interface for pluggable overlay layers drawn on CameraView."""
from __future__ import annotations
from abc import ABC, abstractmethod
from PySide6.QtCore import QRect
from PySide6.QtGui import QPainter
class IOverlayLayer(ABC):
"""
Interface for a single overlay layer drawn over the camera frame.
Each layer receives the active QPainter and the video rect (the letterboxed
area where the camera image was drawn) so it can position elements relative
to the actual video content if needed.
To add a new overlay (e.g. YOLO bboxes):
1. Subclass IOverlayLayer.
2. Implement paint().
3. Register with CameraView.add_overlay_layer().
No Qt subclassing is required — layers are plain Python objects.
"""
@property
def name(self) -> str:
"""Human-readable identifier used in menus / debug output."""
return type(self).__name__
@property
def visible(self) -> bool:
"""Whether this layer should be drawn."""
return self._visible
@visible.setter
def visible(self, value: bool) -> None:
self._visible = value
def __init__(self) -> None:
self._visible: bool = True
@abstractmethod
def paint(self, painter: QPainter, video_rect: QRect) -> None:
"""
Draw this layer.
Args:
painter: Active QPainter on the CameraView widget.
Caller saves/restores painter state around each layer.
video_rect: The QRect where the camera image was drawn (letterboxed).
Use this to position overlays relative to the video image.
"""

View File

@@ -1,127 +0,0 @@
"""Overlay Widget — transparent layer rendered above the video preview."""
from __future__ import annotations
from PySide6.QtCore import QRect, Qt, Slot
from PySide6.QtGui import QColor, QFont, QPainter, QPen
from PySide6.QtWidgets import QWidget
from app.config import (
OVERLAY_BG_COLOR,
OVERLAY_FONT_SIZE,
OVERLAY_MARGIN,
OVERLAY_PADDING,
OVERLAY_TEXT_COLOR,
)
from app.telemetry.telemetry_collector import TelemetrySnapshot
class OverlayWidget(QWidget):
"""
Semi-transparent performance metrics overlay.
Sits on top of the video widget (same parent, raised).
Does NOT intercept mouse events — clicks pass through to the video widget.
Usage:
overlay = OverlayWidget(parent=main_window)
telemetry_collector.metrics_updated.connect(overlay.update_metrics)
"""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
# Child widget — NO window flags (FramelessWindowHint would detach it
# from the parent and create an invisible top-level window).
# WA_TranslucentBackground on a child only works when the parent is
# also translucent, so we paint the background ourselves in paintEvent.
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground, True)
self._snapshot: TelemetrySnapshot | None = None
self._visible_overlay: bool = True
# Font
self._font = QFont("Monospace")
self._font.setStyleHint(QFont.StyleHint.TypeWriter)
self._font.setPointSize(OVERLAY_FONT_SIZE)
self._font.setBold(False)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
@Slot(object)
def update_metrics(self, snapshot: TelemetrySnapshot) -> None:
"""Receive a new telemetry snapshot and trigger a repaint."""
self._snapshot = snapshot
if self._visible_overlay:
self.update()
def set_overlay_visible(self, visible: bool) -> None:
self._visible_overlay = visible
self.update()
def toggle_overlay(self) -> None:
self.set_overlay_visible(not self._visible_overlay)
# ------------------------------------------------------------------
# Qt painting
# ------------------------------------------------------------------
def paintEvent(self, event) -> None: # noqa: N802
if not self._visible_overlay or self._snapshot is None:
return
lines = self._format_lines(self._snapshot)
if not lines:
return
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
painter.setFont(self._font)
fm = painter.fontMetrics()
line_height = fm.height()
max_width = max(fm.horizontalAdvance(line) for line in lines)
box_w = max_width + OVERLAY_PADDING * 2
box_h = line_height * len(lines) + OVERLAY_PADDING * 2
x = OVERLAY_MARGIN
y = OVERLAY_MARGIN
# Background rectangle
bg = QColor(*OVERLAY_BG_COLOR)
painter.setBrush(bg)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(QRect(x, y, box_w, box_h), 6, 6)
# Text
text_color = QColor(*OVERLAY_TEXT_COLOR)
painter.setPen(QPen(text_color))
text_x = x + OVERLAY_PADDING
text_y = y + OVERLAY_PADDING + fm.ascent()
for line in lines:
painter.drawText(text_x, text_y, line)
text_y += line_height
painter.end()
# ------------------------------------------------------------------
# Private
# ------------------------------------------------------------------
@staticmethod
def _format_lines(snap: TelemetrySnapshot) -> list[str]:
lines = [
f"FPS {snap.fps:>6.1f}",
f"Frame {snap.frame_time_ms:>6.1f} ms",
f"Drop {snap.dropped_frames:>6d}",
f"CPU {snap.cpu_percent:>5.1f} %",
]
if snap.memory_mb is not None:
lines.append(f"Mem {snap.memory_mb:>5.0f} MB")
return lines

View File

@@ -0,0 +1,94 @@
"""TelemetryOverlay — draws the performance metrics box on the camera view."""
from __future__ import annotations
from PySide6.QtCore import QRect, Qt, Slot
from PySide6.QtGui import QColor, QFont, QPainter, QPen
from app.config import (
OVERLAY_BG_COLOR,
OVERLAY_FONT_SIZE,
OVERLAY_MARGIN,
OVERLAY_PADDING,
OVERLAY_TEXT_COLOR,
)
from app.overlay.overlay_layer import IOverlayLayer
from app.telemetry.telemetry_collector import TelemetrySnapshot
class TelemetryOverlay(IOverlayLayer):
"""
Renders a semi-transparent metrics box in the top-left corner.
Usage:
overlay = TelemetryOverlay()
camera_view.add_overlay_layer(overlay)
telemetry_collector.metrics_updated.connect(overlay.on_metrics_updated)
"""
def __init__(self) -> None:
super().__init__()
self._snapshot: TelemetrySnapshot | None = None
self._font = QFont("Monospace")
self._font.setStyleHint(QFont.StyleHint.TypeWriter)
self._font.setPointSize(OVERLAY_FONT_SIZE)
self._font.setBold(False)
@Slot(object)
def on_metrics_updated(self, snapshot: TelemetrySnapshot) -> None:
"""Receive a new snapshot from TelemetryCollector."""
self._snapshot = snapshot
# ------------------------------------------------------------------
# IOverlayLayer
# ------------------------------------------------------------------
def paint(self, painter: QPainter, video_rect: QRect) -> None:
if self._snapshot is None:
return
lines = self._format_lines(self._snapshot)
if not lines:
return
painter.setFont(self._font)
fm = painter.fontMetrics()
line_height = fm.height()
max_width = max(fm.horizontalAdvance(line) for line in lines)
box_w = max_width + OVERLAY_PADDING * 2
box_h = line_height * len(lines) + OVERLAY_PADDING * 2
# Position relative to the actual video area, not the full widget
x = video_rect.left() + OVERLAY_MARGIN
y = video_rect.top() + OVERLAY_MARGIN
# Background
painter.setBrush(QColor(*OVERLAY_BG_COLOR))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(QRect(x, y, box_w, box_h), 6, 6)
# Text
painter.setPen(QPen(QColor(*OVERLAY_TEXT_COLOR)))
text_x = x + OVERLAY_PADDING
text_y = y + OVERLAY_PADDING + fm.ascent()
for line in lines:
painter.drawText(text_x, text_y, line)
text_y += line_height
# ------------------------------------------------------------------
# Private
# ------------------------------------------------------------------
@staticmethod
def _format_lines(snap: TelemetrySnapshot) -> list[str]:
lines = [
f"FPS {snap.fps:>6.1f}",
f"Frame {snap.frame_time_ms:>6.1f} ms",
f"Drop {snap.dropped_frames:>6d}",
f"CPU {snap.cpu_percent:>5.1f} %",
]
if snap.memory_mb is not None:
lines.append(f"Mem {snap.memory_mb:>5.0f} MB")
return lines

View File

@@ -20,8 +20,8 @@ class TelemetrySnapshot:
fps: float
frame_time_ms: float # average inter-frame time in ms
dropped_frames: int # cumulative dropped frames detected
cpu_percent: float # overall CPU usage (0100)
memory_mb: float | None # RSS memory usage in MB (optional)
cpu_percent: float # this process CPU usage (0100, all cores)
memory_mb: float | None # process private working set in MB
timestamp: float # time.perf_counter() when snapshot was taken
@@ -57,8 +57,9 @@ class TelemetryCollector(QObject):
self._fps_window: deque[float] = deque() # timestamps of recent frames
self._fps_window_size_s: float = 1.0
# psutil process reference
# psutil process reference — call cpu_percent once to initialise the baseline
self._process = psutil.Process()
self._process.cpu_percent() # first call always returns 0.0; discard it
# periodic snapshot timer
self._timer = QTimer(self)
@@ -138,15 +139,19 @@ class TelemetryCollector(QObject):
else:
avg_frame_time_ms = 0.0
# CPU
# CPU — this process only, cumulative since last call (non-blocking)
try:
cpu = psutil.cpu_percent(interval=None)
cpu = self._process.cpu_percent()
except Exception:
cpu = 0.0
# memory
# Memory — private working set (Windows) or RSS (macOS/Linux)
# This excludes shared DLLs/frameworks and matches Task Manager "Private"
try:
mem_mb = self._process.memory_info().rss / (1024 * 1024)
mem_info = self._process.memory_info()
# wset = Windows Working Set (private); rss on macOS/Linux
mem_bytes = getattr(mem_info, "wset", None) or mem_info.rss
mem_mb = mem_bytes / (1024 * 1024)
except Exception:
mem_mb = None

144
app/ui/camera_view.py Normal file
View File

@@ -0,0 +1,144 @@
"""CameraView — QWidget that renders camera frames and composites overlay layers.
Responsibilities:
- Receive QVideoFrame, convert to QImage, schedule repaint.
- In paintEvent: draw the frame (letterboxed) then call paint() on each
registered IOverlayLayer in order.
- Know nothing about what specific overlays draw — that is entirely up to
each IOverlayLayer implementation.
Adding a new overlay (e.g. YOLO bboxes):
layer = YoloBboxOverlay()
camera_view.add_overlay_layer(layer)
yolo_processor.results_ready.connect(layer.on_results)
No modification to CameraView is needed.
"""
from __future__ import annotations
import logging
from PySide6.QtCore import QRect, Qt, Slot
from PySide6.QtGui import QImage, QPainter
from PySide6.QtMultimedia import QVideoFrame
from PySide6.QtWidgets import QWidget
from app.overlay.overlay_layer import IOverlayLayer
logger = logging.getLogger(__name__)
class CameraView(QWidget):
"""
Camera preview widget.
Frame pipeline:
CameraService.frame_ready(QVideoFrame)
→ CameraView.on_frame() — convert to QImage, schedule update()
→ paintEvent() — draw image + all overlay layers
"""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setStyleSheet("background: black;")
self._current_image: QImage | None = None
self._video_rect: QRect = QRect()
self._overlay_layers: list[IOverlayLayer] = []
# ------------------------------------------------------------------
# Overlay layer registry
# ------------------------------------------------------------------
def add_overlay_layer(self, layer: IOverlayLayer) -> None:
"""Register an overlay layer. Layers are painted in registration order."""
if layer not in self._overlay_layers:
self._overlay_layers.append(layer)
logger.debug("Overlay layer added: %s", layer.name)
def remove_overlay_layer(self, layer: IOverlayLayer) -> None:
"""Unregister an overlay layer."""
try:
self._overlay_layers.remove(layer)
logger.debug("Overlay layer removed: %s", layer.name)
except ValueError:
pass
def set_all_overlays_visible(self, visible: bool) -> None:
"""Show or hide all registered overlay layers at once."""
for layer in self._overlay_layers:
layer.visible = visible
self.update()
# ------------------------------------------------------------------
# Frame input
# ------------------------------------------------------------------
@Slot(QVideoFrame)
def on_frame(self, frame: QVideoFrame) -> None:
"""Receive a camera frame, convert to QImage, schedule repaint."""
if not frame.isValid():
return
image = frame.toImage()
if image.isNull():
return
# Convert to Format_RGB32 — QPainter's fastest path on all platforms
if image.format() != QImage.Format.Format_RGB32:
image = image.convertToFormat(QImage.Format.Format_RGB32)
self._current_image = image
self.update()
# ------------------------------------------------------------------
# Qt paint
# ------------------------------------------------------------------
def paintEvent(self, event) -> None: # noqa: N802
painter = QPainter(self)
# 1. Background
painter.fillRect(self.rect(), Qt.GlobalColor.black)
# 2. Camera frame — letterboxed
if self._current_image is not None:
img = self._current_image
self._video_rect = _letterbox_rect(
img.width(), img.height(), self.width(), self.height()
)
painter.drawImage(self._video_rect, img)
else:
self._video_rect = self.rect()
# 3. Overlay layers — each gets a clean painter state
for layer in self._overlay_layers:
if not layer.visible:
continue
painter.save()
try:
layer.paint(painter, self._video_rect)
except Exception:
logger.exception("Error painting overlay layer %s", layer.name)
finally:
painter.restore()
painter.end()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _letterbox_rect(img_w: int, img_h: int, widget_w: int, widget_h: int) -> QRect:
"""Return the largest rect that fits the image while preserving aspect ratio."""
if img_w <= 0 or img_h <= 0:
return QRect(0, 0, widget_w, widget_h)
scale = min(widget_w / img_w, widget_h / img_h)
draw_w = int(img_w * scale)
draw_h = int(img_h * scale)
x = (widget_w - draw_w) // 2
y = (widget_h - draw_h) // 2
return QRect(x, y, draw_w, draw_h)

View File

@@ -10,10 +10,11 @@ from PySide6.QtWidgets import QLabel, QMainWindow, QSizePolicy, QStatusBar
from app.camera.camera_enumerator import CameraEnumerator, CameraInfo
from app.camera.camera_service import CameraService
from app.config import APP_NAME, APP_VERSION
from app.overlay.telemetry_overlay import TelemetryOverlay
from app.pipeline.frame_dispatcher import FrameDispatcher
from app.telemetry.telemetry_collector import TelemetryCollector
from app.ui.camera_view import CameraView
from app.ui.menu_bar import AppMenuBar
from app.ui.video_widget import VideoWidget
logger = logging.getLogger(__name__)
@@ -23,20 +24,18 @@ class MainWindow(QMainWindow):
Top-level application window.
Rendering architecture:
QVideoWidget is intentionally NOT used. On Windows its native HWND
surface occludes all sibling/child QWidgets regardless of z-order,
making overlay rendering impossible.
Instead, frames are received as QVideoFrame via CameraService.frame_ready,
converted to QImage inside VideoWidget.on_frame(), and drawn together with
the metrics overlay in a single paintEvent pass.
QVideoWidget is intentionally NOT used — on Windows its native HWND
surface occludes all sibling/child QWidgets regardless of z-order.
CameraView is a plain QWidget that renders frames and overlay layers
in a single paintEvent pass.
Signal flow:
CameraService.frame_ready
→ FrameDispatcher.dispatch
VideoWidget.on_frame (render)
→ TelemetryCollector.on_frame (metrics)
VideoWidget.update_metrics (overlay data)
CameraView.on_frame (render frame)
→ TelemetryCollector.on_frame (measure metrics)
TelemetryOverlay.on_metrics_updated (feed overlay data)
(CameraView repaints and calls TelemetryOverlay.paint())
"""
def __init__(self) -> None:
@@ -46,18 +45,21 @@ class MainWindow(QMainWindow):
self.setMinimumSize(640, 480)
self.resize(1280, 720)
# --- Core components ---
# --- Core pipeline components ---
self._camera_service = CameraService(self)
self._dispatcher = FrameDispatcher(self)
self._telemetry = TelemetryCollector(parent=self)
# --- Video widget (central widget) ---
# Plain QWidget — no native surface, overlay rendered in same paintEvent.
self._video_widget = VideoWidget(self)
self._video_widget.setSizePolicy(
# --- Camera view (central widget) ---
self._camera_view = CameraView(self)
self._camera_view.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
self.setCentralWidget(self._video_widget)
self.setCentralWidget(self._camera_view)
# --- Overlay layers ---
self._telemetry_overlay = TelemetryOverlay()
self._camera_view.add_overlay_layer(self._telemetry_overlay)
# --- Menu bar ---
self._menu = AppMenuBar(self)
@@ -109,14 +111,14 @@ class MainWindow(QMainWindow):
# CameraService → FrameDispatcher
self._camera_service.frame_ready.connect(self._dispatcher.dispatch)
# FrameDispatcher → VideoWidget (render) — drop if busy: skip frame, keep UI fluid
self._dispatcher.subscribe(self._video_widget.on_frame, drop_if_busy=True)
# FrameDispatcher → CameraView (render) — drop if busy: stay fluid
self._dispatcher.subscribe(self._camera_view.on_frame, drop_if_busy=True)
# FrameDispatcher → TelemetryCollector — never drop, count every frame
self._dispatcher.subscribe(self._telemetry.on_frame, drop_if_busy=False)
# TelemetryCollector → VideoWidget overlay
self._telemetry.metrics_updated.connect(self._video_widget.update_metrics)
# TelemetryCollector → TelemetryOverlay (data only, no repaint trigger here)
self._telemetry.metrics_updated.connect(self._telemetry_overlay.on_metrics_updated)
# CameraService status
self._camera_service.camera_started.connect(self._on_camera_started)
@@ -128,7 +130,7 @@ class MainWindow(QMainWindow):
self._menu.resolution_selected.connect(self._on_resolution_selected)
self._menu.fps_selected.connect(self._on_fps_selected)
self._menu.reconnect_requested.connect(self._camera_service.reconnect)
self._menu.overlay_toggled.connect(self._video_widget.set_overlay_visible)
self._menu.overlay_toggled.connect(self._camera_view.set_all_overlays_visible)
# ------------------------------------------------------------------
# Camera status slots

View File

@@ -1,185 +0,0 @@
"""VideoWidget — custom QWidget that renders camera frames and the metrics overlay.
Why not QVideoWidget?
QVideoWidget on Windows uses a native HWND surface (D3D / Media Foundation)
that is painted *outside* the normal Qt widget hierarchy. Any sibling or
child QWidget is occluded by this native surface regardless of z-order.
The only reliable cross-platform solution is to render frames ourselves
in paintEvent() so that overlay drawing happens in the same Qt paint pass.
Pipeline:
CameraService.frame_ready(QVideoFrame)
→ VideoWidget.on_frame() — stores latest QImage, schedules repaint
→ paintEvent() — draws image + overlay text in one pass
"""
from __future__ import annotations
import logging
from PySide6.QtCore import QRect, Qt, Slot
from PySide6.QtGui import QColor, QFont, QImage, QPainter, QPen
from PySide6.QtMultimedia import QVideoFrame
from PySide6.QtWidgets import QWidget
from app.config import (
OVERLAY_BG_COLOR,
OVERLAY_FONT_SIZE,
OVERLAY_MARGIN,
OVERLAY_PADDING,
OVERLAY_TEXT_COLOR,
)
from app.telemetry.telemetry_collector import TelemetrySnapshot
logger = logging.getLogger(__name__)
class VideoWidget(QWidget):
"""
Renders camera frames using plain QPainter.
Accepts frames via on_frame() slot (connect to CameraService.frame_ready).
Accepts telemetry via update_metrics() slot (connect to TelemetryCollector.metrics_updated).
Overlay visibility is controlled by set_overlay_visible().
"""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setStyleSheet("background: black;")
self._current_image: QImage | None = None
self._snapshot: TelemetrySnapshot | None = None
self._overlay_visible: bool = True
# Overlay font
self._font = QFont("Monospace")
self._font.setStyleHint(QFont.StyleHint.TypeWriter)
self._font.setPointSize(OVERLAY_FONT_SIZE)
self._font.setBold(False)
# ------------------------------------------------------------------
# Frame input
# ------------------------------------------------------------------
@Slot(QVideoFrame)
def on_frame(self, frame: QVideoFrame) -> None:
"""Receive a new camera frame, convert to QImage and schedule repaint."""
if not frame.isValid():
return
image = frame.toImage()
if image.isNull():
return
# Convert to a format that QPainter handles efficiently
if image.format() != QImage.Format.Format_RGB32:
image = image.convertToFormat(QImage.Format.Format_RGB32)
self._current_image = image
self.update() # schedule paintEvent — non-blocking
# ------------------------------------------------------------------
# Telemetry input
# ------------------------------------------------------------------
@Slot(object)
def update_metrics(self, snapshot: TelemetrySnapshot) -> None:
"""Receive a new telemetry snapshot. Overlay will reflect it on next repaint."""
self._snapshot = snapshot
if self._overlay_visible:
self.update()
# ------------------------------------------------------------------
# Overlay control
# ------------------------------------------------------------------
def set_overlay_visible(self, visible: bool) -> None:
self._overlay_visible = visible
self.update()
def toggle_overlay(self) -> None:
self.set_overlay_visible(not self._overlay_visible)
# ------------------------------------------------------------------
# Qt paint
# ------------------------------------------------------------------
def paintEvent(self, event) -> None: # noqa: N802
painter = QPainter(self)
# 1. Background
painter.fillRect(self.rect(), Qt.GlobalColor.black)
# 2. Camera frame — letterboxed to keep aspect ratio
if self._current_image is not None:
img = self._current_image
target = _letterbox_rect(img.width(), img.height(), self.width(), self.height())
painter.drawImage(target, img)
# 3. Overlay
if self._overlay_visible and self._snapshot is not None:
self._paint_overlay(painter)
painter.end()
# ------------------------------------------------------------------
# Private — overlay rendering
# ------------------------------------------------------------------
def _paint_overlay(self, painter: QPainter) -> None:
lines = _format_lines(self._snapshot)
if not lines:
return
painter.setFont(self._font)
fm = painter.fontMetrics()
line_height = fm.height()
max_width = max(fm.horizontalAdvance(line) for line in lines)
box_w = max_width + OVERLAY_PADDING * 2
box_h = line_height * len(lines) + OVERLAY_PADDING * 2
x = OVERLAY_MARGIN
y = OVERLAY_MARGIN
# Semi-transparent background
painter.setBrush(QColor(*OVERLAY_BG_COLOR))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(QRect(x, y, box_w, box_h), 6, 6)
# Text
painter.setPen(QPen(QColor(*OVERLAY_TEXT_COLOR)))
text_x = x + OVERLAY_PADDING
text_y = y + OVERLAY_PADDING + fm.ascent()
for line in lines:
painter.drawText(text_x, text_y, line)
text_y += line_height
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _letterbox_rect(img_w: int, img_h: int, widget_w: int, widget_h: int) -> QRect:
"""Return the largest rect that fits img inside widget while keeping aspect ratio."""
if img_w <= 0 or img_h <= 0:
return QRect(0, 0, widget_w, widget_h)
scale = min(widget_w / img_w, widget_h / img_h)
draw_w = int(img_w * scale)
draw_h = int(img_h * scale)
x = (widget_w - draw_w) // 2
y = (widget_h - draw_h) // 2
return QRect(x, y, draw_w, draw_h)
def _format_lines(snap: TelemetrySnapshot) -> list[str]:
lines = [
f"FPS {snap.fps:>6.1f}",
f"Frame {snap.frame_time_ms:>6.1f} ms",
f"Drop {snap.dropped_frames:>6d}",
f"CPU {snap.cpu_percent:>5.1f} %",
]
if snap.memory_mb is not None:
lines.append(f"Mem {snap.memory_mb:>5.0f} MB")
return lines

View File

@@ -24,7 +24,12 @@ class TestTelemetryCollector:
col._fps_window = deque()
col._fps_window_size_s = 1.0
col._process = MagicMock()
col._process.memory_info.return_value.rss = 50 * 1024 * 1024 # 50 MB
# Simulate Windows: wset is present and takes priority over rss
mem_info = MagicMock()
mem_info.wset = 50 * 1024 * 1024 # 50 MB private working set
mem_info.rss = 70 * 1024 * 1024 # RSS (larger, includes shared)
col._process.memory_info.return_value = mem_info
col._process.cpu_percent.return_value = 0.0
return col
def test_initial_snapshot_has_zero_fps(self):