Implement dual output for camera using QVideoWidget and QVideoSink, add camera permission handling, and enhance overlay error messaging
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user