Skip to main content
`Ctrl`+`K`

# Source code for silx.gui.plot.items.scatter

    # /*##########################################################################
    #
    # Copyright (c) 2017-2024 European Synchrotron Radiation Facility
    #
    # Permission is hereby granted, free of charge, to any person obtaining a copy
    # of this software and associated documentation files (the "Software"), to deal
    # in the Software without restriction, including without limitation the rights
    # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    # copies of the Software, and to permit persons to whom the Software is
    # furnished to do so, subject to the following conditions:
    #
    # The above copyright notice and this permission notice shall be included in
    # all copies or substantial portions of the Software.
    #
    # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    # THE SOFTWARE.
    #
    # ###########################################################################*/
    """This module provides the :class:`Scatter` item of the :class:`Plot`.
    """

    __authors__ = ["T. Vincent", "P. Knobel"]
    __license__ = "MIT"
    __date__ = "29/03/2017"

    from collections import namedtuple
    import logging
    import threading
    import numpy
    from matplotlib.tri import LinearTriInterpolator, Triangulation

    from collections import defaultdict
    from concurrent.futures import ThreadPoolExecutor, CancelledError

    from ....utils.proxy import docstring
    from ....math.combo import min_max
    from ....math.histogram import Histogramnd
    from ....utils.weakref import WeakList
    from .core import PointsBase, ColormapMixIn, ScatterVisualizationMixIn
    from .axis import Axis
    from ._pick import PickingResult
    from silx._utils import NP_OPTIONAL_COPY

    _logger = logging.getLogger(__name__)

    class _GreedyThreadPoolExecutor(ThreadPoolExecutor):
        """:class:`ThreadPoolExecutor` with an extra :meth:`submit_greedy` method."""

        def __init__(self, *args, **kwargs):
            super(_GreedyThreadPoolExecutor, self).__init__(*args, **kwargs)
            self.__futures = defaultdict(WeakList)
            self.__lock = threading.RLock()

        def submit_greedy(self, queue, fn, *args, **kwargs):
            """Same as :meth:`submit` but cancel previous tasks in given queue.

            This means that when a new task is submitted for a given queue,
            all other pending tasks of that queue are cancelled.

            :param queue: Identifier of the queue. This must be hashable.
            :param callable fn: The callable to call with provided extra arguments
            :return: Future corresponding to this task
            :rtype: concurrent.futures.Future
            """
            with self.__lock:
                # Cancel previous tasks in given queue
                for future in self.__futures.pop(queue, []):
                    if not future.done():
                        future.cancel()

                future = super(_GreedyThreadPoolExecutor, self).submit(fn, *args, **kwargs)
                self.__futures[queue].append(future)

            return future

    # Functions to guess grid shape from coordinates

    def _get_z_line_length(array):
        """Return length of line if array is a Z-like 2D regular grid.

        :param numpy.ndarray array: The 1D array of coordinates to check
        :return: 0 if no line length could be found,
            else the number of element per line.
        :rtype: int
        """
        sign = numpy.sign(numpy.diff(array))
        if len(sign) == 0 or sign[0] == 0:  # We don't handle that
            return 0
        # Check this way to account for 0 sign (i.e., diff == 0)
        beginnings = numpy.where(sign == -sign[0])[0] + 1
        if len(beginnings) == 0:
            return 0
        length = beginnings[0]
        if numpy.all(numpy.equal(numpy.diff(beginnings), length)):
            return length
        return 0

    def _guess_z_grid_shape(x, y):
        """Guess the shape of a grid from (x, y) coordinates.

        The grid might contain more elements than x and y,
        as the last line might be partly filled.

        :param numpy.ndarray x:
        :paran numpy.ndarray y:
        :returns: (order, (height, width)) of the regular grid,
            or None if could not guess one.
            'order' is 'row' if X (i.e., column) is the fast dimension, else 'column'.
        :rtype: Union[List(str,int),None]
        """
        width = _get_z_line_length(x)
        if width != 0:
            return "row", (int(numpy.ceil(len(x) / width)), width)
        else:
            height = _get_z_line_length(y)
            if height != 0:
                return "column", (height, int(numpy.ceil(len(y) / height)))
        return None

    def is_monotonic(array):
        """Returns whether array is monotonic (increasing or decreasing).

        :param numpy.ndarray array: 1D array-like container.
        :returns: 1 if array is monotonically increasing,
           -1 if array is monotonically decreasing,
           0 if array is not monotonic
        :rtype: int
        """
        diff = numpy.diff(numpy.ravel(array))
        with numpy.errstate(invalid="ignore"):
            if numpy.all(diff >= 0):
                return 1
            elif numpy.all(diff <= 0):
                return -1
            else:
                return 0

    def _guess_grid(x, y):
        """Guess a regular grid from the points.

        Result convention is (x, y)

        :param numpy.ndarray x: X coordinates of the points
        :param numpy.ndarray y: Y coordinates of the points
        :returns: (order, (height, width)
            order is 'row' or 'column'
        :rtype: Union[List[str,List[int]],None]
        """
        x, y = numpy.ravel(x), numpy.ravel(y)

        guess = _guess_z_grid_shape(x, y)
        if guess is not None:
            return guess

        else:
            # Cannot guess a regular grid
            # Let's assume it's a single line
            order = "row"  # or 'column' doesn't matter for a single line
            y_monotonic = is_monotonic(y)
            if is_monotonic(x) or y_monotonic:  # we can guess a line
                x_min, x_max = min_max(x)
                y_min, y_max = min_max(y)

                if not y_monotonic or x_max - x_min >= y_max - y_min:
                    # x only is monotonic or both are and X varies more
                    # line along X
                    shape = 1, len(x)
                else:
                    # y only is monotonic or both are and Y varies more
                    # line along Y
                    shape = len(y), 1

            else:  # Cannot guess a line from the points
                return None

        return order, shape

    def _quadrilateral_grid_coords(points):
        """Compute an irregular grid of quadrilaterals from a set of points

        The input points are expected to lie on a grid.

        :param numpy.ndarray points:
           3D data set of 2D input coordinates (height, width, 2)
           height and width must be at least 2.
        :return: 3D dataset of 2D coordinates of the grid (height+1, width+1, 2)
        """
        assert points.ndim == 3
        assert points.shape[0] >= 2
        assert points.shape[1] >= 2
        assert points.shape[2] == 2

        dim0, dim1 = points.shape[:2]
        grid_points = numpy.zeros((dim0 + 1, dim1 + 1, 2), dtype=numpy.float64)

        # Compute inner points as mean of 4 neighbours
        neighbour_view = numpy.lib.stride_tricks.as_strided(
            points,
            shape=(dim0 - 1, dim1 - 1, 2, 2, points.shape[2]),
            strides=points.strides[:2] + points.strides[:2] + points.strides[-1:],
            writeable=False,
        )
        inner_points = numpy.mean(neighbour_view, axis=(2, 3))
        grid_points[1:-1, 1:-1] = inner_points

        # Compute 'vertical' sides
        # Alternative: grid_points[1:-1, [0, -1]] = points[:-1, [0, -1]] + points[1:, [0, -1]] - inner_points[:, [0, -1]]
        grid_points[1:-1, [0, -1], 0] = (
            points[:-1, [0, -1], 0] + points[1:, [0, -1], 0] - inner_points[:, [0, -1], 0]
        )
        grid_points[1:-1, [0, -1], 1] = inner_points[:, [0, -1], 1]

        # Compute 'horizontal' sides
        grid_points[[0, -1], 1:-1, 0] = inner_points[[0, -1], :, 0]
        grid_points[[0, -1], 1:-1, 1] = (
            points[[0, -1], :-1, 1] + points[[0, -1], 1:, 1] - inner_points[[0, -1], :, 1]
        )

        # Compute corners
        d0, d1 = [0, 0, -1, -1], [0, -1, -1, 0]
        grid_points[d0, d1] = 2 * points[d0, d1] - inner_points[d0, d1]
        return grid_points

    def _quadrilateral_grid_as_triangles(points):
        """Returns the points and indices to make a grid of quadrilaterals

        :param numpy.ndarray points:
            3D array of points (height, width, 2)
        :return: triangle corners (4 * N, 2), triangle indices (2 * N, 3)
            With N = height * width, the number of input points
        """
        nbpoints = numpy.prod(points.shape[:2])

        grid = _quadrilateral_grid_coords(points)
        coords = numpy.empty((4 * nbpoints, 2), dtype=grid.dtype)
        coords[::4] = grid[:-1, :-1].reshape(-1, 2)
        coords[1::4] = grid[1:, :-1].reshape(-1, 2)
        coords[2::4] = grid[:-1, 1:].reshape(-1, 2)
        coords[3::4] = grid[1:, 1:].reshape(-1, 2)

        indices = numpy.empty((2 * nbpoints, 3), dtype=numpy.uint32)
        indices[::2, 0] = numpy.arange(0, 4 * nbpoints, 4)
        indices[::2, 1] = numpy.arange(1, 4 * nbpoints, 4)
        indices[::2, 2] = numpy.arange(2, 4 * nbpoints, 4)
        indices[1::2, 0] = indices[::2, 1]
        indices[1::2, 1] = indices[::2, 2]
        indices[1::2, 2] = numpy.arange(3, 4 * nbpoints, 4)

        return coords, indices

    _RegularGridInfo = namedtuple(
        "_RegularGridInfo", ["bounds", "origin", "scale", "shape", "order"]
    )

    _HistogramInfo = namedtuple(
        "_HistogramInfo", ["mean", "count", "sum", "origin", "scale", "shape"]
    )

    class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
        """Description of a scatter"""

        _DEFAULT_SELECTABLE = True
        """Default selectable state for scatter plots"""

        _SUPPORTED_SCATTER_VISUALIZATION = (
            ScatterVisualizationMixIn.Visualization.POINTS,
            ScatterVisualizationMixIn.Visualization.SOLID,
            ScatterVisualizationMixIn.Visualization.REGULAR_GRID,
            ScatterVisualizationMixIn.Visualization.IRREGULAR_GRID,
            ScatterVisualizationMixIn.Visualization.BINNED_STATISTIC,
        )
        """Overrides supported Visualizations"""

        def __init__(self):
            PointsBase.__init__(self)
            ColormapMixIn.__init__(self)
            ScatterVisualizationMixIn.__init__(self)
            self._value = ()
            self.__alpha = None
            # Cache Delaunay triangulation future object
            self.__triangulationFuture = None
            # Cache interpolator future object
            self.__interpolatorFuture = None
            self.__executor = None

            # Cache triangles: x, y, indices
            self.__cacheTriangles = None, None, None

            # Cache regular grid and histogram info
            self.__cacheRegularGridInfo = None
            self.__cacheHistogramInfo = None

        def _updateColormappedData(self):
            """Update the colormapped data, to be called when changed"""
            if self.getVisualization() is self.Visualization.BINNED_STATISTIC:
                histoInfo = self.__getHistogramInfo()
                if histoInfo is None:
                    data = None
                else:
                    data = getattr(
                        histoInfo,
                        self.getVisualizationParameter(
                            self.VisualizationParameter.BINNED_STATISTIC_FUNCTION
                        ),
                    )
            else:
                data = self.getValueData(copy=False)
            self._setColormappedData(data, copy=False)

        @docstring(ScatterVisualizationMixIn)
        def setVisualization(self, mode):
            previous = self.getVisualization()
            if super().setVisualization(mode):
                if bool(mode is self.Visualization.BINNED_STATISTIC) ^ bool(
                    previous is self.Visualization.BINNED_STATISTIC
                ):
                    self._updateColormappedData()
                return True
            else:
                return False

        @docstring(ScatterVisualizationMixIn)
        def setVisualizationParameter(self, parameter, value):
            parameter = self.VisualizationParameter(parameter)

            if super(Scatter, self).setVisualizationParameter(parameter, value):
                if parameter in (
                    self.VisualizationParameter.GRID_BOUNDS,
                    self.VisualizationParameter.GRID_MAJOR_ORDER,
                    self.VisualizationParameter.GRID_SHAPE,
                ):
                    self.__cacheRegularGridInfo = None

                if parameter in (
                    self.VisualizationParameter.BINNED_STATISTIC_SHAPE,
                    self.VisualizationParameter.BINNED_STATISTIC_FUNCTION,
                    self.VisualizationParameter.DATA_BOUNDS_HINT,
                ):
                    if parameter in (
                        self.VisualizationParameter.BINNED_STATISTIC_SHAPE,
                        self.VisualizationParameter.DATA_BOUNDS_HINT,
                    ):
                        self.__cacheHistogramInfo = None  # Clean-up cache
                    if self.getVisualization() is self.Visualization.BINNED_STATISTIC:
                        self._updateColormappedData()
                return True
            else:
                return False

        @docstring(ScatterVisualizationMixIn)
        def getCurrentVisualizationParameter(self, parameter):
            value = self.getVisualizationParameter(parameter)
            if (
                parameter is self.VisualizationParameter.DATA_BOUNDS_HINT
                or value is not None
            ):
                return value  # Value has been set, return it

            elif parameter is self.VisualizationParameter.GRID_BOUNDS:
                grid = self.__getRegularGridInfo()
                return None if grid is None else grid.bounds

            elif parameter is self.VisualizationParameter.GRID_MAJOR_ORDER:
                grid = self.__getRegularGridInfo()
                return None if grid is None else grid.order

            elif parameter is self.VisualizationParameter.GRID_SHAPE:
                grid = self.__getRegularGridInfo()
                return None if grid is None else grid.shape

            elif parameter is self.VisualizationParameter.BINNED_STATISTIC_SHAPE:
                info = self.__getHistogramInfo()
                return None if info is None else info.shape

            else:
                raise NotImplementedError()

        def __getRegularGridInfo(self):
            """Get grid info"""
            if self.__cacheRegularGridInfo is None:
                shape = self.getVisualizationParameter(
                    self.VisualizationParameter.GRID_SHAPE
                )
                order = self.getVisualizationParameter(
                    self.VisualizationParameter.GRID_MAJOR_ORDER
                )
                if shape is None or order is None:
                    guess = _guess_grid(
                        self.getXData(copy=False), self.getYData(copy=False)
                    )
                    if guess is None:
                        _logger.warning(
                            "Cannot guess a grid: Cannot display as regular grid image"
                        )
                        return None
                    if shape is None:
                        shape = guess[1]
                    if order is None:
                        order = guess[0]

                nbpoints = len(self.getXData(copy=False))
                if nbpoints > shape[0] * shape[1]:
                    # More data points that provided grid shape: enlarge grid
                    _logger.warning(
                        "More data points than provided grid shape size: extends grid"
                    )
                    dim0, dim1 = shape
                    if order == "row":  # keep dim1, enlarge dim0
                        dim0 = nbpoints // dim1 + (1 if nbpoints % dim1 else 0)
                    else:  # keep dim0, enlarge dim1
                        dim1 = nbpoints // dim0 + (1 if nbpoints % dim0 else 0)
                    shape = dim0, dim1

                bounds = self.getVisualizationParameter(
                    self.VisualizationParameter.GRID_BOUNDS
                )
                if bounds is None:
                    x, y = self.getXData(copy=False), self.getYData(copy=False)
                    min_, max_ = min_max(x)
                    xRange = (min_, max_) if (x[0] - min_) < (max_ - x[0]) else (max_, min_)
                    min_, max_ = min_max(y)
                    yRange = (min_, max_) if (y[0] - min_) < (max_ - y[0]) else (max_, min_)
                    bounds = (xRange[0], yRange[0]), (xRange[1], yRange[1])

                begin, end = bounds
                scale = (
                    (end[0] - begin[0]) / max(1, shape[1] - 1),
                    (end[1] - begin[1]) / max(1, shape[0] - 1),
                )
                if scale[0] == 0 and scale[1] == 0:
                    scale = 1.0, 1.0
                elif scale[0] == 0:
                    scale = scale[1], scale[1]
                elif scale[1] == 0:
                    scale = scale[0], scale[0]

                origin = begin[0] - 0.5 * scale[0], begin[1] - 0.5 * scale[1]

                self.__cacheRegularGridInfo = _RegularGridInfo(
                    bounds=bounds, origin=origin, scale=scale, shape=shape, order=order
                )

            return self.__cacheRegularGridInfo

        def __getHistogramInfo(self):
            """Get histogram info"""
            if self.__cacheHistogramInfo is None:
                shape = self.getVisualizationParameter(
                    self.VisualizationParameter.BINNED_STATISTIC_SHAPE
                )
                if shape is None:
                    shape = 100, 100  # TODO compute auto shape

                x, y, values = self.getData(copy=False)[:3]
                if len(x) == 0:  # No histogram
                    return None

                if not numpy.issubdtype(x.dtype, numpy.floating):
                    x = x.astype(numpy.float64)
                if not numpy.issubdtype(y.dtype, numpy.floating):
                    y = y.astype(numpy.float64)
                if not numpy.issubdtype(values.dtype, numpy.floating):
                    values = values.astype(numpy.float64)

                ranges = (tuple(min_max(y, finite=True)), tuple(min_max(x, finite=True)))
                rangesHint = self.getVisualizationParameter(
                    self.VisualizationParameter.DATA_BOUNDS_HINT
                )
                if rangesHint is not None:
                    ranges = tuple(
                        (min(dataMin, hintMin), max(dataMax, hintMax))
                        for (dataMin, dataMax), (hintMin, hintMax) in zip(
                            ranges, rangesHint
                        )
                    )

                points = numpy.transpose(numpy.array((y, x)))
                counts, sums, bin_edges = Histogramnd(
                    points, histo_range=ranges, n_bins=shape, weights=values
                )
                yEdges, xEdges = bin_edges
                origin = xEdges[0], yEdges[0]
                scale = (
                    (xEdges[-1] - xEdges[0]) / (len(xEdges) - 1),
                    (yEdges[-1] - yEdges[0]) / (len(yEdges) - 1),
                )

                with numpy.errstate(divide="ignore", invalid="ignore"):
                    histo = sums / counts

                self.__cacheHistogramInfo = _HistogramInfo(
                    mean=histo,
                    count=counts,
                    sum=sums,
                    origin=origin,
                    scale=scale,
                    shape=shape,
                )

            return self.__cacheHistogramInfo

        def __applyColormapToData(self):
            """Compute colors by applying colormap to values.

            :returns: Array of RGBA colors
            """
            cmap = self.getColormap()
            rgbacolors = cmap.applyToData(self)

            if self.__alpha is not None:
                rgbacolors[:, -1] = (rgbacolors[:, -1] * self.__alpha).astype(numpy.uint8)
            return rgbacolors

        def _addBackendRenderer(self, backend):
            """Update backend renderer"""
            # Filter-out values <= 0
            xFiltered, yFiltered, valueFiltered, xerror, yerror = self.getData(
                copy=False, displayed=True
            )

            # Remove not finite numbers (this includes filtered out x, y <= 0)
            mask = numpy.logical_and(numpy.isfinite(xFiltered), numpy.isfinite(yFiltered))
            xFiltered = xFiltered[mask]
            yFiltered = yFiltered[mask]

            if len(xFiltered) == 0:
                return None  # No data to display, do not add renderer to backend

            visualization = self.getVisualization()

            if visualization is self.Visualization.BINNED_STATISTIC:
                plot = self.getPlot()
                if (
                    plot is None
                    or plot.getXAxis().getScale() != Axis.LINEAR
                    or plot.getYAxis().getScale() != Axis.LINEAR
                ):
                    # Those visualizations are not available with log scaled axes
                    return None

                histoInfo = self.__getHistogramInfo()
                if histoInfo is None:
                    return None
                data = getattr(
                    histoInfo,
                    self.getVisualizationParameter(
                        self.VisualizationParameter.BINNED_STATISTIC_FUNCTION
                    ),
                )

                return backend.addImage(
                    data=data,
                    origin=histoInfo.origin,
                    scale=histoInfo.scale,
                    colormap=self.getColormap(),
                    alpha=self.getAlpha(),
                )

            elif visualization is self.Visualization.POINTS:
                rgbacolors = self.__applyColormapToData()
                return backend.addCurve(
                    xFiltered,
                    yFiltered,
                    color=rgbacolors[mask],
                    gapcolor=None,
                    symbol=self.getSymbol(),
                    linewidth=0,
                    linestyle="",
                    yaxis="left",
                    xerror=xerror,
                    yerror=yerror,
                    fill=False,
                    alpha=self.getAlpha(),
                    symbolsize=self.getSymbolSize(),
                    baseline=None,
                )

            else:
                plot = self.getPlot()
                if (
                    plot is None
                    or plot.getXAxis().getScale() != Axis.LINEAR
                    or plot.getYAxis().getScale() != Axis.LINEAR
                ):
                    # Those visualizations are not available with log scaled axes
                    return None

                if visualization is self.Visualization.SOLID:
                    try:
                        triangulation = self._getTriangulationFuture().result()
                    except (RuntimeError, ValueError):
                        _logger.warning(
                            "Cannot get a triangulation: Cannot display as solid surface"
                        )
                        return None
                    else:
                        rgbacolors = self.__applyColormapToData()
                        triangles = triangulation.triangles.astype(numpy.int32)
                        return backend.addTriangles(
                            xFiltered,
                            yFiltered,
                            triangles,
                            color=rgbacolors[mask],
                            alpha=self.getAlpha(),
                        )

                elif visualization is self.Visualization.REGULAR_GRID:
                    gridInfo = self.__getRegularGridInfo()
                    if gridInfo is None:
                        return None

                    dim0, dim1 = gridInfo.shape
                    if gridInfo.order == "column":  # transposition needed
                        dim0, dim1 = dim1, dim0

                    values = self.getValueData(copy=False)
                    if self.__alpha is None and len(values) == dim0 * dim1:
                        image = values.reshape(dim0, dim1)
                    else:
                        # The points do not fill the whole image
                        if self.__alpha is None and numpy.issubdtype(
                            values.dtype, numpy.floating
                        ):
                            image = numpy.empty(dim0 * dim1, dtype=values.dtype)
                            image[: len(values)] = values
                            image[len(values) :] = float("nan")  # Transparent pixels
                            image.shape = dim0, dim1
                        else:  # Per value alpha or no NaN, so convert to RGBA
                            rgbacolors = self.__applyColormapToData()
                            image = numpy.empty((dim0 * dim1, 4), dtype=numpy.uint8)
                            image[: len(rgbacolors)] = rgbacolors
                            image[len(rgbacolors) :] = (0, 0, 0, 0)  # Transparent pixels
                            image.shape = dim0, dim1, 4

                    if gridInfo.order == "column":
                        if image.ndim == 2:
                            image = numpy.transpose(image)
                        else:
                            image = numpy.transpose(image, axes=(1, 0, 2))

                    if image.ndim == 2:
                        colormap = self.getColormap()
                        if colormap.isAutoscale():
                            # Avoid backend to compute autoscale: use item cache
                            colormap = colormap.copy()
                            colormap.setVRange(*colormap.getColormapRange(self))
                    else:
                        colormap = None

                    return backend.addImage(
                        data=image,
                        origin=gridInfo.origin,
                        scale=gridInfo.scale,
                        colormap=colormap,
                        alpha=self.getAlpha(),
                    )

                elif visualization is self.Visualization.IRREGULAR_GRID:
                    gridInfo = self.__getRegularGridInfo()
                    if gridInfo is None:
                        return None

                    shape = gridInfo.shape
                    if shape is None:  # No shape, no display
                        return None

                    rgbacolors = self.__applyColormapToData()

                    nbpoints = len(xFiltered)
                    if nbpoints == 1:
                        # single point, render as a square points
                        return backend.addCurve(
                            xFiltered,
                            yFiltered,
                            color=rgbacolors[mask],
                            gapcolor=None,
                            symbol="s",
                            linewidth=0,
                            linestyle="",
                            yaxis="left",
                            xerror=None,
                            yerror=None,
                            fill=False,
                            alpha=self.getAlpha(),
                            symbolsize=7,
                            baseline=None,
                        )

                    # Make shape include all points
                    gridOrder = gridInfo.order
                    if nbpoints != numpy.prod(shape):
                        if gridOrder == "row":
                            shape = int(numpy.ceil(nbpoints / shape[1])), shape[1]
                        else:  # column-major order
                            shape = shape[0], int(numpy.ceil(nbpoints / shape[0]))

                    if shape[0] < 2 or shape[1] < 2:  # Single line, at least 2 points
                        points = numpy.zeros((2, nbpoints, 3), dtype=numpy.float64)
                        # Use row/column major depending on shape, not on info value
                        gridOrder = "row" if shape[0] == 1 else "column"

                        if gridOrder == "row":
                            points[0, :, 0] = xFiltered
                            points[0, :, 1] = yFiltered
                        else:  # column-major order
                            points[0, :, 0] = yFiltered
                            points[0, :, 1] = xFiltered

                        # Add a second line that will be clipped in the end
                        points[1, :-1] = (
                            points[0, :-1]
                            + numpy.cross(points[0, 1:] - points[0, :-1], (0.0, 0.0, 1.0))
                        )
                        points[1, -1] = (
                            points[0, -1]
                            + numpy.cross(points[0, -1] - points[0, -2], (0.0, 0.0, 1.0))
                        )

                        points = points[:, :, :2]

                    elif gridOrder == "row":  # row-major order
                        if nbpoints != numpy.prod(shape):
                            points = numpy.empty(
                                (numpy.prod(shape), 2), dtype=numpy.float64
                            )
                            points[:nbpoints, 0] = xFiltered
                            points[:nbpoints, 1] = yFiltered
                            # Index of last element of last fully filled row
                            index = (nbpoints // shape[1]) * shape[1]
                            points[nbpoints:, 0] = xFiltered[
                                index - (numpy.prod(shape) - nbpoints) : index
                            ]
                            points[nbpoints:, 1] = yFiltered[-1]
                        else:
                            points = numpy.transpose((xFiltered, yFiltered))
                        points.shape = shape[0], shape[1], 2

                    else:  # column-major order
                        if nbpoints != numpy.prod(shape):
                            points = numpy.empty(
                                (numpy.prod(shape), 2), dtype=numpy.float64
                            )
                            points[:nbpoints, 0] = yFiltered
                            points[:nbpoints, 1] = xFiltered
                            # Index of last element of last fully filled column
                            index = (nbpoints // shape[0]) * shape[0]
                            points[nbpoints:, 0] = yFiltered[
                                index - (numpy.prod(shape) - nbpoints) : index
                            ]
                            points[nbpoints:, 1] = xFiltered[-1]
                        else:
                            points = numpy.transpose((yFiltered, xFiltered))
                        points.shape = shape[1], shape[0], 2

                    coords, indices = _quadrilateral_grid_as_triangles(points)

                    # Remove unused extra triangles
                    coords = coords[: 4 * nbpoints]
                    indices = indices[: 2 * nbpoints]

                    if gridOrder == "row":
                        x, y = coords[:, 0], coords[:, 1]
                    else:  # column-major order
                        y, x = coords[:, 0], coords[:, 1]

                    rgbacolors = rgbacolors[mask]  # Filter-out not finite points
                    gridcolors = numpy.empty(
                        (4 * nbpoints, rgbacolors.shape[-1]), dtype=rgbacolors.dtype
                    )
                    for first in range(4):
                        gridcolors[first::4] = rgbacolors[:nbpoints]

                    return backend.addTriangles(
                        x, y, indices, color=gridcolors, alpha=self.getAlpha()
                    )

                else:
                    _logger.error("Unhandled visualization %s", visualization)
                    return None

        @docstring(PointsBase)
        def pick(self, x, y):
            result = super(Scatter, self).pick(x, y)

            if result is not None:
                visualization = self.getVisualization()

                if visualization is self.Visualization.IRREGULAR_GRID:
                    # Specific handling of picking for the irregular grid mode
                    index = result.getIndices(copy=False)[0] // 4
                    result = PickingResult(self, (index,))

                elif visualization is self.Visualization.REGULAR_GRID:
                    # Specific handling of picking for the regular grid mode
                    picked = result.getIndices(copy=False)
                    if picked is None:
                        return None
                    row, column = picked[0][0], picked[1][0]

                    gridInfo = self.__getRegularGridInfo()
                    if gridInfo is None:
                        return None

                    if gridInfo.order == "row":
                        index = row * gridInfo.shape[1] + column
                    else:
                        index = row + column * gridInfo.shape[0]
                    if index >= len(
                        self.getXData(copy=False)
                    ):  # OK as long as not log scale
                        return None  # Image can be larger than scatter

                    result = PickingResult(self, (index,))

                elif visualization is self.Visualization.BINNED_STATISTIC:
                    picked = result.getIndices(copy=False)
                    if picked is None or len(picked) == 0 or len(picked[0]) == 0:
                        return None
                    row, col = picked[0][0], picked[1][0]
                    histoInfo = self.__getHistogramInfo()
                    if histoInfo is None:
                        return None
                    sx, sy = histoInfo.scale
                    ox, oy = histoInfo.origin
                    xdata = self.getXData(copy=False)
                    ydata = self.getYData(copy=False)
                    indices = numpy.nonzero(
                        numpy.logical_and(
                            numpy.logical_and(
                                xdata >= ox + sx * col, xdata < ox + sx * (col + 1)
                            ),
                            numpy.logical_and(
                                ydata >= oy + sy * row, ydata < oy + sy * (row + 1)
                            ),
                        )
                    )[0]
                    result = None if len(indices) == 0 else PickingResult(self, indices)

            return result

        def __getExecutor(self):
            """Returns async greedy executor

            :rtype: _GreedyThreadPoolExecutor
            """
            if self.__executor is None:
                self.__executor = _GreedyThreadPoolExecutor(max_workers=2)
            return self.__executor

        def _getTriangulationFuture(self):
            """Returns a :class:`Future` which result is the Triangulation object.

            :rtype: concurrent.futures.Future
            """
            if self.__triangulationFuture is None or self.__triangulationFuture.cancelled():
                # Need to init a new delaunay
                x, y = self.getData(copy=False)[:2]
                # Remove not finite points
                mask = numpy.logical_and(numpy.isfinite(x), numpy.isfinite(y))

                self.__triangulationFuture = self.__getExecutor().submit_greedy(
                    "Triangulation", Triangulation, x[mask], y[mask]
                )

            return self.__triangulationFuture

        @staticmethod
        def __initInterpolator(triangulationFuture, values):
            """Returns an interpolator for the given data points

            :param concurrent.futures.Future triangulationFuture:
                Future object which result is a Triangulation object
            :param numpy.ndarray values: The data value of valid points.
            :rtype: Union[callable,None]
            """
            # Wait for Triangulation to complete
            try:
                triangulation = triangulationFuture.result()
            except (RuntimeError, ValueError):
                return None  # triangulation failed
            except CancelledError:
                return None

            return LinearTriInterpolator(triangulation, values)

        def _getInterpolatorFuture(self):
            """Returns a :class:`Future` which result is the interpolator.

            The interpolator is a callable taking an array Nx2 of points
            as a single argument.
            The :class:`Future` result is None in case the interpolator cannot
            be initialized.

            :rtype: concurrent.futures.Future
            """
            if self.__interpolatorFuture is None or self.__interpolatorFuture.cancelled():
                # Need to init a new interpolator
                x, y, values = self.getData(copy=False)[:3]
                # Remove not finite points
                mask = numpy.logical_and(numpy.isfinite(x), numpy.isfinite(y))
                x, y, values = x[mask], y[mask], values[mask]

                self.__interpolatorFuture = self.__getExecutor().submit_greedy(
                    "interpolator",
                    self.__initInterpolator,
                    self._getTriangulationFuture(),
                    values,
                )
            return self.__interpolatorFuture

        def _filterData(self, xPositive: bool, yPositive: bool):
            """Filter out errors<0 and values with x or y <= 0 on log axes

            :param bool xPositive: True to filter arrays according to X coords.
            :param bool yPositive: True to filter arrays according to Y coords.
            :return: The filtered arrays or unchanged object if not filtering needed
            :rtype: (x, y, value, xerror, yerror)
            """
            # overloaded from PointsBase to filter also value.
            value = self.getValueData(copy=False)

            if xPositive or yPositive:
                clipped = self._getClippingBoolArray(xPositive, yPositive)

                if numpy.any(clipped):
                    # copy to keep original array and convert to float
                    value = numpy.array(value, copy=True, dtype=numpy.float64)
                    value[clipped] = numpy.nan

            x, y, xerror, yerror = PointsBase._filterData(self, xPositive, yPositive)

            return x, y, value, xerror, yerror

        def getValueData(self, copy=True):
            """Returns the value assigned to the scatter data points.

            :param copy: True (Default) to get a copy,
                         False to use internal representation (do not modify!)
            :rtype: numpy.ndarray
            """
            return numpy.array(self._value, copy=copy or NP_OPTIONAL_COPY)

        def getAlphaData(self, copy=True):
            """Returns the alpha (transparency) assigned to the scatter data points.

            :param copy: True (Default) to get a copy,
                         False to use internal representation (do not modify!)
            :rtype: numpy.ndarray
            """
            return numpy.array(self.__alpha, copy=copy or NP_OPTIONAL_COPY)

        def getData(self, copy=True, displayed=False):
            """Returns the x, y coordinates and the value of the data points

            :param copy: True (Default) to get a copy,
                         False to use internal representation (do not modify!)
            :param bool displayed: True to only get curve points that are displayed
                                   in the plot. Default: False.
                                   Note: If plot has log scale, negative points
                                   are not displayed.
            :returns: (x, y, value, xerror, yerror)
            :rtype: 5-tuple of numpy.ndarray
            """
            if displayed:
                data = self._getCachedData()
                if data is not None:
                    assert len(data) == 5
                    return data

            return (
                self.getXData(copy),
                self.getYData(copy),
                self.getValueData(copy),
                self.getXErrorData(copy),
                self.getYErrorData(copy),
            )

        # reimplemented from PointsBase to handle `value`

        def setData(self, x, y, value, xerror=None, yerror=None, alpha=None, copy=True):
            """Set the data of the scatter.

            :param numpy.ndarray x: The data corresponding to the x coordinates.
            :param numpy.ndarray y: The data corresponding to the y coordinates.
            :param numpy.ndarray value: The data corresponding to the value of
                                        the data points.
            :param xerror: Values with the uncertainties on the x values
            :type xerror: A float, or a numpy.ndarray of float32.
                          If it is an array, it can either be a 1D array of
                          same length as the data or a 2D array with 2 rows
                          of same length as the data: row 0 for lower errors,
                          row 1 for upper errors.
            :param yerror: Values with the uncertainties on the y values
            :type yerror: A float, or a numpy.ndarray of float32. See xerror.
            :param alpha: Values with the transparency (between 0 and 1)
            :type alpha: A float, or a numpy.ndarray of float32
            :param bool copy: True make a copy of the data (default),
                              False to use provided arrays.
            """
            value = numpy.array(value, copy=copy or NP_OPTIONAL_COPY)
            assert value.ndim == 1
            assert len(x) == len(value)

            # Convert complex data
            if numpy.iscomplexobj(value):
                _logger.warning("Converting value data to absolute value to plot it.")
                value = numpy.absolute(value)

            # Reset triangulation and interpolator
            if self.__triangulationFuture is not None:
                self.__triangulationFuture.cancel()
                self.__triangulationFuture = None
            if self.__interpolatorFuture is not None:
                self.__interpolatorFuture.cancel()
                self.__interpolatorFuture = None

            # Data changed, this needs update
            self.__cacheRegularGridInfo = None
            self.__cacheHistogramInfo = None

            self._value = value

            if alpha is not None:
                # Make sure alpha is an array of float in [0, 1]
                alpha = numpy.array(alpha, copy=copy or NP_OPTIONAL_COPY)
                assert alpha.ndim == 1
                assert len(x) == len(alpha)
                if alpha.dtype.kind != "f":
                    alpha = alpha.astype(numpy.float32)
                if numpy.any(numpy.logical_or(alpha < 0.0, alpha > 1.0)):
                    alpha = numpy.clip(alpha, 0.0, 1.0)
            self.__alpha = alpha

            # set x, y, xerror, yerror

            # call self._updated + plot._invalidateDataRange()
            PointsBase.setData(self, x, y, xerror, yerror, copy)

            self._updateColormappedData()