Skip to main content
`Ctrl`+`K`

# Source code for silx.gui.data.ArrayTableModel

    # /*##########################################################################
    #
    # Copyright (c) 2016-2022 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 defines a data model for displaying and editing arrays of any
    number of dimensions in a table view.
    """
    import numpy
    import logging
    from silx.gui import qt
    from silx.gui.data.TextFormatter import TextFormatter

    __authors__ = ["V.A. Sole"]
    __license__ = "MIT"
    __date__ = "18/01/2022"

    _logger = logging.getLogger(__name__)

    def _is_array(data):
        """Return True if object implements all necessary attributes to be used
        as a numpy array.

        :param object data: Array-like object (numpy array, h5py dataset...)
        :return: boolean
        """
        # add more required attribute if necessary
        for attr in ("shape", "dtype"):
            if not hasattr(data, attr):
                return False
        return True

    class ArrayTableModel(qt.QAbstractTableModel):
        """This data model provides access to 2D slices in a N-dimensional
        array.

        A slice for a 3-D array is characterized by a perspective (the number of
        the axis orthogonal to the slice) and an index at which the slice
        intersects the orthogonal axis.

        In the n-D case, only slices parallel to the last two axes are handled. A
        slice is therefore characterized by a list of indices locating the
        slice on all the :math:`n - 2` orthogonal axes.

        :param parent: Parent QObject
        :param data: Numpy array, or object implementing a similar interface
            (e.g. h5py dataset)
        :param str fmt: Format string for representing numerical values.
            Default is ``"%g"``.
        :param sequence[int] perspective: See documentation
            of :meth:`setPerspective`.
        """

        MAX_NUMBER_OF_SECTIONS = 10000000
        """Maximum number of displayed rows and columns"""

        def __init__(self, parent=None, data=None, perspective=None):
            qt.QAbstractTableModel.__init__(self, parent)

            self._array = None
            """n-dimensional numpy array"""

            self._bgcolors = None
            """(n+1)-dimensional numpy array containing RGB(A) color data
            for the background color
            """

            self._fgcolors = None
            """(n+1)-dimensional numpy array containing RGB(A) color data
            for the foreground color
            """

            self._formatter = None
            """Formatter for text representation of data"""

            formatter = TextFormatter(self)
            formatter.setUseQuoteForText(False)
            self.setFormatter(formatter)

            self._index = None
            """This attribute stores the slice index, as a list of indices
            where the frame intersects orthogonal axis."""

            self._perspective = None
            """Sequence of dimensions orthogonal to the frame to be viewed.
            For an array with ``n`` dimensions, this is a sequence of ``n-2``
            integers. the first dimension is numbered ``0``.
            By default, the data frames use the last two dimensions as their axes
            and therefore the perspective is a sequence of the first ``n-2``
            dimensions.
            For example, for a 5-D array, the default perspective is ``(0, 1, 2)``
            and the default frames axes are ``(3, 4)``."""

            # set _data and _perspective
            self.setArrayData(data, perspective=perspective)

        def _getRowDim(self):
            """The row axis is the first axis parallel to the frames
            (lowest dimension number)

            Return None for 0-D (scalar) or 1-D arrays
            """
            n_dimensions = len(self._array.shape)
            if n_dimensions < 2:
                # scalar or 1D array: no row index
                return None
            # take all dimensions and remove the orthogonal ones
            frame_axes = set(range(0, n_dimensions)) - set(self._perspective)
            # sanity check
            assert len(frame_axes) == 2
            return min(frame_axes)

        def _getColumnDim(self):
            """The column axis is the second (highest dimension) axis parallel
            to the frames

            Return None for 0-D (scalar)
            """
            n_dimensions = len(self._array.shape)
            if n_dimensions < 1:
                # scalar: no column index
                return None
            frame_axes = set(range(0, n_dimensions)) - set(self._perspective)
            # sanity check
            assert (len(frame_axes) == 2) if n_dimensions > 1 else (len(frame_axes) == 1)
            return max(frame_axes)

        def _getIndexTuple(self, table_row, table_col):
            """Return the n-dimensional index of a value in the original array,
            based on its row and column indices in the table view

            :param table_row: Row index (0-based) of a table cell
            :param table_col: Column index (0-based) of a table cell
            :return: Tuple of indices of the element in the numpy array
            """
            row_dim = self._getRowDim()
            col_dim = self._getColumnDim()

            # get indices on all orthogonal axes
            selection = list(self._index)
            # insert indices on parallel axes
            if row_dim is not None:
                selection.insert(row_dim, table_row)
            if col_dim is not None:
                selection.insert(col_dim, table_col)
            return tuple(selection)

        # Methods to be implemented to subclass QAbstractTableModel
        def rowCount(self, parent_idx=None):
            """QAbstractTableModel method
            Return number of rows to be displayed in table"""
            row_dim = self._getRowDim()
            if row_dim is None:
                # 0-D and 1-D arrays
                return 1
            return min(self._array.shape[row_dim], self.MAX_NUMBER_OF_SECTIONS)

        def columnCount(self, parent_idx=None):
            """QAbstractTableModel method
            Return number of columns to be displayed in table"""
            col_dim = self._getColumnDim()
            if col_dim is None:
                # 0-D array
                return 1
            return min(self._array.shape[col_dim], self.MAX_NUMBER_OF_SECTIONS)

        def __isClipped(self, orientation=qt.Qt.Vertical) -> bool:
            """Returns whether or not array is clipped in a given orientation"""
            if orientation == qt.Qt.Vertical:
                dim = self._getRowDim()
            else:
                dim = self._getColumnDim()
            return dim is not None and self._array.shape[dim] > self.MAX_NUMBER_OF_SECTIONS

        def __isClippedIndex(self, index) -> bool:
            """Returns whether or not index's cell represents clipped data."""
            if not index.isValid():
                return False
            if index.row() == self.MAX_NUMBER_OF_SECTIONS - 2:
                return self.__isClipped(qt.Qt.Vertical)
            if index.column() == self.MAX_NUMBER_OF_SECTIONS - 2:
                return self.__isClipped(qt.Qt.Horizontal)
            return False

        def __clippedData(self, role=qt.Qt.DisplayRole):
            """Return data for cells representing clipped data"""
            if role == qt.Qt.DisplayRole:
                return "..."
            elif role == qt.Qt.ToolTipRole:
                return "Dataset is too large: display is clipped"
            else:
                return None

        def data(self, index, role=qt.Qt.DisplayRole):
            """QAbstractTableModel method to access data values
            in the format ready to be displayed"""
            if index.isValid():
                if self.__isClippedIndex(index):  # Special displayed for clipped data
                    return self.__clippedData(role)

                row, column = index.row(), index.column()

                # When clipped, display last data of the array in last column of the table
                if (
                    self.__isClipped(qt.Qt.Vertical)
                    and row == self.MAX_NUMBER_OF_SECTIONS - 1
                ):
                    row = self._array.shape[self._getRowDim()] - 1
                if (
                    self.__isClipped(qt.Qt.Horizontal)
                    and column == self.MAX_NUMBER_OF_SECTIONS - 1
                ):
                    column = self._array.shape[self._getColumnDim()] - 1

                selection = self._getIndexTuple(row, column)

                if role == qt.Qt.DisplayRole or role == qt.Qt.EditRole:
                    return self._formatter.toString(
                        self._array[selection], self._array.dtype
                    )

                if role == qt.Qt.BackgroundRole and self._bgcolors is not None:
                    r, g, b = self._bgcolors[selection][0:3]
                    if self._bgcolors.shape[-1] == 3:
                        return qt.QColor(r, g, b)
                    if self._bgcolors.shape[-1] == 4:
                        a = self._bgcolors[selection][3]
                        return qt.QColor(r, g, b, a)

                if role == qt.Qt.ForegroundRole:
                    if self._fgcolors is not None:
                        r, g, b = self._fgcolors[selection][0:3]
                        if self._fgcolors.shape[-1] == 3:
                            return qt.QColor(r, g, b)
                        if self._fgcolors.shape[-1] == 4:
                            a = self._fgcolors[selection][3]
                            return qt.QColor(r, g, b, a)

                    # no fg color given, use black or white
                    # based on luminosity threshold
                    elif self._bgcolors is not None:
                        r, g, b = self._bgcolors[selection][0:3]
                        lum = 0.21 * r + 0.72 * g + 0.07 * b
                        if lum < 128:
                            return qt.QColor(qt.Qt.white)
                        else:
                            return qt.QColor(qt.Qt.black)

        def headerData(self, section, orientation, role=qt.Qt.DisplayRole):
            """QAbstractTableModel method
            Return the 0-based row or column index, for display in the
            horizontal and vertical headers"""
            if self.__isClipped(orientation):  # Header is clipped
                if section == self.MAX_NUMBER_OF_SECTIONS - 2:
                    # Represent clipped data
                    return self.__clippedData(role)

                elif section == self.MAX_NUMBER_OF_SECTIONS - 1:
                    # Display last index from data not table
                    if role == qt.Qt.DisplayRole:
                        if orientation == qt.Qt.Vertical:
                            dim = self._getRowDim()
                        else:
                            dim = self._getColumnDim()
                        return str(self._array.shape[dim] - 1)
                    else:
                        return None

            if role == qt.Qt.DisplayRole:
                return "%d" % section
            return None

        def flags(self, index):
            """QAbstractTableModel method to inform the view whether data
            is editable or not."""
            if not self._editable or self.__isClippedIndex(index):
                return qt.QAbstractTableModel.flags(self, index)
            return qt.QAbstractTableModel.flags(self, index) | qt.Qt.ItemIsEditable

        def setData(self, index, value, role=None):
            """QAbstractTableModel method to handle editing data.
            Cast the new value into the same format as the array before editing
            the array value."""
            if index.isValid() and role == qt.Qt.EditRole:
                try:
                    # cast value to same type as array
                    v = numpy.array(value, dtype=self._array.dtype).item()
                except ValueError:
                    return False

                selection = self._getIndexTuple(index.row(), index.column())
                self._array[selection] = v
                self.dataChanged.emit(index, index)
                return True
            else:
                return False

        # Public methods
        def setArrayData(self, data, copy=True, perspective=None, editable=False):
            """Set the data array and the viewing perspective.

            You can set ``copy=False`` if you need more performances, when dealing
            with a large numpy array. In this case, a simple reference to the data
            is used to access the data, rather than a copy of the array.

            .. warning::

                Any change to the data model will affect your original data
                array, when using a reference rather than a copy..

            :param data: n-dimensional numpy array, or any object that can be
                converted to a numpy array using ``numpy.array(data)`` (e.g.
                a nested sequence).
            :param bool copy: If *True* (default), a copy of the array is stored
                and the original array is not modified if the table is edited.
                If *False*, then the behavior depends on the data type:
                if possible (if the original array is a proper numpy array)
                a reference to the original array is used.
            :param perspective: See documentation of :meth:`setPerspective`.
                If None, the default perspective is the list of the first ``n-2``
                dimensions, to view frames parallel to the last two axes.
            :param bool editable: Flag to enable editing data. Default *False*.
            """
            self.beginResetModel()

            if data is None:
                # empty array
                self._array = numpy.array([])
            elif copy:
                # copy requested (default)
                self._array = numpy.array(data, copy=True)
                if hasattr(data, "dtype"):
                    # Avoid to lose the monkey-patched h5py dtype
                    self._array.dtype = data.dtype
            elif not _is_array(data):
                raise TypeError(
                    "data is not a proper array. Try setting"
                    + " copy=True to convert it into a numpy array"
                    + " (this will cause the data to be copied!)"
                )
                # # copy not requested, but necessary
                # _logger.warning(
                #         "data is not an array-like object. " +
                #         "Data must be copied.")
                # self._array = numpy.array(data, copy=True)
            else:
                # Copy explicitly disabled & data implements required attributes.
                # We can use a reference.
                self._array = data

            # reset colors to None if new data shape is inconsistent
            valid_color_shapes = (self._array.shape + (3,), self._array.shape + (4,))
            if self._bgcolors is not None:
                if self._bgcolors.shape not in valid_color_shapes:
                    self._bgcolors = None
            if self._fgcolors is not None:
                if self._fgcolors.shape not in valid_color_shapes:
                    self._fgcolors = None

            self.setEditable(editable)

            self._index = [0 for _i in range((len(self._array.shape) - 2))]
            self._perspective = (
                tuple(perspective)
                if perspective is not None
                else tuple(range(0, len(self._array.shape) - 2))
            )

            self.endResetModel()

        def setArrayColors(self, bgcolors=None, fgcolors=None):
            """Set the colors for all table cells by passing an array
            of RGB or RGBA values (integers between 0 and 255).

            The shape of the colors array must be consistent with the data shape.

            If the data array is n-dimensional, the colors array must be
            (n+1)-dimensional, with the first n-dimensions identical to the data
            array dimensions, and the last dimension length-3 (RGB) or
            length-4 (RGBA).

            :param bgcolors: RGB or RGBA colors array, defining the background color
                for each cell in the table.
            :param fgcolors: RGB or RGBA colors array, defining the foreground color
                (text color) for each cell in the table.
            """
            # array must be RGB or RGBA
            valid_shapes = (self._array.shape + (3,), self._array.shape + (4,))
            errmsg = "Inconsistent shape for color array, should be %s or %s" % valid_shapes

            if bgcolors is not None:
                if not _is_array(bgcolors):
                    bgcolors = numpy.array(bgcolors)
                assert bgcolors.shape in valid_shapes, errmsg

            self._bgcolors = bgcolors

            if fgcolors is not None:
                if not _is_array(fgcolors):
                    fgcolors = numpy.array(fgcolors)
                assert fgcolors.shape in valid_shapes, errmsg

            self._fgcolors = fgcolors

        def setEditable(self, editable):
            """Set flags to make the data editable.

            .. warning::

                If the data is a reference to a h5py dataset open in read-only
                mode, setting *editable=True* will fail and print a warning.

            .. warning::

                Making the data editable means that the underlying data structure
                in this data model will be modified.
                If the data is a reference to a public object (open with
                ``copy=False``), this could have side effects. If it is a
                reference to an HDF5 dataset, this means the file will be
                modified.

            :param bool editable: Flag to enable editing data.
            :return: True if setting desired flag succeeded, False if it failed.
            """
            self._editable = editable
            if hasattr(self._array, "file"):
                if hasattr(self._array.file, "mode"):
                    if editable and self._array.file.mode == "r":
                        _logger.warning(
                            "Data is a HDF5 dataset open in read-only "
                            + "mode. Editing must be disabled."
                        )
                        self._editable = False
                        return False
            return True

        def getData(self, copy=True):
            """Return a copy of the data array, or a reference to it
            if *copy=False* is passed as parameter.

            In case the shape was modified, to convert 0-D or 1-D data
            into 2-D data, the original shape is restored in the returned data.

            :param bool copy: If *True* (default), return a copy of the data. If
                *False*, return a reference.
            :return: numpy array of data, or reference to original data object
                if *copy=False*
            """
            data = self._array if not copy else numpy.array(self._array, copy=True)
            return data

        def setFrameIndex(self, index):
            """Set the active slice index.

            This method is only relevant to arrays with at least 3 dimensions.

            :param index: Index of the active slice in the array.
                In the general n-D case, this is a sequence of :math:`n - 2`
                indices where the slice intersects the respective orthogonal axes.
            :raise IndexError: If any index in the index sequence is out of bound
                on its respective axis.
            """
            shape = self._array.shape
            if len(shape) < 3:
                # index is ignored
                return

            self.beginResetModel()

            if len(shape) == 3:
                len_ = shape[self._perspective[0]]
                # accept integers as index in the case of 3-D arrays
                if not hasattr(index, "__len__"):
                    self._index = [index]
                else:
                    self._index = index
                if not 0 <= self._index[0] < len_:
                    raise ValueError(
                        "Index must be a positive integer " + "lower than %d" % len_
                    )
            else:
                # general n-D case
                for i_, idx in enumerate(index):
                    if not 0 <= idx < shape[self._perspective[i_]]:
                        raise IndexError(
                            "Invalid index %d " % idx
                            + "not in range 0-%d" % (shape[i_] - 1)
                        )
                self._index = index

            self.endResetModel()

        def setFormatter(self, formatter):
            """Set the formatter object to be used to display data from the model

            :param TextFormatter formatter: Formatter to use
            """
            if formatter is self._formatter:
                return

            self.beginResetModel()

            if self._formatter is not None:
                self._formatter.formatChanged.disconnect(self.__formatChanged)

            self._formatter = formatter
            if self._formatter is not None:
                self._formatter.formatChanged.connect(self.__formatChanged)

            self.endResetModel()

        def getFormatter(self):
            """Returns the text formatter used.

            :rtype: TextFormatter
            """
            return self._formatter

        def __formatChanged(self):
            """Called when the format changed."""
            self.reset()

        def setPerspective(self, perspective):
            """Set the perspective by defining a sequence listing all axes
            orthogonal to the frame or 2-D slice to be visualized.

            Alternatively, you can use :meth:`setFrameAxes` for the complementary
            approach of specifying the two axes parallel  to the frame.

            In the 1-D or 2-D case, this parameter is irrelevant.

            In the 3-D case, if the unit vectors describing
            your axes are :math:`\vec{x}, \vec{y}, \vec{z}`, a perspective of 0
            means you slices are parallel to :math:`\vec{y}\vec{z}`, 1 means they
            are parallel to :math:`\vec{x}\vec{z}` and 2 means they
            are parallel to :math:`\vec{x}\vec{y}`.

            In the n-D case, this parameter is a sequence of :math:`n-2` axes
            numbers.
            For instance if you want to display 2-D frames whose axes are the
            second and third dimensions of a 5-D array, set the perspective to
            ``(0, 3, 4)``.

            :param perspective: Sequence of dimensions/axes orthogonal to the
                frames.
            :raise: IndexError if any value in perspective is higher than the
                number of dimensions minus one (first dimension is 0), or
                if the number of values is different from the number of dimensions
                minus two.
            """
            n_dimensions = len(self._array.shape)
            if n_dimensions < 3:
                _logger.warning("perspective is not relevant for 1D and 2D arrays")
                return

            if not hasattr(perspective, "__len__"):
                # we can tolerate an integer for 3-D array
                if n_dimensions == 3:
                    perspective = [perspective]
                else:
                    raise ValueError("perspective must be a sequence of integers")

            # ensure unicity of dimensions in perspective
            perspective = tuple(set(perspective))

            if (
                len(perspective) != n_dimensions - 2
                or min(perspective) < 0
                or max(perspective) >= n_dimensions
            ):
                raise IndexError(
                    "Invalid perspective "
                    + str(perspective)
                    + " for %d-D array " % n_dimensions
                    + "with shape "
                    + str(self._array.shape)
                )

            self.beginResetModel()

            self._perspective = perspective

            # reset index
            self._index = [0 for _i in range(n_dimensions - 2)]

            self.endResetModel()

        def setFrameAxes(self, row_axis, col_axis):
            """Set the perspective by specifying the two axes parallel to the frame
            to be visualised.

            The complementary approach of defining the orthogonal axes can be used
            with :meth:`setPerspective`.

            :param int row_axis: Index (0-based) of the first dimension used as a frame
                axis
            :param int col_axis: Index (0-based) of the 2nd dimension used as a frame
                axis
            :raise: IndexError if axes are invalid
            """
            if row_axis > col_axis:
                _logger.warning(
                    "The dimension of the row axis must be lower "
                    + "than the dimension of the column axis. Swapping."
                )
                row_axis, col_axis = min(row_axis, col_axis), max(row_axis, col_axis)

            n_dimensions = len(self._array.shape)
            if n_dimensions < 3:
                _logger.warning("Frame axes cannot be changed for 1D and 2D arrays")
                return

            perspective = tuple(set(range(0, n_dimensions)) - {row_axis, col_axis})

            if (
                len(perspective) != n_dimensions - 2
                or min(perspective) < 0
                or max(perspective) >= n_dimensions
            ):
                raise IndexError(
                    "Invalid perspective "
                    + str(perspective)
                    + " for %d-D array " % n_dimensions
                    + "with shape "
                    + str(self._array.shape)
                )

            self.beginResetModel()

            self._perspective = perspective
            # reset index
            self._index = [0 for _i in range(n_dimensions - 2)]

            self.endResetModel()

    if __name__ == "__main__":
        app = qt.QApplication([])
        w = qt.QTableView()
        d = numpy.random.normal(0, 1, (5, 1000, 1000))
        for i in range(5):
            d[i, :, :] += i * 10
        m = ArrayTableModel(data=d)
        w.setModel(m)
        m.setFrameIndex(3)
        # m.setArrayData(numpy.ones((100,)))
        w.show()
        app.exec()