Browse Source

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.
master
Sander van Grieken 2 years ago
parent
commit
a77ff9943a
  1. 2
      contrib/android/p4a_recipes/qt6/__init__.py
  2. 4
      electrum/gui/qml/__init__.py
  3. 6
      electrum/gui/qml/components/ScanDialog.qml
  4. 17
      electrum/gui/qml/components/SendDialog.qml
  5. 2
      electrum/gui/qml/components/WalletMainView.qml
  6. 194
      electrum/gui/qml/components/controls/QRScan.qml
  7. 1
      electrum/gui/qml/components/main.qml
  8. 9
      electrum/gui/qml/qeapp.py
  9. 91
      electrum/gui/qml/qeqr.py

2
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')) 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.4.3"
# assert Qt6Recipe._version == "6.5.3"
assert Qt6Recipe.depends == ['python3', 'hostqt6'] assert Qt6Recipe.depends == ['python3', 'hostqt6']
assert Qt6Recipe.python_depends == [] assert Qt6Recipe.python_depends == []
class Qt6RecipePinned(util.InheritedRecipeMixin, Qt6Recipe): class Qt6RecipePinned(util.InheritedRecipeMixin, Qt6Recipe):
sha512sum = "0bdbe8b9a43390c98cf19e851ec5394bc78438d227cf9d0d7a3748aee9a32a7f14fc46f52d4fa283819f21413567080aee7225c566af5278557f5e1992674da3" sha512sum = "0bdbe8b9a43390c98cf19e851ec5394bc78438d227cf9d0d7a3748aee9a32a7f14fc46f52d4fa283819f21413567080aee7225c566af5278557f5e1992674da3"
# sha512sum = "ca8ea3b81c121886636988275f7fa8ae6d19f7be02669e63ab19b4285b611057a41279db9532c25ae87baa3904b010e1db68b899cd0eda17a5a8d3d87098b4d5"
recipe = Qt6RecipePinned() recipe = Qt6RecipePinned()

4
electrum/gui/qml/__init__.py

@ -7,12 +7,12 @@ from typing import TYPE_CHECKING
try: try:
import PyQt6 import PyQt6
except Exception: 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: try:
import PyQt6.QtQml import PyQt6.QtQml
except Exception: 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.QtCore import (Qt, QCoreApplication, QLocale, QTranslator, QTimer, QT_VERSION_STR, PYQT_VERSION_STR)
from PyQt6.QtGui import QGuiApplication from PyQt6.QtGui import QGuiApplication

6
electrum/gui/qml/components/ScanDialog.qml

