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
|
||||
Reference in New Issue
Block a user