From 758a30462e43ffb1741262345615445325588e47 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 29 Mar 2022 16:36:20 +0200 Subject: [PATCH] implement QR code scanning --- contrib/android/buildozer_qml.spec | 3 +- electrum/gui/qml/components/QRScan.qml | 137 +++++++++++++++++++++++-- electrum/gui/qml/components/Scan.qml | 12 +++ electrum/gui/qml/components/Send.qml | 8 +- electrum/gui/qml/qeqr.py | 110 ++++++++++++++------ electrum/qrreader/zbar.py | 5 +- 6 files changed, 226 insertions(+), 49 deletions(-) diff --git a/contrib/android/buildozer_qml.spec b/contrib/android/buildozer_qml.spec index 6eb434298..b012034a0 100644 --- a/contrib/android/buildozer_qml.spec +++ b/contrib/android/buildozer_qml.spec @@ -52,7 +52,8 @@ requirements = cryptography, pyqt5sip, pyqt5, - pillow + pillow, + libzbar # (str) Presplash of the application #presplash.filename = %(source.dir)s/gui/kivy/theming/splash.png diff --git a/electrum/gui/qml/components/QRScan.qml b/electrum/gui/qml/components/QRScan.qml index f04c4aa57..b0d369deb 100644 --- a/electrum/gui/qml/components/QRScan.qml +++ b/electrum/gui/qml/components/QRScan.qml @@ -1,25 +1,91 @@ -import QtQuick 2.6 +import QtQuick 2.12 +import QtQuick.Controls 2.0 import QtMultimedia 5.6 Item { + id: scanner + + property bool active: false + property string url + property string scanData + + property bool _pointsVisible + + signal found VideoOutput { id: vo anchors.fill: parent source: camera 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) + } } - MouseArea { - anchors.fill: parent - onClicked: { - vo.grabToImage(function(result) { - console.log("grab: image=" + (result.image !== undefined) + " url=" + result.url) - if (result.image !== undefined) { - console.log('scanning image for QR') - QR.scanImage(result.image) - } - }) + Image { + id: still + anchors.fill: vo + } + + 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() + } + + Component { + id: r + Rectangle { + property int cx + property int cy + width: 15 + height: 15 + x: cx - width/2 + y: cy - height/2 + radius: 5 + visible: scanner._pointsVisible + } + } + + Connections { + target: QR + function onDataChanged() { + console.log(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() } } @@ -28,6 +94,12 @@ Item { deviceId: QtMultimedia.defaultCamera.deviceId 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) @@ -36,6 +108,49 @@ Item { 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') + } + }) + } + } + + Component.onCompleted: { + console.log('Scan page initialized') + QtMultimedia.availableCameras.forEach(function(item) { + console.log('cam found') + console.log(item.deviceId) + console.log(item.displayName) + console.log(item.position) + console.log(item.orientation) + if (QtMultimedia.defaultCamera.deviceId == item.deviceId) { + vo.orientation = item.orientation + } + + camera.dumpstats() + }) + + active = true + } } diff --git a/electrum/gui/qml/components/Scan.qml b/electrum/gui/qml/components/Scan.qml index 734e7d6a5..35f18845a 100644 --- a/electrum/gui/qml/components/Scan.qml +++ b/electrum/gui/qml/components/Scan.qml @@ -2,13 +2,25 @@ import QtQuick 2.6 import QtQuick.Controls 2.0 Item { + id: scanPage + property string title: qsTr('Scan') property bool toolbar: false + property string scanData + + signal found + QRScan { anchors.top: parent.top anchors.bottom: parent.bottom width: parent.width + + onFound: { + scanPage.scanData = scanData + scanPage.found() + app.stack.pop() + } } Button { diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 81b3d1362..534cf92f6 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -80,7 +80,13 @@ Pane { Button { text: qsTr('Scan QR Code') - onClicked: app.stack.push(Qt.resolvedUrl('Scan.qml')) + onClicked: { + var page = app.stack.push(Qt.resolvedUrl('Scan.qml')) + page.onFound.connect(function() { + console.log('got ' + page.scanData) + address.text = page.scanData + }) + } } } } diff --git a/electrum/gui/qml/qeqr.py b/electrum/gui/qml/qeqr.py index d93323733..5cc72bd26 100644 --- a/electrum/gui/qml/qeqr.py +++ b/electrum/gui/qml/qeqr.py @@ -1,42 +1,54 @@ -from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl -from PyQt5.QtGui import QImage +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QRect, QPoint +from PyQt5.QtGui import QImage,QColor from PyQt5.QtQuick import QQuickImageProvider from electrum.logging import get_logger +from electrum.qrreader import get_qr_reader +from electrum.i18n import _ import qrcode #from qrcode.image.styledpil import StyledPilImage #from qrcode.image.styles.moduledrawers import * from PIL import Image, ImageQt - from ctypes import * +import sys class QEQR(QObject): def __init__(self, text=None, parent=None): super().__init__(parent) self._text = text + self.qrreader = get_qr_reader() + if not self.qrreader: + raise Exception(_("The platform QR detection library is not available.")) _logger = get_logger(__name__) - scanReadyChanged = pyqtSignal() + busyChanged = pyqtSignal() + dataChanged = pyqtSignal() imageChanged = pyqtSignal() - _scanReady = True + _busy = False _image = None @pyqtSlot('QImage') def scanImage(self, image=None): - if not self._scanReady: - self._logger.warning("Already processing an image. Check 'ready' property before calling scanImage") + if self._busy: + self._logger.warning("Already processing an image. Check 'busy' property before calling scanImage") + return + + if image == None: + self._logger.warning("No image to decode") return - self._scanReady = False - self.scanReadyChanged.emit() - pilimage = self.convertToPILImage(image) - self.parseQR(pilimage) + self._busy = True + self.busyChanged.emit() - self._scanReady = True + self.logImageStats(image) + self._parseQR(image) + + self._busy = False + self.busyChanged.emit() def logImageStats(self, image): self._logger.info('width: ' + str(image.width())) @@ -44,33 +56,63 @@ class QEQR(QObject): self._logger.info('depth: ' + str(image.depth())) self._logger.info('format: ' + str(image.format())) - def convertToPILImage(self, image): # -> Image: - self.logImageStats(image) - - rawimage = image.constBits() - # assumption: pixels are 32 bits ARGB - numbytes = image.width() * image.height() * 4 - - self._logger.info(type(rawimage)) - buf = bytearray(numbytes) - c_buf = (c_byte * numbytes).from_buffer(buf) - memmove(c_buf, c_void_p(rawimage.__int__()), numbytes) - buf2 = bytes(buf) - - return Image.frombytes('RGBA', (image.width(), image.height()), buf2, 'raw') - - def parseQR(self, image): - # TODO - pass - - @pyqtProperty(bool, notify=scanReadyChanged) - def scanReady(self): - return self._scanReady + 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) + + # 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 = frame_cropped.convertToFormat(QImage.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.byteCount(), + 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() + + 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) + + @pyqtProperty(bool, notify=busyChanged) + def busy(self): + return self._busy @pyqtProperty('QImage', notify=imageChanged) def image(self): return self._image + @pyqtProperty(str, notify=dataChanged) + def data(self): + 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 + class QEQRImageProvider(QQuickImageProvider): def __init__(self, parent=None): super().__init__(QQuickImageProvider.Image) diff --git a/electrum/qrreader/zbar.py b/electrum/qrreader/zbar.py index 14df4e70f..8a3ef54df 100644 --- a/electrum/qrreader/zbar.py +++ b/electrum/qrreader/zbar.py @@ -37,8 +37,9 @@ from .abstract_base import AbstractQrCodeReader, QrCodeResult _logger = get_logger(__name__) - -if sys.platform == 'darwin': +if 'ANDROID_DATA' in os.environ: + LIBNAME = 'libzbar.so' +elif sys.platform == 'darwin': LIBNAME = 'libzbar.0.dylib' elif sys.platform in ('windows', 'win32'): LIBNAME = 'libzbar-0.dll'