feat: implement overlay architecture with IOverlayLayer interface and telemetry overlay
This commit is contained in:
54
app/overlay/overlay_layer.py
Normal file
54
app/overlay/overlay_layer.py
Normal 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.
|
||||
"""
|
||||
@@ -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
|
||||
94
app/overlay/telemetry_overlay.py
Normal file
94
app/overlay/telemetry_overlay.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
144
app/ui/camera_view.py
Normal file
144
app/ui/camera_view.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user