summaryrefslogtreecommitdiff
path: root/src/silx/gui/data/ArrayTableModel.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/silx/gui/data/ArrayTableModel.py')
-rw-r--r--src/silx/gui/data/ArrayTableModel.py650
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()