@ -20,11 +20,17 @@ ElDialog {
header: null header: null
topPadding: 0 // dialog needs topPadding override topPadding: 0 // dialog needs topPadding override
function doClose() {
qrscan.stop()
Qt.callLater(doReject)
}
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
spacing: 0 spacing: 0
QRScan { QRScan {
id: qrscan
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
hint: scanDialog.hint hint: scanDialog.hint

17
electrum/gui/qml/components/SendDialog.qml

@ -19,6 +19,11 @@ ElDialog {
padding: 0 padding: 0
topPadding: 0 topPadding: 0
onAboutToHide: {
console.log('about to hide')
qrscan.stop()
}
function restart() { function restart() {
qrscan.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 { ColumnLayout {
anchors.fill: parent anchors.fill: parent
spacing: 0 spacing: 0
@ -55,7 +67,10 @@ ElDialog {
Layout.preferredWidth: 1 Layout.preferredWidth: 1
icon.source: '../../icons/copy_bw.png' icon.source: '../../icons/copy_bw.png'
text: qsTr('Paste') text: qsTr('Paste')
onClicked: dialog.dispatch(AppController.clipboardToText()) onClicked: {
qrscan.stop()
dialog.dispatch(AppController.clipboardToText())
}
} }
} }

2
electrum/gui/qml/components/WalletMainView.qml

@ -40,7 +40,7 @@ Item {
function closeSendDialog() { function closeSendDialog() {
if (_sendDialog) { if (_sendDialog) {
_sendDialog.close() _sendDialog.doClose()
_sendDialog = null _sendDialog = null
} }
} }

194
electrum/gui/qml/components/controls/QRScan.qml

@ -1,6 +1,7 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtMultimedia import QtMultimedia
import QtQml
import org.electrum 1.0 import org.electrum 1.0
@ -12,33 +13,67 @@ Item {
property string scanData property string scanData
property string hint property string hint
property bool _pointsVisible
signal found signal found
function restart() { function restart() {
still.source = '' console.log('qrscan.restart')
_pointsVisible = false scanData = ''
active = true qr.reset()
start()
}
function start() {
console.log('qrscan.start')
loader.item.startTimer.start()
} }
function stop() {
console.log('qrscan.stop')
scanner.active = false
}
Item {
id: points
z: 100
anchors.fill: parent
}
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')
}
}
}
Component {
id: scancomp
Item {
property alias vo: _vo
property alias ic: _ic
property alias startTimer: _startTimer
VideoOutput { VideoOutput {
id: vo id: _vo
anchors.fill: parent anchors.fill: parent
// source: camera
fillMode: VideoOutput.PreserveAspectCrop fillMode: VideoOutput.PreserveAspectCrop
Rectangle { Rectangle {
width: parent.width width: parent.width
height: (parent.height - parent.width) / 2 height: (parent.height - parent.width) / 2
visible: camera.cameraStatus == Camera.ActiveStatus
anchors.top: parent.top anchors.top: parent.top
color: Qt.rgba(0,0,0,0.5) color: Qt.rgba(0,0,0,0.5)
} }
Rectangle { Rectangle {
width: parent.width width: parent.width
height: (parent.height - parent.width) / 2 height: (parent.height - parent.width) / 2
visible: camera.cameraStatus == Camera.ActiveStatus
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
color: Qt.rgba(0,0,0,0.5) color: Qt.rgba(0,0,0,0.5)
} }
@ -56,29 +91,47 @@ Item {
} }
text: scanner.hint text: scanner.hint
} }
Component.onCompleted: {
startTimer.start()
} }
}
ImageCapture {
id: _ic
Image {
id: still
anchors.fill: vo
} }
SequentialAnimation { MediaDevices {
id: foundAnimation id: mediaDevices
PropertyAction { target: scanner; property: '_pointsVisible'; value: true} }
PauseAnimation { duration: 80 }
PropertyAction { target: scanner; property: '_pointsVisible'; value: false} Camera {
PauseAnimation { duration: 80 } id: camera
PropertyAction { target: scanner; property: '_pointsVisible'; value: true} cameraDevice: mediaDevices.defaultVideoInput
PauseAnimation { duration: 80 } active: scanner.active
PropertyAction { target: scanner; property: '_pointsVisible'; value: false} focusMode: Camera.FocusModeAutoNear
PauseAnimation { duration: 80 } customFocusPoint: Qt.point(0.5, 0.5)
PropertyAction { target: scanner; property: '_pointsVisible'; value: true}
PauseAnimation { duration: 80 } onErrorOccurred: {
PropertyAction { target: scanner; property: '_pointsVisible'; value: false} console.log('camera error: ' + errorString)
PauseAnimation { duration: 80 } }
PropertyAction { target: scanner; property: '_pointsVisible'; value: true} }
onFinished: found()
CaptureSession {
videoOutput: _vo
imageCapture: _ic
camera: camera
}
Timer {
id: _startTimer
interval: 500
repeat: false
onTriggered: scanner.active = true
}
}
} }
Component { Component {
@ -98,95 +151,16 @@ Item {
Connections { Connections {
target: qr target: qr
function onDataChanged() { function onDataChanged() {
console.log(qr.data) console.log('QR DATA: ' + qr.data)
scanner.active = false scanner.active = false
scanner.scanData = qr.data scanner.scanData = qr.data
still.source = scanner.url scanner.found()
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')
}
})
} }
} }
QRParser { QRParser {
id: qr 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
}
} }

1
electrum/gui/qml/components/main.qml

