From f2b4a15b2d3967627deb1c3b03aa419eaf1fe982 Mon Sep 17 00:00:00 2001 From: Martin Chase Date: Tue, 19 Jan 2021 21:26:24 -0800 Subject: [PATCH] Performance enhancement: use CUDA in ImageItem (#1466) * Add CLI args to video speed test for easier / automated benchmarking * use a buffer-qimage so we can avoid allocing so much this should improve performance under windows * playing with numba * oh, mins/maxes in the other order * maybe put the cupy in here and see what happens * pre-alloc for gpu and cpu * handle possibility of not having cupy * no numba in this branch * organize imports * name them after their use, not their expected device * cupy.take does not support clip mode, so do it explicitly * add CUDA option to the VideoSpeedTest * rename private attr xp to _xp * handle resizes at the last moment * cupy is less accepting of lists as args * or somehow range isn't allowed? what histogram is this? * construct the array with python objects * get the python value right away * put LUT into cupy if needed * docstring about cuda toolkit version * better handling and display of missing cuda lib * lint * import need * handle switching between cupy and numpy in a single ImageItem * only use xp when necessary we can now depend on numpy >= 1.17, which means __array_function__-implementing cupy can seamlessly pass into numpy functions. the remaining uses of xp are for our functions which need to allocate new data structures, an operation that has to be substrate-specific. remove empty_cupy; just check if the import succeeded, instead. * use an option to control use of cupy * convert cupy.ceil array to int for easier mathing * RawImageWidget gets to use the getCupy function now, too * raise error to calm linters; rename for clarity * Add Generated Template Files * document things better * cruft removal * warnings to communicate when cupy is expected but somehow broken * playing with settings to suss out timeout * playing with more stuff to suss out timeout * replace with empty list * skip test_ExampleApp on linux+pyside2 only Co-authored-by: Luke Campagnola Co-authored-by: Ogi Moore --- README.md | 2 + doc/source/config_options.rst | 2 + examples/VideoSpeedTest.py | 143 ++++++++--- examples/VideoTemplate.ui | 7 + examples/VideoTemplate_pyqt.py | 4 + examples/VideoTemplate_pyqt5.py | 12 +- examples/VideoTemplate_pyside.py | 4 + examples/VideoTemplate_pyside2.py | 341 +++++++++++++++++---------- examples/test_examples.py | 4 + pyqtgraph/__init__.py | 1 + pyqtgraph/functions.py | 185 +++++++-------- pyqtgraph/graphicsItems/ImageItem.py | 144 +++++++---- pyqtgraph/util/cupy_helper.py | 18 ++ pyqtgraph/widgets/RawImageWidget.py | 6 +- 14 files changed, 550 insertions(+), 323 deletions(-) create mode 100644 pyqtgraph/util/cupy_helper.py diff --git a/README.md b/README.md index 41e8407c..7a8e745a 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ Currently this means: * `pyopengl` on macOS Big Sur only works with python 3.9.1+ * `hdf5` for large hdf5 binary format support * `colorcet` for supplemental colormaps + * [`cupy`](https://docs.cupy.dev/en/stable/install.html) for CUDA-enhanced image processing + * On Windows, CUDA toolkit must be >= 11.1 Qt Bindings Test Matrix ----------------------- diff --git a/doc/source/config_options.rst b/doc/source/config_options.rst index 61b64499..797404c6 100644 --- a/doc/source/config_options.rst +++ b/doc/source/config_options.rst @@ -30,6 +30,8 @@ useWeave bool False Use weave to speed up weaveDebug bool False Print full error message if weave compile fails. useOpenGL bool False Enable OpenGL in GraphicsView. This can have unpredictable effects on stability and performance. +useCupy bool False Use cupy to perform calculations on the GPU. Only currently applies to + ImageItem and its associated functions. enableExperimental bool False Enable experimental features (the curious can search for this key in the code). crashWarning bool False If True, print warnings about situations that may result in a crash. ================== =================== ================== ================================================================================ diff --git a/examples/VideoSpeedTest.py b/examples/VideoSpeedTest.py index 25892a0b..bbc7b6a1 100644 --- a/examples/VideoSpeedTest.py +++ b/examples/VideoSpeedTest.py @@ -6,18 +6,36 @@ it is being scaled and/or converted by lookup table, and whether OpenGL is used by the view widget """ +import argparse +import sys -import initExample ## Add path to library (just for examples; you do not need this) - - -from pyqtgraph.Qt import QtGui, QtCore, QT_LIB import numpy as np + import pyqtgraph as pg import pyqtgraph.ptime as ptime +from pyqtgraph.Qt import QtGui, QtCore, QT_LIB import importlib ui_template = importlib.import_module(f'VideoTemplate_{QT_LIB.lower()}') - + +try: + import cupy as cp + pg.setConfigOption("useCupy", True) + _has_cupy = True +except ImportError: + cp = None + _has_cupy = False + +parser = argparse.ArgumentParser(description="Benchmark for testing video performance") +parser.add_argument('--cuda', default=False, action='store_true', help="Use CUDA to process on the GPU", dest="cuda") +parser.add_argument('--dtype', default='uint8', choices=['uint8', 'uint16', 'float'], help="Image dtype (uint8, uint16, or float)") +parser.add_argument('--frames', default=3, type=int, help="Number of image frames to generate (default=3)") +parser.add_argument('--image-mode', default='mono', choices=['mono', 'rgb'], help="Image data mode (mono or rgb)", dest='image_mode') +parser.add_argument('--levels', default=None, type=lambda s: tuple([float(x) for x in s.split(',')]), help="min,max levels to scale monochromatic image dynamic range, or rmin,rmax,gmin,gmax,bmin,bmax to scale rgb") +parser.add_argument('--lut', default=False, action='store_true', help="Use color lookup table") +parser.add_argument('--lut-alpha', default=False, action='store_true', help="Use alpha color lookup table", dest='lut_alpha') +parser.add_argument('--size', default='512x512', type=lambda s: tuple([int(x) for x in s.split('x')]), help="WxH image dimensions default='512x512'") +args = parser.parse_args(sys.argv[1:]) #QtGui.QApplication.setGraphicsSystem('raster') app = QtGui.QApplication([]) @@ -31,14 +49,46 @@ win.show() try: from pyqtgraph.widgets.RawImageWidget import RawImageGLWidget except ImportError: + RawImageGLWidget = None ui.rawGLRadio.setEnabled(False) ui.rawGLRadio.setText(ui.rawGLRadio.text() + " (OpenGL not available)") else: ui.rawGLImg = RawImageGLWidget() ui.stack.addWidget(ui.rawGLImg) +# read in CLI args +ui.cudaCheck.setChecked(args.cuda and _has_cupy) +ui.cudaCheck.setEnabled(_has_cupy) +ui.framesSpin.setValue(args.frames) +ui.widthSpin.setValue(args.size[0]) +ui.heightSpin.setValue(args.size[1]) +ui.dtypeCombo.setCurrentText(args.dtype) +ui.rgbCheck.setChecked(args.image_mode=='rgb') ui.maxSpin1.setOpts(value=255, step=1) ui.minSpin1.setOpts(value=0, step=1) +levelSpins = [ui.minSpin1, ui.maxSpin1, ui.minSpin2, ui.maxSpin2, ui.minSpin3, ui.maxSpin3] +if args.cuda and _has_cupy: + xp = cp +else: + xp = np +if args.levels is None: + ui.scaleCheck.setChecked(False) + ui.rgbLevelsCheck.setChecked(False) +else: + ui.scaleCheck.setChecked(True) + if len(args.levels) == 2: + ui.rgbLevelsCheck.setChecked(False) + ui.minSpin1.setValue(args.levels[0]) + ui.maxSpin1.setValue(args.levels[1]) + elif len(args.levels) == 6: + ui.rgbLevelsCheck.setChecked(True) + for spin,val in zip(levelSpins, args.levels): + spin.setValue(val) + else: + raise ValueError("levels argument must be 2 or 6 comma-separated values (got %r)" % (args.levels,)) +ui.lutCheck.setChecked(args.lut) +ui.alphaCheck.setChecked(args.lut_alpha) + #ui.graphicsView.useOpenGL() ## buggy, but you can try it if you need extra speed. @@ -47,7 +97,8 @@ ui.graphicsView.setCentralItem(vb) vb.setAspectLocked() img = pg.ImageItem() vb.addItem(img) -vb.setRange(QtCore.QRectF(0, 0, 512, 512)) + + LUT = None def updateLUT(): @@ -58,74 +109,94 @@ def updateLUT(): else: n = 4096 LUT = ui.gradient.getLookupTable(n, alpha=ui.alphaCheck.isChecked()) + if _has_cupy and xp == cp: + LUT = cp.asarray(LUT) ui.gradient.sigGradientChanged.connect(updateLUT) updateLUT() ui.alphaCheck.toggled.connect(updateLUT) def updateScale(): - global ui - spins = [ui.minSpin1, ui.maxSpin1, ui.minSpin2, ui.maxSpin2, ui.minSpin3, ui.maxSpin3] + global ui, levelSpins if ui.rgbLevelsCheck.isChecked(): - for s in spins[2:]: + for s in levelSpins[2:]: s.setEnabled(True) else: - for s in spins[2:]: + for s in levelSpins[2:]: s.setEnabled(False) + +updateScale() + ui.rgbLevelsCheck.toggled.connect(updateScale) - + cache = {} def mkData(): with pg.BusyCursor(): - global data, cache, ui + global data, cache, ui, xp frames = ui.framesSpin.value() width = ui.widthSpin.value() height = ui.heightSpin.value() - dtype = (ui.dtypeCombo.currentText(), ui.rgbCheck.isChecked(), frames, width, height) - if dtype not in cache: - if dtype[0] == 'uint8': - dt = np.uint8 + cacheKey = (ui.dtypeCombo.currentText(), ui.rgbCheck.isChecked(), frames, width, height) + if cacheKey not in cache: + if cacheKey[0] == 'uint8': + dt = xp.uint8 loc = 128 scale = 64 mx = 255 - elif dtype[0] == 'uint16': - dt = np.uint16 + elif cacheKey[0] == 'uint16': + dt = xp.uint16 loc = 4096 scale = 1024 mx = 2**16 - elif dtype[0] == 'float': - dt = np.float + elif cacheKey[0] == 'float': + dt = xp.float loc = 1.0 scale = 0.1 mx = 1.0 + else: + raise ValueError(f"unable to handle dtype: {cacheKey[0]}") if ui.rgbCheck.isChecked(): - data = np.random.normal(size=(frames,width,height,3), loc=loc, scale=scale) + data = xp.random.normal(size=(frames,width,height,3), loc=loc, scale=scale) data = pg.gaussianFilter(data, (0, 6, 6, 0)) else: - data = np.random.normal(size=(frames,width,height), loc=loc, scale=scale) + data = xp.random.normal(size=(frames,width,height), loc=loc, scale=scale) data = pg.gaussianFilter(data, (0, 6, 6)) - if dtype[0] != 'float': - data = np.clip(data, 0, mx) + if cacheKey[0] != 'float': + data = xp.clip(data, 0, mx) data = data.astype(dt) data[:, 10, 10:50] = mx data[:, 9:12, 48] = mx data[:, 8:13, 47] = mx - cache = {dtype: data} # clear to save memory (but keep one to prevent unnecessary regeneration) - - data = cache[dtype] + cache = {cacheKey: data} # clear to save memory (but keep one to prevent unnecessary regeneration) + + data = cache[cacheKey] updateLUT() updateSize() def updateSize(): - global ui + global ui, vb frames = ui.framesSpin.value() width = ui.widthSpin.value() height = ui.heightSpin.value() - dtype = np.dtype(str(ui.dtypeCombo.currentText())) + dtype = xp.dtype(str(ui.dtypeCombo.currentText())) rgb = 3 if ui.rgbCheck.isChecked() else 1 ui.sizeLabel.setText('%d MB' % (frames * width * height * rgb * dtype.itemsize / 1e6)) - + vb.setRange(QtCore.QRectF(0, 0, width, height)) + + +def noticeCudaCheck(): + global xp, cache + cache = {} + if ui.cudaCheck.isChecked(): + if _has_cupy: + xp = cp + else: + xp = np + ui.cudaCheck.setChecked(False) + else: + xp = np + mkData() mkData() @@ -139,7 +210,7 @@ ui.framesSpin.editingFinished.connect(mkData) ui.widthSpin.valueChanged.connect(updateSize) ui.heightSpin.valueChanged.connect(updateSize) ui.framesSpin.valueChanged.connect(updateSize) - +ui.cudaCheck.toggled.connect(noticeCudaCheck) ptr = 0 @@ -151,14 +222,14 @@ def update(): useLut = LUT else: useLut = None - + downsample = ui.downsampleCheck.isChecked() if ui.scaleCheck.isChecked(): if ui.rgbLevelsCheck.isChecked(): useScale = [ - [ui.minSpin1.value(), ui.maxSpin1.value()], - [ui.minSpin2.value(), ui.maxSpin2.value()], + [ui.minSpin1.value(), ui.maxSpin1.value()], + [ui.minSpin2.value(), ui.maxSpin2.value()], [ui.minSpin3.value(), ui.maxSpin3.value()]] else: useScale = [ui.minSpin1.value(), ui.maxSpin1.value()] @@ -175,7 +246,7 @@ def update(): img.setImage(data[ptr%data.shape[0]], autoLevels=False, levels=useScale, lut=useLut, autoDownsample=downsample) ui.stack.setCurrentIndex(0) #img.setImage(data[ptr%data.shape[0]], autoRange=False) - + ptr += 1 now = ptime.time() dt = now - lastTime @@ -190,7 +261,7 @@ def update(): timer = QtCore.QTimer() timer.timeout.connect(update) timer.start(0) - + ## Start Qt event loop unless running in interactive mode or using pyside. diff --git a/examples/VideoTemplate.ui b/examples/VideoTemplate.ui index 7da18327..5508442a 100644 --- a/examples/VideoTemplate.ui +++ b/examples/VideoTemplate.ui @@ -15,6 +15,13 @@ + + + + Use CUDA (GPU) if available + + + diff --git a/examples/VideoTemplate_pyqt.py b/examples/VideoTemplate_pyqt.py index b93bedeb..ecb69238 100644 --- a/examples/VideoTemplate_pyqt.py +++ b/examples/VideoTemplate_pyqt.py @@ -30,6 +30,9 @@ class Ui_MainWindow(object): self.centralwidget.setObjectName(_fromUtf8("centralwidget")) self.gridLayout_2 = QtGui.QGridLayout(self.centralwidget) self.gridLayout_2.setObjectName(_fromUtf8("gridLayout_2")) + self.cudaCheck = QtGui.QCheckBox(self.centralwidget) + self.cudaCheck.setObjectName(_fromUtf8("cudaCheck")) + self.gridLayout_2.addWidget(self.cudaCheck, 9, 0, 1, 2) self.downsampleCheck = QtGui.QCheckBox(self.centralwidget) self.downsampleCheck.setObjectName(_fromUtf8("downsampleCheck")) self.gridLayout_2.addWidget(self.downsampleCheck, 8, 0, 1, 2) @@ -189,6 +192,7 @@ class Ui_MainWindow(object): def retranslateUi(self, MainWindow): MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow", None)) + self.cudaCheck.setText(_translate("MainWindow", "Use CUDA (GPU) if available", None)) self.downsampleCheck.setText(_translate("MainWindow", "Auto downsample", None)) self.scaleCheck.setText(_translate("MainWindow", "Scale Data", None)) self.rawRadio.setText(_translate("MainWindow", "RawImageWidget", None)) diff --git a/examples/VideoTemplate_pyqt5.py b/examples/VideoTemplate_pyqt5.py index 63153fb5..2a039ae7 100644 --- a/examples/VideoTemplate_pyqt5.py +++ b/examples/VideoTemplate_pyqt5.py @@ -2,12 +2,15 @@ # Form implementation generated from reading ui file 'examples/VideoTemplate.ui' # -# Created by: PyQt5 UI code generator 5.5.1 +# Created by: PyQt5 UI code generator 5.15.1 # -# WARNING! All changes made in this file will be lost! +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + from PyQt5 import QtCore, QtGui, QtWidgets + class Ui_MainWindow(object): def setupUi(self, MainWindow): MainWindow.setObjectName("MainWindow") @@ -16,6 +19,9 @@ class Ui_MainWindow(object): self.centralwidget.setObjectName("centralwidget") self.gridLayout_2 = QtWidgets.QGridLayout(self.centralwidget) self.gridLayout_2.setObjectName("gridLayout_2") + self.cudaCheck = QtWidgets.QCheckBox(self.centralwidget) + self.cudaCheck.setObjectName("cudaCheck") + self.gridLayout_2.addWidget(self.cudaCheck, 9, 0, 1, 2) self.downsampleCheck = QtWidgets.QCheckBox(self.centralwidget) self.downsampleCheck.setObjectName("downsampleCheck") self.gridLayout_2.addWidget(self.downsampleCheck, 8, 0, 1, 2) @@ -176,6 +182,7 @@ class Ui_MainWindow(object): def retranslateUi(self, MainWindow): _translate = QtCore.QCoreApplication.translate MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow")) + self.cudaCheck.setText(_translate("MainWindow", "Use CUDA (GPU) if available")) self.downsampleCheck.setText(_translate("MainWindow", "Auto downsample")) self.scaleCheck.setText(_translate("MainWindow", "Scale Data")) self.rawRadio.setText(_translate("MainWindow", "RawImageWidget")) @@ -194,6 +201,5 @@ class Ui_MainWindow(object): self.fpsLabel.setText(_translate("MainWindow", "FPS")) self.rgbCheck.setText(_translate("MainWindow", "RGB")) self.label_5.setText(_translate("MainWindow", "Image size")) - from pyqtgraph import GradientWidget, GraphicsView, SpinBox from pyqtgraph.widgets.RawImageWidget import RawImageWidget diff --git a/examples/VideoTemplate_pyside.py b/examples/VideoTemplate_pyside.py index 4af85249..58617983 100644 --- a/examples/VideoTemplate_pyside.py +++ b/examples/VideoTemplate_pyside.py @@ -17,6 +17,9 @@ class Ui_MainWindow(object): self.centralwidget.setObjectName("centralwidget") self.gridLayout_2 = QtGui.QGridLayout(self.centralwidget) self.gridLayout_2.setObjectName("gridLayout_2") + self.cudaCheck = QtGui.QCheckBox(self.centralwidget) + self.cudaCheck.setObjectName("cudaCheck") + self.gridLayout_2.addWidget(self.cudaCheck, 9, 0, 1, 2) self.downsampleCheck = QtGui.QCheckBox(self.centralwidget) self.downsampleCheck.setObjectName("downsampleCheck") self.gridLayout_2.addWidget(self.downsampleCheck, 8, 0, 1, 2) @@ -176,6 +179,7 @@ class Ui_MainWindow(object): def retranslateUi(self, MainWindow): MainWindow.setWindowTitle(QtGui.QApplication.translate("MainWindow", "MainWindow", None, QtGui.QApplication.UnicodeUTF8)) + self.cudaCheck.setText(QtGui.QApplication.translate("MainWindow", "Use CUDA (GPU) if available", None, QtGui.QApplication.UnicodeUTF8)) self.downsampleCheck.setText(QtGui.QApplication.translate("MainWindow", "Auto downsample", None, QtGui.QApplication.UnicodeUTF8)) self.scaleCheck.setText(QtGui.QApplication.translate("MainWindow", "Scale Data", None, QtGui.QApplication.UnicodeUTF8)) self.rawRadio.setText(QtGui.QApplication.translate("MainWindow", "RawImageWidget", None, QtGui.QApplication.UnicodeUTF8)) diff --git a/examples/VideoTemplate_pyside2.py b/examples/VideoTemplate_pyside2.py index 37b7d2e8..10d3cb6c 100644 --- a/examples/VideoTemplate_pyside2.py +++ b/examples/VideoTemplate_pyside2.py @@ -1,207 +1,288 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file 'VideoTemplate.ui' -# -# Created: Sun Sep 18 19:22:41 2016 -# by: pyside2-uic running on PySide2 2.0.0~alpha0 -# -# WARNING! All changes made in this file will be lost! +################################################################################ +## Form generated from reading UI file 'VideoTemplate.ui' +## +## Created by: Qt User Interface Compiler version 5.15.2 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide2.QtCore import * +from PySide2.QtGui import * +from PySide2.QtWidgets import * + +from pyqtgraph import GraphicsView +from pyqtgraph.widgets.RawImageWidget import RawImageWidget +from pyqtgraph import GradientWidget +from pyqtgraph import SpinBox -from PySide2 import QtCore, QtGui, QtWidgets class Ui_MainWindow(object): def setupUi(self, MainWindow): - MainWindow.setObjectName("MainWindow") + if not MainWindow.objectName(): + MainWindow.setObjectName(u"MainWindow") MainWindow.resize(695, 798) - self.centralwidget = QtWidgets.QWidget(MainWindow) - self.centralwidget.setObjectName("centralwidget") - self.gridLayout_2 = QtWidgets.QGridLayout(self.centralwidget) - self.gridLayout_2.setObjectName("gridLayout_2") - self.downsampleCheck = QtWidgets.QCheckBox(self.centralwidget) - self.downsampleCheck.setObjectName("downsampleCheck") + self.centralwidget = QWidget(MainWindow) + self.centralwidget.setObjectName(u"centralwidget") + self.gridLayout_2 = QGridLayout(self.centralwidget) + self.gridLayout_2.setObjectName(u"gridLayout_2") + self.cudaCheck = QCheckBox(self.centralwidget) + self.cudaCheck.setObjectName(u"cudaCheck") + + self.gridLayout_2.addWidget(self.cudaCheck, 9, 0, 1, 2) + + self.downsampleCheck = QCheckBox(self.centralwidget) + self.downsampleCheck.setObjectName(u"downsampleCheck") + self.gridLayout_2.addWidget(self.downsampleCheck, 8, 0, 1, 2) - self.scaleCheck = QtWidgets.QCheckBox(self.centralwidget) - self.scaleCheck.setObjectName("scaleCheck") + + self.scaleCheck = QCheckBox(self.centralwidget) + self.scaleCheck.setObjectName(u"scaleCheck") + self.gridLayout_2.addWidget(self.scaleCheck, 4, 0, 1, 1) - self.gridLayout = QtWidgets.QGridLayout() - self.gridLayout.setObjectName("gridLayout") - self.rawRadio = QtWidgets.QRadioButton(self.centralwidget) - self.rawRadio.setObjectName("rawRadio") + + self.gridLayout = QGridLayout() + self.gridLayout.setObjectName(u"gridLayout") + self.rawRadio = QRadioButton(self.centralwidget) + self.rawRadio.setObjectName(u"rawRadio") + self.gridLayout.addWidget(self.rawRadio, 3, 0, 1, 1) - self.gfxRadio = QtWidgets.QRadioButton(self.centralwidget) + + self.gfxRadio = QRadioButton(self.centralwidget) + self.gfxRadio.setObjectName(u"gfxRadio") self.gfxRadio.setChecked(True) - self.gfxRadio.setObjectName("gfxRadio") + self.gridLayout.addWidget(self.gfxRadio, 2, 0, 1, 1) - self.stack = QtWidgets.QStackedWidget(self.centralwidget) - self.stack.setObjectName("stack") - self.page = QtWidgets.QWidget() - self.page.setObjectName("page") - self.gridLayout_3 = QtWidgets.QGridLayout(self.page) - self.gridLayout_3.setObjectName("gridLayout_3") + + self.stack = QStackedWidget(self.centralwidget) + self.stack.setObjectName(u"stack") + self.page = QWidget() + self.page.setObjectName(u"page") + self.gridLayout_3 = QGridLayout(self.page) + self.gridLayout_3.setObjectName(u"gridLayout_3") self.graphicsView = GraphicsView(self.page) - self.graphicsView.setObjectName("graphicsView") + self.graphicsView.setObjectName(u"graphicsView") + self.gridLayout_3.addWidget(self.graphicsView, 0, 0, 1, 1) + self.stack.addWidget(self.page) - self.page_2 = QtWidgets.QWidget() - self.page_2.setObjectName("page_2") - self.gridLayout_4 = QtWidgets.QGridLayout(self.page_2) - self.gridLayout_4.setObjectName("gridLayout_4") + self.page_2 = QWidget() + self.page_2.setObjectName(u"page_2") + self.gridLayout_4 = QGridLayout(self.page_2) + self.gridLayout_4.setObjectName(u"gridLayout_4") self.rawImg = RawImageWidget(self.page_2) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) + self.rawImg.setObjectName(u"rawImg") + sizePolicy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.rawImg.sizePolicy().hasHeightForWidth()) self.rawImg.setSizePolicy(sizePolicy) - self.rawImg.setObjectName("rawImg") + self.gridLayout_4.addWidget(self.rawImg, 0, 0, 1, 1) + self.stack.addWidget(self.page_2) - self.page_3 = QtWidgets.QWidget() - self.page_3.setObjectName("page_3") - self.gridLayout_5 = QtWidgets.QGridLayout(self.page_3) - self.gridLayout_5.setObjectName("gridLayout_5") - self.rawGLImg = RawImageGLWidget(self.page_3) - self.rawGLImg.setObjectName("rawGLImg") - self.gridLayout_5.addWidget(self.rawGLImg, 0, 0, 1, 1) - self.stack.addWidget(self.page_3) + self.gridLayout.addWidget(self.stack, 0, 0, 1, 1) - self.rawGLRadio = QtWidgets.QRadioButton(self.centralwidget) - self.rawGLRadio.setObjectName("rawGLRadio") + + self.rawGLRadio = QRadioButton(self.centralwidget) + self.rawGLRadio.setObjectName(u"rawGLRadio") + self.gridLayout.addWidget(self.rawGLRadio, 4, 0, 1, 1) + + self.gridLayout_2.addLayout(self.gridLayout, 1, 0, 1, 4) - self.dtypeCombo = QtWidgets.QComboBox(self.centralwidget) - self.dtypeCombo.setObjectName("dtypeCombo") + + self.dtypeCombo = QComboBox(self.centralwidget) self.dtypeCombo.addItem("") self.dtypeCombo.addItem("") self.dtypeCombo.addItem("") + self.dtypeCombo.setObjectName(u"dtypeCombo") + self.gridLayout_2.addWidget(self.dtypeCombo, 3, 2, 1, 1) - self.label = QtWidgets.QLabel(self.centralwidget) - self.label.setObjectName("label") + + self.label = QLabel(self.centralwidget) + self.label.setObjectName(u"label") + self.gridLayout_2.addWidget(self.label, 3, 0, 1, 1) - self.rgbLevelsCheck = QtWidgets.QCheckBox(self.centralwidget) - self.rgbLevelsCheck.setObjectName("rgbLevelsCheck") + + self.rgbLevelsCheck = QCheckBox(self.centralwidget) + self.rgbLevelsCheck.setObjectName(u"rgbLevelsCheck") + self.gridLayout_2.addWidget(self.rgbLevelsCheck, 4, 1, 1, 1) - self.horizontalLayout_2 = QtWidgets.QHBoxLayout() - self.horizontalLayout_2.setObjectName("horizontalLayout_2") + + self.horizontalLayout_2 = QHBoxLayout() + self.horizontalLayout_2.setObjectName(u"horizontalLayout_2") self.minSpin2 = SpinBox(self.centralwidget) + self.minSpin2.setObjectName(u"minSpin2") self.minSpin2.setEnabled(False) - self.minSpin2.setObjectName("minSpin2") + self.horizontalLayout_2.addWidget(self.minSpin2) - self.label_3 = QtWidgets.QLabel(self.centralwidget) - self.label_3.setAlignment(QtCore.Qt.AlignCenter) - self.label_3.setObjectName("label_3") + + self.label_3 = QLabel(self.centralwidget) + self.label_3.setObjectName(u"label_3") + self.label_3.setAlignment(Qt.AlignCenter) + self.horizontalLayout_2.addWidget(self.label_3) + self.maxSpin2 = SpinBox(self.centralwidget) + self.maxSpin2.setObjectName(u"maxSpin2") self.maxSpin2.setEnabled(False) - self.maxSpin2.setObjectName("maxSpin2") + self.horizontalLayout_2.addWidget(self.maxSpin2) + + self.gridLayout_2.addLayout(self.horizontalLayout_2, 5, 2, 1, 1) - self.horizontalLayout = QtWidgets.QHBoxLayout() - self.horizontalLayout.setObjectName("horizontalLayout") + + self.horizontalLayout = QHBoxLayout() + self.horizontalLayout.setObjectName(u"horizontalLayout") self.minSpin1 = SpinBox(self.centralwidget) - self.minSpin1.setObjectName("minSpin1") + self.minSpin1.setObjectName(u"minSpin1") + self.horizontalLayout.addWidget(self.minSpin1) - self.label_2 = QtWidgets.QLabel(self.centralwidget) - self.label_2.setAlignment(QtCore.Qt.AlignCenter) - self.label_2.setObjectName("label_2") + + self.label_2 = QLabel(self.centralwidget) + self.label_2.setObjectName(u"label_2") + self.label_2.setAlignment(Qt.AlignCenter) + self.horizontalLayout.addWidget(self.label_2) + self.maxSpin1 = SpinBox(self.centralwidget) - self.maxSpin1.setObjectName("maxSpin1") + self.maxSpin1.setObjectName(u"maxSpin1") + self.horizontalLayout.addWidget(self.maxSpin1) + + self.gridLayout_2.addLayout(self.horizontalLayout, 4, 2, 1, 1) - self.horizontalLayout_3 = QtWidgets.QHBoxLayout() - self.horizontalLayout_3.setObjectName("horizontalLayout_3") + + self.horizontalLayout_3 = QHBoxLayout() + self.horizontalLayout_3.setObjectName(u"horizontalLayout_3") self.minSpin3 = SpinBox(self.centralwidget) + self.minSpin3.setObjectName(u"minSpin3") self.minSpin3.setEnabled(False) - self.minSpin3.setObjectName("minSpin3") + self.horizontalLayout_3.addWidget(self.minSpin3) - self.label_4 = QtWidgets.QLabel(self.centralwidget) - self.label_4.setAlignment(QtCore.Qt.AlignCenter) - self.label_4.setObjectName("label_4") + + self.label_4 = QLabel(self.centralwidget) + self.label_4.setObjectName(u"label_4") + self.label_4.setAlignment(Qt.AlignCenter) + self.horizontalLayout_3.addWidget(self.label_4) + self.maxSpin3 = SpinBox(self.centralwidget) + self.maxSpin3.setObjectName(u"maxSpin3") self.maxSpin3.setEnabled(False) - self.maxSpin3.setObjectName("maxSpin3") + self.horizontalLayout_3.addWidget(self.maxSpin3) + + self.gridLayout_2.addLayout(self.horizontalLayout_3, 6, 2, 1, 1) - self.lutCheck = QtWidgets.QCheckBox(self.centralwidget) - self.lutCheck.setObjectName("lutCheck") + + self.lutCheck = QCheckBox(self.centralwidget) + self.lutCheck.setObjectName(u"lutCheck") + self.gridLayout_2.addWidget(self.lutCheck, 7, 0, 1, 1) - self.alphaCheck = QtWidgets.QCheckBox(self.centralwidget) - self.alphaCheck.setObjectName("alphaCheck") + + self.alphaCheck = QCheckBox(self.centralwidget) + self.alphaCheck.setObjectName(u"alphaCheck") + self.gridLayout_2.addWidget(self.alphaCheck, 7, 1, 1, 1) + self.gradient = GradientWidget(self.centralwidget) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) + self.gradient.setObjectName(u"gradient") sizePolicy.setHeightForWidth(self.gradient.sizePolicy().hasHeightForWidth()) self.gradient.setSizePolicy(sizePolicy) - self.gradient.setObjectName("gradient") + self.gridLayout_2.addWidget(self.gradient, 7, 2, 1, 2) - spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.gridLayout_2.addItem(spacerItem, 3, 3, 1, 1) - self.fpsLabel = QtWidgets.QLabel(self.centralwidget) - font = QtGui.QFont() + + self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) + + self.gridLayout_2.addItem(self.horizontalSpacer, 3, 3, 1, 1) + + self.fpsLabel = QLabel(self.centralwidget) + self.fpsLabel.setObjectName(u"fpsLabel") + font = QFont() font.setPointSize(12) self.fpsLabel.setFont(font) - self.fpsLabel.setAlignment(QtCore.Qt.AlignCenter) - self.fpsLabel.setObjectName("fpsLabel") + self.fpsLabel.setAlignment(Qt.AlignCenter) + self.gridLayout_2.addWidget(self.fpsLabel, 0, 0, 1, 4) - self.rgbCheck = QtWidgets.QCheckBox(self.centralwidget) - self.rgbCheck.setObjectName("rgbCheck") + + self.rgbCheck = QCheckBox(self.centralwidget) + self.rgbCheck.setObjectName(u"rgbCheck") + self.gridLayout_2.addWidget(self.rgbCheck, 3, 1, 1, 1) - self.label_5 = QtWidgets.QLabel(self.centralwidget) - self.label_5.setObjectName("label_5") + + self.label_5 = QLabel(self.centralwidget) + self.label_5.setObjectName(u"label_5") + self.gridLayout_2.addWidget(self.label_5, 2, 0, 1, 1) - self.horizontalLayout_4 = QtWidgets.QHBoxLayout() - self.horizontalLayout_4.setObjectName("horizontalLayout_4") - self.framesSpin = QtWidgets.QSpinBox(self.centralwidget) - self.framesSpin.setButtonSymbols(QtWidgets.QAbstractSpinBox.NoButtons) - self.framesSpin.setProperty("value", 10) - self.framesSpin.setObjectName("framesSpin") + + self.horizontalLayout_4 = QHBoxLayout() + self.horizontalLayout_4.setObjectName(u"horizontalLayout_4") + self.framesSpin = QSpinBox(self.centralwidget) + self.framesSpin.setObjectName(u"framesSpin") + self.framesSpin.setButtonSymbols(QAbstractSpinBox.NoButtons) + self.framesSpin.setValue(10) + self.horizontalLayout_4.addWidget(self.framesSpin) - self.widthSpin = QtWidgets.QSpinBox(self.centralwidget) - self.widthSpin.setButtonSymbols(QtWidgets.QAbstractSpinBox.PlusMinus) + + self.widthSpin = QSpinBox(self.centralwidget) + self.widthSpin.setObjectName(u"widthSpin") + self.widthSpin.setButtonSymbols(QAbstractSpinBox.PlusMinus) self.widthSpin.setMaximum(10000) - self.widthSpin.setProperty("value", 512) - self.widthSpin.setObjectName("widthSpin") + self.widthSpin.setValue(512) + self.horizontalLayout_4.addWidget(self.widthSpin) - self.heightSpin = QtWidgets.QSpinBox(self.centralwidget) - self.heightSpin.setButtonSymbols(QtWidgets.QAbstractSpinBox.NoButtons) + + self.heightSpin = QSpinBox(self.centralwidget) + self.heightSpin.setObjectName(u"heightSpin") + self.heightSpin.setButtonSymbols(QAbstractSpinBox.NoButtons) self.heightSpin.setMaximum(10000) - self.heightSpin.setProperty("value", 512) - self.heightSpin.setObjectName("heightSpin") + self.heightSpin.setValue(512) + self.horizontalLayout_4.addWidget(self.heightSpin) + + self.gridLayout_2.addLayout(self.horizontalLayout_4, 2, 1, 1, 2) - self.sizeLabel = QtWidgets.QLabel(self.centralwidget) - self.sizeLabel.setText("") - self.sizeLabel.setObjectName("sizeLabel") + + self.sizeLabel = QLabel(self.centralwidget) + self.sizeLabel.setObjectName(u"sizeLabel") + self.gridLayout_2.addWidget(self.sizeLabel, 2, 3, 1, 1) + MainWindow.setCentralWidget(self.centralwidget) self.retranslateUi(MainWindow) - self.stack.setCurrentIndex(2) - QtCore.QMetaObject.connectSlotsByName(MainWindow) + + self.stack.setCurrentIndex(1) + + + QMetaObject.connectSlotsByName(MainWindow) + # setupUi def retranslateUi(self, MainWindow): - MainWindow.setWindowTitle(QtWidgets.QApplication.translate("MainWindow", "MainWindow", None, -1)) - self.downsampleCheck.setText(QtWidgets.QApplication.translate("MainWindow", "Auto downsample", None, -1)) - self.scaleCheck.setText(QtWidgets.QApplication.translate("MainWindow", "Scale Data", None, -1)) - self.rawRadio.setText(QtWidgets.QApplication.translate("MainWindow", "RawImageWidget", None, -1)) - self.gfxRadio.setText(QtWidgets.QApplication.translate("MainWindow", "GraphicsView + ImageItem", None, -1)) - self.rawGLRadio.setText(QtWidgets.QApplication.translate("MainWindow", "RawGLImageWidget", None, -1)) - self.dtypeCombo.setItemText(0, QtWidgets.QApplication.translate("MainWindow", "uint8", None, -1)) - self.dtypeCombo.setItemText(1, QtWidgets.QApplication.translate("MainWindow", "uint16", None, -1)) - self.dtypeCombo.setItemText(2, QtWidgets.QApplication.translate("MainWindow", "float", None, -1)) - self.label.setText(QtWidgets.QApplication.translate("MainWindow", "Data type", None, -1)) - self.rgbLevelsCheck.setText(QtWidgets.QApplication.translate("MainWindow", "RGB", None, -1)) - self.label_3.setText(QtWidgets.QApplication.translate("MainWindow", "<--->", None, -1)) - self.label_2.setText(QtWidgets.QApplication.translate("MainWindow", "<--->", None, -1)) - self.label_4.setText(QtWidgets.QApplication.translate("MainWindow", "<--->", None, -1)) - self.lutCheck.setText(QtWidgets.QApplication.translate("MainWindow", "Use Lookup Table", None, -1)) - self.alphaCheck.setText(QtWidgets.QApplication.translate("MainWindow", "alpha", None, -1)) - self.fpsLabel.setText(QtWidgets.QApplication.translate("MainWindow", "FPS", None, -1)) - self.rgbCheck.setText(QtWidgets.QApplication.translate("MainWindow", "RGB", None, -1)) - self.label_5.setText(QtWidgets.QApplication.translate("MainWindow", "Image size", None, -1)) + MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", u"MainWindow", None)) + self.cudaCheck.setText(QCoreApplication.translate("MainWindow", u"Use CUDA (GPU) if available", None)) + self.downsampleCheck.setText(QCoreApplication.translate("MainWindow", u"Auto downsample", None)) + self.scaleCheck.setText(QCoreApplication.translate("MainWindow", u"Scale Data", None)) + self.rawRadio.setText(QCoreApplication.translate("MainWindow", u"RawImageWidget", None)) + self.gfxRadio.setText(QCoreApplication.translate("MainWindow", u"GraphicsView + ImageItem", None)) + self.rawGLRadio.setText(QCoreApplication.translate("MainWindow", u"RawGLImageWidget", None)) + self.dtypeCombo.setItemText(0, QCoreApplication.translate("MainWindow", u"uint8", None)) + self.dtypeCombo.setItemText(1, QCoreApplication.translate("MainWindow", u"uint16", None)) + self.dtypeCombo.setItemText(2, QCoreApplication.translate("MainWindow", u"float", None)) + + self.label.setText(QCoreApplication.translate("MainWindow", u"Data type", None)) + self.rgbLevelsCheck.setText(QCoreApplication.translate("MainWindow", u"RGB", None)) + self.label_3.setText(QCoreApplication.translate("MainWindow", u"<--->", None)) + self.label_2.setText(QCoreApplication.translate("MainWindow", u"<--->", None)) + self.label_4.setText(QCoreApplication.translate("MainWindow", u"<--->", None)) + self.lutCheck.setText(QCoreApplication.translate("MainWindow", u"Use Lookup Table", None)) + self.alphaCheck.setText(QCoreApplication.translate("MainWindow", u"alpha", None)) + self.fpsLabel.setText(QCoreApplication.translate("MainWindow", u"FPS", None)) + self.rgbCheck.setText(QCoreApplication.translate("MainWindow", u"RGB", None)) + self.label_5.setText(QCoreApplication.translate("MainWindow", u"Image size", None)) + self.sizeLabel.setText("") + # retranslateUi -from pyqtgraph.widgets.RawImageWidget import RawImageGLWidget, RawImageWidget -from pyqtgraph import GradientWidget, SpinBox, GraphicsView diff --git a/examples/test_examples.py b/examples/test_examples.py index a0be0750..b5baf76f 100644 --- a/examples/test_examples.py +++ b/examples/test_examples.py @@ -54,6 +54,10 @@ installedFrontends = sorted([ exceptionCondition = namedtuple("exceptionCondition", ["condition", "reason"]) conditionalExamples = { + "test_ExampleApp.py": exceptionCondition( + not(platform.system() == "Linux" and frontends[Qt.PYSIDE2]), + reason="Unexplained, intermittent segfault and subsequent timeout on CI" + ), "hdf5.py": exceptionCondition( False, reason="Example requires user interaction" diff --git a/pyqtgraph/__init__.py b/pyqtgraph/__init__.py index f3833616..6dac9a0e 100644 --- a/pyqtgraph/__init__.py +++ b/pyqtgraph/__init__.py @@ -61,6 +61,7 @@ CONFIG_OPTIONS = { # For 'col-major', image data is expected in reversed (col, row) order. # The default is 'col-major' for backward compatibility, but this may # change in the future. + 'useCupy': False, # When True, attempt to use cupy ( currently only with ImageItem and related functions ) } diff --git a/pyqtgraph/functions.py b/pyqtgraph/functions.py index 32fb9626..e7ca0e32 100644 --- a/pyqtgraph/functions.py +++ b/pyqtgraph/functions.py @@ -6,18 +6,22 @@ Distributed under MIT/X11 license. See license.txt for more information. """ from __future__ import division -import warnings -import numpy as np -import decimal, re + import ctypes -import sys, struct +import decimal +import re +import struct +import sys +import warnings + +import numpy as np +from pyqtgraph.util.cupy_helper import getCupy + +from . import debug, reload +from .Qt import QtGui, QtCore, QT_LIB, QtVersion +from .metaarray import MetaArray from .pgcollections import OrderedDict from .python2_3 import asUnicode, basestring -from .Qt import QtGui, QtCore, QT_LIB, QtVersion -from . import getConfigOption, setConfigOptions -from . import debug, reload -from .metaarray import MetaArray - Colors = { 'b': QtGui.QColor(0,0,255,255), @@ -940,80 +944,53 @@ def rescaleData(data, scale, offset, dtype=None, clip=None): The scaling operation is:: data => (data-offset) * scale - """ if dtype is None: dtype = data.dtype else: dtype = np.dtype(dtype) - try: - if not getConfigOption('useWeave'): - raise Exception('Weave is disabled; falling back to slower version.') - try: - import scipy.weave - except ImportError: - raise Exception('scipy.weave is not importable; falling back to slower version.') - - ## require native dtype when using weave - if not data.dtype.isnative: - data = data.astype(data.dtype.newbyteorder('=')) - if not dtype.isnative: - weaveDtype = dtype.newbyteorder('=') + d2 = data.astype(np.float) - float(offset) + d2 *= scale + + # Clip before converting dtype to avoid overflow + if dtype.kind in 'ui': + lim = np.iinfo(dtype) + if clip is None: + # don't let rescale cause integer overflow + d2 = np.clip(d2, lim.min, lim.max) else: - weaveDtype = dtype - - newData = np.empty((data.size,), dtype=weaveDtype) - flat = np.ascontiguousarray(data).reshape(data.size) - size = data.size - - code = """ - double sc = (double)scale; - double off = (double)offset; - for( int i=0; i= 11.1. """ if data.dtype.kind not in ('i', 'u'): data = data.astype(int) - - return np.take(lut, data, axis=0, mode='clip') + + cp = getCupy() + if cp and cp.get_array_module(data) == cp: + # cupy.take only supports "wrap" mode + return cp.take(lut, cp.clip(data, 0, lut.shape[0] - 1), axis=0) + else: + return np.take(lut, data, axis=0, mode='clip') def makeRGBA(*args, **kwds): @@ -1022,7 +999,7 @@ def makeRGBA(*args, **kwds): return makeARGB(*args, **kwds) -def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): +def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False, output=None): """ Convert an array of values into an ARGB array suitable for building QImages, OpenGL textures, etc. @@ -1062,29 +1039,31 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): is BGRA). ============== ================================================================================== """ + cp = getCupy() + xp = cp.get_array_module(data) if cp else np profile = debug.Profiler() if data.ndim not in (2, 3): raise TypeError("data must be 2D or 3D") if data.ndim == 3 and data.shape[2] > 4: raise TypeError("data.shape[2] must be <= 4") - if lut is not None and not isinstance(lut, np.ndarray): - lut = np.array(lut) + if lut is not None and not isinstance(lut, xp.ndarray): + lut = xp.array(lut) if levels is None: # automatically decide levels based on data dtype if data.dtype.kind == 'u': - levels = np.array([0, 2**(data.itemsize*8)-1]) + levels = xp.array([0, 2**(data.itemsize*8)-1]) elif data.dtype.kind == 'i': s = 2**(data.itemsize*8 - 1) - levels = np.array([-s, s-1]) + levels = xp.array([-s, s-1]) elif data.dtype.kind == 'b': - levels = np.array([0,1]) + levels = xp.array([0,1]) else: raise Exception('levels argument is required for float input types') - if not isinstance(levels, np.ndarray): - levels = np.array(levels) - levels = levels.astype(np.float) + if not isinstance(levels, xp.ndarray): + levels = xp.array(levels) + levels = levels.astype(xp.float) if levels.ndim == 1: if levels.shape[0] != 2: raise Exception('levels argument must have length 2') @@ -1096,7 +1075,7 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): else: raise Exception("levels argument must be 1D or 2D (got shape=%s)." % repr(levels.shape)) - profile() + profile('check inputs') # Decide on maximum scaled value if scale is None: @@ -1107,28 +1086,28 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): # Decide on the dtype we want after scaling if lut is None: - dtype = np.ubyte + dtype = xp.ubyte else: - dtype = np.min_scalar_type(lut.shape[0]-1) + dtype = xp.min_scalar_type(lut.shape[0]-1) # awkward, but fastest numpy native nan evaluation # nanMask = None - if data.dtype.kind == 'f' and np.isnan(data.min()): - nanMask = np.isnan(data) + if data.dtype.kind == 'f' and xp.isnan(data.min()): + nanMask = xp.isnan(data) if data.ndim > 2: - nanMask = np.any(nanMask, axis=-1) + nanMask = xp.any(nanMask, axis=-1) # Apply levels if given if levels is not None: - if isinstance(levels, np.ndarray) and levels.ndim == 2: + if isinstance(levels, xp.ndarray) and levels.ndim == 2: # we are going to rescale each channel independently if levels.shape[0] != data.shape[-1]: raise Exception("When rescaling multi-channel data, there must be the same number of levels as channels (data.shape[-1] == levels.shape[0])") - newData = np.empty(data.shape, dtype=int) + newData = xp.empty(data.shape, dtype=int) for i in range(data.shape[-1]): minVal, maxVal = levels[i] if minVal == maxVal: - maxVal = np.nextafter(maxVal, 2*maxVal) + maxVal = xp.nextafter(maxVal, 2*maxVal) rng = maxVal-minVal rng = 1 if rng == 0 else rng newData[...,i] = rescaleData(data[...,i], scale / rng, minVal, dtype=dtype) @@ -1138,25 +1117,29 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): minVal, maxVal = levels if minVal != 0 or maxVal != scale: if minVal == maxVal: - maxVal = np.nextafter(maxVal, 2*maxVal) + maxVal = xp.nextafter(maxVal, 2*maxVal) rng = maxVal-minVal rng = 1 if rng == 0 else rng data = rescaleData(data, scale/rng, minVal, dtype=dtype) - profile() + profile('apply levels') + # apply LUT if given if lut is not None: data = applyLookupTable(data, lut) else: - if data.dtype is not np.ubyte: - data = np.clip(data, 0, 255).astype(np.ubyte) + if data.dtype is not xp.ubyte: + data = xp.clip(data, 0, 255).astype(xp.ubyte) - profile() + profile('apply lut') # this will be the final image array - imgData = np.empty(data.shape[:2]+(4,), dtype=np.ubyte) + if output is None: + imgData = xp.empty(data.shape[:2]+(4,), dtype=xp.ubyte) + else: + imgData = output - profile() + profile('allocate') # decide channel order if useRGBA: @@ -1167,7 +1150,7 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): # copy data into image array if data.ndim == 2: # This is tempting: - # imgData[..., :3] = data[..., np.newaxis] + # imgData[..., :3] = data[..., xp.newaxis] # ..but it turns out this is faster: for i in range(3): imgData[..., i] = data @@ -1178,7 +1161,7 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): for i in range(0, data.shape[2]): imgData[..., i] = data[..., order[i]] - profile() + profile('reorder channels') # add opaque alpha channel if needed if data.ndim == 2 or data.shape[2] == 3: @@ -1192,7 +1175,7 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): alpha = True imgData[nanMask, 3] = 0 - profile() + profile('alpha channel') return imgData, alpha @@ -1364,7 +1347,9 @@ def gaussianFilter(data, sigma): (note: results are only approximately equal to the output of gaussian_filter) """ - if np.isscalar(sigma): + cp = getCupy() + xp = cp.get_array_module(data) if cp else np + if xp.isscalar(sigma): sigma = (sigma,) * data.ndim baseline = data.mean() @@ -1376,17 +1361,17 @@ def gaussianFilter(data, sigma): # generate 1D gaussian kernel ksize = int(s * 6) - x = np.arange(-ksize, ksize) - kernel = np.exp(-x**2 / (2*s**2)) + x = xp.arange(-ksize, ksize) + kernel = xp.exp(-x**2 / (2*s**2)) kshape = [1,] * data.ndim kshape[ax] = len(kernel) kernel = kernel.reshape(kshape) # convolve as product of FFTs shape = data.shape[ax] + ksize - scale = 1.0 / (abs(s) * (2*np.pi)**0.5) - filtered = scale * np.fft.irfft(np.fft.rfft(filtered, shape, axis=ax) * - np.fft.rfft(kernel, shape, axis=ax), + scale = 1.0 / (abs(s) * (2*xp.pi)**0.5) + filtered = scale * xp.fft.irfft(xp.fft.rfft(filtered, shape, axis=ax) * + xp.fft.rfft(kernel, shape, axis=ax), axis=ax) # clip off extra data diff --git a/pyqtgraph/graphicsItems/ImageItem.py b/pyqtgraph/graphicsItems/ImageItem.py index 479f4917..a1bf49f0 100644 --- a/pyqtgraph/graphicsItems/ImageItem.py +++ b/pyqtgraph/graphicsItems/ImageItem.py @@ -1,13 +1,15 @@ # -*- coding: utf-8 -*- from __future__ import division -from ..Qt import QtGui, QtCore -import numpy as np -from .. import functions as fn -from .. import debug as debug +import numpy + from .GraphicsObject import GraphicsObject -from ..Point import Point +from .. import debug as debug +from .. import functions as fn from .. import getConfigOption +from ..Point import Point +from ..Qt import QtGui, QtCore +from ..util.cupy_helper import getCupy try: from collections.abc import Callable @@ -53,6 +55,13 @@ class ImageItem(GraphicsObject): self.lut = None self.autoDownsample = False self._lastDownsample = (1, 1) + self._processingBuffer = None + self._displayBuffer = None + self._renderRequired = True + self._unrenderable = False + self._cupy = getCupy() + self._xp = None # either numpy or cupy, to match the image data + self._defferedLevels = None self.axisOrder = getConfigOption('imageAxisOrder') @@ -124,13 +133,16 @@ class ImageItem(GraphicsObject): Only the first format is compatible with lookup tables. See :func:`makeARGB ` for more details on how levels are applied. """ - if levels is not None: - levels = np.asarray(levels) - if not fn.eq(levels, self.levels): + if self._xp is None: self.levels = levels - self._effectiveLut = None - if update: - self.updateImage() + self._defferedLevels = levels + return + if levels is not None: + levels = self._xp.asarray(levels) + self.levels = levels + self._effectiveLut = None + if update: + self.updateImage() def getLevels(self): return self.levels @@ -159,7 +171,7 @@ class ImageItem(GraphicsObject): Added in version 0.9.9 """ self.autoDownsample = ads - self.qimage = None + self._renderRequired = True self.update() def setOpts(self, update=True, **kargs): @@ -200,6 +212,14 @@ class ImageItem(GraphicsObject): self.informViewBoundsChanged() self.update() + def _buildQImageBuffer(self, shape): + self._displayBuffer = numpy.empty(shape[:2] + (4,), dtype=numpy.ubyte) + if self._xp == self._cupy: + self._processingBuffer = self._xp.empty(shape[:2] + (4,), dtype=self._xp.ubyte) + else: + self._processingBuffer = self._displayBuffer + self.qimage = fn.makeQImage(self._displayBuffer, transpose=False, copy=False) + def setImage(self, image=None, autoLevels=None, **kargs): """ Update the image displayed by this item. For more information on how the image @@ -250,9 +270,14 @@ class ImageItem(GraphicsObject): if self.image is None: return else: + old_xp = self._xp + self._xp = self._cupy.get_array_module(image) if self._cupy else numpy gotNewData = True - shapeChanged = (self.image is None or image.shape != self.image.shape) - image = image.view(np.ndarray) + processingSubstrateChanged = old_xp != self._xp + if processingSubstrateChanged: + self._processingBuffer = None + shapeChanged = (processingSubstrateChanged or self.image is None or image.shape != self.image.shape) + image = image.view() if self.image is None or image.dtype != self.image.dtype: self._effectiveLut = None self.image = image @@ -274,9 +299,9 @@ class ImageItem(GraphicsObject): img = self.image while img.size > 2**16: img = img[::2, ::2] - mn, mx = np.nanmin(img), np.nanmax(img) + mn, mx = self._xp.nanmin(img), self._xp.nanmax(img) # mn and mx can still be NaN if the data is all-NaN - if mn == mx or np.isnan(mn) or np.isnan(mx): + if mn == mx or self._xp.isnan(mn) or self._xp.isnan(mx): mn = 0 mx = 255 kargs['levels'] = [mn,mx] @@ -287,13 +312,17 @@ class ImageItem(GraphicsObject): profile() - self.qimage = None + self._renderRequired = True self.update() profile() if gotNewData: self.sigImageChanged.emit() + if self._defferedLevels is not None: + levels = self._defferedLevels + self._defferedLevels = None + self.setLevels((levels)) def dataTransform(self): """Return the transform that maps from this image's input array to its @@ -337,11 +366,11 @@ class ImageItem(GraphicsObject): """ data = self.image while data.size > targetSize: - ax = np.argmax(data.shape) + ax = self._xp.argmax(data.shape) sl = [slice(None)] * data.ndim sl[ax] = slice(None, None, 2) data = data[sl] - return np.nanmin(data), np.nanmax(data) + return self._xp.nanmin(data), self._xp.nanmax(data) def updateImage(self, *args, **kargs): ## used for re-rendering qimage from self.image. @@ -356,8 +385,7 @@ class ImageItem(GraphicsObject): def render(self): # Convert data to QImage for display. - - profile = debug.Profiler() + self._unrenderable = True if self.image is None or self.image.size == 0: return @@ -373,7 +401,6 @@ class ImageItem(GraphicsObject): if self.autoDownsample: xds, yds = self._computeDownsampleFactors() if xds is None: - self.qimage = None return axes = [1, 0] if self.axisOrder == 'row-major' else [0, 1] @@ -390,18 +417,18 @@ class ImageItem(GraphicsObject): # if the image data is a small int, then we can combine levels + lut # into a single lut for better performance levels = self.levels - if levels is not None and levels.ndim == 1 and image.dtype in (np.ubyte, np.uint16): + if levels is not None and levels.ndim == 1 and image.dtype in (self._xp.ubyte, self._xp.uint16): if self._effectiveLut is None: eflsize = 2**(image.itemsize*8) - ind = np.arange(eflsize) + ind = self._xp.arange(eflsize) minlev, maxlev = levels levdiff = maxlev - minlev levdiff = 1 if levdiff == 0 else levdiff # don't allow division by 0 if lut is None: efflut = fn.rescaleData(ind, scale=255./levdiff, - offset=minlev, dtype=np.ubyte) + offset=minlev, dtype=self._xp.ubyte) else: - lutdtype = np.min_scalar_type(lut.shape[0]-1) + lutdtype = self._xp.min_scalar_type(lut.shape[0] - 1) efflut = fn.rescaleData(ind, scale=(lut.shape[0]-1)/levdiff, offset=minlev, dtype=lutdtype, clip=(0, lut.shape[0]-1)) efflut = lut[efflut] @@ -419,16 +446,22 @@ class ImageItem(GraphicsObject): if self.axisOrder == 'col-major': image = image.transpose((1, 0, 2)[:image.ndim]) - argb, alpha = fn.makeARGB(image, lut=lut, levels=levels) - self.qimage = fn.makeQImage(argb, alpha, transpose=False) + if self._processingBuffer is None or self._processingBuffer.shape[:2] != image.shape[:2]: + self._buildQImageBuffer(image.shape) + + fn.makeARGB(image, lut=lut, levels=levels, output=self._processingBuffer) + if self._xp == self._cupy: + self._processingBuffer.get(out=self._displayBuffer) + self._renderRequired = False + self._unrenderable = False def paint(self, p, *args): profile = debug.Profiler() if self.image is None: return - if self.qimage is None: + if self._renderRequired: self.render() - if self.qimage is None: + if self._unrenderable: return profile('render QImage') if self.paintMode is not None: @@ -444,7 +477,7 @@ class ImageItem(GraphicsObject): def save(self, fileName, *args): """Save this image to file. Note that this saves the visible image (after scale/color changes), not the original data.""" - if self.qimage is None: + if self._renderRequired: self.render() self.qimage.save(fileName, *args) @@ -458,7 +491,7 @@ class ImageItem(GraphicsObject): dimensions roughly *targetImageSize* for each axis. The *bins* argument and any extra keyword arguments are passed to - np.histogram(). If *bins* is 'auto', then a bin number is automatically + self.xp.histogram(). If *bins* is 'auto', then a bin number is automatically chosen based on the image characteristics: * Integer images will have approximately *targetHistogramSize* bins, @@ -473,33 +506,33 @@ class ImageItem(GraphicsObject): if self.image is None or self.image.size == 0: return None, None if step == 'auto': - step = (max(1, int(np.ceil(self.image.shape[0] / targetImageSize))), - max(1, int(np.ceil(self.image.shape[1] / targetImageSize)))) - if np.isscalar(step): + step = (max(1, int(self._xp.ceil(self.image.shape[0] / targetImageSize))), + max(1, int(self._xp.ceil(self.image.shape[1] / targetImageSize)))) + if self._xp.isscalar(step): step = (step, step) stepData = self.image[::step[0], ::step[1]] if isinstance(bins, str) and bins == 'auto': - mn = np.nanmin(stepData) - mx = np.nanmax(stepData) + mn = self._xp.nanmin(stepData).item() + mx = self._xp.nanmax(stepData).item() if mx == mn: # degenerate image, arange will fail mx += 1 - if np.isnan(mn) or np.isnan(mx): + if self._xp.isnan(mn) or self._xp.isnan(mx): # the data are all-nan return None, None if stepData.dtype.kind in "ui": # For integer data, we select the bins carefully to avoid aliasing - step = np.ceil((mx-mn) / 500.) + step = int(self._xp.ceil((mx - mn) / 500.)) bins = [] if step > 0.0: - bins = np.arange(mn, mx+1.01*step, step, dtype=np.int) + bins = self._xp.arange(mn, mx + 1.01 * step, step, dtype=self._xp.int) else: # for float data, let numpy select the bins. - bins = np.linspace(mn, mx, 500) + bins = self._xp.linspace(mn, mx, 500) if len(bins) == 0: - bins = [mn, mx] + bins = self._xp.asarray((mn, mx)) kwds['bins'] = bins @@ -507,14 +540,20 @@ class ImageItem(GraphicsObject): hist = [] for i in range(stepData.shape[-1]): stepChan = stepData[..., i] - stepChan = stepChan[np.isfinite(stepChan)] - h = np.histogram(stepChan, **kwds) - hist.append((h[1][:-1], h[0])) + stepChan = stepChan[self._xp.isfinite(stepChan)] + h = self._xp.histogram(stepChan, **kwds) + if self._cupy: + hist.append((self._cupy.asnumpy(h[1][:-1]), self._cupy.asnumpy(h[0]))) + else: + hist.append((h[1][:-1], h[0])) return hist else: - stepData = stepData[np.isfinite(stepData)] - hist = np.histogram(stepData, **kwds) - return hist[1][:-1], hist[0] + stepData = stepData[self._xp.isfinite(stepData)] + hist = self._xp.histogram(stepData, **kwds) + if self._cupy: + return self._cupy.asnumpy(hist[1][:-1]), self._cupy.asnumpy(hist[0]) + else: + return hist[1][:-1], hist[0] def setPxMode(self, b): """ @@ -529,9 +568,9 @@ class ImageItem(GraphicsObject): self.setPxMode(False) def getPixmap(self): - if self.qimage is None: + if self._renderRequired: self.render() - if self.qimage is None: + if self._unrenderable: return None return QtGui.QPixmap.fromImage(self.qimage) @@ -546,10 +585,11 @@ class ImageItem(GraphicsObject): if self.autoDownsample: xds, yds = self._computeDownsampleFactors() if xds is None: - self.qimage = None + self._renderRequired = True + self._unrenderable = True return if (xds, yds) != self._lastDownsample: - self.qimage = None + self._renderRequired = True self.update() def _computeDownsampleFactors(self): diff --git a/pyqtgraph/util/cupy_helper.py b/pyqtgraph/util/cupy_helper.py new file mode 100644 index 00000000..5313bb62 --- /dev/null +++ b/pyqtgraph/util/cupy_helper.py @@ -0,0 +1,18 @@ +import os +from warnings import warn + +from pyqtgraph import getConfigOption + + +def getCupy(): + if getConfigOption("useCupy"): + try: + import cupy + except ImportError: + warn("cupy library could not be loaded, but 'useCupy' is set.") + return None + if os.name == "nt" and cupy.cuda.runtime.runtimeGetVersion() < 11000: + warn("In Windows, CUDA toolkit should be version 11 or higher, or some functions may misbehave.") + return cupy + else: + return None diff --git a/pyqtgraph/widgets/RawImageWidget.py b/pyqtgraph/widgets/RawImageWidget.py index b23ee126..98cee889 100644 --- a/pyqtgraph/widgets/RawImageWidget.py +++ b/pyqtgraph/widgets/RawImageWidget.py @@ -5,6 +5,7 @@ Copyright 2010-2016 Luke Campagnola Distributed under MIT/X11 license. See license.txt for more infomation. """ +from .. import getConfigOption, functions as fn, getCupy from ..Qt import QtCore, QtGui try: @@ -17,8 +18,6 @@ except (ImportError, AttributeError): # AttributeError upon import HAVE_OPENGL = False -from .. import getConfigOption, functions as fn - class RawImageWidget(QtGui.QWidget): """ @@ -37,6 +36,7 @@ class RawImageWidget(QtGui.QWidget): self.scaled = scaled self.opts = None self.image = None + self._cp = getCupy() def setImage(self, img, *args, **kargs): """ @@ -52,6 +52,8 @@ class RawImageWidget(QtGui.QWidget): return if self.image is None: argb, alpha = fn.makeARGB(self.opts[0], *self.opts[1], **self.opts[2]) + if self._cp and self._cp.get_array_module(argb) == self._cp: + argb = argb.get() # transfer GPU data back to the CPU self.image = fn.makeQImage(argb, alpha) self.opts = () # if self.pixmap is None: