diff --git a/app/overlay/overlay_layer.py b/app/overlay/overlay_layer.py new file mode 100644 index 0000000..e263c56 --- /dev/null +++ b/app/overlay/overlay_layer.py @@ -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. + """ diff --git a/app/overlay/overlay_widget.py b/app/overlay/overlay_widget.py deleted file mode 100644 index c5bdd25..0000000 --- a/app/overlay/overlay_widget.py +++ /dev/null @@ -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 diff --git a/app/overlay/telemetry_overlay.py b/app/overlay/telemetry_overlay.py new file mode 100644 index 0000000..b8eb1b9 --- /dev/null +++ b/app/overlay/telemetry_overlay.py @@ -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 diff --git a/app/telemetry/telemetry_collector.py b/app/telemetry/telemetry_collector.py index 221343c..3222f07 100644 --- a/app/telemetry/telemetry_collector.py +++ b/app/telemetry/telemetry_collector.py @@ -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 (0–100) - memory_mb: float | None # RSS memory usage in MB (optional) + cpu_percent: float # this process CPU usage (0–100, 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 diff --git a/app/ui/camera_view.py b/app/ui/camera_view.py new file mode 100644 index 0000000..064dc10 --- /dev/null +++ b/app/ui/camera_view.py @@ -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) diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 08eae02..c5af17e 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -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 diff --git a/app/ui/video_widget.py b/app/ui/video_widget.py deleted file mode 100644 index 897cb2a..0000000 --- a/app/ui/video_widget.py +++ /dev/null @@ -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 diff --git a/tests/test_telemetry_collector.py b/tests/test_telemetry_collector.py index e0d95f7..795d626 100644 --- a/tests/test_telemetry_collector.py +++ b/tests/test_telemetry_collector.py @@ -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):