From 9913f7c1e7393bf69ab52fda17fae2ce4d6a744a Mon Sep 17 00:00:00 2001 From: KIU Shueng Chuan Date: Sat, 31 Jul 2021 15:49:56 +0800 Subject: [PATCH 1/5] import shiboken{2,6} as shiboken --- examples/PlotSpeedTest.py | 8 +++----- pyqtgraph/Qt/__init__.py | 14 +++++++++----- pyqtgraph/graphicsItems/ScatterPlotItem.py | 12 +++--------- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/examples/PlotSpeedTest.py b/examples/PlotSpeedTest.py index 84039a5f..2f233f31 100644 --- a/examples/PlotSpeedTest.py +++ b/examples/PlotSpeedTest.py @@ -17,12 +17,10 @@ import pyqtgraph.functions as fn import itertools import argparse -if QT_LIB == 'PySide2': - wrapinstance = pg.Qt.shiboken2.wrapInstance -elif QT_LIB == 'PySide6': - wrapinstance = pg.Qt.shiboken6.wrapInstance -elif QT_LIB in ['PyQt5', 'PyQt6']: +if QT_LIB.startswith('PyQt'): wrapinstance = pg.Qt.sip.wrapinstance +else: + wrapinstance = pg.Qt.shiboken.wrapInstance # defaults here result in the same configuration as the original PlotSpeedTest parser = argparse.ArgumentParser() diff --git a/pyqtgraph/Qt/__init__.py b/pyqtgraph/Qt/__init__.py index 48e4190a..cf286d67 100644 --- a/pyqtgraph/Qt/__init__.py +++ b/pyqtgraph/Qt/__init__.py @@ -152,7 +152,12 @@ if QT_LIB == PYQT5: _copy_attrs(PyQt5.QtGui, QtGui) _copy_attrs(PyQt5.QtWidgets, QtWidgets) - from PyQt5 import sip, uic + try: + from PyQt5 import sip + except ImportError: + # some Linux distros package it this way (e.g. Ubuntu) + import sip + from PyQt5 import uic try: from PyQt5 import QtSvg @@ -203,8 +208,7 @@ elif QT_LIB == PYSIDE2: except ImportError as err: QtTest = FailedImport(err) - import shiboken2 - isQObjectAlive = shiboken2.isValid + import shiboken2 as shiboken import PySide2 VERSION_INFO = 'PySide2 ' + PySide2.__version__ + ' Qt ' + QtCore.__version__ elif QT_LIB == PYSIDE6: @@ -226,8 +230,7 @@ elif QT_LIB == PYSIDE6: except ImportError as err: QtTest = FailedImport(err) - import shiboken6 - isQObjectAlive = shiboken6.isValid + import shiboken6 as shiboken import PySide6 VERSION_INFO = 'PySide6 ' + PySide6.__version__ + ' Qt ' + QtCore.__version__ @@ -313,6 +316,7 @@ if QT_LIB in [PYQT6, PYSIDE6]: if QT_LIB in [PYSIDE2, PYSIDE6]: QtVersion = QtCore.__version__ loadUiType = _loadUiType + isQObjectAlive = shiboken.isValid # PySide does not implement qWait if not isinstance(QtTest, FailedImport): diff --git a/pyqtgraph/graphicsItems/ScatterPlotItem.py b/pyqtgraph/graphicsItems/ScatterPlotItem.py index 42c689d3..c2c8498e 100644 --- a/pyqtgraph/graphicsItems/ScatterPlotItem.py +++ b/pyqtgraph/graphicsItems/ScatterPlotItem.py @@ -4,6 +4,7 @@ import itertools import math import numpy as np import weakref +from .. import Qt from ..Qt import QtGui, QtCore, QT_LIB from ..Point import Point from .. import functions as fn @@ -13,13 +14,6 @@ from .. import getConfigOption from collections import OrderedDict from .. import debug -if QT_LIB == 'PySide2': - from shiboken2 import wrapInstance -elif QT_LIB == 'PySide6': - from shiboken6 import wrapInstance -elif QT_LIB in ['PyQt5', 'PyQt6']: - from ..Qt import sip - __all__ = ['ScatterPlotItem', 'SpotItem'] @@ -167,11 +161,11 @@ class PixmapFragments: # instances into a contiguous array, in order to call the underlying C++ native API. self.arr = np.empty((size, 10), dtype=np.float64) if QT_LIB.startswith('PyQt'): - self.ptrs = list(map(sip.wrapinstance, + self.ptrs = list(map(Qt.sip.wrapinstance, itertools.count(self.arr.ctypes.data, self.arr.strides[0]), itertools.repeat(QtGui.QPainter.PixmapFragment, self.arr.shape[0]))) else: - self.ptrs = wrapInstance(self.arr.ctypes.data, QtGui.QPainter.PixmapFragment) + self.ptrs = Qt.shiboken.wrapInstance(self.arr.ctypes.data, QtGui.QPainter.PixmapFragment) def array(self, size): if size > self.arr.shape[0]: From 75654b8495ca275164a0e427dd7d26fc3f473cac Mon Sep 17 00:00:00 2001 From: KIU Shueng Chuan Date: Sat, 31 Jul 2021 17:16:12 +0800 Subject: [PATCH 2/5] handle zero-sized QPolygonF depending on the implementation, a zero-sized QPolygonF may not have any underlying buffer allocated and may return a null pointer when queried for its "data()" this null pointer is returned to Python as a "None" which breaks code not expecting it. --- pyqtgraph/functions.py | 24 ++++++++++++------------ tests/test_functions.py | 7 +++++++ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/pyqtgraph/functions.py b/pyqtgraph/functions.py index b6aae84c..0ef36560 100644 --- a/pyqtgraph/functions.py +++ b/pyqtgraph/functions.py @@ -2060,25 +2060,25 @@ def arrayToQPath(x, y, connect='all', finiteCheck=True): def ndarray_from_qpolygonf(polyline): nbytes = 2 * len(polyline) * 8 - if QT_LIB == "PySide2": - buffer = Qt.shiboken2.VoidPtr(polyline.data(), nbytes, True) - elif QT_LIB == "PySide6": - buffer = Qt.shiboken6.VoidPtr(polyline.data(), nbytes, True) - else: + if QT_LIB.startswith('PyQt'): buffer = polyline.data() + if buffer is None: + buffer = Qt.sip.voidptr(0) buffer.setsize(nbytes) + else: + ptr = polyline.data() + if ptr is None: + ptr = 0 + buffer = Qt.shiboken.VoidPtr(ptr, nbytes, True) memory = np.frombuffer(buffer, np.double).reshape((-1, 2)) return memory def create_qpolygonf(size): - if QtVersion.startswith("5"): - polyline = QtGui.QPolygonF(size) + polyline = QtGui.QPolygonF() + if QT_LIB.startswith('PyQt'): + polyline.fill(QtCore.QPointF(), size) else: - polyline = QtGui.QPolygonF() - if QT_LIB == "PySide6": - polyline.resize(size) - else: - polyline.fill(QtCore.QPointF(), size) + polyline.resize(size) return polyline def arrayToQPolygonF(x, y): diff --git a/tests/test_functions.py b/tests/test_functions.py index 9c0dcb27..e38d8638 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -333,3 +333,10 @@ def test_arrayToQPath(xs, ys, connect, expected): continue element = path.elementAt(i) assert eq(expected[i], (element.type, element.x, element.y)) + + +def test_ndarray_from_qpolygonf(): + # test that we get an empty ndarray from an empty QPolygonF + poly = pg.functions.create_qpolygonf(0) + arr = pg.functions.ndarray_from_qpolygonf(poly) + assert isinstance(arr, np.ndarray) From 9355ecf469e8538e7fbc44069314c827e0944bc8 Mon Sep 17 00:00:00 2001 From: KIU Shueng Chuan Date: Sat, 31 Jul 2021 20:58:51 +0800 Subject: [PATCH 3/5] support padded QImage --- pyqtgraph/functions.py | 28 +++++++++++++++++----------- tests/test_functions.py | 22 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/pyqtgraph/functions.py b/pyqtgraph/functions.py index 0ef36560..25d1f773 100644 --- a/pyqtgraph/functions.py +++ b/pyqtgraph/functions.py @@ -1685,21 +1685,25 @@ def makeQImage(imgData, alpha=None, copy=True, transpose=True): def qimage_to_ndarray(qimg): img_ptr = qimg.bits() - if hasattr(img_ptr, 'setsize'): # PyQt sip.voidptr + if img_ptr is None: + raise ValueError("Null QImage not supported") + + h, w = qimg.height(), qimg.width() + bpl = qimg.bytesPerLine() + depth = qimg.depth() + logical_bpl = w * depth // 8 + + if QT_LIB.startswith('PyQt'): # sizeInBytes() was introduced in Qt 5.10 # however PyQt5 5.12 will fail with: # "TypeError: QImage.sizeInBytes() is a private method" # note that sizeInBytes() works fine with: # PyQt5 5.15, PySide2 5.12, PySide2 5.15 - try: - # 64-bits size - nbytes = qimg.sizeInBytes() - except (TypeError, AttributeError): - # 32-bits size - nbytes = qimg.byteCount() - img_ptr.setsize(nbytes) + img_ptr.setsize(h * bpl) + + memory = np.frombuffer(img_ptr, dtype=np.ubyte).reshape((h, bpl)) + memory = memory[:, :logical_bpl] - depth = qimg.depth() if depth in (8, 24, 32): dtype = np.uint8 nchan = depth // 8 @@ -1708,10 +1712,12 @@ def qimage_to_ndarray(qimg): nchan = depth // 16 else: raise ValueError("Unsupported Image Type") - shape = qimg.height(), qimg.width() + + shape = h, w if nchan != 1: shape = shape + (nchan,) - return np.frombuffer(img_ptr, dtype=dtype).reshape(shape) + arr = memory.view(dtype).reshape(shape) + return arr def imageToArray(img, copy=False, transpose=True): diff --git a/tests/test_functions.py b/tests/test_functions.py index e38d8638..169453f8 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -9,6 +9,7 @@ import pytest from numpy.testing import assert_array_almost_equal import pyqtgraph as pg +from pyqtgraph.Qt import QtGui np.random.seed(12345) @@ -340,3 +341,24 @@ def test_ndarray_from_qpolygonf(): poly = pg.functions.create_qpolygonf(0) arr = pg.functions.ndarray_from_qpolygonf(poly) assert isinstance(arr, np.ndarray) + + +def test_qimage_to_ndarray(): + # for QImages created w/o specifying bytesPerLine, Qt will pad + # each line to a multiple of 4-bytes. + # test that we can handle such QImages. + h = 10 + + fmt = QtGui.QImage.Format.Format_RGB888 + for w in [5, 6, 7, 8]: + qimg = QtGui.QImage(w, h, fmt) + qimg.fill(0) + arr = pg.functions.qimage_to_ndarray(qimg) + assert arr.shape == (h, w, 3) + + fmt = QtGui.QImage.Format.Format_Grayscale8 + for w in [5, 6, 7, 8]: + qimg = QtGui.QImage(w, h, fmt) + qimg.fill(0) + arr = pg.functions.qimage_to_ndarray(qimg) + assert arr.shape == (h, w) From f64290be9ecdce29a5afee39a2d2f77585faa718 Mon Sep 17 00:00:00 2001 From: KIU Shueng Chuan Date: Sat, 31 Jul 2021 21:00:54 +0800 Subject: [PATCH 4/5] test that binding provides a write-thru QImage --- tests/test_qimage_writethru.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/test_qimage_writethru.py diff --git a/tests/test_qimage_writethru.py b/tests/test_qimage_writethru.py new file mode 100644 index 00000000..91faa3c8 --- /dev/null +++ b/tests/test_qimage_writethru.py @@ -0,0 +1,28 @@ +import numpy as np +import pyqtgraph as pg + +def test_qimage_writethrough(): + w, h = 256, 256 + backstore = np.ones((h, w), dtype=np.uint8) + ptr0 = backstore.ctypes.data + fmt = pg.Qt.QtGui.QImage.Format.Format_Grayscale8 + qimg = pg.functions.ndarray_to_qimage(backstore, fmt) + + def get_pointer(obj): + if hasattr(obj, 'setsize'): + return int(obj) + else: + return np.frombuffer(obj, dtype=np.uint8).ctypes.data + + # test that QImage is using the provided buffer (i.e. zero-copy) + ptr1 = get_pointer(qimg.constBits()) + assert ptr0 == ptr1 + + # test that QImage is not const (i.e. no COW) + # if QImage is const, then bits() returns a copy + ptr2 = get_pointer(qimg.bits()) + assert ptr1 == ptr2 + + # test that data gets written through to provided buffer + qimg.fill(0) + assert np.all(backstore == 0) From 04673ac98b62f979a8dfc41cc0678ee6ac19e882 Mon Sep 17 00:00:00 2001 From: KIU Shueng Chuan Date: Sat, 31 Jul 2021 21:10:34 +0800 Subject: [PATCH 5/5] rename: qimage_to_ndarray -> ndarray_from_qimage --- pyqtgraph/exporters/ImageExporter.py | 2 +- pyqtgraph/functions.py | 6 +++--- pyqtgraph/graphicsItems/ROI.py | 2 +- tests/exporters/test_image.py | 2 +- tests/image_testing.py | 4 ++-- tests/test_functions.py | 6 +++--- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pyqtgraph/exporters/ImageExporter.py b/pyqtgraph/exporters/ImageExporter.py index a6aec63f..4991bb79 100644 --- a/pyqtgraph/exporters/ImageExporter.py +++ b/pyqtgraph/exporters/ImageExporter.py @@ -98,7 +98,7 @@ class ImageExporter(Exporter): painter.end() if self.params['invertValue']: - bg = fn.qimage_to_ndarray(self.png) + bg = fn.ndarray_from_qimage(self.png) if sys.byteorder == 'little': cv = slice(0, 3) else: diff --git a/pyqtgraph/functions.py b/pyqtgraph/functions.py index 25d1f773..9b123b08 100644 --- a/pyqtgraph/functions.py +++ b/pyqtgraph/functions.py @@ -42,7 +42,7 @@ __all__ = [ 'makeRGBA', 'makeARGB', # 'try_fastpath_argb', 'ndarray_to_qimage', 'makeQImage', - # 'qimage_to_ndarray', + # 'ndarray_from_qimage', 'imageToArray', 'colorToAlpha', 'gaussianFilter', 'downsample', 'arrayToQPath', # 'ndarray_from_qpolygonf', 'create_qpolygonf', 'arrayToQPolygonF', @@ -1682,7 +1682,7 @@ def makeQImage(imgData, alpha=None, copy=True, transpose=True): return ndarray_to_qimage(imgData, imgFormat) -def qimage_to_ndarray(qimg): +def ndarray_from_qimage(qimg): img_ptr = qimg.bits() if img_ptr is None: @@ -1727,7 +1727,7 @@ def imageToArray(img, copy=False, transpose=True): the QImage is collected before the array, there may be trouble). The array will have shape (width, height, (b,g,r,a)). """ - arr = qimage_to_ndarray(img) + arr = ndarray_from_qimage(img) fmt = img.format() if fmt == img.Format.Format_RGB32: diff --git a/pyqtgraph/graphicsItems/ROI.py b/pyqtgraph/graphicsItems/ROI.py index a4526804..86528d5f 100644 --- a/pyqtgraph/graphicsItems/ROI.py +++ b/pyqtgraph/graphicsItems/ROI.py @@ -1299,7 +1299,7 @@ class ROI(GraphicsObject): p.drawPath(shape) p.end() cidx = 0 if sys.byteorder == 'little' else 3 - mask = fn.qimage_to_ndarray(im)[...,cidx].T + mask = fn.ndarray_from_qimage(im)[...,cidx].T return mask.astype(float) / 255 def getGlobalTransform(self, relativeTo=None): diff --git a/tests/exporters/test_image.py b/tests/exporters/test_image.py index a3015e67..374fa4e5 100644 --- a/tests/exporters/test_image.py +++ b/tests/exporters/test_image.py @@ -23,6 +23,6 @@ def test_ImageExporter_toBytes(): exp = ImageExporter(p.getPlotItem()) qimg = exp.export(toBytes=True) qimg = qimg.convertToFormat(QtGui.QImage.Format.Format_RGBA8888) - data = fn.qimage_to_ndarray(qimg) + data = fn.ndarray_from_qimage(qimg) black = (0, 0, 0, 255) assert np.all(data == black), "Exported image should be entirely black." diff --git a/tests/image_testing.py b/tests/image_testing.py index f5f688eb..0887e271 100644 --- a/tests/image_testing.py +++ b/tests/image_testing.py @@ -77,7 +77,7 @@ def getImageFromWidget(widget): painter.end() qimg = qimg.convertToFormat(QtGui.QImage.Format.Format_RGBA8888) - return fn.qimage_to_ndarray(qimg).copy() + return fn.ndarray_from_qimage(qimg).copy() def assertImageApproved(image, standardFile, message=None, **kwargs): @@ -126,7 +126,7 @@ def assertImageApproved(image, standardFile, message=None, **kwargs): else: qimg = QtGui.QImage(stdFileName) qimg = qimg.convertToFormat(QtGui.QImage.Format.Format_RGBA8888) - stdImage = fn.qimage_to_ndarray(qimg).copy() + stdImage = fn.ndarray_from_qimage(qimg).copy() del qimg # If the test image does not match, then we go to audit if requested. diff --git a/tests/test_functions.py b/tests/test_functions.py index 169453f8..40a58fb3 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -343,7 +343,7 @@ def test_ndarray_from_qpolygonf(): assert isinstance(arr, np.ndarray) -def test_qimage_to_ndarray(): +def test_ndarray_from_qimage(): # for QImages created w/o specifying bytesPerLine, Qt will pad # each line to a multiple of 4-bytes. # test that we can handle such QImages. @@ -353,12 +353,12 @@ def test_qimage_to_ndarray(): for w in [5, 6, 7, 8]: qimg = QtGui.QImage(w, h, fmt) qimg.fill(0) - arr = pg.functions.qimage_to_ndarray(qimg) + arr = pg.functions.ndarray_from_qimage(qimg) assert arr.shape == (h, w, 3) fmt = QtGui.QImage.Format.Format_Grayscale8 for w in [5, 6, 7, 8]: qimg = QtGui.QImage(w, h, fmt) qimg.fill(0) - arr = pg.functions.qimage_to_ndarray(qimg) + arr = pg.functions.ndarray_from_qimage(qimg) assert arr.shape == (h, w)