From a91953e93daa143af12d8f35e6ba953fc7e67dde Mon Sep 17 00:00:00 2001 From: Martin Chase Date: Wed, 19 May 2021 17:21:12 -0700 Subject: [PATCH] Bypass makeARGB in some cases (#1786) * refactor out _ndarray_to_qimage() * combine levels back with lut * make use of Grayscale8, RGB888 and Indexed8 QImage formats Grayscale8 and RGB888 images are those that are ready for display without further processing. * add Grayscale16 * apply the efflut early for uint16 mono/rgb, uint8 rgb * ndarray indexing is faster than np.take * handle uint16 rgb(a) with no levels same as levels=[0, 65535] * add support for Format_RGBA64 * fix: support colormaps of shape (h, 1) * check ImageItem uint8 and uint16 QImage formats * uint16 mono with rgb lut -> RGBX8888 * got width and height swapped in array dimensions * set ImageItem as row-major * no need to form a 1d 32-bit lut for array indexing you can index (y, x) into a lookup table of shape (nentry, 3) or (nentry, 4) and get an output of shape (y, x, 3) or (y, x, 4) * Revert "no need to form a 1d 32-bit lut for array indexing" This reverts commit 45cf3100de637ed7e53ebb565fbb840ae1534255. * distinguish between levels_lut and colors_lut this allows uint16 images with user lut to be rendered as Format_Indexed8 * uint8 (1-chan) images should always combine to efflut this efflut will then be used for Indexed8 format color table. previously, we would be taking a performance hit with doing a numpy lookup with levels_lut. * adapt benchmarks/makeARGB.py to renderImageItem.py * restructure uint8 and uint16 codepaths * normalize 1-chan images to ndim==2 earlier up * refactor long code into functions * bug: qimage may not be assigned * fix: assign to self.qimage only if not None * for uint16, do rescale rather than do levels_lut lookup * cases 2,3 are already handled i.e. no more using lut to do rescale of uint16 image data. * rescale rgb images by computation, not by memory lookup * setImage() does not take an output argument * try to be cupy compatible use "xp" instead of numpy module * add numba to benchmarking * fix: lut_big is dtype uint8 with more than 256 entries * bug: applying colors_lut needs C-order * support float with no nans * fix: variable could be uninitialized * add float32 format tests * avoid explicitly forcing to C-contiguous * cache effective lut only if combination took place every one of the four branches now does its own return. this makes it easier to follow. * fix cupy benchmark : typo in renderQImage * remove for loop of 1 iteration * use float32 for floating point benchmark * superceded by renderImageItem.py * lint * benchmark without lut conversion * put the lut onto the substrate * fix editor complaints * handle lack of cupy * leading underscores imply privacy Co-authored-by: KIU Shueng Chuan --- benchmarks/makeARGB.py | 130 -------- benchmarks/renderImageItem.py | 139 ++++++++ pyqtgraph/functions.py | 69 ++-- pyqtgraph/graphicsItems/ImageItem.py | 296 ++++++++++++++++-- .../graphicsItems/tests/test_ImageItem.py | 28 ++ .../tests/test_ImageItemFormat.py | 168 ++++++++++ 6 files changed, 652 insertions(+), 178 deletions(-) delete mode 100644 benchmarks/makeARGB.py create mode 100644 benchmarks/renderImageItem.py create mode 100644 pyqtgraph/graphicsItems/tests/test_ImageItemFormat.py diff --git a/benchmarks/makeARGB.py b/benchmarks/makeARGB.py deleted file mode 100644 index 4696c48e..00000000 --- a/benchmarks/makeARGB.py +++ /dev/null @@ -1,130 +0,0 @@ -import numpy as np - -import pyqtgraph as pg -from pyqtgraph.functions import makeARGB - -try: - import cupy as cp - - pg.setConfigOption("useCupy", True) -except ImportError: - cp = None - - -class _TimeSuite(object): - def __init__(self): - super(_TimeSuite, self).__init__() - self.float_data = None - self.uint8_data = None - self.uint8_lut = None - self.uint16_data = None - self.uint16_lut = None - self.output = None - self.cupy_output = None - - def setup(self): - size = (self.size, self.size) - self.float_data, self.uint16_data, self.uint8_data, self.uint16_lut, self.uint8_lut = self._create_data( - size, np - ) - self.output = np.zeros(size + (4,), dtype=np.ubyte) - makeARGB(self.uint16_data["data"]) # prime the cpu - if cp: - self.cupy_output = cp.zeros(size + (4,), dtype=cp.ubyte) - makeARGB(cp.asarray(self.uint16_data["data"])) # prime the gpu - - @staticmethod - def _create_data(size, xp): - float_data = { - "data": xp.random.normal(size=size), - "levels": [-4.0, 4.0], - } - uint16_data = { - "data": xp.random.randint(100, 4500, size=size).astype("uint16"), - "levels": [250, 3000], - } - uint8_data = { - "data": xp.random.randint(0, 255, size=size).astype("ubyte"), - "levels": [20, 220], - } - c_map = xp.array([[-500.0, 255.0], [-255.0, 255.0], [0.0, 500.0]]) - uint8_lut = xp.zeros((256, 4), dtype="ubyte") - for i in range(3): - uint8_lut[:, i] = xp.clip(xp.linspace(c_map[i][0], c_map[i][1], 256), 0, 255) - uint8_lut[:, 3] = 255 - uint16_lut = xp.zeros((2 ** 16, 4), dtype="ubyte") - for i in range(3): - uint16_lut[:, i] = xp.clip(xp.linspace(c_map[i][0], c_map[i][1], 2 ** 16), 0, 255) - uint16_lut[:, 3] = 255 - return float_data, uint16_data, uint8_data, uint16_lut, uint8_lut - - -def make_test(dtype, use_cupy, use_levels, lut_name, func_name): - def time_test(self): - data = getattr(self, dtype + "_data") - levels = data["levels"] if use_levels else None - lut = getattr(self, lut_name + "_lut", None) if lut_name is not None else None - for _ in range(10): - img_data = data["data"] - output = self.output - if use_cupy: - img_data = cp.asarray(img_data) - output = self.cupy_output - makeARGB( - img_data, lut=lut, levels=levels, output=output, - ) - if use_cupy: - output.get(out=self.output) - - time_test.__name__ = func_name - return time_test - - -for cupy in [True, False]: - if cupy and cp is None: - continue - for dtype in ["float", "uint16", "uint8"]: - for levels in [True, False]: - if dtype == "float" and not levels: - continue - for lutname in [None, "uint8", "uint16"]: - name = ( - f'time_10x_makeARGB_{"cupy" if cupy else ""}{dtype}_{"" if levels else "no"}levels_{lutname or "no"}lut' - ) - setattr(_TimeSuite, name, make_test(dtype, cupy, levels, lutname, name)) - - -class Time0256Suite(_TimeSuite): - def __init__(self): - self.size = 256 - super(Time0256Suite, self).__init__() - - -class Time0512Suite(_TimeSuite): - def __init__(self): - self.size = 512 - super(Time0512Suite, self).__init__() - - -class Time1024Suite(_TimeSuite): - def __init__(self): - self.size = 1024 - super(Time1024Suite, self).__init__() - - -class Time2048Suite(_TimeSuite): - def __init__(self): - self.size = 2048 - super(Time2048Suite, self).__init__() - - -class Time3072Suite(_TimeSuite): - def __init__(self): - self.size = 3072 - super(Time3072Suite, self).__init__() - - -class Time4096Suite(_TimeSuite): - def __init__(self): - self.size = 4096 - super(Time4096Suite, self).__init__() diff --git a/benchmarks/renderImageItem.py b/benchmarks/renderImageItem.py new file mode 100644 index 00000000..3def037f --- /dev/null +++ b/benchmarks/renderImageItem.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +import numpy as np + +import pyqtgraph as pg + +try: + import cupy as cp + + pg.setConfigOption("useCupy", True) +except ImportError: + cp = None + +try: + import numba +except ImportError: + numba = None + + +def renderQImage(*args, **kwargs): + imgitem = pg.ImageItem(axisOrder='row-major') + if 'autoLevels' not in kwargs: + kwargs['autoLevels'] = False + imgitem.setImage(*args, **kwargs) + imgitem.render() + + +def prime_numba(): + shape = (64, 64) + lut_small = np.random.randint(256, size=(256, 3), dtype=np.uint8) + lut_big = np.random.randint(256, size=(512, 3), dtype=np.uint8) + for lut in [lut_small, lut_big]: + renderQImage(np.zeros(shape, dtype=np.uint8), levels=(20, 220), lut=lut) + renderQImage(np.zeros(shape, dtype=np.uint16), levels=(250, 3000), lut=lut) + renderQImage(np.zeros(shape, dtype=np.float32), levels=(-4.0, 4.0), lut=lut) + + +class _TimeSuite(object): + def __init__(self): + super(_TimeSuite, self).__init__() + self.size = None + self.float_data = None + self.uint8_data = None + self.uint8_lut = None + self.uint16_data = None + self.uint16_lut = None + self.cupy_uint16_lut = None + self.cupy_uint8_lut = None + + def setup(self): + size = (self.size, self.size) + self.float_data, self.uint16_data, self.uint8_data, self.uint16_lut, self.uint8_lut = self._create_data( + size, np + ) + if numba is not None: + # ensure JIT compilation + pg.setConfigOption("useNumba", True) + prime_numba() + pg.setConfigOption("useNumba", False) + if cp: + _d1, _d2, _d3, self.cupy_uint16_lut, self.cupy_uint8_lut = self._create_data(size, cp) + renderQImage(cp.asarray(self.uint16_data["data"])) # prime the gpu + + @property + def numba_uint16_lut(self): + return self.uint16_lut + + @property + def numba_uint8_lut(self): + return self.uint8_lut + + @property + def numpy_uint16_lut(self): + return self.uint16_lut + + @property + def numpy_uint8_lut(self): + return self.uint8_lut + + @staticmethod + def _create_data(size, xp): + float_data = { + "data": xp.random.normal(size=size).astype("float32"), + "levels": [-4.0, 4.0], + } + uint16_data = { + "data": xp.random.randint(100, 4500, size=size).astype("uint16"), + "levels": [250, 3000], + } + uint8_data = { + "data": xp.random.randint(0, 255, size=size).astype("ubyte"), + "levels": [20, 220], + } + c_map = xp.array([[-500.0, 255.0], [-255.0, 255.0], [0.0, 500.0]]) + uint8_lut = xp.zeros((256, 4), dtype="ubyte") + for i in range(3): + uint8_lut[:, i] = xp.clip(xp.linspace(c_map[i][0], c_map[i][1], 256), 0, 255) + uint8_lut[:, 3] = 255 + uint16_lut = xp.zeros((2 ** 16, 4), dtype="ubyte") + for i in range(3): + uint16_lut[:, i] = xp.clip(xp.linspace(c_map[i][0], c_map[i][1], 2 ** 16), 0, 255) + uint16_lut[:, 3] = 255 + return float_data, uint16_data, uint8_data, uint16_lut, uint8_lut + + +def make_test(dtype, kind, use_levels, lut_name, func_name): + def time_test(self): + data = getattr(self, dtype + "_data") + levels = data["levels"] if use_levels else None + lut = getattr(self, f"{kind}_{lut_name}_lut", None) if lut_name is not None else None + pg.setConfigOption("useNumba", kind == "numba") + img_data = data["data"] + if kind == "cupy": + img_data = cp.asarray(img_data) + renderQImage(img_data, lut=lut, levels=levels) + + time_test.__name__ = func_name + return time_test + + +for option in ["cupy", "numba", "numpy"]: + if option == "cupy" and cp is None: + continue + if option == "numba" and numba is None: + continue + for data_type in ["float", "uint16", "uint8"]: + for lvls in [True, False]: + if data_type == "float" and not lvls: + continue + for lutname in [None, "uint8", "uint16"]: + name = ( + f'time_1x_renderImageItem_{option}_{data_type}_{"" if lvls else "no"}levels_{lutname or "no"}lut' + ) + setattr(_TimeSuite, name, make_test(data_type, option, lvls, lutname, name)) + + +class Time4096Suite(_TimeSuite): + def __init__(self): + super(Time4096Suite, self).__init__() + self.size = 4096 diff --git a/pyqtgraph/functions.py b/pyqtgraph/functions.py index 21f00bec..6abfecce 100644 --- a/pyqtgraph/functions.py +++ b/pyqtgraph/functions.py @@ -1390,6 +1390,47 @@ def try_fastpath_argb(xp, ain, aout, useRGBA): return True +def ndarray_to_qimage(arr, fmt): + """ + Low level function to encapsulate QImage creation differences between bindings. + "arr" is assumed to be C-contiguous. + """ + + # C++ QImage has two kind of constructors + # - QImage(const uchar*, ...) + # - QImage(uchar*, ...) + # If the const constructor is used, subsequently calling any non-const method + # will trigger the COW mechanism, i.e. a copy is made under the hood. + + if QT_LIB.startswith('PyQt'): + if QtCore.PYQT_VERSION == 0x60000: + # PyQt5 -> const + # PyQt6 >= 6.0.1 -> const + # PyQt6 == 6.0.0 -> non-const + img_ptr = Qt.sip.voidptr(arr) + else: + # PyQt5 -> non-const + # PyQt6 >= 6.0.1 -> non-const + img_ptr = int(Qt.sip.voidptr(arr)) # or arr.ctypes.data + else: + # bindings that support ndarray + # PyQt5 -> const + # PyQt6 >= 6.0.1 -> const + # PySide2 -> non-const + # PySide6 -> non-const + img_ptr = arr + + h, w = arr.shape[:2] + bytesPerLine = arr.strides[0] + qimg = QtGui.QImage(img_ptr, w, h, bytesPerLine, fmt) + + # Note that the bindings that support ndarray directly already hold a reference + # to it. The manual reference below is only needed for those bindings that take + # in a raw pointer. + qimg.data = arr + return qimg + + def makeQImage(imgData, alpha=None, copy=True, transpose=True): """ Turn an ARGB array into QImage. @@ -1467,34 +1508,8 @@ def makeQImage(imgData, alpha=None, copy=True, transpose=True): profile("copy") - # C++ QImage has two kind of constructors - # - QImage(const uchar*, ...) - # - QImage(uchar*, ...) - # If the const constructor is used, subsequently calling any non-const method - # will trigger the COW mechanism, i.e. a copy is made under the hood. + return ndarray_to_qimage(imgData, imgFormat) - if QT_LIB.startswith('PyQt'): - if QtCore.PYQT_VERSION == 0x60000: - # PyQt5 -> const - # PyQt6 >= 6.0.1 -> const - # PyQt6 == 6.0.0 -> non-const - img_ptr = Qt.sip.voidptr(imgData) - else: - # PyQt5 -> non-const - # PyQt6 >= 6.0.1 -> non-const - img_ptr = int(Qt.sip.voidptr(imgData)) # or imgData.ctypes.data - else: - # bindings that support ndarray - # PyQt5 -> const - # PyQt6 >= 6.0.1 -> const - # PySide2 -> non-const - # PySide6 -> non-const - img_ptr = imgData - - img = QtGui.QImage(img_ptr, imgData.shape[1], imgData.shape[0], imgFormat) - - img.data = imgData - return img def imageToArray(img, copy=False, transpose=True): """ diff --git a/pyqtgraph/graphicsItems/ImageItem.py b/pyqtgraph/graphicsItems/ImageItem.py index 107334bf..83016cc8 100644 --- a/pyqtgraph/graphicsItems/ImageItem.py +++ b/pyqtgraph/graphicsItems/ImageItem.py @@ -159,11 +159,27 @@ class ImageItem(GraphicsObject): or :class:`GradientEditorItem `. """ if lut is not self.lut: + if self._xp is not None: + lut = self._ensure_proper_substrate(lut, self._xp) self.lut = lut self._effectiveLut = None if update: self.updateImage() + @staticmethod + def _ensure_proper_substrate(data, substrate): + if data is None or isinstance(data, Callable) or isinstance(data, substrate.ndarray): + return data + cupy = getCupy() + if substrate == cupy and not isinstance(data, cupy.ndarray): + data = cupy.asarray(data) + elif substrate == numpy: + if cupy is not None and isinstance(data, cupy.ndarray): + data = data.get() + else: + data = numpy.asarray(data) + return data + def setAutoDownsample(self, ads): """ Set the automatic downsampling mode for this ImageItem. @@ -393,6 +409,7 @@ class ImageItem(GraphicsObject): # Request a lookup table if this image has only one channel if self.image.ndim == 2 or self.image.shape[2] == 1: + self.lut = self._ensure_proper_substrate(self.lut, self._xp) if isinstance(self.lut, Callable): lut = self.lut(self.image) else: @@ -416,26 +433,6 @@ class ImageItem(GraphicsObject): else: image = self.image - # 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 lut 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 = self._xp.arange(eflsize) - minlev, maxlev = levels - levdiff = maxlev - minlev - levdiff = 1 if levdiff == 0 else levdiff # don't allow division by 0 - 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] - - self._effectiveLut = efflut - lut = self._effectiveLut - levels = None - # Convert single-channel image to 2D array if image.ndim == 3 and image.shape[-1] == 1: image = image[..., 0] @@ -443,7 +440,30 @@ class ImageItem(GraphicsObject): # Assume images are in column-major order for backward compatibility # (most images are in row-major order) if self.axisOrder == 'col-major': - image = image.transpose((1, 0, 2)[:image.ndim]) + image = image.swapaxes(0, 1) + + levels = self.levels + augmented_alpha = False + + if image.dtype.kind == 'f': + image, levels, lut, augmented_alpha = self._try_rescale_float(image, levels, lut) + # if we succeeded, we will have an uint8 image with levels None. + # lut if not None will have <= 256 entries + + # if the image data is a small int, then we can combine levels + lut + # into a single lut for better performance + elif image.dtype in (self._xp.ubyte, self._xp.uint16): + image, levels, lut, augmented_alpha = self._try_combine_lut(image, levels, lut) + + qimage = self._try_make_qimage(image, levels, lut, augmented_alpha) + + if qimage is not None: + self._processingBuffer = None + self._displayBuffer = None + self.qimage = qimage + self._renderRequired = False + self._unrenderable = False + return if self._processingBuffer is None or self._processingBuffer.shape[:2] != image.shape[:2]: self._buildQImageBuffer(image.shape) @@ -451,9 +471,243 @@ class ImageItem(GraphicsObject): fn.makeARGB(image, lut=lut, levels=levels, output=self._processingBuffer) if self._xp == getCupy(): self._processingBuffer.get(out=self._displayBuffer) + self._renderRequired = False self._unrenderable = False + def _try_rescale_float(self, image, levels, lut): + xp = self._xp + augmented_alpha = False + + can_handle = False + while True: + if levels is None or levels.ndim != 1: + # float images always need levels + # can't handle multi-channel levels + break + + # awkward, but fastest numpy native nan evaluation + if xp.isnan(image.min()): + # don't handle images with nans + # this should be an uncommon case + break + + can_handle = True + break + + if not can_handle: + return image, levels, lut, augmented_alpha + + # Decide on maximum scaled value + if lut is not None: + scale = lut.shape[0] + num_colors = lut.shape[0] + else: + scale = 255. + num_colors = 256 + dtype = xp.min_scalar_type(num_colors-1) + + minVal, maxVal = levels + if minVal == maxVal: + maxVal = xp.nextafter(maxVal, 2*maxVal) + rng = maxVal - minVal + rng = 1 if rng == 0 else rng + image = fn.rescaleData(image, scale/rng, offset=minVal, dtype=dtype, clip=(0, num_colors-1)) + + levels = None + + if image.dtype == xp.uint16 and image.ndim == 2: + image, augmented_alpha = self._apply_lut_for_uint16_mono(image, lut) + lut = None + + # image is now of type uint8 + return image, levels, lut, augmented_alpha + + def _try_combine_lut(self, image, levels, lut): + augmented_alpha = False + xp = self._xp + + can_handle = False + while True: + if levels is not None and levels.ndim != 1: + # can't handle multi-channel levels + break + if image.dtype == xp.uint16 and levels is None and \ + image.ndim == 3 and image.shape[2] == 3: + # uint16 rgb can't be directly displayed, so make it + # pass through effective lut processing + levels = [0, 65535] + if levels is None and lut is None: + # nothing to combine + break + + can_handle = True + break + + if not can_handle: + return image, levels, lut, augmented_alpha + + # distinguish between lut for levels and colors + levels_lut = None + colors_lut = lut + lut = None + + eflsize = 2**(image.itemsize*8) + if levels is None: + info = xp.iinfo(image.dtype) + minlev, maxlev = info.min, info.max + else: + minlev, maxlev = levels + levdiff = maxlev - minlev + levdiff = 1 if levdiff == 0 else levdiff # don't allow division by 0 + + if colors_lut is None: + if image.dtype == xp.ubyte and image.ndim == 2: + # uint8 mono image + ind = xp.arange(eflsize) + levels_lut = fn.rescaleData(ind, scale=255./levdiff, + offset=minlev, dtype=xp.ubyte) + # image data is not scaled. instead, levels_lut is used + # as (grayscale) Indexed8 ColorTable to get the same effect. + # due to the small size of the input to rescaleData(), we + # do not bother caching the result + return image, None, levels_lut, augmented_alpha + else: + # uint16 mono, uint8 rgb, uint16 rgb + # rescale image data by computation instead of by memory lookup + image = fn.rescaleData(image, scale=255./levdiff, + offset=minlev, dtype=xp.ubyte) + return image, None, colors_lut, augmented_alpha + else: + num_colors = colors_lut.shape[0] + effscale = num_colors / levdiff + lutdtype = xp.min_scalar_type(num_colors - 1) + + if image.dtype == xp.ubyte or lutdtype != xp.ubyte: + # combine if either: + # 1) uint8 mono image + # 2) colors_lut has more entries than will fit within 8-bits + if self._effectiveLut is None: + ind = xp.arange(eflsize) + levels_lut = fn.rescaleData(ind, scale=effscale, + offset=minlev, dtype=lutdtype, clip=(0, num_colors-1)) + efflut = colors_lut[levels_lut] + levels_lut = None + colors_lut = None + self._effectiveLut = efflut + efflut = self._effectiveLut + + # apply the effective lut early for the following types: + if image.dtype == xp.uint16 and image.ndim == 2: + image, augmented_alpha = self._apply_lut_for_uint16_mono(image, efflut) + efflut = None + return image, None, efflut, augmented_alpha + else: + # uint16 image with colors_lut <= 256 entries + # don't combine, we will use QImage ColorTable + image = fn.rescaleData(image, scale=effscale, + offset=minlev, dtype=lutdtype, clip=(0, num_colors-1)) + return image, None, colors_lut, augmented_alpha + + def _apply_lut_for_uint16_mono(self, image, lut): + # Note: compared to makeARGB(), we have already clipped the data to range + + xp = self._xp + augmented_alpha = False + + # if lut is 1d, then lut[image] is fastest + # if lut is 2d, then lut.take(image, axis=0) is faster than lut[image] + + if not image.flags.c_contiguous: + image = lut.take(image, axis=0) + + # if lut had dimensions (N, 1), then our resultant image would + # have dimensions (h, w, 1) + if image.ndim == 3 and image.shape[-1] == 1: + image = image[..., 0] + + return image, augmented_alpha + + # if we are contiguous, we can take a faster codepath where we + # ensure that the lut is 1d + + if lut.ndim == 2: + if lut.shape[1] == 3: # rgb + # convert rgb lut to rgba so that it is 32-bits + lut = xp.column_stack([lut, xp.full(lut.shape[0], 255, dtype=xp.uint8)]) + augmented_alpha = True + if lut.shape[1] == 4: # rgba + lut = lut.view(xp.uint32) + + image = lut.ravel()[image] + lut = None + # now both levels and lut are None + if image.dtype == xp.uint32: + image = image.view(xp.uint8).reshape(image.shape + (4,)) + + return image, augmented_alpha + + def _try_make_qimage(self, image, levels, lut, augmented_alpha): + xp = self._xp + + ubyte_nolvl = image.dtype == xp.ubyte and levels is None + is_passthru8 = ubyte_nolvl and lut is None + is_indexed8 = ubyte_nolvl and image.ndim == 2 and \ + lut is not None and lut.shape[0] <= 256 + is_passthru16 = image.dtype == xp.uint16 and levels is None and lut is None + can_grayscale16 = is_passthru16 and image.ndim == 2 and \ + hasattr(QtGui.QImage.Format, 'Format_Grayscale16') + is_rgba64 = is_passthru16 and image.ndim == 3 and image.shape[2] == 4 + + # bypass makeARGB for supported combinations + supported = is_passthru8 or is_indexed8 or can_grayscale16 or is_rgba64 + if not supported: + return None + + if self._xp == getCupy(): + image = image.get() + + # worthwhile supporting non-contiguous arrays + image = numpy.ascontiguousarray(image) + + fmt = None + ctbl = None + if is_passthru8: + # both levels and lut are None + # these images are suitable for display directly + if image.ndim == 2: + fmt = QtGui.QImage.Format.Format_Grayscale8 + elif image.shape[2] == 3: + fmt = QtGui.QImage.Format.Format_RGB888 + elif image.shape[2] == 4: + if augmented_alpha: + fmt = QtGui.QImage.Format.Format_RGBX8888 + else: + fmt = QtGui.QImage.Format.Format_RGBA8888 + elif is_indexed8: + # levels and/or lut --> lut-only + fmt = QtGui.QImage.Format.Format_Indexed8 + if lut.ndim == 1 or lut.shape[1] == 1: + ctbl = [QtGui.qRgb(x,x,x) for x in lut.ravel().tolist()] + elif lut.shape[1] == 3: + ctbl = [QtGui.qRgb(*rgb) for rgb in lut.tolist()] + elif lut.shape[1] == 4: + ctbl = [QtGui.qRgba(*rgba) for rgba in lut.tolist()] + elif can_grayscale16: + # single channel uint16 + # both levels and lut are None + fmt = QtGui.QImage.Format.Format_Grayscale16 + elif is_rgba64: + # uint16 rgba + # both levels and lut are None + fmt = QtGui.QImage.Format.Format_RGBA64 # endian-independent + if fmt is None: + raise ValueError("unsupported image type") + qimage = fn.ndarray_to_qimage(image, fmt) + if ctbl is not None: + qimage.setColorTable(ctbl) + return qimage + def paint(self, p, *args): profile = debug.Profiler() if self.image is None: diff --git a/pyqtgraph/graphicsItems/tests/test_ImageItem.py b/pyqtgraph/graphicsItems/tests/test_ImageItem.py index b69ec9c1..cc9b45f5 100644 --- a/pyqtgraph/graphicsItems/tests/test_ImageItem.py +++ b/pyqtgraph/graphicsItems/tests/test_ImageItem.py @@ -33,6 +33,34 @@ def test_useCupy_can_be_set_after_init(): pg.setConfigOption("useCupy", prev_setting) +@pytest.mark.skipif(cupy is None, reason="CuPy unavailable to test") +def test_ensuring_substrate(): + prev_setting = pg.getConfigOption("useCupy") + try: + pg.setConfigOption("useCupy", True) + ii = pg.ImageItem() + + data = cupy.random.randint(0, 255, size=(32, 32)).astype(cupy.uint8) + assert data is ii._ensure_proper_substrate(data, cupy) + assert isinstance(ii._ensure_proper_substrate(data, cupy), cupy.ndarray) + assert data is not ii._ensure_proper_substrate(data, np) + assert isinstance(ii._ensure_proper_substrate(data, np), np.ndarray) + + data = np.random.randint(0, 255, size=(32, 32)).astype(np.uint8) + assert data is ii._ensure_proper_substrate(data, np) + assert isinstance(ii._ensure_proper_substrate(data, np), np.ndarray) + assert data is not ii._ensure_proper_substrate(data, cupy) + assert isinstance(ii._ensure_proper_substrate(data, cupy), cupy.ndarray) + + data = range(0, 255) + assert data is not ii._ensure_proper_substrate(data, np) + assert isinstance(ii._ensure_proper_substrate(data, np), np.ndarray) + assert data is not ii._ensure_proper_substrate(data, cupy) + assert isinstance(ii._ensure_proper_substrate(data, cupy), cupy.ndarray) + finally: + pg.setConfigOption("useCupy", prev_setting) + + def test_ImageItem(transpose=False): w = pg.GraphicsLayoutWidget() w.show() diff --git a/pyqtgraph/graphicsItems/tests/test_ImageItemFormat.py b/pyqtgraph/graphicsItems/tests/test_ImageItemFormat.py new file mode 100644 index 00000000..3995cae6 --- /dev/null +++ b/pyqtgraph/graphicsItems/tests/test_ImageItemFormat.py @@ -0,0 +1,168 @@ +import numpy as np +import pyqtgraph as pg +from pyqtgraph.Qt import QtGui + + +def check_format(shape, dtype, levels, lut, expected_format): + data = np.zeros(shape, dtype=dtype) + item = pg.ImageItem(axisOrder='row-major') + item.setImage(data, autoLevels=False, lut=lut, levels=levels) + item.render() + assert item.qimage.format() == expected_format + + +def test_uint8(): + Format = QtGui.QImage.Format + dtype = np.uint8 + w, h = 192, 108 + lo, hi = 50, 200 + lut_none = None + lut_mono1 = np.random.randint(256, size=256, dtype=np.uint8) + lut_mono2 = np.random.randint(256, size=(256, 1), dtype=np.uint8) + lut_rgb = np.random.randint(256, size=(256, 3), dtype=np.uint8) + lut_rgba = np.random.randint(256, size=(256, 4), dtype=np.uint8) + + # lut with less than 256 entries + lut_mono1_s = np.random.randint(256, size=255, dtype=np.uint8) + lut_mono2_s = np.random.randint(256, size=(255, 1), dtype=np.uint8) + lut_rgb_s = np.random.randint(256, size=(255, 3), dtype=np.uint8) + lut_rgba_s = np.random.randint(256, size=(255, 4), dtype=np.uint8) + + # lut with more than 256 entries + lut_mono1_l = np.random.randint(256, size=257, dtype=np.uint8) + lut_mono2_l = np.random.randint(256, size=(257, 1), dtype=np.uint8) + lut_rgb_l = np.random.randint(256, size=(257, 3), dtype=np.uint8) + lut_rgba_l = np.random.randint(256, size=(257, 4), dtype=np.uint8) + + levels = None + check_format((h, w), dtype, levels, lut_none, Format.Format_Grayscale8) + check_format((h, w, 3), dtype, levels, lut_none, Format.Format_RGB888) + check_format((h, w, 4), dtype, levels, lut_none, Format.Format_RGBA8888) + + levels = [lo, hi] + check_format((h, w), dtype, levels, lut_none, Format.Format_Indexed8) + levels = None + check_format((h, w), dtype, levels, lut_mono1, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_mono2, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_rgb, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_rgba, Format.Format_Indexed8) + levels = [lo, hi] + check_format((h, w), dtype, levels, lut_mono1, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_mono2, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_rgb, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_rgba, Format.Format_Indexed8) + + check_format((h, w), dtype, levels, lut_mono1_s, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_mono2_s, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_rgb_s, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_rgba_s, Format.Format_Indexed8) + + check_format((h, w), dtype, levels, lut_mono1_l, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_mono2_l, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_rgb_l, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_rgba_l, Format.Format_Indexed8) + + levels = [lo, hi] + check_format((h, w, 3), dtype, levels, lut_none, Format.Format_RGB888) + + +def test_uint16(): + Format = QtGui.QImage.Format + dtype = np.uint16 + w, h = 192, 108 + lo, hi = 100, 10000 + lut_none = None + + lut_mono1 = np.random.randint(256, size=256, dtype=np.uint8) + lut_mono2 = np.random.randint(256, size=(256, 1), dtype=np.uint8) + lut_rgb = np.random.randint(256, size=(256, 3), dtype=np.uint8) + lut_rgba = np.random.randint(256, size=(256, 4), dtype=np.uint8) + + # lut with less than 256 entries + lut_mono1_s = np.random.randint(256, size=255, dtype=np.uint8) + lut_mono2_s = np.random.randint(256, size=(255, 1), dtype=np.uint8) + lut_rgb_s = np.random.randint(256, size=(255, 3), dtype=np.uint8) + lut_rgba_s = np.random.randint(256, size=(255, 4), dtype=np.uint8) + + # lut with more than 256 entries + lut_mono1_l = np.random.randint(256, size=257, dtype=np.uint8) + lut_mono2_l = np.random.randint(256, size=(257, 1), dtype=np.uint8) + lut_rgb_l = np.random.randint(256, size=(257, 3), dtype=np.uint8) + lut_rgba_l = np.random.randint(256, size=(257, 4), dtype=np.uint8) + + levels = None + try: + fmt_gray16 = Format.Format_Grayscale16 + except AttributeError: + fmt_gray16 = Format.Format_ARGB32 + check_format((h, w), dtype, levels, lut_none, fmt_gray16) + check_format((h, w, 3), dtype, levels, lut_none, Format.Format_RGB888) + check_format((h, w, 4), dtype, levels, lut_none, Format.Format_RGBA64) + + levels = [lo, hi] + check_format((h, w), dtype, levels, lut_none, Format.Format_Grayscale8) + levels = None + check_format((h, w), dtype, levels, lut_mono1, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_mono2, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_rgb, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_rgba, Format.Format_Indexed8) + + check_format((h, w), dtype, levels, lut_mono1_s, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_mono2_s, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_rgb_s, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_rgba_s, Format.Format_Indexed8) + + check_format((h, w), dtype, levels, lut_mono1_l, Format.Format_Grayscale8) + check_format((h, w), dtype, levels, lut_mono2_l, Format.Format_Grayscale8) + check_format((h, w), dtype, levels, lut_rgb_l, Format.Format_RGBX8888) + check_format((h, w), dtype, levels, lut_rgba_l, Format.Format_RGBA8888) + + levels = [lo, hi] + check_format((h, w, 3), dtype, levels, lut_none, Format.Format_RGB888) + + +def test_float32(): + Format = QtGui.QImage.Format + dtype = np.float32 + w, h = 192, 108 + lo, hi = -1, 1 + lut_none = None + + lut_mono1 = np.random.randint(256, size=256, dtype=np.uint8) + lut_mono2 = np.random.randint(256, size=(256, 1), dtype=np.uint8) + lut_rgb = np.random.randint(256, size=(256, 3), dtype=np.uint8) + lut_rgba = np.random.randint(256, size=(256, 4), dtype=np.uint8) + + # lut with less than 256 entries + lut_mono1_s = np.random.randint(256, size=255, dtype=np.uint8) + lut_mono2_s = np.random.randint(256, size=(255, 1), dtype=np.uint8) + lut_rgb_s = np.random.randint(256, size=(255, 3), dtype=np.uint8) + lut_rgba_s = np.random.randint(256, size=(255, 4), dtype=np.uint8) + + # lut with more than 256 entries + lut_mono1_l = np.random.randint(256, size=257, dtype=np.uint8) + lut_mono2_l = np.random.randint(256, size=(257, 1), dtype=np.uint8) + lut_rgb_l = np.random.randint(256, size=(257, 3), dtype=np.uint8) + lut_rgba_l = np.random.randint(256, size=(257, 4), dtype=np.uint8) + + levels = [lo, hi] + + check_format((h, w), dtype, levels, lut_none, Format.Format_Grayscale8) + check_format((h, w, 3), dtype, levels, lut_none, Format.Format_RGB888) + check_format((h, w, 4), dtype, levels, lut_none, Format.Format_RGBA8888) + + check_format((h, w), dtype, levels, lut_mono1, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_mono2, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_rgb, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_rgba, Format.Format_Indexed8) + + check_format((h, w), dtype, levels, lut_mono1_s, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_mono2_s, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_rgb_s, Format.Format_Indexed8) + check_format((h, w), dtype, levels, lut_rgba_s, Format.Format_Indexed8) + + check_format((h, w), dtype, levels, lut_mono1_l, Format.Format_Grayscale8) + check_format((h, w), dtype, levels, lut_mono2_l, Format.Format_Grayscale8) + check_format((h, w), dtype, levels, lut_rgb_l, Format.Format_RGBX8888) + check_format((h, w), dtype, levels, lut_rgba_l, Format.Format_RGBA8888) +