From 78676c8f68c67f9fa52ad10e9e10191866009ab1 Mon Sep 17 00:00:00 2001 From: Levi Dean Date: Wed, 12 Feb 2025 00:00:31 +0000 Subject: [PATCH 1/3] Switch to accessing Qt enumerations directly Signed-off-by: Levi Dean --- picamera2/previews/q_gl_picamera2.py | 6 +++--- picamera2/previews/q_picamera2.py | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/picamera2/previews/q_gl_picamera2.py b/picamera2/previews/q_gl_picamera2.py index 901bcc09..e42f63c8 100644 --- a/picamera2/previews/q_gl_picamera2.py +++ b/picamera2/previews/q_gl_picamera2.py @@ -79,8 +79,8 @@ def __init__(self, picam2, parent=None, width=640, height=480, bg_colour=(20, 20 super().__init__(parent=parent) self.resize(width, height) - self.setAttribute(Qt.WA_PaintOnScreen) - self.setAttribute(Qt.WA_NativeWindow) + self.setAttribute(Qt.WidgetAttribute.WA_PaintOnScreen) + self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow) self.bg_colour = [colour / 255.0 for colour in bg_colour] + [1.0] self.keep_ar = keep_ar @@ -108,7 +108,7 @@ def __init__(self, picam2, parent=None, width=640, height=480, bg_colour=(20, 20 self.preview_window = preview_window self.camera_notifier = QSocketNotifier(self.picamera2.notifyme_r, - QSocketNotifier.Read, self) + QSocketNotifier.Type.Read, self) self.camera_notifier.activated.connect(self.handle_requests) # Must always run cleanup when this widget goes away. self.destroyed.connect(lambda: self.cleanup()) diff --git a/picamera2/previews/q_picamera2.py b/picamera2/previews/q_picamera2.py index c15bb5b5..c4da6fc2 100644 --- a/picamera2/previews/q_picamera2.py +++ b/picamera2/previews/q_picamera2.py @@ -36,14 +36,14 @@ def __init__(self, picam2, parent=None, width=640, height=480, bg_colour=(20, 20 self.setScene(self.scene) self.setBackgroundBrush(QBrush(QColor(*bg_colour))) self.resize(width, height) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.enabled = True self.title_function = None self.update_overlay_signal.connect(self.update_overlay) self.camera_notifier = QSocketNotifier(self.picamera2.notifyme_r, - QSocketNotifier.Read, self) + QSocketNotifier.Type.Read, self) self.camera_notifier.activated.connect(self.handle_requests) # Must always run cleanup when this widget goes away. self.destroyed.connect(lambda: self.cleanup()) @@ -94,7 +94,7 @@ def set_overlay(self, overlay): if overlay is not None: overlay = np.copy(overlay, order='C') shape = overlay.shape - qim = QImage(overlay.data, shape[1], shape[0], QImage.Format_RGBA8888) + qim = QImage(overlay.data, shape[1], shape[0], QImage.Format.Format_RGBA8888) new_pixmap = QPixmap(qim) # No scaling here - we leave it to fitInView to set that up. self.update_overlay_signal.emit(new_pixmap) @@ -188,7 +188,7 @@ def render_request(self, completed_request): width = min(img.shape[1], stream_config["size"][0]) width -= width % 4 img = np.ascontiguousarray(img[:, :width, :3]) - fmt = QImage.Format_BGR888 if stream_config['format'] in ('RGB888', 'XRGB8888') else QImage.Format_RGB888 + fmt = QImage.Format.Format_BGR888 if stream_config['format'] in ('RGB888', 'XRGB8888') else QImage.Format.Format_RGB888 qim = QImage(img.data, width, img.shape[0], fmt) pix = QPixmap(qim) # Add the pixmap to the scene if there wasn't one, or replace it if the images have From fa4a7b3534578ec8fe56698432106dbfa5591e0a Mon Sep 17 00:00:00 2001 From: Levi Dean Date: Wed, 12 Feb 2025 00:02:11 +0000 Subject: [PATCH 2/3] Add compatibility with PySide6 and PyQt6 for QPicamera2 (Q6Picamera2, and QSide6Picamera2 respectively) Signed-off-by: Levi Dean --- picamera2/previews/q_picamera2.py | 408 +++++++++++++------------ picamera2/previews/qt.py | 11 +- picamera2/previews/qt_compatability.py | 40 +++ 3 files changed, 267 insertions(+), 192 deletions(-) create mode 100644 picamera2/previews/qt_compatability.py diff --git a/picamera2/previews/q_picamera2.py b/picamera2/previews/q_picamera2.py index c4da6fc2..a9d48cc9 100644 --- a/picamera2/previews/q_picamera2.py +++ b/picamera2/previews/q_picamera2.py @@ -2,10 +2,6 @@ import numpy as np from libcamera import Transform -from PyQt5.QtCore import (QRect, QRectF, QSize, QSocketNotifier, Qt, - pyqtSignal, pyqtSlot) -from PyQt5.QtGui import QBrush, QColor, QImage, QPixmap, QTransform -from PyQt5.QtWidgets import QGraphicsScene, QGraphicsView try: import cv2 @@ -13,199 +9,229 @@ except ImportError: cv2_available = False - -class QPicamera2(QGraphicsView): - done_signal = pyqtSignal(object) - update_overlay_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.picamera2 = picam2 - picam2.attach_preview(preview_window) - self.preview_window = preview_window - self.keep_ar = keep_ar - self.transform = Transform() if transform is None else transform - self.image_size = None - self.last_rect = QRect(0, 0, 0, 0) - - self.size = QSize(width, height) - self.pixmap = None - self.overlay = None - self.scene = QGraphicsScene() - self.setScene(self.scene) - self.setBackgroundBrush(QBrush(QColor(*bg_colour))) - self.resize(width, height) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.enabled = True - self.title_function = None - - self.update_overlay_signal.connect(self.update_overlay) - self.camera_notifier = QSocketNotifier(self.picamera2.notifyme_r, - QSocketNotifier.Type.Read, self) - self.camera_notifier.activated.connect(self.handle_requests) - # Must always run cleanup when this widget goes away. - self.destroyed.connect(lambda: self.cleanup()) - self.running = True - - def cleanup(self): - if not self.running: - return - self.running = False - del self.scene - del self.overlay - self.camera_notifier.deleteLater() - # We have to tell both the preview window and the Picamera2 object that we have - # disappeared. - self.picamera2.detach_preview() - if self.preview_window is not None: # will be none when a proper Qt app - self.preview_window.qpicamera2 = None - - def closeEvent(self, event): - self.cleanup() - - def signal_done(self, job): - self.done_signal.emit(job) - - def image_dimensions(self): - # The dimensions of the camera images we're displaying. - camera_config = self.picamera2.camera_config - if camera_config is not None and camera_config['display'] is not None: - # This works even before we receive any camera images. - size = (self.picamera2.stream_map[camera_config['display']].configuration.size.width, - self.picamera2.stream_map[camera_config['display']].configuration.size.height) - elif self.image_size is not None: - # If the camera is unconfigured, stick with the last size (if available). - size = self.image_size - else: - # Otherwise pretend the image fills everything. - rect = self.viewport().rect() - size = rect.width(), rect.height() - self.image_size = size - return size - - def set_overlay(self, overlay): - camera_config = self.picamera2.camera_config - if camera_config is None: - raise RuntimeError("Camera must be configured before using set_overlay") - - new_pixmap = None - if overlay is not None: - overlay = np.copy(overlay, order='C') - shape = overlay.shape - qim = QImage(overlay.data, shape[1], shape[0], QImage.Format.Format_RGBA8888) - new_pixmap = QPixmap(qim) - # No scaling here - we leave it to fitInView to set that up. - self.update_overlay_signal.emit(new_pixmap) - - @pyqtSlot(object) - def update_overlay(self, pix): - if pix is None: - # Delete overlay if present - if self.overlay is not None: - self.scene.removeItem(self.overlay) - self.overlay = None +from functools import lru_cache +from operator import attrgetter + +from .qt_compatability import _QT_BINDING, _get_qt_modules + +@lru_cache(maxsize=None, typed=False) +def _get_qpicamera2(qt_module: _QT_BINDING): + + # Get Qt modules + QtCore, QtGui, QtWidgets = _get_qt_modules(qt_module) + + #Get from QtCore + QRect, QRectF, QSize, QSocketNotifier, Qt, pyqtSignal, pyqtSlot = attrgetter( + 'QRect', 'QRectF', 'QSize', 'QSocketNotifier', 'Qt', 'pyqtSignal', 'pyqtSlot')(QtCore) + + #Get from QtGui + QBrush, QColor, QImage, QPixmap, QTransform = attrgetter( + 'QBrush', 'QColor', 'QImage', 'QPixmap', 'QTransform')(QtGui) + + #Get from QtWidgets + QGraphicsScene, QGraphicsView = attrgetter( + 'QGraphicsScene', 'QGraphicsView')(QtWidgets) + + class QPicamera2(QGraphicsView): + done_signal = pyqtSignal(object) + update_overlay_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.picamera2 = picam2 + picam2.attach_preview(preview_window) + self.preview_window = preview_window + self.keep_ar = keep_ar + self.transform = Transform() if transform is None else transform + self.image_size = None + self.last_rect = QRect(0, 0, 0, 0) + + self.size = QSize(width, height) + self.pixmap = None + self.overlay = None + self.scene = QGraphicsScene() + self.setScene(self.scene) + self.setBackgroundBrush(QBrush(QColor(*bg_colour))) + self.resize(width, height) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.enabled = True + self.title_function = None + + self.update_overlay_signal.connect(self.update_overlay) + self.camera_notifier = QSocketNotifier(self.picamera2.notifyme_r, + QSocketNotifier.Type.Read, self) + self.camera_notifier.activated.connect(self.handle_requests) + # Must always run cleanup when this widget goes away. + self.destroyed.connect(lambda: self.cleanup()) + self.running = True + + def cleanup(self): + if not self.running: return - elif self.overlay is None: - # Need to add the overlay to the scene - self.overlay = self.scene.addPixmap(pix) - self.overlay.setZValue(100) - else: - # Just update it - self.overlay.setPixmap(pix) - self.fitInView() - - @pyqtSlot(bool) - def set_enabled(self, enabled): - self.enabled = enabled - - def fitInView(self): - # Reimplemented fitInView to remove fixed border - image_w, image_h = self.image_dimensions() - rect = QRectF(0, 0, image_w, image_h) - self.setSceneRect(rect) - # I get one column of background peeping through on the right without this: - viewrect = self.viewport().rect().adjusted(0, 0, 1, 1) - self.resetTransform() - factor_x = viewrect.width() / image_w - factor_y = viewrect.height() / image_h - if self.keep_ar: - factor_x = min(factor_x, factor_y) - factor_y = factor_x - if self.transform.hflip: - factor_x = -factor_x - if self.transform.vflip: - factor_y = -factor_y - self.scale(factor_x, factor_y) - - # This scales the overlay to be on top of the camera image. - if self.overlay: - rect = self.overlay.boundingRect() - self.overlay.resetTransform() - factor_x = image_w / rect.width() - factor_y = image_h / rect.height() - translate_x, translate_y = 0, 0 + self.running = False + del self.scene + del self.overlay + self.camera_notifier.deleteLater() + # We have to tell both the preview window and the Picamera2 object that we have + # disappeared. + self.picamera2.detach_preview() + if self.preview_window is not None: # will be none when a proper Qt app + self.preview_window.qpicamera2 = None + + def closeEvent(self, event): + self.cleanup() + + def signal_done(self, job): + self.done_signal.emit(job) + + def image_dimensions(self): + # The dimensions of the camera images we're displaying. + camera_config = self.picamera2.camera_config + if camera_config is not None and camera_config['display'] is not None: + # This works even before we receive any camera images. + size = (self.picamera2.stream_map[camera_config['display']].configuration.size.width, + self.picamera2.stream_map[camera_config['display']].configuration.size.height) + elif self.image_size is not None: + # If the camera is unconfigured, stick with the last size (if available). + size = self.image_size + else: + # Otherwise pretend the image fills everything. + rect = self.viewport().rect() + size = rect.width(), rect.height() + self.image_size = size + return size + + def set_overlay(self, overlay): + camera_config = self.picamera2.camera_config + if camera_config is None: + raise RuntimeError("Camera must be configured before using set_overlay") + + new_pixmap = None + if overlay is not None: + overlay = np.copy(overlay, order='C') + shape = overlay.shape + qim = QImage(overlay.data, shape[1], shape[0], QImage.Format.Format_RGBA8888) + new_pixmap = QPixmap(qim) + # No scaling here - we leave it to fitInView to set that up. + self.update_overlay_signal.emit(new_pixmap) + + @pyqtSlot(object) + def update_overlay(self, pix): + if pix is None: + # Delete overlay if present + if self.overlay is not None: + self.scene.removeItem(self.overlay) + self.overlay = None + return + elif self.overlay is None: + # Need to add the overlay to the scene + self.overlay = self.scene.addPixmap(pix) + self.overlay.setZValue(100) + else: + # Just update it + self.overlay.setPixmap(pix) + self.fitInView() + + @pyqtSlot(bool) + def set_enabled(self, enabled): + self.enabled = enabled + + def fitInView(self): + # Reimplemented fitInView to remove fixed border + image_w, image_h = self.image_dimensions() + rect = QRectF(0, 0, image_w, image_h) + self.setSceneRect(rect) + # I get one column of background peeping through on the right without this: + viewrect = self.viewport().rect().adjusted(0, 0, 1, 1) + self.resetTransform() + factor_x = viewrect.width() / image_w + factor_y = viewrect.height() / image_h + if self.keep_ar: + factor_x = min(factor_x, factor_y) + factor_y = factor_x if self.transform.hflip: factor_x = -factor_x - translate_x = -rect.width() if self.transform.vflip: factor_y = -factor_y - translate_y = -rect.height() - transform = QTransform.fromScale(factor_x, factor_y) - transform.translate(translate_x, translate_y) - self.overlay.setTransform(transform, True) - - def resizeEvent(self, event): - self.fitInView() - - def render_request(self, completed_request): - """Draw the camera image using Qt.""" - if not self.enabled: - return - - if self.title_function is not None: - self.setWindowTitle(self.title_function(completed_request.get_metadata())) - - camera_config = completed_request.config - display_stream_name = camera_config['display'] - stream_config = camera_config[display_stream_name] - - img = completed_request.make_array(display_stream_name) - if stream_config["format"] in ("YUV420", "YUYV"): - if cv2_available: - if stream_config["format"] == "YUV420": - img = cv2.cvtColor(img, cv2.COLOR_YUV420p2BGR) + self.scale(factor_x, factor_y) + + # This scales the overlay to be on top of the camera image. + if self.overlay: + rect = self.overlay.boundingRect() + self.overlay.resetTransform() + factor_x = image_w / rect.width() + factor_y = image_h / rect.height() + translate_x, translate_y = 0, 0 + if self.transform.hflip: + factor_x = -factor_x + translate_x = -rect.width() + if self.transform.vflip: + factor_y = -factor_y + translate_y = -rect.height() + transform = QTransform.fromScale(factor_x, factor_y) + transform.translate(translate_x, translate_y) + self.overlay.setTransform(transform, True) + + def resizeEvent(self, event): + self.fitInView() + + def render_request(self, completed_request): + """Draw the camera image using Qt.""" + if not self.enabled: + return + + if self.title_function is not None: + self.setWindowTitle(self.title_function(completed_request.get_metadata())) + + camera_config = completed_request.config + display_stream_name = camera_config['display'] + stream_config = camera_config[display_stream_name] + + img = completed_request.make_array(display_stream_name) + if stream_config["format"] in ("YUV420", "YUYV"): + if cv2_available: + if stream_config["format"] == "YUV420": + img = cv2.cvtColor(img, cv2.COLOR_YUV420p2BGR) + else: + img = cv2.cvtColor(img, cv2.COLOR_YUV2RGB_YUYV) else: - img = cv2.cvtColor(img, cv2.COLOR_YUV2RGB_YUYV) + logging.error("Qt preview cannot display YUV420/YUYV without cv2") + return + + # Crop width for two reasons: (1) to remove "stride" padding from YUV images; + # (2) to ensure the RGB buffer passed to QImage has mandatory 4-byte alignment. + # [TODO: Consider QImage.Format_RGB32, if byte order can be made correct] + width = min(img.shape[1], stream_config["size"][0]) + width -= width % 4 + img = np.ascontiguousarray(img[:, :width, :3]) + fmt = QImage.Format.Format_BGR888 if stream_config['format'] in ('RGB888', 'XRGB8888') else QImage.Format.Format_RGB888 + qim = QImage(img.data, width, img.shape[0], fmt) + pix = QPixmap(qim) + # Add the pixmap to the scene if there wasn't one, or replace it if the images have + # changed size. + if self.pixmap is None or pix.rect() != self.last_rect: + if self.pixmap: + self.scene.removeItem(self.pixmap) + self.last_rect = pix.rect() + self.pixmap = self.scene.addPixmap(pix) + self.fitInView() else: - logging.error("Qt preview cannot display YUV420/YUYV without cv2") + # Update pixmap + self.pixmap.setPixmap(pix) + + @pyqtSlot() + def handle_requests(self): + if not self.running: return + self.picamera2.notifymeread.read() + self.picamera2.process_requests(self) + + return QPicamera2 - # Crop width for two reasons: (1) to remove "stride" padding from YUV images; - # (2) to ensure the RGB buffer passed to QImage has mandatory 4-byte alignment. - # [TODO: Consider QImage.Format_RGB32, if byte order can be made correct] - width = min(img.shape[1], stream_config["size"][0]) - width -= width % 4 - img = np.ascontiguousarray(img[:, :width, :3]) - fmt = QImage.Format.Format_BGR888 if stream_config['format'] in ('RGB888', 'XRGB8888') else QImage.Format.Format_RGB888 - qim = QImage(img.data, width, img.shape[0], fmt) - pix = QPixmap(qim) - # Add the pixmap to the scene if there wasn't one, or replace it if the images have - # changed size. - if self.pixmap is None or pix.rect() != self.last_rect: - if self.pixmap: - self.scene.removeItem(self.pixmap) - self.last_rect = pix.rect() - self.pixmap = self.scene.addPixmap(pix) - self.fitInView() - else: - # Update pixmap - self.pixmap.setPixmap(pix) - - @pyqtSlot() - def handle_requests(self): - if not self.running: - return - self.picamera2.notifymeread.read() - self.picamera2.process_requests(self) +# Define QPicamera2 class for compatability +try: + QPicamera2 = _get_qpicamera2(_QT_BINDING.PyQt5) +except ImportError: + pass diff --git a/picamera2/previews/qt.py b/picamera2/previews/qt.py index 88ec1daa..42921bcd 100644 --- a/picamera2/previews/qt.py +++ b/picamera2/previews/qt.py @@ -5,7 +5,16 @@ # which is in any case what is required for remote preview windows. from logging import getLogger -from .q_picamera2 import QPicamera2 +from .q_picamera2 import _get_qpicamera2 +from .qt_compatability import _QT_BINDING + +def __getattr__(name: str): + if name == 'QPicamera2': + return _get_qpicamera2(_QT_BINDING.PyQt5) + elif name == 'Q6Picamera2': + return _get_qpicamera2(_QT_BINDING.PyQt6) + elif name == 'QSide6Picamera2': + return _get_qpicamera2(_QT_BINDING.PySide6) _log = getLogger(__name__) diff --git a/picamera2/previews/qt_compatability.py b/picamera2/previews/qt_compatability.py new file mode 100644 index 00000000..a1243005 --- /dev/null +++ b/picamera2/previews/qt_compatability.py @@ -0,0 +1,40 @@ +from types import ModuleType +from enum import Enum +import importlib + +class _QT_BINDING(Enum): + PyQt5 = 'PyQt5' + PyQt6 = 'PyQt6' + PySide6 = 'PySide6' + +def _get_qt_modules(qt_module: _QT_BINDING) -> tuple[ModuleType, ModuleType, ModuleType]: + """ + Gets the qt modules for the given qt binding + + Parameters + ---------- + qt_module : _QT_BINDING + The module we want to get Qt QtCore, QtGui, and QtWidgets modules from + + Returns + ------- + out : tuple[ModuleType, ModuleType, ModuleType] + A tuple containing the QtCore, QtGui, and QtWidgets modules respectively, from the given python qt module + + Notes + ----- + - Assigns alternate attributes for Signals and Slots as these are different in PySide and PyQt + """ + try: + QtCoreModule = importlib.import_module('.QtCore', package=qt_module.value) + QtGuiModule = importlib.import_module('.QtGui', package=qt_module.value) + QtWidgetsModule = importlib.import_module('.QtWidgets', package=qt_module.value) + + if qt_module == _QT_BINDING.PySide6: + QtCoreModule.pyqtSignal = QtCoreModule.Signal + QtCoreModule.pyqtSlot = QtCoreModule.Slot + + return (QtCoreModule, QtGuiModule, QtWidgetsModule) + except ImportError: + raise ImportError(f'{qt_module} is not installed or could not be imported.') + From 423d40e477716d3c12886d017ead8976cceeb637 Mon Sep 17 00:00:00 2001 From: Levi Dean Date: Wed, 12 Feb 2025 13:11:39 +0000 Subject: [PATCH 3/3] Add compatibility with PySide6 and PyQt6 for QGlPicamera2 (QGl6Picamera2, and QGlSide6Picamera2 respectively) Signed-off-by: Levi Dean --- picamera2/previews/q_gl_picamera2.py | 739 ++++++++++++++------------- picamera2/previews/qt.py | 26 +- 2 files changed, 400 insertions(+), 365 deletions(-) diff --git a/picamera2/previews/q_gl_picamera2.py b/picamera2/previews/q_gl_picamera2.py index e42f63c8..c9e70622 100644 --- a/picamera2/previews/q_gl_picamera2.py +++ b/picamera2/previews/q_gl_picamera2.py @@ -15,11 +15,13 @@ 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 PyQt5.QtCore import QSocketNotifier, Qt, pyqtSignal, pyqtSlot -from PyQt5.QtWidgets import QWidget from picamera2.previews.gl_helpers import * +from functools import lru_cache +from operator import attrgetter + +from .qt_compatability import _QT_BINDING, _get_qt_modules class EglState: def __init__(self): @@ -71,367 +73,390 @@ def create_context(self): eglMakeCurrent(self.display, EGL_NO_SURFACE, EGL_NO_SURFACE, self.context) -class QGlPicamera2(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.setAttribute(Qt.WidgetAttribute.WA_PaintOnScreen) - self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow) - - 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.count = 0 - self.overlay_present = False - self.buffers = {} - self.surface = None - self.current_request = None - self.own_current = False - self.stop_count = 0 - self.title_function = None - self.egl = EglState() - if picam2.verbose_console: - print(f"EGL {eglQueryString(self.egl.display, EGL_VENDOR).decode()} " - f"{eglQueryString(self.egl.display, EGL_VERSION).decode()}") - self.init_gl() - - # set_overlay could be called before the first frame arrives, hence: - eglMakeCurrent(self.egl.display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT) - - 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) - # Must always run cleanup when this widget goes away. - self.destroyed.connect(lambda: self.cleanup()) - self.running = True - - def cleanup(self): - if not self.running: - return - self.running = False - self.camera_notifier.deleteLater() - eglDestroySurface(self.egl.display, self.surface) - self.surface = None - # We may be hanging on to a request, return it to the camera system. - if self.current_request is not None and self.own_current: - self.current_request.release() - self.current_request = None - # We have to tell both the preview window and the Picamera2 object that we have - # disappeared. - self.picamera2.detach_preview() - if self.preview_window is not None: # will be none when a proper Qt app - self.preview_window.qpicamera2 = None - - # Some extra EGL cleanup seems to be required. - for (_, buffer) in self.buffers.items(): - glDeleteTextures(1, [buffer.texture]) - self.buffers = {} - eglDestroyContext(self.egl.display, self.egl.context) - self.egl.context = None - - def closeEvent(self, event): - self.cleanup() - - def signal_done(self, job): - self.done_signal.emit(job) - - def paintEngine(self): - return None - - def create_surface(self): - native_surface = c_void_p(self.winId().__int__()) - surface = eglCreateWindowSurface(self.egl.display, self.egl.config, - native_surface, None) - - self.surface = surface - - def init_gl(self): - self.create_surface() - - eglMakeCurrent(self.egl.display, self.surface, self.surface, self.egl.context) - - glEnable(GL_BLEND) - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - self.overlay_texture = glGenTextures(1) - - 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); - } - """ - - vertex_shader = shaders.compileShader(vertShaderSrc_image, GL_VERTEX_SHADER), - # For some reason I seem to be getting a 1 element tuple back. Absolutely no clue why. - if isinstance(vertex_shader, tuple): - vertex_shader = vertex_shader[0] - fragment_shader = shaders.compileShader(fragShaderSrc_image, GL_FRAGMENT_SHADER) - self.program_image = shaders.compileProgram(vertex_shader, fragment_shader) - - self.program_overlay = shaders.compileProgram( - shaders.compileShader(vertShaderSrc_overlay, GL_VERTEX_SHADER), - shaders.compileShader(fragShaderSrc_overlay, GL_FRAGMENT_SHADER) - ) - - vertPositions = [ - 0.0, 0.0, - 1.0, 0.0, - 1.0, 1.0, - 0.0, 1.0 - ] +@lru_cache(maxsize=None, typed=False) +def _get_qglpicamera2(qt_module: _QT_BINDING): + + # Get Qt modules + QtCore, QtGui, QtWidgets = _get_qt_modules(qt_module) + + #Get from QtCore + QSocketNotifier, Qt, pyqtSignal, pyqtSlot = attrgetter( + 'QSocketNotifier', 'Qt', 'pyqtSignal', 'pyqtSlot')(QtCore) + + #Get from QtWidgets + QWidget = attrgetter( + 'QWidget')(QtWidgets) + + class QGlPicamera2(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.setAttribute(Qt.WidgetAttribute.WA_PaintOnScreen) + self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow) + + 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.count = 0 + self.overlay_present = False + self.buffers = {} + self.surface = None + self.current_request = None + self.own_current = False + self.stop_count = 0 + self.title_function = None + self.egl = EglState() + if picam2.verbose_console: + print(f"EGL {eglQueryString(self.egl.display, EGL_VENDOR).decode()} " + f"{eglQueryString(self.egl.display, EGL_VERSION).decode()}") + self.init_gl() + + # set_overlay could be called before the first frame arrives, hence: + eglMakeCurrent(self.egl.display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT) + + 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) + # Must always run cleanup when this widget goes away. + self.destroyed.connect(lambda: self.cleanup()) + self.running = True + + def cleanup(self): + if not self.running: + return + self.running = False + self.camera_notifier.deleteLater() + eglDestroySurface(self.egl.display, self.surface) + self.surface = None + # We may be hanging on to a request, return it to the camera system. + if self.current_request is not None and self.own_current: + self.current_request.release() + self.current_request = None + # We have to tell both the preview window and the Picamera2 object that we have + # disappeared. + self.picamera2.detach_preview() + if self.preview_window is not None: # will be none when a proper Qt app + self.preview_window.qpicamera2 = None + + # Some extra EGL cleanup seems to be required. + for (_, buffer) in self.buffers.items(): + glDeleteTextures(1, [buffer.texture]) + self.buffers = {} + eglDestroyContext(self.egl.display, self.egl.context) + self.egl.context = None + + def closeEvent(self, event): + self.cleanup() + + def signal_done(self, job): + self.done_signal.emit(job) + + def paintEngine(self): + return None + + def create_surface(self): + native_surface = c_void_p(self.winId().__int__()) + surface = eglCreateWindowSurface(self.egl.display, self.egl.config, + native_surface, None) + + self.surface = surface + + def init_gl(self): + self.create_surface() - inputAttrib = glGetAttribLocation(self.program_image, "aPosition") - glVertexAttribPointer(inputAttrib, 2, GL_FLOAT, GL_FALSE, 0, vertPositions) - glEnableVertexAttribArray(inputAttrib) - - inputAttrib = glGetAttribLocation(self.program_overlay, "aPosition") - glVertexAttribPointer(inputAttrib, 2, GL_FLOAT, GL_FALSE, 0, vertPositions) - glEnableVertexAttribArray(inputAttrib) - - glUseProgram(self.program_overlay) - glUniform1i(glGetUniformLocation(self.program_overlay, "overlay"), 0) - - class Buffer: - # libcamera format string -> DRM fourcc, note that 24-bit formats are not supported - FMT_MAP = { - "XRGB8888": "XR24", - "XBGR8888": "XB24", - "YUYV": "YUYV", - # doesn't work "YVYU": "YVYU", - "UYVY": "UYVY", - # doesn't work "VYUY": "VYUY", - "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 QGlPicamera2 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 set_overlay(self, overlay): - if self.picamera2.camera_config is None: - raise RuntimeError("Camera must be configured before setting overlay") - if self.picamera2.camera_config['buffer_count'] < 2: - raise RuntimeError("Need at least buffer_count=2 to set overlay") - - with self.lock: eglMakeCurrent(self.egl.display, self.surface, self.surface, self.egl.context) - if overlay is None: - self.overlay_present = False - self.repaint(self.current_request) - else: - # All this swapping round of contexts is a bit icky, but I'd rather copy - # the overlay here so that the user doesn't have to worry about us still - # using it after this function returns. - 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, channels) = overlay.shape - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, overlay) - self.overlay_present = True - self.repaint(self.current_request) + glEnable(GL_BLEND) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + self.overlay_texture = glGenTextures(1) + + 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); + } + """ + + vertex_shader = shaders.compileShader(vertShaderSrc_image, GL_VERTEX_SHADER), + # For some reason I seem to be getting a 1 element tuple back. Absolutely no clue why. + if isinstance(vertex_shader, tuple): + vertex_shader = vertex_shader[0] + fragment_shader = shaders.compileShader(fragShaderSrc_image, GL_FRAGMENT_SHADER) + self.program_image = shaders.compileProgram(vertex_shader, fragment_shader) + + self.program_overlay = shaders.compileProgram( + shaders.compileShader(vertShaderSrc_overlay, GL_VERTEX_SHADER), + shaders.compileShader(fragShaderSrc_overlay, GL_FRAGMENT_SHADER) + ) + + vertPositions = [ + 0.0, 0.0, + 1.0, 0.0, + 1.0, 1.0, + 0.0, 1.0 + ] + + inputAttrib = glGetAttribLocation(self.program_image, "aPosition") + glVertexAttribPointer(inputAttrib, 2, GL_FLOAT, GL_FALSE, 0, vertPositions) + glEnableVertexAttribArray(inputAttrib) + + inputAttrib = glGetAttribLocation(self.program_overlay, "aPosition") + glVertexAttribPointer(inputAttrib, 2, GL_FLOAT, GL_FALSE, 0, vertPositions) + glEnableVertexAttribArray(inputAttrib) - eglMakeCurrent(self.egl.display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT) + glUseProgram(self.program_overlay) + glUniform1i(glGetUniformLocation(self.program_overlay, "overlay"), 0) + + class Buffer: + # libcamera format string -> DRM fourcc, note that 24-bit formats are not supported + FMT_MAP = { + "XRGB8888": "XR24", + "XBGR8888": "XB24", + "YUYV": "YUYV", + # doesn't work "YVYU": "YVYU", + "UYVY": "UYVY", + # doesn't work "VYUY": "VYUY", + "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 QGlPicamera2 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 set_overlay(self, overlay): + if self.picamera2.camera_config is None: + raise RuntimeError("Camera must be configured before setting overlay") + if self.picamera2.camera_config['buffer_count'] < 2: + raise RuntimeError("Need at least buffer_count=2 to set overlay") + + with self.lock: + eglMakeCurrent(self.egl.display, self.surface, self.surface, self.egl.context) + + if overlay is None: + self.overlay_present = False + self.repaint(self.current_request) + else: + # All this swapping round of contexts is a bit icky, but I'd rather copy + # the overlay here so that the user doesn't have to worry about us still + # using it after this function returns. + 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, channels) = overlay.shape + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, overlay) + self.overlay_present = True + self.repaint(self.current_request) + + eglMakeCurrent(self.egl.display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT) + + def repaint(self, completed_request, update_viewport=False): + # The context should be set up and cleared by the caller. + if completed_request and completed_request.request not in self.buffers: + if self.stop_count != self.picamera2.stop_count: + if self.picamera2.verbose_console: + print("Garbage collect", len(self.buffers), "textures") + for (_, buffer) in self.buffers.items(): + glDeleteTextures(1, [buffer.texture]) + self.buffers = {} + self.stop_count = self.picamera2.stop_count - def repaint(self, completed_request, update_viewport=False): - # The context should be set up and cleared by the caller. - if completed_request and completed_request.request not in self.buffers: - if self.stop_count != self.picamera2.stop_count: if self.picamera2.verbose_console: - print("Garbage collect", len(self.buffers), "textures") - for (_, buffer) in self.buffers.items(): - glDeleteTextures(1, [buffer.texture]) - self.buffers = {} - self.stop_count = self.picamera2.stop_count - - if self.picamera2.verbose_console: - print("Make buffer for request", completed_request.request) - self.buffers[completed_request.request] = self.Buffer( - self.egl.display, completed_request, self.egl.max_texture_size) - - # New buffers mean the image size may change so update the viewport just in case. - update_viewport = True - - # If there's no request, then the viewport may never have been set up, so force it anyway. - if update_viewport or not completed_request: - 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) - - eglSwapBuffers(self.egl.display, self.surface) - - def render_request(self, completed_request): - """Draw the camera image using Qt and OpenGL/GLES.""" - # For reasons not terribly well understood, eglMakeCurrent hangs in the X-Wayland world if the - # window is being closed. This appears to stop it. - if not self.isVisible(): - return - if self.title_function is not None: - self.setWindowTitle(self.title_function(completed_request.get_metadata())) - with self.lock: - if self.running: + print("Make buffer for request", completed_request.request) + self.buffers[completed_request.request] = self.Buffer( + self.egl.display, completed_request, self.egl.max_texture_size) + + # New buffers mean the image size may change so update the viewport just in case. + update_viewport = True + + # If there's no request, then the viewport may never have been set up, so force it anyway. + if update_viewport or not completed_request: + 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) + + eglSwapBuffers(self.egl.display, self.surface) + + def render_request(self, completed_request): + """Draw the camera image using Qt and OpenGL/GLES.""" + # For reasons not terribly well understood, eglMakeCurrent hangs in the X-Wayland world if the + # window is being closed. This appears to stop it. + if not self.isVisible(): + return + if self.title_function is not None: + self.setWindowTitle(self.title_function(completed_request.get_metadata())) + with self.lock: + if self.running: + eglMakeCurrent(self.egl.display, self.surface, self.surface, self.egl.context) + self.repaint(completed_request) + eglMakeCurrent(self.egl.display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT) + 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() + + @pyqtSlot() + def handle_requests(self): + if not self.running: + return + self.picamera2.notifymeread.read() + self.picamera2.process_requests(self) + + def recalculate_viewport(self): + window_w = self.width() + window_h = self.height() + + stream_map = self.picamera2.stream_map + camera_config = self.picamera2.camera_config + if not self.keep_ar or camera_config is None or camera_config['display'] is None: + return 0, 0, window_w, window_h + + image_w, image_h = (stream_map[camera_config['display']].configuration.size.width, + 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 + x_off = (window_w - w) // 2 + y_off = (window_h - h) // 2 + return x_off, y_off, w, h + + def resizeEvent(self, event): + with self.lock: eglMakeCurrent(self.egl.display, self.surface, self.surface, self.egl.context) - self.repaint(completed_request) + self.repaint(self.current_request, update_viewport=True) eglMakeCurrent(self.egl.display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT) - 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() - - @pyqtSlot() - def handle_requests(self): - if not self.running: - return - self.picamera2.notifymeread.read() - self.picamera2.process_requests(self) - - def recalculate_viewport(self): - window_w = self.width() - window_h = self.height() - - stream_map = self.picamera2.stream_map - camera_config = self.picamera2.camera_config - if not self.keep_ar or camera_config is None or camera_config['display'] is None: - return 0, 0, window_w, window_h - - image_w, image_h = (stream_map[camera_config['display']].configuration.size.width, - 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 - x_off = (window_w - w) // 2 - y_off = (window_h - h) // 2 - return x_off, y_off, w, h - - def resizeEvent(self, event): - with self.lock: - eglMakeCurrent(self.egl.display, self.surface, self.surface, self.egl.context) - self.repaint(self.current_request, update_viewport=True) - eglMakeCurrent(self.egl.display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT) - def show(self): - super().show() - # We seem to need a short delay before rendering the background colour works. No idea why. - time.sleep(0.05) - self.resizeEvent(None) + def show(self): + super().show() + # We seem to need a short delay before rendering the background colour works. No idea why. + time.sleep(0.05) + self.resizeEvent(None) + + return QGlPicamera2 + +# Define QGlPicamera2 class for compatability +try: + QPicamera2 = _get_qglpicamera2(_QT_BINDING.PyQt5) +except ImportError: + pass + diff --git a/picamera2/previews/qt.py b/picamera2/previews/qt.py index 42921bcd..ee5ebb31 100644 --- a/picamera2/previews/qt.py +++ b/picamera2/previews/qt.py @@ -5,20 +5,30 @@ # which is in any case what is required for remote preview windows. from logging import getLogger -from .q_picamera2 import _get_qpicamera2 from .qt_compatability import _QT_BINDING +from .q_picamera2 import _get_qpicamera2 + +_log = getLogger(__name__) + +try: + from .q_gl_picamera2 import _get_qglpicamera2 +except Exception: + _log.warning("OpenGL will not be available") + +# Lazy load QPicamera2 widget classes as will likely only use one or two within a given application def __getattr__(name: str): + # Standard Qt widgets if name == 'QPicamera2': return _get_qpicamera2(_QT_BINDING.PyQt5) elif name == 'Q6Picamera2': return _get_qpicamera2(_QT_BINDING.PyQt6) elif name == 'QSide6Picamera2': return _get_qpicamera2(_QT_BINDING.PySide6) - -_log = getLogger(__name__) - -try: - from .q_gl_picamera2 import QGlPicamera2 -except Exception: - _log.warning("OpenGL will not be available") + # OpenGL accelerated Qt widgets + elif name == 'QGlPicamera2': + return _get_qglpicamera2(_QT_BINDING.PyQt5) + elif name == 'QGl6Picamera2': + return _get_qglpicamera2(_QT_BINDING.PyQt6) + elif name == 'QGlSide6Picamera2': + return _get_qglpicamera2(_QT_BINDING.PySide6)