@ -478,6 +478,7 @@ ApplicationWindow
if (activeDialogs.length > 0) { if (activeDialogs.length > 0) {
var activeDialog = activeDialogs[activeDialogs.length - 1] var activeDialog = activeDialogs[activeDialogs.length - 1]
if (activeDialog.allowClose) { if (activeDialog.allowClose) {
console.log('main: dialog.doClose')
activeDialog.doClose() activeDialog.doClose()
} else { } else {
console.log('dialog disallowed close') console.log('dialog disallowed close')

9
electrum/gui/qml/qeapp.py

@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Set
from PyQt6.QtCore import (pyqtSlot, pyqtSignal, pyqtProperty, QObject, from PyQt6.QtCore import (pyqtSlot, pyqtSignal, pyqtProperty, QObject,
qInstallMessageHandler, QTimer, QSortFilterProxyModel) qInstallMessageHandler, QTimer, QSortFilterProxyModel)
from PyQt6.QtGui import QGuiApplication, QFontDatabase from PyQt6.QtGui import QGuiApplication, QFontDatabase, QScreen
from PyQt6.QtQml import qmlRegisterType, qmlRegisterUncreatableType, QQmlApplicationEngine from PyQt6.QtQml import qmlRegisterType, qmlRegisterUncreatableType, QQmlApplicationEngine
from electrum import version, constants from electrum import version, constants
@ -369,7 +369,7 @@ class ElectrumQmlApplication(QGuiApplication):
qmlRegisterType(QETxCanceller, 'org.electrum', 1, 0, 'TxCanceller') qmlRegisterType(QETxCanceller, 'org.electrum', 1, 0, 'TxCanceller')
qmlRegisterType(QEBip39RecoveryListModel, 'org.electrum', 1, 0, 'Bip39RecoveryListModel') 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(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(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') # 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() 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.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 # add a monospace font as we can't rely on device having one
self.fixedFont = 'PT Mono' self.fixedFont = 'PT Mono'

91
electrum/gui/qml/qeqr.py

@ -5,11 +5,12 @@ from qrcode.exceptions import DataOverflowError
import math import math
import urllib 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.QtGui import QImage, QColor
from PyQt6.QtQuick import QQuickImageProvider from PyQt6.QtQuick import QQuickImageProvider
from PyQt6.QtMultimedia import QVideoSink
from electrum.logging import get_logger from electrum.logging import get_logger
from electrum.qrreader import get_qr_reader from electrum.qrreader import get_qr_reader
@ -22,55 +23,61 @@ class QEQRParser(QObject):
busyChanged = pyqtSignal() busyChanged = pyqtSignal()
dataChanged = pyqtSignal() dataChanged = pyqtSignal()
imageChanged = pyqtSignal() sizeChanged = pyqtSignal()
videoSinkChanged = pyqtSignal()
def __init__(self, text=None, parent=None): def __init__(self, text=None, parent=None):
super().__init__(parent) super().__init__(parent)
self._busy = False self._busy = False
self._image = None
self._data = None self._data = None
self._video_sink = None
self._text = text self._text = text
self.qrreader = get_qr_reader() self.qrreader = get_qr_reader()
if not self.qrreader: if not self.qrreader:
raise Exception(_("The platform QR detection library is not available.")) raise Exception(_("The platform QR detection library is not available."))
@pyqtSlot('QImage') @pyqtProperty(QVideoSink, notify=videoSinkChanged)
def scanImage(self, image=None): def videoSink(self):
if self._busy: return self._video_sink
self._logger.warning("Already processing an image. Check 'busy' property before calling scanImage")
return @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: def onVideoFrame(self, videoframe):
self._logger.warning("No image to decode") if self._busy or self._data:
return return
self._busy = True self._busy = True
self.busyChanged.emit() self.busyChanged.emit()
# self.logImageStats(image) if not videoframe.isValid():
self._logger.debug('invalid frame')
return
async def co_parse_qr(frame):
image = frame.toImage()
self._parseQR(image) self._parseQR(image)
def logImageStats(self, image): asyncio.run_coroutine_threadsafe(co_parse_qr(videoframe), get_asyncio_loop())
self._logger.info(f'width: {image.width()} height: {image.height()} depth: {image.depth()} format: {image.format()}')
def _parseQR(self, image): def _parseQR(self, image: QImage):
self.w = image.width() self._size = min(image.width(), image.height())
self.h = image.height() self.sizeChanged.emit()
img_crop_rect = self._get_crop(image, 360) img_crop_rect = self._get_crop(image, self._size)
frame_cropped = image.copy(img_crop_rect) frame_cropped = image.copy(img_crop_rect)
async def co_parse_qr(image):
# Convert to Y800 / GREY FourCC (single 8-bit channel) # 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.Format_Grayscale8)
frame_y800 = image.convertToFormat(QImage.Format_Grayscale8)
self.frame_id = 0 self.frame_id = 0
# Read the QR codes from the frame # Read the QR codes from the frame
self.qrreader_res = self.qrreader.read_qr_code( self.qrreader_res = self.qrreader.read_qr_code(
frame_y800.constBits().__int__(), frame_y800.constBits().__int__(),
frame_y800.byteCount(), frame_y800.sizeInBytes(),
frame_y800.bytesPerLine(), frame_y800.bytesPerLine(),
frame_y800.width(), frame_y800.width(),
frame_y800.height(), frame_y800.height(),
@ -85,40 +92,30 @@ class QEQRParser(QObject):
self._busy = False self._busy = False
self.busyChanged.emit() self.busyChanged.emit()
asyncio.run_coroutine_threadsafe(co_parse_qr(frame_cropped), get_asyncio_loop())
def _get_crop(self, image: QImage, scan_size: int) -> QRect: 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"""
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
self.scan_pos_x = (image.width() - scan_size) // 2 return QRect(scan_pos_x, scan_pos_y, scan_size, scan_size)
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) @pyqtProperty(bool, notify=busyChanged)
def busy(self): def busy(self):
return self._busy return self._busy
@pyqtProperty('QImage', notify=imageChanged) @pyqtProperty(int, notify=sizeChanged)
def image(self): def size(self):
return self._image return self._size
@pyqtProperty(str, notify=dataChanged) @pyqtProperty(str, notify=dataChanged)
def data(self): def data(self):
if not self._data:
return ''
return self._data.data return self._data.data
@pyqtProperty('QPoint', notify=dataChanged) @pyqtSlot()
def center(self): def reset(self):
(x,y) = self._data.center self._data = None
return QPoint(x+self.scan_pos_x, y+self.scan_pos_y) self.dataChanged.emit()
@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): class QEQRImageProvider(QQuickImageProvider):
@ -161,7 +158,7 @@ class QEQRImageProvider(QQuickImageProvider):
# fake it # fake it
modules = 17 + qr.border * 2 modules = 17 + qr.border * 2
box_size = math.floor(pixelsize/modules) 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')) self.qimg.fill(QColor('gray'))
return self.qimg, self.qimg.size() return self.qimg, self.qimg.size()

Loading…
Cancel
Save