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'))
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()

4
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

6
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

17
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())
}
}
}

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

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

248
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
}
}

1
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')

9
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'

127
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()

Loading…
Cancel
Save