ghost43
4 years ago
committed by
GitHub
26 changed files with 1744 additions and 171 deletions
@ -1 +0,0 @@ |
|||
Subproject commit 59dfc03272751cd29ee311456fa34c40f7ebb7c0 |
@ -0,0 +1,141 @@ |
|||
# Copyright (C) 2021 The Electrum developers |
|||
# Distributed under the MIT software license, see the accompanying |
|||
# file LICENCE or http://www.opensource.org/licenses/mit-license.php |
|||
# |
|||
# We have two toolchains to scan qr codes: |
|||
# 1. access camera via QtMultimedia, take picture, feed picture to zbar |
|||
# 2. let zbar handle whole flow (including accessing the camera) |
|||
# |
|||
# notes: |
|||
# - zbar needs to be compiled with platform-dependent extra config options to be able |
|||
# to access the camera |
|||
# - zbar fails to access the camera on macOS |
|||
# - qtmultimedia seems to support more cameras on Windows than zbar |
|||
# - qtmultimedia is often not packaged with PyQt5 |
|||
# in particular, on debian, you need both "python3-pyqt5" and "python3-pyqt5.qtmultimedia" |
|||
# - older versions of qtmultimedia don't seem to work reliably |
|||
# |
|||
# Considering the above, we use QtMultimedia for Windows and macOS, as there |
|||
# most users run our binaries where we can make sure the packaged versions work well. |
|||
# On Linux where many people run from source, we use zbar. |
|||
# |
|||
# Note: this module is safe to import on all platforms. |
|||
|
|||
import sys |
|||
from typing import Callable, Optional, TYPE_CHECKING, Mapping |
|||
|
|||
from PyQt5.QtWidgets import QMessageBox, QWidget |
|||
|
|||
from electrum.i18n import _ |
|||
from electrum.util import UserFacingException |
|||
from electrum.logging import get_logger |
|||
|
|||
from electrum.gui.qt.util import MessageBoxMixin, custom_message_box |
|||
|
|||
|
|||
if TYPE_CHECKING: |
|||
from electrum.simple_config import SimpleConfig |
|||
|
|||
|
|||
_logger = get_logger(__name__) |
|||
|
|||
|
|||
def scan_qrcode( |
|||
*, |
|||
parent: Optional[QWidget], |
|||
config: 'SimpleConfig', |
|||
callback: Callable[[bool, str, Optional[str]], None], |
|||
) -> None: |
|||
if sys.platform == 'darwin' or sys.platform in ('windows', 'win32'): |
|||
_scan_qrcode_using_qtmultimedia(parent=parent, config=config, callback=callback) |
|||
else: # desktop Linux and similar |
|||
_scan_qrcode_using_zbar(parent=parent, config=config, callback=callback) |
|||
|
|||
|
|||
def find_system_cameras() -> Mapping[str, str]: |
|||
"""Returns a camera_description -> camera_path map.""" |
|||
if sys.platform == 'darwin' or sys.platform in ('windows', 'win32'): |
|||
try: |
|||
from .qtmultimedia import find_system_cameras |
|||
except ImportError as e: |
|||
return {} |
|||
else: |
|||
return find_system_cameras() |
|||
else: # desktop Linux and similar |
|||
from electrum import qrscanner |
|||
return qrscanner.find_system_cameras() |
|||
|
|||
|
|||
# --- Internals below (not part of external API) |
|||
|
|||
def _scan_qrcode_using_zbar( |
|||
*, |
|||
parent: Optional[QWidget], |
|||
config: 'SimpleConfig', |
|||
callback: Callable[[bool, str, Optional[str]], None], |
|||
) -> None: |
|||
from electrum import qrscanner |
|||
data = None |
|||
try: |
|||
data = qrscanner.scan_barcode(config.get_video_device()) |
|||
except UserFacingException as e: |
|||
success = False |
|||
error = str(e) |
|||
except BaseException as e: |
|||
_logger.exception('camera error') |
|||
success = False |
|||
error = repr(e) |
|||
else: |
|||
success = True |
|||
error = "" |
|||
callback(success, error, data) |
|||
|
|||
|
|||
# Use a global to prevent multiple QR dialogs created simultaneously |
|||
_qr_dialog = None |
|||
|
|||
|
|||
def _scan_qrcode_using_qtmultimedia( |
|||
*, |
|||
parent: Optional[QWidget], |
|||
config: 'SimpleConfig', |
|||
callback: Callable[[bool, str, Optional[str]], None], |
|||
) -> None: |
|||
try: |
|||
from .qtmultimedia import QrReaderCameraDialog, CameraError, MissingQrDetectionLib |
|||
except ImportError as e: |
|||
icon = QMessageBox.Warning |
|||
title = _("QR Reader Error") |
|||
message = _("QR reader failed to load. This may happen if " |
|||
"you are using an older version of PyQt5.") + "\n\n" + str(e) |
|||
if isinstance(parent, MessageBoxMixin): |
|||
parent.msg_box(title=title, text=message, icon=icon, parent=None) |
|||
else: |
|||
custom_message_box(title=title, text=message, icon=icon, parent=parent) |
|||
return |
|||
|
|||
global _qr_dialog |
|||
if _qr_dialog: |
|||
_logger.warning("QR dialog is already presented, ignoring.") |
|||
return |
|||
_qr_dialog = None |
|||
try: |
|||
_qr_dialog = QrReaderCameraDialog(parent=parent, config=config) |
|||
|
|||
def _on_qr_reader_finished(success: bool, error: str, data): |
|||
global _qr_dialog |
|||
if _qr_dialog: |
|||
_qr_dialog.deleteLater() |
|||
_qr_dialog = None |
|||
callback(success, error, data) |
|||
|
|||
_qr_dialog.qr_finished.connect(_on_qr_reader_finished) |
|||
_qr_dialog.start_scan(config.get_video_device()) |
|||
except (MissingQrDetectionLib, CameraError) as e: |
|||
_qr_dialog = None |
|||
callback(False, str(e), None) |
|||
except Exception as e: |
|||
_logger.exception('camera error') |
|||
_qr_dialog = None |
|||
callback(False, repr(e), None) |
|||
|
@ -0,0 +1,39 @@ |
|||
#!/usr/bin/env python3 |
|||
# |
|||
# Electron Cash - lightweight Bitcoin client |
|||
# Copyright (C) 2019 Axel Gembe <derago@gmail.com> |
|||
# |
|||
# Permission is hereby granted, free of charge, to any person |
|||
# obtaining a copy of this software and associated documentation files |
|||
# (the "Software"), to deal in the Software without restriction, |
|||
# including without limitation the rights to use, copy, modify, merge, |
|||
# publish, distribute, sublicense, and/or sell copies of the Software, |
|||
# and to permit persons to whom the Software is furnished to do so, |
|||
# subject to the following conditions: |
|||
# |
|||
# The above copyright notice and this permission notice shall be |
|||
# included in all copies or substantial portions of the Software. |
|||
# |
|||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
|||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
|||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND |
|||
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS |
|||
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN |
|||
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
|||
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|||
# SOFTWARE. |
|||
|
|||
from typing import Mapping |
|||
|
|||
from .camera_dialog import (QrReaderCameraDialog, CameraError, NoCamerasFound, |
|||
NoCameraResolutionsFound, MissingQrDetectionLib) |
|||
from .validator import (QrReaderValidatorResult, AbstractQrReaderValidator, |
|||
QrReaderValidatorCounting, QrReaderValidatorColorizing, |
|||
QrReaderValidatorStrong, QrReaderValidatorCounted) |
|||
|
|||
|
|||
def find_system_cameras() -> Mapping[str, str]: |
|||
"""Returns a camera_description -> camera_path map.""" |
|||
from PyQt5.QtMultimedia import QCameraInfo |
|||
system_cameras = QCameraInfo.availableCameras() |
|||
return {cam.description(): cam.deviceName() for cam in system_cameras} |
@ -0,0 +1,463 @@ |
|||
#!/usr/bin/env python3 |
|||
# |
|||
# Electron Cash - lightweight Bitcoin client |
|||
# Copyright (C) 2019 Axel Gembe <derago@gmail.com> |
|||
# |
|||
# Permission is hereby granted, free of charge, to any person |
|||
# obtaining a copy of this software and associated documentation files |
|||
# (the "Software"), to deal in the Software without restriction, |
|||
# including without limitation the rights to use, copy, modify, merge, |
|||
# publish, distribute, sublicense, and/or sell copies of the Software, |
|||
# and to permit persons to whom the Software is furnished to do so, |
|||
# subject to the following conditions: |
|||
# |
|||
# The above copyright notice and this permission notice shall be |
|||
# included in all copies or substantial portions of the Software. |
|||
# |
|||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
|||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
|||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND |
|||
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS |
|||
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN |
|||
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
|||
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|||
# SOFTWARE. |
|||
|
|||
import time |
|||
import math |
|||
import sys |
|||
import os |
|||
from typing import List |
|||
|
|||
from PyQt5.QtMultimedia import QCameraInfo, QCamera, QCameraViewfinderSettings |
|||
from PyQt5.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QCheckBox, QPushButton, QLabel |
|||
from PyQt5.QtGui import QImage, QPixmap |
|||
from PyQt5.QtCore import QSize, QRect, Qt, pyqtSignal, PYQT_VERSION |
|||
|
|||
from electrum.simple_config import SimpleConfig |
|||
from electrum.i18n import _ |
|||
from electrum.qrreader import get_qr_reader, QrCodeResult |
|||
from electrum.logging import Logger |
|||
|
|||
from electrum.gui.qt.util import MessageBoxMixin, FixedAspectRatioLayout, ImageGraphicsEffect |
|||
|
|||
from .video_widget import QrReaderVideoWidget |
|||
from .video_overlay import QrReaderVideoOverlay |
|||
from .video_surface import QrReaderVideoSurface |
|||
from .crop_blur_effect import QrReaderCropBlurEffect |
|||
from .validator import AbstractQrReaderValidator, QrReaderValidatorCounted, QrReaderValidatorResult |
|||
|
|||
|
|||
class CameraError(RuntimeError): |
|||
''' Base class of the camera-related error conditions. ''' |
|||
|
|||
class NoCamerasFound(CameraError): |
|||
''' Raised by start_scan if no usable cameras were found. Interested |
|||
code can catch this specific exception.''' |
|||
|
|||
class NoCameraResolutionsFound(CameraError): |
|||
''' Raised internally if no usable camera resolutions were found. ''' |
|||
|
|||
class MissingQrDetectionLib(RuntimeError): |
|||
''' Raised if we can't find zbar or whatever other platform lib |
|||
we require to detect QR in image frames. ''' |
|||
|
|||
class QrReaderCameraDialog(Logger, MessageBoxMixin, QDialog): |
|||
""" |
|||
Dialog for reading QR codes from a camera |
|||
""" |
|||
|
|||
# Try to crop so we have minimum 512 dimensions |
|||
SCAN_SIZE: int = 512 |
|||
|
|||
qr_finished = pyqtSignal(bool, str, object) |
|||
|
|||
def __init__(self, parent, *, config: SimpleConfig): |
|||
''' Note: make sure parent is a "top_level_window()" as per |
|||
MessageBoxMixin API else bad things can happen on macOS. ''' |
|||
QDialog.__init__(self, parent=parent) |
|||
Logger.__init__(self) |
|||
|
|||
self.validator: AbstractQrReaderValidator = None |
|||
self.frame_id: int = 0 |
|||
self.qr_crop: QRect = None |
|||
self.qrreader_res: List[QrCodeResult] = [] |
|||
self.validator_res: QrReaderValidatorResult = None |
|||
self.last_stats_time: float = 0.0 |
|||
self.frame_counter: int = 0 |
|||
self.qr_frame_counter: int = 0 |
|||
self.last_qr_scan_ts: float = 0.0 |
|||
self.camera: QCamera = None |
|||
self._error_message: str = None |
|||
self._ok_done: bool = False |
|||
self.camera_sc_conn = None |
|||
self.resolution: QSize = None |
|||
|
|||
self.config = config |
|||
|
|||
# Try to get the QR reader for this system |
|||
self.qrreader = get_qr_reader() |
|||
if not self.qrreader: |
|||
raise MissingQrDetectionLib(_("The platform QR detection library is not available.")) |
|||
|
|||
# Set up the window, add the maximize button |
|||
flags = self.windowFlags() |
|||
flags = flags | Qt.WindowMaximizeButtonHint |
|||
self.setWindowFlags(flags) |
|||
self.setWindowTitle(_("Scan QR Code")) |
|||
self.setWindowModality(Qt.WindowModal if parent else Qt.ApplicationModal) |
|||
|
|||
# Create video widget and fixed aspect ratio layout to contain it |
|||
self.video_widget = QrReaderVideoWidget() |
|||
self.video_overlay = QrReaderVideoOverlay() |
|||
self.video_layout = FixedAspectRatioLayout() |
|||
self.video_layout.addWidget(self.video_widget) |
|||
self.video_layout.addWidget(self.video_overlay) |
|||
|
|||
# Create root layout and add the video widget layout to it |
|||
vbox = QVBoxLayout() |
|||
self.setLayout(vbox) |
|||
vbox.setContentsMargins(0, 0, 0, 0) |
|||
vbox.addLayout(self.video_layout) |
|||
|
|||
self.lowres_label = QLabel(_("Note: This camera generates frames of relatively low resolution; QR scanning accuracy may be affected")) |
|||
self.lowres_label.setWordWrap(True) |
|||
self.lowres_label.setAlignment(Qt.AlignVCenter | Qt.AlignHCenter) |
|||
vbox.addWidget(self.lowres_label) |
|||
self.lowres_label.setHidden(True) |
|||
|
|||
# Create a layout for the controls |
|||
controls_layout = QHBoxLayout() |
|||
controls_layout.addStretch(2) |
|||
controls_layout.setContentsMargins(10, 10, 10, 10) |
|||
controls_layout.setSpacing(10) |
|||
vbox.addLayout(controls_layout) |
|||
|
|||
# Flip horizontally checkbox with default coming from global config |
|||
self.flip_x = QCheckBox() |
|||
self.flip_x.setText(_("&Flip horizontally")) |
|||
self.flip_x.setChecked(bool(self.config.get('qrreader_flip_x', True))) |
|||
self.flip_x.stateChanged.connect(self._on_flip_x_changed) |
|||
controls_layout.addWidget(self.flip_x) |
|||
|
|||
close_but = QPushButton(_("&Close")) |
|||
close_but.clicked.connect(self.reject) |
|||
controls_layout.addWidget(close_but) |
|||
|
|||
# Create the video surface and receive events when new frames arrive |
|||
self.video_surface = QrReaderVideoSurface(self) |
|||
self.video_surface.frame_available.connect(self._on_frame_available) |
|||
|
|||
# Create the crop blur effect |
|||
self.crop_blur_effect = QrReaderCropBlurEffect(self) |
|||
self.image_effect = ImageGraphicsEffect(self, self.crop_blur_effect) |
|||
|
|||
|
|||
# Note these should stay as queued connections becasue we use the idiom |
|||
# self.reject() and self.accept() in this class to kill the scan -- |
|||
# and we do it from within callback functions. If you don't use |
|||
# queued connections here, bad things can happen. |
|||
self.finished.connect(self._boilerplate_cleanup, Qt.QueuedConnection) |
|||
self.finished.connect(self._on_finished, Qt.QueuedConnection) |
|||
|
|||
def _on_flip_x_changed(self, _state: int): |
|||
self.config.set_key('qrreader_flip_x', self.flip_x.isChecked()) |
|||
|
|||
def _get_resolution(self, resolutions: List[QSize], min_size: int) -> QSize: |
|||
""" |
|||
Given a list of resolutions that the camera supports this function picks the |
|||
lowest resolution that is at least min_size in both width and height. |
|||
If no resolution is found, NoCameraResolutionsFound is raised. |
|||
""" |
|||
def res_list_to_str(res_list: List[QSize]) -> str: |
|||
return ', '.join(['{}x{}'.format(r.width(), r.height()) for r in res_list]) |
|||
|
|||
def check_res(res: QSize): |
|||
return res.width() >= min_size and res.height() >= min_size |
|||
|
|||
self.logger.info('searching for at least {0}x{0}'.format(min_size)) |
|||
|
|||
# Query and display all resolutions the camera supports |
|||
format_str = 'camera resolutions: {}' |
|||
self.logger.info(format_str.format(res_list_to_str(resolutions))) |
|||
|
|||
# Filter to those that are at least min_size in both width and height |
|||
candidate_resolutions = [] |
|||
ideal_resolutions = [r for r in resolutions if check_res(r)] |
|||
less_than_ideal_resolutions = [r for r in resolutions if r not in ideal_resolutions] |
|||
format_str = 'ideal resolutions: {}, less-than-ideal resolutions: {}' |
|||
self.logger.info(format_str.format(res_list_to_str(ideal_resolutions), res_list_to_str(less_than_ideal_resolutions))) |
|||
|
|||
# Raise an error if we have no usable resolutions |
|||
if not ideal_resolutions and not less_than_ideal_resolutions: |
|||
raise NoCameraResolutionsFound(_("Cannot start QR scanner, no usable camera resolution found.") + self._linux_pyqt5bug_msg()) |
|||
|
|||
if not ideal_resolutions: |
|||
self.logger.warning('No ideal resolutions found, falling back to less-than-ideal resolutions -- QR recognition may fail!') |
|||
candidate_resolutions = less_than_ideal_resolutions |
|||
is_ideal = False |
|||
else: |
|||
candidate_resolutions = ideal_resolutions |
|||
is_ideal = True |
|||
|
|||
|
|||
# Sort the usable resolutions, least number of pixels first, get the first element |
|||
resolution = sorted(candidate_resolutions, key=lambda r: r.width() * r.height(), reverse=not is_ideal)[0] |
|||
format_str = 'chosen resolution is {}x{}' |
|||
self.logger.info(format_str.format(resolution.width(), resolution.height())) |
|||
|
|||
return resolution, is_ideal |
|||
|
|||
@staticmethod |
|||
def _get_crop(resolution: QSize, scan_size: int) -> QRect: |
|||
""" |
|||
Returns a QRect that is scan_size x scan_size in the middle of the resolution |
|||
""" |
|||
scan_pos_x = (resolution.width() - scan_size) / 2 |
|||
scan_pos_y = (resolution.height() - scan_size) / 2 |
|||
return QRect(scan_pos_x, scan_pos_y, scan_size, scan_size) |
|||
|
|||
@staticmethod |
|||
def _linux_pyqt5bug_msg(): |
|||
''' Returns a string that may be appended to an exception error message |
|||
only if on Linux and PyQt5 < 5.12.2, otherwise returns an empty string. ''' |
|||
if (sys.platform == 'linux' and PYQT_VERSION < 0x050c02 # Check if PyQt5 < 5.12.2 on linux |
|||
# Also: this warning is not relevant to APPIMAGE; so make sure |
|||
# we are not running from APPIMAGE. |
|||
and not os.environ.get('APPIMAGE')): |
|||
# In this case it's possible we couldn't detect a camera because |
|||
# of that missing libQt5MultimediaGstTools.so problem. |
|||
return ("\n\n" + _('If you indeed do have a usable camera connected, then this error may be caused by bugs in previous PyQt5 versions on Linux. Try installing the latest PyQt5:') |
|||
+ "\n\n" + "python3 -m pip install --user -I pyqt5") |
|||
return '' |
|||
|
|||
def start_scan(self, device: str = ''): |
|||
""" |
|||
Scans a QR code from the given camera device. |
|||
If no QR code is found the returned string will be empty. |
|||
If the camera is not found or can't be opened NoCamerasFound will be raised. |
|||
""" |
|||
|
|||
self.validator = QrReaderValidatorCounted() |
|||
self.validator.strong_count = 5 # FIXME: make this time based rather than framect based |
|||
|
|||
device_info = None |
|||
|
|||
for camera in QCameraInfo.availableCameras(): |
|||
if camera.deviceName() == device: |
|||
device_info = camera |
|||
break |
|||
|
|||
if not device_info: |
|||
self.logger.info('Failed to open selected camera, trying to use default camera') |
|||
device_info = QCameraInfo.defaultCamera() |
|||
|
|||
if not device_info or device_info.isNull(): |
|||
raise NoCamerasFound(_("Cannot start QR scanner, no usable camera found.") + self._linux_pyqt5bug_msg()) |
|||
|
|||
self._init_stats() |
|||
self.qrreader_res = [] |
|||
self.validator_res = None |
|||
self._ok_done = False |
|||
self._error_message = None |
|||
|
|||
if self.camera: |
|||
self.logger.info("Warning: start_scan already called for this instance.") |
|||
|
|||
self.camera = QCamera(device_info) |
|||
self.camera.setViewfinder(self.video_surface) |
|||
self.camera.setCaptureMode(QCamera.CaptureViewfinder) |
|||
|
|||
# this operates on camera from within the signal handler, so should be a queued connection |
|||
self.camera_sc_conn = self.camera.statusChanged.connect(self._on_camera_status_changed, Qt.QueuedConnection) |
|||
self.camera.error.connect(self._on_camera_error) # log the errors we get, if any, for debugging |
|||
# Camera needs to be loaded to query resolutions, this tries to open the camera |
|||
self.camera.load() |
|||
|
|||
_camera_status_names = { |
|||
QCamera.UnavailableStatus: _('unavailable'), |
|||
QCamera.UnloadedStatus: _('unloaded'), |
|||
QCamera.UnloadingStatus: _('unloading'), |
|||
QCamera.LoadingStatus: _('loading'), |
|||
QCamera.LoadedStatus: _('loaded'), |
|||
QCamera.StandbyStatus: _('standby'), |
|||
QCamera.StartingStatus: _('starting'), |
|||
QCamera.StoppingStatus: _('stopping'), |
|||
QCamera.ActiveStatus: _('active') |
|||
} |
|||
|
|||
def _get_camera_status_name(self, status: QCamera.Status): |
|||
return self._camera_status_names.get(status, _('unknown')) |
|||
|
|||
def _set_resolution(self, resolution: QSize): |
|||
self.resolution = resolution |
|||
self.qr_crop = self._get_crop(resolution, self.SCAN_SIZE) |
|||
|
|||
# Initialize the video widget |
|||
#self.video_widget.setMinimumSize(resolution) # <-- on macOS this makes it fixed size for some reason. |
|||
self.resize(720, 540) |
|||
self.video_overlay.set_crop(self.qr_crop) |
|||
self.video_overlay.set_resolution(resolution) |
|||
self.video_layout.set_aspect_ratio(resolution.width() / resolution.height()) |
|||
|
|||
# Set up the crop blur effect |
|||
self.crop_blur_effect.setCrop(self.qr_crop) |
|||
|
|||
def _on_camera_status_changed(self, status: QCamera.Status): |
|||
if self._ok_done: |
|||
# camera/scan is quitting, abort. |
|||
return |
|||
|
|||
self.logger.info('camera status changed to {}'.format(self._get_camera_status_name(status))) |
|||
|
|||
if status == QCamera.LoadedStatus: |
|||
# Determine the optimal resolution and compute the crop rect |
|||
camera_resolutions = self.camera.supportedViewfinderResolutions() |
|||
try: |
|||
resolution, was_ideal = self._get_resolution(camera_resolutions, self.SCAN_SIZE) |
|||
except RuntimeError as e: |
|||
self._error_message = str(e) |
|||
self.reject() |
|||
return |
|||
self._set_resolution(resolution) |
|||
|
|||
# Set the camera resolution |
|||
viewfinder_settings = QCameraViewfinderSettings() |
|||
viewfinder_settings.setResolution(resolution) |
|||
self.camera.setViewfinderSettings(viewfinder_settings) |
|||
|
|||
# Counter for the QR scanner frame number |
|||
self.frame_id = 0 |
|||
|
|||
self.camera.start() |
|||
self.lowres_label.setVisible(not was_ideal) # if they have a low res camera, show the warning label. |
|||
elif status == QCamera.UnloadedStatus or status == QCamera.UnavailableStatus: |
|||
self._error_message = _("Cannot start QR scanner, camera is unavailable.") |
|||
self.reject() |
|||
elif status == QCamera.ActiveStatus: |
|||
self.open() |
|||
|
|||
CameraErrorStrings = { |
|||
QCamera.NoError : "No Error", |
|||
QCamera.CameraError : "Camera Error", |
|||
QCamera.InvalidRequestError : "Invalid Request Error", |
|||
QCamera.ServiceMissingError : "Service Missing Error", |
|||
QCamera.NotSupportedFeatureError : "Unsupported Feature Error" |
|||
} |
|||
def _on_camera_error(self, errorCode): |
|||
errStr = self.CameraErrorStrings.get(errorCode, "Unknown Error") |
|||
self.logger.info(f"QCamera error: {errStr}") |
|||
|
|||
def accept(self): |
|||
self._ok_done = True # immediately blocks further processing |
|||
super().accept() |
|||
|
|||
def reject(self): |
|||
self._ok_done = True # immediately blocks further processing |
|||
super().reject() |
|||
|
|||
def _boilerplate_cleanup(self): |
|||
self._close_camera() |
|||
if self.isVisible(): |
|||
self.close() |
|||
|
|||
def _close_camera(self): |
|||
if self.camera: |
|||
self.camera.setViewfinder(None) |
|||
if self.camera_sc_conn: |
|||
self.camera.statusChanged.disconnect(self.camera_sc_conn) |
|||
self.camera_sc_conn = None |
|||
self.camera.unload() |
|||
self.camera = None |
|||
|
|||
def _on_finished(self, code): |
|||
res = ( (code == QDialog.Accepted |
|||
and self.validator_res and self.validator_res.accepted |
|||
and self.validator_res.simple_result) |
|||
or '' ) |
|||
|
|||
self.validator = None |
|||
|
|||
self.logger.info(f'closed {res}') |
|||
|
|||
self.qr_finished.emit(code == QDialog.Accepted, self._error_message, res) |
|||
|
|||
def _on_frame_available(self, frame: QImage): |
|||
if self._ok_done: |
|||
return |
|||
|
|||
self.frame_id += 1 |
|||
|
|||
if frame.size() != self.resolution: |
|||
self.logger.info('Getting video data at {}x{} instead of the requested {}x{}, switching resolution.'.format( |
|||
frame.size().width(), frame.size().height(), |
|||
self.resolution.width(), self.resolution.height() |
|||
)) |
|||
self._set_resolution(frame.size()) |
|||
|
|||
flip_x = self.flip_x.isChecked() |
|||
|
|||
# Only QR scan every QR_SCAN_PERIOD secs |
|||
qr_scanned = time.time() - self.last_qr_scan_ts >= self.qrreader.interval() |
|||
if qr_scanned: |
|||
self.last_qr_scan_ts = time.time() |
|||
# Crop the frame so we only scan a SCAN_SIZE rect |
|||
frame_cropped = frame.copy(self.qr_crop) |
|||
|
|||
# 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) |
|||
|
|||
# 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 |
|||
) |
|||
|
|||
# Call the validator to see if the scanned results are acceptable |
|||
self.validator_res = self.validator.validate_results(self.qrreader_res) |
|||
|
|||
# Update the video overlay with the results |
|||
self.video_overlay.set_results(self.qrreader_res, flip_x, self.validator_res) |
|||
|
|||
# Close the dialog if the validator accepted the result |
|||
if self.validator_res.accepted: |
|||
self.accept() |
|||
return |
|||
|
|||
# Apply the crop blur effect |
|||
if self.image_effect: |
|||
frame = self.image_effect.apply(frame) |
|||
|
|||
# If horizontal flipping is enabled, only flip the display |
|||
if flip_x: |
|||
frame = frame.mirrored(True, False) |
|||
|
|||
# Display the frame in the widget |
|||
self.video_widget.setPixmap(QPixmap.fromImage(frame)) |
|||
|
|||
self._update_stats(qr_scanned) |
|||
|
|||
def _init_stats(self): |
|||
self.last_stats_time = time.perf_counter() |
|||
self.frame_counter = 0 |
|||
self.qr_frame_counter = 0 |
|||
|
|||
def _update_stats(self, qr_scanned): |
|||
self.frame_counter += 1 |
|||
if qr_scanned: |
|||
self.qr_frame_counter += 1 |
|||
now = time.perf_counter() |
|||
last_stats_delta = now - self.last_stats_time |
|||
if last_stats_delta > 1.0: # stats every 1.0 seconds |
|||
fps = self.frame_counter / last_stats_delta |
|||
qr_fps = self.qr_frame_counter / last_stats_delta |
|||
if self.validator is not None: |
|||
self.validator.strong_count = math.ceil(qr_fps / 3) # 1/3 of a second's worth of qr frames determines strong_count |
|||
stats_format = 'running at {} FPS, scanner at {} FPS' |
|||
self.logger.info(stats_format.format(fps, qr_fps)) |
|||
self.frame_counter = 0 |
|||
self.qr_frame_counter = 0 |
|||
self.last_stats_time = now |
@ -0,0 +1,77 @@ |
|||
#!/usr/bin/env python3 |
|||
# |
|||
# Electron Cash - lightweight Bitcoin client |
|||
# Copyright (C) 2019 Axel Gembe <derago@gmail.com> |
|||
# |
|||
# Permission is hereby granted, free of charge, to any person |
|||
# obtaining a copy of this software and associated documentation files |
|||
# (the "Software"), to deal in the Software without restriction, |
|||
# including without limitation the rights to use, copy, modify, merge, |
|||
# publish, distribute, sublicense, and/or sell copies of the Software, |
|||
# and to permit persons to whom the Software is furnished to do so, |
|||
# subject to the following conditions: |
|||
# |
|||
# The above copyright notice and this permission notice shall be |
|||
# included in all copies or substantial portions of the Software. |
|||
# |
|||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
|||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
|||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND |
|||
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS |
|||
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN |
|||
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
|||
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|||
# SOFTWARE. |
|||
|
|||
from PyQt5.QtWidgets import QGraphicsBlurEffect, QGraphicsEffect |
|||
from PyQt5.QtGui import QPainter, QTransform, QRegion |
|||
from PyQt5.QtCore import QObject, QRect, QPoint, Qt |
|||
|
|||
|
|||
class QrReaderCropBlurEffect(QGraphicsBlurEffect): |
|||
CROP_OFFSET_ENABLED = False |
|||
CROP_OFFSET = QPoint(5, 5) |
|||
|
|||
BLUR_DARKEN = 0.25 |
|||
BLUR_RADIUS = 8 |
|||
|
|||
def __init__(self, parent: QObject, crop: QRect = None): |
|||
super().__init__(parent) |
|||
self.crop = crop |
|||
self.setBlurRadius(self.BLUR_RADIUS) |
|||
|
|||
def setCrop(self, crop: QRect = None): |
|||
self.crop = crop |
|||
|
|||
def draw(self, painter: QPainter): |
|||
assert self.crop, 'crop must be set' |
|||
|
|||
# Compute painter regions for the crop and the blur |
|||
all_region = QRegion(painter.viewport()) |
|||
crop_region = QRegion(self.crop) |
|||
blur_region = all_region.subtracted(crop_region) |
|||
|
|||
# Let the QGraphicsBlurEffect only paint in blur_region |
|||
painter.setClipRegion(blur_region) |
|||
|
|||
# Fill with black and set opacity so that the blurred region is drawn darker |
|||
if self.BLUR_DARKEN > 0.0: |
|||
painter.fillRect(painter.viewport(), Qt.black) |
|||
painter.setOpacity(1 - self.BLUR_DARKEN) |
|||
|
|||
# Draw the blur effect |
|||
super().draw(painter) |
|||
|
|||
# Restore clipping and opacity |
|||
painter.setClipping(False) |
|||
painter.setOpacity(1.0) |
|||
|
|||
# Get the source pixmap |
|||
pixmap, offset = self.sourcePixmap(Qt.DeviceCoordinates, QGraphicsEffect.NoPad) |
|||
painter.setWorldTransform(QTransform()) |
|||
|
|||
# Get the source by adding the offset to the crop location |
|||
source = self.crop |
|||
if self.CROP_OFFSET_ENABLED: |
|||
source = source.translated(self.CROP_OFFSET) |
|||
painter.drawPixmap(self.crop.topLeft() + offset, pixmap, source) |
@ -0,0 +1,166 @@ |
|||
#!/usr/bin/env python3 |
|||
# |
|||
# Electron Cash - lightweight Bitcoin client |
|||
# Copyright (C) 2019 Axel Gembe <derago@gmail.com> |
|||
# |
|||
# Permission is hereby granted, free of charge, to any person |
|||
# obtaining a copy of this software and associated documentation files |
|||
# (the "Software"), to deal in the Software without restriction, |
|||
# including without limitation the rights to use, copy, modify, merge, |
|||
# publish, distribute, sublicense, and/or sell copies of the Software, |
|||
# and to permit persons to whom the Software is furnished to do so, |
|||
# subject to the following conditions: |
|||
# |
|||
# The above copyright notice and this permission notice shall be |
|||
# included in all copies or substantial portions of the Software. |
|||
# |
|||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
|||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
|||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND |
|||
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS |
|||
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN |
|||
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
|||
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|||
# SOFTWARE. |
|||
|
|||
from typing import List, Dict, Callable, Any |
|||
from abc import ABC, abstractmethod |
|||
|
|||
from PyQt5.QtGui import QColor |
|||
from PyQt5.QtCore import Qt |
|||
|
|||
from electrum.i18n import _ |
|||
from electrum.qrreader import QrCodeResult |
|||
|
|||
from electrum.gui.qt.util import ColorScheme, QColorLerp |
|||
|
|||
|
|||
class QrReaderValidatorResult(): |
|||
""" |
|||
Result of a QR code validator |
|||
""" |
|||
|
|||
def __init__(self): |
|||
self.accepted: bool = False |
|||
|
|||
self.message: str = None |
|||
self.message_color: QColor = None |
|||
|
|||
self.simple_result : str = None |
|||
|
|||
self.result_usable: Dict[QrCodeResult, bool] = {} |
|||
self.result_colors: Dict[QrCodeResult, QColor] = {} |
|||
self.result_messages: Dict[QrCodeResult, str] = {} |
|||
|
|||
self.selected_results: List[QrCodeResult] = [] |
|||
|
|||
|
|||
class AbstractQrReaderValidator(ABC): |
|||
""" |
|||
Abstract base class for QR code result validators. |
|||
""" |
|||
|
|||
@abstractmethod |
|||
def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResult: |
|||
""" |
|||
Checks a list of QR code results for usable codes. |
|||
""" |
|||
|
|||
class QrReaderValidatorCounting(AbstractQrReaderValidator): |
|||
""" |
|||
This QR code result validator doesn't directly accept any results but maintains a dictionary |
|||
of detection counts in `result_counts`. |
|||
""" |
|||
|
|||
result_counts: Dict[QrCodeResult, int] = {} |
|||
|
|||
def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResult: |
|||
res = QrReaderValidatorResult() |
|||
|
|||
for result in results: |
|||
# Increment the detection count |
|||
if not result in self.result_counts: |
|||
self.result_counts[result] = 0 |
|||
self.result_counts[result] += 1 |
|||
|
|||
# Search for missing results, iterate over a copy because the loop might modify the dict |
|||
for result in self.result_counts.copy(): |
|||
# Count down missing results |
|||
if result in results: |
|||
continue |
|||
self.result_counts[result] -= 2 |
|||
# When the count goes to zero, remove |
|||
if self.result_counts[result] < 1: |
|||
del self.result_counts[result] |
|||
|
|||
return res |
|||
|
|||
class QrReaderValidatorColorizing(QrReaderValidatorCounting): |
|||
""" |
|||
This QR code result validator doesn't directly accept any results but colorizes the results |
|||
based on the counts maintained by `QrReaderValidatorCounting`. |
|||
""" |
|||
|
|||
WEAK_COLOR: QColor = QColor(Qt.red) |
|||
STRONG_COLOR: QColor = QColor(Qt.green) |
|||
|
|||
strong_count: int = 10 |
|||
|
|||
def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResult: |
|||
res = super().validate_results(results) |
|||
|
|||
# Colorize the QR code results by their detection counts |
|||
for result in results: |
|||
# Enforce strong_count as upper limit |
|||
self.result_counts[result] = min(self.result_counts[result], self.strong_count) |
|||
|
|||
# Interpolate between WEAK_COLOR and STRONG_COLOR based on count / strong_count |
|||
lerp_factor = (self.result_counts[result] - 1) / self.strong_count |
|||
lerped_color = QColorLerp(self.WEAK_COLOR, self.STRONG_COLOR, lerp_factor) |
|||
res.result_colors[result] = lerped_color |
|||
|
|||
return res |
|||
|
|||
class QrReaderValidatorStrong(QrReaderValidatorColorizing): |
|||
""" |
|||
This QR code result validator doesn't directly accept any results but passes every strong |
|||
detection in the return values `selected_results`. |
|||
""" |
|||
|
|||
def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResult: |
|||
res = super().validate_results(results) |
|||
|
|||
for result in results: |
|||
if self.result_counts[result] >= self.strong_count: |
|||
res.selected_results.append(result) |
|||
break |
|||
|
|||
return res |
|||
|
|||
class QrReaderValidatorCounted(QrReaderValidatorStrong): |
|||
""" |
|||
This QR code result validator accepts a result as soon as there is at least `minimum` and at |
|||
most `maximum` QR code(s) with strong detection. |
|||
""" |
|||
|
|||
def __init__(self, minimum: int = 1, maximum: int = 1): |
|||
super().__init__() |
|||
self.minimum = minimum |
|||
self.maximum = maximum |
|||
|
|||
def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResult: |
|||
res = super().validate_results(results) |
|||
|
|||
num_results = len(res.selected_results) |
|||
if num_results < self.minimum: |
|||
if num_results > 0: |
|||
res.message = _('Too few QR codes detected.') |
|||
res.message_color = ColorScheme.RED.as_color() |
|||
elif num_results > self.maximum: |
|||
res.message = _('Too many QR codes detected.') |
|||
res.message_color = ColorScheme.RED.as_color() |
|||
else: |
|||
res.accepted = True |
|||
res.simple_result = (results and results[0].data) or '' # hack added by calin just to take the first one |
|||
|
|||
return res |
@ -0,0 +1,157 @@ |
|||
#!/usr/bin/env python3 |
|||
# |
|||
# Electron Cash - lightweight Bitcoin client |
|||
# Copyright (C) 2019 Axel Gembe <derago@gmail.com> |
|||
# |
|||
# Permission is hereby granted, free of charge, to any person |
|||
# obtaining a copy of this software and associated documentation files |
|||
# (the "Software"), to deal in the Software without restriction, |
|||
# including without limitation the rights to use, copy, modify, merge, |
|||
# publish, distribute, sublicense, and/or sell copies of the Software, |
|||
# and to permit persons to whom the Software is furnished to do so, |
|||
# subject to the following conditions: |
|||
# |
|||
# The above copyright notice and this permission notice shall be |
|||
# included in all copies or substantial portions of the Software. |
|||
# |
|||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
|||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
|||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND |
|||
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS |
|||
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN |
|||
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
|||
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|||
# SOFTWARE. |
|||
|
|||
from typing import List |
|||
|
|||
from PyQt5.QtWidgets import QWidget |
|||
from PyQt5.QtGui import QPainter, QPaintEvent, QPen, QPainterPath, QColor, QTransform |
|||
from PyQt5.QtCore import QPoint, QSize, QRect, QRectF, Qt |
|||
|
|||
from electrum.qrreader import QrCodeResult |
|||
|
|||
from .validator import QrReaderValidatorResult |
|||
|
|||
|
|||
class QrReaderVideoOverlay(QWidget): |
|||
""" |
|||
Overlays the QR scanner results over the video |
|||
""" |
|||
|
|||
BG_RECT_PADDING = 10 |
|||
BG_RECT_CORNER_RADIUS = 10.0 |
|||
BG_RECT_OPACITY = 0.75 |
|||
|
|||
def __init__(self, parent: QWidget = None): |
|||
super().__init__(parent) |
|||
|
|||
self.results = [] |
|||
self.flip_x = False |
|||
self.validator_results = None |
|||
self.crop = None |
|||
self.resolution = None |
|||
|
|||
self.qr_outline_pen = QPen() |
|||
self.qr_outline_pen.setColor(Qt.red) |
|||
self.qr_outline_pen.setWidth(3) |
|||
self.qr_outline_pen.setStyle(Qt.DotLine) |
|||
|
|||
self.text_pen = QPen() |
|||
self.text_pen.setColor(Qt.black) |
|||
|
|||
self.bg_rect_pen = QPen() |
|||
self.bg_rect_pen.setColor(Qt.black) |
|||
self.bg_rect_pen.setStyle(Qt.DotLine) |
|||
self.bg_rect_fill = QColor(255, 255, 255, 255 * self.BG_RECT_OPACITY) |
|||
|
|||
def set_results(self, results: List[QrCodeResult], flip_x: bool, |
|||
validator_results: QrReaderValidatorResult): |
|||
self.results = results |
|||
self.flip_x = flip_x |
|||
self.validator_results = validator_results |
|||
self.update() |
|||
|
|||
def set_crop(self, crop: QRect): |
|||
self.crop = crop |
|||
|
|||
def set_resolution(self, resolution: QSize): |
|||
self.resolution = resolution |
|||
|
|||
def paintEvent(self, _event: QPaintEvent): |
|||
if not self.crop or not self.resolution: |
|||
return |
|||
|
|||
painter = QPainter(self) |
|||
|
|||
# Keep a backup of the transform and create a new one |
|||
transform = painter.worldTransform() |
|||
|
|||
# Set scaling transform |
|||
transform = transform.scale(self.width() / self.resolution.width(), |
|||
self.height() / self.resolution.height()) |
|||
|
|||
# Compute the transform to flip the coordinate system on the x axis |
|||
transform_flip = QTransform() |
|||
if self.flip_x: |
|||
transform_flip = transform_flip.translate(self.resolution.width(), 0.0) |
|||
transform_flip = transform_flip.scale(-1.0, 1.0) |
|||
|
|||
# Small helper for tuple to QPoint |
|||
def toqp(point): |
|||
return QPoint(point[0], point[1]) |
|||
|
|||
# Starting from here we care about AA |
|||
painter.setRenderHint(QPainter.Antialiasing) |
|||
|
|||
# Draw all the QR code results |
|||
for res in self.results: |
|||
painter.setWorldTransform(transform_flip * transform, False) |
|||
|
|||
# Draw lines between all of the QR code points |
|||
pen = QPen(self.qr_outline_pen) |
|||
if res in self.validator_results.result_colors: |
|||
pen.setColor(self.validator_results.result_colors[res]) |
|||
painter.setPen(pen) |
|||
num_points = len(res.points) |
|||
for i in range(0, num_points): |
|||
i_n = i + 1 |
|||
|
|||
line_from = toqp(res.points[i]) |
|||
line_from += self.crop.topLeft() |
|||
|
|||
line_to = toqp(res.points[i_n] if i_n < num_points else res.points[0]) |
|||
line_to += self.crop.topLeft() |
|||
|
|||
painter.drawLine(line_from, line_to) |
|||
|
|||
# Draw the QR code data |
|||
# Note that we reset the world transform to only the scaled transform |
|||
# because otherwise the text could be flipped. We only use transform_flip |
|||
# to map the center point of the result. |
|||
painter.setWorldTransform(transform, False) |
|||
font_metrics = painter.fontMetrics() |
|||
data_metrics = QSize(font_metrics.horizontalAdvance(res.data), font_metrics.capHeight()) |
|||
|
|||
center_pos = toqp(res.center) |
|||
center_pos += self.crop.topLeft() |
|||
center_pos = transform_flip.map(center_pos) |
|||
|
|||
text_offset = QPoint(data_metrics.width(), data_metrics.height()) |
|||
text_offset = text_offset / 2 |
|||
text_offset.setX(-text_offset.x()) |
|||
center_pos += text_offset |
|||
|
|||
padding = self.BG_RECT_PADDING |
|||
bg_rect_pos = center_pos - QPoint(padding, data_metrics.height() + padding) |
|||
bg_rect_size = data_metrics + (QSize(padding, padding) * 2) |
|||
bg_rect = QRect(bg_rect_pos, bg_rect_size) |
|||
bg_rect_path = QPainterPath() |
|||
radius = self.BG_RECT_CORNER_RADIUS |
|||
bg_rect_path.addRoundedRect(QRectF(bg_rect), radius, radius, Qt.AbsoluteSize) |
|||
painter.setPen(self.bg_rect_pen) |
|||
painter.fillPath(bg_rect_path, self.bg_rect_fill) |
|||
painter.drawPath(bg_rect_path) |
|||
|
|||
painter.setPen(self.text_pen) |
|||
painter.drawText(center_pos, res.data) |
@ -0,0 +1,91 @@ |
|||
#!/usr/bin/env python3 |
|||
# |
|||
# Electron Cash - lightweight Bitcoin client |
|||
# Copyright (C) 2019 Axel Gembe <derago@gmail.com> |
|||
# |
|||
# Permission is hereby granted, free of charge, to any person |
|||
# obtaining a copy of this software and associated documentation files |
|||
# (the "Software"), to deal in the Software without restriction, |
|||
# including without limitation the rights to use, copy, modify, merge, |
|||
# publish, distribute, sublicense, and/or sell copies of the Software, |
|||
# and to permit persons to whom the Software is furnished to do so, |
|||
# subject to the following conditions: |
|||
# |
|||
# The above copyright notice and this permission notice shall be |
|||
# included in all copies or substantial portions of the Software. |
|||
# |
|||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
|||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
|||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND |
|||
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS |
|||
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN |
|||
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
|||
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|||
# SOFTWARE. |
|||
|
|||
from typing import List |
|||
|
|||
from PyQt5.QtMultimedia import (QVideoFrame, QAbstractVideoBuffer, QAbstractVideoSurface, |
|||
QVideoSurfaceFormat) |
|||
from PyQt5.QtGui import QImage |
|||
from PyQt5.QtCore import QObject, pyqtSignal |
|||
|
|||
from electrum.i18n import _ |
|||
from electrum.logging import get_logger |
|||
|
|||
|
|||
_logger = get_logger(__name__) |
|||
|
|||
|
|||
class QrReaderVideoSurface(QAbstractVideoSurface): |
|||
""" |
|||
Receives QVideoFrames from QCamera, converts them into a QImage, flips the X and Y axis if |
|||
necessary and sends them to listeners via the frame_available event. |
|||
""" |
|||
|
|||
def __init__(self, parent: QObject = None): |
|||
super().__init__(parent) |
|||
|
|||
def present(self, frame: QVideoFrame) -> bool: |
|||
if not frame.isValid(): |
|||
return False |
|||
|
|||
image_format = QVideoFrame.imageFormatFromPixelFormat(frame.pixelFormat()) |
|||
if image_format == QVideoFrame.Format_Invalid: |
|||
_logger.info(_('QR code scanner for video frame with invalid pixel format')) |
|||
return False |
|||
|
|||
if not frame.map(QAbstractVideoBuffer.ReadOnly): |
|||
_logger.info(_('QR code scanner failed to map video frame')) |
|||
return False |
|||
|
|||
try: |
|||
img = QImage(frame.bits(), frame.width(), frame.height(), image_format) |
|||
|
|||
# Check whether we need to flip the image on any axis |
|||
surface_format = self.surfaceFormat() |
|||
flip_x = surface_format.isMirrored() |
|||
flip_y = surface_format.scanLineDirection() == QVideoSurfaceFormat.BottomToTop |
|||
|
|||
# Mirror the image if needed |
|||
if flip_x or flip_y: |
|||
img = img.mirrored(flip_x, flip_y) |
|||
|
|||
# Create a copy of the image so the original frame data can be freed |
|||
img = img.copy() |
|||
finally: |
|||
frame.unmap() |
|||
|
|||
self.frame_available.emit(img) |
|||
|
|||
return True |
|||
|
|||
def supportedPixelFormats(self, handle_type: QAbstractVideoBuffer.HandleType) -> List[QVideoFrame.PixelFormat]: |
|||
if handle_type == QAbstractVideoBuffer.NoHandle: |
|||
# We support all pixel formats that can be understood by QImage directly |
|||
return [QVideoFrame.Format_ARGB32, QVideoFrame.Format_ARGB32_Premultiplied, |
|||
QVideoFrame.Format_RGB32, QVideoFrame.Format_RGB24, QVideoFrame.Format_RGB565, |
|||
QVideoFrame.Format_RGB555, QVideoFrame.Format_ARGB8565_Premultiplied] |
|||
return [] |
|||
|
|||
frame_available = pyqtSignal(QImage) |
@ -0,0 +1,52 @@ |
|||
#!/usr/bin/env python3 |
|||
# |
|||
# Electron Cash - lightweight Bitcoin client |
|||
# Copyright (C) 2019 Axel Gembe <derago@gmail.com> |
|||
# |
|||
# Permission is hereby granted, free of charge, to any person |
|||
# obtaining a copy of this software and associated documentation files |
|||
# (the "Software"), to deal in the Software without restriction, |
|||
# including without limitation the rights to use, copy, modify, merge, |
|||
# publish, distribute, sublicense, and/or sell copies of the Software, |
|||
# and to permit persons to whom the Software is furnished to do so, |
|||
# subject to the following conditions: |
|||
# |
|||
# The above copyright notice and this permission notice shall be |
|||
# included in all copies or substantial portions of the Software. |
|||
# |
|||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
|||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
|||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND |
|||
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS |
|||
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN |
|||
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
|||
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|||
# SOFTWARE. |
|||
|
|||
from PyQt5.QtWidgets import QWidget |
|||
from PyQt5.QtGui import QPixmap, QPainter, QPaintEvent |
|||
|
|||
|
|||
class QrReaderVideoWidget(QWidget): |
|||
""" |
|||
Simple widget for drawing a pixmap |
|||
""" |
|||
|
|||
USE_BILINEAR_FILTER = True |
|||
|
|||
def __init__(self, parent: QWidget = None): |
|||
super().__init__(parent) |
|||
|
|||
self.pixmap = None |
|||
|
|||
def paintEvent(self, _event: QPaintEvent): |
|||
if not self.pixmap: |
|||
return |
|||
painter = QPainter(self) |
|||
if self.USE_BILINEAR_FILTER: |
|||
painter.setRenderHint(QPainter.SmoothPixmapTransform) |
|||
painter.drawPixmap(self.rect(), self.pixmap, self.pixmap.rect()) |
|||
|
|||
def setPixmap(self, pixmap: QPixmap): |
|||
self.pixmap = pixmap |
|||
self.update() |
@ -0,0 +1,63 @@ |
|||
#!/usr/bin/env python3 |
|||
# |
|||
# Electron Cash - lightweight Bitcoin client |
|||
# Copyright (C) 2019 Axel Gembe <derago@gmail.com> |
|||
# |
|||
# Permission is hereby granted, free of charge, to any person |
|||
# obtaining a copy of this software and associated documentation files |
|||
# (the "Software"), to deal in the Software without restriction, |
|||
# including without limitation the rights to use, copy, modify, merge, |
|||
# publish, distribute, sublicense, and/or sell copies of the Software, |
|||
# and to permit persons to whom the Software is furnished to do so, |
|||
# subject to the following conditions: |
|||
# |
|||
# The above copyright notice and this permission notice shall be |
|||
# included in all copies or substantial portions of the Software. |
|||
# |
|||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
|||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
|||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND |
|||
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS |
|||
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN |
|||
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
|||
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|||
# SOFTWARE. |
|||
# |
|||
# A module, that, given an image (buffer), finds and decodes a QR code in it. |
|||
|
|||
from typing import Optional |
|||
|
|||
from ..logging import get_logger |
|||
|
|||
from .abstract_base import AbstractQrCodeReader, QrCodeResult |
|||
|
|||
|
|||
_logger = get_logger(__name__) |
|||
|
|||
|
|||
class MissingLib(RuntimeError): |
|||
''' Raised by underlying implementation if missing libs ''' |
|||
pass |
|||
|
|||
|
|||
def get_qr_reader() -> Optional[AbstractQrCodeReader]: |
|||
""" |
|||
Get the Qr code reader for the current platform |
|||
""" |
|||
try: |
|||
from .zbar import ZbarQrCodeReader |
|||
return ZbarQrCodeReader() |
|||
""" |
|||
# DEBUG CODE BELOW |
|||
# If you want to test this code on a platform that doesn't yet work or have |
|||
# zbar, use the below... |
|||
class Fake(AbstractQrCodeReader): |
|||
def read_qr_code(self, buffer, buffer_size, dummy, width, height, frame_id = -1): |
|||
''' fake noop to test ''' |
|||
return [] |
|||
return Fake() |
|||
""" |
|||
except MissingLib as e: |
|||
_logger.exception("") |
|||
|
|||
return None |
@ -0,0 +1,77 @@ |
|||
#!/usr/bin/env python3 |
|||
# |
|||
# Electron Cash - lightweight Bitcoin client |
|||
# Copyright (C) 2019 Axel Gembe <derago@gmail.com> |
|||
# |
|||
# Permission is hereby granted, free of charge, to any person |
|||
# obtaining a copy of this software and associated documentation files |
|||
# (the "Software"), to deal in the Software without restriction, |
|||
# including without limitation the rights to use, copy, modify, merge, |
|||
# publish, distribute, sublicense, and/or sell copies of the Software, |
|||
# and to permit persons to whom the Software is furnished to do so, |
|||
# subject to the following conditions: |
|||
# |
|||
# The above copyright notice and this permission notice shall be |
|||
# included in all copies or substantial portions of the Software. |
|||
# |
|||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
|||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
|||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND |
|||
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS |
|||
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN |
|||
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
|||
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|||
# SOFTWARE. |
|||
|
|||
import ctypes |
|||
from typing import List, Tuple |
|||
from abc import ABC, abstractmethod |
|||
|
|||
QrCodePoint = Tuple[int, int] |
|||
QrCodePointList = List[QrCodePoint] |
|||
|
|||
|
|||
class QrCodeResult(): |
|||
""" |
|||
A detected QR code. |
|||
""" |
|||
def __init__(self, data: str, center: QrCodePoint, points: QrCodePointList): |
|||
self.data: str = data |
|||
self.center: QrCodePoint = center |
|||
self.points: QrCodePointList = points |
|||
|
|||
def __str__(self) -> str: |
|||
return 'data: {} center: {} points: {}'.format(self.data, self.center, self.points) |
|||
|
|||
def __hash__(self): |
|||
return hash(self.data) |
|||
|
|||
def __eq__(self, other): |
|||
return self.data == other.data |
|||
|
|||
def __ne__(self, other): |
|||
return not self == other |
|||
|
|||
class AbstractQrCodeReader(ABC): |
|||
""" |
|||
Abstract base class for QR code readers. |
|||
""" |
|||
|
|||
def interval(self) -> float: |
|||
''' Reimplement to specify a time (in seconds) that the implementation |
|||
recommends elapse between subsequent calls to read_qr_code. |
|||
Implementations that have very expensive and/or slow detection code |
|||
may want to rate-limit read_qr_code calls by overriding this function. |
|||
e.g.: to make detection happen every 200ms, you would return 0.2 here. |
|||
Defaults to 0.0''' |
|||
return 0.0 |
|||
|
|||
@abstractmethod |
|||
def read_qr_code(self, buffer: ctypes.c_void_p, |
|||
buffer_size: int, # overall image size in bytes |
|||
rowlen_bytes: int, # the scan line length in bytes. (many libs, such as OSX, expect this value to properly grok image data) |
|||
width: int, height: int, frame_id: int = -1) -> List[QrCodeResult]: |
|||
""" |
|||
Reads a QR code from an image buffer in Y800 / GREY format. |
|||
Returns a list of detected QR codes which includes their data and positions. |
|||
""" |
@ -0,0 +1,183 @@ |
|||
#!/usr/bin/env python3 |
|||
# |
|||
# Electron Cash - lightweight Bitcoin client |
|||
# Copyright (C) 2019 Axel Gembe <derago@gmail.com> |
|||
# |
|||
# Permission is hereby granted, free of charge, to any person |
|||
# obtaining a copy of this software and associated documentation files |
|||
# (the "Software"), to deal in the Software without restriction, |
|||
# including without limitation the rights to use, copy, modify, merge, |
|||
# publish, distribute, sublicense, and/or sell copies of the Software, |
|||
# and to permit persons to whom the Software is furnished to do so, |
|||
# subject to the following conditions: |
|||
# |
|||
# The above copyright notice and this permission notice shall be |
|||
# included in all copies or substantial portions of the Software. |
|||
# |
|||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
|||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
|||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND |
|||
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS |
|||
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN |
|||
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
|||
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|||
# SOFTWARE. |
|||
|
|||
import sys |
|||
import ctypes |
|||
import os |
|||
from typing import List |
|||
from enum import IntEnum |
|||
|
|||
from ..logging import get_logger |
|||
|
|||
from . import MissingLib |
|||
from .abstract_base import AbstractQrCodeReader, QrCodeResult |
|||
|
|||
|
|||
_logger = get_logger(__name__) |
|||
|
|||
|
|||
if sys.platform == 'darwin': |
|||
LIBNAME = 'libzbar.0.dylib' |
|||
elif sys.platform in ('windows', 'win32'): |
|||
LIBNAME = 'libzbar-0.dll' |
|||
else: |
|||
LIBNAME = 'libzbar.so.0' |
|||
|
|||
try: |
|||
try: |
|||
LIBZBAR = ctypes.cdll.LoadLibrary(os.path.join(os.path.dirname(__file__), '..', LIBNAME)) |
|||
except OSError as e: |
|||
LIBZBAR = ctypes.cdll.LoadLibrary(LIBNAME) |
|||
|
|||
LIBZBAR.zbar_image_create.restype = ctypes.c_void_p |
|||
LIBZBAR.zbar_image_scanner_create.restype = ctypes.c_void_p |
|||
LIBZBAR.zbar_image_scanner_get_results.restype = ctypes.c_void_p |
|||
LIBZBAR.zbar_symbol_set_first_symbol.restype = ctypes.c_void_p |
|||
LIBZBAR.zbar_symbol_get_data.restype = ctypes.POINTER(ctypes.c_char_p) |
|||
LIBZBAR.zbar_image_scanner_set_config.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_int] |
|||
LIBZBAR.zbar_image_set_sequence.argtypes = [ctypes.c_void_p, ctypes.c_int] |
|||
LIBZBAR.zbar_image_set_size.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int] |
|||
LIBZBAR.zbar_image_set_format.argtypes = [ctypes.c_void_p, ctypes.c_int] |
|||
LIBZBAR.zbar_image_set_data.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p] |
|||
LIBZBAR.zbar_image_scanner_recycle_image.argtypes = [ctypes.c_void_p, ctypes.c_void_p] |
|||
LIBZBAR.zbar_scan_image.argtypes = [ctypes.c_void_p, ctypes.c_void_p] |
|||
LIBZBAR.zbar_image_scanner_get_results.argtypes = [ctypes.c_void_p] |
|||
LIBZBAR.zbar_symbol_set_first_symbol.argtypes = [ctypes.c_void_p] |
|||
LIBZBAR.zbar_symbol_get_data_length.argtypes = [ctypes.c_void_p] |
|||
LIBZBAR.zbar_symbol_get_data.argtypes = [ctypes.c_void_p] |
|||
LIBZBAR.zbar_symbol_get_loc_size.argtypes = [ctypes.c_void_p] |
|||
LIBZBAR.zbar_symbol_get_loc_x.argtypes = [ctypes.c_void_p, ctypes.c_int] |
|||
LIBZBAR.zbar_symbol_get_loc_y.argtypes = [ctypes.c_void_p, ctypes.c_int] |
|||
LIBZBAR.zbar_symbol_next.argtypes = [ctypes.c_void_p] |
|||
LIBZBAR.zbar_image_scanner_destroy.argtypes = [ctypes.c_void_p] |
|||
LIBZBAR.zbar_image_destroy.argtypes = [ctypes.c_void_p] |
|||
|
|||
#if is_verbose: |
|||
#LIBZBAR.zbar_set_verbosity(100) |
|||
except OSError: |
|||
_logger.exception("Failed to load zbar") |
|||
LIBZBAR = None |
|||
|
|||
FOURCC_Y800 = 0x30303859 |
|||
|
|||
@ctypes.CFUNCTYPE(None, ctypes.c_void_p) |
|||
def zbar_cleanup(image): |
|||
""" |
|||
Do nothing, this is just so zbar doesn't try to manage our QImage buffers |
|||
""" |
|||
|
|||
class ZbarSymbolType(IntEnum): |
|||
""" |
|||
Supported symbol types, see zbar_symbol_type_e in zbar.h |
|||
""" |
|||
EAN2 = 2 |
|||
EAN5 = 5 |
|||
EAN8 = 8 |
|||
UPCE = 9 |
|||
ISBN10 = 10 |
|||
UPCA = 12 |
|||
EAN13 = 13 |
|||
ISBN13 = 14 |
|||
COMPOSITE = 15 |
|||
I25 = 25 |
|||
DATABAR = 34 |
|||
DATABAR_EXP = 35 |
|||
CODABAR = 38 |
|||
CODE39 = 39 |
|||
PDF417 = 57 |
|||
QRCODE = 64 |
|||
SQCODE = 80 |
|||
CODE93 = 93 |
|||
CODE128 = 128 |
|||
|
|||
class ZbarConfig(IntEnum): |
|||
""" |
|||
Supported configuration options, see zbar_config_e in zbar.h |
|||
""" |
|||
ENABLE = 0 |
|||
|
|||
class ZbarQrCodeReader(AbstractQrCodeReader): |
|||
""" |
|||
Reader that uses libzbar |
|||
""" |
|||
|
|||
def __init__(self): |
|||
if not LIBZBAR: |
|||
raise MissingLib('Zbar library not found') |
|||
# Set up zbar |
|||
self.zbar_scanner = LIBZBAR.zbar_image_scanner_create() |
|||
self.zbar_image = LIBZBAR.zbar_image_create() |
|||
|
|||
# Disable all symbols |
|||
for sym_type in ZbarSymbolType: |
|||
LIBZBAR.zbar_image_scanner_set_config(self.zbar_scanner, sym_type, ZbarConfig.ENABLE, 0) |
|||
|
|||
# Enable only QR codes |
|||
LIBZBAR.zbar_image_scanner_set_config(self.zbar_scanner, ZbarSymbolType.QRCODE, |
|||
ZbarConfig.ENABLE, 1) |
|||
|
|||
def __del__(self): |
|||
if LIBZBAR: |
|||
LIBZBAR.zbar_image_scanner_destroy(self.zbar_scanner) |
|||
LIBZBAR.zbar_image_destroy(self.zbar_image) |
|||
|
|||
def read_qr_code(self, buffer: ctypes.c_void_p, buffer_size: int, |
|||
rowlen_bytes: int, # this param is ignored in this implementation |
|||
width: int, height: int, frame_id: int = -1) -> List[QrCodeResult]: |
|||
LIBZBAR.zbar_image_set_sequence(self.zbar_image, frame_id) |
|||
LIBZBAR.zbar_image_set_size(self.zbar_image, width, height) |
|||
LIBZBAR.zbar_image_set_format(self.zbar_image, FOURCC_Y800) |
|||
LIBZBAR.zbar_image_set_data(self.zbar_image, buffer, buffer_size, zbar_cleanup) |
|||
LIBZBAR.zbar_image_scanner_recycle_image(self.zbar_scanner, self.zbar_image) |
|||
LIBZBAR.zbar_scan_image(self.zbar_scanner, self.zbar_image) |
|||
|
|||
result_set = LIBZBAR.zbar_image_scanner_get_results(self.zbar_scanner) |
|||
|
|||
res = [] |
|||
symbol = LIBZBAR.zbar_symbol_set_first_symbol(result_set) |
|||
while symbol: |
|||
symbol_data_len = LIBZBAR.zbar_symbol_get_data_length(symbol) |
|||
symbol_data_ptr = LIBZBAR.zbar_symbol_get_data(symbol) |
|||
symbol_data_bytes = ctypes.string_at(symbol_data_ptr, symbol_data_len) |
|||
symbol_data = symbol_data_bytes.decode('utf-8') |
|||
|
|||
symbol_loc = [] |
|||
symbol_loc_len = LIBZBAR.zbar_symbol_get_loc_size(symbol) |
|||
for i in range(0, symbol_loc_len): |
|||
# Normalize the coordinates into 0..1 range by dividing by width / height |
|||
symbol_loc_x = LIBZBAR.zbar_symbol_get_loc_x(symbol, i) |
|||
symbol_loc_y = LIBZBAR.zbar_symbol_get_loc_y(symbol, i) |
|||
symbol_loc.append((symbol_loc_x, symbol_loc_y)) |
|||
|
|||
# Find the center by getting the average values of the corners x and y coordinates |
|||
symbol_loc_sum_x = sum([l[0] for l in symbol_loc]) |
|||
symbol_loc_sum_y = sum([l[1] for l in symbol_loc]) |
|||
symbol_loc_center = (int(symbol_loc_sum_x / symbol_loc_len), int(symbol_loc_sum_y / symbol_loc_len)) |
|||
|
|||
res.append(QrCodeResult(symbol_data, symbol_loc_center, symbol_loc)) |
|||
|
|||
symbol = LIBZBAR.zbar_symbol_next(symbol) |
|||
|
|||
return res |
Loading…
Reference in new issue