Implement dual output for camera using QVideoWidget and QVideoSink, add camera permission handling, and enhance overlay error messaging

This commit is contained in:
2026-05-12 19:27:24 +02:00
parent 58fff52d31
commit 4916764264
8 changed files with 415 additions and 49 deletions

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import sys
from PySide6.QtCore import QTimer
from PySide6.QtMultimedia import QVideoSink
from PySide6.QtWidgets import QApplication
from duck_preview.camera.service import CameraService
@@ -18,18 +19,22 @@ def main() -> None:
app.setApplicationName("Duck Preview")
camera = CameraService()
video_sink = QVideoSink()
dispatcher = FrameDispatcher()
telemetry = TelemetryCollector()
video_widget = VideoWidget()
overlay = OverlayWidget()
camera.sink.videoFrameChanged.connect(dispatcher.on_frame)
camera.set_video_widget(video_widget)
camera.set_video_sink(video_sink)
video_sink.videoFrameChanged.connect(dispatcher.on_frame)
dispatcher.subscribe(telemetry.on_frame)
dispatcher.subscribe(video_widget.on_frame)
window = MainWindow(camera, video_widget, overlay)
camera.error_occurred.connect(lambda msg: overlay.set_metrics({"error": msg}))
metrics_timer = QTimer()
metrics_timer.timeout.connect(lambda: overlay.set_metrics(telemetry.metrics()))
metrics_timer.start(200)

View File

@@ -9,6 +9,7 @@ from PySide6.QtMultimedia import (
QMediaDevices,
QVideoSink,
)
from PySide6.QtMultimediaWidgets import QVideoWidget
class CameraService(QObject):
@@ -17,18 +18,18 @@ class CameraService(QObject):
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._session = QMediaCaptureSession()
self._sink = QVideoSink()
self._session.setVideoOutput(self._sink)
self._camera: QCamera | None = None
@property
def sink(self) -> QVideoSink:
return self._sink
@property
def session(self) -> QMediaCaptureSession:
return self._session
def set_video_widget(self, widget: QVideoWidget) -> None:
self._session.setVideoOutput(widget)
def set_video_sink(self, sink: QVideoSink) -> None:
self._session.setVideoSink(sink)
@staticmethod
def available_cameras() -> list[QCameraDevice]:
return QMediaDevices.videoInputs()

View File

@@ -1,8 +1,9 @@
from __future__ import annotations
from PySide6.QtCore import QCameraPermission, Qt
from PySide6.QtGui import QAction, QCloseEvent
from PySide6.QtMultimedia import QCameraDevice, QMediaDevices
from PySide6.QtWidgets import QGridLayout, QMainWindow, QWidget
from PySide6.QtWidgets import QApplication, QGridLayout, QMainWindow, QWidget
from duck_preview.camera.service import CameraService
from duck_preview.rendering.overlay import OverlayWidget
@@ -70,8 +71,28 @@ class MainWindow(QMainWindow):
self._camera_menu.addAction(action)
def _on_camera_selected(self, device: QCameraDevice) -> None:
self._camera.start(device)
self._rebuild_resolution_menu(device)
self._request_camera_permission(device)
def _request_camera_permission(self, device: QCameraDevice) -> None:
perm = QCameraPermission()
match QApplication.checkPermission(perm):
case Qt.PermissionStatus.Undetermined:
QApplication.requestPermission(
perm, self, lambda: self._request_camera_permission(device)
)
case Qt.PermissionStatus.Denied:
self._overlay.set_metrics(
{
"error": (
"Camera permission denied.\n"
"Grant access in System Settings > "
"Privacy & Security > Camera"
)
}
)
case Qt.PermissionStatus.Granted:
self._camera.start(device)
self._rebuild_resolution_menu(device)
def _rebuild_resolution_menu(self, device: QCameraDevice) -> None:
self._resolution_menu.clear()

View File

@@ -11,13 +11,13 @@ class OverlayWidget(QWidget):
self.setAttribute(Qt.WA_TransparentForMouseEvents)
self.setAttribute(Qt.WA_NoSystemBackground)
self._visible = True
self._metrics: dict[str, float | int] = {}
self._metrics: dict[str, float | int | str] = {}
def set_visible(self, visible: bool) -> None:
self._visible = visible
self.update()
def set_metrics(self, metrics: dict[str, float | int]) -> None:
def set_metrics(self, metrics: dict[str, float | int | str]) -> None:
self._metrics = metrics
self.update()
@@ -25,16 +25,22 @@ class OverlayWidget(QWidget):
if not self._visible or not self._metrics:
return
painter = QPainter(self)
painter.setRenderHint(QPainter.TextAntialiasing)
if "error" in self._metrics:
self._paint_error(painter)
else:
self._paint_metrics(painter)
def _paint_metrics(self, painter: QPainter) -> None:
lines = [
f"FPS: {self._metrics.get('fps', 0):>7}",
f"Frame: {self._metrics.get('frame_time_ms', 0):>7.1f} ms",
f"Frames: {self._metrics.get('frame_count', 0):>7}",
]
painter = QPainter(self)
painter.setRenderHint(QPainter.TextAntialiasing)
font = QFont("Consolas", 11)
font = QFont("monospace", 11)
painter.setFont(font)
fm = QFontMetrics(font)
@@ -42,8 +48,26 @@ class OverlayWidget(QWidget):
text_height = len(lines) * (fm.height() + 6) + 12
painter.fillRect(8, 8, text_width, text_height, QColor(0, 0, 0, 160))
painter.setPen(QColor(0, 255, 0))
for i, line in enumerate(lines):
y = 22 + i * (fm.height() + 6)
painter.drawText(16, y, line)
def _paint_error(self, painter: QPainter) -> None:
msg = str(self._metrics.get("error", "Unknown error"))
lines = msg.split("\n")
font = QFont("monospace", 12)
painter.setFont(font)
fm = QFontMetrics(font)
text_width = max(fm.horizontalAdvance(line) for line in lines) + 24
text_height = len(lines) * (fm.height() + 6) + 12
painter.fillRect(8, 8, text_width, text_height, QColor(0, 0, 0, 200))
painter.setPen(QColor(200, 50, 50))
for i, line in enumerate(lines):
y = 22 + i * (fm.height() + 6)
painter.drawText(16, y, line)

View File

@@ -1,39 +1,10 @@
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QImage, QPainter
from PySide6.QtMultimedia import QVideoFrame
from PySide6.QtMultimediaWidgets import QVideoWidget
from PySide6.QtWidgets import QWidget
class VideoWidget(QWidget):
class VideoWidget(QVideoWidget):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._frame: QImage | None = None
self.setAttribute(Qt.WA_OpaquePaintEvent)
self.setMinimumSize(320, 240)
def on_frame(self, frame: QVideoFrame) -> None:
image = frame.toImage()
if not image.isNull():
self._frame = image
self.update()
def paintEvent(self, event) -> None: # noqa: N802
painter = QPainter(self)
painter.setRenderHint(QPainter.SmoothPixmapTransform)
if self._frame is not None:
painter.fillRect(self.rect(), QColor(0, 0, 0))
scaled = self._frame.scaled(
self.size(),
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
)
x = (self.width() - scaled.width()) // 2
y = (self.height() - scaled.height()) // 2
painter.drawImage(x, y, scaled)
else:
painter.fillRect(self.rect(), QColor(20, 20, 20))
painter.setPen(QColor(100, 100, 100))
painter.drawText(self.rect(), Qt.AlignCenter, "No camera feed")