diff options
Diffstat (limited to 'silx/gui/data')
-rw-r--r-- | silx/gui/data/ArrayTableModel.py | 610 | ||||
-rw-r--r-- | silx/gui/data/ArrayTableWidget.py | 490 | ||||
-rw-r--r-- | silx/gui/data/DataViewer.py | 464 | ||||
-rw-r--r-- | silx/gui/data/DataViewerFrame.py | 186 | ||||
-rw-r--r-- | silx/gui/data/DataViewerSelector.py | 153 | ||||
-rw-r--r-- | silx/gui/data/DataViews.py | 988 | ||||
-rw-r--r-- | silx/gui/data/Hdf5TableView.py | 414 | ||||
-rw-r--r-- | silx/gui/data/NXdataWidgets.py | 523 | ||||
-rw-r--r-- | silx/gui/data/NumpyAxesSelector.py | 468 | ||||
-rw-r--r-- | silx/gui/data/RecordTableView.py | 405 | ||||
-rw-r--r-- | silx/gui/data/TextFormatter.py | 222 | ||||
-rw-r--r-- | silx/gui/data/__init__.py | 35 | ||||
-rw-r--r-- | silx/gui/data/setup.py | 41 | ||||
-rw-r--r-- | silx/gui/data/test/__init__.py | 45 | ||||
-rw-r--r-- | silx/gui/data/test/test_arraywidget.py | 320 | ||||
-rw-r--r-- | silx/gui/data/test/test_dataviewer.py | 281 | ||||
-rw-r--r-- | silx/gui/data/test/test_numpyaxesselector.py | 152 | ||||
-rw-r--r-- | silx/gui/data/test/test_textformatter.py | 94 |
18 files changed, 5891 insertions, 0 deletions
diff --git a/silx/gui/data/ArrayTableModel.py b/silx/gui/data/ArrayTableModel.py new file mode 100644 index 0000000..87a2fc1 --- /dev/null +++ b/silx/gui/data/ArrayTableModel.py @@ -0,0 +1,610 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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__ = "24/01/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`. + """ + 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 self._array.shape[row_dim] + + 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 self._array.shape[col_dim] + + def data(self, index, role=qt.Qt.DisplayRole): + """QAbstractTableModel method to access data values + in the format ready to be displayed""" + if index.isValid(): + selection = self._getIndexTuple(index.row(), + index.column()) + if role == qt.Qt.DisplayRole: + return self._formatter.toString(self._array[selection]) + + 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 role == qt.Qt.DisplayRole: + if orientation == qt.Qt.Vertical: + return "%d" % section + if orientation == qt.Qt.Horizontal: + 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: + 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.asscalar( + numpy.array(value, dtype=self._array.dtype)) + 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*. + """ + if qt.qVersion() > "4.6": + self.beginResetModel() + else: + self.reset() + + if data is None: + # empty array + self._array = numpy.array([]) + elif copy: + # copy requested (default) + self._array = numpy.array(data, copy=True) + 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)) + + if qt.qVersion() > "4.6": + 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 + + if qt.qVersion() > "4.6": + self.beginResetModel() + else: + self.reset() + + 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 + + if qt.qVersion() > "4.6": + 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 + + if qt.qVersion() > "4.6": + 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) + + if qt.qVersion() > "4.6": + self.endResetModel() + else: + self.reset() + + 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)) + + if qt.qVersion() > "4.6": + self.beginResetModel() + else: + self.reset() + + self._perspective = perspective + + # reset index + self._index = [0 for _i in range(n_dimensions - 2)] + + if qt.qVersion() > "4.6": + 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)) + + if qt.qVersion() > "4.6": + self.beginResetModel() + else: + self.reset() + + self._perspective = perspective + # reset index + self._index = [0 for _i in range(n_dimensions - 2)] + + if qt.qVersion() > "4.6": + 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_() diff --git a/silx/gui/data/ArrayTableWidget.py b/silx/gui/data/ArrayTableWidget.py new file mode 100644 index 0000000..ba3fa11 --- /dev/null +++ b/silx/gui/data/ArrayTableWidget.py @@ -0,0 +1,490 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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 widget designed to display data arrays with any +number of dimensions as 2D frames (images, slices) in a table view. +The dimensions not displayed in the table can be browsed using improved +sliders. + +The widget uses a TableView that relies on a custom abstract item +model: :class:`silx.gui.data.ArrayTableModel`. +""" +from __future__ import division +import sys + +from silx.gui import qt +from silx.gui.widgets.TableWidget import TableView +from .ArrayTableModel import ArrayTableModel +from silx.gui.widgets.FrameBrowser import HorizontalSliderWithBrowser + +__authors__ = ["V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "24/01/2017" + + +class AxesSelector(qt.QWidget): + """Widget with two combo-boxes to select two dimensions among + all possible dimensions of an n-dimensional array. + + The first combobox contains values from :math:`0` to :math:`n-2`. + + The choices in the 2nd CB depend on the value selected in the first one. + If the value selected in the first CB is :math:`m`, the second one lets you + select values from :math:`m+1` to :math:`n-1`. + + The two axes can be used to select the row axis and the column axis t + display a slice of the array data in a table view. + """ + sigDimensionsChanged = qt.Signal(int, int) + """Signal emitted whenever one of the comboboxes is changed. + The signal carries the two selected dimensions.""" + + def __init__(self, parent=None, n=None): + qt.QWidget.__init__(self, parent) + self.layout = qt.QHBoxLayout(self) + self.layout.setContentsMargins(0, 2, 0, 2) + self.layout.setSpacing(10) + + self.rowsCB = qt.QComboBox(self) + self.columnsCB = qt.QComboBox(self) + + self.layout.addWidget(qt.QLabel("Rows dimension", self)) + self.layout.addWidget(self.rowsCB) + self.layout.addWidget(qt.QLabel(" ", self)) + self.layout.addWidget(qt.QLabel("Columns dimension", self)) + self.layout.addWidget(self.columnsCB) + self.layout.addStretch(1) + + self._slotsAreConnected = False + if n is not None: + self.setNDimensions(n) + + def setNDimensions(self, n): + """Initialize combo-boxes depending on number of dimensions of array. + Initially, the rows dimension is the second-to-last one, and the + columns dimension is the last one. + + Link the CBs together. MAke them emit a signal when their value is + changed. + + :param int n: Number of dimensions of array + """ + # remember the number of dimensions and the rows dimension + self.n = n + self._rowsDim = n - 2 + + # ensure slots are disconnected before (re)initializing widget + if self._slotsAreConnected: + self.rowsCB.currentIndexChanged.disconnect(self._rowDimChanged) + self.columnsCB.currentIndexChanged.disconnect(self._colDimChanged) + + self._clear() + self.rowsCB.addItems([str(i) for i in range(n - 1)]) + self.rowsCB.setCurrentIndex(n - 2) + if n >= 1: + self.columnsCB.addItem(str(n - 1)) + self.columnsCB.setCurrentIndex(0) + + # reconnect slots + self.rowsCB.currentIndexChanged.connect(self._rowDimChanged) + self.columnsCB.currentIndexChanged.connect(self._colDimChanged) + self._slotsAreConnected = True + + # emit new dimensions + if n > 2: + self.sigDimensionsChanged.emit(n - 2, n - 1) + + def setDimensions(self, row_dim, col_dim): + """Set the rows and columns dimensions. + + The rows dimension must be lower than the columns dimension. + + :param int row_dim: Rows dimension + :param int col_dim: Columns dimension + """ + if row_dim >= col_dim: + raise IndexError("Row dimension must be lower than column dimension") + if not (0 <= row_dim < self.n - 1): + raise IndexError("Row dimension must be between 0 and %d" % (self.n - 2)) + if not (row_dim < col_dim <= self.n - 1): + raise IndexError("Col dimension must be between %d and %d" % (row_dim + 1, self.n - 1)) + + # set the rows dimension; this triggers an update of columnsCB + self.rowsCB.setCurrentIndex(row_dim) + # columnsCB first item is "row_dim + 1". So index of "col_dim" is + # col_dim - (row_dim + 1) + self.columnsCB.setCurrentIndex(col_dim - row_dim - 1) + + def getDimensions(self): + """Return a 2-tuple of the rows dimension and the columns dimension. + + :return: 2-tuple of axes numbers (row_dimension, col_dimension) + """ + return self._getRowDim(), self._getColDim() + + def _clear(self): + """Empty the combo-boxes""" + self.rowsCB.clear() + self.columnsCB.clear() + + def _getRowDim(self): + """Get rows dimension, selected in :attr:`rowsCB` + """ + # rows combobox contains elements "0", ..."n-2", + # so the selected dim is always equal to the index + return self.rowsCB.currentIndex() + + def _getColDim(self): + """Get columns dimension, selected in :attr:`columnsCB`""" + # columns combobox contains elements "row_dim+1", "row_dim+2", ..., "n-1" + # so the selected dim is equal to row_dim + 1 + index + return self._rowsDim + 1 + self.columnsCB.currentIndex() + + def _rowDimChanged(self): + """Update columns combobox when the rows dimension is changed. + + Emit :attr:`sigDimensionsChanged`""" + old_col_dim = self._getColDim() + new_row_dim = self._getRowDim() + + # clear cols CB + self.columnsCB.currentIndexChanged.disconnect(self._colDimChanged) + self.columnsCB.clear() + # refill cols CB + for i in range(new_row_dim + 1, self.n): + self.columnsCB.addItem(str(i)) + + # keep previous col dimension if possible + new_col_cb_idx = old_col_dim - (new_row_dim + 1) + if new_col_cb_idx < 0: + # if row_dim is now greater than the previous col_dim, + # we select a new col_dim = row_dim + 1 (first element in cols CB) + new_col_cb_idx = 0 + self.columnsCB.setCurrentIndex(new_col_cb_idx) + + # reconnect slot + self.columnsCB.currentIndexChanged.connect(self._colDimChanged) + + self._rowsDim = new_row_dim + + self.sigDimensionsChanged.emit(self._getRowDim(), self._getColDim()) + + def _colDimChanged(self): + """Emit :attr:`sigDimensionsChanged`""" + self.sigDimensionsChanged.emit(self._getRowDim(), self._getColDim()) + + +def _get_shape(array_like): + """Return shape of an array like object. + + In case the object is a nested sequence (list of lists, tuples...), + the size of each dimension is assumed to be uniform, and is deduced from + the length of the first sequence. + + :param array_like: Array like object: numpy array, hdf5 dataset, + multi-dimensional sequence + :return: Shape of array, as a tuple of integers + """ + if hasattr(array_like, "shape"): + return array_like.shape + + shape = [] + subsequence = array_like + while hasattr(subsequence, "__len__"): + shape.append(len(subsequence)) + subsequence = subsequence[0] + + return tuple(shape) + + +class ArrayTableWidget(qt.QWidget): + """This widget is designed to display data of 2D frames (images, slices) + in a table view. The widget can load any n-dimensional array, and display + any 2-D frame/slice in the array. + + The index of the dimensions orthogonal to the displayed frame can be set + interactively using a browser widget (sliders, buttons and text entries). + + To set the data, use :meth:`setArrayData`. + To select the perspective, use :meth:`setPerspective` or + use :meth:`setFrameAxes`. + To select the frame, use :meth:`setFrameIndex`. + """ + def __init__(self, parent=None): + """ + + :param parent: parent QWidget + :param labels: list of labels for each dimension of the array + """ + qt.QWidget.__init__(self, parent) + self.mainLayout = qt.QVBoxLayout(self) + self.mainLayout.setContentsMargins(0, 0, 0, 0) + self.mainLayout.setSpacing(0) + + self.browserContainer = qt.QWidget(self) + self.browserLayout = qt.QGridLayout(self.browserContainer) + self.browserLayout.setContentsMargins(0, 0, 0, 0) + self.browserLayout.setSpacing(0) + + self._dimensionLabelsText = [] + """List of text labels sorted in the increasing order of the dimension + they apply to.""" + self._browserLabels = [] + """List of QLabel widgets.""" + self._browserWidgets = [] + """List of HorizontalSliderWithBrowser widgets.""" + + self.axesSelector = AxesSelector(self) + + self.view = TableView(self) + + self.mainLayout.addWidget(self.browserContainer) + self.mainLayout.addWidget(self.axesSelector) + self.mainLayout.addWidget(self.view) + + self.model = ArrayTableModel(self) + self.view.setModel(self.model) + + def setArrayData(self, data, labels=None, copy=True, editable=False): + """Set the data array. Update frame browsers and labels. + + :param data: Numpy array or similar object (e.g. nested sequence, + h5py dataset...) + :param labels: list of labels for each dimension of the array, or + boolean ``True`` to use default labels ("dimension 0", + "dimension 1", ...). `None` to disable labels (default). + :param bool copy: If *True*, store a copy of *data* in the model. If + *False*, store a reference to *data* if possible (only possible if + *data* is a proper numpy array or an object that implements the + same methods). + :param bool editable: Flag to enable editing data. Default is *False* + """ + self._data_shape = _get_shape(data) + + n_widgets = len(self._browserWidgets) + n_dimensions = len(self._data_shape) + + # Reset text of labels + self._dimensionLabelsText = [] + for i in range(n_dimensions): + if labels in [True, 1]: + label_text = "Dimension %d" % i + elif labels is None or i >= len(labels): + label_text = "" + else: + label_text = labels[i] + self._dimensionLabelsText.append(label_text) + + # not enough widgets, create new ones (we need n_dim - 2) + for i in range(n_widgets, n_dimensions - 2): + browser = HorizontalSliderWithBrowser(self.browserContainer) + self.browserLayout.addWidget(browser, i, 1) + self._browserWidgets.append(browser) + browser.valueChanged.connect(self._browserSlot) + browser.setEnabled(False) + browser.hide() + + label = qt.QLabel(self.browserContainer) + self._browserLabels.append(label) + self.browserLayout.addWidget(label, i, 0) + label.hide() + + n_widgets = len(self._browserWidgets) + for i in range(n_widgets): + label = self._browserLabels[i] + browser = self._browserWidgets[i] + + if (i + 2) < n_dimensions: + label.setText(self._dimensionLabelsText[i]) + browser.setRange(0, self._data_shape[i] - 1) + browser.setEnabled(True) + browser.show() + if labels is not None: + label.show() + else: + label.hide() + else: + browser.setEnabled(False) + browser.hide() + label.hide() + + # set model + self.model.setArrayData(data, copy=copy, editable=editable) + # some linux distributions need this call + self.view.setModel(self.model) + if editable: + self.view.enableCut() + self.view.enablePaste() + + # initialize & connect axesSelector + self.axesSelector.setNDimensions(n_dimensions) + self.axesSelector.sigDimensionsChanged.connect(self.setFrameAxes) + + 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. + """ + self.model.setArrayColors(bgcolors, fgcolors) + + def displayAxesSelector(self, isVisible): + """Allow to display or hide the axes selector. + + :param bool isVisible: True to display the axes selector. + """ + self.axesSelector.setVisible(isVisible) + + def setFrameIndex(self, index): + """Set the active slice/image index in the n-dimensional array. + + A frame is a 2D array extracted from an array. This frame is + necessarily parallel to 2 axes, and orthogonal to all other axes. + + The index of a frame is a sequence of indices along the orthogonal + axes, where the frame intersects the respective axis. The indices + are listed in the same order as the corresponding dimensions of the + data array. + + For example, it the data array has 5 dimensions, and we are + considering frames whose parallel axes are the 2nd and 4th dimensions + of the array, the frame index will be a sequence of length 3 + corresponding to the indices where the frame intersects the 1st, 3rd + and 5th axes. + + :param index: Sequence of indices defining the active data slice in + a n-dimensional array. The sequence length is :math:`n-2` + :raise: IndexError if any index in the index sequence is out of bound + on its respective axis. + """ + self.model.setFrameIndex(index) + + def _resetBrowsers(self, perspective): + """Adjust limits for browsers based on the perspective and the + size of the corresponding dimensions. Reset the index to 0. + Update the dimension in the labels. + + :param perspective: Sequence of axes/dimensions numbers (0-based) + defining the axes orthogonal to the frame. + """ + # for 3D arrays we can accept an int rather than a 1-tuple + if not hasattr(perspective, "__len__"): + perspective = [perspective] + + # perspective must be sorted + perspective = sorted(perspective) + + n_dimensions = len(self._data_shape) + for i in range(n_dimensions - 2): + browser = self._browserWidgets[i] + label = self._browserLabels[i] + browser.setRange(0, self._data_shape[perspective[i]] - 1) + browser.setValue(0) + label.setText(self._dimensionLabelsText[perspective[i]]) + + def setPerspective(self, perspective): + """Set the *perspective* by specifying which axes are orthogonal + to the frame. + + For the opposite approach (defining parallel axes), use + :meth:`setFrameAxes` instead. + + :param perspective: Sequence of unique axes numbers (0-based) defining + the orthogonal axes. For a n-dimensional array, the sequence + length is :math:`n-2`. The order is of the sequence is not taken + into account (the dimensions are displayed in increasing order + in the widget). + """ + self.model.setPerspective(perspective) + self._resetBrowsers(perspective) + + def setFrameAxes(self, row_axis, col_axis): + """Set the *perspective* by specifying which axes are parallel + to the frame. + + For the opposite approach (defining orthogonal axes), use + :meth:`setPerspective` instead. + + :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 + """ + self.model.setFrameAxes(row_axis, col_axis) + n_dimensions = len(self._data_shape) + perspective = tuple(set(range(0, n_dimensions)) - {row_axis, col_axis}) + self._resetBrowsers(perspective) + + def _browserSlot(self, value): + index = [] + for browser in self._browserWidgets: + if browser.isEnabled(): + index.append(browser.value()) + self.setFrameIndex(index) + self.view.reset() + + def getData(self, copy=True): + """Return a copy of the data array, or a reference to it if + *copy=False* is passed as parameter. + + :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* + """ + return self.model.getData(copy=copy) + + +def main(): + import numpy + a = qt.QApplication([]) + d = numpy.random.normal(0, 1, (4, 5, 1000, 1000)) + for j in range(4): + for i in range(5): + d[j, i, :, :] += i + 10 * j + w = ArrayTableWidget() + if "2" in sys.argv: + print("sending a single image") + w.setArrayData(d[0, 0]) + elif "3" in sys.argv: + print("sending 5 images") + w.setArrayData(d[0]) + else: + print("sending 4 * 5 images ") + w.setArrayData(d, labels=True) + w.show() + a.exec_() + +if __name__ == "__main__": + main() diff --git a/silx/gui/data/DataViewer.py b/silx/gui/data/DataViewer.py new file mode 100644 index 0000000..3a3ac64 --- /dev/null +++ b/silx/gui/data/DataViewer.py @@ -0,0 +1,464 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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 widget designed to display data using to most adapted +view from available ones from silx. +""" +from __future__ import division + +from silx.gui.data import DataViews +from silx.gui.data.DataViews import _normalizeData +import logging +from silx.gui import qt +from silx.gui.data.NumpyAxesSelector import NumpyAxesSelector + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "26/04/2017" + + +_logger = logging.getLogger(__name__) + + +class DataViewer(qt.QFrame): + """Widget to display any kind of data + + .. image:: img/DataViewer.png + + The method :meth:`setData` allows to set any data to the widget. Mostly + `numpy.array` and `h5py.Dataset` are supported with adapted views. Other + data types are displayed using a text viewer. + + A default view is automatically selected when a data is set. The method + :meth:`setDisplayMode` allows to change the view. To have a graphical tool + to select the view, prefer using the widget :class:`DataViewerFrame`. + + The dimension of the input data and the expected dimension of the selected + view can differ. For example you can display an image (2D) from 4D + data. In this case a :class:`NumpyAxesSelector` is displayed to allow the + user to select the axis mapping and the slicing of other axes. + + .. code-block:: python + + import numpy + data = numpy.random.rand(500,500) + viewer = DataViewer() + viewer.setData(data) + viewer.setVisible(True) + """ + + EMPTY_MODE = 0 + PLOT1D_MODE = 10 + PLOT2D_MODE = 20 + PLOT3D_MODE = 30 + RAW_MODE = 40 + RAW_ARRAY_MODE = 41 + RAW_RECORD_MODE = 42 + RAW_SCALAR_MODE = 43 + STACK_MODE = 50 + HDF5_MODE = 60 + + displayedViewChanged = qt.Signal(object) + """Emitted when the displayed view changes""" + + dataChanged = qt.Signal() + """Emitted when the data changes""" + + currentAvailableViewsChanged = qt.Signal() + """Emitted when the current available views (which support the current + data) change""" + + def __init__(self, parent=None): + """Constructor + + :param QWidget parent: The parent of the widget + """ + super(DataViewer, self).__init__(parent) + + self.__stack = qt.QStackedWidget(self) + self.__numpySelection = NumpyAxesSelector(self) + self.__numpySelection.selectedAxisChanged.connect(self.__numpyAxisChanged) + self.__numpySelection.selectionChanged.connect(self.__numpySelectionChanged) + self.__numpySelection.customAxisChanged.connect(self.__numpyCustomAxisChanged) + + self.setLayout(qt.QVBoxLayout(self)) + self.layout().addWidget(self.__stack, 1) + + group = qt.QGroupBox(self) + group.setLayout(qt.QVBoxLayout()) + group.layout().addWidget(self.__numpySelection) + group.setTitle("Axis selection") + self.__axisSelection = group + + self.layout().addWidget(self.__axisSelection) + + self.__currentAvailableViews = [] + self.__currentView = None + self.__data = None + self.__useAxisSelection = False + self.__userSelectedView = None + + self.__views = [] + self.__index = {} + """store stack index for each views""" + + self._initializeViews() + + def _initializeViews(self): + """Inisialize the available views""" + views = self.createDefaultViews(self.__stack) + self.__views = list(views) + self.setDisplayMode(self.EMPTY_MODE) + + def createDefaultViews(self, parent=None): + """Create and returns available views which can be displayed by default + by the data viewer. It is called internally by the widget. It can be + overwriten to provide a different set of viewers. + + :param QWidget parent: QWidget parent of the views + :rtype: list[silx.gui.data.DataViews.DataView] + """ + viewClasses = [ + DataViews._EmptyView, + DataViews._Hdf5View, + DataViews._NXdataView, + DataViews._Plot1dView, + DataViews._Plot2dView, + DataViews._Plot3dView, + DataViews._RawView, + DataViews._StackView, + ] + views = [] + for viewClass in viewClasses: + try: + view = viewClass(parent) + views.append(view) + except Exception: + _logger.warning("%s instantiation failed. View is ignored" % viewClass.__name__) + _logger.debug("Backtrace", exc_info=True) + + return views + + def clear(self): + """Clear the widget""" + self.setData(None) + + def normalizeData(self, data): + """Returns a normalized data if the embed a numpy or a dataset. + Else returns the data.""" + return _normalizeData(data) + + def __getStackIndex(self, view): + """Get the stack index containing the view. + + :param silx.gui.data.DataViews.DataView view: The view + """ + if view not in self.__index: + widget = view.getWidget() + index = self.__stack.addWidget(widget) + self.__index[view] = index + else: + index = self.__index[view] + return index + + def __clearCurrentView(self): + """Clear the current selected view""" + view = self.__currentView + if view is not None: + view.clear() + + def __numpyCustomAxisChanged(self, name, value): + view = self.__currentView + if view is not None: + view.setCustomAxisValue(name, value) + + def __updateNumpySelectionAxis(self): + """ + Update the numpy-selector according to the needed axis names + """ + previous = self.__numpySelection.blockSignals(True) + self.__numpySelection.clear() + info = DataViews.DataInfo(self.__data) + axisNames = self.__currentView.axesNames(self.__data, info) + if info.isArray and self.__data is not None and len(axisNames) > 0: + self.__useAxisSelection = True + self.__numpySelection.setAxisNames(axisNames) + self.__numpySelection.setCustomAxis(self.__currentView.customAxisNames()) + data = self.normalizeData(self.__data) + self.__numpySelection.setData(data) + if hasattr(data, "shape"): + isVisible = not (len(axisNames) == 1 and len(data.shape) == 1) + else: + isVisible = True + self.__axisSelection.setVisible(isVisible) + else: + self.__useAxisSelection = False + self.__axisSelection.setVisible(False) + self.__numpySelection.blockSignals(previous) + + def __updateDataInView(self): + """ + Update the views using the current data + """ + if self.__useAxisSelection: + self.__displayedData = self.__numpySelection.selectedData() + else: + self.__displayedData = self.__data + + qt.QTimer.singleShot(10, self.__setDataInView) + + def __setDataInView(self): + self.__currentView.setData(self.__displayedData) + + def setDisplayedView(self, view): + """Set the displayed view. + + Change the displayed view according to the view itself. + + :param silx.gui.data.DataViews.DataView view: The DataView to use to display the data + """ + self.__userSelectedView = view + self._setDisplayedView(view) + + def _setDisplayedView(self, view): + """Internal set of the displayed view. + + Change the displayed view according to the view itself. + + :param silx.gui.data.DataViews.DataView view: The DataView to use to display the data + """ + if self.__currentView is view: + return + self.__clearCurrentView() + self.__currentView = view + self.__updateNumpySelectionAxis() + self.__updateDataInView() + stackIndex = self.__getStackIndex(self.__currentView) + if self.__currentView is not None: + self.__currentView.select() + self.__stack.setCurrentIndex(stackIndex) + self.displayedViewChanged.emit(view) + + def getViewFromModeId(self, modeId): + """Returns the first available view which have the requested modeId. + + :param int modeId: Requested mode id + :rtype: silx.gui.data.DataViews.DataView + """ + for view in self.__views: + if view.modeId() == modeId: + return view + return view + + def setDisplayMode(self, modeId): + """Set the displayed view using display mode. + + Change the displayed view according to the requested mode. + + :param int modeId: Display mode, one of + + - `EMPTY_MODE`: display nothing + - `PLOT1D_MODE`: display the data as a curve + - `PLOT2D_MODE`: display the data as an image + - `PLOT3D_MODE`: display the data as an isosurface + - `RAW_MODE`: display the data as a table + - `STACK_MODE`: display the data as a stack of images + - `HDF5_MODE`: display the data as a table + """ + try: + view = self.getViewFromModeId(modeId) + except KeyError: + raise ValueError("Display mode %s is unknown" % modeId) + self._setDisplayedView(view) + + def displayedView(self): + """Returns the current displayed view. + + :rtype: silx.gui.data.DataViews.DataView + """ + return self.__currentView + + def addView(self, view): + """Allow to add a view to the dataview. + + If the current data support this view, it will be displayed. + + :param DataView view: A dataview + """ + self.__views.append(view) + # TODO It can be skipped if the view do not support the data + self.__updateAvailableViews() + + def removeView(self, view): + """Allow to remove a view which was available from the dataview. + + If the view was displayed, the widget will be updated. + + :param DataView view: A dataview + """ + self.__views.remove(view) + self.__stack.removeWidget(view.getWidget()) + # invalidate the full index. It will be updated as expected + self.__index = {} + + if self.__userSelectedView is view: + self.__userSelectedView = None + + if view is self.__currentView: + self.__updateView() + else: + # TODO It can be skipped if the view is not part of the + # available views + self.__updateAvailableViews() + + def __updateAvailableViews(self): + """ + Update available views from the current data. + """ + data = self.__data + # sort available views according to priority + info = DataViews.DataInfo(data) + priorities = [v.getDataPriority(data, info) for v in self.__views] + views = zip(priorities, self.__views) + views = filter(lambda t: t[0] > DataViews.DataView.UNSUPPORTED, views) + views = sorted(views, reverse=True) + + # store available views + if len(views) == 0: + self.__setCurrentAvailableViews([]) + available = [] + else: + available = [v[1] for v in views] + self.__setCurrentAvailableViews(available) + + def __updateView(self): + """Display the data using the widget which fit the best""" + data = self.__data + + # update available views for this data + self.__updateAvailableViews() + available = self.__currentAvailableViews + + # display the view with the most priority (the default view) + view = self.getDefaultViewFromAvailableViews(data, available) + self.__clearCurrentView() + try: + self._setDisplayedView(view) + except Exception as e: + # in case there is a problem to read the data, try to use a safe + # view + view = self.getSafeViewFromAvailableViews(data, available) + self._setDisplayedView(view) + raise e + + def getSafeViewFromAvailableViews(self, data, available): + """Returns a view which is sure to display something without failing + on rendering. + + :param object data: data which will be displayed + :param list[view] available: List of available views, from highest + priority to lowest. + :rtype: DataView + """ + hdf5View = self.getViewFromModeId(DataViewer.HDF5_MODE) + if hdf5View in available: + return hdf5View + return self.getViewFromModeId(DataViewer.EMPTY_MODE) + + def getDefaultViewFromAvailableViews(self, data, available): + """Returns the default view which will be used according to available + views. + + :param object data: data which will be displayed + :param list[view] available: List of available views, from highest + priority to lowest. + :rtype: DataView + """ + if len(available) > 0: + # returns the view with the highest priority + if self.__userSelectedView in available: + return self.__userSelectedView + self.__userSelectedView = None + view = available[0] + else: + # else returns the empty view + view = self.getViewFromModeId(DataViewer.EMPTY_MODE) + return view + + def __setCurrentAvailableViews(self, availableViews): + """Set the current available viewa + + :param List[DataView] availableViews: Current available viewa + """ + self.__currentAvailableViews = availableViews + self.currentAvailableViewsChanged.emit() + + def currentAvailableViews(self): + """Returns the list of available views for the current data + + :rtype: List[DataView] + """ + return self.__currentAvailableViews + + def availableViews(self): + """Returns the list of registered views + + :rtype: List[DataView] + """ + return self.__views + + def setData(self, data): + """Set the data to view. + + It mostly can be a h5py.Dataset or a numpy.ndarray. Other kind of + objects will be displayed as text rendering. + + :param numpy.ndarray data: The data. + """ + self.__data = data + self.__displayedData = None + self.__updateView() + self.__updateNumpySelectionAxis() + self.__updateDataInView() + self.dataChanged.emit() + + def __numpyAxisChanged(self): + """ + Called when axis selection of the numpy-selector changed + """ + self.__clearCurrentView() + + def __numpySelectionChanged(self): + """ + Called when data selection of the numpy-selector changed + """ + self.__updateDataInView() + + def data(self): + """Returns the data""" + return self.__data + + def displayMode(self): + """Returns the current display mode""" + return self.__currentView.modeId() diff --git a/silx/gui/data/DataViewerFrame.py b/silx/gui/data/DataViewerFrame.py new file mode 100644 index 0000000..b48fa7b --- /dev/null +++ b/silx/gui/data/DataViewerFrame.py @@ -0,0 +1,186 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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 contains a DataViewer with a view selector. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "10/04/2017" + +from silx.gui import qt +from .DataViewer import DataViewer +from .DataViewerSelector import DataViewerSelector + + +class DataViewerFrame(qt.QWidget): + """ + A :class:`DataViewer` with a view selector. + + .. image:: img/DataViewerFrame.png + + This widget provides the same API as :class:`DataViewer`. Therefore, for more + documentation, take a look at the documentation of the class + :class:`DataViewer`. + + .. code-block:: python + + import numpy + data = numpy.random.rand(500,500) + viewer = DataViewerFrame() + viewer.setData(data) + viewer.setVisible(True) + + """ + + displayedViewChanged = qt.Signal(object) + """Emitted when the displayed view changes""" + + dataChanged = qt.Signal() + """Emitted when the data changes""" + + def __init__(self, parent=None): + """ + Constructor + + :param qt.QWidget parent: + """ + super(DataViewerFrame, self).__init__(parent) + + class _DataViewer(DataViewer): + """Overwrite methods to avoid to create views while the instance + is not created. `initializeViews` have to be called manually.""" + + def _initializeViews(self): + pass + + def initializeViews(self): + """Avoid to create views while the instance is not created.""" + super(_DataViewer, self)._initializeViews() + + self.__dataViewer = _DataViewer(self) + # initialize views when `self.__dataViewer` is set + self.__dataViewer.initializeViews() + self.__dataViewer.setFrameShape(qt.QFrame.StyledPanel) + self.__dataViewer.setFrameShadow(qt.QFrame.Sunken) + self.__dataViewerSelector = DataViewerSelector(self, self.__dataViewer) + self.__dataViewerSelector.setFlat(True) + + layout = qt.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self.__dataViewer, 1) + layout.addWidget(self.__dataViewerSelector) + self.setLayout(layout) + + self.__dataViewer.dataChanged.connect(self.__dataChanged) + self.__dataViewer.displayedViewChanged.connect(self.__displayedViewChanged) + + def __dataChanged(self): + """Called when the data is changed""" + self.dataChanged.emit() + + def __displayedViewChanged(self, view): + """Called when the displayed view changes""" + self.displayedViewChanged.emit(view) + + def availableViews(self): + """Returns the list of registered views + + :rtype: List[DataView] + """ + return self.__dataViewer.availableViews() + + def currentAvailableViews(self): + """Returns the list of available views for the current data + + :rtype: List[DataView] + """ + return self.__dataViewer.currentAvailableViews() + + def createDefaultViews(self, parent=None): + """Create and returns available views which can be displayed by default + by the data viewer. It is called internally by the widget. It can be + overwriten to provide a different set of viewers. + + :param QWidget parent: QWidget parent of the views + :rtype: list[silx.gui.data.DataViews.DataView] + """ + return self.__dataViewer.createDefaultViews(parent) + + def addView(self, view): + """Allow to add a view to the dataview. + + If the current data support this view, it will be displayed. + + :param DataView view: A dataview + """ + return self.__dataViewer.addView(view) + + def removeView(self, view): + """Allow to remove a view which was available from the dataview. + + If the view was displayed, the widget will be updated. + + :param DataView view: A dataview + """ + return self.__dataViewer.removeView(view) + + def setData(self, data): + """Set the data to view. + + It mostly can be a h5py.Dataset or a numpy.ndarray. Other kind of + objects will be displayed as text rendering. + + :param numpy.ndarray data: The data. + """ + self.__dataViewer.setData(data) + + def data(self): + """Returns the data""" + return self.__dataViewer.data() + + def setDisplayedView(self, view): + self.__dataViewer.setDisplayedView(view) + + def displayedView(self): + return self.__dataViewer.displayedView() + + def displayMode(self): + return self.__dataViewer.displayMode() + + def setDisplayMode(self, modeId): + """Set the displayed view using display mode. + + Change the displayed view according to the requested mode. + + :param int modeId: Display mode, one of + + - `EMPTY_MODE`: display nothing + - `PLOT1D_MODE`: display the data as a curve + - `PLOT2D_MODE`: display the data as an image + - `TEXT_MODE`: display the data as a text + - `ARRAY_MODE`: display the data as a table + """ + return self.__dataViewer.setDisplayMode(modeId) diff --git a/silx/gui/data/DataViewerSelector.py b/silx/gui/data/DataViewerSelector.py new file mode 100644 index 0000000..32cc636 --- /dev/null +++ b/silx/gui/data/DataViewerSelector.py @@ -0,0 +1,153 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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 widget to be able to select the available view +of the DataViewer. +""" +from __future__ import division + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "26/01/2017" + +import weakref +import functools +from silx.gui import qt +from silx.gui.data.DataViewer import DataViewer +import silx.utils.weakref + + +class DataViewerSelector(qt.QWidget): + """Widget to be able to select a custom view from the DataViewer""" + + def __init__(self, parent=None, dataViewer=None): + """Constructor + + :param QWidget parent: The parent of the widget + :param DataViewer dataViewer: The connected `DataViewer` + """ + super(DataViewerSelector, self).__init__(parent) + + self.__group = None + self.__buttons = {} + self.__buttonDummy = None + self.__dataViewer = None + + if dataViewer is not None: + self.setDataViewer(dataViewer) + + def __updateButtons(self): + if self.__group is not None: + self.__group.deleteLater() + self.__buttons = {} + self.__buttonDummy = None + + self.__group = qt.QButtonGroup(self) + self.setLayout(qt.QHBoxLayout()) + self.layout().setContentsMargins(0, 0, 0, 0) + if self.__dataViewer is None: + return + + iconSize = qt.QSize(16, 16) + + for view in self.__dataViewer.availableViews(): + label = view.label() + icon = view.icon() + button = qt.QPushButton(label) + button.setIcon(icon) + button.setIconSize(iconSize) + button.setCheckable(True) + # the weak objects are needed to be able to destroy the widget safely + weakView = weakref.ref(view) + weakMethod = silx.utils.weakref.WeakMethodProxy(self.__setDisplayedView) + callback = functools.partial(weakMethod, weakView) + button.clicked.connect(callback) + self.layout().addWidget(button) + self.__group.addButton(button) + self.__buttons[view] = button + + button = qt.QPushButton("Dummy") + button.setCheckable(True) + button.setVisible(False) + self.layout().addWidget(button) + self.__group.addButton(button) + self.__buttonDummy = button + + self.layout().addStretch(1) + + self.__updateButtonsVisibility() + self.__displayedViewChanged(self.__dataViewer.displayedView()) + + def setDataViewer(self, dataViewer): + """Define the dataviewer connected to this status bar + + :param DataViewer dataViewer: The connected `DataViewer` + """ + if self.__dataViewer is dataViewer: + return + if self.__dataViewer is not None: + self.__dataViewer.dataChanged.disconnect(self.__updateButtonsVisibility) + self.__dataViewer.displayedViewChanged.disconnect(self.__displayedViewChanged) + self.__dataViewer = dataViewer + if self.__dataViewer is not None: + self.__dataViewer.dataChanged.connect(self.__updateButtonsVisibility) + self.__dataViewer.displayedViewChanged.connect(self.__displayedViewChanged) + self.__updateButtons() + + def setFlat(self, isFlat): + """Set the flat state of all the buttons. + + :param bool isFlat: True to display the buttons flatten. + """ + for b in self.__buttons.values(): + b.setFlat(isFlat) + self.__buttonDummy.setFlat(isFlat) + + def __displayedViewChanged(self, view): + """Called on displayed view changeS""" + selectedButton = self.__buttons.get(view, self.__buttonDummy) + selectedButton.setChecked(True) + + def __setDisplayedView(self, refView, clickEvent=None): + """Display a data using the requested view + + :param DataView view: Requested view + :param clickEvent: Event sent by the clicked event + """ + if self.__dataViewer is None: + return + view = refView() + if view is None: + return + self.__dataViewer.setDisplayedView(view) + + def __updateButtonsVisibility(self): + """Called on data changed""" + if self.__dataViewer is None: + for b in self.__buttons.values(): + b.setVisible(False) + else: + availableViews = set(self.__dataViewer.currentAvailableViews()) + for view, button in self.__buttons.items(): + button.setVisible(view in availableViews) diff --git a/silx/gui/data/DataViews.py b/silx/gui/data/DataViews.py new file mode 100644 index 0000000..d8d605a --- /dev/null +++ b/silx/gui/data/DataViews.py @@ -0,0 +1,988 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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 views used by :class:`silx.gui.data.DataViewer`. +""" + +import logging +import numbers +import numpy + +import silx.io +from silx.gui import qt, icons +from silx.gui.data.TextFormatter import TextFormatter +from silx.io import nxdata +from silx.gui.hdf5 import H5Node +from silx.io.nxdata import NXdata + +__authors__ = ["V. Valls", "P. Knobel"] +__license__ = "MIT" +__date__ = "07/04/2017" + +_logger = logging.getLogger(__name__) + + +# DataViewer modes +EMPTY_MODE = 0 +PLOT1D_MODE = 10 +PLOT2D_MODE = 20 +PLOT3D_MODE = 30 +RAW_MODE = 40 +RAW_ARRAY_MODE = 41 +RAW_RECORD_MODE = 42 +RAW_SCALAR_MODE = 43 +STACK_MODE = 50 +HDF5_MODE = 60 + + +def _normalizeData(data): + """Returns a normalized data. + + If the data embed a numpy data or a dataset it is returned. + Else returns the input data.""" + if isinstance(data, H5Node): + return data.h5py_object + return data + + +def _normalizeComplex(data): + """Returns a normalized complex data. + + If the data is a numpy data with complex, returns the + absolute value. + Else returns the input data.""" + if hasattr(data, "dtype"): + isComplex = numpy.issubdtype(data.dtype, numpy.complex) + else: + isComplex = isinstance(data, numbers.Complex) + if isComplex: + data = numpy.absolute(data) + return data + + +class DataInfo(object): + """Store extracted information from a data""" + + def __init__(self, data): + data = self.normalizeData(data) + self.isArray = False + self.interpretation = None + self.isNumeric = False + self.isComplex = False + self.isRecord = False + self.isNXdata = False + self.shape = tuple() + self.dim = 0 + + if data is None: + return + + if silx.io.is_group(data) and nxdata.is_valid_nxdata(data): + self.isNXdata = True + nxd = nxdata.NXdata(data) + + if isinstance(data, numpy.ndarray): + self.isArray = True + elif silx.io.is_dataset(data) and data.shape != tuple(): + self.isArray = True + else: + self.isArray = False + + if silx.io.is_dataset(data): + self.interpretation = data.attrs.get("interpretation", None) + elif self.isNXdata: + self.interpretation = nxd.interpretation + else: + self.interpretation = None + + if hasattr(data, "dtype"): + self.isNumeric = numpy.issubdtype(data.dtype, numpy.number) + self.isRecord = data.dtype.fields is not None + self.isComplex = numpy.issubdtype(data.dtype, numpy.complex) + elif self.isNXdata: + self.isNumeric = numpy.issubdtype(nxd.signal.dtype, + numpy.number) + self.isComplex = numpy.issubdtype(nxd.signal.dtype, numpy.complex) + else: + self.isNumeric = isinstance(data, numbers.Number) + self.isComplex = isinstance(data, numbers.Complex) + self.isRecord = False + + if hasattr(data, "shape"): + self.shape = data.shape + elif self.isNXdata: + self.shape = nxd.signal.shape + else: + self.shape = tuple() + self.dim = len(self.shape) + + def normalizeData(self, data): + """Returns a normalized data if the embed a numpy or a dataset. + Else returns the data.""" + return _normalizeData(data) + + +class DataView(object): + """Holder for the data view.""" + + UNSUPPORTED = -1 + """Priority returned when the requested data can't be displayed by the + view.""" + + def __init__(self, parent, modeId=None, icon=None, label=None): + """Constructor + + :param qt.QWidget parent: Parent of the hold widget + """ + self.__parent = parent + self.__widget = None + self.__modeId = modeId + if label is None: + label = self.__class__.__name__ + self.__label = label + if icon is None: + icon = qt.QIcon() + self.__icon = icon + + def icon(self): + """Returns the default icon""" + return self.__icon + + def label(self): + """Returns the default label""" + return self.__label + + def modeId(self): + """Returns the mode id""" + return self.__modeId + + def normalizeData(self, data): + """Returns a normalized data if the embed a numpy or a dataset. + Else returns the data.""" + return _normalizeData(data) + + def customAxisNames(self): + """Returns names of axes which can be custom by the user and provided + to the view.""" + return [] + + def setCustomAxisValue(self, name, value): + """ + Set the value of a custom axis + + :param str name: Name of the custom axis + :param int value: Value of the custom axis + """ + pass + + def isWidgetInitialized(self): + """Returns true if the widget is already initialized. + """ + return self.__widget is not None + + def select(self): + """Called when the view is selected to display the data. + """ + return + + def getWidget(self): + """Returns the widget hold in the view and displaying the data. + + :returns: qt.QWidget + """ + if self.__widget is None: + self.__widget = self.createWidget(self.__parent) + return self.__widget + + def createWidget(self, parent): + """Create the the widget displaying the data + + :param qt.QWidget parent: Parent of the widget + :returns: qt.QWidget + """ + raise NotImplementedError() + + def clear(self): + """Clear the data from the view""" + return None + + def setData(self, data): + """Set the data displayed by the view + + :param data: Data to display + :type data: numpy.ndarray or h5py.Dataset + """ + return None + + def axesNames(self, data, info): + """Returns names of the expected axes of the view, according to the + input data. + + :param data: Data to display + :type data: numpy.ndarray or h5py.Dataset + :param DataInfo info: Pre-computed information on the data + :rtype: list[str] + """ + return [] + + def getDataPriority(self, data, info): + """ + Returns the priority of using this view according to a data. + + - `UNSUPPORTED` means this view can't display this data + - `1` means this view can display the data + - `100` means this view should be used for this data + - `1000` max value used by the views provided by silx + - ... + + :param object data: The data to check + :param DataInfo info: Pre-computed information on the data + :rtype: int + """ + return DataView.UNSUPPORTED + + def __lt__(self, other): + return str(self) < str(other) + + +class CompositeDataView(DataView): + """Data view which can display a data using different view according to + the kind of the data.""" + + def __init__(self, parent, modeId=None, icon=None, label=None): + """Constructor + + :param qt.QWidget parent: Parent of the hold widget + """ + super(CompositeDataView, self).__init__(parent, modeId, icon, label) + self.__views = {} + self.__currentView = None + + def addView(self, dataView): + """Add a new dataview to the available list.""" + self.__views[dataView] = None + + def getBestView(self, data, info): + """Returns the best view according to priorities.""" + info = DataInfo(data) + views = [(v.getDataPriority(data, info), v) for v in self.__views.keys()] + views = filter(lambda t: t[0] > DataView.UNSUPPORTED, views) + views = sorted(views, reverse=True) + + if len(views) == 0: + return None + elif views[0][0] == DataView.UNSUPPORTED: + return None + else: + return views[0][1] + + def customAxisNames(self): + if self.__currentView is None: + return + return self.__currentView.customAxisNames() + + def setCustomAxisValue(self, name, value): + if self.__currentView is None: + return + self.__currentView.setCustomAxisValue(name, value) + + def __updateDisplayedView(self): + widget = self.getWidget() + if self.__currentView is None: + return + + # load the widget if it is not yet done + index = self.__views[self.__currentView] + if index is None: + w = self.__currentView.getWidget() + index = widget.addWidget(w) + self.__views[self.__currentView] = index + if widget.currentIndex() != index: + widget.setCurrentIndex(index) + self.__currentView.select() + + def select(self): + self.__updateDisplayedView() + if self.__currentView is not None: + self.__currentView.select() + + def createWidget(self, parent): + return qt.QStackedWidget() + + def clear(self): + for v in self.__views.keys(): + v.clear() + + def setData(self, data): + if self.__currentView is None: + return + self.__updateDisplayedView() + self.__currentView.setData(data) + + def axesNames(self, data, info): + view = self.getBestView(data, info) + self.__currentView = view + return view.axesNames(data, info) + + def getDataPriority(self, data, info): + view = self.getBestView(data, info) + self.__currentView = view + if view is None: + return DataView.UNSUPPORTED + else: + return view.getDataPriority(data, info) + + +class _EmptyView(DataView): + """Dummy view to display nothing""" + + def __init__(self, parent): + DataView.__init__(self, parent, modeId=EMPTY_MODE) + + def axesNames(self, data, info): + return [] + + def createWidget(self, parent): + return qt.QLabel(parent) + + def getDataPriority(self, data, info): + return DataView.UNSUPPORTED + + +class _Plot1dView(DataView): + """View displaying data using a 1d plot""" + + def __init__(self, parent): + super(_Plot1dView, self).__init__( + parent=parent, + modeId=PLOT1D_MODE, + label="Curve", + icon=icons.getQIcon("view-1d")) + self.__resetZoomNextTime = True + + def createWidget(self, parent): + from silx.gui import plot + return plot.Plot1D(parent=parent) + + def clear(self): + self.getWidget().clear() + self.__resetZoomNextTime = True + + def normalizeData(self, data): + data = DataView.normalizeData(self, data) + data = _normalizeComplex(data) + return data + + def setData(self, data): + data = self.normalizeData(data) + self.getWidget().addCurve(legend="data", + x=range(len(data)), + y=data, + resetzoom=self.__resetZoomNextTime) + self.__resetZoomNextTime = True + + def axesNames(self, data, info): + return ["y"] + + def getDataPriority(self, data, info): + if data is None or not info.isArray or not info.isNumeric: + return DataView.UNSUPPORTED + if info.dim < 1: + return DataView.UNSUPPORTED + if info.interpretation == "spectrum": + return 1000 + if info.dim == 2 and info.shape[0] == 1: + return 210 + if info.dim == 1: + return 100 + else: + return 10 + + +class _Plot2dView(DataView): + """View displaying data using a 2d plot""" + + def __init__(self, parent): + super(_Plot2dView, self).__init__( + parent=parent, + modeId=PLOT2D_MODE, + label="Image", + icon=icons.getQIcon("view-2d")) + self.__resetZoomNextTime = True + + def createWidget(self, parent): + from silx.gui import plot + widget = plot.Plot2D(parent=parent) + widget.setKeepDataAspectRatio(True) + widget.setGraphXLabel('X') + widget.setGraphYLabel('Y') + return widget + + def clear(self): + self.getWidget().clear() + self.__resetZoomNextTime = True + + def normalizeData(self, data): + data = DataView.normalizeData(self, data) + data = _normalizeComplex(data) + return data + + def setData(self, data): + data = self.normalizeData(data) + self.getWidget().addImage(legend="data", + data=data, + resetzoom=self.__resetZoomNextTime) + self.__resetZoomNextTime = False + + def axesNames(self, data, info): + return ["y", "x"] + + def getDataPriority(self, data, info): + if data is None or not info.isArray or not info.isNumeric: + return DataView.UNSUPPORTED + if info.dim < 2: + return DataView.UNSUPPORTED + if info.interpretation == "image": + return 1000 + if info.dim == 2: + return 200 + else: + return 190 + + +class _Plot3dView(DataView): + """View displaying data using a 3d plot""" + + def __init__(self, parent): + super(_Plot3dView, self).__init__( + parent=parent, + modeId=PLOT3D_MODE, + label="Cube", + icon=icons.getQIcon("view-3d")) + try: + import silx.gui.plot3d #noqa + except ImportError: + _logger.warning("Plot3dView is not available") + _logger.debug("Backtrace", exc_info=True) + raise + self.__resetZoomNextTime = True + + def createWidget(self, parent): + from silx.gui.plot3d import ScalarFieldView + from silx.gui.plot3d import SFViewParamTree + + plot = ScalarFieldView.ScalarFieldView(parent) + plot.setAxesLabels(*reversed(self.axesNames(None, None))) + plot.addIsosurface( + lambda data: numpy.mean(data) + numpy.std(data), '#FF0000FF') + + # Create a parameter tree for the scalar field view + options = SFViewParamTree.TreeView(plot) + options.setSfView(plot) + + # Add the parameter tree to the main window in a dock widget + dock = qt.QDockWidget() + dock.setWidget(options) + plot.addDockWidget(qt.Qt.RightDockWidgetArea, dock) + + return plot + + def clear(self): + self.getWidget().setData(None) + self.__resetZoomNextTime = True + + def normalizeData(self, data): + data = DataView.normalizeData(self, data) + data = _normalizeComplex(data) + return data + + def setData(self, data): + data = self.normalizeData(data) + plot = self.getWidget() + plot.setData(data) + self.__resetZoomNextTime = False + + def axesNames(self, data, info): + return ["z", "y", "x"] + + def getDataPriority(self, data, info): + if data is None or not info.isArray or not info.isNumeric: + return DataView.UNSUPPORTED + if info.dim < 3: + return DataView.UNSUPPORTED + if min(data.shape) < 2: + return DataView.UNSUPPORTED + if info.dim == 3: + return 100 + else: + return 10 + + +class _ArrayView(DataView): + """View displaying data using a 2d table""" + + def __init__(self, parent): + DataView.__init__(self, parent, modeId=RAW_ARRAY_MODE) + + def createWidget(self, parent): + from silx.gui.data.ArrayTableWidget import ArrayTableWidget + widget = ArrayTableWidget(parent) + widget.displayAxesSelector(False) + return widget + + def clear(self): + self.getWidget().setArrayData(numpy.array([[]])) + + def setData(self, data): + data = self.normalizeData(data) + self.getWidget().setArrayData(data) + + def axesNames(self, data, info): + return ["col", "row"] + + def getDataPriority(self, data, info): + if data is None or not info.isArray or info.isRecord: + return DataView.UNSUPPORTED + if info.dim < 2: + return DataView.UNSUPPORTED + if info.interpretation in ["scalar", "scaler"]: + return 1000 + return 500 + + +class _StackView(DataView): + """View displaying data using a stack of images""" + + def __init__(self, parent): + super(_StackView, self).__init__( + parent=parent, + modeId=STACK_MODE, + label="Image stack", + icon=icons.getQIcon("view-2d-stack")) + self.__resetZoomNextTime = True + + def customAxisNames(self): + return ["depth"] + + def setCustomAxisValue(self, name, value): + if name == "depth": + self.getWidget().setFrameNumber(value) + else: + raise Exception("Unsupported axis") + + def createWidget(self, parent): + from silx.gui import plot + widget = plot.StackView(parent=parent) + widget.setKeepDataAspectRatio(True) + widget.setLabels(self.axesNames(None, None)) + # hide default option panel + widget.setOptionVisible(False) + return widget + + def clear(self): + self.getWidget().clear() + self.__resetZoomNextTime = True + + def normalizeData(self, data): + data = DataView.normalizeData(self, data) + data = _normalizeComplex(data) + return data + + def setData(self, data): + data = self.normalizeData(data) + self.getWidget().setStack(stack=data, reset=self.__resetZoomNextTime) + self.__resetZoomNextTime = False + + def axesNames(self, data, info): + return ["depth", "y", "x"] + + def getDataPriority(self, data, info): + if data is None or not info.isArray or not info.isNumeric: + return DataView.UNSUPPORTED + if info.dim < 3: + return DataView.UNSUPPORTED + if info.interpretation == "image": + return 500 + return 90 + + +class _ScalarView(DataView): + """View displaying data using text""" + + def __init__(self, parent): + DataView.__init__(self, parent, modeId=RAW_SCALAR_MODE) + + def createWidget(self, parent): + widget = qt.QTextEdit(parent) + widget.setTextInteractionFlags(qt.Qt.TextSelectableByMouse) + widget.setAlignment(qt.Qt.AlignLeft | qt.Qt.AlignTop) + self.__formatter = TextFormatter(parent) + return widget + + def clear(self): + self.getWidget().setText("") + + def setData(self, data): + data = self.normalizeData(data) + if silx.io.is_dataset(data): + data = data[()] + text = self.__formatter.toString(data) + self.getWidget().setText(text) + + def axesNames(self, data, info): + return [] + + def getDataPriority(self, data, info): + data = self.normalizeData(data) + if data is None: + return DataView.UNSUPPORTED + if silx.io.is_group(data): + return DataView.UNSUPPORTED + return 2 + + +class _RecordView(DataView): + """View displaying data using text""" + + def __init__(self, parent): + DataView.__init__(self, parent, modeId=RAW_RECORD_MODE) + + def createWidget(self, parent): + from .RecordTableView import RecordTableView + widget = RecordTableView(parent) + widget.setWordWrap(False) + return widget + + def clear(self): + self.getWidget().setArrayData(None) + + def setData(self, data): + data = self.normalizeData(data) + widget = self.getWidget() + widget.setArrayData(data) + widget.resizeRowsToContents() + widget.resizeColumnsToContents() + + def axesNames(self, data, info): + return ["data"] + + def getDataPriority(self, data, info): + if info.isRecord: + return 40 + if data is None or not info.isArray: + return DataView.UNSUPPORTED + if info.dim == 1: + if info.interpretation in ["scalar", "scaler"]: + return 1000 + if info.shape[0] == 1: + return 510 + return 500 + elif info.isRecord: + return 40 + return DataView.UNSUPPORTED + + +class _Hdf5View(DataView): + """View displaying data using text""" + + def __init__(self, parent): + super(_Hdf5View, self).__init__( + parent=parent, + modeId=HDF5_MODE, + label="HDF5", + icon=icons.getQIcon("view-hdf5")) + + def createWidget(self, parent): + from .Hdf5TableView import Hdf5TableView + widget = Hdf5TableView(parent) + return widget + + def clear(self): + widget = self.getWidget() + widget.setData(None) + + def setData(self, data): + widget = self.getWidget() + widget.setData(data) + + def axesNames(self, data, info): + return [] + + def getDataPriority(self, data, info): + widget = self.getWidget() + if widget.isSupportedData(data): + return 1 + else: + return DataView.UNSUPPORTED + + +class _RawView(CompositeDataView): + """View displaying data as raw data. + + This implementation use a 2d-array view, or a record array view, or a + raw text output. + """ + + def __init__(self, parent): + super(_RawView, self).__init__( + parent=parent, + modeId=RAW_MODE, + label="Raw", + icon=icons.getQIcon("view-raw")) + self.addView(_ScalarView(parent)) + self.addView(_ArrayView(parent)) + self.addView(_RecordView(parent)) + + +class _NXdataScalarView(DataView): + """DataView using a table view for displaying NXdata scalars: + 0-D signal or n-D signal with *@interpretation=scalar*""" + def __init__(self, parent): + DataView.__init__(self, parent) + + def createWidget(self, parent): + from silx.gui.data.ArrayTableWidget import ArrayTableWidget + widget = ArrayTableWidget(parent) + # widget.displayAxesSelector(False) + return widget + + def axesNames(self, data, info): + return ["col", "row"] + + def clear(self): + self.getWidget().setArrayData(numpy.array([[]]), + labels=True) + + def setData(self, data): + data = self.normalizeData(data) + signal = NXdata(data).signal + self.getWidget().setArrayData(signal, + labels=True) + + def getDataPriority(self, data, info): + data = self.normalizeData(data) + if info.isNXdata: + nxd = NXdata(data) + if nxd.signal_is_0d or nxd.interpretation in ["scalar", "scaler"]: + return 100 + return DataView.UNSUPPORTED + + +class _NXdataCurveView(DataView): + """DataView using a Plot1D for displaying NXdata curves: + 1-D signal or n-D signal with *@interpretation=spectrum*. + + It also handles basic scatter plots: + a 1-D signal with one axis whose values are not monotonically increasing. + """ + def __init__(self, parent): + DataView.__init__(self, parent) + + def createWidget(self, parent): + from silx.gui.data.NXdataWidgets import ArrayCurvePlot + widget = ArrayCurvePlot(parent) + return widget + + def axesNames(self, data, info): + # disabled (used by default axis selector widget in Hdf5Viewer) + return [] + + def clear(self): + self.getWidget().clear() + + def setData(self, data): + data = self.normalizeData(data) + nxd = NXdata(data) + signal_name = data.attrs["signal"] + group_name = data.name + if nxd.axes_names[-1] is not None: + x_errors = nxd.get_axis_errors(nxd.axes_names[-1]) + else: + x_errors = None + + self.getWidget().setCurveData(nxd.signal, nxd.axes[-1], + yerror=nxd.errors, xerror=x_errors, + ylabel=signal_name, xlabel=nxd.axes_names[-1], + title="NXdata group " + group_name) + + def getDataPriority(self, data, info): + data = self.normalizeData(data) + if info.isNXdata: + nxd = NXdata(data) + if nxd.is_x_y_value_scatter or nxd.is_unsupported_scatter: + return DataView.UNSUPPORTED + if nxd.signal_is_1d and \ + not nxd.interpretation in ["scalar", "scaler"]: + return 100 + if nxd.interpretation == "spectrum": + return 100 + return DataView.UNSUPPORTED + + +class _NXdataXYVScatterView(DataView): + """DataView using a Plot1D for displaying NXdata 3D scatters as + a scatter of coloured points (1-D signal with 2 axes)""" + def __init__(self, parent): + DataView.__init__(self, parent) + + def createWidget(self, parent): + from silx.gui.data.NXdataWidgets import ArrayCurvePlot + widget = ArrayCurvePlot(parent) + return widget + + def axesNames(self, data, info): + # disabled (used by default axis selector widget in Hdf5Viewer) + return [] + + def clear(self): + self.getWidget().clear() + + def setData(self, data): + data = self.normalizeData(data) + nxd = NXdata(data) + signal_name = data.attrs["signal"] + # signal_errors = nx.errors # not supported + group_name = data.name + x_axis, y_axis = nxd.axes[-2:] + + x_label, y_label = nxd.axes_names[-2:] + if x_label is not None: + x_errors = nxd.get_axis_errors(x_label) + else: + x_errors = None + + if y_label is not None: + y_errors = nxd.get_axis_errors(y_label) + else: + y_errors = None + + self.getWidget().setCurveData(y_axis, x_axis, values=nxd.signal, + yerror=y_errors, xerror=x_errors, + ylabel=signal_name, xlabel=x_label, + title="NXdata group " + group_name) + + def getDataPriority(self, data, info): + data = self.normalizeData(data) + if info.isNXdata: + if NXdata(data).is_x_y_value_scatter: + return 100 + return DataView.UNSUPPORTED + + +class _NXdataImageView(DataView): + """DataView using a Plot2D for displaying NXdata images: + 2-D signal or n-D signals with *@interpretation=spectrum*.""" + def __init__(self, parent): + DataView.__init__(self, parent) + + def createWidget(self, parent): + from silx.gui.data.NXdataWidgets import ArrayImagePlot + widget = ArrayImagePlot(parent) + return widget + + def axesNames(self, data, info): + return [] + + def clear(self): + self.getWidget().clear() + + def setData(self, data): + data = self.normalizeData(data) + nxd = NXdata(data) + signal_name = data.attrs["signal"] + group_name = data.name + y_axis, x_axis = nxd.axes[-2:] + y_label, x_label = nxd.axes_names[-2:] + + self.getWidget().setImageData( + nxd.signal, x_axis=x_axis, y_axis=y_axis, + signal_name=signal_name, xlabel=x_label, ylabel=y_label, + title="NXdata group %s: %s" % (group_name, signal_name)) + + def getDataPriority(self, data, info): + data = self.normalizeData(data) + if info.isNXdata: + nxd = NXdata(data) + if nxd.signal_is_2d: + if nxd.interpretation not in ["scalar", "spectrum", "scaler"]: + return 100 + if nxd.interpretation == "image": + return 100 + return DataView.UNSUPPORTED + + +class _NXdataStackView(DataView): + def __init__(self, parent): + DataView.__init__(self, parent) + + def createWidget(self, parent): + from silx.gui.data.NXdataWidgets import ArrayStackPlot + widget = ArrayStackPlot(parent) + return widget + + def axesNames(self, data, info): + return [] + + def clear(self): + self.getWidget().clear() + + def setData(self, data): + data = self.normalizeData(data) + nxd = NXdata(data) + signal_name = data.attrs["signal"] + group_name = data.name + z_axis, y_axis, x_axis = nxd.axes[-3:] + z_label, y_label, x_label = nxd.axes_names[-3:] + + self.getWidget().setStackData( + nxd.signal, x_axis=x_axis, y_axis=y_axis, z_axis=z_axis, + signal_name=signal_name, + xlabel=x_label, ylabel=y_label, zlabel=z_label, + title="NXdata group %s: %s" % (group_name, signal_name)) + + def getDataPriority(self, data, info): + data = self.normalizeData(data) + if info.isNXdata: + nxd = NXdata(data) + if nxd.signal_ndim >= 3: + if nxd.interpretation not in ["scalar", "scaler", + "spectrum", "image"]: + return 100 + return DataView.UNSUPPORTED + + +class _NXdataView(CompositeDataView): + """Composite view displaying NXdata groups using the most adequate + widget depending on the dimensionality.""" + def __init__(self, parent): + super(_NXdataView, self).__init__( + parent=parent, + label="NXdata", + icon=icons.getQIcon("view-nexus")) + + self.addView(_NXdataScalarView(parent)) + self.addView(_NXdataCurveView(parent)) + self.addView(_NXdataXYVScatterView(parent)) + self.addView(_NXdataImageView(parent)) + self.addView(_NXdataStackView(parent)) diff --git a/silx/gui/data/Hdf5TableView.py b/silx/gui/data/Hdf5TableView.py new file mode 100644 index 0000000..5d79907 --- /dev/null +++ b/silx/gui/data/Hdf5TableView.py @@ -0,0 +1,414 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 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 define model and widget to display 1D slices from numpy +array using compound data types or hdf5 databases. +""" +from __future__ import division + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "07/04/2017" + +import functools +import os.path +import logging +from silx.gui import qt +import silx.io +from .TextFormatter import TextFormatter +import silx.gui.hdf5 +from silx.gui.widgets import HierarchicalTableView + +_logger = logging.getLogger(__name__) + + +class _CellData(object): + """Store a table item + """ + def __init__(self, value=None, isHeader=False, span=None): + """ + Constructor + + :param str value: Label of this property + :param bool isHeader: True if the cell is an header + :param tuple span: Tuple of row, column span + """ + self.__value = value + self.__isHeader = isHeader + self.__span = span + + def isHeader(self): + """Returns true if the property is a sub-header title. + + :rtype: bool + """ + return self.__isHeader + + def value(self): + """Returns the value of the item. + """ + return self.__value + + def span(self): + """Returns the span size of the cell. + + :rtype: tuple + """ + return self.__span + + +class _TableData(object): + """Modelize a table with header, row and column span. + + It is mostly defined as a row based table. + """ + + def __init__(self, columnCount): + """Constructor. + + :param int columnCount: Define the number of column of the table + """ + self.__colCount = columnCount + self.__data = [] + + def rowCount(self): + """Returns the number of rows. + + :rtype: int + """ + return len(self.__data) + + def columnCount(self): + """Returns the number of columns. + + :rtype: int + """ + return self.__colCount + + def clear(self): + """Remove all the cells of the table""" + self.__data = [] + + def cellAt(self, row, column): + """Returns the cell at the row column location. Else None if there is + nothing. + + :rtype: _CellData + """ + if row < 0: + return None + if column < 0: + return None + if row >= len(self.__data): + return None + cells = self.__data[row] + if column >= len(cells): + return None + return cells[column] + + def addHeaderRow(self, headerLabel): + """Append the table with header on the full row. + + :param str headerLabel: label of the header. + """ + item = _CellData(value=headerLabel, isHeader=True, span=(1, self.__colCount)) + self.__data.append([item]) + + def addHeaderValueRow(self, headerLabel, value): + """Append the table with a row using the first column as an header and + other cells as a single cell for the value. + + :param str headerLabel: label of the header. + :param object value: value to store. + """ + header = _CellData(value=headerLabel, isHeader=True) + value = _CellData(value=value, span=(1, self.__colCount)) + self.__data.append([header, value]) + + def addRow(self, *args): + """Append the table with a row using arguments for each cells + + :param list[object] args: List of cell values for the row + """ + row = [] + for value in args: + if not isinstance(value, _CellData): + value = _CellData(value=value) + row.append(value) + self.__data.append(row) + + +class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): + """This data model provides access to HDF5 node content (File, Group, + Dataset). Main info, like name, file, attributes... are displayed + """ + + def __init__(self, parent=None, data=None): + """ + Constructor + + :param qt.QObject parent: Parent object + :param object data: An h5py-like object (file, group or dataset) + """ + super(Hdf5TableModel, self).__init__(parent) + + self.__obj = None + self.__data = _TableData(columnCount=4) + self.__formatter = None + formatter = TextFormatter(self) + self.setFormatter(formatter) + self.setObject(data) + + def rowCount(self, parent_idx=None): + """Returns number of rows to be displayed in table""" + return self.__data.rowCount() + + def columnCount(self, parent_idx=None): + """Returns number of columns to be displayed in table""" + return self.__data.columnCount() + + def data(self, index, role=qt.Qt.DisplayRole): + """QAbstractTableModel method to access data values + in the format ready to be displayed""" + if not index.isValid(): + return None + + cell = self.__data.cellAt(index.row(), index.column()) + if cell is None: + return None + + if role == self.SpanRole: + return cell.span() + elif role == self.IsHeaderRole: + return cell.isHeader() + elif role == qt.Qt.DisplayRole: + value = cell.value() + if callable(value): + value = value(self.__obj) + return str(value) + return None + + def flags(self, index): + """QAbstractTableModel method to inform the view whether data + is editable or not. + """ + return qt.QAbstractTableModel.flags(self, index) + + def isSupportedObject(self, h5pyObject): + """ + Returns true if the provided object can be modelized using this model. + """ + isSupported = False + isSupported = isSupported or silx.io.is_group(h5pyObject) + isSupported = isSupported or silx.io.is_dataset(h5pyObject) + isSupported = isSupported or isinstance(h5pyObject, silx.gui.hdf5.H5Node) + return isSupported + + def setObject(self, h5pyObject): + """Set the h5py-like object exposed by the model + + :param h5pyObject: A h5py-like object. It can be a `h5py.Dataset`, + a `h5py.File`, a `h5py.Group`. It also can be a, + `silx.gui.hdf5.H5Node` which is needed to display some local path + information. + """ + if qt.qVersion() > "4.6": + self.beginResetModel() + + if h5pyObject is None or self.isSupportedObject(h5pyObject): + self.__obj = h5pyObject + else: + _logger.warning("Object class %s unsupported. Object ignored.", type(h5pyObject)) + self.__initProperties() + + if qt.qVersion() > "4.6": + self.endResetModel() + else: + self.reset() + + def __initProperties(self): + """Initialize the list of available properties according to the defined + h5py-like object.""" + self.__data.clear() + if self.__obj is None: + return + + obj = self.__obj + + hdf5obj = obj + if isinstance(obj, silx.gui.hdf5.H5Node): + hdf5obj = obj.h5py_object + + if silx.io.is_file(hdf5obj): + objectType = "File" + elif silx.io.is_group(hdf5obj): + objectType = "Group" + elif silx.io.is_dataset(hdf5obj): + objectType = "Dataset" + else: + objectType = obj.__class__.__name__ + self.__data.addHeaderRow(headerLabel="HDF5 %s" % objectType) + self.__data.addHeaderRow(headerLabel="Path info") + + self.__data.addHeaderValueRow("basename", lambda x: os.path.basename(x.name)) + self.__data.addHeaderValueRow("name", lambda x: x.name) + if silx.io.is_file(obj): + self.__data.addHeaderValueRow("filename", lambda x: x.filename) + + if isinstance(obj, silx.gui.hdf5.H5Node): + # helpful informations if the object come from an HDF5 tree + self.__data.addHeaderValueRow("local_basename", lambda x: x.local_basename) + self.__data.addHeaderValueRow("local_name", lambda x: x.local_name) + self.__data.addHeaderValueRow("local_filename", lambda x: x.local_file.filename) + + if hasattr(obj, "dtype"): + self.__data.addHeaderRow(headerLabel="Data info") + self.__data.addHeaderValueRow("dtype", lambda x: x.dtype) + if hasattr(obj, "shape"): + self.__data.addHeaderValueRow("shape", lambda x: x.shape) + if hasattr(obj, "size"): + self.__data.addHeaderValueRow("size", lambda x: x.size) + if hasattr(obj, "chunks") and obj.chunks is not None: + self.__data.addHeaderValueRow("chunks", lambda x: x.chunks) + + # relative to compression + # h5py expose compression, compression_opts but are not initialized + # for external plugins, then we use id + # h5py also expose fletcher32 and shuffle attributes, but it is also + # part of the filters + if hasattr(obj, "shape") and hasattr(obj, "id"): + dcpl = obj.id.get_create_plist() + if dcpl.get_nfilters() > 0: + self.__data.addHeaderRow(headerLabel="Compression info") + pos = _CellData(value="Position", isHeader=True) + hdf5id = _CellData(value="HDF5 ID", isHeader=True) + name = _CellData(value="Name", isHeader=True) + options = _CellData(value="Options", isHeader=True) + self.__data.addRow(pos, hdf5id, name, options) + for index in range(dcpl.get_nfilters()): + callback = lambda index, dataIndex, x: self.__get_filter_info(x, index)[dataIndex] + pos = _CellData(value=functools.partial(callback, index, 0)) + hdf5id = _CellData(value=functools.partial(callback, index, 1)) + name = _CellData(value=functools.partial(callback, index, 2)) + options = _CellData(value=functools.partial(callback, index, 3)) + self.__data.addRow(pos, hdf5id, name, options) + + if hasattr(obj, "attrs"): + if len(obj.attrs) > 0: + self.__data.addHeaderRow(headerLabel="Attributes") + for key in sorted(obj.attrs.keys()): + callback = lambda key, x: self.__formatter.toString(x.attrs[key]) + self.__data.addHeaderValueRow(headerLabel=key, value=functools.partial(callback, key)) + + def __get_filter_info(self, dataset, filterIndex): + """Get a tuple of readable info from dataset filters + + :param h5py.Dataset dataset: A h5py dataset + :param int filterId: + """ + try: + dcpl = dataset.id.get_create_plist() + info = dcpl.get_filter(filterIndex) + filterId, _flags, cdValues, name = info + name = self.__formatter.toString(name) + options = " ".join([self.__formatter.toString(i) for i in cdValues]) + return (filterIndex, filterId, name, options) + except Exception: + _logger.debug("Backtrace", exc_info=True) + return [filterIndex, None, None, None] + + def object(self): + """Returns the internal object modelized. + + :rtype: An h5py-like object + """ + return self.__obj + + 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 + + if qt.qVersion() > "4.6": + 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) + + if qt.qVersion() > "4.6": + self.endResetModel() + else: + self.reset() + + def getFormatter(self): + """Returns the text formatter used. + + :rtype: TextFormatter + """ + return self.__formatter + + def __formatChanged(self): + """Called when the format changed. + """ + self.reset() + + +class Hdf5TableView(HierarchicalTableView.HierarchicalTableView): + """A widget to display metadata about a HDF5 node using a table.""" + + def __init__(self, parent=None): + super(Hdf5TableView, self).__init__(parent) + self.setModel(Hdf5TableModel(self)) + + def isSupportedData(self, data): + """ + Returns true if the provided object can be modelized using this model. + """ + return self.model().isSupportedObject(data) + + def setData(self, data): + """Set the h5py-like object exposed by the model + + :param h5pyObject: A h5py-like object. It can be a `h5py.Dataset`, + a `h5py.File`, a `h5py.Group`. It also can be a, + `silx.gui.hdf5.H5Node` which is needed to display some local path + information. + """ + self.model().setObject(data) + header = self.horizontalHeader() + if qt.qVersion() < "5.0": + setResizeMode = header.setResizeMode + else: + setResizeMode = header.setSectionResizeMode + setResizeMode(0, qt.QHeaderView.Fixed) + setResizeMode(1, qt.QHeaderView.Stretch) + header.setStretchLastSection(True) diff --git a/silx/gui/data/NXdataWidgets.py b/silx/gui/data/NXdataWidgets.py new file mode 100644 index 0000000..343c7f9 --- /dev/null +++ b/silx/gui/data/NXdataWidgets.py @@ -0,0 +1,523 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 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 widgets used by _NXdataView. +""" +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "20/03/2017" + +import numpy + +from silx.gui import qt +from silx.gui.data.NumpyAxesSelector import NumpyAxesSelector +from silx.gui.plot import Plot1D, Plot2D, StackView + +from silx.math.calibration import ArrayCalibration, NoCalibration, LinearCalibration + + +class ArrayCurvePlot(qt.QWidget): + """ + Widget for plotting a curve from a multi-dimensional signal array + and a 1D axis array. + + The signal array can have an arbitrary number of dimensions, the only + limitation being that the last dimension must have the same length as + the axis array. + + The widget provides sliders to select indices on the first (n - 1) + dimensions of the signal array, and buttons to add/replace selected + curves to the plot. + + This widget also handles simple 2D or 3D scatter plots (third dimension + displayed as colour of points). + """ + def __init__(self, parent=None): + """ + + :param parent: Parent QWidget + """ + super(ArrayCurvePlot, self).__init__(parent) + + self.__signal = None + self.__signal_name = None + self.__signal_errors = None + self.__axis = None + self.__axis_name = None + self.__axis_errors = None + self.__values = None + + self.__first_curve_added = False + + self._plot = Plot1D(self) + self._plot.setDefaultColormap( # for scatters + {"name": "viridis", + "vmin": 0., "vmax": 1., # ignored (autoscale) but mandatory + "normalization": "linear", + "autoscale": True}) + + self.selectorDock = qt.QDockWidget("Data selector", self._plot) + # not closable + self.selectorDock.setFeatures(qt.QDockWidget.DockWidgetMovable | + qt.QDockWidget.DockWidgetFloatable) + self._selector = NumpyAxesSelector(self.selectorDock) + self._selector.setNamedAxesSelectorVisibility(False) + self.__selector_is_connected = False + self.selectorDock.setWidget(self._selector) + self._plot.addTabbedDockWidget(self.selectorDock) + + layout = qt.QGridLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._plot, 0, 0) + + self.setLayout(layout) + + def setCurveData(self, y, x=None, values=None, + yerror=None, xerror=None, + ylabel=None, xlabel=None, title=None): + """ + + :param y: dataset to be represented by the y (vertical) axis. + For a scatter, this must be a 1D array and x and values must be + 1-D arrays of the same size. + In other cases, it can be a n-D array whose last dimension must + have the same length as x (and values must be None) + :param x: 1-D dataset used as the curve's x values. If provided, + its lengths must be equal to the length of the last dimension of + ``y`` (and equal to the length of ``value``, for a scatter plot). + :param values: Values, to be provided for a x-y-value scatter plot. + This will be used to compute the color map and assign colors + to the points. + :param yerror: 1-D dataset of errors for y, or None + :param xerror: 1-D dataset of errors for x, or None + :param ylabel: Label for Y axis + :param xlabel: Label for X axis + :param title: Graph title + """ + self.__signal = y + self.__signal_name = ylabel + self.__signal_errors = yerror + self.__axis = x + self.__axis_name = xlabel + self.__axis_errors = xerror + self.__values = values + + if self.__selector_is_connected: + self._selector.selectionChanged.disconnect(self._updateCurve) + self.__selector_is_connected = False + self._selector.setData(y) + self._selector.setAxisNames([ylabel or "Y"]) + + if len(y.shape) < 2: + self.selectorDock.hide() + else: + self.selectorDock.show() + + self._plot.setGraphTitle(title or "") + self._plot.setGraphXLabel(self.__axis_name or "X") + self._plot.setGraphYLabel(self.__signal_name or "Y") + self._updateCurve() + + if not self.__selector_is_connected: + self._selector.selectionChanged.connect(self._updateCurve) + self.__selector_is_connected = True + + def _updateCurve(self): + y = self._selector.selectedData() + x = self.__axis + if x is None: + x = numpy.arange(len(y)) + elif numpy.isscalar(x) or len(x) == 1: + # constant axis + x = x * numpy.ones_like(y) + elif len(x) == 2 and len(y) != 2: + # linear calibration a + b * x + x = x[0] + x[1] * numpy.arange(len(y)) + legend = self.__signal_name + "[" + for sl in self._selector.selection(): + if sl == slice(None): + legend += ":, " + else: + legend += str(sl) + ", " + legend = legend[:-2] + "]" + if self.__signal_errors is not None: + y_errors = self.__signal_errors[self._selector.selection()] + else: + y_errors = None + + self._plot.remove(kind=("curve", "scatter")) + + # values: x-y-v scatter + if self.__values is not None: + self._plot.addScatter(x, y, self.__values, + legend=legend, + xerror=self.__axis_errors, + yerror=y_errors) + + # x monotonically increasing: curve + elif numpy.all(numpy.diff(x) > 0): + self._plot.addCurve(x, y, legend=legend, + xerror=self.__axis_errors, + yerror=y_errors) + + # scatter + else: + self._plot.addScatter(x, y, value=numpy.ones_like(y), + legend=legend, + xerror=self.__axis_errors, + yerror=y_errors) + self._plot.resetZoom() + self._plot.setGraphXLabel(self.__axis_name) + self._plot.setGraphYLabel(self.__signal_name) + + def clear(self): + self._plot.clear() + + +class ArrayImagePlot(qt.QWidget): + """ + Widget for plotting an image from a multi-dimensional signal array + and two 1D axes array. + + The signal array can have an arbitrary number of dimensions, the only + limitation being that the last two dimensions must have the same length as + the axes arrays. + + Sliders are provided to select indices on the first (n - 2) dimensions of + the signal array, and the plot is updated to show the image corresponding + to the selection. + + If one or both of the axes does not have regularly spaced values, the + the image is plotted as a coloured scatter plot. + """ + def __init__(self, parent=None): + """ + + :param parent: Parent QWidget + """ + super(ArrayImagePlot, self).__init__(parent) + + self.__signal = None + self.__signal_name = None + self.__x_axis = None + self.__x_axis_name = None + self.__y_axis = None + self.__y_axis_name = None + + self._plot = Plot2D(self) + self._plot.setDefaultColormap( + {"name": "viridis", + "vmin": 0., "vmax": 1., # ignored (autoscale) but mandatory + "normalization": "linear", + "autoscale": True}) + + self.selectorDock = qt.QDockWidget("Data selector", self._plot) + # not closable + self.selectorDock.setFeatures(qt.QDockWidget.DockWidgetMovable | + qt.QDockWidget.DockWidgetFloatable) + self._legend = qt.QLabel(self) + self._selector = NumpyAxesSelector(self.selectorDock) + self._selector.setNamedAxesSelectorVisibility(False) + self.__selector_is_connected = False + + layout = qt.QVBoxLayout() + layout.addWidget(self._plot) + layout.addWidget(self._legend) + self.selectorDock.setWidget(self._selector) + self._plot.addTabbedDockWidget(self.selectorDock) + + self.setLayout(layout) + + def setImageData(self, signal, + x_axis=None, y_axis=None, + signal_name=None, + xlabel=None, ylabel=None, + title=None): + """ + + :param signal: n-D dataset, whose last 2 dimensions are used as the + image's values. + :param x_axis: 1-D dataset used as the image's x coordinates. If + provided, its lengths must be equal to the length of the last + dimension of ``signal``. + :param y_axis: 1-D dataset used as the image's y. If provided, + its lengths must be equal to the length of the 2nd to last + dimension of ``signal``. + :param signal_name: Label used in the legend + :param xlabel: Label for X axis + :param ylabel: Label for Y axis + :param title: Graph title + """ + if self.__selector_is_connected: + self._selector.selectionChanged.disconnect(self._updateImage) + self.__selector_is_connected = False + + self.__signal = signal + self.__signal_name = signal_name or "" + self.__x_axis = x_axis + self.__x_axis_name = xlabel + self.__y_axis = y_axis + self.__y_axis_name = ylabel + + self._selector.setData(signal) + self._selector.setAxisNames([ylabel or "Y", xlabel or "X"]) + + if len(signal.shape) < 3: + self.selectorDock.hide() + else: + self.selectorDock.show() + + self._plot.setGraphTitle(title or "") + self._plot.setGraphXLabel(self.__x_axis_name or "X") + self._plot.setGraphYLabel(self.__y_axis_name or "Y") + + self._updateImage() + + if not self.__selector_is_connected: + self._selector.selectionChanged.connect(self._updateImage) + self.__selector_is_connected = True + + def _updateImage(self): + legend = self.__signal_name + "[" + for sl in self._selector.selection(): + if sl == slice(None): + legend += ":, " + else: + legend += str(sl) + ", " + legend = legend[:-2] + "]" + self._legend.setText("Displayed data: " + legend) + + img = self._selector.selectedData() + x_axis = self.__x_axis + y_axis = self.__y_axis + + if x_axis is None and y_axis is None: + xcalib = NoCalibration() + ycalib = NoCalibration() + else: + if x_axis is None: + # no calibration + x_axis = numpy.arange(img.shape[-1]) + elif numpy.isscalar(x_axis) or len(x_axis) == 1: + # constant axis + x_axis = x_axis * numpy.ones((img.shape[-1], )) + elif len(x_axis) == 2: + # linear calibration + x_axis = x_axis[0] * numpy.arange(img.shape[-1]) + x_axis[1] + + if y_axis is None: + y_axis = numpy.arange(img.shape[-2]) + elif numpy.isscalar(y_axis) or len(y_axis) == 1: + y_axis = y_axis * numpy.ones((img.shape[-2], )) + elif len(y_axis) == 2: + y_axis = y_axis[0] * numpy.arange(img.shape[-2]) + y_axis[1] + + xcalib = ArrayCalibration(x_axis) + ycalib = ArrayCalibration(y_axis) + + self._plot.remove(kind=("scatter", "image")) + if xcalib.is_affine() and ycalib.is_affine(): + # regular image + xorigin, xscale = xcalib(0), xcalib.get_slope() + yorigin, yscale = ycalib(0), ycalib.get_slope() + origin = (xorigin, yorigin) + scale = (xscale, yscale) + + self._plot.addImage(img, legend=legend, + origin=origin, scale=scale) + else: + scatterx, scattery = numpy.meshgrid(x_axis, y_axis) + self._plot.addScatter(numpy.ravel(scatterx), + numpy.ravel(scattery), + numpy.ravel(img), + legend=legend) + self._plot.setGraphXLabel(self.__x_axis_name) + self._plot.setGraphYLabel(self.__y_axis_name) + self._plot.resetZoom() + + def clear(self): + self._plot.clear() + + +class ArrayStackPlot(qt.QWidget): + """ + Widget for plotting a n-D array (n >= 3) as a stack of images. + Three axis arrays can be provided to calibrate the axes. + + The signal array can have an arbitrary number of dimensions, the only + limitation being that the last 3 dimensions must have the same length as + the axes arrays. + + Sliders are provided to select indices on the first (n - 3) dimensions of + the signal array, and the plot is updated to load the stack corresponding + to the selection. + """ + def __init__(self, parent=None): + """ + + :param parent: Parent QWidget + """ + super(ArrayStackPlot, self).__init__(parent) + + self.__signal = None + self.__signal_name = None + # the Z, Y, X axes apply to the last three dimensions of the signal + # (in that order) + self.__z_axis = None + self.__z_axis_name = None + self.__y_axis = None + self.__y_axis_name = None + self.__x_axis = None + self.__x_axis_name = None + + self._stack_view = StackView(self) + self._hline = qt.QFrame(self) + self._hline.setFrameStyle(qt.QFrame.HLine) + self._hline.setFrameShadow(qt.QFrame.Sunken) + self._legend = qt.QLabel(self) + self._selector = NumpyAxesSelector(self) + self._selector.setNamedAxesSelectorVisibility(False) + self.__selector_is_connected = False + + layout = qt.QVBoxLayout() + layout.addWidget(self._stack_view) + layout.addWidget(self._hline) + layout.addWidget(self._legend) + layout.addWidget(self._selector) + + self.setLayout(layout) + + def setStackData(self, signal, + x_axis=None, y_axis=None, z_axis=None, + signal_name=None, + xlabel=None, ylabel=None, zlabel=None, + title=None): + """ + + :param signal: n-D dataset, whose last 3 dimensions are used as the + 3D stack values. + :param x_axis: 1-D dataset used as the image's x coordinates. If + provided, its lengths must be equal to the length of the last + dimension of ``signal``. + :param y_axis: 1-D dataset used as the image's y. If provided, + its lengths must be equal to the length of the 2nd to last + dimension of ``signal``. + :param z_axis: 1-D dataset used as the image's z. If provided, + its lengths must be equal to the length of the 3rd to last + dimension of ``signal``. + :param signal_name: Label used in the legend + :param xlabel: Label for X axis + :param ylabel: Label for Y axis + :param zlabel: Label for Z axis + :param title: Graph title + """ + if self.__selector_is_connected: + self._selector.selectionChanged.disconnect(self._updateStack) + self.__selector_is_connected = False + + self.__signal = signal + self.__signal_name = signal_name or "" + self.__x_axis = x_axis + self.__x_axis_name = xlabel + self.__y_axis = y_axis + self.__y_axis_name = ylabel + self.__z_axis = z_axis + self.__z_axis_name = zlabel + + self._selector.setData(signal) + self._selector.setAxisNames([ylabel or "Y", xlabel or "X", zlabel or "Z"]) + + self._stack_view.setGraphTitle(title or "") + # by default, the z axis is the image position (dimension not plotted) + self._stack_view.setGraphXLabel(self.__x_axis_name or "X") + self._stack_view.setGraphYLabel(self.__y_axis_name or "Y") + + self._updateStack() + + ndims = len(signal.shape) + self._stack_view.setFirstStackDimension(ndims - 3) + + # the legend label shows the selection slice producing the volume + # (only interesting for ndim > 3) + if ndims > 3: + self._selector.setVisible(True) + self._legend.setVisible(True) + self._hline.setVisible(True) + else: + self._selector.setVisible(False) + self._legend.setVisible(False) + self._hline.setVisible(False) + + if not self.__selector_is_connected: + self._selector.selectionChanged.connect(self._updateStack) + self.__selector_is_connected = True + + @staticmethod + def _get_origin_scale(axis): + """Assuming axis is a regularly spaced 1D array, + return a tuple (origin, scale) where: + - origin = axis[0] + - scale = (axis[n-1] - axis[0]) / (n -1) + :param axis: 1D numpy array + :return: Tuple (axis[0], (axis[-1] - axis[0]) / (len(axis) - 1)) + """ + return axis[0], (axis[-1] - axis[0]) / (len(axis) - 1) + + def _updateStack(self): + """Update displayed stack according to the current axes selector + data.""" + stk = self._selector.selectedData() + x_axis = self.__x_axis + y_axis = self.__y_axis + z_axis = self.__z_axis + + calibrations = [] + for axis in [z_axis, y_axis, x_axis]: + + if axis is None: + calibrations.append(NoCalibration()) + elif len(axis) == 2: + calibrations.append( + LinearCalibration(y_intercept=axis[0], + slope=axis[1])) + else: + calibrations.append(ArrayCalibration(axis)) + + legend = self.__signal_name + "[" + for sl in self._selector.selection(): + if sl == slice(None): + legend += ":, " + else: + legend += str(sl) + ", " + legend = legend[:-2] + "]" + self._legend.setText("Displayed data: " + legend) + + self._stack_view.setStack(stk, calibrations=calibrations) + self._stack_view.setLabels( + labels=[self.__z_axis_name, + self.__y_axis_name, + self.__x_axis_name]) + + def clear(self): + self._stack_view.clear() diff --git a/silx/gui/data/NumpyAxesSelector.py b/silx/gui/data/NumpyAxesSelector.py new file mode 100644 index 0000000..f4641da --- /dev/null +++ b/silx/gui/data/NumpyAxesSelector.py @@ -0,0 +1,468 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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 widget able to convert a numpy array from n-dimensions +to a numpy array with less dimensions. +""" +from __future__ import division + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "16/01/2017" + +import numpy +import functools +from silx.gui.widgets.FrameBrowser import HorizontalSliderWithBrowser +from silx.gui import qt +import silx.utils.weakref + + +class _Axis(qt.QWidget): + """Widget displaying an axis. + + It allows to display and scroll in the axis, and provide a widget to + map the axis with a named axis (the one from the view). + """ + + valueChanged = qt.Signal(int) + """Emitted when the location on the axis change.""" + + axisNameChanged = qt.Signal(object) + """Emitted when the user change the name of the axis.""" + + def __init__(self, parent=None): + """Constructor + + :param parent: Parent of the widget + """ + super(_Axis, self).__init__(parent) + self.__axisNumber = None + self.__customAxisNames = set([]) + self.__label = qt.QLabel(self) + self.__axes = qt.QComboBox(self) + self.__axes.currentIndexChanged[int].connect(self.__axisMappingChanged) + self.__slider = HorizontalSliderWithBrowser(self) + self.__slider.valueChanged[int].connect(self.__sliderValueChanged) + layout = qt.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.__label) + layout.addWidget(self.__axes) + layout.addWidget(self.__slider, 10000) + layout.addStretch(1) + self.setLayout(layout) + + def slider(self): + """Returns the slider used to display axes location. + + :rtype: HorizontalSliderWithBrowser + """ + return self.__slider + + def setAxis(self, number, position, size): + """Set axis information. + + :param int number: The number of the axis (from the original numpy + array) + :param int position: The current position in the axis (for a slicing) + :param int size: The size of this axis (0..n) + """ + self.__label.setText("Dimension %s" % number) + self.__axisNumber = number + self.__slider.setMaximum(size - 1) + + def axisNumber(self): + """Returns the axis number. + + :rtype: int + """ + return self.__axisNumber + + def setAxisName(self, axisName): + """Set the current used axis name. + + If this name is not available an exception is raised. An empty string + means that no name is selected. + + :param str axisName: The new name of the axis + :raise ValueError: When the name is not available + """ + if axisName == "" and self.__axes.count() == 0: + self.__axes.setCurrentIndex(-1) + self.__updateSliderVisibility() + for index in range(self.__axes.count()): + name = self.__axes.itemData(index) + if name == axisName: + self.__axes.setCurrentIndex(index) + self.__updateSliderVisibility() + return + raise ValueError("Axis name '%s' not found", axisName) + + def axisName(self): + """Returns the selected axis name. + + If no names are selected, an empty string is retruned. + + :rtype: str + """ + index = self.__axes.currentIndex() + if index == -1: + return "" + return self.__axes.itemData(index) + + def setAxisNames(self, axesNames): + """Set the available list of names for the axis. + + :param list[str] axesNames: List of available names + """ + self.__axes.clear() + previous = self.__axes.blockSignals(True) + self.__axes.addItem(" ", "") + for axis in axesNames: + self.__axes.addItem(axis, axis) + self.__axes.blockSignals(previous) + self.__updateSliderVisibility() + + def setCustomAxis(self, axesNames): + """Set the available list of named axis which can be set to a value. + + :param list[str] axesNames: List of customable axis names + """ + self.__customAxisNames = set(axesNames) + self.__updateSliderVisibility() + + def __axisMappingChanged(self, index): + """Called when the selected name change. + + :param int index: Selected index + """ + self.__updateSliderVisibility() + name = self.axisName() + self.axisNameChanged.emit(name) + + def __updateSliderVisibility(self): + """Update the visibility of the slider according to axis names and + customable axis names.""" + name = self.axisName() + isVisible = name == "" or name in self.__customAxisNames + self.__slider.setVisible(isVisible) + + def value(self): + """Returns the current selected position in the axis. + + :rtype: int + """ + return self.__slider.value() + + def __sliderValueChanged(self, value): + """Called when the selected position in the axis change. + + :param int value: Position of the axis + """ + self.valueChanged.emit(value) + + def setNamedAxisSelectorVisibility(self, visible): + """Hide or show the named axis combobox. + If both the selector and the slider are hidden, + hide the entire widget. + + :param visible: boolean + """ + self.__axes.setVisible(visible) + name = self.axisName() + + if not visible and name != "": + self.setVisible(False) + else: + self.setVisible(True) + + +class NumpyAxesSelector(qt.QWidget): + """Widget to select a view from a numpy array. + + .. image:: img/NumpyAxesSelector.png + + The widget is set with an input data using :meth:`setData`, and a requested + output dimension using :meth:`setAxisNames`. + + Widgets are provided to selected expected input axis, and a slice on the + non-selected axis. + + The final selected array can be reached using the getter + :meth:`selectedData`, and the event `selectionChanged`. + + If the input data is a HDF5 Dataset, the selected output data will be a + new numpy array. + """ + + dataChanged = qt.Signal() + """Emitted when the input data change""" + + selectedAxisChanged = qt.Signal() + """Emitted when the selected axis change""" + + selectionChanged = qt.Signal() + """Emitted when the selected data change""" + + customAxisChanged = qt.Signal(str, int) + """Emitted when a custom axis change""" + + def __init__(self, parent=None): + """Constructor + + :param parent: Parent of the widget + """ + super(NumpyAxesSelector, self).__init__(parent) + + self.__data = None + self.__selectedData = None + self.__selection = tuple() + self.__axis = [] + self.__axisNames = [] + self.__customAxisNames = set([]) + self.__namedAxesVisibility = True + layout = qt.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSizeConstraint(qt.QLayout.SetMinAndMaxSize) + self.setLayout(layout) + + def clear(self): + """Clear the widget.""" + self.setData(None) + + def setAxisNames(self, axesNames): + """Set the axis names of the output selected data. + + Axis names are defined from slower to faster axis. + + The size of the list will constrain the dimension of the resulting + array. + + :param list[str] axesNames: List of string identifying axis names + """ + self.__axisNames = list(axesNames) + delta = len(self.__axis) - len(self.__axisNames) + if delta < 0: + delta = 0 + for index, axis in enumerate(self.__axis): + previous = axis.blockSignals(True) + axis.setAxisNames(self.__axisNames) + if index >= delta and index - delta < len(self.__axisNames): + axis.setAxisName(self.__axisNames[index - delta]) + else: + axis.setAxisName("") + axis.blockSignals(previous) + self.__updateSelectedData() + + def setCustomAxis(self, axesNames): + """Set the available list of named axis which can be set to a value. + + :param list[str] axesNames: List of customable axis names + """ + self.__customAxisNames = set(axesNames) + for axis in self.__axis: + axis.setCustomAxis(self.__customAxisNames) + + def setData(self, data): + """Set the input data unsed by the widget. + + :param numpy.ndarray data: The input data + """ + if self.__data is not None: + # clean up + for widget in self.__axis: + self.layout().removeWidget(widget) + widget.deleteLater() + self.__axis = [] + + self.__data = data + + if data is not None: + # create expected axes + dimensionNumber = len(data.shape) + delta = dimensionNumber - len(self.__axisNames) + for index in range(dimensionNumber): + axis = _Axis(self) + axis.setAxis(index, 0, data.shape[index]) + axis.setAxisNames(self.__axisNames) + axis.setCustomAxis(self.__customAxisNames) + if index >= delta and index - delta < len(self.__axisNames): + axis.setAxisName(self.__axisNames[index - delta]) + # this weak method was expected to be able to delete sub widget + callback = functools.partial(silx.utils.weakref.WeakMethodProxy(self.__axisValueChanged), axis) + axis.valueChanged.connect(callback) + # this weak method was expected to be able to delete sub widget + callback = functools.partial(silx.utils.weakref.WeakMethodProxy(self.__axisNameChanged), axis) + axis.axisNameChanged.connect(callback) + axis.setNamedAxisSelectorVisibility(self.__namedAxesVisibility) + self.layout().addWidget(axis) + self.__axis.append(axis) + self.__normalizeAxisGeometry() + + self.dataChanged.emit() + self.__updateSelectedData() + + def __normalizeAxisGeometry(self): + """Update axes geometry to align all axes components together.""" + if len(self.__axis) <= 0: + return + lineEditWidth = max([a.slider().lineEdit().minimumSize().width() for a in self.__axis]) + limitWidth = max([a.slider().limitWidget().minimumSizeHint().width() for a in self.__axis]) + for a in self.__axis: + a.slider().lineEdit().setFixedWidth(lineEditWidth) + a.slider().limitWidget().setFixedWidth(limitWidth) + + def __axisValueChanged(self, axis, value): + name = axis.axisName() + if name in self.__customAxisNames: + self.customAxisChanged.emit(name, value) + else: + self.__updateSelectedData() + + def __axisNameChanged(self, axis, name): + """Called when an axis name change. + + :param _Axis axis: The changed axis + :param str name: The new name of the axis + """ + names = [x.axisName() for x in self.__axis] + missingName = set(self.__axisNames) - set(names) - set("") + if len(missingName) == 0: + missingName = None + elif len(missingName) == 1: + missingName = list(missingName)[0] + else: + raise Exception("Unexpected state") + + axisChanged = True + + if axis.axisName() == "": + # set the removed label to another widget if it is possible + availableWidget = None + for widget in self.__axis: + if widget is axis: + continue + if widget.axisName() == "": + availableWidget = widget + break + if availableWidget is None: + # If there is no other solution we set the name at the same place + axisChanged = False + availableWidget = axis + previous = availableWidget.blockSignals(True) + availableWidget.setAxisName(missingName) + availableWidget.blockSignals(previous) + else: + # there is a duplicated name somewhere + # we swap it with the missing name or with nothing + dupWidget = None + for widget in self.__axis: + if widget is axis: + continue + if widget.axisName() == axis.axisName(): + dupWidget = widget + break + if missingName is None: + missingName = "" + previous = dupWidget.blockSignals(True) + dupWidget.setAxisName(missingName) + dupWidget.blockSignals(previous) + + if self.__data is None: + return + if axisChanged: + self.selectedAxisChanged.emit() + self.__updateSelectedData() + + def __updateSelectedData(self): + """Update the selected data according to the state of the widget. + + It fires a `selectionChanged` event. + """ + if self.__data is None: + if self.__selectedData is not None: + self.__selectedData = None + self.__selection = tuple() + self.selectionChanged.emit() + return + + selection = [] + axisNames = [] + for slider in self.__axis: + name = slider.axisName() + if name == "": + selection.append(slider.value()) + else: + selection.append(slice(None)) + axisNames.append(name) + + self.__selection = tuple(selection) + # get a view with few fixed dimensions + # with a h5py dataset, it create a copy + # TODO we can reuse the same memory in case of a copy + view = self.__data[self.__selection] + + # order axis as expected + source = [] + destination = [] + order = [] + for index, name in enumerate(self.__axisNames): + destination.append(index) + source.append(axisNames.index(name)) + for _, s in sorted(zip(destination, source)): + order.append(s) + view = numpy.transpose(view, order) + + self.__selectedData = view + self.selectionChanged.emit() + + def data(self): + """Returns the input data. + + :rtype: numpy.ndarray + """ + return self.__data + + def selectedData(self): + """Returns the output data. + + :rtype: numpy.ndarray + """ + return self.__selectedData + + def selection(self): + """Returns the selection tuple used to slice the data. + + :rtype: tuple + """ + return self.__selection + + def setNamedAxesSelectorVisibility(self, visible): + """Show or hide the combo-boxes allowing to map the plot axes + to the data dimension. + + :param visible: Boolean + """ + self.__namedAxesVisibility = visible + for axis in self.__axis: + axis.setNamedAxisSelectorVisibility(visible) diff --git a/silx/gui/data/RecordTableView.py b/silx/gui/data/RecordTableView.py new file mode 100644 index 0000000..ce6a178 --- /dev/null +++ b/silx/gui/data/RecordTableView.py @@ -0,0 +1,405 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 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 define model and widget to display 1D slices from numpy +array using compound data types or hdf5 databases. +""" +from __future__ import division + +import itertools +import numpy +from silx.gui import qt +import silx.io +from .TextFormatter import TextFormatter +from silx.gui.widgets.TableWidget import CopySelectedCellsAction + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "27/01/2017" + + +class _MultiLineItem(qt.QItemDelegate): + """Draw a multiline text without hiding anything. + + The paint method display a cell without any wrap. And an editor is + available to scroll into the selected cell. + """ + + def __init__(self, parent=None): + """ + Constructor + + :param qt.QWidget parent: Parent of the widget + """ + qt.QItemDelegate.__init__(self, parent) + self.__textOptions = qt.QTextOption() + self.__textOptions.setFlags(qt.QTextOption.IncludeTrailingSpaces | + qt.QTextOption.ShowTabsAndSpaces) + self.__textOptions.setWrapMode(qt.QTextOption.NoWrap) + self.__textOptions.setAlignment(qt.Qt.AlignTop | qt.Qt.AlignLeft) + + def paint(self, painter, option, index): + """ + Write multiline text without using any wrap or any alignment according + to the cell size. + + :param qt.QPainter painter: Painter context used to displayed the cell + :param qt.QStyleOptionViewItem option: Control how the editor is shown + :param qt.QIndex index: Index of the data to display + """ + painter.save() + + # set colors + painter.setPen(qt.QPen(qt.Qt.NoPen)) + if option.state & qt.QStyle.State_Selected: + brush = option.palette.highlight() + painter.setBrush(brush) + else: + brush = index.data(qt.Qt.BackgroundRole) + if brush is None: + # default background color for a cell + brush = qt.Qt.white + painter.setBrush(brush) + painter.drawRect(option.rect) + + if index.isValid(): + if option.state & qt.QStyle.State_Selected: + brush = option.palette.highlightedText() + else: + brush = index.data(qt.Qt.ForegroundRole) + if brush is None: + brush = option.palette.text() + painter.setPen(qt.QPen(brush.color())) + text = index.data(qt.Qt.DisplayRole) + painter.drawText(qt.QRectF(option.rect), text, self.__textOptions) + + painter.restore() + + def createEditor(self, parent, option, index): + """ + Returns the widget used to edit the item specified by index for editing. + + We use it not to edit the content but to show the content with a + convenient scroll bar. + + :param qt.QWidget parent: Parent of the widget + :param qt.QStyleOptionViewItem option: Control how the editor is shown + :param qt.QIndex index: Index of the data to display + """ + if not index.isValid(): + return super(_MultiLineItem, self).createEditor(parent, option, index) + + editor = qt.QTextEdit(parent) + editor.setReadOnly(True) + return editor + + def setEditorData(self, editor, index): + """ + Read data from the model and feed the editor. + + :param qt.QWidget editor: Editor widget + :param qt.QIndex index: Index of the data to display + """ + text = index.model().data(index, qt.Qt.EditRole) + editor.setText(text) + + def updateEditorGeometry(self, editor, option, index): + """ + Update the geometry of the editor according to the changes of the view. + + :param qt.QWidget editor: Editor widget + :param qt.QStyleOptionViewItem option: Control how the editor is shown + :param qt.QIndex index: Index of the data to display + """ + editor.setGeometry(option.rect) + + +class RecordTableModel(qt.QAbstractTableModel): + """This data model provides access to 1D slices from numpy array using + compound data types or hdf5 databases. + + Each entries are displayed in a single row, and each columns contain a + specific field of the compound type. + + It also allows to display 1D arrays of simple data types. + array. + + :param qt.QObject parent: Parent object + :param numpy.ndarray data: A numpy array or a h5py dataset + """ + def __init__(self, parent=None, data=None): + qt.QAbstractTableModel.__init__(self, parent) + + self.__data = None + self.__is_array = False + self.__fields = None + self.__formatter = None + self.__editFormatter = None + self.setFormatter(TextFormatter(self)) + + # set _data + self.setArrayData(data) + + # Methods to be implemented to subclass QAbstractTableModel + def rowCount(self, parent_idx=None): + """Returns number of rows to be displayed in table""" + if self.__data is None: + return 0 + elif not self.__is_array: + return 1 + else: + return len(self.__data) + + def columnCount(self, parent_idx=None): + """Returns number of columns to be displayed in table""" + if self.__fields is None: + return 1 + else: + return len(self.__fields) + + def data(self, index, role=qt.Qt.DisplayRole): + """QAbstractTableModel method to access data values + in the format ready to be displayed""" + if not index.isValid(): + return None + + if self.__data is None: + return None + + if self.__is_array: + if index.row() >= len(self.__data): + return None + data = self.__data[index.row()] + else: + if index.row() > 0: + return None + data = self.__data + + if self.__fields is not None: + if index.column() >= len(self.__fields): + return None + key = self.__fields[index.column()][1] + data = data[key[0]] + if len(key) > 1: + data = data[key[1]] + + if role == qt.Qt.DisplayRole: + return self.__formatter.toString(data) + elif role == qt.Qt.EditRole: + return self.__editFormatter.toString(data) + return None + + def headerData(self, section, orientation, role=qt.Qt.DisplayRole): + """Returns the 0-based row or column index, for display in the + horizontal and vertical headers""" + if section == -1: + # PyQt4 send -1 when there is columns but no rows + return None + + if role == qt.Qt.DisplayRole: + if orientation == qt.Qt.Vertical: + if not self.__is_array: + return "Scalar" + else: + return str(section) + if orientation == qt.Qt.Horizontal: + if self.__fields is None: + if section == 0: + return "Data" + else: + return None + else: + if section < len(self.__fields): + return self.__fields[section][0] + else: + return None + return None + + def flags(self, index): + """QAbstractTableModel method to inform the view whether data + is editable or not. + """ + return qt.QAbstractTableModel.flags(self, index) + + def setArrayData(self, data): + """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: 1D numpy array, or any object that can be + converted to a numpy array using ``numpy.array(data)`` (e.g. + a nested sequence). + """ + if qt.qVersion() > "4.6": + self.beginResetModel() + + self.__data = data + if isinstance(data, numpy.ndarray): + self.__is_array = True + elif silx.io.is_dataset(data) and data.shape != tuple(): + self.__is_array = True + else: + self.__is_array = False + + + self.__fields = [] + if data is not None: + if data.dtype.fields is not None: + for name, (dtype, _index) in data.dtype.fields.items(): + if dtype.shape != tuple(): + keys = itertools.product(*[range(x) for x in dtype.shape]) + for key in keys: + label = "%s%s" % (name, list(key)) + array_key = (name, key) + self.__fields.append((label, array_key)) + else: + self.__fields.append((name, (name,))) + else: + self.__fields = None + + if qt.qVersion() > "4.6": + self.endResetModel() + else: + self.reset() + + def arrayData(self): + """Returns the internal data. + + :rtype: numpy.ndarray of h5py.Dataset + """ + return self.__data + + 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 + + if qt.qVersion() > "4.6": + self.beginResetModel() + + if self.__formatter is not None: + self.__formatter.formatChanged.disconnect(self.__formatChanged) + + self.__formatter = formatter + self.__editFormatter = TextFormatter(formatter) + self.__editFormatter.setUseQuoteForText(False) + + if self.__formatter is not None: + self.__formatter.formatChanged.connect(self.__formatChanged) + + if qt.qVersion() > "4.6": + self.endResetModel() + else: + self.reset() + + def getFormatter(self): + """Returns the text formatter used. + + :rtype: TextFormatter + """ + return self.__formatter + + def __formatChanged(self): + """Called when the format changed. + """ + self.__editFormatter = TextFormatter(self, self.getFormatter()) + self.__editFormatter.setUseQuoteForText(False) + self.reset() + + +class _ShowEditorProxyModel(qt.QIdentityProxyModel): + """ + Allow to custom the flag edit of the model + """ + + def __init__(self, parent=None): + """ + Constructor + + :param qt.QObject arent: parent object + """ + super(_ShowEditorProxyModel, self).__init__(parent) + self.__forceEditable = False + + def flags(self, index): + flag = qt.QIdentityProxyModel.flags(self, index) + if self.__forceEditable: + flag = flag | qt.Qt.ItemIsEditable + return flag + + def forceCellEditor(self, show): + """ + Enable the editable flag to allow to display cell editor. + """ + if self.__forceEditable == show: + return + self.beginResetModel() + self.__forceEditable = show + self.endResetModel() + + +class RecordTableView(qt.QTableView): + """TableView using DatabaseTableModel as default model. + """ + def __init__(self, parent=None): + """ + Constructor + + :param qt.QWidget parent: parent QWidget + """ + qt.QTableView.__init__(self, parent) + + model = _ShowEditorProxyModel(self) + model.setSourceModel(RecordTableModel()) + self.setModel(model) + self.__multilineView = _MultiLineItem(self) + self.setEditTriggers(qt.QAbstractItemView.AllEditTriggers) + self._copyAction = CopySelectedCellsAction(self) + self.addAction(self._copyAction) + + def copy(self): + self._copyAction.trigger() + + def setArrayData(self, data): + self.model().sourceModel().setArrayData(data) + if data is not None: + if issubclass(data.dtype.type, (numpy.string_, numpy.unicode_)): + # TODO it would be nice to also fix fields + # but using it only for string array is already very useful + self.setItemDelegateForColumn(0, self.__multilineView) + self.model().forceCellEditor(True) + else: + self.setItemDelegateForColumn(0, None) + self.model().forceCellEditor(False) diff --git a/silx/gui/data/TextFormatter.py b/silx/gui/data/TextFormatter.py new file mode 100644 index 0000000..f074de5 --- /dev/null +++ b/silx/gui/data/TextFormatter.py @@ -0,0 +1,222 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 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 package provides a class sharred by widget from the +data module to format data as text in the same way.""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "26/04/2017" + +import numpy +import numbers +import binascii +from silx.third_party import six +from silx.gui import qt + + +class TextFormatter(qt.QObject): + """Formatter to convert data to string. + + The method :meth:`toString` returns a formatted string from an input data + using parameters set to this object. + + It support most python and numpy data, expecting dictionary. Unsupported + data are displayed using the string representation of the object (`str`). + + It provides a set of parameters to custom the formatting of integer and + float values (:meth:`setIntegerFormat`, :meth:`setFloatFormat`). + + It also allows to custom the use of quotes to display text data + (:meth:`setUseQuoteForText`), and custom unit used to display imaginary + numbers (:meth:`setImaginaryUnit`). + + The object emit an event `formatChanged` every time a parametter is + changed. + """ + + formatChanged = qt.Signal() + """Emitted when properties of the formatter change.""" + + def __init__(self, parent=None, formatter=None): + """ + Constructor + + :param qt.QObject parent: Owner of the object + :param TextFormatter formatter: Instantiate this object from the + formatter + """ + qt.QObject.__init__(self, parent) + if formatter is not None: + self.__integerFormat = formatter.integerFormat() + self.__floatFormat = formatter.floatFormat() + self.__useQuoteForText = formatter.useQuoteForText() + self.__imaginaryUnit = formatter.imaginaryUnit() + else: + self.__integerFormat = "%d" + self.__floatFormat = "%g" + self.__useQuoteForText = True + self.__imaginaryUnit = u"j" + + def integerFormat(self): + """Returns the format string controlling how the integer data + are formated by this object. + + This is the C-style format string used by python when formatting + strings with the modulus operator. + + :rtype: str + """ + return self.__integerFormat + + def setIntegerFormat(self, value): + """Set format string controlling how the integer data are + formated by this object. + + :param str value: Format string (e.g. "%d", "%i", "%08i"). + This is the C-style format string used by python when formatting + strings with the modulus operator. + """ + if self.__integerFormat == value: + return + self.__integerFormat = value + self.formatChanged.emit() + + def floatFormat(self): + """Returns the format string controlling how the floating-point data + are formated by this object. + + This is the C-style format string used by python when formatting + strings with the modulus operator. + + :rtype: str + """ + return self.__floatFormat + + def setFloatFormat(self, value): + """Set format string controlling how the floating-point data are + formated by this object. + + :param str value: Format string (e.g. "%.3f", "%d", "%-10.2f", + "%10.3e"). + This is the C-style format string used by python when formatting + strings with the modulus operator. + """ + if self.__floatFormat == value: + return + self.__floatFormat = value + self.formatChanged.emit() + + def useQuoteForText(self): + """Returns true if the string data are formatted using double quotes. + + Else, no quotes are used. + """ + return self.__integerFormat + + def setUseQuoteForText(self, useQuote): + """Set the use of quotes to delimit string data. + + :param bool useQuote: True to use quotes. + """ + if self.__useQuoteForText == useQuote: + return + self.__useQuoteForText = useQuote + self.formatChanged.emit() + + def imaginaryUnit(self): + """Returns the unit display for imaginary numbers. + + :rtype: str + """ + return self.__imaginaryUnit + + def setImaginaryUnit(self, imaginaryUnit): + """Set the unit display for imaginary numbers. + + :param str imaginaryUnit: Unit displayed after imaginary numbers + """ + if self.__imaginaryUnit == imaginaryUnit: + return + self.__imaginaryUnit = imaginaryUnit + self.formatChanged.emit() + + def toString(self, data): + """Format a data into a string using formatter options + + :param object data: Data to render + :rtype: str + """ + if isinstance(data, tuple): + text = [self.toString(d) for d in data] + return "(" + " ".join(text) + ")" + elif isinstance(data, (list, numpy.ndarray)): + text = [self.toString(d) for d in data] + return "[" + " ".join(text) + "]" + elif isinstance(data, numpy.void): + dtype = data.dtype + if data.dtype.fields is not None: + text = [self.toString(data[f]) for f in dtype.fields] + return "(" + " ".join(text) + ")" + return "0x" + binascii.hexlify(data).decode("ascii") + elif isinstance(data, (numpy.string_, numpy.object_, bytes)): + # This have to be done before checking python string inheritance + try: + text = "%s" % data.decode("utf-8") + if self.__useQuoteForText: + text = "\"%s\"" % text.replace("\"", "\\\"") + return text + except UnicodeDecodeError: + pass + return "0x" + binascii.hexlify(data).decode("ascii") + elif isinstance(data, six.string_types): + text = "%s" % data + if self.__useQuoteForText: + text = "\"%s\"" % text.replace("\"", "\\\"") + return text + elif isinstance(data, (numpy.integer, numbers.Integral)): + return self.__integerFormat % data + elif isinstance(data, (numbers.Real, numpy.floating)): + # It have to be done before complex checking + return self.__floatFormat % data + elif isinstance(data, (numpy.complex_, numbers.Complex)): + text = "" + if data.real != 0: + text += self.__floatFormat % data.real + if data.real != 0 and data.imag != 0: + if data.imag < 0: + template = self.__floatFormat + " - " + self.__floatFormat + self.__imaginaryUnit + params = (data.real, -data.imag) + else: + template = self.__floatFormat + " + " + self.__floatFormat + self.__imaginaryUnit + params = (data.real, data.imag) + else: + if data.imag != 0: + template = self.__floatFormat + self.__imaginaryUnit + params = (data.imag) + else: + template = self.__floatFormat + params = (data.real) + return template % params + return str(data) diff --git a/silx/gui/data/__init__.py b/silx/gui/data/__init__.py new file mode 100644 index 0000000..560062d --- /dev/null +++ b/silx/gui/data/__init__.py @@ -0,0 +1,35 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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 package provides a set of Qt widgets for displaying data arrays using +table views and plot widgets. + +.. note:: + + Widgets in this package may rely on additional dependencies that are + not mandatory for *silx*. + :class:`DataViewer.DataViewer` relies on :mod:`silx.gui.plot` which + depends on *matplotlib*. It also optionally depends on *PyOpenGL* for 3D + visualization. +""" diff --git a/silx/gui/data/setup.py b/silx/gui/data/setup.py new file mode 100644 index 0000000..23ccbdd --- /dev/null +++ b/silx/gui/data/setup.py @@ -0,0 +1,41 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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. +# +# ###########################################################################*/ +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "16/01/2017" + + +from numpy.distutils.misc_util import Configuration + + +def configuration(parent_package='', top_path=None): + config = Configuration('data', parent_package, top_path) + config.add_subpackage('test') + return config + + +if __name__ == "__main__": + from numpy.distutils.core import setup + setup(configuration=configuration) diff --git a/silx/gui/data/test/__init__.py b/silx/gui/data/test/__init__.py new file mode 100644 index 0000000..08c044b --- /dev/null +++ b/silx/gui/data/test/__init__.py @@ -0,0 +1,45 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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. +# +# ###########################################################################*/ +import unittest + +from . import test_arraywidget +from . import test_numpyaxesselector +from . import test_dataviewer +from . import test_textformatter + +__authors__ = ["V. Valls", "P. Knobel"] +__license__ = "MIT" +__date__ = "24/01/2017" + + +def suite(): + test_suite = unittest.TestSuite() + test_suite.addTests( + [test_arraywidget.suite(), + test_numpyaxesselector.suite(), + test_dataviewer.suite(), + test_textformatter.suite(), + ]) + return test_suite diff --git a/silx/gui/data/test/test_arraywidget.py b/silx/gui/data/test/test_arraywidget.py new file mode 100644 index 0000000..bbd7ee5 --- /dev/null +++ b/silx/gui/data/test/test_arraywidget.py @@ -0,0 +1,320 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016 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. +# +# ###########################################################################*/ +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "05/12/2016" + +import os +import tempfile +import unittest + +import numpy + +from silx.gui import qt +from silx.gui.data import ArrayTableWidget +from silx.gui.test.utils import TestCaseQt + +try: + import h5py +except ImportError: + h5py = None + + +class TestArrayWidget(TestCaseQt): + """Basic test for ArrayTableWidget with a numpy array""" + def setUp(self): + super(TestArrayWidget, self).setUp() + self.aw = ArrayTableWidget.ArrayTableWidget() + + def tearDown(self): + del self.aw + super(TestArrayWidget, self).tearDown() + + def testShow(self): + """test for errors""" + self.aw.show() + self.qWaitForWindowExposed(self.aw) + + def testSetData0D(self): + a = 1 + self.aw.setArrayData(a) + b = self.aw.getData(copy=True) + + self.assertTrue(numpy.array_equal(a, b)) + + # scalar/0D data has no frame index + self.assertEqual(len(self.aw.model._index), 0) + # and no perspective + self.assertEqual(len(self.aw.model._perspective), 0) + + def testSetData1D(self): + a = [1, 2] + self.aw.setArrayData(a) + b = self.aw.getData(copy=True) + + self.assertTrue(numpy.array_equal(a, b)) + + # 1D data has no frame index + self.assertEqual(len(self.aw.model._index), 0) + # and no perspective + self.assertEqual(len(self.aw.model._perspective), 0) + + def testSetData4D(self): + a = numpy.reshape(numpy.linspace(0.213, 1.234, 1250), + (5, 5, 5, 10)) + self.aw.setArrayData(a) + + # default perspective (0, 1) + self.assertEqual(list(self.aw.model._perspective), + [0, 1]) + self.aw.setPerspective((1, 3)) + self.assertEqual(list(self.aw.model._perspective), + [1, 3]) + + b = self.aw.getData(copy=True) + self.assertTrue(numpy.array_equal(a, b)) + + # 4D data has a 2-tuple as frame index + self.assertEqual(len(self.aw.model._index), 2) + # default index is (0, 0) + self.assertEqual(list(self.aw.model._index), + [0, 0]) + self.aw.setFrameIndex((3, 1)) + + self.assertEqual(list(self.aw.model._index), + [3, 1]) + + def testColors(self): + a = numpy.arange(256, dtype=numpy.uint8) + self.aw.setArrayData(a) + + bgcolor = numpy.empty(a.shape + (3,), dtype=numpy.uint8) + # Black & white palette + bgcolor[..., 0] = a + bgcolor[..., 1] = a + bgcolor[..., 2] = a + + fgcolor = numpy.bitwise_xor(bgcolor, 255) + + self.aw.setArrayColors(bgcolor, fgcolor) + + # test colors are as expected in model + for i in range(256): + # all RGB channels for BG equal to data value + self.assertEqual( + self.aw.model.data(self.aw.model.index(0, i), + role=qt.Qt.BackgroundRole), + qt.QColor(i, i, i), + "Unexpected background color" + ) + + # all RGB channels for FG equal to XOR(data value, 255) + self.assertEqual( + self.aw.model.data(self.aw.model.index(0, i), + role=qt.Qt.ForegroundRole), + qt.QColor(i ^ 255, i ^ 255, i ^ 255), + "Unexpected text color" + ) + + # test colors are reset to None when a new data array is loaded + # with different shape + self.aw.setArrayData(numpy.arange(300)) + + for i in range(300): + # all RGB channels for BG equal to data value + self.assertIsNone( + self.aw.model.data(self.aw.model.index(0, i), + role=qt.Qt.BackgroundRole)) + + def testDefaultFlagNotEditable(self): + """editable should be False by default, in setArrayData""" + self.aw.setArrayData([[0]]) + idx = self.aw.model.createIndex(0, 0) + # model is editable + self.assertFalse( + self.aw.model.flags(idx) & qt.Qt.ItemIsEditable) + + def testFlagEditable(self): + self.aw.setArrayData([[0]], editable=True) + idx = self.aw.model.createIndex(0, 0) + # model is editable + self.assertTrue( + self.aw.model.flags(idx) & qt.Qt.ItemIsEditable) + + def testFlagNotEditable(self): + self.aw.setArrayData([[0]], editable=False) + idx = self.aw.model.createIndex(0, 0) + # model is editable + self.assertFalse( + self.aw.model.flags(idx) & qt.Qt.ItemIsEditable) + + def testReferenceReturned(self): + """when setting the data with copy=False and + retrieving it with getData(copy=False), we should recover + the same original object. + """ + # n-D (n >=2) + a0 = numpy.reshape(numpy.linspace(0.213, 1.234, 1000), + (10, 10, 10)) + self.aw.setArrayData(a0, copy=False) + a1 = self.aw.getData(copy=False) + + self.assertIs(a0, a1) + + # 1D + b0 = numpy.linspace(0.213, 1.234, 1000) + self.aw.setArrayData(b0, copy=False) + b1 = self.aw.getData(copy=False) + self.assertIs(b0, b1) + + +@unittest.skipIf(h5py is None, "Could not import h5py") +class TestH5pyArrayWidget(TestCaseQt): + """Basic test for ArrayTableWidget with a dataset. + + Test flags, for dataset open in read-only or read-write modes""" + def setUp(self): + super(TestH5pyArrayWidget, self).setUp() + self.aw = ArrayTableWidget.ArrayTableWidget() + self.data = numpy.reshape(numpy.linspace(0.213, 1.234, 1000), + (10, 10, 10)) + # create an h5py file with a dataset + self.tempdir = tempfile.mkdtemp() + self.h5_fname = os.path.join(self.tempdir, "array.h5") + h5f = h5py.File(self.h5_fname) + h5f["my_array"] = self.data + h5f["my_scalar"] = 3.14 + h5f["my_1D_array"] = numpy.array(numpy.arange(1000)) + h5f.close() + + def tearDown(self): + del self.aw + os.unlink(self.h5_fname) + os.rmdir(self.tempdir) + super(TestH5pyArrayWidget, self).tearDown() + + def testShow(self): + self.aw.show() + self.qWaitForWindowExposed(self.aw) + + def testReadOnly(self): + """Open H5 dataset in read-only mode, ensure the model is not editable.""" + h5f = h5py.File(self.h5_fname, "r") + a = h5f["my_array"] + # ArrayTableModel relies on following condition + self.assertTrue(a.file.mode == "r") + + self.aw.setArrayData(a, copy=False, editable=True) + + self.assertIsInstance(a, h5py.Dataset) # simple sanity check + # internal representation must be a reference to original data (copy=False) + self.assertIsInstance(self.aw.model._array, h5py.Dataset) + self.assertTrue(self.aw.model._array.file.mode == "r") + + b = self.aw.getData() + self.assertTrue(numpy.array_equal(self.data, b)) + + # model must have detected read-only dataset and disabled editing + self.assertFalse(self.aw.model._editable) + idx = self.aw.model.createIndex(0, 0) + self.assertFalse( + self.aw.model.flags(idx) & qt.Qt.ItemIsEditable) + + # force editing read-only datasets raises IOError + self.assertRaises(IOError, self.aw.model.setData, + idx, 123.4, role=qt.Qt.EditRole) + h5f.close() + + def testReadWrite(self): + h5f = h5py.File(self.h5_fname, "r+") + a = h5f["my_array"] + self.assertTrue(a.file.mode == "r+") + + self.aw.setArrayData(a, copy=False, editable=True) + b = self.aw.getData(copy=False) + self.assertTrue(numpy.array_equal(self.data, b)) + + idx = self.aw.model.createIndex(0, 0) + # model is editable + self.assertTrue( + self.aw.model.flags(idx) & qt.Qt.ItemIsEditable) + h5f.close() + + def testSetData0D(self): + h5f = h5py.File(self.h5_fname, "r+") + a = h5f["my_scalar"] + self.aw.setArrayData(a) + b = self.aw.getData(copy=True) + + self.assertTrue(numpy.array_equal(a, b)) + + h5f.close() + + def testSetData1D(self): + h5f = h5py.File(self.h5_fname, "r+") + a = h5f["my_1D_array"] + self.aw.setArrayData(a) + b = self.aw.getData(copy=True) + + self.assertTrue(numpy.array_equal(a, b)) + + h5f.close() + + def testReferenceReturned(self): + """when setting the data with copy=False and + retrieving it with getData(copy=False), we should recover + the same original object. + + This only works for array with at least 2D. For 1D and 0D + arrays, a view is created at some point, which in the case + of an hdf5 dataset creates a copy.""" + h5f = h5py.File(self.h5_fname, "r+") + + # n-D + a0 = h5f["my_array"] + self.aw.setArrayData(a0, copy=False) + a1 = self.aw.getData(copy=False) + self.assertIs(a0, a1) + + # 1D + b0 = h5f["my_1D_array"] + self.aw.setArrayData(b0, copy=False) + b1 = self.aw.getData(copy=False) + self.assertIs(b0, b1) + + h5f.close() + + +def suite(): + test_suite = unittest.TestSuite() + test_suite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(TestArrayWidget)) + test_suite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(TestH5pyArrayWidget)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/data/test/test_dataviewer.py b/silx/gui/data/test/test_dataviewer.py new file mode 100644 index 0000000..5a0de0b --- /dev/null +++ b/silx/gui/data/test/test_dataviewer.py @@ -0,0 +1,281 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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. +# +# ###########################################################################*/ +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "10/04/2017" + +import os +import tempfile +import unittest +from contextlib import contextmanager + +import numpy +from ..DataViewer import DataViewer +from ..DataViews import DataView +from .. import DataViews + +from silx.gui import qt + +from silx.gui.data.DataViewerFrame import DataViewerFrame +from silx.gui.test.utils import SignalListener +from silx.gui.test.utils import TestCaseQt + +from silx.gui.hdf5.test import _mock + +try: + import h5py +except ImportError: + h5py = None + + +class _DataViewMock(DataView): + """Dummy view to display nothing""" + + def __init__(self, parent): + DataView.__init__(self, parent) + + def axesNames(self, data, info): + return [] + + def createWidget(self, parent): + return qt.QLabel(parent) + + def getDataPriority(self, data, info): + return 0 + + +class AbstractDataViewerTests(TestCaseQt): + + def create_widget(self): + raise NotImplementedError() + + @contextmanager + def h5_temporary_file(self): + # create tmp file + fd, tmp_name = tempfile.mkstemp(suffix=".h5") + os.close(fd) + data = numpy.arange(3 * 3 * 3) + data.shape = 3, 3, 3 + # create h5 data + h5file = h5py.File(tmp_name, "w") + h5file["data"] = data + yield h5file + # clean up + h5file.close() + os.unlink(tmp_name) + + def test_text_data(self): + data_list = ["aaa", int, 8, self] + widget = self.create_widget() + for data in data_list: + widget.setData(data) + self.assertEqual(DataViewer.RAW_MODE, widget.displayMode()) + + def test_plot_1d_data(self): + data = numpy.arange(3 ** 1) + data.shape = [3] * 1 + widget = self.create_widget() + widget.setData(data) + availableModes = set([v.modeId() for v in widget.currentAvailableViews()]) + self.assertEqual(DataViewer.RAW_MODE, widget.displayMode()) + self.assertIn(DataViewer.PLOT1D_MODE, availableModes) + + def test_plot_2d_data(self): + data = numpy.arange(3 ** 2) + data.shape = [3] * 2 + widget = self.create_widget() + widget.setData(data) + availableModes = set([v.modeId() for v in widget.currentAvailableViews()]) + self.assertEqual(DataViewer.RAW_MODE, widget.displayMode()) + self.assertIn(DataViewer.PLOT2D_MODE, availableModes) + + def test_plot_3d_data(self): + data = numpy.arange(3 ** 3) + data.shape = [3] * 3 + widget = self.create_widget() + widget.setData(data) + availableModes = set([v.modeId() for v in widget.currentAvailableViews()]) + try: + import silx.gui.plot3d # noqa + self.assertIn(DataViewer.PLOT3D_MODE, availableModes) + except ImportError: + self.assertIn(DataViewer.STACK_MODE, availableModes) + self.assertEqual(DataViewer.RAW_MODE, widget.displayMode()) + + def test_array_1d_data(self): + data = numpy.array(["aaa"] * (3 ** 1)) + data.shape = [3] * 1 + widget = self.create_widget() + widget.setData(data) + self.assertEqual(DataViewer.RAW_MODE, widget.displayedView().modeId()) + + def test_array_2d_data(self): + data = numpy.array(["aaa"] * (3 ** 2)) + data.shape = [3] * 2 + widget = self.create_widget() + widget.setData(data) + self.assertEqual(DataViewer.RAW_MODE, widget.displayedView().modeId()) + + def test_array_4d_data(self): + data = numpy.array(["aaa"] * (3 ** 4)) + data.shape = [3] * 4 + widget = self.create_widget() + widget.setData(data) + self.assertEqual(DataViewer.RAW_MODE, widget.displayedView().modeId()) + + def test_record_4d_data(self): + data = numpy.zeros(3 ** 4, dtype='3int8, float32, (2,3)float64') + data.shape = [3] * 4 + widget = self.create_widget() + widget.setData(data) + self.assertEqual(DataViewer.RAW_MODE, widget.displayedView().modeId()) + + def test_3d_h5_dataset(self): + if h5py is None: + self.skipTest("h5py library is not available") + with self.h5_temporary_file() as h5file: + dataset = h5file["data"] + widget = self.create_widget() + widget.setData(dataset) + + def test_data_event(self): + listener = SignalListener() + widget = self.create_widget() + widget.dataChanged.connect(listener) + widget.setData(10) + widget.setData(None) + self.assertEquals(listener.callCount(), 2) + + def test_display_mode_event(self): + listener = SignalListener() + widget = self.create_widget() + widget.displayedViewChanged.connect(listener) + widget.setData(10) + widget.setData(None) + modes = [v.modeId() for v in listener.arguments(argumentIndex=0)] + self.assertEquals(modes, [DataViewer.RAW_MODE, DataViewer.EMPTY_MODE]) + listener.clear() + + def test_change_display_mode(self): + data = numpy.arange(10 ** 4) + data.shape = [10] * 4 + widget = self.create_widget() + widget.setData(data) + widget.setDisplayMode(DataViewer.PLOT1D_MODE) + self.assertEquals(widget.displayedView().modeId(), DataViewer.PLOT1D_MODE) + widget.setDisplayMode(DataViewer.PLOT2D_MODE) + self.assertEquals(widget.displayedView().modeId(), DataViewer.PLOT2D_MODE) + widget.setDisplayMode(DataViewer.RAW_MODE) + self.assertEquals(widget.displayedView().modeId(), DataViewer.RAW_MODE) + widget.setDisplayMode(DataViewer.EMPTY_MODE) + self.assertEquals(widget.displayedView().modeId(), DataViewer.EMPTY_MODE) + + def test_create_default_views(self): + widget = self.create_widget() + views = widget.createDefaultViews() + self.assertTrue(len(views) > 0) + + def test_add_view(self): + widget = self.create_widget() + view = _DataViewMock(widget) + widget.addView(view) + self.assertTrue(view in widget.availableViews()) + self.assertTrue(view in widget.currentAvailableViews()) + + def test_remove_view(self): + widget = self.create_widget() + widget.setData("foobar") + view = widget.currentAvailableViews()[0] + widget.removeView(view) + self.assertTrue(view not in widget.availableViews()) + self.assertTrue(view not in widget.currentAvailableViews()) + +class TestDataViewer(AbstractDataViewerTests): + def create_widget(self): + return DataViewer() + + +class TestDataViewerFrame(AbstractDataViewerTests): + def create_widget(self): + return DataViewerFrame() + + +class TestDataView(TestCaseQt): + + def createComplexData(self): + line = [1, 2j, 3+3j, 4] + image = [line, line, line, line] + cube = [image, image, image, image] + data = numpy.array(cube, + dtype=numpy.complex) + return data + + def createDataViewWithData(self, dataViewClass, data): + viewer = dataViewClass(None) + widget = viewer.getWidget() + viewer.setData(data) + return widget + + def testCurveWithComplex(self): + data = self.createComplexData() + dataViewClass = DataViews._Plot1dView + widget = self.createDataViewWithData(dataViewClass, data[0, 0]) + self.qWaitForWindowExposed(widget) + + def testImageWithComplex(self): + data = self.createComplexData() + dataViewClass = DataViews._Plot2dView + widget = self.createDataViewWithData(dataViewClass, data[0]) + self.qWaitForWindowExposed(widget) + + def testCubeWithComplex(self): + self.skipTest("OpenGL widget not yet tested") + try: + import silx.gui.plot3d # noqa + except ImportError: + self.skipTest("OpenGL not available") + data = self.createComplexData() + dataViewClass = DataViews._Plot3dView + widget = self.createDataViewWithData(dataViewClass, data) + self.qWaitForWindowExposed(widget) + + def testImageStackWithComplex(self): + data = self.createComplexData() + dataViewClass = DataViews._StackView + widget = self.createDataViewWithData(dataViewClass, data) + self.qWaitForWindowExposed(widget) + + +def suite(): + test_suite = unittest.TestSuite() + loadTestsFromTestCase = unittest.defaultTestLoader.loadTestsFromTestCase + test_suite.addTest(loadTestsFromTestCase(TestDataViewer)) + test_suite.addTest(loadTestsFromTestCase(TestDataViewerFrame)) + test_suite.addTest(loadTestsFromTestCase(TestDataView)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/data/test/test_numpyaxesselector.py b/silx/gui/data/test/test_numpyaxesselector.py new file mode 100644 index 0000000..cc15f83 --- /dev/null +++ b/silx/gui/data/test/test_numpyaxesselector.py @@ -0,0 +1,152 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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. +# +# ###########################################################################*/ +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "15/12/2016" + +import os +import tempfile +import unittest +from contextlib import contextmanager + +import numpy + +from silx.gui.data.NumpyAxesSelector import NumpyAxesSelector +from silx.gui.test.utils import SignalListener +from silx.gui.test.utils import TestCaseQt + +try: + import h5py +except ImportError: + h5py = None + + +class TestNumpyAxesSelector(TestCaseQt): + + def test_creation(self): + data = numpy.arange(3 * 3 * 3) + data.shape = 3, 3, 3 + widget = NumpyAxesSelector() + widget.setVisible(True) + + def test_none(self): + data = numpy.arange(3 * 3 * 3) + widget = NumpyAxesSelector() + widget.setData(data) + widget.setData(None) + result = widget.selectedData() + self.assertIsNone(result) + + def test_output_samedim(self): + data = numpy.arange(3 * 3 * 3) + data.shape = 3, 3, 3 + expectedResult = data + + widget = NumpyAxesSelector() + widget.setAxisNames(["x", "y", "z"]) + widget.setData(data) + result = widget.selectedData() + self.assertTrue(numpy.array_equal(result, expectedResult)) + + def test_output_lessdim(self): + data = numpy.arange(3 * 3 * 3) + data.shape = 3, 3, 3 + expectedResult = data[0] + + widget = NumpyAxesSelector() + widget.setAxisNames(["y", "x"]) + widget.setData(data) + result = widget.selectedData() + self.assertTrue(numpy.array_equal(result, expectedResult)) + + def test_output_1dim(self): + data = numpy.arange(3 * 3 * 3) + data.shape = 3, 3, 3 + expectedResult = data[0, 0, 0] + + widget = NumpyAxesSelector() + widget.setData(data) + result = widget.selectedData() + self.assertTrue(numpy.array_equal(result, expectedResult)) + + @contextmanager + def h5_temporary_file(self): + # create tmp file + fd, tmp_name = tempfile.mkstemp(suffix=".h5") + os.close(fd) + data = numpy.arange(3 * 3 * 3) + data.shape = 3, 3, 3 + # create h5 data + h5file = h5py.File(tmp_name, "w") + h5file["data"] = data + yield h5file + # clean up + h5file.close() + os.unlink(tmp_name) + + def test_h5py_dataset(self): + if h5py is None: + self.skipTest("h5py library is not available") + with self.h5_temporary_file() as h5file: + dataset = h5file["data"] + expectedResult = dataset[0] + + widget = NumpyAxesSelector() + widget.setData(dataset) + widget.setAxisNames(["y", "x"]) + result = widget.selectedData() + self.assertTrue(numpy.array_equal(result, expectedResult)) + + def test_data_event(self): + data = numpy.arange(3 * 3 * 3) + widget = NumpyAxesSelector() + listener = SignalListener() + widget.dataChanged.connect(listener) + widget.setData(data) + widget.setData(None) + self.assertEqual(listener.callCount(), 2) + + def test_selected_data_event(self): + data = numpy.arange(3 * 3 * 3) + data.shape = 3, 3, 3 + widget = NumpyAxesSelector() + listener = SignalListener() + widget.selectionChanged.connect(listener) + widget.setData(data) + widget.setAxisNames(["x"]) + widget.setData(None) + self.assertEqual(listener.callCount(), 3) + listener.clear() + + +def suite(): + test_suite = unittest.TestSuite() + test_suite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(TestNumpyAxesSelector)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/data/test/test_textformatter.py b/silx/gui/data/test/test_textformatter.py new file mode 100644 index 0000000..f21e033 --- /dev/null +++ b/silx/gui/data/test/test_textformatter.py @@ -0,0 +1,94 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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. +# +# ###########################################################################*/ +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "24/01/2017" + +import unittest + +from silx.gui.test.utils import TestCaseQt +from silx.gui.test.utils import SignalListener +from ..TextFormatter import TextFormatter + + +class TestTextFormatter(TestCaseQt): + + def test_copy(self): + formatter = TextFormatter() + copy = TextFormatter(formatter=formatter) + self.assertIsNot(formatter, copy) + copy.setFloatFormat("%.3f") + self.assertEquals(formatter.integerFormat(), copy.integerFormat()) + self.assertNotEquals(formatter.floatFormat(), copy.floatFormat()) + self.assertEquals(formatter.useQuoteForText(), copy.useQuoteForText()) + self.assertEquals(formatter.imaginaryUnit(), copy.imaginaryUnit()) + + def test_event(self): + listener = SignalListener() + formatter = TextFormatter() + formatter.formatChanged.connect(listener) + formatter.setFloatFormat("%.3f") + formatter.setIntegerFormat("%03i") + formatter.setUseQuoteForText(False) + formatter.setImaginaryUnit("z") + self.assertEquals(listener.callCount(), 4) + + def test_int(self): + formatter = TextFormatter() + formatter.setIntegerFormat("%05i") + result = formatter.toString(512) + self.assertEquals(result, "00512") + + def test_float(self): + formatter = TextFormatter() + formatter.setFloatFormat("%.3f") + result = formatter.toString(1.3) + self.assertEquals(result, "1.300") + + def test_complex(self): + formatter = TextFormatter() + formatter.setFloatFormat("%.1f") + formatter.setImaginaryUnit("i") + result = formatter.toString(1.0 + 5j) + result = result.replace(" ", "") + self.assertEquals(result, "1.0+5.0i") + + def test_string(self): + formatter = TextFormatter() + formatter.setIntegerFormat("%.1f") + formatter.setImaginaryUnit("z") + result = formatter.toString("toto") + self.assertEquals(result, '"toto"') + + +def suite(): + test_suite = unittest.TestSuite() + test_suite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(TestTextFormatter)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') |