summaryrefslogtreecommitdiff
path: root/silx/gui/data
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui/data')
-rw-r--r--silx/gui/data/ArrayTableModel.py610
-rw-r--r--silx/gui/data/ArrayTableWidget.py490
-rw-r--r--silx/gui/data/DataViewer.py464
-rw-r--r--silx/gui/data/DataViewerFrame.py186
-rw-r--r--silx/gui/data/DataViewerSelector.py153
-rw-r--r--silx/gui/data/DataViews.py988
-rw-r--r--silx/gui/data/Hdf5TableView.py414
-rw-r--r--silx/gui/data/NXdataWidgets.py523
-rw-r--r--silx/gui/data/NumpyAxesSelector.py468
-rw-r--r--silx/gui/data/RecordTableView.py405
-rw-r--r--silx/gui/data/TextFormatter.py222
-rw-r--r--silx/gui/data/__init__.py35
-rw-r--r--silx/gui/data/setup.py41
-rw-r--r--silx/gui/data/test/__init__.py45
-rw-r--r--silx/gui/data/test/test_arraywidget.py320
-rw-r--r--silx/gui/data/test/test_dataviewer.py281
-rw-r--r--silx/gui/data/test/test_numpyaxesselector.py152
-rw-r--r--silx/gui/data/test/test_textformatter.py94
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')