From a77ff9943a501a45548a2568a279b6e4fd6939d4 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 19 Jul 2023 14:13:10 +0200 Subject: [PATCH] qml: refactor qr scan to qt6 As the method of capturing frames is totally different, the animation when a QR is found has been removed. --- contrib/android/p4a_recipes/qt6/__init__.py | 2 + electrum/gui/qml/__init__.py | 4 +- electrum/gui/qml/components/ScanDialog.qml | 6 + electrum/gui/qml/components/SendDialog.qml | 17 +- .../gui/qml/components/WalletMainView.qml | 2 +- .../gui/qml/components/controls/QRScan.qml | 248 ++++++++---------- electrum/gui/qml/components/main.qml | 1 + electrum/gui/qml/qeapp.py | 9 +- electrum/gui/qml/qeqr.py | 127 +++++---- 9 files changed, 206 insertions(+), 210 deletions(-) diff --git a/contrib/android/p4a_recipes/qt6/__init__.py b/contrib/android/p4a_recipes/qt6/__init__.py index 312f459b3..1a91e0bf3 100644 --- a/contrib/android/p4a_recipes/qt6/__init__.py +++ b/contrib/android/p4a_recipes/qt6/__init__.py @@ -7,11 +7,13 @@ from pythonforandroid.util import load_source util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py')) assert Qt6Recipe._version == "6.4.3" +# assert Qt6Recipe._version == "6.5.3" assert Qt6Recipe.depends == ['python3', 'hostqt6'] assert Qt6Recipe.python_depends == [] class Qt6RecipePinned(util.InheritedRecipeMixin, Qt6Recipe): sha512sum = "0bdbe8b9a43390c98cf19e851ec5394bc78438d227cf9d0d7a3748aee9a32a7f14fc46f52d4fa283819f21413567080aee7225c566af5278557f5e1992674da3" + # sha512sum = "ca8ea3b81c121886636988275f7fa8ae6d19f7be02669e63ab19b4285b611057a41279db9532c25ae87baa3904b010e1db68b899cd0eda17a5a8d3d87098b4d5" recipe = Qt6RecipePinned() diff --git a/electrum/gui/qml/__init__.py b/electrum/gui/qml/__init__.py index 08894eb67..53e87c2e5 100644 --- a/electrum/gui/qml/__init__.py +++ b/electrum/gui/qml/__init__.py @@ -7,12 +7,12 @@ from typing import TYPE_CHECKING try: import PyQt6 except Exception: - sys.exit("Error: Could not import PyQt6 on Linux systems, you may try 'sudo apt-get install python3-pyqt6'") + sys.exit("Error: Could not import PyQt6. On Linux systems, you may try 'sudo apt-get install python3-pyqt6'") try: import PyQt6.QtQml except Exception: - sys.exit("Error: Could not import PyQt6.QtQml on Linux systems, you may try 'sudo apt-get install python3-pyqt6.qtquick'") + sys.exit("Error: Could not import PyQt6.QtQml. On Linux systems, you may try 'sudo apt-get install python3-pyqt6.qtquick'") from PyQt6.QtCore import (Qt, QCoreApplication, QLocale, QTranslator, QTimer, QT_VERSION_STR, PYQT_VERSION_STR) from PyQt6.QtGui import QGuiApplication diff --git a/electrum/gui/qml/components/ScanDialog.qml b/electrum/gui/qml/components/ScanDialog.qml index 2ced89a5f..933dcd7e5 100644 --- a/electrum/gui/qml/components/ScanDialog.qml +++ b/electrum/gui/qml/components/ScanDialog.qml @@ -20,11 +20,17 @@ ElDialog { header: null topPadding: 0 // dialog needs topPadding override + function doClose() { + qrscan.stop() + Qt.callLater(doReject) + } + ColumnLayout { anchors.fill: parent spacing: 0 QRScan { + id: qrscan Layout.fillWidth: true Layout.fillHeight: true hint: scanDialog.hint diff --git a/electrum/gui/qml/components/SendDialog.qml b/electrum/gui/qml/components/SendDialog.qml index d042fbb71..d1bb235f3 100644 --- a/electrum/gui/qml/components/SendDialog.qml +++ b/electrum/gui/qml/components/SendDialog.qml @@ -19,6 +19,11 @@ ElDialog { padding: 0 topPadding: 0 + onAboutToHide: { + console.log('about to hide') + qrscan.stop() + } + function restart() { qrscan.restart() } @@ -34,6 +39,13 @@ ElDialog { } } + // override + function doClose() { + console.log('SendDialog doClose override') // doesn't trigger when going back?? + qrscan.stop() + Qt.callLater(doReject) + } + ColumnLayout { anchors.fill: parent spacing: 0 @@ -55,7 +67,10 @@ ElDialog { Layout.preferredWidth: 1 icon.source: '../../icons/copy_bw.png' text: qsTr('Paste') - onClicked: dialog.dispatch(AppController.clipboardToText()) + onClicked: { + qrscan.stop() + dialog.dispatch(AppController.clipboardToText()) + } } } diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index f12cfb122..0b2a70740 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -40,7 +40,7 @@ Item { function closeSendDialog() { if (_sendDialog) { - _sendDialog.close() + _sendDialog.doClose() _sendDialog = null } } diff --git a/electrum/gui/qml/components/controls/QRScan.qml b/electrum/gui/qml/components/controls/QRScan.qml index 34c023fac..66ce7c17f 100644 --- a/electrum/gui/qml/components/controls/QRScan.qml +++ b/electrum/gui/qml/components/controls/QRScan.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls import QtMultimedia +import QtQml import org.electrum 1.0 @@ -12,73 +13,125 @@ Item { property string scanData property string hint - property bool _pointsVisible - signal found function restart() { - still.source = '' - _pointsVisible = false - active = true + console.log('qrscan.restart') + scanData = '' + qr.reset() + start() + } + + function start() { + console.log('qrscan.start') + loader.item.startTimer.start() } - VideoOutput { - id: vo + function stop() { + console.log('qrscan.stop') + scanner.active = false + } + + Item { + id: points + z: 100 anchors.fill: parent - // source: camera - fillMode: VideoOutput.PreserveAspectCrop + } - Rectangle { - width: parent.width - height: (parent.height - parent.width) / 2 - visible: camera.cameraStatus == Camera.ActiveStatus - anchors.top: parent.top - color: Qt.rgba(0,0,0,0.5) - } - Rectangle { - width: parent.width - height: (parent.height - parent.width) / 2 - visible: camera.cameraStatus == Camera.ActiveStatus - anchors.bottom: parent.bottom - color: Qt.rgba(0,0,0,0.5) - } - InfoTextArea { - visible: scanner.hint - background.opacity: 0.5 - iconStyle: InfoTextArea.IconStyle.None - anchors { - top: parent.top - topMargin: constants.paddingXLarge - left: parent.left - leftMargin: constants.paddingXXLarge - right: parent.right - rightMargin: constants.paddingXXLarge + Loader { + id: loader + anchors.fill: parent + sourceComponent: scancomp + onStatusChanged: { + if (loader.status == Loader.Ready) { + console.log('camera loaded') + } else if (loader.status == Loader.Error) { + console.log('camera load error') } - text: scanner.hint } } - Image { - id: still - anchors.fill: vo - } + Component { + id: scancomp + + Item { + property alias vo: _vo + property alias ic: _ic + property alias startTimer: _startTimer - SequentialAnimation { - id: foundAnimation - PropertyAction { target: scanner; property: '_pointsVisible'; value: true} - PauseAnimation { duration: 80 } - PropertyAction { target: scanner; property: '_pointsVisible'; value: false} - PauseAnimation { duration: 80 } - PropertyAction { target: scanner; property: '_pointsVisible'; value: true} - PauseAnimation { duration: 80 } - PropertyAction { target: scanner; property: '_pointsVisible'; value: false} - PauseAnimation { duration: 80 } - PropertyAction { target: scanner; property: '_pointsVisible'; value: true} - PauseAnimation { duration: 80 } - PropertyAction { target: scanner; property: '_pointsVisible'; value: false} - PauseAnimation { duration: 80 } - PropertyAction { target: scanner; property: '_pointsVisible'; value: true} - onFinished: found() + VideoOutput { + id: _vo + anchors.fill: parent + + fillMode: VideoOutput.PreserveAspectCrop + + Rectangle { + width: parent.width + height: (parent.height - parent.width) / 2 + anchors.top: parent.top + color: Qt.rgba(0,0,0,0.5) + } + Rectangle { + width: parent.width + height: (parent.height - parent.width) / 2 + anchors.bottom: parent.bottom + color: Qt.rgba(0,0,0,0.5) + } + InfoTextArea { + visible: scanner.hint + background.opacity: 0.5 + iconStyle: InfoTextArea.IconStyle.None + anchors { + top: parent.top + topMargin: constants.paddingXLarge + left: parent.left + leftMargin: constants.paddingXXLarge + right: parent.right + rightMargin: constants.paddingXXLarge + } + text: scanner.hint + } + + Component.onCompleted: { + startTimer.start() + } + } + + ImageCapture { + id: _ic + + } + + MediaDevices { + id: mediaDevices + } + + Camera { + id: camera + cameraDevice: mediaDevices.defaultVideoInput + active: scanner.active + focusMode: Camera.FocusModeAutoNear + customFocusPoint: Qt.point(0.5, 0.5) + + onErrorOccurred: { + console.log('camera error: ' + errorString) + } + } + + CaptureSession { + videoOutput: _vo + imageCapture: _ic + camera: camera + } + + Timer { + id: _startTimer + interval: 500 + repeat: false + onTriggered: scanner.active = true + } + + } } Component { @@ -98,95 +151,16 @@ Item { Connections { target: qr function onDataChanged() { - console.log(qr.data) + console.log('QR DATA: ' + qr.data) scanner.active = false scanner.scanData = qr.data - still.source = scanner.url - - var sx = still.width/still.sourceSize.width - var sy = still.height/still.sourceSize.height - r.createObject(scanner, {cx: qr.points[0].x * sx, cy: qr.points[0].y * sy, color: 'yellow'}) - r.createObject(scanner, {cx: qr.points[1].x * sx, cy: qr.points[1].y * sy, color: 'yellow'}) - r.createObject(scanner, {cx: qr.points[2].x * sx, cy: qr.points[2].y * sy, color: 'yellow'}) - r.createObject(scanner, {cx: qr.points[3].x * sx, cy: qr.points[3].y * sy, color: 'yellow'}) - - foundAnimation.start() - } - } - - MediaDevices { - id: mediaDevices - } - - CaptureSession { - videoOutput: VideoOutput - - camera: Camera { - id: camera - // deviceId: QtMultimedia.defaultCamera.deviceId - cameraDevice: mediaDevices.defaultVideoInput - // TODO QT6 - // viewfinder.resolution: "640x480" - - // focus { - // focusMode: Camera.FocusContinuous - // focusPointMode: Camera.FocusPointCustom - // customFocusPoint: Qt.point(0.5, 0.5) - // } - - function dumpstats() { - console.log(camera.viewfinder.resolution) - console.log(camera.viewfinder.minimumFrameRate) - console.log(camera.viewfinder.maximumFrameRate) - var resolutions = camera.supportedViewfinderResolutions() - resolutions.forEach(function(item, i) { - console.log('' + item.width + 'x' + item.height) - }) - // TODO - // pick a suitable resolution from the available resolutions - // problem: some cameras have no supportedViewfinderResolutions - // but still error out when an invalid resolution is set. - // 640x480 seems to be universally available, but this needs to - // be checked across a range of phone models. - } - } - } - - Timer { - id: scanTimer - interval: 200 - repeat: true - running: scanner.active - onTriggered: { - if (qr.busy) - return - vo.grabToImage(function(result) { - if (result.image !== undefined) { - scanner.url = result.url - qr.scanImage(result.image) - } else { - console.log('image grab returned null') - } - }) + scanner.found() } } QRParser { id: qr + videoSink: loader.item.vo.videoSink } - Component.onCompleted: { - console.log('enumerating cameras') - QtMultimedia.availableCameras.forEach(function(item) { - console.log('cam found, id=' + item.deviceId + ' name=' + item.displayName) - console.log('pos=' + item.position + ' orientation=' + item.orientation) - if (QtMultimedia.defaultCamera.deviceId == item.deviceId) { - vo.orientation = item.orientation - } - - camera.dumpstats() - }) - - active = true - } } diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index c44f5fb61..0821952bc 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -478,6 +478,7 @@ ApplicationWindow if (activeDialogs.length > 0) { var activeDialog = activeDialogs[activeDialogs.length - 1] if (activeDialog.allowClose) { + console.log('main: dialog.doClose') activeDialog.doClose() } else { console.log('dialog disallowed close') diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 37805f340..4bd8194d8 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Set from PyQt6.QtCore import (pyqtSlot, pyqtSignal, pyqtProperty, QObject, qInstallMessageHandler, QTimer, QSortFilterProxyModel) -from PyQt6.QtGui import QGuiApplication, QFontDatabase +from PyQt6.QtGui import QGuiApplication, QFontDatabase, QScreen from PyQt6.QtQml import qmlRegisterType, qmlRegisterUncreatableType, QQmlApplicationEngine from electrum import version, constants @@ -369,7 +369,7 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QETxCanceller, 'org.electrum', 1, 0, 'TxCanceller') qmlRegisterType(QEBip39RecoveryListModel, 'org.electrum', 1, 0, 'Bip39RecoveryListModel') - # TODO QT6 + # TODO QT6: these were declared as uncreatable, but that doesn't seem to work for pyqt6 # qmlRegisterUncreatableType(QEAmount, 'org.electrum', 1, 0, 'Amount', 'Amount can only be used as property') # qmlRegisterUncreatableType(QENewWalletWizard, 'org.electrum', 1, 0, 'QNewWalletWizard', 'QNewWalletWizard can only be used as property') # qmlRegisterUncreatableType(QEServerConnectWizard, 'org.electrum', 1, 0, 'QServerConnectWizard', 'QServerConnectWizard can only be used as property') @@ -380,9 +380,10 @@ class ElectrumQmlApplication(QGuiApplication): screensize = self.primaryScreen().size() - self.qr_ip = QEQRImageProvider((7/8)*min(screensize.width(), screensize.height())) + qr_size = min(screensize.width(), screensize.height()) * 7/8 + self.qr_ip = QEQRImageProvider(qr_size) self.engine.addImageProvider('qrgen', self.qr_ip) - self.qr_ip_h = QEQRImageProviderHelper((7/8)*min(screensize.width(), screensize.height())) + self.qr_ip_h = QEQRImageProviderHelper(qr_size) # add a monospace font as we can't rely on device having one self.fixedFont = 'PT Mono' diff --git a/electrum/gui/qml/qeqr.py b/electrum/gui/qml/qeqr.py index a102461bd..d268fa9cd 100644 --- a/electrum/gui/qml/qeqr.py +++ b/electrum/gui/qml/qeqr.py @@ -5,11 +5,12 @@ from qrcode.exceptions import DataOverflowError import math import urllib -from PIL import Image, ImageQt +from PIL import ImageQt -from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QRect, QPoint +from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QRect from PyQt6.QtGui import QImage, QColor from PyQt6.QtQuick import QQuickImageProvider +from PyQt6.QtMultimedia import QVideoSink from electrum.logging import get_logger from electrum.qrreader import get_qr_reader @@ -22,103 +23,99 @@ class QEQRParser(QObject): busyChanged = pyqtSignal() dataChanged = pyqtSignal() - imageChanged = pyqtSignal() + sizeChanged = pyqtSignal() + videoSinkChanged = pyqtSignal() def __init__(self, text=None, parent=None): super().__init__(parent) self._busy = False - self._image = None self._data = None + self._video_sink = None self._text = text self.qrreader = get_qr_reader() if not self.qrreader: raise Exception(_("The platform QR detection library is not available.")) - @pyqtSlot('QImage') - def scanImage(self, image=None): - if self._busy: - self._logger.warning("Already processing an image. Check 'busy' property before calling scanImage") - return + @pyqtProperty(QVideoSink, notify=videoSinkChanged) + def videoSink(self): + return self._video_sink + + @videoSink.setter + def videoSink(self, sink: QVideoSink): + if self._video_sink != sink: + self._video_sink = sink + self._video_sink.videoFrameChanged.connect(self.onVideoFrame) - if image is None: - self._logger.warning("No image to decode") + def onVideoFrame(self, videoframe): + if self._busy or self._data: return self._busy = True self.busyChanged.emit() - # self.logImageStats(image) - self._parseQR(image) - - def logImageStats(self, image): - self._logger.info(f'width: {image.width()} height: {image.height()} depth: {image.depth()} format: {image.format()}') - - def _parseQR(self, image): - self.w = image.width() - self.h = image.height() - img_crop_rect = self._get_crop(image, 360) - frame_cropped = image.copy(img_crop_rect) + if not videoframe.isValid(): + self._logger.debug('invalid frame') + return - async def co_parse_qr(image): - # Convert to Y800 / GREY FourCC (single 8-bit channel) - # This creates a copy, so we don't need to keep the frame around anymore - frame_y800 = image.convertToFormat(QImage.Format_Grayscale8) + async def co_parse_qr(frame): + image = frame.toImage() + self._parseQR(image) - self.frame_id = 0 - # Read the QR codes from the frame - self.qrreader_res = self.qrreader.read_qr_code( - frame_y800.constBits().__int__(), - frame_y800.byteCount(), - frame_y800.bytesPerLine(), - frame_y800.width(), - frame_y800.height(), - self.frame_id - ) + asyncio.run_coroutine_threadsafe(co_parse_qr(videoframe), get_asyncio_loop()) - if len(self.qrreader_res) > 0: - result = self.qrreader_res[0] - self._data = result - self.dataChanged.emit() + def _parseQR(self, image: QImage): + self._size = min(image.width(), image.height()) + self.sizeChanged.emit() + img_crop_rect = self._get_crop(image, self._size) + frame_cropped = image.copy(img_crop_rect) - self._busy = False - self.busyChanged.emit() + # Convert to Y800 / GREY FourCC (single 8-bit channel) + frame_y800 = frame_cropped.convertToFormat(QImage.Format.Format_Grayscale8) + self.frame_id = 0 + # Read the QR codes from the frame + self.qrreader_res = self.qrreader.read_qr_code( + frame_y800.constBits().__int__(), + frame_y800.sizeInBytes(), + frame_y800.bytesPerLine(), + frame_y800.width(), + frame_y800.height(), + self.frame_id + ) + + if len(self.qrreader_res) > 0: + result = self.qrreader_res[0] + self._data = result + self.dataChanged.emit() - asyncio.run_coroutine_threadsafe(co_parse_qr(frame_cropped), get_asyncio_loop()) + self._busy = False + self.busyChanged.emit() def _get_crop(self, image: QImage, scan_size: int) -> QRect: - """ - Returns a QRect that is scan_size x scan_size in the middle of the resolution - """ - self.scan_pos_x = (image.width() - scan_size) // 2 - self.scan_pos_y = (image.height() - scan_size) // 2 - return QRect(self.scan_pos_x, self.scan_pos_y, scan_size, scan_size) + """Returns a QRect that is scan_size x scan_size in the middle of the resolution""" + scan_pos_x = (image.width() - scan_size) // 2 + scan_pos_y = (image.height() - scan_size) // 2 + return QRect(scan_pos_x, scan_pos_y, scan_size, scan_size) @pyqtProperty(bool, notify=busyChanged) def busy(self): return self._busy - @pyqtProperty('QImage', notify=imageChanged) - def image(self): - return self._image + @pyqtProperty(int, notify=sizeChanged) + def size(self): + return self._size @pyqtProperty(str, notify=dataChanged) def data(self): + if not self._data: + return '' return self._data.data - @pyqtProperty('QPoint', notify=dataChanged) - def center(self): - (x,y) = self._data.center - return QPoint(x+self.scan_pos_x, y+self.scan_pos_y) - - @pyqtProperty('QVariant', notify=dataChanged) - def points(self): - result = [] - for item in self._data.points: - (x,y) = item - result.append(QPoint(x+self.scan_pos_x, y+self.scan_pos_y)) - return result + @pyqtSlot() + def reset(self): + self._data = None + self.dataChanged.emit() class QEQRImageProvider(QQuickImageProvider): @@ -161,7 +158,7 @@ class QEQRImageProvider(QQuickImageProvider): # fake it modules = 17 + qr.border * 2 box_size = math.floor(pixelsize/modules) - self.qimg = QImage(box_size * modules, box_size * modules, QImage.Format_RGB32) + self.qimg = QImage(box_size * modules, box_size * modules, QImage.Format.Format_RGB32) self.qimg.fill(QColor('gray')) return self.qimg, self.qimg.size()