From 8d1a5299ce152e5772197b9d2d2e52dbcb89200a Mon Sep 17 00:00:00 2001 From: Dom Cobley Date: Fri, 26 Jun 2026 13:49:29 +0100 Subject: [PATCH 1/2] previews: Add native-Wayland Qt GL preview (Preview.QTGL_WL) QtGlPreview (QGlPicamera2) renders raw EGL straight onto the widget's native window: WA_PaintOnScreen plus eglCreateWindowSurface(winId()). That contract only holds on X11, where winId() is an X window. On Wayland eglCreateWindowSurface needs a wl_egl_window built from the window's wl_surface, and no Qt binding exposes that per-window surface to Python (Qt6 dropped the public per-window native interface; only QNativeInterface::QWaylandApplication remains). So under a Wayland compositor QtGlPreview can only run through XWayland, which adds a per-frame texture-format/copy pass in the compositor. Add an alternative preview, Preview.QTGL_WL (QtGlPreviewWayland, widget QGlPicamera2Wl), that renders through Qt's own OpenGL context via QOpenGLWidget. Qt creates and owns the platform surface (the wl_egl_window on Wayland, the GLX/EGL drawable on X11), so we never touch wl_surface and there is no platform-specific code. The only thing the zero-copy path needs is an EGLDisplay to import the camera dmabuf on, and inside paintGL() Qt's context is current, so eglGetCurrentDisplay() returns the right display on either platform. The dmabuf -> EGLImage(EGL_LINUX_DMA_BUF_EXT) -> GL_OES_EGL_image_external sampling is reused unchanged. Because it uses Qt's context, the same widget works on X11 too, but the existing Preview.QTGL is left as the default so nothing changes for current users; QTGL_WL is purely additive and opt-in. Enabling it: from picamera2 import Picamera2, Preview picam2 = Picamera2() picam2.start_preview(Preview.QTGL_WL) picam2.configure(picam2.create_preview_configuration()) picam2.start() See examples/preview_qtgl_wayland.py. Performance (Pi 4, IMX219, labwc, Mesa 25.0.7 / V3D; GPU-busy time from DRM fdinfo, summed across the preview client and the compositor, over a 20s window): 1920x1080 QTGL (XWayland) 59.8 fps gl 15.29 ms/frame labwc tfu 2.77 ms/frame QTGL_WL (Wayland) 80.9 fps gl 11.38 ms/frame labwc tfu 0 3840x2160 QTGL (XWayland) 30.1 fps gl 31.16 ms/frame labwc tfu 9.31 ms/frame QTGL_WL (Wayland) 42.3 fps gl 24.95 ms/frame labwc tfu 0 The native path eliminates the compositor's XWayland texture-format (tfu) pass entirely; that cost scales with buffer size (~2.8 ms/frame at 1080p, ~9.3 ms/frame at 4K). At the same GPU saturation that freed budget becomes more delivered frames (+35% at 1080p, +40% at 4K). Trade-off: QOpenGLWidget renders into an FBO that Qt then composites into the window (one extra blit) rather than rendering directly to the surface; this shows up as higher client-side render cost, most visibly at 4K, but is outweighed by dropping the compositor format pass. Implementation notes: - Requests a GLES context (samplerExternalOES / GL_OES_EGL_image_external live in GLES). - Camera frames arrive on the GUI thread via the existing QSocketNotifier; render_request() stashes the request and calls update(), and the GL work happens in paintGL() with Qt's context current. - Live resize is handled (resizeGL repaints; the viewport, including aspect-ratio letterboxing, is recomputed from the live widget size every repaint). Signed-off-by: Dom Cobley --- CHANGELOG.md | 2 + examples/preview_qtgl_wayland.py | 26 ++ picamera2/picamera2.py | 3 +- picamera2/previews/__init__.py | 2 +- picamera2/previews/q_gl_picamera2_wl.py | 386 ++++++++++++++++++++++++ picamera2/previews/qt.py | 6 + picamera2/previews/qt_previews.py | 13 + 7 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 examples/preview_qtgl_wayland.py create mode 100644 picamera2/previews/q_gl_picamera2_wl.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fc17900d..f8465cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +* Add `Preview.QTGL_WL`, a GPU-accelerated Qt preview that runs as a native Wayland client (as well as X11), avoiding XWayland. See `examples/preview_qtgl_wayland.py`. + ### Changed ## 0.3.36 Beta Release 35 diff --git a/examples/preview_qtgl_wayland.py b/examples/preview_qtgl_wayland.py new file mode 100644 index 00000000..720896db --- /dev/null +++ b/examples/preview_qtgl_wayland.py @@ -0,0 +1,26 @@ +#!/usr/bin/python3 + +# QtGlPreviewWayland is a GPU-accelerated Qt preview (like QtGlPreview) that +# also works as a *native* Wayland client, not only through XWayland. +# +# QtGlPreview renders raw EGL onto the widget's native window, which only works +# on X11; under a Wayland compositor it falls back to XWayland. QtGlPreviewWayland +# instead renders through Qt's own OpenGL context (QOpenGLWidget), so it needs no +# X server and composites natively. The zero-copy dmabuf import is unchanged. +# +# Select it with Preview.QTGL_WL. Prefer it when running under Wayland (e.g. +# labwc) and you want to avoid XWayland; on X11 the regular Preview.QTGL is the +# usual choice. + +import time + +from picamera2 import Picamera2, Preview + +picam2 = Picamera2() +picam2.start_preview(Preview.QTGL_WL) + +preview_config = picam2.create_preview_configuration() +picam2.configure(preview_config) + +picam2.start() +time.sleep(5) diff --git a/picamera2/picamera2.py b/picamera2/picamera2.py index 5e9f2fd8..a77d437b 100644 --- a/picamera2/picamera2.py +++ b/picamera2/picamera2.py @@ -28,7 +28,7 @@ from picamera2.allocators import DmaAllocator from picamera2.encoders import Encoder, H264Encoder, MJPEGEncoder, Quality from picamera2.outputs import FileOutput, PyavOutput -from picamera2.previews import DrmPreview, NullPreview, QtGlPreview, QtPreview +from picamera2.previews import DrmPreview, NullPreview, QtGlPreview, QtGlPreviewWayland, QtPreview from .configuration import CameraConfiguration from .controls import Controls @@ -52,6 +52,7 @@ class Preview(Enum): DRM = DrmPreview QT = QtPreview QTGL = QtGlPreview + QTGL_WL = QtGlPreviewWayland class GlobalCameraInfo(TypedDict): diff --git a/picamera2/previews/__init__.py b/picamera2/previews/__init__.py index 83068efd..e512d9d7 100644 --- a/picamera2/previews/__init__.py +++ b/picamera2/previews/__init__.py @@ -1,3 +1,3 @@ from .drm_preview import DrmPreview from .null_preview import NullPreview -from .qt_previews import QtGlPreview, QtPreview +from .qt_previews import QtGlPreview, QtGlPreviewWayland, QtPreview diff --git a/picamera2/previews/q_gl_picamera2_wl.py b/picamera2/previews/q_gl_picamera2_wl.py new file mode 100644 index 00000000..57e96502 --- /dev/null +++ b/picamera2/previews/q_gl_picamera2_wl.py @@ -0,0 +1,386 @@ +# +# q_gl_picamera2_wl.py - a GPU-accelerated Qt viewfinder that works on native +# Wayland (as well as X11), unlike QGlPicamera2. +# +# QGlPicamera2 renders raw EGL straight onto the widget's native window +# (WA_PaintOnScreen + eglCreateWindowSurface(winId())). That contract only +# holds on X11, where winId() is an X window; on Wayland eglCreateWindowSurface +# needs a wl_egl_window, and no Qt binding exposes the per-window wl_surface to +# build one. So on Wayland QGlPicamera2 can only run through XWayland. +# +# This widget side-steps that by rendering through Qt's own GL context +# (QOpenGLWidget): Qt creates and owns the platform surface (the wl_egl_window +# on Wayland, the GLX/EGL drawable on X11), and we just draw inside paintGL(). +# The only thing the zero-copy dmabuf path needs is an EGLDisplay to import the +# camera buffer on - and inside paintGL Qt's context is current, so +# eglGetCurrentDisplay() hands us the right display on either platform. No +# wl_surface, no platform branching, no private Qt APIs. +# +# Trade-off vs QGlPicamera2: QOpenGLWidget renders into an FBO that Qt then +# composites into the window, i.e. one extra full-frame blit. For a viewfinder +# that is fine, and it is the price of being platform-portable. +# +import os +import threading +from functools import lru_cache +from operator import attrgetter + +os.environ["PYOPENGL_PLATFORM"] = "egl" + +from libcamera import Transform +from OpenGL.EGL.EXT.image_dma_buf_import import * +from OpenGL.EGL.KHR.image import * +from OpenGL.EGL.VERSION.EGL_1_0 import * +from OpenGL.EGL.VERSION.EGL_1_2 import * +from OpenGL.EGL.VERSION.EGL_1_3 import * +from OpenGL.GL import shaders +from OpenGL.GLES2.OES.EGL_image import * +from OpenGL.GLES2.OES.EGL_image_external import * +from OpenGL.GLES2.VERSION.GLES2_2_0 import * +from OpenGL.GLES3.VERSION.GLES3_3_0 import * + +from picamera2.previews.gl_helpers import * + +from .qt_compatibility import _QT_BINDING, _get_qt_modules + + +@lru_cache(maxsize=None, typed=False) +def _get_qglpicamera2_wl(qt_module: _QT_BINDING): + QtCore, QtGui, QtWidgets = _get_qt_modules(qt_module) + QSocketNotifier, Qt, pyqtSignal, pyqtSlot = attrgetter( + 'QSocketNotifier', 'Qt', 'pyqtSignal', 'pyqtSlot')(QtCore) + QSurfaceFormat = QtGui.QSurfaceFormat + + # QOpenGLWidget lives in QtWidgets on Qt5 but moved to QtOpenGLWidgets on Qt6. + if hasattr(QtWidgets, 'QOpenGLWidget'): + QOpenGLWidget = QtWidgets.QOpenGLWidget + else: + import importlib + QOpenGLWidget = importlib.import_module( + '.QtOpenGLWidgets', qt_module.value).QOpenGLWidget + + def _gles_format(): + fmt = QSurfaceFormat() + # samplerExternalOES / GL_OES_EGL_image_external live in GLES. + try: + fmt.setRenderableType(QSurfaceFormat.OpenGLES) # Qt5 + except AttributeError: + fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGLES) # Qt6 + fmt.setVersion(3, 1) + return fmt + + class QGlPicamera2Wl(QOpenGLWidget): + done_signal = pyqtSignal(object) + + def __init__(self, picam2, parent=None, width=640, height=480, + bg_colour=(20, 20, 20), keep_ar=True, transform=None, + preview_window=None): + super().__init__(parent=parent) + self.setFormat(_gles_format()) + self.resize(width, height) + + self.bg_colour = [colour / 255.0 for colour in bg_colour] + [1.0] + self.keep_ar = keep_ar + self.transform = Transform() if transform is None else transform + self.lock = threading.Lock() + self.buffers = {} + self.current_request = None + self.own_current = False + self.stop_count = 0 + self.title_function = None + + self.egl_display = None # captured from Qt in initializeGL + self.max_texture_size = 2048 + self.program_image = None + self.program_overlay = None + self.overlay_texture = None + self.overlay_present = False + self.overlay_array = None + self._overlay_dirty = False + self._gl_ready = False + + self.picamera2 = picam2 + picam2.attach_preview(preview_window) + self.preview_window = preview_window + + self.camera_notifier = QSocketNotifier( + self.picamera2.notifyme_r, QSocketNotifier.Type.Read, self) + self.camera_notifier.activated.connect(self.handle_requests) + self.destroyed.connect(lambda: self.cleanup()) + self.running = True + + # ------------------------------------------------------------------ + # GL setup - runs with Qt's context current. + # ------------------------------------------------------------------ + def initializeGL(self): + # Qt's context is current here, so eglGetCurrentDisplay() is Qt's + # EGLDisplay (Wayland under Wayland, X11 under X11/XWayland); fall + # back to the default display if PyOpenGL can't see a current one. + # + # PyOpenGL's lazy extension check for eglCreateImageKHR queries + # `eglGetCurrentDisplay() or eglGetDisplay(EGL_DEFAULT_DISPLAY)` for + # EGL_EXTENSIONS and raises EGL_NOT_INITIALIZED if that display was + # never initialised. eglInitialize is idempotent/refcounted, so we + # initialise it here. Crucially, when no current display is visible + # this expression resolves to the default display - the very one + # that fallback uses - so initialising it makes the check pass. + self.egl_display = eglGetCurrentDisplay() or eglGetDisplay(EGL_DEFAULT_DISPLAY) + eglInitialize(self.egl_display, EGLint(), EGLint()) + n = GLint() + glGetIntegerv(GL_MAX_TEXTURE_SIZE, n) + self.max_texture_size = n.value + self._build_programs() + self._gl_ready = True + + def _build_programs(self): + vertShaderSrc_image = f""" + attribute vec2 aPosition; + varying vec2 texcoord; + void main() + {{ + gl_Position = vec4(aPosition * 2.0 - 1.0, 0.0, 1.0); + texcoord.x = {'1.0 - ' if self.transform.hflip else ''}aPosition.x; + texcoord.y = {'' if self.transform.vflip else '1.0 - '}aPosition.y; + }} + """ + fragShaderSrc_image = """ + #extension GL_OES_EGL_image_external : enable + precision mediump float; + varying vec2 texcoord; + uniform samplerExternalOES texture; + void main() + { + gl_FragColor = texture2D(texture, texcoord); + } + """ + vertShaderSrc_overlay = """ + attribute vec2 aPosition; + varying vec2 texcoord; + void main() + { + gl_Position = vec4(aPosition * 2.0 - 1.0, 0.0, 1.0); + texcoord.x = aPosition.x; + texcoord.y = 1.0 - aPosition.y; + } + """ + fragShaderSrc_overlay = """ + precision mediump float; + varying vec2 texcoord; + uniform sampler2D overlay; + void main() + { + gl_FragColor = texture2D(overlay, texcoord); + } + """ + self.program_image = shaders.compileProgram( + shaders.compileShader(vertShaderSrc_image, GL_VERTEX_SHADER), + shaders.compileShader(fragShaderSrc_image, GL_FRAGMENT_SHADER)) + self.program_overlay = shaders.compileProgram( + shaders.compileShader(vertShaderSrc_overlay, GL_VERTEX_SHADER), + shaders.compileShader(fragShaderSrc_overlay, GL_FRAGMENT_SHADER)) + + # Client-side vertex array (allowed in GLES); keep a ref alive. + self._vertPositions = [0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0] + for prog in (self.program_image, self.program_overlay): + loc = glGetAttribLocation(prog, "aPosition") + glVertexAttribPointer(loc, 2, GL_FLOAT, GL_FALSE, 0, + self._vertPositions) + glEnableVertexAttribArray(loc) + glUseProgram(self.program_overlay) + glUniform1i(glGetUniformLocation(self.program_overlay, "overlay"), 0) + self.overlay_texture = glGenTextures(1) + + # ------------------------------------------------------------------ + # dmabuf -> EGLImage -> external texture (identical to QGlPicamera2). + # ------------------------------------------------------------------ + class Buffer: + FMT_MAP = { + "XRGB8888": "XR24", "XBGR8888": "XB24", + "YUYV": "YUYV", "UYVY": "UYVY", + "YUV420": "YU12", "YVU420": "YV12", + } + + def __init__(self, display, completed_request, max_texture_size): + picam2 = completed_request.picam2 + stream = picam2.stream_map[picam2.display_stream_name] + fb = completed_request.request.buffers[stream] + cfg = stream.configuration + pixel_format = str(cfg.pixel_format) + if pixel_format not in self.FMT_MAP: + raise RuntimeError( + f"Format {pixel_format} not supported by QGlPicamera2Wl preview") + fmt = str_to_fourcc(self.FMT_MAP[pixel_format]) + w, h = (cfg.size.width, cfg.size.height) + if w > max_texture_size or h > max_texture_size: + raise RuntimeError( + f"Maximum supported preview image size is {max_texture_size}") + if pixel_format in ("YUV420", "YVU420"): + h2 = h // 2 + stride2 = cfg.stride // 2 + attribs = [ + EGL_WIDTH, w, EGL_HEIGHT, h, + EGL_LINUX_DRM_FOURCC_EXT, fmt, + EGL_DMA_BUF_PLANE0_FD_EXT, fb.planes[0].fd, + EGL_DMA_BUF_PLANE0_OFFSET_EXT, 0, + EGL_DMA_BUF_PLANE0_PITCH_EXT, cfg.stride, + EGL_DMA_BUF_PLANE1_FD_EXT, fb.planes[0].fd, + EGL_DMA_BUF_PLANE1_OFFSET_EXT, h * cfg.stride, + EGL_DMA_BUF_PLANE1_PITCH_EXT, stride2, + EGL_DMA_BUF_PLANE2_FD_EXT, fb.planes[0].fd, + EGL_DMA_BUF_PLANE2_OFFSET_EXT, h * cfg.stride + h2 * stride2, + EGL_DMA_BUF_PLANE2_PITCH_EXT, stride2, + EGL_NONE, + ] + else: + attribs = [ + EGL_WIDTH, w, EGL_HEIGHT, h, + EGL_LINUX_DRM_FOURCC_EXT, fmt, + EGL_DMA_BUF_PLANE0_FD_EXT, fb.planes[0].fd, + EGL_DMA_BUF_PLANE0_OFFSET_EXT, 0, + EGL_DMA_BUF_PLANE0_PITCH_EXT, cfg.stride, + EGL_NONE, + ] + image = eglCreateImageKHR(display, EGL_NO_CONTEXT, + EGL_LINUX_DMA_BUF_EXT, None, attribs) + self.texture = glGenTextures(1) + glBindTexture(GL_TEXTURE_EXTERNAL_OES, self.texture) + glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MAG_FILTER, GL_LINEAR) + glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MIN_FILTER, GL_LINEAR) + glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE) + glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE) + glEGLImageTargetTexture2DOES(GL_TEXTURE_EXTERNAL_OES, image) + eglDestroyImageKHR(display, image) + + # ------------------------------------------------------------------ + # Painting - Qt makes the context current and presents the FBO. + # ------------------------------------------------------------------ + def paintGL(self): + if not self._gl_ready: + return + with self.lock: + self._repaint(self.current_request) + + def resizeGL(self, w, h): + # Qt has already resized our FBO and will call paintGL after this; + # repaint here too so a resize while the stream is paused (no new + # camera frame) still updates the viewport/letterboxing. The + # viewport is recomputed from the live widget size every repaint, + # so this covers window drags and output/scale changes. + if self._gl_ready: + with self.lock: + self._repaint(self.current_request) + + def _repaint(self, completed_request): + if completed_request and completed_request.request not in self.buffers: + if self.stop_count != self.picamera2.stop_count: + for _, buffer in self.buffers.items(): + glDeleteTextures(1, [buffer.texture]) + self.buffers = {} + self.stop_count = self.picamera2.stop_count + self.buffers[completed_request.request] = self.Buffer( + self.egl_display, completed_request, self.max_texture_size) + + if self._overlay_dirty and self.overlay_array is not None: + glBindTexture(GL_TEXTURE_2D, self.overlay_texture) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE) + height, width, _ = self.overlay_array.shape + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, + GL_RGBA, GL_UNSIGNED_BYTE, self.overlay_array) + self._overlay_dirty = False + + x_off, y_off, w, h = self.recalculate_viewport() + glViewport(x_off, y_off, w, h) + glClearColor(*self.bg_colour) + glClear(GL_COLOR_BUFFER_BIT) + + if completed_request: + buffer = self.buffers[completed_request.request] + glUseProgram(self.program_image) + glBindTexture(GL_TEXTURE_EXTERNAL_OES, buffer.texture) + glDrawArrays(GL_TRIANGLE_FAN, 0, 4) + + if self.overlay_present: + glUseProgram(self.program_overlay) + glBindTexture(GL_TEXTURE_2D, self.overlay_texture) + glDrawArrays(GL_TRIANGLE_FAN, 0, 4) + # No eglSwapBuffers: QOpenGLWidget presents its FBO for us. + + def recalculate_viewport(self): + dpr = self.devicePixelRatioF() + window_w = int(self.width() * dpr) + window_h = int(self.height() * dpr) + stream_map = self.picamera2.stream_map + camera_config = self.picamera2.camera_config + if not self.keep_ar or not camera_config or camera_config['display'] is None: + return 0, 0, window_w, window_h + image_w = stream_map[camera_config['display']].configuration.size.width + image_h = stream_map[camera_config['display']].configuration.size.height + if image_w * window_h > window_w * image_h: + w = window_w + h = w * image_h // image_w + else: + h = window_h + w = h * image_w // image_h + return (window_w - w) // 2, (window_h - h) // 2, w, h + + # ------------------------------------------------------------------ + # Camera frames arrive on the GUI thread (via the QSocketNotifier), + # so we just stash the request and schedule a repaint; the GL work + # happens in paintGL with Qt's context current. + # ------------------------------------------------------------------ + def render_request(self, completed_request): + if self.title_function is not None: + self.setWindowTitle(self.title_function(completed_request.get_metadata())) + with self.lock: + if self.current_request and self.own_current: + self.current_request.release() + self.current_request = completed_request + self.own_current = completed_request.config['buffer_count'] > 1 + if self.own_current: + self.current_request.acquire() + self.update() + + @pyqtSlot() + def handle_requests(self): + if not self.running: + return + self.picamera2.notifymeread.read() + self.picamera2.process_requests(self) + + def signal_done(self, job): + self.done_signal.emit(job) + + def set_overlay(self, overlay): + with self.lock: + self.overlay_array = overlay + self.overlay_present = overlay is not None + self._overlay_dirty = overlay is not None + self.update() + + def cleanup(self): + if not self.running: + return + self.running = False + self.camera_notifier.deleteLater() + try: + self.makeCurrent() + for _, buffer in self.buffers.items(): + glDeleteTextures(1, [buffer.texture]) + self.doneCurrent() + except Exception: + pass + self.buffers = {} + if self.current_request is not None and self.own_current: + self.current_request.release() + self.current_request = None + self.picamera2.detach_preview() + if self.preview_window is not None: + self.preview_window.qpicamera2 = None + + def closeEvent(self, event): + self.cleanup() + + return QGlPicamera2Wl diff --git a/picamera2/previews/qt.py b/picamera2/previews/qt.py index 618512db..c5733625 100644 --- a/picamera2/previews/qt.py +++ b/picamera2/previews/qt.py @@ -12,6 +12,7 @@ try: from .q_gl_picamera2 import _get_qglpicamera2 + from .q_gl_picamera2_wl import _get_qglpicamera2_wl except Exception: _log.warning("OpenGL will not be available") @@ -36,4 +37,9 @@ def __getattr__(name: str): return _get_qglpicamera2(_QT_BINDING.PySide2) elif name == 'QGlSide6Picamera2': return _get_qglpicamera2(_QT_BINDING.PySide6) + # OpenGL accelerated Qt widget that also works on native Wayland + elif name == 'QGlPicamera2Wl': + return _get_qglpicamera2_wl(_QT_BINDING.PyQt5) + elif name == 'QGl6Picamera2Wl': + return _get_qglpicamera2_wl(_QT_BINDING.PyQt6) raise AttributeError(f"qt has no attribute '{name}'") diff --git a/picamera2/previews/qt_previews.py b/picamera2/previews/qt_previews.py index cc8c39a0..c9320360 100644 --- a/picamera2/previews/qt_previews.py +++ b/picamera2/previews/qt_previews.py @@ -144,3 +144,16 @@ def make_picamera2_widget(self, picam2, width=640, height=480, transform=None): def get_title(self): return "QtGlPreview" + + +class QtGlPreviewWayland(QtPreviewBase): + # Like QtGlPreview, but uses the QOpenGLWidget-based QGlPicamera2Wl, which + # renders through Qt's own GL context and so works on native Wayland as + # well as X11 (QtGlPreview's raw-EGL-on-winId path is X11/XWayland only). + def make_picamera2_widget(self, picam2, width=640, height=480, transform=None): + from picamera2.previews.qt import QGlPicamera2Wl + + return QGlPicamera2Wl(picam2, width=self.width, height=self.height, transform=self.transform, preview_window=self) + + def get_title(self): + return "QtGlPreviewWayland" From 700e4312fb27a1968bcaf5f22797b26b95796fa4 Mon Sep 17 00:00:00 2001 From: Dom Cobley Date: Fri, 26 Jun 2026 14:14:04 +0100 Subject: [PATCH 2/2] previews: Add direct-render native-Wayland Qt GL preview (Preview.QTGL_WL_DIRECT) Preview.QTGL_WL uses a QOpenGLWidget, which always renders into an offscreen FBO that Qt then composites into the window - one extra full-frame GPU blit per frame. Add Preview.QTGL_WL_DIRECT, which uses a QOpenGLWindow embedded with QWidget.createWindowContainer(): the QOpenGLWindow owns its own native (sub)surface and presents directly via eglSwapBuffers, so there is no in-process blit. This restores the directness of the original X11 QGlPicamera2 (WA_PaintOnScreen) while staying native Wayland - on Wayland Qt backs the window with a wl_egl_window, on X11 with an X drawable. The zero-copy dmabuf -> EGLImage -> GL_OES_EGL_image_external path is unchanged. Rendering is done synchronously in render_request() (makeCurrent -> repaint -> swapBuffers), exactly like the original QGlPicamera2. Deferred update()/requestUpdate() does not work here: it is throttled to Wayland frame callbacks, which an embedded subsurface does not reliably receive (observed ~1 paint/s), so the preview would starve. A QOpenGLWindow, unlike a QOpenGLWidget, lets us drive its context from outside paintGL, which makes the synchronous path possible. Both previews are kept. QTGL_WL_DIRECT is the fastest option; QTGL_WL remains for embedders that need it, because container/native windows always stack above sibling widgets and cannot be clipped by non-rectangular masks. For a viewfinder that fills its area this is fine (overlays are drawn inside this GL context); apps that float Qt widgets over the preview should use QTGL_WL. Enabling it: from picamera2 import Picamera2, Preview picam2 = Picamera2() picam2.start_preview(Preview.QTGL_WL_DIRECT) picam2.configure(picam2.create_preview_configuration()) picam2.start() See examples/preview_qtgl_wayland_direct.py. Performance (Pi 4, IMX219, labwc, Mesa 25.0.7 / V3D; GPU-busy time from DRM fdinfo summed across the preview client and the compositor; the client-side render/frame column isolates the blit cost; 20s window): 1920x1080 QTGL (XWayland) 59.5 fps gl 15.33 ms/f client render 1.64 ms/f QTGL_WL (FBO) 80.0 fps gl 11.39 ms/f client render 3.58 ms/f QTGL_WL_DIRECT 90.4 fps gl 7.42 ms/f client render 1.02 ms/f 3840x2160 QTGL (XWayland) 30.1 fps gl 31.16 ms/f client render 5.73 ms/f QTGL_WL (FBO) 42.2 fps gl 25.05 ms/f client render 13.70 ms/f QTGL_WL_DIRECT 80.0 fps gl 12.07 ms/f client render 3.64 ms/f Removing the blit cuts the preview client's per-frame GPU render cost from 3.58 to 1.02 ms at 1080p and from 13.70 to 3.64 ms at 4K (a ~2.6 / ~10 ms/frame saving), and roughly doubles 4K throughput versus XWayland (30 -> 80 fps). Signed-off-by: Dom Cobley --- CHANGELOG.md | 1 + examples/preview_qtgl_wayland_direct.py | 24 ++ picamera2/picamera2.py | 4 +- picamera2/previews/__init__.py | 2 +- .../previews/q_gl_picamera2_wl_direct.py | 381 ++++++++++++++++++ picamera2/previews/qt.py | 7 + picamera2/previews/qt_previews.py | 14 + 7 files changed, 431 insertions(+), 2 deletions(-) create mode 100644 examples/preview_qtgl_wayland_direct.py create mode 100644 picamera2/previews/q_gl_picamera2_wl_direct.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f8465cd1..cd18d276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added * Add `Preview.QTGL_WL`, a GPU-accelerated Qt preview that runs as a native Wayland client (as well as X11), avoiding XWayland. See `examples/preview_qtgl_wayland.py`. +* Add `Preview.QTGL_WL_DIRECT`, a native-Wayland Qt preview that renders directly to the window surface (QOpenGLWindow), avoiding the FBO blit of `Preview.QTGL_WL` for higher throughput. See `examples/preview_qtgl_wayland_direct.py`. ### Changed diff --git a/examples/preview_qtgl_wayland_direct.py b/examples/preview_qtgl_wayland_direct.py new file mode 100644 index 00000000..1a53f00f --- /dev/null +++ b/examples/preview_qtgl_wayland_direct.py @@ -0,0 +1,24 @@ +#!/usr/bin/python3 + +# QtGlPreviewWaylandDirect is like QtGlPreviewWayland (a native-Wayland, +# GPU-accelerated Qt preview) but renders straight to the window surface using +# a QOpenGLWindow, avoiding the QOpenGLWidget FBO -> window blit. This is the +# fastest option, especially at high resolutions. +# +# Caveat: it uses a container/native window, which always stacks above sibling +# Qt widgets and can't be clipped by non-rectangular masks. For a viewfinder +# that fills its area this is fine (overlays are drawn inside the GL context); +# apps that float Qt widgets over the preview should use Preview.QTGL_WL. + +import time + +from picamera2 import Picamera2, Preview + +picam2 = Picamera2() +picam2.start_preview(Preview.QTGL_WL_DIRECT) + +preview_config = picam2.create_preview_configuration() +picam2.configure(preview_config) + +picam2.start() +time.sleep(5) diff --git a/picamera2/picamera2.py b/picamera2/picamera2.py index a77d437b..348fdee4 100644 --- a/picamera2/picamera2.py +++ b/picamera2/picamera2.py @@ -28,7 +28,8 @@ from picamera2.allocators import DmaAllocator from picamera2.encoders import Encoder, H264Encoder, MJPEGEncoder, Quality from picamera2.outputs import FileOutput, PyavOutput -from picamera2.previews import DrmPreview, NullPreview, QtGlPreview, QtGlPreviewWayland, QtPreview +from picamera2.previews import (DrmPreview, NullPreview, QtGlPreview, QtGlPreviewWayland, + QtGlPreviewWaylandDirect, QtPreview) from .configuration import CameraConfiguration from .controls import Controls @@ -53,6 +54,7 @@ class Preview(Enum): QT = QtPreview QTGL = QtGlPreview QTGL_WL = QtGlPreviewWayland + QTGL_WL_DIRECT = QtGlPreviewWaylandDirect class GlobalCameraInfo(TypedDict): diff --git a/picamera2/previews/__init__.py b/picamera2/previews/__init__.py index e512d9d7..e07cd7d7 100644 --- a/picamera2/previews/__init__.py +++ b/picamera2/previews/__init__.py @@ -1,3 +1,3 @@ from .drm_preview import DrmPreview from .null_preview import NullPreview -from .qt_previews import QtGlPreview, QtGlPreviewWayland, QtPreview +from .qt_previews import QtGlPreview, QtGlPreviewWayland, QtGlPreviewWaylandDirect, QtPreview diff --git a/picamera2/previews/q_gl_picamera2_wl_direct.py b/picamera2/previews/q_gl_picamera2_wl_direct.py new file mode 100644 index 00000000..3a2d4665 --- /dev/null +++ b/picamera2/previews/q_gl_picamera2_wl_direct.py @@ -0,0 +1,381 @@ +# +# q_gl_picamera2_wl_direct.py - native-Wayland Qt GL viewfinder that renders +# straight to the window surface (no intermediate FBO blit). +# +# QGlPicamera2Wl (q_gl_picamera2_wl.py) uses a QOpenGLWidget, which always +# renders into an offscreen FBO that Qt then composites into the window - one +# extra full-frame GPU blit per frame. This variant uses a QOpenGLWindow +# embedded with QWidget.createWindowContainer(): the QOpenGLWindow owns its own +# native (sub)surface and presents directly via eglSwapBuffers, so there is no +# in-process blit. This restores the directness of the original X11 +# QGlPicamera2 (WA_PaintOnScreen) while remaining native Wayland - on Wayland +# Qt backs the QOpenGLWindow with a wl_egl_window, on X11 with an X drawable. +# +# Trade-off: container/native windows always stack above sibling widgets and +# can't be clipped by non-rectangular masks. For a viewfinder that fills its +# area this is fine (overlays are drawn inside this GL context), but apps that +# float Qt widgets over the preview should prefer QGlPicamera2Wl. +# +# The zero-copy dmabuf -> EGLImage -> GL_OES_EGL_image_external path and the +# eglGetCurrentDisplay() trick are identical to QGlPicamera2Wl. +# +import os +import threading +from functools import lru_cache +from operator import attrgetter + +os.environ["PYOPENGL_PLATFORM"] = "egl" + +from libcamera import Transform +from OpenGL.EGL.EXT.image_dma_buf_import import * +from OpenGL.EGL.KHR.image import * +from OpenGL.EGL.VERSION.EGL_1_0 import * +from OpenGL.EGL.VERSION.EGL_1_2 import * +from OpenGL.EGL.VERSION.EGL_1_3 import * +from OpenGL.GL import shaders +from OpenGL.GLES2.OES.EGL_image import * +from OpenGL.GLES2.OES.EGL_image_external import * +from OpenGL.GLES2.VERSION.GLES2_2_0 import * +from OpenGL.GLES3.VERSION.GLES3_3_0 import * + +from picamera2.previews.gl_helpers import * + +from .qt_compatibility import _QT_BINDING, _get_qt_modules + + +@lru_cache(maxsize=None, typed=False) +def _get_qglpicamera2_wl_direct(qt_module: _QT_BINDING): + QtCore, QtGui, QtWidgets = _get_qt_modules(qt_module) + QSocketNotifier, Qt, pyqtSignal, pyqtSlot = attrgetter( + 'QSocketNotifier', 'Qt', 'pyqtSignal', 'pyqtSlot')(QtCore) + QSurfaceFormat = QtGui.QSurfaceFormat + QWidget = QtWidgets.QWidget + QVBoxLayout = QtWidgets.QVBoxLayout + + # QOpenGLWindow is in QtGui on Qt5, QtOpenGL on Qt6. + if hasattr(QtGui, 'QOpenGLWindow'): + QOpenGLWindow = QtGui.QOpenGLWindow + else: + import importlib + QOpenGLWindow = importlib.import_module( + '.QtOpenGL', qt_module.value).QOpenGLWindow + + def _gles_format(): + fmt = QSurfaceFormat() + try: + fmt.setRenderableType(QSurfaceFormat.OpenGLES) + except AttributeError: + fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGLES) + fmt.setVersion(3, 1) + return fmt + + class _GlWindow(QOpenGLWindow): + """The GL surface. Renders directly to its own window - no FBO blit.""" + + def __init__(self, picam2, keep_ar, transform, bg_colour): + super().__init__() + self.setFormat(_gles_format()) + self.picamera2 = picam2 + self.keep_ar = keep_ar + self.transform = Transform() if transform is None else transform + self.bg_colour = [c / 255.0 for c in bg_colour] + [1.0] + self.lock = threading.Lock() + self.buffers = {} + self.current_request = None + self.own_current = False + self.stop_count = 0 + self.egl_display = None + self.max_texture_size = 2048 + self.program_image = None + self.program_overlay = None + self.overlay_texture = None + self.overlay_present = False + self.overlay_array = None + self._overlay_dirty = False + self._gl_ready = False + + def initializeGL(self): + # Use Qt's current EGL display, falling back to the default display + # if PyOpenGL can't see a current one. PyOpenGL's lazy extension + # check for eglCreateImageKHR queries `eglGetCurrentDisplay() or + # eglGetDisplay(EGL_DEFAULT_DISPLAY)` and raises EGL_NOT_INITIALIZED + # if that display was never initialised. eglInitialize is + # idempotent/refcounted; initialising the resolved display (which is + # the default display exactly when the fallback would be used) makes + # the check pass. + self.egl_display = eglGetCurrentDisplay() or eglGetDisplay(EGL_DEFAULT_DISPLAY) + eglInitialize(self.egl_display, EGLint(), EGLint()) + n = GLint() + glGetIntegerv(GL_MAX_TEXTURE_SIZE, n) + self.max_texture_size = n.value + self._build_programs() + self._gl_ready = True + + def _build_programs(self): + vertShaderSrc_image = f""" + attribute vec2 aPosition; + varying vec2 texcoord; + void main() + {{ + gl_Position = vec4(aPosition * 2.0 - 1.0, 0.0, 1.0); + texcoord.x = {'1.0 - ' if self.transform.hflip else ''}aPosition.x; + texcoord.y = {'' if self.transform.vflip else '1.0 - '}aPosition.y; + }} + """ + fragShaderSrc_image = """ + #extension GL_OES_EGL_image_external : enable + precision mediump float; + varying vec2 texcoord; + uniform samplerExternalOES texture; + void main() { gl_FragColor = texture2D(texture, texcoord); } + """ + vertShaderSrc_overlay = """ + attribute vec2 aPosition; + varying vec2 texcoord; + void main() + { + gl_Position = vec4(aPosition * 2.0 - 1.0, 0.0, 1.0); + texcoord.x = aPosition.x; + texcoord.y = 1.0 - aPosition.y; + } + """ + fragShaderSrc_overlay = """ + precision mediump float; + varying vec2 texcoord; + uniform sampler2D overlay; + void main() { gl_FragColor = texture2D(overlay, texcoord); } + """ + self.program_image = shaders.compileProgram( + shaders.compileShader(vertShaderSrc_image, GL_VERTEX_SHADER), + shaders.compileShader(fragShaderSrc_image, GL_FRAGMENT_SHADER)) + self.program_overlay = shaders.compileProgram( + shaders.compileShader(vertShaderSrc_overlay, GL_VERTEX_SHADER), + shaders.compileShader(fragShaderSrc_overlay, GL_FRAGMENT_SHADER)) + self._vertPositions = [0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0] + for prog in (self.program_image, self.program_overlay): + loc = glGetAttribLocation(prog, "aPosition") + glVertexAttribPointer(loc, 2, GL_FLOAT, GL_FALSE, 0, + self._vertPositions) + glEnableVertexAttribArray(loc) + glUseProgram(self.program_overlay) + glUniform1i(glGetUniformLocation(self.program_overlay, "overlay"), 0) + self.overlay_texture = glGenTextures(1) + + class Buffer: + FMT_MAP = { + "XRGB8888": "XR24", "XBGR8888": "XB24", + "YUYV": "YUYV", "UYVY": "UYVY", + "YUV420": "YU12", "YVU420": "YV12", + } + + def __init__(self, display, completed_request, max_texture_size): + picam2 = completed_request.picam2 + stream = picam2.stream_map[picam2.display_stream_name] + fb = completed_request.request.buffers[stream] + cfg = stream.configuration + pixel_format = str(cfg.pixel_format) + if pixel_format not in self.FMT_MAP: + raise RuntimeError( + f"Format {pixel_format} not supported by QGlPicamera2WlDirect preview") + fmt = str_to_fourcc(self.FMT_MAP[pixel_format]) + w, h = (cfg.size.width, cfg.size.height) + if w > max_texture_size or h > max_texture_size: + raise RuntimeError( + f"Maximum supported preview image size is {max_texture_size}") + if pixel_format in ("YUV420", "YVU420"): + h2 = h // 2 + stride2 = cfg.stride // 2 + attribs = [ + EGL_WIDTH, w, EGL_HEIGHT, h, + EGL_LINUX_DRM_FOURCC_EXT, fmt, + EGL_DMA_BUF_PLANE0_FD_EXT, fb.planes[0].fd, + EGL_DMA_BUF_PLANE0_OFFSET_EXT, 0, + EGL_DMA_BUF_PLANE0_PITCH_EXT, cfg.stride, + EGL_DMA_BUF_PLANE1_FD_EXT, fb.planes[0].fd, + EGL_DMA_BUF_PLANE1_OFFSET_EXT, h * cfg.stride, + EGL_DMA_BUF_PLANE1_PITCH_EXT, stride2, + EGL_DMA_BUF_PLANE2_FD_EXT, fb.planes[0].fd, + EGL_DMA_BUF_PLANE2_OFFSET_EXT, h * cfg.stride + h2 * stride2, + EGL_DMA_BUF_PLANE2_PITCH_EXT, stride2, + EGL_NONE, + ] + else: + attribs = [ + EGL_WIDTH, w, EGL_HEIGHT, h, + EGL_LINUX_DRM_FOURCC_EXT, fmt, + EGL_DMA_BUF_PLANE0_FD_EXT, fb.planes[0].fd, + EGL_DMA_BUF_PLANE0_OFFSET_EXT, 0, + EGL_DMA_BUF_PLANE0_PITCH_EXT, cfg.stride, + EGL_NONE, + ] + image = eglCreateImageKHR(display, EGL_NO_CONTEXT, + EGL_LINUX_DMA_BUF_EXT, None, attribs) + self.texture = glGenTextures(1) + glBindTexture(GL_TEXTURE_EXTERNAL_OES, self.texture) + glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MAG_FILTER, GL_LINEAR) + glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MIN_FILTER, GL_LINEAR) + glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE) + glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE) + glEGLImageTargetTexture2DOES(GL_TEXTURE_EXTERNAL_OES, image) + eglDestroyImageKHR(display, image) + + def render_request(self, completed_request): + # Render synchronously, like the original QGlPicamera2: a + # QOpenGLWindow lets us make its context current and swap from + # outside paintGL. (Deferred update()/requestUpdate() is throttled + # to Wayland frame callbacks, which an embedded subsurface may not + # receive, so it would starve - hence direct rendering here.) + with self.lock: + if self.current_request and self.own_current: + self.current_request.release() + self.current_request = completed_request + self.own_current = completed_request.config['buffer_count'] > 1 + if self.own_current: + self.current_request.acquire() + if self._gl_ready and self.isExposed(): + self.makeCurrent() + self._repaint(self.current_request) + self.context().swapBuffers(self) + self.doneCurrent() + + def paintGL(self): + # Called by Qt on expose/resize; Qt swaps for us here. + if not self._gl_ready: + return + with self.lock: + self._repaint(self.current_request) + + def _repaint(self, completed_request): + if completed_request and completed_request.request not in self.buffers: + if self.stop_count != self.picamera2.stop_count: + for _, buffer in self.buffers.items(): + glDeleteTextures(1, [buffer.texture]) + self.buffers = {} + self.stop_count = self.picamera2.stop_count + self.buffers[completed_request.request] = self.Buffer( + self.egl_display, completed_request, self.max_texture_size) + + if self._overlay_dirty and self.overlay_array is not None: + glBindTexture(GL_TEXTURE_2D, self.overlay_texture) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE) + height, width, _ = self.overlay_array.shape + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, + GL_RGBA, GL_UNSIGNED_BYTE, self.overlay_array) + self._overlay_dirty = False + + x_off, y_off, w, h = self.recalculate_viewport() + glViewport(x_off, y_off, w, h) + glClearColor(*self.bg_colour) + glClear(GL_COLOR_BUFFER_BIT) + + if completed_request: + buffer = self.buffers[completed_request.request] + glUseProgram(self.program_image) + glBindTexture(GL_TEXTURE_EXTERNAL_OES, buffer.texture) + glDrawArrays(GL_TRIANGLE_FAN, 0, 4) + + if self.overlay_present: + glUseProgram(self.program_overlay) + glBindTexture(GL_TEXTURE_2D, self.overlay_texture) + glDrawArrays(GL_TRIANGLE_FAN, 0, 4) + # No eglSwapBuffers: QOpenGLWindow presents the surface for us + # (directly, with no intermediate FBO). + + def recalculate_viewport(self): + dpr = self.devicePixelRatio() + window_w = int(self.width() * dpr) + window_h = int(self.height() * dpr) + stream_map = self.picamera2.stream_map + camera_config = self.picamera2.camera_config + if not self.keep_ar or not camera_config or camera_config['display'] is None: + return 0, 0, window_w, window_h + image_w = stream_map[camera_config['display']].configuration.size.width + image_h = stream_map[camera_config['display']].configuration.size.height + if image_w * window_h > window_w * image_h: + w = window_w + h = w * image_h // image_w + else: + h = window_h + w = h * image_w // image_h + return (window_w - w) // 2, (window_h - h) // 2, w, h + + def set_overlay(self, overlay): + with self.lock: + self.overlay_array = overlay + self.overlay_present = overlay is not None + self._overlay_dirty = overlay is not None + self.update() + + def cleanup_gl(self): + try: + self.makeCurrent() + for _, buffer in self.buffers.items(): + glDeleteTextures(1, [buffer.texture]) + self.doneCurrent() + except Exception: + pass + self.buffers = {} + if self.current_request is not None and self.own_current: + self.current_request.release() + self.current_request = None + + class QGlPicamera2WlDirect(QWidget): + done_signal = pyqtSignal(object) + + def __init__(self, picam2, parent=None, width=640, height=480, + bg_colour=(20, 20, 20), keep_ar=True, transform=None, + preview_window=None): + super().__init__(parent=parent) + self.resize(width, height) + self.picamera2 = picam2 + self.preview_window = preview_window + self.title_function = None + + self._glwin = _GlWindow(picam2, keep_ar, transform, bg_colour) + container = QWidget.createWindowContainer(self._glwin, self) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(container) + + picam2.attach_preview(preview_window) + self.camera_notifier = QSocketNotifier( + self.picamera2.notifyme_r, QSocketNotifier.Type.Read, self) + self.camera_notifier.activated.connect(self.handle_requests) + self.destroyed.connect(lambda: self.cleanup()) + self.running = True + + def render_request(self, completed_request): + if self.title_function is not None: + self.setWindowTitle(self.title_function(completed_request.get_metadata())) + self._glwin.render_request(completed_request) + + @pyqtSlot() + def handle_requests(self): + if not self.running: + return + self.picamera2.notifymeread.read() + self.picamera2.process_requests(self) + + def signal_done(self, job): + self.done_signal.emit(job) + + def set_overlay(self, overlay): + self._glwin.set_overlay(overlay) + + def cleanup(self): + if not self.running: + return + self.running = False + self.camera_notifier.deleteLater() + self._glwin.cleanup_gl() + self.picamera2.detach_preview() + if self.preview_window is not None: + self.preview_window.qpicamera2 = None + + def closeEvent(self, event): + self.cleanup() + + return QGlPicamera2WlDirect diff --git a/picamera2/previews/qt.py b/picamera2/previews/qt.py index c5733625..1824cd75 100644 --- a/picamera2/previews/qt.py +++ b/picamera2/previews/qt.py @@ -13,6 +13,7 @@ try: from .q_gl_picamera2 import _get_qglpicamera2 from .q_gl_picamera2_wl import _get_qglpicamera2_wl + from .q_gl_picamera2_wl_direct import _get_qglpicamera2_wl_direct except Exception: _log.warning("OpenGL will not be available") @@ -42,4 +43,10 @@ def __getattr__(name: str): return _get_qglpicamera2_wl(_QT_BINDING.PyQt5) elif name == 'QGl6Picamera2Wl': return _get_qglpicamera2_wl(_QT_BINDING.PyQt6) + # Native-Wayland OpenGL widget that renders direct to the window surface + # (QOpenGLWindow, no FBO blit) + elif name == 'QGlPicamera2WlDirect': + return _get_qglpicamera2_wl_direct(_QT_BINDING.PyQt5) + elif name == 'QGl6Picamera2WlDirect': + return _get_qglpicamera2_wl_direct(_QT_BINDING.PyQt6) raise AttributeError(f"qt has no attribute '{name}'") diff --git a/picamera2/previews/qt_previews.py b/picamera2/previews/qt_previews.py index c9320360..d8e8b347 100644 --- a/picamera2/previews/qt_previews.py +++ b/picamera2/previews/qt_previews.py @@ -157,3 +157,17 @@ def make_picamera2_widget(self, picam2, width=640, height=480, transform=None): def get_title(self): return "QtGlPreviewWayland" + + +class QtGlPreviewWaylandDirect(QtPreviewBase): + # As QtGlPreviewWayland, but uses QGlPicamera2WlDirect (a QOpenGLWindow + # embedded via createWindowContainer) which renders straight to the window + # surface, avoiding the QOpenGLWidget FBO->window blit. See the stacking + # caveats in q_gl_picamera2_wl_direct.py. + def make_picamera2_widget(self, picam2, width=640, height=480, transform=None): + from picamera2.previews.qt import QGlPicamera2WlDirect + + return QGlPicamera2WlDirect(picam2, width=self.width, height=self.height, transform=self.transform, preview_window=self) + + def get_title(self): + return "QtGlPreviewWaylandDirect"