diff --git a/CHANGELOG.md b/CHANGELOG.md index fc17900d..cd18d276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### 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 ## 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/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 5e9f2fd8..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, QtPreview +from picamera2.previews import (DrmPreview, NullPreview, QtGlPreview, QtGlPreviewWayland, + QtGlPreviewWaylandDirect, QtPreview) from .configuration import CameraConfiguration from .controls import Controls @@ -52,6 +53,8 @@ class Preview(Enum): DRM = DrmPreview 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 83068efd..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, QtPreview +from .qt_previews import QtGlPreview, QtGlPreviewWayland, QtGlPreviewWaylandDirect, 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/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 618512db..1824cd75 100644 --- a/picamera2/previews/qt.py +++ b/picamera2/previews/qt.py @@ -12,6 +12,8 @@ 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") @@ -36,4 +38,15 @@ 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) + # 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 cc8c39a0..d8e8b347 100644 --- a/picamera2/previews/qt_previews.py +++ b/picamera2/previews/qt_previews.py @@ -144,3 +144,30 @@ 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" + + +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"