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. 248
      electrum/gui/qml/components/controls/QRScan.qml
  7. 1
      electrum/gui/qml/components/main.qml
  8. 9
      electrum/gui/qml/qeapp.py
  9. 127
      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
} }
} }

248
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,73 +13,125 @@ 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()
} }
VideoOutput { function stop() {
id: vo console.log('qrscan.stop')
scanner.active = false
}
Item {
id: points
z: 100
anchors.fill: parent anchors.fill: parent
// source: camera }
fillMode: VideoOutput.PreserveAspectCrop
Rectangle { Loader {
width: parent.width id: loader
height: (parent.height - parent.width) / 2 anchors.fill: parent
visible: camera.cameraStatus == Camera.ActiveStatus sourceComponent: scancomp
anchors.top: parent.top onStatusChanged: {
color: Qt.rgba(0,0,0,0.5) if (loader.status == Loader.Ready) {
} console.log('camera loaded')
Rectangle { } else if (loader.status == Loader.Error) {
width: parent.width console.log('camera load error')
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
} }
text: scanner.hint
} }
} }
Image { Component {
id: still id: scancomp
anchors.fill: vo
} Item {
property alias vo: _vo
property alias ic: _ic
property alias startTimer: _startTimer
SequentialAnimation { VideoOutput {
id: foundAnimation id: _vo
PropertyAction { target: scanner; property: '_pointsVisible'; value: true} anchors.fill: parent
PauseAnimation { duration: 80 }
PropertyAction { target: scanner; property: '_pointsVisible'; value: false} fillMode: VideoOutput.PreserveAspectCrop
PauseAnimation { duration: 80 }
PropertyAction { target: scanner; property: '_pointsVisible'; value: true} Rectangle {
PauseAnimation { duration: 80 } width: parent.width
PropertyAction { target: scanner; property: '_pointsVisible'; value: false} height: (parent.height - parent.width) / 2
PauseAnimation { duration: 80 } anchors.top: parent.top
PropertyAction { target: scanner; property: '_pointsVisible'; value: true} color: Qt.rgba(0,0,0,0.5)
PauseAnimation { duration: 80 } }
PropertyAction { target: scanner; property: '_pointsVisible'; value: false} Rectangle {
PauseAnimation { duration: 80 } width: parent.width
PropertyAction { target: scanner; property: '_pointsVisible'; value: true} height: (parent.height - parent.width) / 2
onFinished: found() 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 { 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'

127
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,103 +23,99 @@ 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._parseQR(image) self._logger.debug('invalid frame')
return
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)
async def co_parse_qr(image): async def co_parse_qr(frame):
# Convert to Y800 / GREY FourCC (single 8-bit channel) image = frame.toImage()
# This creates a copy, so we don't need to keep the frame around anymore self._parseQR(image)
frame_y800 = image.convertToFormat(QImage.Format_Grayscale8)
self.frame_id = 0 asyncio.run_coroutine_threadsafe(co_parse_qr(videoframe), get_asyncio_loop())
# 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: def _parseQR(self, image: QImage):
result = self.qrreader_res[0] self._size = min(image.width(), image.height())
self._data = result self.sizeChanged.emit()
self.dataChanged.emit() img_crop_rect = self._get_crop(image, self._size)
frame_cropped = image.copy(img_crop_rect)
self._busy = False # Convert to Y800 / GREY FourCC (single 8-bit channel)
self.busyChanged.emit() 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: 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