diff options
Diffstat (limited to 'src/silx/gui/data/ArrayTableModel.py')
-rw-r--r-- | src/silx/gui/data/ArrayTableModel.py | 650 |
1 files changed, 650 insertions, 0 deletions
diff --git a/src/silx/gui/data/ArrayTableModel.py b/src/silx/gui/data/ArrayTableModel.py new file mode 100644 index 0000000..23b0bb2 --- /dev/null +++ b/src/silx/gui/data/ArrayTableModel.py @@ -0,0 +1,650 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2021 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. +""" +from __future__ import division +import numpy +import logging +from silx.gui import qt +from silx.gui.data.TextFormatter import TextFormatter + +__authors__ = ["V.A. Sole"] +__license__ = "MIT" +__date__ = "27/09/2017" + + +_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 = 10e6 + """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: + 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() |