diff options
author | Picca Frédéric-Emmanuel <picca@synchrotron-soleil.fr> | 2018-07-31 16:22:25 +0200 |
---|---|---|
committer | Picca Frédéric-Emmanuel <picca@synchrotron-soleil.fr> | 2018-07-31 16:22:25 +0200 |
commit | 159ef14fb9e198bb0066ea14e6b980f065de63dd (patch) | |
tree | bc37c7d4ba09ee59deb708897fa0571709aec293 /silx/gui/dialog | |
parent | 270d5ddc31c26b62379e3caa9044dd75ccc71847 (diff) |
New upstream version 0.8.0+dfsg
Diffstat (limited to 'silx/gui/dialog')
-rw-r--r-- | silx/gui/dialog/AbstractDataFileDialog.py | 6 | ||||
-rw-r--r-- | silx/gui/dialog/ColormapDialog.py | 986 | ||||
-rw-r--r-- | silx/gui/dialog/GroupDialog.py | 177 | ||||
-rw-r--r-- | silx/gui/dialog/test/__init__.py | 4 | ||||
-rw-r--r-- | silx/gui/dialog/test/test_colormapdialog.py | 398 | ||||
-rw-r--r-- | silx/gui/dialog/test/test_datafiledialog.py | 24 | ||||
-rw-r--r-- | silx/gui/dialog/test/test_imagefiledialog.py | 32 |
7 files changed, 1611 insertions, 16 deletions
diff --git a/silx/gui/dialog/AbstractDataFileDialog.py b/silx/gui/dialog/AbstractDataFileDialog.py index 1bd52bb..cb6711c 100644 --- a/silx/gui/dialog/AbstractDataFileDialog.py +++ b/silx/gui/dialog/AbstractDataFileDialog.py @@ -28,7 +28,7 @@ This module contains an :class:`AbstractDataFileDialog`. __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "12/02/2018" +__date__ = "05/03/2018" import sys @@ -494,7 +494,9 @@ class _CatchResizeEvent(qt.QObject): class AbstractDataFileDialog(qt.QDialog): """The `AbstractFileDialog` provides a generic GUI to create a custom dialog - allowing to access to file resources like HDF5 files or HDF5 datasets + allowing to access to file resources like HDF5 files or HDF5 datasets. + + .. image:: img/abstractdatafiledialog.png The dialog contains: diff --git a/silx/gui/dialog/ColormapDialog.py b/silx/gui/dialog/ColormapDialog.py new file mode 100644 index 0000000..ed10728 --- /dev/null +++ b/silx/gui/dialog/ColormapDialog.py @@ -0,0 +1,986 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2018 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. +# +# ###########################################################################*/ +"""A QDialog widget to set-up the colormap. + +It uses a description of colormaps as dict compatible with :class:`Plot`. + +To run the following sample code, a QApplication must be initialized. + +Create the colormap dialog and set the colormap description and data range: + +>>> from silx.gui.dialog.ColormapDialog import ColormapDialog +>>> from silx.gui.colors import Colormap + +>>> dialog = ColormapDialog() +>>> colormap = Colormap(name='red', normalization='log', +... vmin=1., vmax=2.) + +>>> dialog.setColormap(colormap) +>>> colormap.setVRange(1., 100.) # This scale the width of the plot area +>>> dialog.show() + +Get the colormap description (compatible with :class:`Plot`) from the dialog: + +>>> cmap = dialog.getColormap() +>>> cmap.getName() +'red' + +It is also possible to display an histogram of the image in the dialog. +This updates the data range with the range of the bins. + +>>> import numpy +>>> image = numpy.random.normal(size=512 * 512).reshape(512, -1) +>>> hist, bin_edges = numpy.histogram(image, bins=10) +>>> dialog.setHistogram(hist, bin_edges) + +The updates of the colormap description are also available through the signal: +:attr:`ColormapDialog.sigColormapChanged`. +""" # noqa + +from __future__ import division + +__authors__ = ["V.A. Sole", "T. Vincent", "H. Payno"] +__license__ = "MIT" +__date__ = "23/05/2018" + + +import logging + +import numpy + +from .. import qt +from ..colors import Colormap, preferredColormaps +from ..plot import PlotWidget +from silx.gui.widgets.FloatEdit import FloatEdit +import weakref +from silx.math.combo import min_max +from silx.third_party import enum +from silx.gui import icons +from silx.math.histogram import Histogramnd + +_logger = logging.getLogger(__name__) + + +_colormapIconPreview = {} + + +class _BoundaryWidget(qt.QWidget): + """Widget to edit a boundary of the colormap (vmin, vmax)""" + sigValueChanged = qt.Signal(object) + """Signal emitted when value is changed""" + + def __init__(self, parent=None, value=0.0): + qt.QWidget.__init__(self, parent=None) + self.setLayout(qt.QHBoxLayout()) + self.layout().setContentsMargins(0, 0, 0, 0) + self._numVal = FloatEdit(parent=self, value=value) + self.layout().addWidget(self._numVal) + self._autoCB = qt.QCheckBox('auto', parent=self) + self.layout().addWidget(self._autoCB) + self._autoCB.setChecked(False) + + self._autoCB.toggled.connect(self._autoToggled) + self.sigValueChanged = self._autoCB.toggled + self.textEdited = self._numVal.textEdited + self.editingFinished = self._numVal.editingFinished + self._dataValue = None + + def isAutoChecked(self): + return self._autoCB.isChecked() + + def getValue(self): + return None if self._autoCB.isChecked() else self._numVal.value() + + def getFiniteValue(self): + if not self._autoCB.isChecked(): + return self._numVal.value() + elif self._dataValue is None: + return self._numVal.value() + else: + return self._dataValue + + def _autoToggled(self, enabled): + self._numVal.setEnabled(not enabled) + self._updateDisplayedText() + + def _updateDisplayedText(self): + # if dataValue is finite + if self._autoCB.isChecked() and self._dataValue is not None: + old = self._numVal.blockSignals(True) + self._numVal.setValue(self._dataValue) + self._numVal.blockSignals(old) + + def setDataValue(self, dataValue): + self._dataValue = dataValue + self._updateDisplayedText() + + def setFiniteValue(self, value): + assert(value is not None) + old = self._numVal.blockSignals(True) + self._numVal.setValue(value) + self._numVal.blockSignals(old) + + def setValue(self, value, isAuto=False): + self._autoCB.setChecked(isAuto or value is None) + if value is not None: + self._numVal.setValue(value) + self._updateDisplayedText() + + +class _ColormapNameCombox(qt.QComboBox): + def __init__(self, parent=None): + qt.QComboBox.__init__(self, parent) + self.__initItems() + + ORIGINAL_NAME = qt.Qt.UserRole + 1 + + def __initItems(self): + for colormapName in preferredColormaps(): + index = self.count() + self.addItem(str.title(colormapName)) + self.setItemIcon(index, self.getIconPreview(colormapName)) + self.setItemData(index, colormapName, role=self.ORIGINAL_NAME) + + def getIconPreview(self, colormapName): + """Return an icon preview from a LUT name. + + This icons are cached into a global structure. + + :param str colormapName: str + :rtype: qt.QIcon + """ + if colormapName not in _colormapIconPreview: + icon = self.createIconPreview(colormapName) + _colormapIconPreview[colormapName] = icon + return _colormapIconPreview[colormapName] + + def createIconPreview(self, colormapName): + """Create and return an icon preview from a LUT name. + + This icons are cached into a global structure. + + :param str colormapName: Name of the LUT + :rtype: qt.QIcon + """ + colormap = Colormap(colormapName) + size = 32 + lut = colormap.getNColors(size) + if lut is None or len(lut) == 0: + return qt.QIcon() + + pixmap = qt.QPixmap(size, size) + painter = qt.QPainter(pixmap) + for i in range(size): + rgb = lut[i] + r, g, b = rgb[0], rgb[1], rgb[2] + painter.setPen(qt.QColor(r, g, b)) + painter.drawPoint(qt.QPoint(i, 0)) + + painter.drawPixmap(0, 1, size, size - 1, pixmap, 0, 0, size, 1) + painter.end() + + return qt.QIcon(pixmap) + + def getCurrentName(self): + return self.itemData(self.currentIndex(), self.ORIGINAL_NAME) + + def findColormap(self, name): + return self.findData(name, role=self.ORIGINAL_NAME) + + def setCurrentName(self, name): + index = self.findColormap(name) + if index < 0: + index = self.count() + self.addItem(str.title(name)) + self.setItemIcon(index, self.getIconPreview(name)) + self.setItemData(index, name, role=self.ORIGINAL_NAME) + self.setCurrentIndex(index) + + +@enum.unique +class _DataInPlotMode(enum.Enum): + """Enum for each mode of display of the data in the plot.""" + NONE = 'none' + RANGE = 'range' + HISTOGRAM = 'histogram' + + +class ColormapDialog(qt.QDialog): + """A QDialog widget to set the colormap. + + :param parent: See :class:`QDialog` + :param str title: The QDialog title + """ + + visibleChanged = qt.Signal(bool) + """This event is sent when the dialog visibility change""" + + def __init__(self, parent=None, title="Colormap Dialog"): + qt.QDialog.__init__(self, parent) + self.setWindowTitle(title) + + self._colormap = None + self._data = None + self._dataInPlotMode = _DataInPlotMode.RANGE + + self._ignoreColormapChange = False + """Used as a semaphore to avoid editing the colormap object when we are + only attempt to display it. + Used instead of n connect and disconnect of the sigChanged. The + disconnection to sigChanged was also limiting when this colormapdialog + is used in the colormapaction and associated to the activeImageChanged. + (because the activeImageChanged is send when the colormap changed and + the self.setcolormap is a callback) + """ + + self._histogramData = None + self._minMaxWasEdited = False + self._initialRange = None + + self._dataRange = None + """If defined 3-tuple containing information from a data: + minimum, positive minimum, maximum""" + + self._colormapStoredState = None + + # Make the GUI + vLayout = qt.QVBoxLayout(self) + + formWidget = qt.QWidget(parent=self) + vLayout.addWidget(formWidget) + formLayout = qt.QFormLayout(formWidget) + formLayout.setContentsMargins(10, 10, 10, 10) + formLayout.setSpacing(0) + + # Colormap row + self._comboBoxColormap = _ColormapNameCombox(parent=formWidget) + self._comboBoxColormap.currentIndexChanged[int].connect(self._updateName) + formLayout.addRow('Colormap:', self._comboBoxColormap) + + # Normalization row + self._normButtonLinear = qt.QRadioButton('Linear') + self._normButtonLinear.setChecked(True) + self._normButtonLog = qt.QRadioButton('Log') + self._normButtonLog.toggled.connect(self._activeLogNorm) + + normButtonGroup = qt.QButtonGroup(self) + normButtonGroup.setExclusive(True) + normButtonGroup.addButton(self._normButtonLinear) + normButtonGroup.addButton(self._normButtonLog) + self._normButtonLinear.toggled[bool].connect(self._updateLinearNorm) + + normLayout = qt.QHBoxLayout() + normLayout.setContentsMargins(0, 0, 0, 0) + normLayout.setSpacing(10) + normLayout.addWidget(self._normButtonLinear) + normLayout.addWidget(self._normButtonLog) + + formLayout.addRow('Normalization:', normLayout) + + # Min row + self._minValue = _BoundaryWidget(parent=self, value=1.0) + self._minValue.textEdited.connect(self._minMaxTextEdited) + self._minValue.editingFinished.connect(self._minEditingFinished) + self._minValue.sigValueChanged.connect(self._updateMinMax) + formLayout.addRow('\tMin:', self._minValue) + + # Max row + self._maxValue = _BoundaryWidget(parent=self, value=10.0) + self._maxValue.textEdited.connect(self._minMaxTextEdited) + self._maxValue.sigValueChanged.connect(self._updateMinMax) + self._maxValue.editingFinished.connect(self._maxEditingFinished) + formLayout.addRow('\tMax:', self._maxValue) + + # Add plot for histogram + self._plotToolbar = qt.QToolBar(self) + self._plotToolbar.setFloatable(False) + self._plotToolbar.setMovable(False) + self._plotToolbar.setIconSize(qt.QSize(8, 8)) + self._plotToolbar.setStyleSheet("QToolBar { border: 0px }") + self._plotToolbar.setOrientation(qt.Qt.Vertical) + + group = qt.QActionGroup(self._plotToolbar) + group.setExclusive(True) + + action = qt.QAction("Nothing", self) + action.setToolTip("No range nor histogram are displayed. No extra computation have to be done.") + action.setIcon(icons.getQIcon('colormap-none')) + action.setCheckable(True) + action.setData(_DataInPlotMode.NONE) + action.setChecked(action.data() == self._dataInPlotMode) + self._plotToolbar.addAction(action) + group.addAction(action) + action = qt.QAction("Data range", self) + action.setToolTip("Display the data range within the colormap range. A fast data processing have to be done.") + action.setIcon(icons.getQIcon('colormap-range')) + action.setCheckable(True) + action.setData(_DataInPlotMode.RANGE) + action.setChecked(action.data() == self._dataInPlotMode) + self._plotToolbar.addAction(action) + group.addAction(action) + action = qt.QAction("Histogram", self) + action.setToolTip("Display the data histogram within the colormap range. A slow data processing have to be done. ") + action.setIcon(icons.getQIcon('colormap-histogram')) + action.setCheckable(True) + action.setData(_DataInPlotMode.HISTOGRAM) + action.setChecked(action.data() == self._dataInPlotMode) + self._plotToolbar.addAction(action) + group.addAction(action) + group.triggered.connect(self._displayDataInPlotModeChanged) + + self._plotBox = qt.QWidget(self) + self._plotInit() + + plotBoxLayout = qt.QHBoxLayout() + plotBoxLayout.setContentsMargins(0, 0, 0, 0) + plotBoxLayout.setSpacing(2) + plotBoxLayout.addWidget(self._plotToolbar) + plotBoxLayout.addWidget(self._plot) + plotBoxLayout.setSizeConstraint(qt.QLayout.SetMinimumSize) + self._plotBox.setLayout(plotBoxLayout) + vLayout.addWidget(self._plotBox) + + # define modal buttons + types = qt.QDialogButtonBox.Ok | qt.QDialogButtonBox.Cancel + self._buttonsModal = qt.QDialogButtonBox(parent=self) + self._buttonsModal.setStandardButtons(types) + self.layout().addWidget(self._buttonsModal) + self._buttonsModal.accepted.connect(self.accept) + self._buttonsModal.rejected.connect(self.reject) + + # define non modal buttons + types = qt.QDialogButtonBox.Close | qt.QDialogButtonBox.Reset + self._buttonsNonModal = qt.QDialogButtonBox(parent=self) + self._buttonsNonModal.setStandardButtons(types) + self.layout().addWidget(self._buttonsNonModal) + self._buttonsNonModal.button(qt.QDialogButtonBox.Close).clicked.connect(self.accept) + self._buttonsNonModal.button(qt.QDialogButtonBox.Reset).clicked.connect(self.resetColormap) + + # Set the colormap to default values + self.setColormap(Colormap(name='gray', normalization='linear', + vmin=None, vmax=None)) + + self.setModal(self.isModal()) + + vLayout.setSizeConstraint(qt.QLayout.SetMinimumSize) + self.setFixedSize(self.sizeHint()) + self._applyColormap() + + def showEvent(self, event): + self.visibleChanged.emit(True) + super(ColormapDialog, self).showEvent(event) + + def closeEvent(self, event): + if not self.isModal(): + self.accept() + super(ColormapDialog, self).closeEvent(event) + + def hideEvent(self, event): + self.visibleChanged.emit(False) + super(ColormapDialog, self).hideEvent(event) + + def close(self): + self.accept() + qt.QDialog.close(self) + + def setModal(self, modal): + assert type(modal) is bool + self._buttonsNonModal.setVisible(not modal) + self._buttonsModal.setVisible(modal) + qt.QDialog.setModal(self, modal) + + def exec_(self): + wasModal = self.isModal() + self.setModal(True) + result = super(ColormapDialog, self).exec_() + self.setModal(wasModal) + return result + + def _plotInit(self): + """Init the plot to display the range and the values""" + self._plot = PlotWidget() + self._plot.setDataMargins(yMinMargin=0.125, yMaxMargin=0.125) + self._plot.getXAxis().setLabel("Data Values") + self._plot.getYAxis().setLabel("") + self._plot.setInteractiveMode('select', zoomOnWheel=False) + self._plot.setActiveCurveHandling(False) + self._plot.setMinimumSize(qt.QSize(250, 200)) + self._plot.sigPlotSignal.connect(self._plotSlot) + + self._plotUpdate() + + def sizeHint(self): + return self.layout().minimumSize() + + def _plotUpdate(self, updateMarkers=True): + """Update the plot content + + :param bool updateMarkers: True to update markers, False otherwith + """ + colormap = self.getColormap() + if colormap is None: + if self._plotBox.isVisibleTo(self): + self._plotBox.setVisible(False) + self.setFixedSize(self.sizeHint()) + return + + if not self._plotBox.isVisibleTo(self): + self._plotBox.setVisible(True) + self.setFixedSize(self.sizeHint()) + + minData, maxData = self._minValue.getFiniteValue(), self._maxValue.getFiniteValue() + if minData > maxData: + # avoid a full collapse + minData, maxData = maxData, minData + minimum = minData + maximum = maxData + + if self._dataRange is not None: + minRange = self._dataRange[0] + maxRange = self._dataRange[2] + minimum = min(minimum, minRange) + maximum = max(maximum, maxRange) + + if self._histogramData is not None: + minHisto = self._histogramData[1][0] + maxHisto = self._histogramData[1][-1] + minimum = min(minimum, minHisto) + maximum = max(maximum, maxHisto) + + marge = abs(maximum - minimum) / 6.0 + if marge < 0.0001: + # Smaller that the QLineEdit precision + marge = 0.0001 + + minView, maxView = minimum - marge, maximum + marge + + if updateMarkers: + # Save the state in we are not moving the markers + self._initialRange = minView, maxView + elif self._initialRange is not None: + minView = min(minView, self._initialRange[0]) + maxView = max(maxView, self._initialRange[1]) + + x = [minView, minData, maxData, maxView] + y = [0, 0, 1, 1] + + self._plot.addCurve(x, y, + legend="ConstrainedCurve", + color='black', + symbol='o', + linestyle='-', + resetzoom=False) + + if updateMarkers: + minDraggable = (self._colormap().isEditable() and + not self._minValue.isAutoChecked()) + self._plot.addXMarker( + self._minValue.getFiniteValue(), + legend='Min', + text='Min', + draggable=minDraggable, + color='blue', + constraint=self._plotMinMarkerConstraint) + + maxDraggable = (self._colormap().isEditable() and + not self._maxValue.isAutoChecked()) + self._plot.addXMarker( + self._maxValue.getFiniteValue(), + legend='Max', + text='Max', + draggable=maxDraggable, + color='blue', + constraint=self._plotMaxMarkerConstraint) + + self._plot.resetZoom() + + def _plotMinMarkerConstraint(self, x, y): + """Constraint of the min marker""" + return min(x, self._maxValue.getFiniteValue()), y + + def _plotMaxMarkerConstraint(self, x, y): + """Constraint of the max marker""" + return max(x, self._minValue.getFiniteValue()), y + + def _plotSlot(self, event): + """Handle events from the plot""" + if event['event'] in ('markerMoving', 'markerMoved'): + value = float(str(event['xdata'])) + if event['label'] == 'Min': + self._minValue.setValue(value) + elif event['label'] == 'Max': + self._maxValue.setValue(value) + + # This will recreate the markers while interacting... + # It might break if marker interaction is changed + if event['event'] == 'markerMoved': + self._initialRange = None + self._updateMinMax() + else: + self._plotUpdate(updateMarkers=False) + + @staticmethod + def computeDataRange(data): + """Compute the data range as used by :meth:`setDataRange`. + + :param data: The data to process + :rtype: Tuple(float, float, float) + """ + if data is None or len(data) == 0: + return None, None, None + + dataRange = min_max(data, min_positive=True, finite=True) + if dataRange.minimum is None: + # Only non-finite data + dataRange = None + + if dataRange is not None: + min_positive = dataRange.min_positive + if min_positive is None: + min_positive = float('nan') + dataRange = dataRange.minimum, min_positive, dataRange.maximum + + if dataRange is None or len(dataRange) != 3: + qt.QMessageBox.warning( + None, "No Data", + "Image data does not contain any real value") + dataRange = 1., 1., 10. + + return dataRange + + @staticmethod + def computeHistogram(data): + """Compute the data histogram as used by :meth:`setHistogram`. + + :param data: The data to process + :rtype: Tuple(List(float),List(float) + """ + _data = data + if _data.ndim == 3: # RGB(A) images + _logger.info('Converting current image from RGB(A) to grayscale\ + in order to compute the intensity distribution') + _data = (_data[:, :, 0] * 0.299 + + _data[:, :, 1] * 0.587 + + _data[:, :, 2] * 0.114) + + if len(_data) == 0: + return None, None + + xmin, xmax = min_max(_data, min_positive=False, finite=True) + nbins = min(256, int(numpy.sqrt(_data.size))) + data_range = xmin, xmax + + # bad hack: get 256 bins in the case we have a B&W + if numpy.issubdtype(_data.dtype, numpy.integer): + if nbins > xmax - xmin: + nbins = xmax - xmin + + nbins = max(2, nbins) + _data = _data.ravel().astype(numpy.float32) + + histogram = Histogramnd(_data, n_bins=nbins, histo_range=data_range) + return histogram.histo, histogram.edges[0] + + def _getData(self): + if self._data is None: + return None + return self._data() + + def setData(self, data): + """Store the data as a weakref. + + According to the state of the dialog, the data will be used to display + the data range or the histogram of the data using :meth:`setDataRange` + and :meth:`setHistogram` + """ + oldData = self._getData() + if oldData is data: + return + + if data is None: + self._data = None + else: + self._data = weakref.ref(data, self._dataAboutToFinalize) + + self._updateDataInPlot() + + def _setDataInPlotMode(self, mode): + if self._dataInPlotMode == mode: + return + self._dataInPlotMode = mode + self._updateDataInPlot() + + def _displayDataInPlotModeChanged(self, action): + mode = action.data() + self._setDataInPlotMode(mode) + + def _updateDataInPlot(self): + data = self._getData() + if data is None: + self.setDataRange() + self.setHistogram() + return + + if data.size == 0: + # One or more dimensions are equal to 0 + self.setHistogram() + self.setDataRange() + return + + mode = self._dataInPlotMode + + if mode == _DataInPlotMode.NONE: + self.setHistogram() + self.setDataRange() + elif mode == _DataInPlotMode.RANGE: + result = self.computeDataRange(data) + self.setHistogram() + self.setDataRange(*result) + elif mode == _DataInPlotMode.HISTOGRAM: + # The histogram should be done in a worker thread + result = self.computeHistogram(data) + self.setHistogram(*result) + self.setDataRange() + + def _colormapAboutToFinalize(self, weakrefColormap): + """Callback when the data weakref is about to be finalized.""" + if self._colormap is weakrefColormap: + self.setColormap(None) + + def _dataAboutToFinalize(self, weakrefData): + """Callback when the data weakref is about to be finalized.""" + if self._data is weakrefData: + self.setData(None) + + def getHistogram(self): + """Returns the counts and bin edges of the displayed histogram. + + :return: (hist, bin_edges) + :rtype: 2-tuple of numpy arrays""" + if self._histogramData is None: + return None + else: + bins, counts = self._histogramData + return numpy.array(bins, copy=True), numpy.array(counts, copy=True) + + def setHistogram(self, hist=None, bin_edges=None): + """Set the histogram to display. + + This update the data range with the bounds of the bins. + + :param hist: array-like of counts or None to hide histogram + :param bin_edges: array-like of bins edges or None to hide histogram + """ + if hist is None or bin_edges is None: + self._histogramData = None + self._plot.remove(legend='Histogram', kind='histogram') + else: + hist = numpy.array(hist, copy=True) + bin_edges = numpy.array(bin_edges, copy=True) + self._histogramData = hist, bin_edges + norm_hist = hist / max(hist) + self._plot.addHistogram(norm_hist, + bin_edges, + legend="Histogram", + color='gray', + align='center', + fill=True) + self._updateMinMaxData() + + def getColormap(self): + """Return the colormap description as a :class:`.Colormap`. + + """ + if self._colormap is None: + return None + return self._colormap() + + def resetColormap(self): + """ + Reset the colormap state before modification. + + ..note :: the colormap reference state is the state when set or the + state when validated + """ + colormap = self.getColormap() + if colormap is not None and self._colormapStoredState is not None: + if self._colormap()._toDict() != self._colormapStoredState: + self._ignoreColormapChange = True + colormap._setFromDict(self._colormapStoredState) + self._ignoreColormapChange = False + self._applyColormap() + + def setDataRange(self, minimum=None, positiveMin=None, maximum=None): + """Set the range of data to use for the range of the histogram area. + + :param float minimum: The minimum of the data + :param float positiveMin: The positive minimum of the data + :param float maximum: The maximum of the data + """ + if minimum is None or positiveMin is None or maximum is None: + self._dataRange = None + self._plot.remove(legend='Range', kind='histogram') + else: + hist = numpy.array([1]) + bin_edges = numpy.array([minimum, maximum]) + self._plot.addHistogram(hist, + bin_edges, + legend="Range", + color='gray', + align='center', + fill=True) + self._dataRange = minimum, positiveMin, maximum + self._updateMinMaxData() + + def _updateMinMaxData(self): + """Update the min and max of the data according to the data range and + the histogram preset.""" + colormap = self.getColormap() + + minimum = float("+inf") + maximum = float("-inf") + + if colormap is not None and colormap.getNormalization() == colormap.LOGARITHM: + # find a range in the positive part of the data + if self._dataRange is not None: + minimum = min(minimum, self._dataRange[1]) + maximum = max(maximum, self._dataRange[2]) + if self._histogramData is not None: + positives = list(filter(lambda x: x > 0, self._histogramData[1])) + if len(positives) > 0: + minimum = min(minimum, positives[0]) + maximum = max(maximum, positives[-1]) + else: + if self._dataRange is not None: + minimum = min(minimum, self._dataRange[0]) + maximum = max(maximum, self._dataRange[2]) + if self._histogramData is not None: + minimum = min(minimum, self._histogramData[1][0]) + maximum = max(maximum, self._histogramData[1][-1]) + + if not numpy.isfinite(minimum): + minimum = None + if not numpy.isfinite(maximum): + maximum = None + + self._minValue.setDataValue(minimum) + self._maxValue.setDataValue(maximum) + self._plotUpdate() + + def accept(self): + self.storeCurrentState() + qt.QDialog.accept(self) + + def storeCurrentState(self): + """ + save the current value sof the colormap if the user want to undo is + modifications + """ + colormap = self.getColormap() + if colormap is not None: + self._colormapStoredState = colormap._toDict() + else: + self._colormapStoredState = None + + def reject(self): + self.resetColormap() + qt.QDialog.reject(self) + + def setColormap(self, colormap): + """Set the colormap description + + :param :class:`Colormap` colormap: the colormap to edit + """ + assert colormap is None or isinstance(colormap, Colormap) + if self._ignoreColormapChange is True: + return + + oldColormap = self.getColormap() + if oldColormap is colormap: + return + if oldColormap is not None: + oldColormap.sigChanged.disconnect(self._applyColormap) + + if colormap is not None: + colormap.sigChanged.connect(self._applyColormap) + colormap = weakref.ref(colormap, self._colormapAboutToFinalize) + + self._colormap = colormap + self.storeCurrentState() + self._updateResetButton() + self._applyColormap() + + def _updateResetButton(self): + resetButton = self._buttonsNonModal.button(qt.QDialogButtonBox.Reset) + rStateEnabled = False + colormap = self.getColormap() + if colormap is not None and colormap.isEditable(): + # can reset only in the case the colormap changed + rStateEnabled = colormap._toDict() != self._colormapStoredState + resetButton.setEnabled(rStateEnabled) + + def _applyColormap(self): + self._updateResetButton() + if self._ignoreColormapChange is True: + return + + colormap = self.getColormap() + if colormap is None: + self._comboBoxColormap.setEnabled(False) + self._normButtonLinear.setEnabled(False) + self._normButtonLog.setEnabled(False) + self._minValue.setEnabled(False) + self._maxValue.setEnabled(False) + else: + self._ignoreColormapChange = True + + if colormap.getName() is not None: + name = colormap.getName() + self._comboBoxColormap.setCurrentName(name) + self._comboBoxColormap.setEnabled(self._colormap().isEditable()) + + assert colormap.getNormalization() in Colormap.NORMALIZATIONS + self._normButtonLinear.setChecked( + colormap.getNormalization() == Colormap.LINEAR) + self._normButtonLog.setChecked( + colormap.getNormalization() == Colormap.LOGARITHM) + vmin = colormap.getVMin() + vmax = colormap.getVMax() + dataRange = colormap.getColormapRange() + self._normButtonLinear.setEnabled(self._colormap().isEditable()) + self._normButtonLog.setEnabled(self._colormap().isEditable()) + self._minValue.setValue(vmin or dataRange[0], isAuto=vmin is None) + self._maxValue.setValue(vmax or dataRange[1], isAuto=vmax is None) + self._minValue.setEnabled(self._colormap().isEditable()) + self._maxValue.setEnabled(self._colormap().isEditable()) + self._ignoreColormapChange = False + + self._plotUpdate() + + def _updateMinMax(self): + if self._ignoreColormapChange is True: + return + + vmin = self._minValue.getFiniteValue() + vmax = self._maxValue.getFiniteValue() + if vmax is not None and vmin is not None and vmax < vmin: + # If only one autoscale is checked constraints are too strong + # We have to edit a user value anyway it is not requested + # TODO: It would be better IMO to disable the auto checkbox before + # this case occur (valls) + cmin = self._minValue.isAutoChecked() + cmax = self._maxValue.isAutoChecked() + if cmin is False: + self._minValue.setFiniteValue(vmax) + if cmax is False: + self._maxValue.setFiniteValue(vmin) + + vmin = self._minValue.getValue() + vmax = self._maxValue.getValue() + self._ignoreColormapChange = True + colormap = self._colormap() + if colormap is not None: + colormap.setVRange(vmin, vmax) + self._ignoreColormapChange = False + self._plotUpdate() + self._updateResetButton() + + def _updateName(self): + if self._ignoreColormapChange is True: + return + + if self._colormap(): + self._ignoreColormapChange = True + self._colormap().setName( + self._comboBoxColormap.getCurrentName()) + self._ignoreColormapChange = False + + def _updateLinearNorm(self, isNormLinear): + if self._ignoreColormapChange is True: + return + + if self._colormap(): + self._ignoreColormapChange = True + norm = Colormap.LINEAR if isNormLinear else Colormap.LOGARITHM + self._colormap().setNormalization(norm) + self._ignoreColormapChange = False + + def _minMaxTextEdited(self, text): + """Handle _minValue and _maxValue textEdited signal""" + self._minMaxWasEdited = True + + def _minEditingFinished(self): + """Handle _minValue editingFinished signal + + Together with :meth:`_minMaxTextEdited`, this avoids to notify + colormap change when the min and max value where not edited. + """ + if self._minMaxWasEdited: + self._minMaxWasEdited = False + + # Fix start value + if (self._maxValue.getValue() is not None and + self._minValue.getValue() > self._maxValue.getValue()): + self._minValue.setValue(self._maxValue.getValue()) + self._updateMinMax() + + def _maxEditingFinished(self): + """Handle _maxValue editingFinished signal + + Together with :meth:`_minMaxTextEdited`, this avoids to notify + colormap change when the min and max value where not edited. + """ + if self._minMaxWasEdited: + self._minMaxWasEdited = False + + # Fix end value + if (self._minValue.getValue() is not None and + self._minValue.getValue() > self._maxValue.getValue()): + self._maxValue.setValue(self._minValue.getValue()) + self._updateMinMax() + + def keyPressEvent(self, event): + """Override key handling. + + It disables leaving the dialog when editing a text field. + """ + if event.key() == qt.Qt.Key_Enter and (self._minValue.hasFocus() or + self._maxValue.hasFocus()): + # Bypass QDialog keyPressEvent + # To avoid leaving the dialog when pressing enter on a text field + super(qt.QDialog, self).keyPressEvent(event) + else: + # Use QDialog keyPressEvent + super(ColormapDialog, self).keyPressEvent(event) + + def _activeLogNorm(self, isLog): + if self._ignoreColormapChange is True: + return + if self._colormap(): + self._ignoreColormapChange = True + norm = Colormap.LOGARITHM if isLog is True else Colormap.LINEAR + self._colormap().setNormalization(norm) + self._ignoreColormapChange = False + self._updateMinMaxData() diff --git a/silx/gui/dialog/GroupDialog.py b/silx/gui/dialog/GroupDialog.py new file mode 100644 index 0000000..71235d2 --- /dev/null +++ b/silx/gui/dialog/GroupDialog.py @@ -0,0 +1,177 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018 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 provides a dialog widget to select a HDF5 group in a +tree. + +.. autoclass:: GroupDialog + :show-inheritance: + :members: + + +""" +from silx.gui import qt +from silx.gui.hdf5.Hdf5TreeView import Hdf5TreeView +import silx.io +from silx.io.url import DataUrl + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "22/03/2018" + + +class GroupDialog(qt.QDialog): + """This :class:`QDialog` uses a :class:`silx.gui.hdf5.Hdf5TreeView` to + provide a HDF5 group selection dialog. + + The information identifying the selected node is provided as a + :class:`silx.io.url.DataUrl`. + + Example: + + .. code-block:: python + + dialog = GroupDialog() + dialog.addFile(filepath1) + dialog.addFile(filepath2) + + if dialog.exec_(): + print("File path: %s" % dialog.getSelectedDataUrl().file_path()) + print("HDF5 group path : %s " % dialog.getSelectedDataUrl().data_path()) + else: + print("Operation cancelled :(") + + """ + def __init__(self, parent=None): + qt.QDialog.__init__(self, parent) + self.setWindowTitle("HDF5 group selection") + + self._tree = Hdf5TreeView(self) + self._tree.setSelectionMode(qt.QAbstractItemView.SingleSelection) + self._tree.activated.connect(self._onActivation) + self._tree.selectionModel().selectionChanged.connect( + self._onSelectionChange) + + self._model = self._tree.findHdf5TreeModel() + + self._header = self._tree.header() + self._header.setSections([self._model.NAME_COLUMN, + self._model.NODE_COLUMN, + self._model.LINK_COLUMN]) + + _labelSubgroup = qt.QLabel(self) + _labelSubgroup.setText("Subgroup name (optional)") + self._lineEditSubgroup = qt.QLineEdit(self) + self._lineEditSubgroup.setToolTip( + "Specify the name of a new subgroup " + "to be created in the selected group.") + self._lineEditSubgroup.textChanged.connect( + self._onSubgroupNameChange) + + _labelSelectionTitle = qt.QLabel(self) + _labelSelectionTitle.setText("Current selection") + self._labelSelection = qt.QLabel(self) + self._labelSelection.setStyleSheet("color: gray") + self._labelSelection.setWordWrap(True) + self._labelSelection.setText("Select a group") + + buttonBox = qt.QDialogButtonBox() + self._okButton = buttonBox.addButton(qt.QDialogButtonBox.Ok) + self._okButton.setEnabled(False) + buttonBox.addButton(qt.QDialogButtonBox.Cancel) + + buttonBox.accepted.connect(self.accept) + buttonBox.rejected.connect(self.reject) + + vlayout = qt.QVBoxLayout(self) + vlayout.addWidget(self._tree) + vlayout.addWidget(_labelSubgroup) + vlayout.addWidget(self._lineEditSubgroup) + vlayout.addWidget(_labelSelectionTitle) + vlayout.addWidget(self._labelSelection) + vlayout.addWidget(buttonBox) + self.setLayout(vlayout) + + self.setMinimumWidth(400) + + self._selectedUrl = None + + def addFile(self, path): + """Add a HDF5 file to the tree. + All groups it contains will be selectable in the dialog. + + :param str path: File path + """ + self._model.insertFile(path) + + def addGroup(self, group): + """Add a HDF5 group to the tree. This group and all its subgroups + will be selectable in the dialog. + + :param h5py.Group group: HDF5 group + """ + self._model.insertH5pyObject(group) + + def _onActivation(self, idx): + # double-click or enter press + nodes = list(self._tree.selectedH5Nodes()) + node = nodes[0] + if silx.io.is_group(node.h5py_object): + self.accept() + + def _onSelectionChange(self, old, new): + self._updateUrl() + + def _onSubgroupNameChange(self, text): + self._updateUrl() + + def _updateUrl(self): + nodes = list(self._tree.selectedH5Nodes()) + subgroupName = self._lineEditSubgroup.text() + if nodes: + node = nodes[0] + if silx.io.is_group(node.h5py_object): + data_path = node.local_name + if subgroupName.lstrip("/"): + if not data_path.endswith("/"): + data_path += "/" + data_path += subgroupName.lstrip("/") + self._selectedUrl = DataUrl(file_path=node.local_filename, + data_path=data_path) + self._okButton.setEnabled(True) + self._labelSelection.setText( + self._selectedUrl.path()) + else: + self._selectedUrl = None + self._okButton.setEnabled(False) + self._labelSelection.setText("Select a group") + + def getSelectedDataUrl(self): + """Return a :class:`DataUrl` with a file path and a data path. + Return None if the dialog was cancelled. + + :return: :class:`silx.io.url.DataUrl` object pointing to the + selected group. + """ + return self._selectedUrl diff --git a/silx/gui/dialog/test/__init__.py b/silx/gui/dialog/test/__init__.py index eee8aea..f43a37a 100644 --- a/silx/gui/dialog/test/__init__.py +++ b/silx/gui/dialog/test/__init__.py @@ -26,7 +26,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "07/02/2018" +__date__ = "24/04/2018" import logging @@ -42,6 +42,8 @@ def suite(): test_suite = unittest.TestSuite() from . import test_imagefiledialog from . import test_datafiledialog + from . import test_colormapdialog test_suite.addTest(test_imagefiledialog.suite()) test_suite.addTest(test_datafiledialog.suite()) + test_suite.addTest(test_colormapdialog.suite()) return test_suite diff --git a/silx/gui/dialog/test/test_colormapdialog.py b/silx/gui/dialog/test/test_colormapdialog.py new file mode 100644 index 0000000..6f0ceea --- /dev/null +++ b/silx/gui/dialog/test/test_colormapdialog.py @@ -0,0 +1,398 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2018 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. +# +# ###########################################################################*/ +"""Basic tests for ColormapDialog""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "23/05/2018" + + +import doctest +import unittest + +from silx.gui.test.utils import qWaitForWindowExposedAndActivate +from silx.gui import qt +from silx.gui.dialog import ColormapDialog +from silx.gui.test.utils import TestCaseQt +from silx.gui.colors import Colormap, preferredColormaps +from silx.utils.testutils import ParametricTestCase +from silx.gui.plot.PlotWindow import PlotWindow + +import numpy.random + + +# Makes sure a QApplication exists +_qapp = qt.QApplication.instance() or qt.QApplication([]) + + +def _tearDownQt(docTest): + """Tear down to use for test from docstring. + + Checks that dialog widget is displayed + """ + dialogWidget = docTest.globs['dialog'] + qWaitForWindowExposedAndActivate(dialogWidget) + dialogWidget.setAttribute(qt.Qt.WA_DeleteOnClose) + dialogWidget.close() + del dialogWidget + _qapp.processEvents() + + +cmapDocTestSuite = doctest.DocTestSuite(ColormapDialog, tearDown=_tearDownQt) +"""Test suite of tests from the module's docstrings.""" + + +class TestColormapDialog(TestCaseQt, ParametricTestCase): + """Test the ColormapDialog.""" + def setUp(self): + TestCaseQt.setUp(self) + ParametricTestCase.setUp(self) + self.colormap = Colormap(name='gray', vmin=10.0, vmax=20.0, + normalization='linear') + + self.colormapDiag = ColormapDialog.ColormapDialog() + self.colormapDiag.setAttribute(qt.Qt.WA_DeleteOnClose) + + def tearDown(self): + del self.colormapDiag + ParametricTestCase.tearDown(self) + TestCaseQt.tearDown(self) + + def testGUIEdition(self): + """Make sure the colormap is correctly edited and also that the + modification are correctly updated if an other colormapdialog is + editing the same colormap""" + colormapDiag2 = ColormapDialog.ColormapDialog() + colormapDiag2.setColormap(self.colormap) + self.colormapDiag.setColormap(self.colormap) + + self.colormapDiag._comboBoxColormap.setCurrentName('red') + self.colormapDiag._normButtonLog.setChecked(True) + self.assertTrue(self.colormap.getName() == 'red') + self.assertTrue(self.colormapDiag.getColormap().getName() == 'red') + self.assertTrue(self.colormap.getNormalization() == 'log') + self.assertTrue(self.colormap.getVMin() == 10) + self.assertTrue(self.colormap.getVMax() == 20) + # checked second colormap dialog + self.assertTrue(colormapDiag2._comboBoxColormap.getCurrentName() == 'red') + self.assertTrue(colormapDiag2._normButtonLog.isChecked()) + self.assertTrue(int(colormapDiag2._minValue.getValue()) == 10) + self.assertTrue(int(colormapDiag2._maxValue.getValue()) == 20) + colormapDiag2.close() + + def testGUIModalOk(self): + """Make sure the colormap is modified if gone through accept""" + assert self.colormap.isAutoscale() is False + self.colormapDiag.setModal(True) + self.colormapDiag.show() + self.colormapDiag.setColormap(self.colormap) + self.assertTrue(self.colormap.getVMin() is not None) + self.colormapDiag._minValue.setValue(None) + self.assertTrue(self.colormap.getVMin() is None) + self.colormapDiag._maxValue.setValue(None) + self.mouseClick( + widget=self.colormapDiag._buttonsModal.button(qt.QDialogButtonBox.Ok), + button=qt.Qt.LeftButton + ) + self.assertTrue(self.colormap.getVMin() is None) + self.assertTrue(self.colormap.getVMax() is None) + self.assertTrue(self.colormap.isAutoscale() is True) + + def testGUIModalCancel(self): + """Make sure the colormap is not modified if gone through reject""" + assert self.colormap.isAutoscale() is False + self.colormapDiag.setModal(True) + self.colormapDiag.show() + self.colormapDiag.setColormap(self.colormap) + self.assertTrue(self.colormap.getVMin() is not None) + self.colormapDiag._minValue.setValue(None) + self.assertTrue(self.colormap.getVMin() is None) + self.mouseClick( + widget=self.colormapDiag._buttonsModal.button(qt.QDialogButtonBox.Cancel), + button=qt.Qt.LeftButton + ) + self.assertTrue(self.colormap.getVMin() is not None) + + def testGUIModalClose(self): + assert self.colormap.isAutoscale() is False + self.colormapDiag.setModal(False) + self.colormapDiag.show() + self.colormapDiag.setColormap(self.colormap) + self.assertTrue(self.colormap.getVMin() is not None) + self.colormapDiag._minValue.setValue(None) + self.assertTrue(self.colormap.getVMin() is None) + self.mouseClick( + widget=self.colormapDiag._buttonsNonModal.button(qt.QDialogButtonBox.Close), + button=qt.Qt.LeftButton + ) + self.assertTrue(self.colormap.getVMin() is None) + + def testGUIModalReset(self): + assert self.colormap.isAutoscale() is False + self.colormapDiag.setModal(False) + self.colormapDiag.show() + self.colormapDiag.setColormap(self.colormap) + self.assertTrue(self.colormap.getVMin() is not None) + self.colormapDiag._minValue.setValue(None) + self.assertTrue(self.colormap.getVMin() is None) + self.mouseClick( + widget=self.colormapDiag._buttonsNonModal.button(qt.QDialogButtonBox.Reset), + button=qt.Qt.LeftButton + ) + self.assertTrue(self.colormap.getVMin() is not None) + self.colormapDiag.close() + + def testGUIClose(self): + """Make sure the colormap is modify if go through reject""" + assert self.colormap.isAutoscale() is False + self.colormapDiag.show() + self.colormapDiag.setColormap(self.colormap) + self.assertTrue(self.colormap.getVMin() is not None) + self.colormapDiag._minValue.setValue(None) + self.assertTrue(self.colormap.getVMin() is None) + self.colormapDiag.close() + self.assertTrue(self.colormap.getVMin() is None) + + def testSetColormapIsCorrect(self): + """Make sure the interface fir the colormap when set a new colormap""" + self.colormap.setName('red') + for norm in (Colormap.NORMALIZATIONS): + for autoscale in (True, False): + if autoscale is True: + self.colormap.setVRange(None, None) + else: + self.colormap.setVRange(11, 101) + self.colormap.setNormalization(norm) + with self.subTest(colormap=self.colormap): + self.colormapDiag.setColormap(self.colormap) + self.assertTrue( + self.colormapDiag._normButtonLinear.isChecked() == (norm is Colormap.LINEAR)) + self.assertTrue( + self.colormapDiag._comboBoxColormap.getCurrentName() == 'red') + self.assertTrue( + self.colormapDiag._minValue.isAutoChecked() == autoscale) + self.assertTrue( + self.colormapDiag._maxValue.isAutoChecked() == autoscale) + if autoscale is False: + self.assertTrue(self.colormapDiag._minValue.getValue() == 11) + self.assertTrue(self.colormapDiag._maxValue.getValue() == 101) + self.assertTrue(self.colormapDiag._minValue.isEnabled()) + self.assertTrue(self.colormapDiag._maxValue.isEnabled()) + else: + self.assertFalse(self.colormapDiag._minValue._numVal.isEnabled()) + self.assertFalse(self.colormapDiag._maxValue._numVal.isEnabled()) + + def testColormapDel(self): + """Check behavior if the colormap has been deleted outside. For now + we make sure the colormap is still running and nothing more""" + self.colormapDiag.setColormap(self.colormap) + self.colormapDiag.show() + del self.colormap + self.assertTrue(self.colormapDiag.getColormap() is None) + self.colormapDiag._comboBoxColormap.setCurrentName('blue') + + def testColormapEditedOutside(self): + """Make sure the GUI is still up to date if the colormap is modified + outside""" + self.colormapDiag.setColormap(self.colormap) + self.colormapDiag.show() + + self.colormap.setName('red') + self.assertTrue( + self.colormapDiag._comboBoxColormap.getCurrentName() == 'red') + self.colormap.setNormalization(Colormap.LOGARITHM) + self.assertFalse(self.colormapDiag._normButtonLinear.isChecked()) + self.colormap.setVRange(11, 201) + self.assertTrue(self.colormapDiag._minValue.getValue() == 11) + self.assertTrue(self.colormapDiag._maxValue.getValue() == 201) + self.assertTrue(self.colormapDiag._minValue._numVal.isEnabled()) + self.assertTrue(self.colormapDiag._maxValue._numVal.isEnabled()) + self.assertFalse(self.colormapDiag._minValue.isAutoChecked()) + self.assertFalse(self.colormapDiag._maxValue.isAutoChecked()) + self.colormap.setVRange(None, None) + self.assertFalse(self.colormapDiag._minValue._numVal.isEnabled()) + self.assertFalse(self.colormapDiag._maxValue._numVal.isEnabled()) + self.assertTrue(self.colormapDiag._minValue.isAutoChecked()) + self.assertTrue(self.colormapDiag._maxValue.isAutoChecked()) + + def testSetColormapScenario(self): + """Test of a simple scenario of a colormap dialog editing several + colormap""" + colormap1 = Colormap(name='gray', vmin=10.0, vmax=20.0, + normalization='linear') + colormap2 = Colormap(name='red', vmin=10.0, vmax=20.0, + normalization='log') + colormap3 = Colormap(name='blue', vmin=None, vmax=None, + normalization='linear') + self.colormapDiag.setColormap(self.colormap) + self.colormapDiag.setColormap(colormap1) + del colormap1 + self.colormapDiag.setColormap(colormap2) + del colormap2 + self.colormapDiag.setColormap(colormap3) + del colormap3 + + def testNotPreferredColormap(self): + """Test that the colormapEditor is able to edit a colormap which is not + part of the 'prefered colormap' + """ + def getFirstNotPreferredColormap(): + cms = Colormap.getSupportedColormaps() + preferred = preferredColormaps() + for cm in cms: + if cm not in preferred: + return cm + return None + + colormapName = getFirstNotPreferredColormap() + assert colormapName is not None + colormap = Colormap(name=colormapName) + self.colormapDiag.setColormap(colormap) + self.colormapDiag.show() + cb = self.colormapDiag._comboBoxColormap + self.assertTrue(cb.getCurrentName() == colormapName) + cb.setCurrentIndex(0) + index = cb.findColormap(colormapName) + assert index is not 0 # if 0 then the rest of the test has no sense + cb.setCurrentIndex(index) + self.assertTrue(cb.getCurrentName() == colormapName) + + def testColormapEditableMode(self): + """Test that the colormapDialog is correctly updated when changing the + colormap editable status""" + colormap = Colormap(normalization='linear', vmin=1.0, vmax=10.0) + self.colormapDiag.setColormap(colormap) + for editable in (True, False): + with self.subTest(editable=editable): + colormap.setEditable(editable) + self.assertTrue( + self.colormapDiag._comboBoxColormap.isEnabled() is editable) + self.assertTrue( + self.colormapDiag._minValue.isEnabled() is editable) + self.assertTrue( + self.colormapDiag._maxValue.isEnabled() is editable) + self.assertTrue( + self.colormapDiag._normButtonLinear.isEnabled() is editable) + self.assertTrue( + self.colormapDiag._normButtonLog.isEnabled() is editable) + + # Make sure the reset button is also set to enable when edition mode is + # False + self.colormapDiag.setModal(False) + colormap.setEditable(True) + self.colormapDiag._normButtonLog.setChecked(True) + resetButton = self.colormapDiag._buttonsNonModal.button(qt.QDialogButtonBox.Reset) + self.assertTrue(resetButton.isEnabled()) + colormap.setEditable(False) + self.assertFalse(resetButton.isEnabled()) + + def testImageData(self): + data = numpy.random.rand(5, 5) + self.colormapDiag.setData(data) + + def testEmptyData(self): + data = numpy.empty((10, 0)) + self.colormapDiag.setData(data) + + def testNoneData(self): + data = numpy.random.rand(5, 5) + self.colormapDiag.setData(data) + self.colormapDiag.setData(None) + + +class TestColormapAction(TestCaseQt): + def setUp(self): + TestCaseQt.setUp(self) + self.plot = PlotWindow() + self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) + + self.colormap1 = Colormap(name='blue', vmin=0.0, vmax=1.0, + normalization='linear') + self.colormap2 = Colormap(name='red', vmin=10.0, vmax=100.0, + normalization='log') + self.defaultColormap = self.plot.getDefaultColormap() + + self.plot.getColormapAction()._actionTriggered(checked=True) + self.colormapDialog = self.plot.getColormapAction()._dialog + self.colormapDialog.setAttribute(qt.Qt.WA_DeleteOnClose) + + def tearDown(self): + self.colormapDialog.close() + self.plot.close() + del self.colormapDialog + del self.plot + TestCaseQt.tearDown(self) + + def testActiveColormap(self): + self.assertTrue(self.colormapDialog.getColormap() is self.defaultColormap) + + self.plot.addImage(data=numpy.random.rand(10, 10), legend='img1', + origin=(0, 0), + colormap=self.colormap1) + self.plot.setActiveImage('img1') + self.assertTrue(self.colormapDialog.getColormap() is self.colormap1) + + self.plot.addImage(data=numpy.random.rand(10, 10), legend='img2', + origin=(0, 0), + colormap=self.colormap2) + self.plot.addImage(data=numpy.random.rand(10, 10), legend='img3', + origin=(0, 0)) + + self.plot.setActiveImage('img3') + self.assertTrue(self.colormapDialog.getColormap() is self.defaultColormap) + self.plot.getActiveImage().setColormap(self.colormap2) + self.assertTrue(self.colormapDialog.getColormap() is self.colormap2) + + self.plot.remove('img2') + self.plot.remove('img3') + self.plot.remove('img1') + self.assertTrue(self.colormapDialog.getColormap() is self.defaultColormap) + + def testShowHideColormapDialog(self): + self.plot.getColormapAction()._actionTriggered(checked=False) + self.assertFalse(self.plot.getColormapAction().isChecked()) + self.plot.getColormapAction()._actionTriggered(checked=True) + self.assertTrue(self.plot.getColormapAction().isChecked()) + self.plot.addImage(data=numpy.random.rand(10, 10), legend='img1', + origin=(0, 0), + colormap=self.colormap1) + self.colormap1.setName('red') + self.plot.getColormapAction()._actionTriggered() + self.colormap1.setName('blue') + self.colormapDialog.close() + self.assertFalse(self.plot.getColormapAction().isChecked()) + + +def suite(): + test_suite = unittest.TestSuite() + test_suite.addTest(cmapDocTestSuite) + for testClass in (TestColormapDialog, TestColormapAction): + test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase( + testClass)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/dialog/test/test_datafiledialog.py b/silx/gui/dialog/test/test_datafiledialog.py index bdda810..38fa03b 100644 --- a/silx/gui/dialog/test/test_datafiledialog.py +++ b/silx/gui/dialog/test/test_datafiledialog.py @@ -26,7 +26,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "14/02/2018" +__date__ = "03/07/2018" import unittest @@ -79,6 +79,20 @@ def setUpModule(): f["nxdata"].attrs["NX_class"] = u"NXdata" f.close() + if h5py is not None: + directory = os.path.join(_tmpDirectory, "data") + os.mkdir(directory) + filename = os.path.join(directory, "data.h5") + f = h5py.File(filename, "w") + f["scalar"] = 10 + f["image"] = data + f["cube"] = [data, data + 1, data + 2] + f["complex_image"] = data * 1j + f["group/image"] = data + f["nxdata/foo"] = 10 + f["nxdata"].attrs["NX_class"] = u"NXdata" + f.close() + filename = _tmpDirectory + "/badformat.h5" with io.open(filename, "wb") as f: f.write(b"{\nHello Nurse!") @@ -270,7 +284,7 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] action = utils.findChildren(dialog, qt.QAction, name="toParentAction")[0] toParentButton = utils.getQToolButtonFromAction(action) - filename = _tmpDirectory + "/data.h5" + filename = _tmpDirectory + "/data/data.h5" # init state path = silx.io.url.DataUrl(file_path=filename, data_path="/group/image").path() @@ -286,11 +300,11 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): self.mouseClick(toParentButton, qt.Qt.LeftButton) self.qWaitForPendingActions(dialog) - self.assertSamePath(url.text(), _tmpDirectory) + self.assertSamePath(url.text(), _tmpDirectory + "/data") self.mouseClick(toParentButton, qt.Qt.LeftButton) self.qWaitForPendingActions(dialog) - self.assertSamePath(url.text(), os.path.dirname(_tmpDirectory)) + self.assertSamePath(url.text(), _tmpDirectory) def testClickOnBackToRootTool(self): if h5py is None: @@ -529,7 +543,7 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): self.qWaitForWindowExposed(dialog) dialog.selectUrl(_tmpDirectory) self.qWaitForPendingActions(dialog) - self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 3) + self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 4) class TestDataFileDialog_FilterDataset(utils.TestCaseQt, _UtilsMixin): diff --git a/silx/gui/dialog/test/test_imagefiledialog.py b/silx/gui/dialog/test/test_imagefiledialog.py index 7909f10..8fef3c5 100644 --- a/silx/gui/dialog/test/test_imagefiledialog.py +++ b/silx/gui/dialog/test/test_imagefiledialog.py @@ -26,7 +26,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "12/02/2018" +__date__ = "03/07/2018" import unittest @@ -50,7 +50,7 @@ import silx.io.url from silx.gui import qt from silx.gui.test import utils from ..ImageFileDialog import ImageFileDialog -from silx.gui.plot.Colormap import Colormap +from silx.gui.colors import Colormap from silx.gui.hdf5 import Hdf5TreeModel _tmpDirectory = None @@ -88,6 +88,18 @@ def setUpModule(): f["group/image"] = data f.close() + if h5py is not None: + directory = os.path.join(_tmpDirectory, "data") + os.mkdir(directory) + filename = os.path.join(directory, "data.h5") + f = h5py.File(filename, "w") + f["scalar"] = 10 + f["image"] = data + f["cube"] = [data, data + 1, data + 2] + f["complex_image"] = data * 1j + f["group/image"] = data + f.close() + filename = _tmpDirectory + "/badformat.edf" with io.open(filename, "wb") as f: f.write(b"{\nHello Nurse!") @@ -256,27 +268,31 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] action = utils.findChildren(dialog, qt.QAction, name="toParentAction")[0] toParentButton = utils.getQToolButtonFromAction(action) - filename = _tmpDirectory + "/data.h5" + filename = _tmpDirectory + "/data/data.h5" # init state path = silx.io.url.DataUrl(file_path=filename, data_path="/group/image").path() dialog.selectUrl(path) self.qWaitForPendingActions(dialog) path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path() + print(url.text()) self.assertSamePath(url.text(), path) # test self.mouseClick(toParentButton, qt.Qt.LeftButton) self.qWaitForPendingActions(dialog) path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path() + print(url.text()) self.assertSamePath(url.text(), path) self.mouseClick(toParentButton, qt.Qt.LeftButton) self.qWaitForPendingActions(dialog) - self.assertSamePath(url.text(), _tmpDirectory) + print(url.text()) + self.assertSamePath(url.text(), _tmpDirectory + "/data") self.mouseClick(toParentButton, qt.Qt.LeftButton) self.qWaitForPendingActions(dialog) - self.assertSamePath(url.text(), os.path.dirname(_tmpDirectory)) + print(url.text()) + self.assertSamePath(url.text(), _tmpDirectory) def testClickOnBackToRootTool(self): if h5py is None: @@ -540,21 +556,21 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): self.qWaitForWindowExposed(dialog) dialog.selectUrl(_tmpDirectory) self.qWaitForPendingActions(dialog) - self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 5) + self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 6) codecName = fabio.edfimage.EdfImage.codec_name() index = filters.indexFromCodec(codecName) filters.setCurrentIndex(index) filters.activated[int].emit(index) self.qWait(50) - self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 3) + self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 4) codecName = fabio.fit2dmaskimage.Fit2dMaskImage.codec_name() index = filters.indexFromCodec(codecName) filters.setCurrentIndex(index) filters.activated[int].emit(index) self.qWait(50) - self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 1) + self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 2) class TestImageFileDialogApi(utils.TestCaseQt, _UtilsMixin): |