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 | |
parent | 270d5ddc31c26b62379e3caa9044dd75ccc71847 (diff) |
New upstream version 0.8.0+dfsg
Diffstat (limited to 'silx/gui')
140 files changed, 15211 insertions, 4100 deletions
diff --git a/silx/gui/__init__.py b/silx/gui/__init__.py index 6baf238..b796e20 100644 --- a/silx/gui/__init__.py +++ b/silx/gui/__init__.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016 European Synchrotron Radiation Facility +# 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 @@ -22,7 +22,27 @@ # THE SOFTWARE. # # ###########################################################################*/ -"""Set of Qt widgets""" +"""This package provides a set of Qt widgets. + +It contains the following sub-packages and modules: + +- silx.gui.colors: Functions to handle colors and colormap +- silx.gui.console: IPython console widget +- silx.gui.data: + Widgets for displaying data arrays using table views and plot widgets +- silx.gui.dialog: Specific dialog widgets +- silx.gui.fit: Widgets for controlling curve fitting +- silx.gui.hdf5: Widgets for displaying content relative to HDF5 format +- silx.gui.icons: Functions to access embedded icons +- silx.gui.plot: Widgets for 1D and 2D plotting and related tools +- silx.gui.plot3d: Widgets for visualizing data in 3D based on OpenGL +- silx.gui.printer: Shared printer used by the library +- silx.gui.qt: Common wrapper over different Python Qt binding +- silx.gui.utils: Miscellaneous helpers for Qt +- silx.gui.widgets: Miscellaneous standalone widgets + +See silx documentation: http://www.silx.org/doc/silx/latest/ +""" __authors__ = ["T. Vincent"] __license__ = "MIT" diff --git a/silx/gui/_glutils/font.py b/silx/gui/_glutils/font.py index 2be2c04..b5bd6b5 100644 --- a/silx/gui/_glutils/font.py +++ b/silx/gui/_glutils/font.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# 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 @@ -30,11 +30,10 @@ __date__ = "13/10/2016" import logging -import sys import numpy -from .. import qt -from .._utils import convertQImageToArray +from ..utils._image import convertQImageToArray +from .. import qt _logger = logging.getLogger(__name__) diff --git a/silx/gui/colors.py b/silx/gui/colors.py new file mode 100644 index 0000000..028609b --- /dev/null +++ b/silx/gui/colors.py @@ -0,0 +1,732 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-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 API to manage colors. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent", "H.Payno"] +__license__ = "MIT" +__date__ = "14/06/2018" + +from silx.gui import qt +import copy as copy_mdl +import numpy +import logging +from silx.math.combo import min_max +from silx.math.colormap import cmap as _cmap +from silx.utils.exceptions import NotEditableError + +_logger = logging.getLogger(__file__) + + +_COLORDICT = {} +"""Dictionary of common colors.""" + +_COLORDICT['b'] = _COLORDICT['blue'] = '#0000ff' +_COLORDICT['r'] = _COLORDICT['red'] = '#ff0000' +_COLORDICT['g'] = _COLORDICT['green'] = '#00ff00' +_COLORDICT['k'] = _COLORDICT['black'] = '#000000' +_COLORDICT['w'] = _COLORDICT['white'] = '#ffffff' +_COLORDICT['pink'] = '#ff66ff' +_COLORDICT['brown'] = '#a52a2a' +_COLORDICT['orange'] = '#ff9900' +_COLORDICT['violet'] = '#6600ff' +_COLORDICT['gray'] = _COLORDICT['grey'] = '#a0a0a4' +# _COLORDICT['darkGray'] = _COLORDICT['darkGrey'] = '#808080' +# _COLORDICT['lightGray'] = _COLORDICT['lightGrey'] = '#c0c0c0' +_COLORDICT['y'] = _COLORDICT['yellow'] = '#ffff00' +_COLORDICT['m'] = _COLORDICT['magenta'] = '#ff00ff' +_COLORDICT['c'] = _COLORDICT['cyan'] = '#00ffff' +_COLORDICT['darkBlue'] = '#000080' +_COLORDICT['darkRed'] = '#800000' +_COLORDICT['darkGreen'] = '#008000' +_COLORDICT['darkBrown'] = '#660000' +_COLORDICT['darkCyan'] = '#008080' +_COLORDICT['darkYellow'] = '#808000' +_COLORDICT['darkMagenta'] = '#800080' + + +# FIXME: It could be nice to expose a functional API instead of that attribute +COLORDICT = _COLORDICT + + +def rgba(color, colorDict=None): + """Convert color code '#RRGGBB' and '#RRGGBBAA' to (R, G, B, A) + + It also convert RGB(A) values from uint8 to float in [0, 1] and + accept a QColor as color argument. + + :param str color: The color to convert + :param dict colorDict: A dictionary of color name conversion to color code + :returns: RGBA colors as floats in [0., 1.] + :rtype: tuple + """ + if colorDict is None: + colorDict = _COLORDICT + + if hasattr(color, 'getRgbF'): # QColor support + color = color.getRgbF() + + values = numpy.asarray(color).ravel() + + if values.dtype.kind in 'iuf': # integer or float + # Color is an array + assert len(values) in (3, 4) + + # Convert from integers in [0, 255] to float in [0, 1] + if values.dtype.kind in 'iu': + values = values / 255. + + # Clip to [0, 1] + values[values < 0.] = 0. + values[values > 1.] = 1. + + if len(values) == 3: + return values[0], values[1], values[2], 1. + else: + return tuple(values) + + # We assume color is a string + if not color.startswith('#'): + color = colorDict[color] + + assert len(color) in (7, 9) and color[0] == '#' + r = int(color[1:3], 16) / 255. + g = int(color[3:5], 16) / 255. + b = int(color[5:7], 16) / 255. + a = int(color[7:9], 16) / 255. if len(color) == 9 else 1. + return r, g, b, a + + +_COLORMAP_CURSOR_COLORS = { + 'gray': 'pink', + 'reversed gray': 'pink', + 'temperature': 'pink', + 'red': 'green', + 'green': 'pink', + 'blue': 'yellow', + 'jet': 'pink', + 'viridis': 'pink', + 'magma': 'green', + 'inferno': 'green', + 'plasma': 'green', +} + + +def cursorColorForColormap(colormapName): + """Get a color suitable for overlay over a colormap. + + :param str colormapName: The name of the colormap. + :return: Name of the color. + :rtype: str + """ + return _COLORMAP_CURSOR_COLORS.get(colormapName, 'black') + + +DEFAULT_COLORMAPS = ( + 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue') +"""Tuple of supported colormap names.""" + +DEFAULT_MIN_LIN = 0 +"""Default min value if in linear normalization""" +DEFAULT_MAX_LIN = 1 +"""Default max value if in linear normalization""" +DEFAULT_MIN_LOG = 1 +"""Default min value if in log normalization""" +DEFAULT_MAX_LOG = 10 +"""Default max value if in log normalization""" + + +class Colormap(qt.QObject): + """Description of a colormap + + :param str name: Name of the colormap + :param tuple colors: optional, custom colormap. + Nx3 or Nx4 numpy array of RGB(A) colors, + either uint8 or float in [0, 1]. + If 'name' is None, then this array is used as the colormap. + :param str normalization: Normalization: 'linear' (default) or 'log' + :param float vmin: + Lower bound of the colormap or None for autoscale (default) + :param float vmax: + Upper bounds of the colormap or None for autoscale (default) + """ + + LINEAR = 'linear' + """constant for linear normalization""" + + LOGARITHM = 'log' + """constant for logarithmic normalization""" + + NORMALIZATIONS = (LINEAR, LOGARITHM) + """Tuple of managed normalizations""" + + sigChanged = qt.Signal() + """Signal emitted when the colormap has changed.""" + + def __init__(self, name='gray', colors=None, normalization=LINEAR, vmin=None, vmax=None): + qt.QObject.__init__(self) + assert normalization in Colormap.NORMALIZATIONS + assert not (name is None and colors is None) + if normalization is Colormap.LOGARITHM: + if (vmin is not None and vmin < 0) or (vmax is not None and vmax < 0): + m = "Unsuported vmin (%s) and/or vmax (%s) given for a log scale." + m += ' Autoscale will be performed.' + m = m % (vmin, vmax) + _logger.warning(m) + vmin = None + vmax = None + + self._name = str(name) if name is not None else None + self._setColors(colors) + self._normalization = str(normalization) + self._vmin = float(vmin) if vmin is not None else None + self._vmax = float(vmax) if vmax is not None else None + self._editable = True + + def isAutoscale(self): + """Return True if both min and max are in autoscale mode""" + return self._vmin is None and self._vmax is None + + def getName(self): + """Return the name of the colormap + :rtype: str + """ + return self._name + + @staticmethod + def _convertColorsFromFloatToUint8(colors): + """Convert colors from float in [0, 1] to uint8 + + :param numpy.ndarray colors: Array of float colors to convert + :return: colors as uint8 + :rtype: numpy.ndarray + """ + # Each bin is [N, N+1[ except the last one: [255, 256] + return numpy.clip( + colors.astype(numpy.float64) * 256, 0., 255.).astype(numpy.uint8) + + def _setColors(self, colors): + if colors is None: + self._colors = None + else: + colors = numpy.array(colors, copy=False) + colors.shape = -1, colors.shape[-1] + if colors.dtype.kind == 'f': + colors = self._convertColorsFromFloatToUint8(colors) + + # Makes sure it is RGBA8888 + self._colors = numpy.zeros((len(colors), 4), dtype=numpy.uint8) + self._colors[:, 3] = 255 # Alpha channel + self._colors[:, :colors.shape[1]] = colors # Copy colors + + def getNColors(self, nbColors=None): + """Returns N colors computed by sampling the colormap regularly. + + :param nbColors: + The number of colors in the returned array or None for the default value. + The default value is 256 for colormap with a name (see :meth:`setName`) and + it is the size of the LUT for colormap defined with :meth:`setColormapLUT`. + :type nbColors: int or None + :return: 2D array of uint8 of shape (nbColors, 4) + :rtype: numpy.ndarray + """ + # Handle default value for nbColors + if nbColors is None: + lut = self.getColormapLUT() + if lut is not None: # In this case uses LUT length + nbColors = len(lut) + else: # Default to 256 + nbColors = 256 + + nbColors = int(nbColors) + + colormap = self.copy() + colormap.setNormalization(Colormap.LINEAR) + colormap.setVRange(vmin=None, vmax=None) + colors = colormap.applyToData( + numpy.arange(nbColors, dtype=numpy.int)) + return colors + + def setName(self, name): + """Set the name of the colormap to use. + + :param str name: The name of the colormap. + At least the following names are supported: 'gray', + 'reversed gray', 'temperature', 'red', 'green', 'blue', 'jet', + 'viridis', 'magma', 'inferno', 'plasma'. + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + assert name in self.getSupportedColormaps() + self._name = str(name) + self._colors = None + self.sigChanged.emit() + + def getColormapLUT(self): + """Return the list of colors for the colormap or None if not set + + :return: the list of colors for the colormap or None if not set + :rtype: numpy.ndarray or None + """ + if self._colors is None: + return None + else: + return numpy.array(self._colors, copy=True) + + def setColormapLUT(self, colors): + """Set the colors of the colormap. + + :param numpy.ndarray colors: the colors of the LUT. + If float, it is converted from [0, 1] to uint8 range. + Otherwise it is casted to uint8. + + .. warning: this will set the value of name to None + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + self._setColors(colors) + if len(colors) is 0: + self._colors = None + + self._name = None + self.sigChanged.emit() + + def getNormalization(self): + """Return the normalization of the colormap ('log' or 'linear') + + :return: the normalization of the colormap + :rtype: str + """ + return self._normalization + + def setNormalization(self, norm): + """Set the norm ('log', 'linear') + + :param str norm: the norm to set + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + self._normalization = str(norm) + self.sigChanged.emit() + + def getVMin(self): + """Return the lower bound of the colormap + + :return: the lower bound of the colormap + :rtype: float or None + """ + return self._vmin + + def setVMin(self, vmin): + """Set the minimal value of the colormap + + :param float vmin: Lower bound of the colormap or None for autoscale + (default) + value) + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + if vmin is not None: + if self._vmax is not None and vmin > self._vmax: + err = "Can't set vmin because vmin >= vmax. " \ + "vmin = %s, vmax = %s" % (vmin, self._vmax) + raise ValueError(err) + + self._vmin = vmin + self.sigChanged.emit() + + def getVMax(self): + """Return the upper bounds of the colormap or None + + :return: the upper bounds of the colormap or None + :rtype: float or None + """ + return self._vmax + + def setVMax(self, vmax): + """Set the maximal value of the colormap + + :param float vmax: Upper bounds of the colormap or None for autoscale + (default) + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + if vmax is not None: + if self._vmin is not None and vmax < self._vmin: + err = "Can't set vmax because vmax <= vmin. " \ + "vmin = %s, vmax = %s" % (self._vmin, vmax) + raise ValueError(err) + + self._vmax = vmax + self.sigChanged.emit() + + def isEditable(self): + """ Return if the colormap is editable or not + + :return: editable state of the colormap + :rtype: bool + """ + return self._editable + + def setEditable(self, editable): + """ + Set the editable state of the colormap + + :param bool editable: is the colormap editable + """ + assert type(editable) is bool + self._editable = editable + self.sigChanged.emit() + + def getColormapRange(self, data=None): + """Return (vmin, vmax) + + :return: the tuple vmin, vmax fitting vmin, vmax, normalization and + data if any given + :rtype: tuple + """ + vmin = self._vmin + vmax = self._vmax + assert vmin is None or vmax is None or vmin <= vmax # TODO handle this in setters + + if self.getNormalization() == self.LOGARITHM: + # Handle negative bounds as autoscale + if vmin is not None and (vmin is not None and vmin <= 0.): + mess = 'negative vmin, moving to autoscale for lower bound' + _logger.warning(mess) + vmin = None + if vmax is not None and (vmax is not None and vmax <= 0.): + mess = 'negative vmax, moving to autoscale for upper bound' + _logger.warning(mess) + vmax = None + + if vmin is None or vmax is None: # Handle autoscale + # Get min/max from data + if data is not None: + data = numpy.array(data, copy=False) + if data.size == 0: # Fallback an array but no data + min_, max_ = self._getDefaultMin(), self._getDefaultMax() + else: + if self.getNormalization() == self.LOGARITHM: + result = min_max(data, min_positive=True, finite=True) + min_ = result.min_positive # >0 or None + max_ = result.maximum # can be <= 0 + else: + min_, max_ = min_max(data, min_positive=False, finite=True) + + # Handle fallback + if min_ is None or not numpy.isfinite(min_): + min_ = self._getDefaultMin() + if max_ is None or not numpy.isfinite(max_): + max_ = self._getDefaultMax() + else: # Fallback if no data is provided + min_, max_ = self._getDefaultMin(), self._getDefaultMax() + + if vmin is None: # Set vmin respecting provided vmax + vmin = min_ if vmax is None else min(min_, vmax) + + if vmax is None: + vmax = max(max_, vmin) # Handle max_ <= 0 for log scale + + return vmin, vmax + + def setVRange(self, vmin, vmax): + """Set the bounds of the colormap + + :param vmin: Lower bound of the colormap or None for autoscale + (default) + :param vmax: Upper bounds of the colormap or None for autoscale + (default) + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + if vmin is not None and vmax is not None: + if vmin > vmax: + err = "Can't set vmin and vmax because vmin >= vmax " \ + "vmin = %s, vmax = %s" % (vmin, vmax) + raise ValueError(err) + + if self._vmin == vmin and self._vmax == vmax: + return + + self._vmin = vmin + self._vmax = vmax + self.sigChanged.emit() + + def __getitem__(self, item): + if item == 'autoscale': + return self.isAutoscale() + elif item == 'name': + return self.getName() + elif item == 'normalization': + return self.getNormalization() + elif item == 'vmin': + return self.getVMin() + elif item == 'vmax': + return self.getVMax() + elif item == 'colors': + return self.getColormapLUT() + else: + raise KeyError(item) + + def _toDict(self): + """Return the equivalent colormap as a dictionary + (old colormap representation) + + :return: the representation of the Colormap as a dictionary + :rtype: dict + """ + return { + 'name': self._name, + 'colors': copy_mdl.copy(self._colors), + 'vmin': self._vmin, + 'vmax': self._vmax, + 'autoscale': self.isAutoscale(), + 'normalization': self._normalization + } + + def _setFromDict(self, dic): + """Set values to the colormap from a dictionary + + :param dict dic: the colormap as a dictionary + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + name = dic['name'] if 'name' in dic else None + colors = dic['colors'] if 'colors' in dic else None + vmin = dic['vmin'] if 'vmin' in dic else None + vmax = dic['vmax'] if 'vmax' in dic else None + if 'normalization' in dic: + normalization = dic['normalization'] + else: + warn = 'Normalization not given in the dictionary, ' + warn += 'set by default to ' + Colormap.LINEAR + _logger.warning(warn) + normalization = Colormap.LINEAR + + if name is None and colors is None: + err = 'The colormap should have a name defined or a tuple of colors' + raise ValueError(err) + if normalization not in Colormap.NORMALIZATIONS: + err = 'Given normalization is not recoginized (%s)' % normalization + raise ValueError(err) + + # If autoscale, then set boundaries to None + if dic.get('autoscale', False): + vmin, vmax = None, None + + self._name = name + self._colors = colors + self._vmin = vmin + self._vmax = vmax + self._autoscale = True if (vmin is None and vmax is None) else False + self._normalization = normalization + + self.sigChanged.emit() + + @staticmethod + def _fromDict(dic): + colormap = Colormap(name="") + colormap._setFromDict(dic) + return colormap + + def copy(self): + """Return a copy of the Colormap. + + :rtype: silx.gui.colors.Colormap + """ + return Colormap(name=self._name, + colors=copy_mdl.copy(self._colors), + vmin=self._vmin, + vmax=self._vmax, + normalization=self._normalization) + + def applyToData(self, data): + """Apply the colormap to the data + + :param numpy.ndarray data: The data to convert. + """ + name = self.getName() + if name is not None: # Get colormap definition from matplotlib + # FIXME: If possible remove dependency to the plot + from .plot.matplotlib import Colormap as MPLColormap + mplColormap = MPLColormap.getColormap(name) + colors = mplColormap(numpy.linspace(0, 1, 256, endpoint=True)) + colors = self._convertColorsFromFloatToUint8(colors) + + else: # Use user defined LUT + colors = self.getColormapLUT() + + vmin, vmax = self.getColormapRange(data) + normalization = self.getNormalization() + + return _cmap(data, colors, vmin, vmax, normalization) + + @staticmethod + def getSupportedColormaps(): + """Get the supported colormap names as a tuple of str. + + The list should at least contain and start by: + ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue') + :rtype: tuple + """ + # FIXME: If possible remove dependency to the plot + from .plot.matplotlib import Colormap as MPLColormap + maps = MPLColormap.getSupportedColormaps() + return DEFAULT_COLORMAPS + maps + + def __str__(self): + return str(self._toDict()) + + def _getDefaultMin(self): + return DEFAULT_MIN_LIN if self._normalization == Colormap.LINEAR else DEFAULT_MIN_LOG + + def _getDefaultMax(self): + return DEFAULT_MAX_LIN if self._normalization == Colormap.LINEAR else DEFAULT_MAX_LOG + + def __eq__(self, other): + """Compare colormap values and not pointers""" + return (self.getName() == other.getName() and + self.getNormalization() == other.getNormalization() and + self.getVMin() == other.getVMin() and + self.getVMax() == other.getVMax() and + numpy.array_equal(self.getColormapLUT(), other.getColormapLUT()) + ) + + _SERIAL_VERSION = 1 + + def restoreState(self, byteArray): + """ + Read the colormap state from a QByteArray. + + :param qt.QByteArray byteArray: Stream containing the state + :return: True if the restoration sussseed + :rtype: bool + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + stream = qt.QDataStream(byteArray, qt.QIODevice.ReadOnly) + + className = stream.readQString() + if className != self.__class__.__name__: + _logger.warning("Classname mismatch. Found %s." % className) + return False + + version = stream.readUInt32() + if version != self._SERIAL_VERSION: + _logger.warning("Serial version mismatch. Found %d." % version) + return False + + name = stream.readQString() + isNull = stream.readBool() + if not isNull: + vmin = stream.readQVariant() + else: + vmin = None + isNull = stream.readBool() + if not isNull: + vmax = stream.readQVariant() + else: + vmax = None + normalization = stream.readQString() + + # emit change event only once + old = self.blockSignals(True) + try: + self.setName(name) + self.setNormalization(normalization) + self.setVRange(vmin, vmax) + finally: + self.blockSignals(old) + self.sigChanged.emit() + return True + + def saveState(self): + """ + Save state of the colomap into a QDataStream. + + :rtype: qt.QByteArray + """ + data = qt.QByteArray() + stream = qt.QDataStream(data, qt.QIODevice.WriteOnly) + + stream.writeQString(self.__class__.__name__) + stream.writeUInt32(self._SERIAL_VERSION) + stream.writeQString(self.getName()) + stream.writeBool(self.getVMin() is None) + if self.getVMin() is not None: + stream.writeQVariant(self.getVMin()) + stream.writeBool(self.getVMax() is None) + if self.getVMax() is not None: + stream.writeQVariant(self.getVMax()) + stream.writeQString(self.getNormalization()) + return data + + +_PREFERRED_COLORMAPS = None +""" +Tuple of preferred colormap names accessed with :meth:`preferredColormaps`. +""" + + +def preferredColormaps(): + """Returns the name of the preferred colormaps. + + This list is used by widgets allowing to change the colormap + like the :class:`ColormapDialog` as a subset of colormap choices. + + :rtype: tuple of str + """ + global _PREFERRED_COLORMAPS + if _PREFERRED_COLORMAPS is None: + _PREFERRED_COLORMAPS = DEFAULT_COLORMAPS + # Initialize preferred colormaps + setPreferredColormaps(('gray', 'reversed gray', + 'temperature', 'red', 'green', 'blue', 'jet', + 'viridis', 'magma', 'inferno', 'plasma', + 'hsv')) + return _PREFERRED_COLORMAPS + + +def setPreferredColormaps(colormaps): + """Set the list of preferred colormap names. + + Warning: If a colormap name is not available + it will be removed from the list. + + :param colormaps: Not empty list of colormap names + :type colormaps: iterable of str + :raise ValueError: if the list of available preferred colormaps is empty. + """ + supportedColormaps = Colormap.getSupportedColormaps() + colormaps = tuple( + cmap for cmap in colormaps if cmap in supportedColormaps) + if len(colormaps) == 0: + raise ValueError("Cannot set preferred colormaps to an empty list") + + global _PREFERRED_COLORMAPS + _PREFERRED_COLORMAPS = colormaps diff --git a/silx/gui/console.py b/silx/gui/console.py index 3c69419..b6341ef 100644 --- a/silx/gui/console.py +++ b/silx/gui/console.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2017 European Synchrotron Radiation Facility +# 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 @@ -34,9 +34,8 @@ the widgets' methods from the console. .. note:: This module has a dependency on - `IPython <https://pypi.python.org/pypi/ipython>`_ and - `qtconsole <https://pypi.python.org/pypi/qtconsole>`_ (or *ipython.qt* for - older versions of *IPython*). An ``ImportError`` will be raised if it is + `qtconsole <https://pypi.org/project/qtconsole/>`_. + An ``ImportError`` will be raised if it is imported while the dependencies are not satisfied. Basic usage example:: @@ -76,11 +75,7 @@ from . import qt _logger = logging.getLogger(__name__) -try: - import IPython -except ImportError as e: - raise ImportError("Failed to import IPython, required by " + __name__) - + # This widget cannot be used inside an interactive IPython shell. # It would raise MultipleInstanceError("Multiple incompatible subclass # instances of InProcessInteractiveShell are being created"). @@ -92,48 +87,14 @@ else: msg = "Module " + __name__ + " cannot be used within an IPython shell" raise ImportError(msg) -# qtconsole is a separate module in recent versions of IPython/Jupyter -# http://blog.jupyter.org/2015/04/15/the-big-split/ -if IPython.__version__.startswith("2"): - qtconsole = None -else: - try: - import qtconsole - except ImportError: - qtconsole = None - -if qtconsole is not None: - try: - from qtconsole.rich_ipython_widget import RichJupyterWidget as \ - RichIPythonWidget - except ImportError: - try: - from qtconsole.rich_ipython_widget import RichIPythonWidget - except ImportError as e: - qtconsole = None - else: - from qtconsole.inprocess import QtInProcessKernelManager - else: - from qtconsole.inprocess import QtInProcessKernelManager - - -if qtconsole is None: - # Import the console machinery from ipython - - # The `has_binding` test of IPython does not find the Qt bindings - # in case silx is used in a frozen binary - import IPython.external.qt_loaders - - def has_binding(*var, **kw): - return True - - IPython.external.qt_loaders.has_binding = has_binding - - try: - from IPython.qtconsole.rich_ipython_widget import RichIPythonWidget - except ImportError: - from IPython.qt.console.rich_ipython_widget import RichIPythonWidget - from IPython.qt.inprocess import QtInProcessKernelManager + +try: + from qtconsole.rich_ipython_widget import RichJupyterWidget as \ + RichIPythonWidget +except ImportError: + from qtconsole.rich_ipython_widget import RichIPythonWidget + +from qtconsole.inprocess import QtInProcessKernelManager class IPythonWidget(RichIPythonWidget): diff --git a/silx/gui/data/DataViewer.py b/silx/gui/data/DataViewer.py index 5e0b25e..4db2863 100644 --- a/silx/gui/data/DataViewer.py +++ b/silx/gui/data/DataViewer.py @@ -37,7 +37,7 @@ from silx.utils.property import classproperty __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "26/02/2018" +__date__ = "24/04/2018" _logger = logging.getLogger(__name__) @@ -167,8 +167,10 @@ class DataViewer(qt.QFrame): self.__currentAvailableViews = [] self.__currentView = None self.__data = None + self.__info = None self.__useAxisSelection = False self.__userSelectedView = None + self.__hooks = None self.__views = [] self.__index = {} @@ -182,6 +184,15 @@ class DataViewer(qt.QFrame): self.__views = list(views) self.setDisplayMode(DataViews.EMPTY_MODE) + def setGlobalHooks(self, hooks): + """Set a data view hooks for all the views + + :param DataViewHooks context: The hooks to use + """ + self.__hooks = hooks + for v in self.__views: + v.setHooks(hooks) + 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 @@ -250,7 +261,7 @@ class DataViewer(qt.QFrame): """ previous = self.__numpySelection.blockSignals(True) self.__numpySelection.clear() - info = DataViews.DataInfo(self.__data) + info = self._getInfo() axisNames = self.__currentView.axesNames(self.__data, info) if info.isArray and info.size != 0 and self.__data is not None and axisNames is not None: self.__useAxisSelection = True @@ -359,6 +370,8 @@ class DataViewer(qt.QFrame): :param DataView view: A dataview """ + if self.__hooks is not None: + view.setHooks(self.__hooks) self.__views.append(view) # TODO It can be skipped if the view do not support the data self.__updateAvailableViews() @@ -390,8 +403,8 @@ class DataViewer(qt.QFrame): Update available views from the current data. """ data = self.__data + info = self._getInfo() # 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) @@ -490,6 +503,7 @@ class DataViewer(qt.QFrame): :param numpy.ndarray data: The data. """ self.__data = data + self._invalidateInfo() self.__displayedData = None self.__updateView() self.__updateNumpySelectionAxis() @@ -512,6 +526,21 @@ class DataViewer(qt.QFrame): """Returns the data""" return self.__data + def _invalidateInfo(self): + """Invalidate DataInfo cache.""" + self.__info = None + + def _getInfo(self): + """Returns the DataInfo of the current selected data. + + This value is cached. + + :rtype: DataInfo + """ + if self.__info is None: + self.__info = DataViews.DataInfo(self.__data) + return self.__info + def displayMode(self): """Returns the current display mode""" return self.__currentView.modeId() @@ -552,6 +581,8 @@ class DataViewer(qt.QFrame): isReplaced = False for idx, view in enumerate(self.__views): if view.modeId() == modeId: + if self.__hooks is not None: + newView.setHooks(self.__hooks) self.__views[idx] = newView isReplaced = True break diff --git a/silx/gui/data/DataViewerFrame.py b/silx/gui/data/DataViewerFrame.py index 89a9992..4e6d2e8 100644 --- a/silx/gui/data/DataViewerFrame.py +++ b/silx/gui/data/DataViewerFrame.py @@ -27,7 +27,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "21/09/2017" +__date__ = "24/04/2018" from silx.gui import qt from .DataViewer import DataViewer @@ -113,6 +113,13 @@ class DataViewerFrame(qt.QWidget): """Called when the displayed view changes""" self.displayedViewChanged.emit(view) + def setGlobalHooks(self, hooks): + """Set a data view hooks for all the views + + :param DataViewHooks context: The hooks to use + """ + self.__dataViewer.setGlobalHooks(hooks) + def availableViews(self): """Returns the list of registered views diff --git a/silx/gui/data/DataViews.py b/silx/gui/data/DataViews.py index ef69441..2291e87 100644 --- a/silx/gui/data/DataViews.py +++ b/silx/gui/data/DataViews.py @@ -35,13 +35,13 @@ 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 get_attr_as_string -from silx.gui.plot.Colormap import Colormap -from silx.gui.plot.actions.control import ColormapAction +from silx.io.nxdata import get_attr_as_unicode +from silx.gui.colors import Colormap +from silx.gui.dialog.ColormapDialog import ColormapDialog __authors__ = ["V. Valls", "P. Knobel"] __license__ = "MIT" -__date__ = "23/01/2018" +__date__ = "23/05/2018" _logger = logging.getLogger(__name__) @@ -109,6 +109,7 @@ class DataInfo(object): self.isBoolean = False self.isRecord = False self.hasNXdata = False + self.isInvalidNXdata = False self.shape = tuple() self.dim = 0 self.size = 0 @@ -118,8 +119,28 @@ class DataInfo(object): if silx.io.is_group(data): nxd = nxdata.get_default(data) + nx_class = get_attr_as_unicode(data, "NX_class") if nxd is not None: self.hasNXdata = True + # can we plot it? + is_scalar = nxd.signal_is_0d or nxd.interpretation in ["scalar", "scaler"] + if not (is_scalar or nxd.is_curve or nxd.is_x_y_value_scatter or + nxd.is_image or nxd.is_stack): + # invalid: cannot be plotted by any widget + self.isInvalidNXdata = True + elif nx_class == "NXdata": + # group claiming to be NXdata could not be parsed + self.isInvalidNXdata = True + elif nx_class == "NXentry" and "default" in data.attrs: + # entry claiming to have a default NXdata could not be parsed + self.isInvalidNXdata = True + elif nx_class == "NXroot" or silx.io.is_file(data): + # root claiming to have a default entry + if "default" in data.attrs: + def_entry = data.attrs["default"] + if def_entry in data and "default" in data[def_entry].attrs: + # and entry claims to have default NXdata + self.isInvalidNXdata = True if isinstance(data, numpy.ndarray): self.isArray = True @@ -130,7 +151,7 @@ class DataInfo(object): if silx.io.is_dataset(data): if "interpretation" in data.attrs: - self.interpretation = get_attr_as_string(data, "interpretation") + self.interpretation = get_attr_as_unicode(data, "interpretation") else: self.interpretation = None elif self.hasNXdata: @@ -166,7 +187,11 @@ class DataInfo(object): if self.shape is not None: self.dim = len(self.shape) - if hasattr(data, "size"): + if hasattr(data, "shape") and data.shape is None: + # This test is expected to avoid to fall done on the h5py issue + # https://github.com/h5py/h5py/issues/1044 + self.size = 0 + elif hasattr(data, "size"): self.size = int(data.size) else: self.size = 1 @@ -177,6 +202,18 @@ class DataInfo(object): return _normalizeData(data) +class DataViewHooks(object): + """A set of hooks defined to custom the behaviour of the data views.""" + + def getColormap(self, view): + """Returns a colormap for this view.""" + return None + + def getColormapDialog(self, view): + """Returns a color dialog for this view.""" + return None + + class DataView(object): """Holder for the data view.""" @@ -184,12 +221,6 @@ class DataView(object): """Priority returned when the requested data can't be displayed by the view.""" - _defaultColormap = None - """Store a default colormap shared with all the views""" - - _defaultColorDialog = None - """Store a default color dialog shared with all the views""" - def __init__(self, parent, modeId=None, icon=None, label=None): """Constructor @@ -204,32 +235,46 @@ class DataView(object): if icon is None: icon = qt.QIcon() self.__icon = icon + self.__hooks = None - @staticmethod - def defaultColormap(): - """Returns a shared colormap as default for all the views. + def getHooks(self): + """Returns the data viewer hooks used by this view. - :rtype: Colormap + :rtype: DataViewHooks """ - if DataView._defaultColormap is None: - DataView._defaultColormap = Colormap(name="viridis") - return DataView._defaultColormap + return self.__hooks - @staticmethod - def defaultColorDialog(): - """Returns a shared color dialog as default for all the views. + def setHooks(self, hooks): + """Set the data view hooks to use with this view. - :rtype: ColorDialog + :param DataViewHooks hooks: The data view hooks to use """ - if DataView._defaultColorDialog is None: - DataView._defaultColorDialog = ColormapAction._createDialog(qt.QApplication.instance().activeWindow()) - return DataView._defaultColorDialog + self.__hooks = hooks - @staticmethod - def _cleanUpCache(): - """Clean up the cache. Needed for tests""" - DataView._defaultColormap = None - DataView._defaultColorDialog = None + def defaultColormap(self): + """Returns a default colormap. + + :rtype: Colormap + """ + colormap = None + if self.__hooks is not None: + colormap = self.__hooks.getColormap(self) + if colormap is None: + colormap = Colormap(name="viridis") + return colormap + + def defaultColorDialog(self): + """Returns a default color dialog. + + :rtype: ColormapDialog + """ + dialog = None + if self.__hooks is not None: + dialog = self.__hooks.getColormapDialog(self) + if dialog is None: + dialog = ColormapDialog() + dialog.setModal(False) + return dialog def icon(self): """Returns the default icon""" @@ -345,8 +390,21 @@ class CompositeDataView(DataView): self.__views = OrderedDict() self.__currentView = None + def setHooks(self, hooks): + """Set the data context to use with this view. + + :param DataViewHooks hooks: The data view hooks to use + """ + super(CompositeDataView, self).setHooks(hooks) + if hooks is not None: + for v in self.__views: + v.setHooks(hooks) + def addView(self, dataView): """Add a new dataview to the available list.""" + hooks = self.getHooks() + if hooks is not None: + dataView.setHooks(hooks) self.__views[dataView] = None def availableViews(self): @@ -446,6 +504,9 @@ class CompositeDataView(DataView): break elif isinstance(view, CompositeDataView): # recurse + hooks = self.getHooks() + if hooks is not None: + newView.setHooks(hooks) if view.replaceView(modeId, newView): return True if oldView is None: @@ -1022,70 +1083,46 @@ class _InvalidNXdataView(DataView): def getDataPriority(self, data, info): data = self.normalizeData(data) - if silx.io.is_group(data): - nxd = nxdata.get_default(data) - nx_class = get_attr_as_string(data, "NX_class") - - if nxd is None: - if nx_class == "NXdata": - # invalid: could not even be parsed by NXdata - self._msg = "Group has @NX_class = NXdata, but could not be interpreted" - self._msg += " as valid NXdata." - return 100 - elif nx_class == "NXentry": - if "default" not in data.attrs: - # no link to NXdata, no problem - return DataView.UNSUPPORTED - self._msg = "NXentry group provides a @default attribute," - default_nxdata_name = data.attrs["default"] - if default_nxdata_name not in data: - self._msg += " but no corresponding NXdata group exists." - elif get_attr_as_string(data[default_nxdata_name], "NX_class") != "NXdata": - self._msg += " but the corresponding item is not a " - self._msg += "NXdata group." - else: - self._msg += " but the corresponding NXdata seems to be" - self._msg += " malformed." - return 100 - elif nx_class == "NXroot" or silx.io.is_file(data): - if "default" not in data.attrs: - # no link to NXentry, no problem - return DataView.UNSUPPORTED - default_entry_name = data.attrs["default"] - if default_entry_name not in data: - # this is a problem, but not NXdata related - return DataView.UNSUPPORTED - default_entry = data[default_entry_name] - if "default" not in default_entry.attrs: - # no NXdata specified, no problemo - return DataView.UNSUPPORTED - default_nxdata_name = default_entry.attrs["default"] - self._msg = "NXroot group provides a @default attribute " - self._msg += "pointing to a NXentry which defines its own " - self._msg += "@default attribute, " - if default_nxdata_name not in default_entry: - self._msg += " but no corresponding NXdata group exists." - elif get_attr_as_string(default_entry[default_nxdata_name], - "NX_class") != "NXdata": - self._msg += " but the corresponding item is not a " - self._msg += "NXdata group." - else: - self._msg += " but the corresponding NXdata seems to be" - self._msg += " malformed." - return 100 - else: - # Not pretending to be NXdata, no problem - return DataView.UNSUPPORTED - - is_scalar = nxd.signal_is_0d or nxd.interpretation in ["scalar", "scaler"] - if not (is_scalar or nxd.is_curve or nxd.is_x_y_value_scatter or - nxd.is_image or nxd.is_stack): - # invalid: cannot be plotted by any widget (I cannot imagine a case) - self._msg = "NXdata seems valid, but cannot be displayed " - self._msg += "by any existing plot widget." - return 100 - return DataView.UNSUPPORTED + if not info.isInvalidNXdata: + return DataView.UNSUPPORTED + + if info.hasNXdata: + self._msg = "NXdata seems valid, but cannot be displayed " + self._msg += "by any existing plot widget." + else: + nx_class = get_attr_as_unicode(data, "NX_class") + if nx_class == "NXdata": + # invalid: could not even be parsed by NXdata + self._msg = "Group has @NX_class = NXdata, but could not be interpreted" + self._msg += " as valid NXdata." + elif nx_class == "NXentry": + self._msg = "NXentry group provides a @default attribute," + default_nxdata_name = data.attrs["default"] + if default_nxdata_name not in data: + self._msg += " but no corresponding NXdata group exists." + elif get_attr_as_unicode(data[default_nxdata_name], "NX_class") != "NXdata": + self._msg += " but the corresponding item is not a " + self._msg += "NXdata group." + else: + self._msg += " but the corresponding NXdata seems to be" + self._msg += " malformed." + elif nx_class == "NXroot" or silx.io.is_file(data): + default_entry = data[data.attrs["default"]] + default_nxdata_name = default_entry.attrs["default"] + self._msg = "NXroot group provides a @default attribute " + self._msg += "pointing to a NXentry which defines its own " + self._msg += "@default attribute, " + if default_nxdata_name not in default_entry: + self._msg += " but no corresponding NXdata group exists." + elif get_attr_as_unicode(default_entry[default_nxdata_name], + "NX_class") != "NXdata": + self._msg += " but the corresponding item is not a " + self._msg += "NXdata group." + else: + self._msg += " but the corresponding NXdata seems to be" + self._msg += " malformed." + return 100 class _NXdataScalarView(DataView): @@ -1111,7 +1148,7 @@ class _NXdataScalarView(DataView): def setData(self, data): data = self.normalizeData(data) # data could be a NXdata or an NXentry - nxd = nxdata.get_default(data) + nxd = nxdata.get_default(data, validate=False) signal = nxd.signal self.getWidget().setArrayData(signal, labels=True) @@ -1119,8 +1156,8 @@ class _NXdataScalarView(DataView): def getDataPriority(self, data, info): data = self.normalizeData(data) - if info.hasNXdata: - nxd = nxdata.get_default(data) + if info.hasNXdata and not info.isInvalidNXdata: + nxd = nxdata.get_default(data, validate=False) if nxd.signal_is_0d or nxd.interpretation in ["scalar", "scaler"]: return 100 return DataView.UNSUPPORTED @@ -1151,7 +1188,7 @@ class _NXdataCurveView(DataView): def setData(self, data): data = self.normalizeData(data) - nxd = nxdata.get_default(data) + nxd = nxdata.get_default(data, validate=False) signals_names = [nxd.signal_name] + nxd.auxiliary_signals_names if nxd.axes_dataset_names[-1] is not None: x_errors = nxd.get_axis_errors(nxd.axes_dataset_names[-1]) @@ -1177,8 +1214,8 @@ class _NXdataCurveView(DataView): def getDataPriority(self, data, info): data = self.normalizeData(data) - if info.hasNXdata: - if nxdata.get_default(data).is_curve: + if info.hasNXdata and not info.isInvalidNXdata: + if nxdata.get_default(data, validate=False).is_curve: return 100 return DataView.UNSUPPORTED @@ -1204,8 +1241,13 @@ class _NXdataXYVScatterView(DataView): def setData(self, data): data = self.normalizeData(data) - nxd = nxdata.get_default(data) + nxd = nxdata.get_default(data, validate=False) + x_axis, y_axis = nxd.axes[-2:] + if x_axis is None: + x_axis = numpy.arange(nxd.signal.size) + if y_axis is None: + y_axis = numpy.arange(nxd.signal.size) x_label, y_label = nxd.axes_names[-2:] if x_label is not None: @@ -1226,8 +1268,8 @@ class _NXdataXYVScatterView(DataView): def getDataPriority(self, data, info): data = self.normalizeData(data) - if info.hasNXdata: - if nxdata.get_default(data).is_x_y_value_scatter: + if info.hasNXdata and not info.isInvalidNXdata: + if nxdata.get_default(data, validate=False).is_x_y_value_scatter: return 100 return DataView.UNSUPPORTED @@ -1256,7 +1298,7 @@ class _NXdataImageView(DataView): def setData(self, data): data = self.normalizeData(data) - nxd = nxdata.get_default(data) + nxd = nxdata.get_default(data, validate=False) isRgba = nxd.interpretation == "rgba-image" # last two axes are Y & X @@ -1274,8 +1316,8 @@ class _NXdataImageView(DataView): def getDataPriority(self, data, info): data = self.normalizeData(data) - if info.hasNXdata: - if nxdata.get_default(data).is_image: + if info.hasNXdata and not info.isInvalidNXdata: + if nxdata.get_default(data, validate=False).is_image: return 100 return DataView.UNSUPPORTED @@ -1302,7 +1344,7 @@ class _NXdataStackView(DataView): def setData(self, data): data = self.normalizeData(data) - nxd = nxdata.get_default(data) + nxd = nxdata.get_default(data, validate=False) signal_name = nxd.signal_name z_axis, y_axis, x_axis = nxd.axes[-3:] z_label, y_label, x_label = nxd.axes_names[-3:] @@ -1319,8 +1361,8 @@ class _NXdataStackView(DataView): def getDataPriority(self, data, info): data = self.normalizeData(data) - if info.hasNXdata: - if nxdata.get_default(data).is_stack: + if info.hasNXdata and not info.isInvalidNXdata: + if nxdata.get_default(data, validate=False).is_stack: return 100 return DataView.UNSUPPORTED diff --git a/silx/gui/data/Hdf5TableView.py b/silx/gui/data/Hdf5TableView.py index e4a0747..04199b2 100644 --- a/silx/gui/data/Hdf5TableView.py +++ b/silx/gui/data/Hdf5TableView.py @@ -30,8 +30,9 @@ from __future__ import division __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "10/10/2017" +__date__ = "23/05/2018" +import collections import functools import os.path import logging @@ -41,6 +42,7 @@ from .TextFormatter import TextFormatter import silx.gui.hdf5 from silx.gui.widgets import HierarchicalTableView from ..hdf5.Hdf5Formatter import Hdf5Formatter +from ..hdf5._utils import htmlFromDict try: import h5py @@ -54,7 +56,7 @@ _logger = logging.getLogger(__name__) class _CellData(object): """Store a table item """ - def __init__(self, value=None, isHeader=False, span=None): + def __init__(self, value=None, isHeader=False, span=None, tooltip=None): """ Constructor @@ -65,6 +67,7 @@ class _CellData(object): self.__value = value self.__isHeader = isHeader self.__span = span + self.__tooltip = tooltip def isHeader(self): """Returns true if the property is a sub-header title. @@ -85,6 +88,19 @@ class _CellData(object): """ return self.__span + def tooltip(self): + """Returns the tooltip of the item. + + :rtype: tuple + """ + return self.__tooltip + + def invalidateValue(self): + self.__value = None + + def invalidateToolTip(self): + self.__tooltip = None + class _TableData(object): """Modelize a table with header, row and column span. @@ -143,7 +159,7 @@ class _TableData(object): item = _CellData(value=headerLabel, isHeader=True, span=(1, self.__colCount)) self.__data.append([item]) - def addHeaderValueRow(self, headerLabel, value): + def addHeaderValueRow(self, headerLabel, value, tooltip=None): """Append the table with a row using the first column as an header and other cells as a single cell for the value. @@ -151,7 +167,7 @@ class _TableData(object): :param object value: value to store. """ header = _CellData(value=headerLabel, isHeader=True) - value = _CellData(value=value, span=(1, self.__colCount)) + value = _CellData(value=value, span=(1, self.__colCount), tooltip=tooltip) self.__data.append([header, value]) def addRow(self, *args): @@ -214,7 +230,20 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): elif role == qt.Qt.DisplayRole: value = cell.value() if callable(value): - value = value(self.__obj) + try: + value = value(self.__obj) + except Exception: + cell.invalidateValue() + raise + return value + elif role == qt.Qt.ToolTipRole: + value = cell.tooltip() + if callable(value): + try: + value = value(self.__obj) + except Exception: + cell.invalidateToolTip() + raise return value return None @@ -260,6 +289,14 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): """Format the HDF5 type""" return self.__hdf5Formatter.humanReadableHdf5Type(dataset) + def __attributeTooltip(self, attribute): + attributeDict = collections.OrderedDict() + if hasattr(attribute, "shape"): + attributeDict["Shape"] = self.__hdf5Formatter.humanReadableShape(attribute) + attributeDict["Data type"] = self.__hdf5Formatter.humanReadableType(attribute, full=True) + html = htmlFromDict(attributeDict, title="HDF5 Attribute") + return html + def __formatDType(self, dataset): """Format the numpy dtype""" return self.__hdf5Formatter.humanReadableType(dataset, full=True) @@ -310,7 +347,8 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): # it's a real H5py object self.__data.addHeaderValueRow("Basename", lambda x: os.path.basename(x.name)) self.__data.addHeaderValueRow("Name", lambda x: x.name) - self.__data.addHeaderValueRow("File", lambda x: x.file.filename) + if obj.file is not None: + self.__data.addHeaderValueRow("File", lambda x: x.file.filename) if hasattr(obj, "path"): # That's a link @@ -322,8 +360,11 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): else: if silx.io.is_file(obj): physical = lambda x: x.filename + SEPARATOR + x.name + elif obj.file is not None: + physical = lambda x: x.file.filename + SEPARATOR + x.name else: - physical = lambda x: x.file.filename + SEPARATOR + x.name + # Guess it is a virtual node + physical = "No physical location" self.__data.addHeaderValueRow("Physical", physical) if hasattr(obj, "dtype"): @@ -367,7 +408,10 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): 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)) + callbackTooltip = lambda key, x: self.__attributeTooltip(x.attrs[key]) + self.__data.addHeaderValueRow(headerLabel=key, + value=functools.partial(callback, key), + tooltip=functools.partial(callbackTooltip, key)) def __get_filter_info(self, dataset, filterIndex): """Get a tuple of readable info from dataset filters @@ -447,7 +491,7 @@ class Hdf5TableView(HierarchicalTableView.HierarchicalTableView): 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`, + :param data: 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. diff --git a/silx/gui/data/HexaTableView.py b/silx/gui/data/HexaTableView.py index 1b2a7e9..c86c0af 100644 --- a/silx/gui/data/HexaTableView.py +++ b/silx/gui/data/HexaTableView.py @@ -37,7 +37,7 @@ from silx.gui.widgets.TableWidget import CopySelectedCellsAction __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "27/09/2017" +__date__ = "23/05/2018" class _VoidConnector(object): @@ -54,7 +54,13 @@ class _VoidConnector(object): def __getBuffer(self, bufferId): if bufferId not in self.__cache: pos = bufferId << 10 - data = self.__data.tobytes()[pos:pos + 1024] + data = self.__data + if hasattr(data, "tobytes"): + data = data.tobytes()[pos:pos + 1024] + else: + # Old fashion + data = data.data[pos:pos + 1024] + self.__cache[bufferId] = data if len(self.__cache) > 32: self.__cache.popitem() diff --git a/silx/gui/data/NXdataWidgets.py b/silx/gui/data/NXdataWidgets.py index ae2911d..1bf5425 100644 --- a/silx/gui/data/NXdataWidgets.py +++ b/silx/gui/data/NXdataWidgets.py @@ -26,14 +26,14 @@ """ __authors__ = ["P. Knobel"] __license__ = "MIT" -__date__ = "20/12/2017" +__date__ = "24/04/2018" import numpy from silx.gui import qt from silx.gui.data.NumpyAxesSelector import NumpyAxesSelector -from silx.gui.plot import Plot1D, Plot2D, StackView -from silx.gui.plot.Colormap import Colormap +from silx.gui.plot import Plot1D, Plot2D, StackView, ScatterView +from silx.gui.colors import Colormap from silx.gui.widgets.FrameBrowser import HorizontalSliderWithBrowser from silx.math.calibration import ArrayCalibration, NoCalibration, LinearCalibration @@ -211,10 +211,10 @@ class XYVScatterPlot(qt.QWidget): self.__y_axis_name = None self.__y_axis_errors = None - self._plot = Plot1D(self) - self._plot.setDefaultColormap(Colormap(name="viridis", - vmin=None, vmax=None, - normalization=Colormap.LINEAR)) + self._plot = ScatterView(self) + self._plot.setColormap(Colormap(name="viridis", + vmin=None, vmax=None, + normalization=Colormap.LINEAR)) self._slider = HorizontalSliderWithBrowser(parent=self) self._slider.setMinimum(0) @@ -235,9 +235,9 @@ class XYVScatterPlot(qt.QWidget): def getPlot(self): """Returns the plot used for the display - :rtype: Plot1D + :rtype: PlotWidget """ - return self._plot + return self._plot.getPlotWidget() def setScattersData(self, y, x, values, yerror=None, xerror=None, @@ -284,8 +284,6 @@ class XYVScatterPlot(qt.QWidget): x = self.__x_axis y = self.__y_axis - self._plot.remove(kind=("scatter", )) - idx = self._slider.value() title = "" @@ -294,16 +292,15 @@ class XYVScatterPlot(qt.QWidget): title += self.__scatter_titles[idx] # scatter dataset name self._plot.setGraphTitle(title) - self._plot.addScatter(x, y, self.__values[idx], - legend="scatter%d" % idx, - xerror=self.__x_axis_errors, - yerror=self.__y_axis_errors) + self._plot.setData(x, y, self.__values[idx], + xerror=self.__x_axis_errors, + yerror=self.__y_axis_errors) self._plot.resetZoom() self._plot.getXAxis().setLabel(self.__x_axis_name) self._plot.getYAxis().setLabel(self.__y_axis_name) def clear(self): - self._plot.clear() + self._plot.getPlotWidget().clear() class ArrayImagePlot(qt.QWidget): @@ -476,7 +473,8 @@ class ArrayImagePlot(qt.QWidget): scale = (xscale, yscale) self._plot.addImage(image, legend=legend, - origin=origin, scale=scale) + origin=origin, scale=scale, + replace=True) else: scatterx, scattery = numpy.meshgrid(x_axis, y_axis) # fixme: i don't think this can handle "irregular" RGBA images diff --git a/silx/gui/data/TextFormatter.py b/silx/gui/data/TextFormatter.py index 332625c..8440509 100644 --- a/silx/gui/data/TextFormatter.py +++ b/silx/gui/data/TextFormatter.py @@ -27,7 +27,7 @@ data module to format data as text in the same way.""" __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "13/12/2017" +__date__ = "25/06/2018" import numpy import numbers @@ -204,7 +204,7 @@ class TextFormatter(qt.QObject): def __formatBinary(self, data): if isinstance(data, numpy.void): if six.PY2: - data = [ord(d) for d in data.item()] + data = [ord(d) for d in data.data] else: data = data.item().astype(numpy.uint8) elif six.PY2: @@ -266,6 +266,8 @@ class TextFormatter(qt.QObject): elif vlen == six.binary_type: # HDF5 ASCII return self.__formatCharString(data) + elif isinstance(vlen, numpy.dtype): + return self.toString(data, vlen) return None def toString(self, data, dtype=None): @@ -291,11 +293,17 @@ class TextFormatter(qt.QObject): else: text = [self.toString(d, dtype) for d in data] return "[" + " ".join(text) + "]" + if dtype is not None and dtype.kind == 'O': + text = self.__formatH5pyObject(data, dtype) + if text is not None: + return text elif isinstance(data, numpy.void): if dtype is None: dtype = data.dtype - if data.dtype.fields is not None: - text = [self.toString(data[f], dtype) for f in dtype.fields] + if dtype.fields is not None: + text = [] + for index, field in enumerate(dtype.fields.items()): + text.append(field[0] + ":" + self.toString(data[index], field[1][0])) return "(" + " ".join(text) + ")" return self.__formatBinary(data) elif isinstance(data, (numpy.unicode_, six.text_type)): @@ -340,7 +348,7 @@ class TextFormatter(qt.QObject): 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)): + elif isinstance(data, (numpy.complexfloating, numbers.Complex)): text = "" if data.real != 0: text += self.__floatFormat % data.real diff --git a/silx/gui/data/test/test_dataviewer.py b/silx/gui/data/test/test_dataviewer.py index 274df92..f3c2808 100644 --- a/silx/gui/data/test/test_dataviewer.py +++ b/silx/gui/data/test/test_dataviewer.py @@ -24,7 +24,7 @@ # ###########################################################################*/ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "22/02/2018" +__date__ = "23/04/2018" import os import tempfile @@ -208,7 +208,6 @@ class AbstractDataViewerTests(TestCaseQt): self.assertEquals(widget.displayedView().modeId(), DataViews.RAW_MODE) widget.setDisplayMode(DataViews.EMPTY_MODE) self.assertEquals(widget.displayedView().modeId(), DataViews.EMPTY_MODE) - DataView._cleanUpCache() def test_create_default_views(self): widget = self.create_widget() @@ -287,7 +286,6 @@ class TestDataView(TestCaseQt): dataViewClass = DataViews._Plot2dView widget = self.createDataViewWithData(dataViewClass, data[0]) self.qWaitForWindowExposed(widget) - DataView._cleanUpCache() def testCubeWithComplex(self): self.skipTest("OpenGL widget not yet tested") @@ -299,14 +297,12 @@ class TestDataView(TestCaseQt): dataViewClass = DataViews._Plot3dView widget = self.createDataViewWithData(dataViewClass, data) self.qWaitForWindowExposed(widget) - DataView._cleanUpCache() def testImageStackWithComplex(self): data = self.createComplexData() dataViewClass = DataViews._StackView widget = self.createDataViewWithData(dataViewClass, data) self.qWaitForWindowExposed(widget) - DataView._cleanUpCache() def suite(): 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/plot/test/testColormapDialog.py b/silx/gui/dialog/test/test_colormapdialog.py index 8087369..6f0ceea 100644 --- a/silx/gui/plot/test/testColormapDialog.py +++ b/silx/gui/dialog/test/test_colormapdialog.py @@ -26,7 +26,7 @@ __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "17/01/2018" +__date__ = "23/05/2018" import doctest @@ -34,9 +34,9 @@ import unittest from silx.gui.test.utils import qWaitForWindowExposedAndActivate from silx.gui import qt -from silx.gui.plot import ColormapDialog +from silx.gui.dialog import ColormapDialog from silx.gui.test.utils import TestCaseQt -from silx.gui.plot.Colormap import Colormap, preferredColormaps +from silx.gui.colors import Colormap, preferredColormaps from silx.utils.testutils import ParametricTestCase from silx.gui.plot.PlotWindow import PlotWindow @@ -119,7 +119,7 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase): 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 @@ -308,6 +308,19 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase): 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): @@ -336,16 +349,16 @@ class TestColormapAction(TestCaseQt): self.assertTrue(self.colormapDialog.getColormap() is self.defaultColormap) self.plot.addImage(data=numpy.random.rand(10, 10), legend='img1', - replace=False, origin=(0, 0), + 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', - replace=False, origin=(0, 0), + origin=(0, 0), colormap=self.colormap2) self.plot.addImage(data=numpy.random.rand(10, 10), legend='img3', - replace=False, origin=(0, 0)) + origin=(0, 0)) self.plot.setActiveImage('img3') self.assertTrue(self.colormapDialog.getColormap() is self.defaultColormap) @@ -363,7 +376,7 @@ class TestColormapAction(TestCaseQt): self.plot.getColormapAction()._actionTriggered(checked=True) self.assertTrue(self.plot.getColormapAction().isChecked()) self.plot.addImage(data=numpy.random.rand(10, 10), legend='img1', - replace=False, origin=(0, 0), + origin=(0, 0), colormap=self.colormap1) self.colormap1.setName('red') self.plot.getColormapAction()._actionTriggered() 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): diff --git a/silx/gui/hdf5/Hdf5Formatter.py b/silx/gui/hdf5/Hdf5Formatter.py index 0e3697f..6802142 100644 --- a/silx/gui/hdf5/Hdf5Formatter.py +++ b/silx/gui/hdf5/Hdf5Formatter.py @@ -27,7 +27,7 @@ text.""" __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "23/01/2018" +__date__ = "06/06/2018" import numpy from silx.third_party import six @@ -119,7 +119,11 @@ class Hdf5Formatter(qt.QObject): return text def humanReadableType(self, dataset, full=False): - dtype = dataset.dtype + if hasattr(dataset, "dtype"): + dtype = dataset.dtype + else: + # Fallback... + dtype = type(dataset) return self.humanReadableDType(dtype, full) def humanReadableDType(self, dtype, full=False): @@ -164,6 +168,16 @@ class Hdf5Formatter(qt.QObject): return "enum" text = str(dtype.newbyteorder('N')) + if numpy.issubdtype(dtype, numpy.floating): + if hasattr(numpy, "float128") and dtype == numpy.float128: + text = "float80" + if full: + text += " (padding 128bits)" + elif hasattr(numpy, "float96") and dtype == numpy.float96: + text = "float80" + if full: + text += " (padding 96bits)" + if full: if dtype.byteorder == "<": text = "Little-endian " + text diff --git a/silx/gui/hdf5/Hdf5TreeModel.py b/silx/gui/hdf5/Hdf5TreeModel.py index 2d62429..835708a 100644 --- a/silx/gui/hdf5/Hdf5TreeModel.py +++ b/silx/gui/hdf5/Hdf5TreeModel.py @@ -25,7 +25,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "29/11/2017" +__date__ = "11/06/2018" import os @@ -205,7 +205,23 @@ class Hdf5TreeModel(qt.QAbstractItemModel): ] """List of logical columns available""" - def __init__(self, parent=None): + sigH5pyObjectLoaded = qt.Signal(object) + """Emitted when a new root item was loaded and inserted to the model.""" + + sigH5pyObjectRemoved = qt.Signal(object) + """Emitted when a root item is removed from the model.""" + + sigH5pyObjectSynchronized = qt.Signal(object, object) + """Emitted when an item was synchronized.""" + + def __init__(self, parent=None, ownFiles=True): + """ + Constructor + + :param qt.QWidget parent: Parent widget + :param bool ownFiles: If true (default) the model will manage the files + life cycle when they was added using path (like DnD). + """ super(Hdf5TreeModel, self).__init__(parent) self.header_labels = [None] * len(self.COLUMN_IDS) @@ -221,6 +237,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): self.__root = Hdf5Node() self.__fileDropEnabled = True self.__fileMoveEnabled = True + self.__datasetDragEnabled = False self.__animatedIcon = icons.getWaitIcon() self.__animatedIcon.iconChanged.connect(self.__updateLoadingItems) @@ -235,6 +252,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): self.__icons.append(icons.getQIcon("item-3dim")) self.__icons.append(icons.getQIcon("item-ndim")) + self.__ownFiles = ownFiles self.__openedFiles = [] """Store the list of files opened by the model itself.""" # FIXME: It should be managed one by one by Hdf5Item itself @@ -285,16 +303,25 @@ class Hdf5TreeModel(qt.QAbstractItemModel): newItem = _unwrapNone(newItem) error = _unwrapNone(error) row = self.__root.indexOfChild(oldItem) + rootIndex = qt.QModelIndex() self.beginRemoveRows(rootIndex, row, row) self.__root.removeChildAtIndex(row) self.endRemoveRows() + if newItem is not None: rootIndex = qt.QModelIndex() - self.__openedFiles.append(newItem.obj) + if self.__ownFiles: + self.__openedFiles.append(newItem.obj) self.beginInsertRows(rootIndex, row, row) self.__root.insertChild(row, newItem) self.endInsertRows() + + if isinstance(oldItem, Hdf5LoadingItem): + self.sigH5pyObjectLoaded.emit(newItem.obj) + else: + self.sigH5pyObjectSynchronized.emit(oldItem.obj, newItem.obj) + # FIXME the error must be displayed def isFileDropEnabled(self): @@ -306,6 +333,15 @@ class Hdf5TreeModel(qt.QAbstractItemModel): fileDropEnabled = qt.Property(bool, isFileDropEnabled, setFileDropEnabled) """Property to enable/disable file dropping in the model.""" + def isDatasetDragEnabled(self): + return self.__datasetDragEnabled + + def setDatasetDragEnabled(self, enabled): + self.__datasetDragEnabled = enabled + + datasetDragEnabled = qt.Property(bool, isDatasetDragEnabled, setDatasetDragEnabled) + """Property to enable/disable drag of datasets.""" + def isFileMoveEnabled(self): return self.__fileMoveEnabled @@ -323,10 +359,12 @@ class Hdf5TreeModel(qt.QAbstractItemModel): return 0 def mimeTypes(self): + types = [] if self.__fileMoveEnabled: - return [_utils.Hdf5NodeMimeData.MIME_TYPE] - else: - return [] + types.append(_utils.Hdf5NodeMimeData.MIME_TYPE) + if self.__datasetDragEnabled: + types.append(_utils.Hdf5DatasetMimeData.MIME_TYPE) + return types def mimeData(self, indexes): """ @@ -336,7 +374,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): :param List[qt.QModelIndex] indexes: List of indexes :rtype: qt.QMimeData """ - if not self.__fileMoveEnabled or len(indexes) == 0: + if len(indexes) == 0: return None indexes = [i for i in indexes if i.column() == 0] @@ -346,7 +384,13 @@ class Hdf5TreeModel(qt.QAbstractItemModel): raise NotImplementedError("Drag of cell is not implemented") node = self.nodeFromIndex(indexes[0]) - mimeData = _utils.Hdf5NodeMimeData(node) + + if self.__fileMoveEnabled and node.parent is self.__root: + mimeData = _utils.Hdf5NodeMimeData(node=node) + elif self.__datasetDragEnabled: + mimeData = _utils.Hdf5DatasetMimeData(node=node) + else: + mimeData = None return mimeData def flags(self, index): @@ -357,6 +401,8 @@ class Hdf5TreeModel(qt.QAbstractItemModel): if self.__fileMoveEnabled and node.parent is self.__root: # that's a root return qt.Qt.ItemIsDragEnabled | defaultFlags + elif self.__datasetDragEnabled: + return qt.Qt.ItemIsDragEnabled | defaultFlags return defaultFlags elif self.__fileDropEnabled or self.__fileMoveEnabled: return qt.Qt.ItemIsDropEnabled | defaultFlags @@ -543,8 +589,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): return filename = node.obj.filename - self.removeIndex(index) - self.insertFileAsync(filename, index.row()) + self.insertFileAsync(filename, index.row(), synchronizingNode=node) def synchronizeH5pyObject(self, h5pyObject): """ @@ -560,8 +605,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): if item.obj is h5pyObject: qindex = self.index(index, 0, qt.QModelIndex()) self.synchronizeIndex(qindex) - else: - index += 1 + index += 1 def removeIndex(self, index): """ @@ -576,6 +620,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): self.beginRemoveRows(qt.QModelIndex(), index.row(), index.row()) self.__root.removeChildAtIndex(index.row()) self.endRemoveRows() + self.sigH5pyObjectRemoved.emit(node.obj) def removeH5pyObject(self, h5pyObject): """ @@ -608,14 +653,17 @@ class Hdf5TreeModel(qt.QAbstractItemModel): def hasPendingOperations(self): return len(self.__runnerSet) > 0 - def insertFileAsync(self, filename, row=-1): + def insertFileAsync(self, filename, row=-1, synchronizingNode=None): if not os.path.isfile(filename): raise IOError("Filename '%s' must be a file path" % filename) # create temporary item - text = os.path.basename(filename) - item = Hdf5LoadingItem(text=text, parent=self.__root, animatedIcon=self.__animatedIcon) - self.insertNode(row, item) + if synchronizingNode is None: + text = os.path.basename(filename) + item = Hdf5LoadingItem(text=text, parent=self.__root, animatedIcon=self.__animatedIcon) + self.insertNode(row, item) + else: + item = synchronizingNode # start loading the real one runnable = LoadingItemRunnable(filename, item) @@ -634,12 +682,20 @@ class Hdf5TreeModel(qt.QAbstractItemModel): """ try: h5file = silx_io.open(filename) - self.__openedFiles.append(h5file) + if self.__ownFiles: + self.__openedFiles.append(h5file) + self.sigH5pyObjectLoaded.emit(h5file) self.insertH5pyObject(h5file, row=row) except IOError: _logger.debug("File '%s' can't be read.", filename, exc_info=True) raise + def clear(self): + """Remove all the content of the model""" + for _ in range(self.rowCount()): + qindex = self.index(0, 0, qt.QModelIndex()) + self.removeIndex(qindex) + def appendFile(self, filename): self.insertFile(filename, -1) diff --git a/silx/gui/hdf5/Hdf5TreeView.py b/silx/gui/hdf5/Hdf5TreeView.py index 78b5c19..a86140a 100644 --- a/silx/gui/hdf5/Hdf5TreeView.py +++ b/silx/gui/hdf5/Hdf5TreeView.py @@ -25,7 +25,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "20/02/2018" +__date__ = "30/04/2018" import logging @@ -66,10 +66,8 @@ class Hdf5TreeView(qt.QTreeView): """ qt.QTreeView.__init__(self, parent) - model = Hdf5TreeModel(self) - proxy_model = NexusSortFilterProxyModel(self) - proxy_model.setSourceModel(model) - self.setModel(proxy_model) + model = self.createDefaultModel() + self.setModel(model) self.setHeader(Hdf5HeaderView(qt.Qt.Horizontal, self)) self.setSelectionBehavior(qt.QAbstractItemView.SelectRows) @@ -87,6 +85,15 @@ class Hdf5TreeView(qt.QTreeView): self.setContextMenuPolicy(qt.Qt.CustomContextMenu) self.customContextMenuRequested.connect(self._createContextMenu) + def createDefaultModel(self): + """Creates and returns the default model. + + Inherite to custom the default model""" + model = Hdf5TreeModel(self) + proxy_model = NexusSortFilterProxyModel(self) + proxy_model.setSourceModel(model) + return proxy_model + def __removeContextMenuProxies(self, ref): """Callback to remove dead proxy from the list""" self.__context_menu_callbacks.remove(ref) diff --git a/silx/gui/hdf5/NexusSortFilterProxyModel.py b/silx/gui/hdf5/NexusSortFilterProxyModel.py index 9a27968..3f2cf8d 100644 --- a/silx/gui/hdf5/NexusSortFilterProxyModel.py +++ b/silx/gui/hdf5/NexusSortFilterProxyModel.py @@ -25,7 +25,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "10/10/2017" +__date__ = "25/06/2018" import logging @@ -34,6 +34,7 @@ import numpy from .. import qt from .Hdf5TreeModel import Hdf5TreeModel import silx.io.utils +from silx.gui import icons _logger = logging.getLogger(__name__) @@ -45,6 +46,7 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel): def __init__(self, parent=None): qt.QSortFilterProxyModel.__init__(self, parent) self.__split = re.compile("(\\d+|\\D+)") + self.__iconCache = {} def lessThan(self, sourceLeft, sourceRight): """Returns True if the value of the item referred to by the given @@ -86,6 +88,14 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel): nxClass = node.obj.attrs.get("NX_class", None) return nxClass == "NXentry" + def __isNXnode(self, node): + """Returns true if the node is an NX concept""" + class_ = node.h5Class + if class_ is None or class_ != silx.io.utils.H5Type.GROUP: + return False + nxClass = node.obj.attrs.get("NX_class", None) + return nxClass is not None + def getWordsAndNumbers(self, name): """ Returns a list of words and integers composing the name. @@ -96,11 +106,14 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel): :param str name: A name :rtype: List """ + nonSensitive = self.sortCaseSensitivity() == qt.Qt.CaseInsensitive words = self.__split.findall(name) result = [] for i in words: if i[0].isdigit(): i = int(i) + elif nonSensitive: + i = i.lower() result.append(i) return result @@ -145,3 +158,47 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel): except Exception: _logger.debug("Exception occurred", exc_info=True) return None + + def __createCompoundIcon(self, backgroundIcon, foregroundIcon): + icon = qt.QIcon() + + sizes = backgroundIcon.availableSizes() + sizes = sorted(sizes, key=lambda s: s.height()) + sizes = filter(lambda s: s.height() < 100, sizes) + sizes = list(sizes) + if len(sizes) > 0: + baseSize = sizes[-1] + else: + baseSize = qt.QSize(32, 32) + + modes = [qt.QIcon.Normal, qt.QIcon.Disabled] + for mode in modes: + pixmap = qt.QPixmap(baseSize) + pixmap.fill(qt.Qt.transparent) + painter = qt.QPainter(pixmap) + painter.drawPixmap(0, 0, backgroundIcon.pixmap(baseSize, mode=mode)) + painter.drawPixmap(0, 0, foregroundIcon.pixmap(baseSize, mode=mode)) + painter.end() + icon.addPixmap(pixmap, mode=mode) + + return icon + + def __getNxIcon(self, baseIcon): + iconHash = baseIcon.cacheKey() + icon = self.__iconCache.get(iconHash, None) + if icon is None: + nxIcon = icons.getQIcon("layer-nx") + icon = self.__createCompoundIcon(baseIcon, nxIcon) + self.__iconCache[iconHash] = icon + return icon + + def data(self, index, role=qt.Qt.DisplayRole): + result = super(NexusSortFilterProxyModel, self).data(index, role) + + if index.column() == Hdf5TreeModel.NAME_COLUMN: + if role == qt.Qt.DecorationRole: + sourceIndex = self.mapToSource(index) + item = self.sourceModel().data(sourceIndex, Hdf5TreeModel.H5PY_ITEM_ROLE) + if self.__isNXnode(item): + result = self.__getNxIcon(result) + return result diff --git a/silx/gui/hdf5/_utils.py b/silx/gui/hdf5/_utils.py index ddf4db5..8385129 100644 --- a/silx/gui/hdf5/_utils.py +++ b/silx/gui/hdf5/_utils.py @@ -28,7 +28,7 @@ package `silx.gui.hdf5` package. __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "20/12/2017" +__date__ = "04/05/2018" import logging @@ -102,6 +102,26 @@ def htmlFromDict(dictionary, title=None): return result +class Hdf5DatasetMimeData(qt.QMimeData): + """Mimedata class to identify an internal drag and drop of a Hdf5Node.""" + + MIME_TYPE = "application/x-internal-h5py-dataset" + + def __init__(self, node=None, dataset=None): + qt.QMimeData.__init__(self) + self.__dataset = dataset + self.__node = node + self.setData(self.MIME_TYPE, "".encode(encoding='utf-8')) + + def node(self): + return self.__node + + def dataset(self): + if self.__node is not None: + return self.__node.obj + return self.__dataset + + class Hdf5NodeMimeData(qt.QMimeData): """Mimedata class to identify an internal drag and drop of a Hdf5Node.""" diff --git a/silx/gui/hdf5/test/test_hdf5.py b/silx/gui/hdf5/test/test_hdf5.py index 44c4456..fc27f6b 100644 --- a/silx/gui/hdf5/test/test_hdf5.py +++ b/silx/gui/hdf5/test/test_hdf5.py @@ -26,7 +26,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "20/02/2018" +__date__ = "03/05/2018" import time @@ -39,6 +39,7 @@ from contextlib import contextmanager from silx.gui import qt from silx.gui.test.utils import TestCaseQt from silx.gui import hdf5 +from silx.gui.test.utils import SignalListener from silx.io import commonh5 import weakref @@ -48,6 +49,29 @@ except ImportError: h5py = None +_tmpDirectory = None + + +def setUpModule(): + global _tmpDirectory + _tmpDirectory = tempfile.mkdtemp(prefix=__name__) + + if h5py is not None: + filename = _tmpDirectory + "/data.h5" + + # create h5 data + f = h5py.File(filename, "w") + g = f.create_group("arrays") + g.create_dataset("scalar", data=10) + f.close() + + +def tearDownModule(): + global _tmpDirectory + shutil.rmtree(_tmpDirectory) + _tmpDirectory = None + + _called = 0 @@ -71,7 +95,7 @@ class TestHdf5TreeModel(TestCaseQt): self.skipTest("h5py is not available") def waitForPendingOperations(self, model): - for i in range(10): + for _ in range(10): if not model.hasPendingOperations(): break self.qWait(10) @@ -97,53 +121,53 @@ class TestHdf5TreeModel(TestCaseQt): self.assertIsNotNone(model) def testAppendFilename(self): - with self.h5TempFile() as filename: + filename = _tmpDirectory + "/data.h5" + model = hdf5.Hdf5TreeModel() + self.assertEquals(model.rowCount(qt.QModelIndex()), 0) + model.appendFile(filename) + self.assertEquals(model.rowCount(qt.QModelIndex()), 1) + # clean up + index = model.index(0, 0, qt.QModelIndex()) + h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) + ref = weakref.ref(model) + model = None + self.qWaitForDestroy(ref) + + def testAppendBadFilename(self): + model = hdf5.Hdf5TreeModel() + self.assertRaises(IOError, model.appendFile, "#%$") + + def testInsertFilename(self): + filename = _tmpDirectory + "/data.h5" + try: model = hdf5.Hdf5TreeModel() self.assertEquals(model.rowCount(qt.QModelIndex()), 0) - model.appendFile(filename) + model.insertFile(filename) self.assertEquals(model.rowCount(qt.QModelIndex()), 1) # clean up index = model.index(0, 0, qt.QModelIndex()) h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) + self.assertIsNotNone(h5File) + finally: ref = weakref.ref(model) model = None self.qWaitForDestroy(ref) - def testAppendBadFilename(self): - model = hdf5.Hdf5TreeModel() - self.assertRaises(IOError, model.appendFile, "#%$") - - def testInsertFilename(self): - with self.h5TempFile() as filename: - try: - model = hdf5.Hdf5TreeModel() - self.assertEquals(model.rowCount(qt.QModelIndex()), 0) - model.insertFile(filename) - self.assertEquals(model.rowCount(qt.QModelIndex()), 1) - # clean up - index = model.index(0, 0, qt.QModelIndex()) - h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) - self.assertIsNotNone(h5File) - finally: - ref = weakref.ref(model) - model = None - self.qWaitForDestroy(ref) - def testInsertFilenameAsync(self): - with self.h5TempFile() as filename: - try: - model = hdf5.Hdf5TreeModel() - self.assertEquals(model.rowCount(qt.QModelIndex()), 0) - model.insertFileAsync(filename) - index = model.index(0, 0, qt.QModelIndex()) - self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5LoadingItem.Hdf5LoadingItem) - self.waitForPendingOperations(model) - index = model.index(0, 0, qt.QModelIndex()) - self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item) - finally: - ref = weakref.ref(model) - model = None - self.qWaitForDestroy(ref) + filename = _tmpDirectory + "/data.h5" + try: + model = hdf5.Hdf5TreeModel() + self.assertEquals(model.rowCount(qt.QModelIndex()), 0) + model.insertFileAsync(filename) + index = model.index(0, 0, qt.QModelIndex()) + self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5LoadingItem.Hdf5LoadingItem) + self.waitForPendingOperations(model) + index = model.index(0, 0, qt.QModelIndex()) + self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item) + finally: + ref = weakref.ref(model) + model = None + self.qWaitForDestroy(ref) def testInsertObject(self): h5 = commonh5.File("/foo/bar/1.mock", "w") @@ -162,36 +186,37 @@ class TestHdf5TreeModel(TestCaseQt): self.assertEquals(model.rowCount(qt.QModelIndex()), 0) def testSynchronizeObject(self): - with self.h5TempFile() as filename: - h5 = h5py.File(filename) - model = hdf5.Hdf5TreeModel() - model.insertH5pyObject(h5) - self.assertEquals(model.rowCount(qt.QModelIndex()), 1) - index = model.index(0, 0, qt.QModelIndex()) - node1 = model.nodeFromIndex(index) - model.synchronizeH5pyObject(h5) - # Now h5 was loaded from it's filename - # Another ref is owned by the model - h5.close() + filename = _tmpDirectory + "/data.h5" + h5 = h5py.File(filename) + model = hdf5.Hdf5TreeModel() + model.insertH5pyObject(h5) + self.assertEquals(model.rowCount(qt.QModelIndex()), 1) + index = model.index(0, 0, qt.QModelIndex()) + node1 = model.nodeFromIndex(index) + model.synchronizeH5pyObject(h5) + self.waitForPendingOperations(model) + # Now h5 was loaded from it's filename + # Another ref is owned by the model + h5.close() - index = model.index(0, 0, qt.QModelIndex()) - node2 = model.nodeFromIndex(index) - self.assertIsNot(node1, node2) - # after sync - time.sleep(0.1) - self.qapp.processEvents() - time.sleep(0.1) - index = model.index(0, 0, qt.QModelIndex()) - self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item) - # clean up - index = model.index(0, 0, qt.QModelIndex()) - h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) - self.assertIsNotNone(h5File) - h5File = None - # delete the model - ref = weakref.ref(model) - model = None - self.qWaitForDestroy(ref) + index = model.index(0, 0, qt.QModelIndex()) + node2 = model.nodeFromIndex(index) + self.assertIsNot(node1, node2) + # after sync + time.sleep(0.1) + self.qapp.processEvents() + time.sleep(0.1) + index = model.index(0, 0, qt.QModelIndex()) + self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item) + # clean up + index = model.index(0, 0, qt.QModelIndex()) + h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) + self.assertIsNotNone(h5File) + h5File = None + # delete the model + ref = weakref.ref(model) + model = None + self.qWaitForDestroy(ref) def testFileMoveState(self): model = hdf5.Hdf5TreeModel() @@ -222,24 +247,24 @@ class TestHdf5TreeModel(TestCaseQt): self.assertNotEquals(model.supportedDropActions(), 0) def testDropExternalFile(self): - with self.h5TempFile() as filename: - model = hdf5.Hdf5TreeModel() - mimeData = qt.QMimeData() - mimeData.setUrls([qt.QUrl.fromLocalFile(filename)]) - model.dropMimeData(mimeData, qt.Qt.CopyAction, 0, 0, qt.QModelIndex()) - self.assertEquals(model.rowCount(qt.QModelIndex()), 1) - # after sync - self.waitForPendingOperations(model) - index = model.index(0, 0, qt.QModelIndex()) - self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item) - # clean up - index = model.index(0, 0, qt.QModelIndex()) - h5File = model.data(index, role=hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) - self.assertIsNotNone(h5File) - h5File = None - ref = weakref.ref(model) - model = None - self.qWaitForDestroy(ref) + filename = _tmpDirectory + "/data.h5" + model = hdf5.Hdf5TreeModel() + mimeData = qt.QMimeData() + mimeData.setUrls([qt.QUrl.fromLocalFile(filename)]) + model.dropMimeData(mimeData, qt.Qt.CopyAction, 0, 0, qt.QModelIndex()) + self.assertEquals(model.rowCount(qt.QModelIndex()), 1) + # after sync + self.waitForPendingOperations(model) + index = model.index(0, 0, qt.QModelIndex()) + self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item) + # clean up + index = model.index(0, 0, qt.QModelIndex()) + h5File = model.data(index, role=hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) + self.assertIsNotNone(h5File) + h5File = None + ref = weakref.ref(model) + model = None + self.qWaitForDestroy(ref) def getRowDataAsDict(self, model, row): displayed = {} @@ -337,6 +362,66 @@ class TestHdf5TreeModel(TestCaseQt): self.assertEquals(index, qt.QModelIndex()) +class TestHdf5TreeModelSignals(TestCaseQt): + + def setUp(self): + TestCaseQt.setUp(self) + self.model = hdf5.Hdf5TreeModel() + filename = _tmpDirectory + "/data.h5" + self.h5 = h5py.File(filename) + self.model.insertH5pyObject(self.h5) + + self.listener = SignalListener() + self.model.sigH5pyObjectLoaded.connect(self.listener.partial(signal="loaded")) + self.model.sigH5pyObjectRemoved.connect(self.listener.partial(signal="removed")) + self.model.sigH5pyObjectSynchronized.connect(self.listener.partial(signal="synchronized")) + + def tearDown(self): + self.signals = None + ref = weakref.ref(self.model) + self.model = None + self.qWaitForDestroy(ref) + self.h5.close() + self.h5 = None + TestCaseQt.tearDown(self) + + def waitForPendingOperations(self, model): + for _ in range(10): + if not model.hasPendingOperations(): + break + self.qWait(10) + else: + raise RuntimeError("Still waiting for a pending operation") + + def testInsert(self): + filename = _tmpDirectory + "/data.h5" + h5 = h5py.File(filename) + self.model.insertH5pyObject(h5) + self.assertEquals(self.listener.callCount(), 0) + + def testLoaded(self): + filename = _tmpDirectory + "/data.h5" + self.model.insertFile(filename) + self.assertEquals(self.listener.callCount(), 1) + self.assertEquals(self.listener.karguments(argumentName="signal")[0], "loaded") + self.assertIsNot(self.listener.arguments(callIndex=0)[0], self.h5) + self.assertEquals(self.listener.arguments(callIndex=0)[0].filename, filename) + + def testRemoved(self): + self.model.removeH5pyObject(self.h5) + self.assertEquals(self.listener.callCount(), 1) + self.assertEquals(self.listener.karguments(argumentName="signal")[0], "removed") + self.assertIs(self.listener.arguments(callIndex=0)[0], self.h5) + + def testSynchonized(self): + self.model.synchronizeH5pyObject(self.h5) + self.waitForPendingOperations(self.model) + self.assertEquals(self.listener.callCount(), 1) + self.assertEquals(self.listener.karguments(argumentName="signal")[0], "synchronized") + self.assertIs(self.listener.arguments(callIndex=0)[0], self.h5) + self.assertIsNot(self.listener.arguments(callIndex=0)[1], self.h5) + + class TestNexusSortFilterProxyModel(TestCaseQt): def getChildNames(self, model, index): @@ -873,6 +958,7 @@ def suite(): test_suite = unittest.TestSuite() loadTests = unittest.defaultTestLoader.loadTestsFromTestCase test_suite.addTest(loadTests(TestHdf5TreeModel)) + test_suite.addTest(loadTests(TestHdf5TreeModelSignals)) test_suite.addTest(loadTests(TestNexusSortFilterProxyModel)) test_suite.addTest(loadTests(TestHdf5TreeView)) test_suite.addTest(loadTests(TestH5Node)) diff --git a/silx/gui/icons.py b/silx/gui/icons.py index 0108b3a..bd10300 100644 --- a/silx/gui/icons.py +++ b/silx/gui/icons.py @@ -29,7 +29,7 @@ Use :func:`getQIcon` to create Qt QIcon from the name identifying an icon. __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "06/09/2017" +__date__ = "19/06/2018" import os @@ -193,10 +193,13 @@ class MultiImageAnimatedIcon(AbstractAnimatedIcon): self.__frames = [] for i in range(100): try: - pixmap = getQPixmap("%s/%02d" % (filename, i)) + filename = getQFile("%s/%02d" % (filename, i)) + except ValueError: + break + try: + icon = qt.QIcon(filename.fileName()) except ValueError: break - icon = qt.QIcon(pixmap) self.__frames.append(icon) if len(self.__frames) == 0: @@ -328,8 +331,7 @@ def getQIcon(name): """ if name not in _cached_icons: qfile = getQFile(name) - pixmap = qt.QPixmap(qfile.fileName()) - icon = qt.QIcon(pixmap) + icon = qt.QIcon(qfile.fileName()) _cached_icons[name] = icon else: icon = _cached_icons[name] @@ -392,7 +394,7 @@ def getQFile(name): for format_ in _supported_formats: format_ = str(format_) filename = silx.resources._resource_filename('%s.%s' % (name, format_), - default_directory=os.path.join('gui', 'icons')) + default_directory=os.path.join('gui', 'icons')) qfile = qt.QFile(filename) if qfile.exists(): return qfile diff --git a/silx/gui/plot/ColorBar.py b/silx/gui/plot/ColorBar.py index 2db7b79..0941e82 100644 --- a/silx/gui/plot/ColorBar.py +++ b/silx/gui/plot/ColorBar.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# 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 @@ -27,14 +27,16 @@ __authors__ = ["H. Payno", "T. Vincent"] __license__ = "MIT" -__date__ = "15/02/2018" +__date__ = "24/04/2018" import logging +import weakref import numpy + from ._utils import ticklayout -from .. import qt, icons -from silx.gui.plot import Colormap +from .. import qt +from silx.gui import colors _logger = logging.getLogger(__name__) @@ -70,7 +72,7 @@ class ColorBarWidget(qt.QWidget): def __init__(self, parent=None, plot=None, legend=None): self._isConnected = False - self._plot = None + self._plotRef = None self._colormap = None self._data = None @@ -96,7 +98,7 @@ class ColorBarWidget(qt.QWidget): def getPlot(self): """Returns the :class:`Plot` associated to this widget or None""" - return self._plot + return None if self._plotRef is None else self._plotRef() def setPlot(self, plot): """Associate a plot to the ColorBar @@ -105,27 +107,38 @@ class ColorBarWidget(qt.QWidget): If None will remove any connection with a previous plot. """ self._disconnectPlot() - self._plot = plot + self._plotRef = None if plot is None else weakref.ref(plot) self._connectPlot() def _disconnectPlot(self): """Disconnect from Plot signals""" - if self._plot is not None and self._isConnected: + plot = self.getPlot() + if plot is not None and self._isConnected: self._isConnected = False - self._plot.sigActiveImageChanged.disconnect( + plot.sigActiveImageChanged.disconnect( self._activeImageChanged) - self._plot.sigPlotSignal.disconnect(self._defaultColormapChanged) + plot.sigActiveScatterChanged.disconnect( + self._activeScatterChanged) + plot.sigPlotSignal.disconnect(self._defaultColormapChanged) def _connectPlot(self): """Connect to Plot signals""" - if self._plot is not None and not self._isConnected: - activeImageLegend = self._plot.getActiveImage(just_legend=True) - if activeImageLegend is None: # Show plot default colormap + plot = self.getPlot() + if plot is not None and not self._isConnected: + activeImageLegend = plot.getActiveImage(just_legend=True) + activeScatterLegend = plot._getActiveItem( + kind='scatter', just_legend=True) + if activeImageLegend is None and activeScatterLegend is None: + # Show plot default colormap self._syncWithDefaultColormap() - else: # Show active image colormap + elif activeImageLegend is not None: # Show active image colormap self._activeImageChanged(None, activeImageLegend) - self._plot.sigActiveImageChanged.connect(self._activeImageChanged) - self._plot.sigPlotSignal.connect(self._defaultColormapChanged) + elif activeScatterLegend is not None: # Show active scatter colormap + self._activeScatterChanged(None, activeScatterLegend) + + plot.sigActiveImageChanged.connect(self._activeImageChanged) + plot.sigActiveScatterChanged.connect(self._activeScatterChanged) + plot.sigPlotSignal.connect(self._defaultColormapChanged) self._isConnected = True def setVisible(self, isVisible): @@ -196,36 +209,58 @@ class ColorBarWidget(qt.QWidget): """ return self.legend.getText() - def _activeImageChanged(self, previous, legend): - """Handle plot active curve changed""" - if legend is None: # No active image, display no colormap - self.setColormap(colormap=None) - return + def _activeScatterChanged(self, previous, legend): + """Handle plot active scatter changed""" + plot = self.getPlot() - # Sync with active image - image = self._plot.getActiveImage().getData(copy=False) + # Do not handle active scatter while there is an image + if plot.getActiveImage() is not None: + return - # RGB(A) image, display default colormap - if image.ndim != 2: + if legend is None: # No active scatter, display no colormap self.setColormap(colormap=None) return - # data image, sync with image colormap - # do we need the copy here : used in the case we are changing - # vmin and vmax but should have already be done by the plot - self.setColormap(colormap=self._plot.getActiveImage().getColormap(), - data=image) + # Sync with active scatter + activeScatter = plot._getActiveItem(kind='scatter') + + self.setColormap(colormap=activeScatter.getColormap(), + data=activeScatter.getValueData(copy=False)) + + def _activeImageChanged(self, previous, legend): + """Handle plot active image changed""" + plot = self.getPlot() + + if legend is None: # No active image, try with active scatter + activeScatterLegend = plot._getActiveItem( + kind='scatter', just_legend=True) + # No more active image, use active scatter if any + self._activeScatterChanged(None, activeScatterLegend) + else: + # Sync with active image + image = plot.getActiveImage().getData(copy=False) + + # RGB(A) image, display default colormap + if image.ndim != 2: + self.setColormap(colormap=None) + return + + # data image, sync with image colormap + # do we need the copy here : used in the case we are changing + # vmin and vmax but should have already be done by the plot + self.setColormap(colormap=plot.getActiveImage().getColormap(), + data=image) def _defaultColormapChanged(self, event): """Handle plot default colormap changed""" if (event['event'] == 'defaultColormapChanged' and - self._plot.getActiveImage() is None): + self.getPlot().getActiveImage() is None): # No active image, take default colormap update into account self._syncWithDefaultColormap() def _syncWithDefaultColormap(self, data=None): """Update colorbar according to plot default colormap""" - self.setColormap(self._plot.getDefaultColormap(), data) + self.setColormap(self.getPlot().getDefaultColormap(), data) def getColorScaleBar(self): """ @@ -316,9 +351,9 @@ class ColorScaleBar(qt.QWidget): if colormap: vmin, vmax = colormap.getColormapRange(data) else: - vmin, vmax = Colormap.DEFAULT_MIN_LIN, Colormap.DEFAULT_MAX_LIN + vmin, vmax = colors.DEFAULT_MIN_LIN, colors.DEFAULT_MAX_LIN - norm = colormap.getNormalization() if colormap else Colormap.Colormap.LINEAR + norm = colormap.getNormalization() if colormap else colors.Colormap.LINEAR self.tickbar = _TickBar(vmin=vmin, vmax=vmax, norm=norm, @@ -503,7 +538,7 @@ class _ColorScale(qt.QWidget): if colormap is None: self.vmin, self.vmax = None, None else: - assert colormap.getNormalization() in Colormap.Colormap.NORMALIZATIONS + assert colormap.getNormalization() in colors.Colormap.NORMALIZATIONS self.vmin, self.vmax = self._colormap.getColormapRange(data=data) self._updateColorGradient() self.update() @@ -575,9 +610,9 @@ class _ColorScale(qt.QWidget): vmin = self.vmin vmax = self.vmax - if colormap.getNormalization() == Colormap.Colormap.LINEAR: + if colormap.getNormalization() == colors.Colormap.LINEAR: return vmin + (vmax - vmin) * value - elif colormap.getNormalization() == Colormap.Colormap.LOGARITHM: + elif colormap.getNormalization() == colors.Colormap.LOGARITHM: rpos = (numpy.log10(vmax) - numpy.log10(vmin)) * value + numpy.log10(vmin) return numpy.power(10., rpos) else: @@ -706,9 +741,9 @@ class _TickBar(qt.QWidget): # No range: no ticks self.ticks = () self.subTicks = () - elif self._norm == Colormap.Colormap.LOGARITHM: + elif self._norm == colors.Colormap.LOGARITHM: self._computeTicksLog(nticks) - elif self._norm == Colormap.Colormap.LINEAR: + elif self._norm == colors.Colormap.LINEAR: self._computeTicksLin(nticks) else: err = 'TickBar - Wrong normalization %s' % self._norm @@ -765,9 +800,9 @@ class _TickBar(qt.QWidget): def _getRelativePosition(self, val): """Return the relative position of val according to min and max value """ - if self._norm == Colormap.Colormap.LINEAR: + if self._norm == colors.Colormap.LINEAR: return 1 - (val - self._vmin) / (self._vmax - self._vmin) - elif self._norm == Colormap.Colormap.LOGARITHM: + elif self._norm == colors.Colormap.LOGARITHM: return 1 - (numpy.log10(val) - numpy.log10(self._vmin)) / (numpy.log10(self._vmax) - numpy.log(self._vmin)) else: raise ValueError('Norm is not recognized') diff --git a/silx/gui/plot/Colormap.py b/silx/gui/plot/Colormap.py index 9adf0d4..e797d89 100644 --- a/silx/gui/plot/Colormap.py +++ b/silx/gui/plot/Colormap.py @@ -22,568 +22,23 @@ # THE SOFTWARE. # # ###########################################################################*/ -"""This module provides the Colormap object +"""Deprecated module providing the Colormap object """ from __future__ import absolute_import __authors__ = ["T. Vincent", "H.Payno"] __license__ = "MIT" -__date__ = "08/01/2018" +__date__ = "24/04/2018" -from silx.gui import qt -import copy as copy_mdl -import numpy -from .matplotlib import Colormap as MPLColormap -import logging -from silx.math.combo import min_max -from silx.utils.exceptions import NotEditableError +import silx.utils.deprecation -_logger = logging.getLogger(__file__) +silx.utils.deprecation.deprecated_warning("Module", + name="silx.gui.plot.Colormap", + reason="moved", + replacement="silx.gui.colors.Colormap", + since_version="0.8.0", + only_once=True, + skip_backtrace_count=1) -DEFAULT_COLORMAPS = ( - 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue') -"""Tuple of supported colormap names.""" - -DEFAULT_MIN_LIN = 0 -"""Default min value if in linear normalization""" -DEFAULT_MAX_LIN = 1 -"""Default max value if in linear normalization""" -DEFAULT_MIN_LOG = 1 -"""Default min value if in log normalization""" -DEFAULT_MAX_LOG = 10 -"""Default max value if in log normalization""" - - -class Colormap(qt.QObject): - """Description of a colormap - - :param str name: Name of the colormap - :param tuple colors: optional, custom colormap. - Nx3 or Nx4 numpy array of RGB(A) colors, - either uint8 or float in [0, 1]. - If 'name' is None, then this array is used as the colormap. - :param str normalization: Normalization: 'linear' (default) or 'log' - :param float vmin: - Lower bound of the colormap or None for autoscale (default) - :param float vmax: - Upper bounds of the colormap or None for autoscale (default) - """ - - LINEAR = 'linear' - """constant for linear normalization""" - - LOGARITHM = 'log' - """constant for logarithmic normalization""" - - NORMALIZATIONS = (LINEAR, LOGARITHM) - """Tuple of managed normalizations""" - - sigChanged = qt.Signal() - """Signal emitted when the colormap has changed.""" - - def __init__(self, name='gray', colors=None, normalization=LINEAR, vmin=None, vmax=None): - qt.QObject.__init__(self) - assert normalization in Colormap.NORMALIZATIONS - assert not (name is None and colors is None) - if normalization is Colormap.LOGARITHM: - if (vmin is not None and vmin < 0) or (vmax is not None and vmax < 0): - m = "Unsuported vmin (%s) and/or vmax (%s) given for a log scale." - m += ' Autoscale will be performed.' - m = m % (vmin, vmax) - _logger.warning(m) - vmin = None - vmax = None - - self._name = str(name) if name is not None else None - self._setColors(colors) - self._normalization = str(normalization) - self._vmin = float(vmin) if vmin is not None else None - self._vmax = float(vmax) if vmax is not None else None - self._editable = True - - def isAutoscale(self): - """Return True if both min and max are in autoscale mode""" - return self._vmin is None and self._vmax is None - - def getName(self): - """Return the name of the colormap - :rtype: str - """ - return self._name - - def _setColors(self, colors): - if colors is None: - self._colors = None - else: - self._colors = numpy.array(colors, copy=True) - - def getNColors(self, nbColors=None): - """Returns N colors computed by sampling the colormap regularly. - - :param nbColors: - The number of colors in the returned array or None for the default value. - The default value is 256 for colormap with a name (see :meth:`setName`) and - it is the size of the LUT for colormap defined with :meth:`setColormapLUT`. - :type nbColors: int or None - :return: 2D array of uint8 of shape (nbColors, 4) - :rtype: numpy.ndarray - """ - # Handle default value for nbColors - if nbColors is None: - lut = self.getColormapLUT() - if lut is not None: # In this case uses LUT length - nbColors = len(lut) - else: # Default to 256 - nbColors = 256 - - nbColors = int(nbColors) - - colormap = self.copy() - colormap.setNormalization(Colormap.LINEAR) - colormap.setVRange(vmin=None, vmax=None) - colors = colormap.applyToData( - numpy.arange(nbColors, dtype=numpy.int)) - return colors - - def setName(self, name): - """Set the name of the colormap to use. - - :param str name: The name of the colormap. - At least the following names are supported: 'gray', - 'reversed gray', 'temperature', 'red', 'green', 'blue', 'jet', - 'viridis', 'magma', 'inferno', 'plasma'. - """ - if self.isEditable() is False: - raise NotEditableError('Colormap is not editable') - assert name in self.getSupportedColormaps() - self._name = str(name) - self._colors = None - self.sigChanged.emit() - - def getColormapLUT(self): - """Return the list of colors for the colormap or None if not set - - :return: the list of colors for the colormap or None if not set - :rtype: numpy.ndarray or None - """ - if self._colors is None: - return None - else: - return numpy.array(self._colors, copy=True) - - def setColormapLUT(self, colors): - """Set the colors of the colormap. - - :param numpy.ndarray colors: the colors of the LUT - - .. warning: this will set the value of name to None - """ - if self.isEditable() is False: - raise NotEditableError('Colormap is not editable') - self._setColors(colors) - if len(colors) is 0: - self._colors = None - - self._name = None - self.sigChanged.emit() - - def getNormalization(self): - """Return the normalization of the colormap ('log' or 'linear') - - :return: the normalization of the colormap - :rtype: str - """ - return self._normalization - - def setNormalization(self, norm): - """Set the norm ('log', 'linear') - - :param str norm: the norm to set - """ - if self.isEditable() is False: - raise NotEditableError('Colormap is not editable') - self._normalization = str(norm) - self.sigChanged.emit() - - def getVMin(self): - """Return the lower bound of the colormap - - :return: the lower bound of the colormap - :rtype: float or None - """ - return self._vmin - - def setVMin(self, vmin): - """Set the minimal value of the colormap - - :param float vmin: Lower bound of the colormap or None for autoscale - (default) - value) - """ - if self.isEditable() is False: - raise NotEditableError('Colormap is not editable') - if vmin is not None: - if self._vmax is not None and vmin > self._vmax: - err = "Can't set vmin because vmin >= vmax. " \ - "vmin = %s, vmax = %s" % (vmin, self._vmax) - raise ValueError(err) - - self._vmin = vmin - self.sigChanged.emit() - - def getVMax(self): - """Return the upper bounds of the colormap or None - - :return: the upper bounds of the colormap or None - :rtype: float or None - """ - return self._vmax - - def setVMax(self, vmax): - """Set the maximal value of the colormap - - :param float vmax: Upper bounds of the colormap or None for autoscale - (default) - """ - if self.isEditable() is False: - raise NotEditableError('Colormap is not editable') - if vmax is not None: - if self._vmin is not None and vmax < self._vmin: - err = "Can't set vmax because vmax <= vmin. " \ - "vmin = %s, vmax = %s" % (self._vmin, vmax) - raise ValueError(err) - - self._vmax = vmax - self.sigChanged.emit() - - def isEditable(self): - """ Return if the colormap is editable or not - - :return: editable state of the colormap - :rtype: bool - """ - return self._editable - - def setEditable(self, editable): - """ - Set the editable state of the colormap - - :param bool editable: is the colormap editable - """ - assert type(editable) is bool - self._editable = editable - self.sigChanged.emit() - - def getColormapRange(self, data=None): - """Return (vmin, vmax) - - :return: the tuple vmin, vmax fitting vmin, vmax, normalization and - data if any given - :rtype: tuple - """ - vmin = self._vmin - vmax = self._vmax - assert vmin is None or vmax is None or vmin <= vmax # TODO handle this in setters - - if self.getNormalization() == self.LOGARITHM: - # Handle negative bounds as autoscale - if vmin is not None and (vmin is not None and vmin <= 0.): - mess = 'negative vmin, moving to autoscale for lower bound' - _logger.warning(mess) - vmin = None - if vmax is not None and (vmax is not None and vmax <= 0.): - mess = 'negative vmax, moving to autoscale for upper bound' - _logger.warning(mess) - vmax = None - - if vmin is None or vmax is None: # Handle autoscale - # Get min/max from data - if data is not None: - data = numpy.array(data, copy=False) - if data.size == 0: # Fallback an array but no data - min_, max_ = self._getDefaultMin(), self._getDefaultMax() - else: - if self.getNormalization() == self.LOGARITHM: - result = min_max(data, min_positive=True, finite=True) - min_ = result.min_positive # >0 or None - max_ = result.maximum # can be <= 0 - else: - min_, max_ = min_max(data, min_positive=False, finite=True) - - # Handle fallback - if min_ is None or not numpy.isfinite(min_): - min_ = self._getDefaultMin() - if max_ is None or not numpy.isfinite(max_): - max_ = self._getDefaultMax() - else: # Fallback if no data is provided - min_, max_ = self._getDefaultMin(), self._getDefaultMax() - - if vmin is None: # Set vmin respecting provided vmax - vmin = min_ if vmax is None else min(min_, vmax) - - if vmax is None: - vmax = max(max_, vmin) # Handle max_ <= 0 for log scale - - return vmin, vmax - - def setVRange(self, vmin, vmax): - """Set the bounds of the colormap - - :param vmin: Lower bound of the colormap or None for autoscale - (default) - :param vmax: Upper bounds of the colormap or None for autoscale - (default) - """ - if self.isEditable() is False: - raise NotEditableError('Colormap is not editable') - if vmin is not None and vmax is not None: - if vmin > vmax: - err = "Can't set vmin and vmax because vmin >= vmax " \ - "vmin = %s, vmax = %s" % (vmin, vmax) - raise ValueError(err) - - if self._vmin == vmin and self._vmax == vmax: - return - - self._vmin = vmin - self._vmax = vmax - self.sigChanged.emit() - - def __getitem__(self, item): - if item == 'autoscale': - return self.isAutoscale() - elif item == 'name': - return self.getName() - elif item == 'normalization': - return self.getNormalization() - elif item == 'vmin': - return self.getVMin() - elif item == 'vmax': - return self.getVMax() - elif item == 'colors': - return self.getColormapLUT() - else: - raise KeyError(item) - - def _toDict(self): - """Return the equivalent colormap as a dictionary - (old colormap representation) - - :return: the representation of the Colormap as a dictionary - :rtype: dict - """ - return { - 'name': self._name, - 'colors': copy_mdl.copy(self._colors), - 'vmin': self._vmin, - 'vmax': self._vmax, - 'autoscale': self.isAutoscale(), - 'normalization': self._normalization - } - - def _setFromDict(self, dic): - """Set values to the colormap from a dictionary - - :param dict dic: the colormap as a dictionary - """ - if self.isEditable() is False: - raise NotEditableError('Colormap is not editable') - name = dic['name'] if 'name' in dic else None - colors = dic['colors'] if 'colors' in dic else None - vmin = dic['vmin'] if 'vmin' in dic else None - vmax = dic['vmax'] if 'vmax' in dic else None - if 'normalization' in dic: - normalization = dic['normalization'] - else: - warn = 'Normalization not given in the dictionary, ' - warn += 'set by default to ' + Colormap.LINEAR - _logger.warning(warn) - normalization = Colormap.LINEAR - - if name is None and colors is None: - err = 'The colormap should have a name defined or a tuple of colors' - raise ValueError(err) - if normalization not in Colormap.NORMALIZATIONS: - err = 'Given normalization is not recoginized (%s)' % normalization - raise ValueError(err) - - # If autoscale, then set boundaries to None - if dic.get('autoscale', False): - vmin, vmax = None, None - - self._name = name - self._colors = colors - self._vmin = vmin - self._vmax = vmax - self._autoscale = True if (vmin is None and vmax is None) else False - self._normalization = normalization - - self.sigChanged.emit() - - @staticmethod - def _fromDict(dic): - colormap = Colormap(name="") - colormap._setFromDict(dic) - return colormap - - def copy(self): - """Return a copy of the Colormap. - - :rtype: silx.gui.plot.Colormap.Colormap - """ - return Colormap(name=self._name, - colors=copy_mdl.copy(self._colors), - vmin=self._vmin, - vmax=self._vmax, - normalization=self._normalization) - - def applyToData(self, data): - """Apply the colormap to the data - - :param numpy.ndarray data: The data to convert. - """ - rgbaImage = MPLColormap.applyColormapToData(colormap=self, data=data) - return rgbaImage - - @staticmethod - def getSupportedColormaps(): - """Get the supported colormap names as a tuple of str. - - The list should at least contain and start by: - ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue') - :rtype: tuple - """ - maps = MPLColormap.getSupportedColormaps() - return DEFAULT_COLORMAPS + maps - - def __str__(self): - return str(self._toDict()) - - def _getDefaultMin(self): - return DEFAULT_MIN_LIN if self._normalization == Colormap.LINEAR else DEFAULT_MIN_LOG - - def _getDefaultMax(self): - return DEFAULT_MAX_LIN if self._normalization == Colormap.LINEAR else DEFAULT_MAX_LOG - - def __eq__(self, other): - """Compare colormap values and not pointers""" - return (self.getName() == other.getName() and - self.getNormalization() == other.getNormalization() and - self.getVMin() == other.getVMin() and - self.getVMax() == other.getVMax() and - numpy.array_equal(self.getColormapLUT(), other.getColormapLUT()) - ) - - _SERIAL_VERSION = 1 - - def restoreState(self, byteArray): - """ - Read the colormap state from a QByteArray. - - :param qt.QByteArray byteArray: Stream containing the state - :return: True if the restoration sussseed - :rtype: bool - """ - if self.isEditable() is False: - raise NotEditableError('Colormap is not editable') - stream = qt.QDataStream(byteArray, qt.QIODevice.ReadOnly) - - className = stream.readQString() - if className != self.__class__.__name__: - _logger.warning("Classname mismatch. Found %s." % className) - return False - - version = stream.readUInt32() - if version != self._SERIAL_VERSION: - _logger.warning("Serial version mismatch. Found %d." % version) - return False - - name = stream.readQString() - isNull = stream.readBool() - if not isNull: - vmin = stream.readQVariant() - else: - vmin = None - isNull = stream.readBool() - if not isNull: - vmax = stream.readQVariant() - else: - vmax = None - normalization = stream.readQString() - - # emit change event only once - old = self.blockSignals(True) - try: - self.setName(name) - self.setNormalization(normalization) - self.setVRange(vmin, vmax) - finally: - self.blockSignals(old) - self.sigChanged.emit() - return True - - def saveState(self): - """ - Save state of the colomap into a QDataStream. - - :rtype: qt.QByteArray - """ - data = qt.QByteArray() - stream = qt.QDataStream(data, qt.QIODevice.WriteOnly) - - stream.writeQString(self.__class__.__name__) - stream.writeUInt32(self._SERIAL_VERSION) - stream.writeQString(self.getName()) - stream.writeBool(self.getVMin() is None) - if self.getVMin() is not None: - stream.writeQVariant(self.getVMin()) - stream.writeBool(self.getVMax() is None) - if self.getVMax() is not None: - stream.writeQVariant(self.getVMax()) - stream.writeQString(self.getNormalization()) - return data - - -_PREFERRED_COLORMAPS = DEFAULT_COLORMAPS -""" -Tuple of preferred colormap names accessed with :meth:`preferredColormaps`. -""" - - -def preferredColormaps(): - """Returns the name of the preferred colormaps. - - This list is used by widgets allowing to change the colormap - like the :class:`ColormapDialog` as a subset of colormap choices. - - :rtype: tuple of str - """ - return _PREFERRED_COLORMAPS - - -def setPreferredColormaps(colormaps): - """Set the list of preferred colormap names. - - Warning: If a colormap name is not available - it will be removed from the list. - - :param colormaps: Not empty list of colormap names - :type colormaps: iterable of str - :raise ValueError: if the list of available preferred colormaps is empty. - """ - supportedColormaps = Colormap.getSupportedColormaps() - colormaps = tuple( - cmap for cmap in colormaps if cmap in supportedColormaps) - if len(colormaps) == 0: - raise ValueError("Cannot set preferred colormaps to an empty list") - - global _PREFERRED_COLORMAPS - _PREFERRED_COLORMAPS = colormaps - - -# Initialize preferred colormaps -setPreferredColormaps(('gray', 'reversed gray', - 'temperature', 'red', 'green', 'blue', 'jet', - 'viridis', 'magma', 'inferno', 'plasma', - 'hsv')) +from ..colors import * # noqa diff --git a/silx/gui/plot/ColormapDialog.py b/silx/gui/plot/ColormapDialog.py index 4aefab6..7c66cb8 100644 --- a/silx/gui/plot/ColormapDialog.py +++ b/silx/gui/plot/ColormapDialog.py @@ -22,960 +22,22 @@ # THE SOFTWARE. # # ###########################################################################*/ -"""A QDialog widget to set-up the colormap. +"""Deprecated module providing ColormapDialog.""" -It uses a description of colormaps as dict compatible with :class:`Plot`. +from __future__ import absolute_import -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.plot.ColormapDialog import ColormapDialog ->>> from silx.gui.plot.Colormap 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"] +__authors__ = ["T. Vincent", "H.Payno"] __license__ = "MIT" -__date__ = "09/02/2018" - - -import logging - -import numpy - -from .. import qt -from .Colormap import Colormap, preferredColormaps -from . 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.setDataRange() - self.setHistogram() - self._data = None - return - - 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: - 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() +__date__ = "24/04/2018" - def keyPressEvent(self, event): - """Override key handling. +import silx.utils.deprecation - 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) +silx.utils.deprecation.deprecated_warning("Module", + name="silx.gui.plot.ColormapDialog", + reason="moved", + replacement="silx.gui.dialog.ColormapDialog", + since_version="0.8.0", + only_once=True, + skip_backtrace_count=1) - 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() +from ..dialog.ColormapDialog import * # noqa diff --git a/silx/gui/plot/Colors.py b/silx/gui/plot/Colors.py index 2d44d4d..277e104 100644 --- a/silx/gui/plot/Colors.py +++ b/silx/gui/plot/Colors.py @@ -28,120 +28,22 @@ from __future__ import absolute_import __authors__ = ["V.A. Sole", "T. Vincent"] __license__ = "MIT" -__date__ = "15/05/2017" +__date__ = "14/06/2018" +import silx.utils.deprecation -from silx.utils.deprecation import deprecated -import logging -import numpy +silx.utils.deprecation.deprecated_warning("Module", + name="silx.gui.plot.Colors", + reason="moved", + replacement="silx.gui.colors", + since_version="0.8.0", + only_once=True, + skip_backtrace_count=1) -from .Colormap import Colormap +from ..colors import * # noqa -_logger = logging.getLogger(__name__) - - -COLORDICT = {} -"""Dictionary of common colors.""" - -COLORDICT['b'] = COLORDICT['blue'] = '#0000ff' -COLORDICT['r'] = COLORDICT['red'] = '#ff0000' -COLORDICT['g'] = COLORDICT['green'] = '#00ff00' -COLORDICT['k'] = COLORDICT['black'] = '#000000' -COLORDICT['w'] = COLORDICT['white'] = '#ffffff' -COLORDICT['pink'] = '#ff66ff' -COLORDICT['brown'] = '#a52a2a' -COLORDICT['orange'] = '#ff9900' -COLORDICT['violet'] = '#6600ff' -COLORDICT['gray'] = COLORDICT['grey'] = '#a0a0a4' -# COLORDICT['darkGray'] = COLORDICT['darkGrey'] = '#808080' -# COLORDICT['lightGray'] = COLORDICT['lightGrey'] = '#c0c0c0' -COLORDICT['y'] = COLORDICT['yellow'] = '#ffff00' -COLORDICT['m'] = COLORDICT['magenta'] = '#ff00ff' -COLORDICT['c'] = COLORDICT['cyan'] = '#00ffff' -COLORDICT['darkBlue'] = '#000080' -COLORDICT['darkRed'] = '#800000' -COLORDICT['darkGreen'] = '#008000' -COLORDICT['darkBrown'] = '#660000' -COLORDICT['darkCyan'] = '#008080' -COLORDICT['darkYellow'] = '#808000' -COLORDICT['darkMagenta'] = '#800080' - - -def rgba(color, colorDict=None): - """Convert color code '#RRGGBB' and '#RRGGBBAA' to (R, G, B, A) - - It also convert RGB(A) values from uint8 to float in [0, 1] and - accept a QColor as color argument. - - :param str color: The color to convert - :param dict colorDict: A dictionary of color name conversion to color code - :returns: RGBA colors as floats in [0., 1.] - :rtype: tuple - """ - if colorDict is None: - colorDict = COLORDICT - - if hasattr(color, 'getRgbF'): # QColor support - color = color.getRgbF() - - values = numpy.asarray(color).ravel() - - if values.dtype.kind in 'iuf': # integer or float - # Color is an array - assert len(values) in (3, 4) - - # Convert from integers in [0, 255] to float in [0, 1] - if values.dtype.kind in 'iu': - values = values / 255. - - # Clip to [0, 1] - values[values < 0.] = 0. - values[values > 1.] = 1. - - if len(values) == 3: - return values[0], values[1], values[2], 1. - else: - return tuple(values) - - # We assume color is a string - if not color.startswith('#'): - color = colorDict[color] - - assert len(color) in (7, 9) and color[0] == '#' - r = int(color[1:3], 16) / 255. - g = int(color[3:5], 16) / 255. - b = int(color[5:7], 16) / 255. - a = int(color[7:9], 16) / 255. if len(color) == 9 else 1. - return r, g, b, a - - -_COLORMAP_CURSOR_COLORS = { - 'gray': 'pink', - 'reversed gray': 'pink', - 'temperature': 'pink', - 'red': 'green', - 'green': 'pink', - 'blue': 'yellow', - 'jet': 'pink', - 'viridis': 'pink', - 'magma': 'green', - 'inferno': 'green', - 'plasma': 'green', -} - - -def cursorColorForColormap(colormapName): - """Get a color suitable for overlay over a colormap. - - :param str colormapName: The name of the colormap. - :return: Name of the color. - :rtype: str - """ - return _COLORMAP_CURSOR_COLORS.get(colormapName, 'black') - - -@deprecated(replacement='silx.gui.plot.Colormap.applyColormap') +@silx.utils.deprecation.deprecated(replacement='silx.gui.colors.Colormap.applyColormap') def applyColormapToData(data, name='gray', normalization='linear', @@ -178,7 +80,7 @@ def applyColormapToData(data, return colormap.applyToData(data) -@deprecated(replacement='silx.gui.plot.Colormap.getSupportedColormaps') +@silx.utils.deprecation.deprecated(replacement='silx.gui.colors.Colormap.getSupportedColormaps') def getSupportedColormaps(): """Get the supported colormap names as a tuple of str. diff --git a/silx/gui/plot/ComplexImageView.py b/silx/gui/plot/ComplexImageView.py index ebff175..bbcb0a5 100644 --- a/silx/gui/plot/ComplexImageView.py +++ b/silx/gui/plot/ComplexImageView.py @@ -32,7 +32,7 @@ from __future__ import absolute_import __authors__ = ["Vincent Favre-Nicolin", "T. Vincent"] __license__ = "MIT" -__date__ = "19/01/2018" +__date__ = "24/04/2018" import logging @@ -410,7 +410,7 @@ class ComplexImageView(qt.QWidget): WARNING: This colormap is not used when displaying both amplitude and phase. - :param ~silx.gui.plot.Colormap.Colormap colormap: The colormap + :param ~silx.gui.colors.Colormap colormap: The colormap :param Mode mode: If specified, set the colormap of this specific mode """ self._plotImage.setColormap(colormap, mode) @@ -419,7 +419,7 @@ class ComplexImageView(qt.QWidget): """Returns the colormap used to display the data. :param Mode mode: If specified, set the colormap of this specific mode - :rtype: ~silx.gui.plot.Colormap.Colormap + :rtype: ~silx.gui.colors.Colormap """ return self._plotImage.getColormap(mode=mode) diff --git a/silx/gui/plot/CurvesROIWidget.py b/silx/gui/plot/CurvesROIWidget.py index ccb6866..81e684e 100644 --- a/silx/gui/plot/CurvesROIWidget.py +++ b/silx/gui/plot/CurvesROIWidget.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2017 European Synchrotron Radiation Facility +# 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 @@ -33,13 +33,11 @@ ROI are defined by : This can be used to apply or not some ROI to a curve and do some post processing. - The x coordinate of the left limit (`from` column) - The x coordinate of the right limit (`to` column) -- Raw counts: integral of the curve between the - min ROI point and the max ROI point to the y = 0 line +- Raw counts: Sum of the curve's values in the defined Region Of Intereset. .. image:: img/rawCounts.png -- Net counts: the integral of the curve between the - min ROI point and the max ROI point to [ROI min point, ROI max point] segment +- Net counts: Raw counts minus background .. image:: img/netCounts.png """ @@ -53,6 +51,7 @@ from collections import OrderedDict import logging import os import sys +import weakref import numpy @@ -93,7 +92,8 @@ class CurvesROIWidget(qt.QWidget): if name is not None: self.setWindowTitle(name) assert plot is not None - self.plot = plot + self._plotRef = weakref.ref(plot) + layout = qt.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) @@ -162,6 +162,13 @@ class CurvesROIWidget(qt.QWidget): self._isConnected = False # True if connected to plot signals self._isInit = False + def getPlotWidget(self): + """Returns the associated PlotWidget or None + + :rtype: Union[~silx.gui.plot.PlotWidget,None] + """ + return None if self._plotRef is None else self._plotRef() + def showEvent(self, event): self._visibilityChangedHandler(visible=True) qt.QWidget.showEvent(self, event) @@ -400,14 +407,18 @@ class CurvesROIWidget(qt.QWidget): def _roiSignal(self, ddict): """Handle ROI widget signal""" _logger.debug("CurvesROIWidget._roiSignal %s", str(ddict)) + plot = self.getPlotWidget() + if plot is None: + return + if ddict['event'] == "AddROI": - xmin, xmax = self.plot.getXAxis().getLimits() + xmin, xmax = plot.getXAxis().getLimits() fromdata = xmin + 0.25 * (xmax - xmin) todata = xmin + 0.75 * (xmax - xmin) - self.plot.remove('ROI min', kind='marker') - self.plot.remove('ROI max', kind='marker') + plot.remove('ROI min', kind='marker') + plot.remove('ROI max', kind='marker') if self._middleROIMarkerFlag: - self.plot.remove('ROI middle', kind='marker') + plot.remove('ROI middle', kind='marker') roiList, roiDict = self.roiTable.getROIListAndDict() nrois = len(roiList) if nrois == 0: @@ -416,6 +427,7 @@ class CurvesROIWidget(qt.QWidget): draggable = False color = 'black' else: + # find the next index free for newroi. for i in range(nrois): i += 1 newroi = "newroi %d" % i @@ -423,29 +435,29 @@ class CurvesROIWidget(qt.QWidget): break color = 'blue' draggable = True - self.plot.addXMarker(fromdata, - legend='ROI min', - text='ROI min', - color=color, - draggable=draggable) - self.plot.addXMarker(todata, - legend='ROI max', - text='ROI max', - color=color, - draggable=draggable) + plot.addXMarker(fromdata, + legend='ROI min', + text='ROI min', + color=color, + draggable=draggable) + plot.addXMarker(todata, + legend='ROI max', + text='ROI max', + color=color, + draggable=draggable) if draggable and self._middleROIMarkerFlag: pos = 0.5 * (fromdata + todata) - self.plot.addXMarker(pos, - legend='ROI middle', - text="", - color='yellow', - draggable=draggable) + plot.addXMarker(pos, + legend='ROI middle', + text="", + color='yellow', + draggable=draggable) roiList.append(newroi) roiDict[newroi] = {} if newroi == "ICR": roiDict[newroi]['type'] = "Default" else: - roiDict[newroi]['type'] = self.plot.getXAxis().getLabel() + roiDict[newroi]['type'] = plot.getXAxis().getLabel() roiDict[newroi]['from'] = fromdata roiDict[newroi]['to'] = todata self.roiTable.fillFromROIDict(roilist=roiList, @@ -454,10 +466,10 @@ class CurvesROIWidget(qt.QWidget): self.currentROI = newroi self.calculateRois() elif ddict['event'] in ['DelROI', "ResetROI"]: - self.plot.remove('ROI min', kind='marker') - self.plot.remove('ROI max', kind='marker') + plot.remove('ROI min', kind='marker') + plot.remove('ROI max', kind='marker') if self._middleROIMarkerFlag: - self.plot.remove('ROI middle', kind='marker') + plot.remove('ROI middle', kind='marker') roiList, roiDict = self.roiTable.getROIListAndDict() roiDictKeys = list(roiDict.keys()) if len(roiDictKeys): @@ -480,37 +492,37 @@ class CurvesROIWidget(qt.QWidget): self.roilist, self.roidict = self.roiTable.getROIListAndDict() fromdata = ddict['roi']['from'] todata = ddict['roi']['to'] - self.plot.remove('ROI min', kind='marker') - self.plot.remove('ROI max', kind='marker') + plot.remove('ROI min', kind='marker') + plot.remove('ROI max', kind='marker') if ddict['key'] == 'ICR': draggable = False color = 'black' else: draggable = True color = 'blue' - self.plot.addXMarker(fromdata, - legend='ROI min', - text='ROI min', - color=color, - draggable=draggable) - self.plot.addXMarker(todata, - legend='ROI max', - text='ROI max', - color=color, - draggable=draggable) + plot.addXMarker(fromdata, + legend='ROI min', + text='ROI min', + color=color, + draggable=draggable) + plot.addXMarker(todata, + legend='ROI max', + text='ROI max', + color=color, + draggable=draggable) if draggable and self._middleROIMarkerFlag: pos = 0.5 * (fromdata + todata) - self.plot.addXMarker(pos, - legend='ROI middle', - text="", - color='yellow', - draggable=True) + plot.addXMarker(pos, + legend='ROI middle', + text="", + color='yellow', + draggable=True) self.currentROI = ddict['key'] if ddict['colheader'] in ['From', 'To']: dict0 = {} dict0['event'] = "SetActiveCurveEvent" - dict0['legend'] = self.plot.getActiveCurve(just_legend=1) - self.plot.setActiveCurve(dict0['legend']) + dict0['legend'] = plot.getActiveCurve(just_legend=1) + plot.setActiveCurve(dict0['legend']) elif ddict['colheader'] == 'Raw Counts': pass elif ddict['colheader'] == 'Net Counts': @@ -523,7 +535,8 @@ class CurvesROIWidget(qt.QWidget): def _getAllLimits(self): """Retrieve the limits based on the curves.""" - curves = self.plot.getAllCurves() + plot = self.getPlotWidget() + curves = () if plot is None else plot.getAllCurves() if not curves: return 1.0, 1.0, 100., 100. @@ -562,7 +575,12 @@ class CurvesROIWidget(qt.QWidget): if roiList is None or roiDict is None: roiList, roiDict = self.roiTable.getROIListAndDict() - activeCurve = self.plot.getActiveCurve(just_legend=False) + plot = self.getPlotWidget() + if plot is None: + activeCurve = None + else: + activeCurve = plot.getActiveCurve(just_legend=False) + if activeCurve is None: xproc = None yproc = None @@ -640,6 +658,11 @@ class CurvesROIWidget(qt.QWidget): return if self.currentROI not in roiDict: return + + plot = self.getPlotWidget() + if plot is None: + return + x = ddict['x'] if label == 'ROI min': @@ -647,36 +670,36 @@ class CurvesROIWidget(qt.QWidget): if self._middleROIMarkerFlag: pos = 0.5 * (roiDict[self.currentROI]['to'] + roiDict[self.currentROI]['from']) - self.plot.addXMarker(pos, - legend='ROI middle', - text='', - color='yellow', - draggable=True) + plot.addXMarker(pos, + legend='ROI middle', + text='', + color='yellow', + draggable=True) elif label == 'ROI max': roiDict[self.currentROI]['to'] = x if self._middleROIMarkerFlag: pos = 0.5 * (roiDict[self.currentROI]['to'] + roiDict[self.currentROI]['from']) - self.plot.addXMarker(pos, - legend='ROI middle', - text='', - color='yellow', - draggable=True) + plot.addXMarker(pos, + legend='ROI middle', + text='', + color='yellow', + draggable=True) elif label == 'ROI middle': delta = x - 0.5 * (roiDict[self.currentROI]['from'] + roiDict[self.currentROI]['to']) roiDict[self.currentROI]['from'] += delta roiDict[self.currentROI]['to'] += delta - self.plot.addXMarker(roiDict[self.currentROI]['from'], - legend='ROI min', - text='ROI min', - color='blue', - draggable=True) - self.plot.addXMarker(roiDict[self.currentROI]['to'], - legend='ROI max', - text='ROI max', - color='blue', - draggable=True) + plot.addXMarker(roiDict[self.currentROI]['from'], + legend='ROI min', + text='ROI min', + color='blue', + draggable=True) + plot.addXMarker(roiDict[self.currentROI]['to'], + legend='ROI max', + text='ROI max', + color='blue', + draggable=True) else: return self.calculateRois(roiList, roiDict) @@ -687,32 +710,39 @@ class CurvesROIWidget(qt.QWidget): It is connected to plot signals only when visible. """ + plot = self.getPlotWidget() + if visible: if not self._isInit: # Deferred ROI widget init finalization - self._isInit = True - self.sigROIWidgetSignal.connect(self._roiSignal) - # initialize with the ICR - self._roiSignal({'event': "AddROI"}) - - if not self._isConnected: - self.plot.sigPlotSignal.connect(self._handleROIMarkerEvent) - self.plot.sigActiveCurveChanged.connect( + self._finalizeInit() + + if not self._isConnected and plot is not None: + plot.sigPlotSignal.connect(self._handleROIMarkerEvent) + plot.sigActiveCurveChanged.connect( self._activeCurveChanged) self._isConnected = True self.calculateRois() else: if self._isConnected: - self.plot.sigPlotSignal.disconnect(self._handleROIMarkerEvent) - self.plot.sigActiveCurveChanged.disconnect( - self._activeCurveChanged) + if plot is not None: + plot.sigPlotSignal.disconnect(self._handleROIMarkerEvent) + plot.sigActiveCurveChanged.disconnect( + self._activeCurveChanged) self._isConnected = False def _activeCurveChanged(self, *args): """Recompute ROIs when active curve changed.""" self.calculateRois() + def _finalizeInit(self): + self._isInit = True + self.sigROIWidgetSignal.connect(self._roiSignal) + # initialize with the ICR if no ROi existing yet + if len(self.getRois()) is 0: + self._roiSignal({'event': "AddROI"}) + class ROITable(qt.QTableWidget): """Table widget displaying ROI information. @@ -977,9 +1007,6 @@ class CurvesROIDockWidget(qt.QDockWidget): def __init__(self, parent=None, plot=None, name=None): super(CurvesROIDockWidget, self).__init__(name, parent) - assert plot is not None - self.plot = plot - self.roiWidget = CurvesROIWidget(self, name, plot=plot) """Main widget of type :class:`CurvesROIWidget`""" diff --git a/silx/gui/plot/ImageView.py b/silx/gui/plot/ImageView.py index 46e56e6..c28ffca 100644 --- a/silx/gui/plot/ImageView.py +++ b/silx/gui/plot/ImageView.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# Copyright (c) 2015-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 @@ -42,18 +42,19 @@ from __future__ import division __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "17/08/2017" +__date__ = "26/04/2018" import logging import numpy +import silx from .. import qt from . import items, PlotWindow, PlotWidget, actions -from .Colormap import Colormap -from .Colors import cursorColorForColormap -from .PlotTools import LimitsToolBar +from ..colors import Colormap +from ..colors import cursorColorForColormap +from .tools import LimitsToolBar from .Profile import ProfileToolBar @@ -296,6 +297,9 @@ class ImageView(PlotWindow): if parent is None: self.setWindowTitle('ImageView') + if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == 'downward': + self.getYAxis().setInverted(True) + self._initWidgets(backend) self.profile = ProfileToolBar(plot=self) @@ -356,7 +360,7 @@ class ImageView(PlotWindow): layout.setSpacing(0) layout.setContentsMargins(0, 0, 0, 0) - centralWidget = qt.QWidget() + centralWidget = qt.QWidget(self) centralWidget.setLayout(layout) self.setCentralWidget(centralWidget) @@ -773,7 +777,7 @@ class ImageView(PlotWindow): legend=self._imageLegend, origin=origin, scale=scale, colormap=self.getColormap(), - replace=False, resetzoom=False) + resetzoom=False) self.setActiveImage(self._imageLegend) self._updateHistograms() @@ -810,17 +814,17 @@ class ImageViewMainWindow(ImageView): self.statusBar() menu = self.menuBar().addMenu('File') - menu.addAction(self.saveAction) - menu.addAction(self.printAction) + menu.addAction(self.getOutputToolBar().getSaveAction()) + menu.addAction(self.getOutputToolBar().getPrintAction()) menu.addSeparator() action = menu.addAction('Quit') action.triggered[bool].connect(qt.QApplication.instance().quit) menu = self.menuBar().addMenu('Edit') - menu.addAction(self.copyAction) + menu.addAction(self.getOutputToolBar().getCopyAction()) menu.addSeparator() - menu.addAction(self.resetZoomAction) - menu.addAction(self.colormapAction) + menu.addAction(self.getResetZoomAction()) + menu.addAction(self.getColormapAction()) menu.addAction(actions.control.KeepAspectRatioAction(self, self)) menu.addAction(actions.control.YAxisInvertedAction(self, self)) diff --git a/silx/gui/plot/MaskToolsWidget.py b/silx/gui/plot/MaskToolsWidget.py index 09c5ca5..797068e 100644 --- a/silx/gui/plot/MaskToolsWidget.py +++ b/silx/gui/plot/MaskToolsWidget.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# Copyright (c) 2017-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 @@ -35,7 +35,7 @@ from __future__ import division __authors__ = ["T. Vincent", "P. Knobel"] __license__ = "MIT" -__date__ = "20/06/2017" +__date__ = "24/04/2018" import os @@ -48,7 +48,7 @@ from silx.image import shapes from ._BaseMaskToolsWidget import BaseMask, BaseMaskToolsWidget, BaseMaskToolsDockWidget from . import items -from .Colors import cursorColorForColormap, rgba +from ..colors import cursorColorForColormap, rgba from .. import qt from silx.third_party.EdfFile import EdfFile @@ -76,6 +76,7 @@ class ImageMask(BaseMask): :param image: :class:`silx.gui.plot.items.ImageBase` instance """ BaseMask.__init__(self, image) + self.reset(shape=(0, 0)) # Init the mask with a 2D shape def getDataValues(self): """Return image data as a 2D or 3D array (if it is a RGBA image). @@ -222,7 +223,8 @@ class MaskToolsWidget(BaseMaskToolsWidget): def setSelectionMask(self, mask, copy=True): """Set the mask to a new array. - :param numpy.ndarray mask: The array to use for the mask. + :param numpy.ndarray mask: + The array to use for the mask or None to reset the mask. :type mask: numpy.ndarray of uint8 of dimension 2, C-contiguous. Array of other types are converted. :param bool copy: True (the default) to copy the array, @@ -231,11 +233,19 @@ class MaskToolsWidget(BaseMaskToolsWidget): The mask can be cropped or padded to fit active image, the returned shape is that of the active image. """ + if mask is None: + self.resetSelectionMask() + return self._data.shape[:2] + mask = numpy.array(mask, copy=False, dtype=numpy.uint8) if len(mask.shape) != 2: _logger.error('Not an image, shape: %d', len(mask.shape)) return None + # if mask has not changed, do nothing + if numpy.array_equal(mask, self.getSelectionMask()): + return mask.shape + # ensure all mask attributes are synchronized with the active image # and connect listener activeImage = self.plot.getActiveImage() @@ -265,7 +275,7 @@ class MaskToolsWidget(BaseMaskToolsWidget): def _updatePlotMask(self): """Update mask image in plot""" mask = self.getSelectionMask(copy=False) - if len(mask): + if mask is not None: # get the mask from the plot maskItem = self.plot.getImage(self._maskName) mustBeAdded = maskItem is None @@ -303,7 +313,7 @@ class MaskToolsWidget(BaseMaskToolsWidget): if not self.browseAction.isChecked(): self.browseAction.trigger() # Disable drawing tool - if len(self.getSelectionMask(copy=False)): + if self.getSelectionMask(copy=False) is not None: self.plot.sigActiveImageChanged.connect( self._activeImageChangedAfterCare) @@ -328,6 +338,13 @@ class MaskToolsWidget(BaseMaskToolsWidget): activeImage = self.plot.getActiveImage() if activeImage is None or activeImage.getLegend() == self._maskName: # No active image or active image is the mask... + self._data = numpy.zeros((0, 0), dtype=numpy.uint8) + self._mask.setDataItem(None) + self._mask.reset() + + if self.plot.getImage(self._maskName): + self.plot.remove(self._maskName, kind='image') + self.plot.sigActiveImageChanged.disconnect( self._activeImageChangedAfterCare) else: @@ -340,7 +357,7 @@ class MaskToolsWidget(BaseMaskToolsWidget): self._scale = activeImage.getScale() self._z = activeImage.getZValue() + 1 self._data = activeImage.getData(copy=False) - if self._data.shape[:2] != self.getSelectionMask(copy=False).shape: + if self._data.shape[:2] != self._mask.getMask(copy=False).shape: # Image has not the same size, remove mask and stop listening if self.plot.getImage(self._maskName): self.plot.remove(self._maskName, kind='image') @@ -378,7 +395,7 @@ class MaskToolsWidget(BaseMaskToolsWidget): self._z = activeImage.getZValue() + 1 self._data = activeImage.getData(copy=False) self._mask.setDataItem(activeImage) - if self._data.shape[:2] != self.getSelectionMask(copy=False).shape: + if self._data.shape[:2] != self._mask.getMask(copy=False).shape: self._mask.reset(self._data.shape[:2]) self._mask.commit() else: @@ -597,7 +614,7 @@ class MaskToolsWidget(BaseMaskToolsWidget): # convert from plot to array coords col, row = (event['points'][-1] - self._origin) / self._scale col, row = int(col), int(row) - brushSize = self.pencilSpinBox.value() + brushSize = self._getPencilWidth() if self._lastPencilPos != (row, col): if self._lastPencilPos is not None: diff --git a/silx/gui/plot/PlotInteraction.py b/silx/gui/plot/PlotInteraction.py index 865073b..356bda6 100644 --- a/silx/gui/plot/PlotInteraction.py +++ b/silx/gui/plot/PlotInteraction.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# Copyright (c) 2014-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 @@ -26,7 +26,7 @@ __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "27/06/2017" +__date__ = "24/04/2018" import math @@ -34,7 +34,8 @@ import numpy import time import weakref -from . import Colors +from .. import colors +from .. import qt from . import items from .Interaction import (ClickOrDrag, LEFT_BTN, RIGHT_BTN, State, StateMachine) @@ -115,11 +116,52 @@ class _ZoomOnWheel(ClickOrDrag, _PlotInteraction): Base class for :class:`Pan` and :class:`Zoom` """ + + _DOUBLE_CLICK_TIMEOUT = 0.4 + class ZoomIdle(ClickOrDrag.Idle): def onWheel(self, x, y, angle): scaleF = 1.1 if angle > 0 else 1. / 1.1 applyZoomToPlot(self.machine.plot, scaleF, (x, y)) + def click(self, x, y, btn): + """Handle clicks by sending events + + :param int x: Mouse X position in pixels + :param int y: Mouse Y position in pixels + :param btn: Clicked mouse button + """ + if btn == LEFT_BTN: + lastClickTime, lastClickPos = self._lastClick + + # Signal mouse double clicked event first + if (time.time() - lastClickTime) <= self._DOUBLE_CLICK_TIMEOUT: + # Use position of first click + eventDict = prepareMouseSignal('mouseDoubleClicked', 'left', + *lastClickPos) + self.plot.notify(**eventDict) + + self._lastClick = 0., None + else: + # Signal mouse clicked event + dataPos = self.plot.pixelToData(x, y) + assert dataPos is not None + eventDict = prepareMouseSignal('mouseClicked', 'left', + dataPos[0], dataPos[1], + x, y) + self.plot.notify(**eventDict) + + self._lastClick = time.time(), (dataPos[0], dataPos[1], x, y) + + elif btn == RIGHT_BTN: + # Signal mouse clicked event + dataPos = self.plot.pixelToData(x, y) + assert dataPos is not None + eventDict = prepareMouseSignal('mouseClicked', 'right', + dataPos[0], dataPos[1], + x, y) + self.plot.notify(**eventDict) + def __init__(self, plot): """Init. @@ -135,6 +177,8 @@ class _ZoomOnWheel(ClickOrDrag, _PlotInteraction): } StateMachine.__init__(self, states, 'idle') + self._lastClick = 0., None + # Pan ######################################################################### @@ -229,11 +273,9 @@ class Zoom(_ZoomOnWheel): Zoom-in on selected area, zoom-out on right click, and zoom on mouse wheel. """ - _DOUBLE_CLICK_TIMEOUT = 0.4 def __init__(self, plot, color): self.color = color - self._lastClick = 0., None super(Zoom, self).__init__(plot) self.plot.getLimitsHistory().clear() @@ -263,38 +305,6 @@ class Zoom(_ZoomOnWheel): return areaX0, areaY0, areaX1, areaY1 - def click(self, x, y, btn): - if btn == LEFT_BTN: - lastClickTime, lastClickPos = self._lastClick - - # Signal mouse double clicked event first - if (time.time() - lastClickTime) <= self._DOUBLE_CLICK_TIMEOUT: - # Use position of first click - eventDict = prepareMouseSignal('mouseDoubleClicked', 'left', - *lastClickPos) - self.plot.notify(**eventDict) - - self._lastClick = 0., None - else: - # Signal mouse clicked event - dataPos = self.plot.pixelToData(x, y) - assert dataPos is not None - eventDict = prepareMouseSignal('mouseClicked', 'left', - dataPos[0], dataPos[1], - x, y) - self.plot.notify(**eventDict) - - self._lastClick = time.time(), (dataPos[0], dataPos[1], x, y) - - elif btn == RIGHT_BTN: - # Signal mouse clicked event - dataPos = self.plot.pixelToData(x, y) - assert dataPos is not None - eventDict = prepareMouseSignal('mouseClicked', 'right', - dataPos[0], dataPos[1], - x, y) - self.plot.notify(**eventDict) - def beginDrag(self, x, y): dataPos = self.plot.pixelToData(x, y) assert dataPos is not None @@ -424,7 +434,7 @@ class SelectPolygon(Select): """Update drawing first point, using self._firstPos""" x, y = self.machine.plot.dataToPixel(*self._firstPos, check=False) - offset = self.machine.DRAG_THRESHOLD_DIST + offset = self.machine.getDragThreshold() points = [(x - offset, y - offset), (x - offset, y + offset), (x + offset, y + offset), @@ -458,10 +468,10 @@ class SelectPolygon(Select): check=False) dx, dy = abs(firstPos[0] - x), abs(firstPos[1] - y) + threshold = self.machine.getDragThreshold() + # Only allow to close polygon after first point - if (len(self.points) > 2 and - dx < self.machine.DRAG_THRESHOLD_DIST and - dy < self.machine.DRAG_THRESHOLD_DIST): + if len(self.points) > 2 and dx <= threshold and dy <= threshold: self.machine.resetSelectionArea() self.points[-1] = self.points[0] @@ -489,8 +499,7 @@ class SelectPolygon(Select): previousPos = self.machine.plot.dataToPixel(*self.points[-2], check=False) dx, dy = abs(previousPos[0] - x), abs(previousPos[1] - y) - if(dx >= self.machine.DRAG_THRESHOLD_DIST or - dy >= self.machine.DRAG_THRESHOLD_DIST): + if dx >= threshold or dy >= threshold: self.points.append(dataPos) else: self.points[-1] = dataPos @@ -502,8 +511,9 @@ class SelectPolygon(Select): firstPos = self.machine.plot.dataToPixel(*self._firstPos, check=False) dx, dy = abs(firstPos[0] - x), abs(firstPos[1] - y) - if (dx < self.machine.DRAG_THRESHOLD_DIST and - dy < self.machine.DRAG_THRESHOLD_DIST): + threshold = self.machine.getDragThreshold() + + if dx <= threshold and dy <= threshold: x, y = firstPos # Snap to first point dataPos = self.machine.plot.pixelToData(x, y) @@ -523,6 +533,17 @@ class SelectPolygon(Select): if isinstance(self.state, self.states['select']): self.resetSelectionArea() + def getDragThreshold(self): + """Return dragging ratio with device to pixel ratio applied. + + :rtype: float + """ + ratio = 1. + if qt.BINDING in ('PyQt5', 'PySide2'): + ratio = self.plot.window().windowHandle().devicePixelRatio() + return self.DRAG_THRESHOLD_DIST * ratio + + class Select2Points(Select): """Base class for drawing selection based on 2 input points.""" @@ -1204,6 +1225,48 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction): self.plot.setGraphCursorShape() +class ItemsInteractionForCombo(ItemsInteraction): + """Interaction with items to combine through :class:`FocusManager`. + """ + + class Idle(ItemsInteraction.Idle): + def onPress(self, x, y, btn): + if btn == LEFT_BTN: + def test(item): + return (item.isSelectable() or + (isinstance(item, items.DraggableMixIn) and + item.isDraggable())) + + picked = self.machine.plot._pickMarker(x, y, test) + if picked is not None: + itemInteraction = True + + else: + picked = self.machine.plot._pickImageOrCurve(x, y, test) + itemInteraction = picked is not None + + if itemInteraction: # Request focus and handle interaction + self.goto('clickOrDrag', x, y) + return True + else: # Do not request focus + return False + + elif btn == RIGHT_BTN: + self.goto('rightClick', x, y) + return True + + def __init__(self, plot): + _PlotInteraction.__init__(self, plot) + + states = { + 'idle': ItemsInteractionForCombo.Idle, + 'rightClick': ClickOrDrag.RightClick, + 'clickOrDrag': ClickOrDrag.ClickOrDrag, + 'drag': ClickOrDrag.Drag + } + StateMachine.__init__(self, states, 'idle') + + # FocusManager ################################################################ class FocusManager(StateMachine): @@ -1344,6 +1407,74 @@ class ZoomAndSelect(ItemsInteraction): return super(ZoomAndSelect, self).endDrag(startPos, endPos) +class PanAndSelect(ItemsInteraction): + """Combine Pan and ItemInteraction state machine. + + :param plot: The Plot to which this interaction is attached + """ + + def __init__(self, plot): + super(PanAndSelect, self).__init__(plot) + self._pan = Pan(plot) + self._doPan = False + + def click(self, x, y, btn): + """Handle mouse click + + :param x: X position of the mouse in pixels + :param y: Y position of the mouse in pixels + :param btn: Pressed button id + :return: True if click is catched by an item, False otherwise + """ + eventDict = self._handleClick(x, y, btn) + + if eventDict is not None: + # Signal mouse clicked event + dataPos = self.plot.pixelToData(x, y) + assert dataPos is not None + clickedEventDict = prepareMouseSignal('mouseClicked', btn, + dataPos[0], dataPos[1], + x, y) + self.plot.notify(**clickedEventDict) + + self.plot.notify(**eventDict) + + else: + self._pan.click(x, y, btn) + + def beginDrag(self, x, y): + """Handle start drag and switching between zoom and item drag. + + :param x: X position in pixels + :param y: Y position in pixels + """ + self._doPan = not super(PanAndSelect, self).beginDrag(x, y) + if self._doPan: + self._pan.beginDrag(x, y) + + def drag(self, x, y): + """Handle drag, eventually forwarding to zoom. + + :param x: X position in pixels + :param y: Y position in pixels + """ + if self._doPan: + return self._pan.drag(x, y) + else: + return super(PanAndSelect, self).drag(x, y) + + def endDrag(self, startPos, endPos): + """Handle end of drag, eventually forwarding to zoom. + + :param startPos: (x, y) position at the beginning of the drag + :param endPos: (x, y) position at the end of the drag + """ + if self._doPan: + return self._pan.endDrag(startPos, endPos) + else: + return super(PanAndSelect, self).endDrag(startPos, endPos) + + # Interaction mode control #################################################### class PlotInteraction(object): @@ -1384,12 +1515,21 @@ class PlotInteraction(object): if isinstance(self._eventHandler, ZoomAndSelect): return {'mode': 'zoom', 'color': self._eventHandler.color} + elif isinstance(self._eventHandler, FocusManager): + drawHandler = self._eventHandler.eventHandlers[1] + if not isinstance(drawHandler, Select): + raise RuntimeError('Unknown interactive mode') + + result = drawHandler.parameters.copy() + result['mode'] = 'draw' + return result + elif isinstance(self._eventHandler, Select): result = self._eventHandler.parameters.copy() result['mode'] = 'draw' return result - elif isinstance(self._eventHandler, Pan): + elif isinstance(self._eventHandler, PanAndSelect): return {'mode': 'pan'} else: @@ -1400,7 +1540,7 @@ class PlotInteraction(object): """Switch the interactive mode. :param str mode: The name of the interactive mode. - In 'draw', 'pan', 'select', 'zoom'. + In 'draw', 'pan', 'select', 'select-draw', 'zoom'. :param color: Only for 'draw' and 'zoom' modes. Color to use for drawing selection area. Default black. If None, selection area is not drawn. @@ -1413,15 +1553,15 @@ class PlotInteraction(object): :param str label: Only for 'draw' mode. :param float width: Width of the pencil. Only for draw pencil mode. """ - assert mode in ('draw', 'pan', 'select', 'zoom') + assert mode in ('draw', 'pan', 'select', 'select-draw', 'zoom') plot = self._plot() assert plot is not None if color not in (None, 'video inverted'): - color = Colors.rgba(color) + color = colors.rgba(color) - if mode == 'draw': + if mode in ('draw', 'select-draw'): assert shape in self._DRAW_MODES eventHandlerClass = self._DRAW_MODES[shape] parameters = { @@ -1430,14 +1570,21 @@ class PlotInteraction(object): 'color': color, 'width': width, } + eventHandler = eventHandlerClass(plot, parameters) self._eventHandler.cancel() - self._eventHandler = eventHandlerClass(plot, parameters) + + if mode == 'draw': + self._eventHandler = eventHandler + + else: # mode == 'select-draw' + self._eventHandler = FocusManager( + (ItemsInteractionForCombo(plot), eventHandler)) elif mode == 'pan': # Ignores color, shape and label self._eventHandler.cancel() - self._eventHandler = Pan(plot) + self._eventHandler = PanAndSelect(plot) elif mode == 'zoom': # Ignores shape and label diff --git a/silx/gui/plot/PlotToolButtons.py b/silx/gui/plot/PlotToolButtons.py index fc5fcf4..e354877 100644 --- a/silx/gui/plot/PlotToolButtons.py +++ b/silx/gui/plot/PlotToolButtons.py @@ -30,6 +30,7 @@ The following QToolButton are available: - :class:`.AspectToolButton` - :class:`.YAxisOriginToolButton` - :class:`.ProfileToolButton` +- :class:`.SymbolToolButton` """ @@ -38,10 +39,15 @@ __license__ = "MIT" __date__ = "27/06/2017" +import functools import logging +import weakref + from .. import icons from .. import qt +from .items import SymbolMixIn + _logger = logging.getLogger(__name__) @@ -52,7 +58,7 @@ class PlotToolButton(qt.QToolButton): def __init__(self, parent=None, plot=None): super(PlotToolButton, self).__init__(parent) - self._plot = None + self._plotRef = None if plot is not None: self.setPlot(plot) @@ -60,7 +66,7 @@ class PlotToolButton(qt.QToolButton): """ Returns the plot connected to the widget. """ - return self._plot + return None if self._plotRef is None else self._plotRef() def setPlot(self, plot): """ @@ -68,13 +74,18 @@ class PlotToolButton(qt.QToolButton): :param plot: :class:`.PlotWidget` instance on which to operate. """ - if self._plot is plot: + previousPlot = self.plot() + + if previousPlot is plot: return - if self._plot is not None: - self._disconnectPlot(self._plot) - self._plot = plot - if self._plot is not None: - self._connectPlot(self._plot) + if previousPlot is not None: + self._disconnectPlot(previousPlot) + + if plot is None: + self._plotRef = None + else: + self._plotRef = weakref.ref(plot) + self._connectPlot(plot) def _connectPlot(self, plot): """ @@ -282,3 +293,71 @@ class ProfileToolButton(PlotToolButton): def computeProfileIn2D(self): self._profileDimensionChanged(2) + + +class SymbolToolButton(PlotToolButton): + """A tool button with a drop-down menu to control symbol size and marker. + + :param parent: See QWidget + :param plot: The `~silx.gui.plot.PlotWidget` to control + """ + + def __init__(self, parent=None, plot=None): + super(SymbolToolButton, self).__init__(parent=parent, plot=plot) + + self.setToolTip('Set symbol size and marker') + self.setIcon(icons.getQIcon('plot-symbols')) + + menu = qt.QMenu(self) + + # Size slider + + slider = qt.QSlider(qt.Qt.Horizontal) + slider.setRange(1, 20) + slider.setValue(SymbolMixIn._DEFAULT_SYMBOL_SIZE) + slider.setTracking(False) + slider.valueChanged.connect(self._sizeChanged) + widgetAction = qt.QWidgetAction(menu) + widgetAction.setDefaultWidget(slider) + menu.addAction(widgetAction) + + menu.addSeparator() + + # Marker actions + + for marker, name in zip(SymbolMixIn.getSupportedSymbols(), + SymbolMixIn.getSupportedSymbolNames()): + action = qt.QAction(name, menu) + action.setCheckable(False) + action.triggered.connect( + functools.partial(self._markerChanged, marker)) + menu.addAction(action) + + self.setMenu(menu) + self.setPopupMode(qt.QToolButton.InstantPopup) + + def _sizeChanged(self, value): + """Manage slider value changed + + :param int value: Marker size + """ + plot = self.plot() + if plot is None: + return + + for item in plot._getItems(withhidden=True): + if isinstance(item, SymbolMixIn): + item.setSymbolSize(value) + + def _markerChanged(self, marker): + """Manage change of marker. + + :param str marker: Letter describing the marker + """ + plot = self.plot() + if plot is None: + return + + for item in plot._getItems(withhidden=True): + if isinstance(item, SymbolMixIn): + item.setSymbol(marker) diff --git a/silx/gui/plot/PlotTools.py b/silx/gui/plot/PlotTools.py index 7fadfd2..5929473 100644 --- a/silx/gui/plot/PlotTools.py +++ b/silx/gui/plot/PlotTools.py @@ -25,288 +25,19 @@ """Set of widgets to associate with a :class:'PlotWidget'. """ -from __future__ import division +from __future__ import absolute_import -__authors__ = ["V.A. Sole", "T. Vincent"] +__authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "16/10/2017" +__date__ = "01/03/2018" -import logging -import numbers -import traceback -import weakref +from ...utils.deprecation import deprecated_warning -import numpy +deprecated_warning(type_='module', + name=__file__, + reason='Plot tools refactoring', + replacement='silx.gui.plot.tools', + since_version='0.8') -from .. import qt -from silx.gui.widgets.FloatEdit import FloatEdit - -_logger = logging.getLogger(__name__) - - -# PositionInfo ################################################################ - -class PositionInfo(qt.QWidget): - """QWidget displaying coords converted from data coords of the mouse. - - Provide this widget with a list of couple: - - - A name to display before the data - - A function that takes (x, y) as arguments and returns something that - gets converted to a string. - If the result is a float it is converted with '%.7g' format. - - To run the following sample code, a QApplication must be initialized. - First, create a PlotWindow and add a QToolBar where to place the - PositionInfo widget. - - >>> from silx.gui.plot import PlotWindow - >>> from silx.gui import qt - - >>> plot = PlotWindow() # Create a PlotWindow to add the widget to - >>> toolBar = qt.QToolBar() # Create a toolbar to place the widget in - >>> plot.addToolBar(qt.Qt.BottomToolBarArea, toolBar) # Add it to plot - - Then, create the PositionInfo widget and add it to the toolbar. - The PositionInfo widget is created with a list of converters, here - to display polar coordinates of the mouse position. - - >>> import numpy - >>> from silx.gui.plot.PlotTools import PositionInfo - - >>> position = PositionInfo(plot=plot, converters=[ - ... ('Radius', lambda x, y: numpy.sqrt(x*x + y*y)), - ... ('Angle', lambda x, y: numpy.degrees(numpy.arctan2(y, x)))]) - >>> toolBar.addWidget(position) # Add the widget to the toolbar - <...> - >>> plot.show() # To display the PlotWindow with the position widget - - :param plot: The PlotWidget this widget is displaying data coords from. - :param converters: - List of 2-tuple: name to display and conversion function from (x, y) - in data coords to displayed value. - If None, the default, it displays X and Y. - :param parent: Parent widget - """ - - def __init__(self, parent=None, plot=None, converters=None): - assert plot is not None - self._plotRef = weakref.ref(plot) - - super(PositionInfo, self).__init__(parent) - - if converters is None: - converters = (('X', lambda x, y: x), ('Y', lambda x, y: y)) - - self.autoSnapToActiveCurve = False - """Toggle snapping use position to active curve. - - - True to snap used coordinates to the active curve if the active curve - is displayed with symbols and mouse is close enough. - If the mouse is not close to a point of the curve, values are - displayed in red. - - False (the default) to always use mouse coordinates. - - """ - - self._fields = [] # To store (QLineEdit, name, function (x, y)->v) - - # Create a new layout with new widgets - layout = qt.QHBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - # layout.setSpacing(0) - - # Create all QLabel and store them with the corresponding converter - for name, func in converters: - layout.addWidget(qt.QLabel('<b>' + name + ':</b>')) - - contentWidget = qt.QLabel() - contentWidget.setText('------') - contentWidget.setTextInteractionFlags(qt.Qt.TextSelectableByMouse) - contentWidget.setFixedWidth( - contentWidget.fontMetrics().width('##############')) - layout.addWidget(contentWidget) - self._fields.append((contentWidget, name, func)) - - layout.addStretch(1) - self.setLayout(layout) - - # Connect to Plot events - plot.sigPlotSignal.connect(self._plotEvent) - - @property - def plot(self): - """The :class:`.PlotWindow` this widget is attached to.""" - return self._plotRef() - - def getConverters(self): - """Return the list of converters as 2-tuple (name, function).""" - return [(name, func) for _label, name, func in self._fields] - - def _plotEvent(self, event): - """Handle events from the Plot. - - :param dict event: Plot event - """ - if event['event'] == 'mouseMoved': - x, y = event['x'], event['y'] - xPixel, yPixel = event['xpixel'], event['ypixel'] - self._updateStatusBar(x, y, xPixel, yPixel) - - def _updateStatusBar(self, x, y, xPixel, yPixel): - """Update information from the status bar using the definitions. - - :param float x: Position-x in data - :param float y: Position-y in data - :param float xPixel: Position-x in pixels - :param float yPixel: Position-y in pixels - """ - styleSheet = "color: rgb(0, 0, 0);" # Default style - - if self.autoSnapToActiveCurve and self.plot.getGraphCursor(): - # Check if near active curve with symbols. - - styleSheet = "color: rgb(255, 0, 0);" # Style far from curve - - activeCurve = self.plot.getActiveCurve() - if activeCurve: - xData = activeCurve.getXData(copy=False) - yData = activeCurve.getYData(copy=False) - if activeCurve.getSymbol(): # Only handled if symbols on curve - closestIndex = numpy.argmin( - pow(xData - x, 2) + pow(yData - y, 2)) - - xClosest = xData[closestIndex] - yClosest = yData[closestIndex] - - closestInPixels = self.plot.dataToPixel( - xClosest, yClosest, axis=activeCurve.getYAxis()) - if closestInPixels is not None: - if (abs(closestInPixels[0] - xPixel) < 5 and - abs(closestInPixels[1] - yPixel) < 5): - # Update label style sheet - styleSheet = "color: rgb(0, 0, 0);" - - # if close enough, wrap to data point coords - x, y = xClosest, yClosest - - for label, name, func in self._fields: - label.setStyleSheet(styleSheet) - - try: - value = func(x, y) - text = self.valueToString(value) - label.setText(text) - except: - label.setText('Error') - _logger.error( - "Error while converting coordinates (%f, %f)" - "with converter '%s'" % (x, y, name)) - _logger.error(traceback.format_exc()) - - def valueToString(self, value): - if isinstance(value, (tuple, list)): - value = [self.valueToString(v) for v in value] - return ", ".join(value) - elif isinstance(value, numbers.Real): - # Use this for floats and int - return '%.7g' % value - else: - # Fallback for other types - return str(value) - -# LimitsToolBar ############################################################## - -class LimitsToolBar(qt.QToolBar): - """QToolBar displaying and controlling the limits of a :class:`PlotWidget`. - - To run the following sample code, a QApplication must be initialized. - First, create a PlotWindow: - - >>> from silx.gui.plot import PlotWindow - >>> plot = PlotWindow() # Create a PlotWindow to add the toolbar to - - Then, create the LimitsToolBar and add it to the PlotWindow. - - >>> from silx.gui import qt - >>> from silx.gui.plot.PlotTools import LimitsToolBar - - >>> toolbar = LimitsToolBar(plot=plot) # Create the toolbar - >>> plot.addToolBar(qt.Qt.BottomToolBarArea, toolbar) # Add it to the plot - >>> plot.show() # To display the PlotWindow with the limits toolbar - - :param parent: See :class:`QToolBar`. - :param plot: :class:`PlotWidget` instance on which to operate. - :param str title: See :class:`QToolBar`. - """ - - def __init__(self, parent=None, plot=None, title='Limits'): - super(LimitsToolBar, self).__init__(title, parent) - assert plot is not None - self._plot = plot - self._plot.sigPlotSignal.connect(self._plotWidgetSlot) - - self._initWidgets() - - @property - def plot(self): - """The :class:`PlotWidget` the toolbar is attached to.""" - return self._plot - - def _initWidgets(self): - """Create and init Toolbar widgets.""" - xMin, xMax = self.plot.getXAxis().getLimits() - yMin, yMax = self.plot.getYAxis().getLimits() - - self.addWidget(qt.QLabel('Limits: ')) - self.addWidget(qt.QLabel(' X: ')) - self._xMinFloatEdit = FloatEdit(self, xMin) - self._xMinFloatEdit.editingFinished[()].connect( - self._xFloatEditChanged) - self.addWidget(self._xMinFloatEdit) - - self._xMaxFloatEdit = FloatEdit(self, xMax) - self._xMaxFloatEdit.editingFinished[()].connect( - self._xFloatEditChanged) - self.addWidget(self._xMaxFloatEdit) - - self.addWidget(qt.QLabel(' Y: ')) - self._yMinFloatEdit = FloatEdit(self, yMin) - self._yMinFloatEdit.editingFinished[()].connect( - self._yFloatEditChanged) - self.addWidget(self._yMinFloatEdit) - - self._yMaxFloatEdit = FloatEdit(self, yMax) - self._yMaxFloatEdit.editingFinished[()].connect( - self._yFloatEditChanged) - self.addWidget(self._yMaxFloatEdit) - - def _plotWidgetSlot(self, event): - """Listen to :class:`PlotWidget` events.""" - if event['event'] not in ('limitsChanged',): - return - - xMin, xMax = self.plot.getXAxis().getLimits() - yMin, yMax = self.plot.getYAxis().getLimits() - - self._xMinFloatEdit.setValue(xMin) - self._xMaxFloatEdit.setValue(xMax) - self._yMinFloatEdit.setValue(yMin) - self._yMaxFloatEdit.setValue(yMax) - - def _xFloatEditChanged(self): - """Handle X limits changed from the GUI.""" - xMin, xMax = self._xMinFloatEdit.value(), self._xMaxFloatEdit.value() - if xMax < xMin: - xMin, xMax = xMax, xMin - - self.plot.getXAxis().setLimits(xMin, xMax) - - def _yFloatEditChanged(self): - """Handle Y limits changed from the GUI.""" - yMin, yMax = self._yMinFloatEdit.value(), self._yMaxFloatEdit.value() - if yMax < yMin: - yMin, yMax = yMax, yMin - - self.plot.getYAxis().setLimits(yMin, yMax) +from .tools import PositionInfo, LimitsToolBar # noqa diff --git a/silx/gui/plot/PlotWidget.py b/silx/gui/plot/PlotWidget.py index 3641b8c..2f7132c 100644 --- a/silx/gui/plot/PlotWidget.py +++ b/silx/gui/plot/PlotWidget.py @@ -31,37 +31,43 @@ from __future__ import division __authors__ = ["V.A. Sole", "T. Vincent"] __license__ = "MIT" -__date__ = "18/10/2017" +__date__ = "14/06/2018" from collections import OrderedDict, namedtuple from contextlib import contextmanager +import datetime as dt import itertools import logging import numpy +import silx +from silx.utils.weakref import WeakMethodProxy +from silx.utils import deprecation +from silx.utils.property import classproperty from silx.utils.deprecation import deprecated # Import matplotlib backend here to init matplotlib our way from .backends.BackendMatplotlib import BackendMatplotlibQt -from .Colormap import Colormap -from . import Colors +from ..colors import Colormap +from .. import colors from . import PlotInteraction from . import PlotEvents from .LimitsHistory import LimitsHistory from . import _utils from . import items +from .items.axis import TickMode from .. import qt from ._utils.panzoom import ViewConstraints - +from ...gui.plot._utils.dtime_ticklayout import timestamp _logger = logging.getLogger(__name__) -_COLORDICT = Colors.COLORDICT +_COLORDICT = colors.COLORDICT _COLORLIST = [_COLORDICT['black'], _COLORDICT['blue'], _COLORDICT['red'], @@ -110,8 +116,12 @@ class PlotWidget(qt.QMainWindow): :type backend: str or :class:`BackendBase.BackendBase` """ - DEFAULT_BACKEND = 'matplotlib' - """Class attribute setting the default backend for all instances.""" + # TODO: Can be removed for silx 0.10 + @classproperty + @deprecation.deprecated(replacement="silx.config.DEFAULT_PLOT_BACKEND", since_version="0.8", skip_backtrace_count=2) + def DEFAULT_BACKEND(self): + """Class attribute setting the default backend for all instances.""" + return silx.config.DEFAULT_PLOT_BACKEND colorList = _COLORLIST colorDict = _COLORDICT @@ -209,7 +219,7 @@ class PlotWidget(qt.QMainWindow): self.setWindowTitle('PlotWidget') if backend is None: - backend = self.DEFAULT_BACKEND + backend = silx.config.DEFAULT_PLOT_BACKEND if hasattr(backend, "__call__"): self._backend = backend(self, parent) @@ -296,7 +306,9 @@ class PlotWidget(qt.QMainWindow): self.setGraphYLimits(0., 100., axis='right') self.setGraphYLimits(0., 100., axis='left') + # TODO: Can be removed for silx 0.10 @staticmethod + @deprecation.deprecated(replacement="silx.config.DEFAULT_PLOT_BACKEND", since_version="0.8", skip_backtrace_count=2) def setDefaultBackend(backend): """Set system wide default plot backend. @@ -306,7 +318,7 @@ class PlotWidget(qt.QMainWindow): 'matplotlib' (default), 'mpl', 'opengl', 'gl', 'none' or a :class:`BackendBase.BackendBase` class """ - PlotWidget.DEFAULT_BACKEND = backend + silx.config.DEFAULT_PLOT_BACKEND = backend def _getDirtyPlot(self): """Return the plot dirty flag. @@ -525,7 +537,9 @@ class PlotWidget(qt.QMainWindow): :param numpy.ndarray x: The data corresponding to the x coordinates. If you attempt to plot an histogram you can set edges values in x. - In this case len(x) = len(y) + 1 + In this case len(x) = len(y) + 1. + If x contains datetime objects the XAxis tickMode is set to + TickMode.TIME_SERIES. :param numpy.ndarray y: The data corresponding to the y coordinates :param str legend: The legend to be associated to the curve (or None) :param info: User-defined information associated to the curve @@ -533,7 +547,7 @@ class PlotWidget(qt.QMainWindow): curves :param color: color(s) to be used :type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or - one of the predefined color names defined in Colors.py + one of the predefined color names defined in colors.py :param str symbol: Symbol to be drawn at each (x, y) position:: - 'o' circle @@ -686,6 +700,13 @@ class PlotWidget(qt.QMainWindow): if yerror is None: yerror = curve.getYErrorData(copy=False) + # Convert x to timestamps so that the internal representation + # remains floating points. The user is expected to set the axis' + # tickMode to TickMode.TIME_SERIES and, if necessary, set the axis + # to the correct time zone. + if len(x) > 0 and isinstance(x[0], dt.datetime): + x = [timestamp(d) for d in x] + curve.setData(x, y, xerror, yerror, copy=copy) if replace: # Then remove all other curves @@ -739,7 +760,7 @@ class PlotWidget(qt.QMainWindow): The legend to be associated to the histogram (or None) :param color: color to be used :type color: str ("#RRGGBB") or RGB unsigned byte array or - one of the predefined color names defined in Colors.py + one of the predefined color names defined in colors.py :param bool fill: True to fill the curve, False otherwise (default). :param str align: In case histogram values and edges have the same length N, @@ -785,7 +806,7 @@ class PlotWidget(qt.QMainWindow): return legend def addImage(self, data, legend=None, info=None, - replace=True, replot=None, + replace=False, replot=None, xScale=None, yScale=None, z=None, selectable=None, draggable=None, colormap=None, pixmap=None, @@ -811,7 +832,8 @@ class PlotWidget(qt.QMainWindow): Note: boolean values are converted to int8. :param str legend: The legend to be associated to the image (or None) :param info: User-defined information associated to the image - :param bool replace: True (default) to delete already existing images + :param bool replace: + True to delete already existing images (Default: False). :param int z: Layer on which to draw the image (default: 0) This allows to control the overlay. :param bool selectable: Indicate if the image can be selected. @@ -821,7 +843,7 @@ class PlotWidget(qt.QMainWindow): :param colormap: Description of the :class:`.Colormap` to use (or None). This is ignored if data is a RGB(A) image. - :type colormap: Union[silx.gui.plot.Colormap.Colormap, dict] + :type colormap: Union[silx.gui.colors.Colormap, dict] :param pixmap: Pixmap representation of the data (if any) :type pixmap: (nrows, ncolumns, RGBA) ubyte array or None (default) :param str xlabel: X axis label to show when this curve is active, @@ -964,7 +986,7 @@ class PlotWidget(qt.QMainWindow): :param numpy.ndarray y: The data corresponding to the y coordinates :param numpy.ndarray value: The data value associated with each point :param str legend: The legend to be associated to the scatter (or None) - :param silx.gui.plot.Colormap.Colormap colormap: + :param silx.gui.colors.Colormap colormap: The :class:`.Colormap`. to be used for the scatter (or None) :param info: User-defined information associated to the curve :param str symbol: Symbol to be drawn at each (x, y) position:: @@ -1477,7 +1499,7 @@ class PlotWidget(qt.QMainWindow): :param bool flag: Toggle the display of a crosshair cursor. The crosshair cursor is hidden by default. :param color: The color to use for the crosshair. - :type color: A string (either a predefined color name in Colors.py + :type color: A string (either a predefined color name in colors.py or "#RRGGBB")) or a 4 columns unsigned byte array (Default: black). :param int linewidth: The width of the lines of the crosshair @@ -2264,13 +2286,13 @@ class PlotWidget(qt.QMainWindow): It only affects future calls to :meth:`addImage` without the colormap parameter. - :param silx.gui.plot.Colormap.Colormap colormap: + :param silx.gui.colors.Colormap colormap: The description of the default colormap, or None to set the :class:`.Colormap` to a linear autoscale gray colormap. """ if colormap is None: - colormap = Colormap(name='gray', + colormap = Colormap(name=silx.config.DEFAULT_COLORMAP_NAME, normalization='linear', vmin=None, vmax=None) @@ -2370,10 +2392,10 @@ class PlotWidget(qt.QMainWindow): to handle the graph events If None (default), use a default listener. """ - # TODO allow multiple listeners, keep a weakref on it + # TODO allow multiple listeners # allow register listener by event type if callbackFunction is None: - callbackFunction = self.graphCallback + callbackFunction = WeakMethodProxy(self.graphCallback) self._callback = callbackFunction def graphCallback(self, ddict=None): @@ -2392,6 +2414,8 @@ class PlotWidget(qt.QMainWindow): if ddict['button'] == "left": self.setActiveCurve(ddict['label']) qt.QToolTip.showText(self.cursor().pos(), ddict['label']) + elif ddict['event'] == 'mouseClicked' and ddict['button'] == 'left': + self.setActiveCurve(None) def saveGraph(self, filename, fileFormat=None, dpi=None, **kw): """Save a snapshot of the plot. @@ -2519,9 +2543,8 @@ class PlotWidget(qt.QMainWindow): # Compute bbox wth figure aspect ratio plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] - plotRatio = plotHeight / plotWidth - - if plotRatio > 0.: + if plotWidth > 0 and plotHeight > 0: + plotRatio = plotHeight / plotWidth dataRatio = (ymax - ymin) / (xmax - xmin) if dataRatio < plotRatio: # Increase y range @@ -2741,6 +2764,39 @@ class PlotWidget(qt.QMainWindow): return None + def _pick(self, x, y): + """Pick items in the plot at given position. + + :param float x: X position in pixels + :param float y: Y position in pixels + :return: Iterable of (plot item, indices) at picked position. + Items are ordered from back to front. + """ + items = [] + + # Convert backend result to plot items + for itemInfo in self._backend.pickItems( + x, y, kinds=('marker', 'curve', 'image')): + kind, legend = itemInfo['kind'], itemInfo['legend'] + + if kind in ('marker', 'image'): + item = self._getItem(kind=kind, legend=legend) + indices = None # TODO compute indices for images + + else: # backend kind == 'curve' + for kind in ('curve', 'histogram', 'scatter'): + item = self._getItem(kind=kind, legend=legend) + if item is not None: + indices = itemInfo['indices'] + break + else: + _logger.error( + 'Cannot find corresponding picked item') + continue + items.append((item, indices)) + + return tuple(items) + # User event handling # def _isPositionInPlotArea(self, x, y): @@ -2846,7 +2902,7 @@ class PlotWidget(qt.QMainWindow): """Switch the interactive mode. :param str mode: The name of the interactive mode. - In 'draw', 'pan', 'select', 'zoom'. + In 'draw', 'pan', 'select', 'select-draw', 'zoom'. :param color: Only for 'draw' and 'zoom' modes. Color to use for drawing selection area. Default black. :type color: Color description: The name as a str or @@ -2959,7 +3015,7 @@ class PlotWidget(qt.QMainWindow): :param str label: Associated text for identifying draw signals :param color: The color to use to draw the selection area :type color: string ("#RRGGBB") or 4 column unsigned byte array or - one of the predefined color names defined in Colors.py + one of the predefined color names defined in colors.py """ _logger.warning( 'setDrawModeEnabled deprecated, use setInteractiveMode instead') @@ -3011,7 +3067,7 @@ class PlotWidget(qt.QMainWindow): (Default: 'black') :param color: The color to use to draw the selection area :type color: string ("#RRGGBB") or 4 column unsigned byte array or - one of the predefined color names defined in Colors.py + one of the predefined color names defined in colors.py """ _logger.warning( 'setZoomModeEnabled deprecated, use setInteractiveMode instead') diff --git a/silx/gui/plot/PlotWindow.py b/silx/gui/plot/PlotWindow.py index 5c7e661..459ffdc 100644 --- a/silx/gui/plot/PlotWindow.py +++ b/silx/gui/plot/PlotWindow.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2017 European Synchrotron Radiation Facility +# 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 @@ -29,11 +29,14 @@ The :class:`PlotWindow` is a subclass of :class:`.PlotWidget`. __authors__ = ["V.A. Sole", "T. Vincent"] __license__ = "MIT" -__date__ = "15/02/2018" +__date__ = "05/06/2018" import collections import logging +import weakref +import silx +from silx.utils.weakref import WeakMethodProxy from silx.utils.deprecation import deprecated from . import PlotWidget @@ -44,11 +47,12 @@ from .actions import fit as actions_fit from .actions import control as actions_control from .actions import histogram as actions_histogram from . import PlotToolButtons -from .PlotTools import PositionInfo +from . import tools from .Profile import ProfileToolBar from .LegendSelector import LegendsDockWidget from .CurvesROIWidget import CurvesROIDockWidget from .MaskToolsWidget import MaskToolsDockWidget +from .StatsWidget import BasicStatsWidget from .ColorBar import ColorBarWidget try: from ..console import IPythonDockWidget @@ -90,7 +94,7 @@ class PlotWindow(PlotWidget): (Default: False). It also supports a list of (name, funct(x, y)->value) to customize the displayed values. - See :class:`silx.gui.plot.PlotTools.PositionInfo`. + See :class:`~silx.gui.plot.tools.PositionInfo`. :param bool roi: Toggle visibilty of ROI action. :param bool mask: Toggle visibilty of mask action. :param bool fit: Toggle visibilty of fit action. @@ -114,6 +118,7 @@ class PlotWindow(PlotWidget): self._curvesROIDockWidget = None self._maskToolsDockWidget = None self._consoleDockWidget = None + self._statsWidget = None # Create color bar, hidden by default for backward compatibility self._colorbar = ColorBarWidget(parent=self, plot=self) @@ -122,11 +127,6 @@ class PlotWindow(PlotWidget): self.group = qt.QActionGroup(self) self.group.setExclusive(False) - self.zoomModeAction = self.group.addAction( - actions.mode.ZoomModeAction(self)) - self.panModeAction = self.group.addAction( - actions.mode.PanModeAction(self)) - self.resetZoomAction = self.group.addAction( actions.control.ResetZoomAction(self)) self.resetZoomAction.setVisible(resetzoom) @@ -205,28 +205,13 @@ class PlotWindow(PlotWidget): actions_medfilt.MedianFilter1DAction(self)) self._medianFilter1DAction.setVisible(False) - self._separator = qt.QAction('separator', self) - self._separator.setSeparator(True) - self.group.addAction(self._separator) - - self.copyAction = self.group.addAction(actions.io.CopyAction(self)) - self.copyAction.setVisible(copy) - self.addAction(self.copyAction) - - self.saveAction = self.group.addAction(actions.io.SaveAction(self)) - self.saveAction.setVisible(save) - self.addAction(self.saveAction) - - self.printAction = self.group.addAction(actions.io.PrintAction(self)) - self.printAction.setVisible(print_) - self.addAction(self.printAction) - self.fitAction = self.group.addAction(actions_fit.FitAction(self)) self.fitAction.setVisible(fit) self.addAction(self.fitAction) # lazy loaded actions needed by the controlButton menu self._consoleAction = None + self._statsAction = None self._panWithArrowKeysAction = None self._crosshairAction = None @@ -244,10 +229,12 @@ class PlotWindow(PlotWidget): gridLayout.addWidget(self._colorbar, 0, 1) gridLayout.setRowStretch(0, 1) gridLayout.setColumnStretch(0, 1) - centralWidget = qt.QWidget() + centralWidget = qt.QWidget(self) centralWidget.setLayout(gridLayout) self.setCentralWidget(centralWidget) + self._positionWidget = None + if control or position: hbox = qt.QHBoxLayout() hbox.setContentsMargins(0, 0, 0, 0) @@ -270,22 +257,69 @@ class PlotWindow(PlotWidget): converters = position else: converters = None - self.positionWidget = PositionInfo( + self._positionWidget = tools.PositionInfo( plot=self, converters=converters) - self.positionWidget.autoSnapToActiveCurve = True + # Set a snapping mode that is consistent with legacy one + self._positionWidget.setSnappingMode( + tools.PositionInfo.SNAPPING_CROSSHAIR | + tools.PositionInfo.SNAPPING_ACTIVE_ONLY | + tools.PositionInfo.SNAPPING_SYMBOLS_ONLY | + tools.PositionInfo.SNAPPING_CURVE | + tools.PositionInfo.SNAPPING_SCATTER) - hbox.addWidget(self.positionWidget) + hbox.addWidget(self._positionWidget) hbox.addStretch(1) - bottomBar = qt.QWidget() + bottomBar = qt.QWidget(centralWidget) bottomBar.setLayout(hbox) gridLayout.addWidget(bottomBar, 1, 0, 1, -1) # Creating the toolbar also create actions for toolbuttons + self._interactiveModeToolBar = tools.InteractiveModeToolBar( + parent=self, plot=self) + self.addToolBar(self._interactiveModeToolBar) + self._toolbar = self._createToolBar(title='Plot', parent=None) self.addToolBar(self._toolbar) + self._outputToolBar = tools.OutputToolBar(parent=self, plot=self) + self._outputToolBar.getCopyAction().setVisible(copy) + self._outputToolBar.getSaveAction().setVisible(save) + self._outputToolBar.getPrintAction().setVisible(print_) + self.addToolBar(self._outputToolBar) + + # Activate shortcuts in PlotWindow widget: + for toolbar in (self._interactiveModeToolBar, self._outputToolBar): + for action in toolbar.actions(): + self.addAction(action) + + def getInteractiveModeToolBar(self): + """Returns QToolBar controlling interactive mode. + + :rtype: QToolBar + """ + return self._interactiveModeToolBar + + def getOutputToolBar(self): + """Returns QToolBar containing save, copy and print actions + + :rtype: QToolBar + """ + return self._outputToolBar + + @property + @deprecated(replacement="getPositionInfoWidget()", since_version="0.8.0") + def positionWidget(self): + return self.getPositionInfoWidget() + + def getPositionInfoWidget(self): + """Returns the widget displaying current cursor position information + + :rtype: ~silx.gui.plot.tools.PositionInfo + """ + return self._positionWidget + def getSelectionMask(self): """Return the current mask handled by :attr:`maskToolsDockWidget`. @@ -313,7 +347,7 @@ class PlotWindow(PlotWidget): show it or hide it.""" # create widget if needed (first call) if self._consoleDockWidget is None: - available_vars = {"plt": self} + available_vars = {"plt": weakref.proxy(self)} banner = "The variable 'plt' is available. Use the 'whos' " banner += "and 'help(plt)' commands for more information.\n\n" self._consoleDockWidget = IPythonDockWidget( @@ -327,6 +361,9 @@ class PlotWindow(PlotWidget): self._consoleDockWidget.setVisible(isChecked) + def _toggleStatsVisibility(self, isChecked=False): + self.getStatsWidget().parent().setVisible(isChecked) + def _createToolBar(self, title, parent): """Create a QToolBar from the QAction of the PlotWindow. @@ -355,8 +392,6 @@ class PlotWindow(PlotWidget): self.yAxisInvertedAction = toolbar.addWidget(obj) else: raise RuntimeError() - if obj is self.panModeAction: - toolbar.addSeparator() return toolbar def toolBar(self): @@ -381,6 +416,7 @@ class PlotWindow(PlotWidget): controlMenu.clear() controlMenu.addAction(self.getLegendsDockWidget().toggleViewAction()) controlMenu.addAction(self.getRoiAction()) + controlMenu.addAction(self.getStatsAction()) controlMenu.addAction(self.getMaskAction()) controlMenu.addAction(self.getConsoleAction()) @@ -474,8 +510,35 @@ class PlotWindow(PlotWidget): self.addTabbedDockWidget(self._maskToolsDockWidget) return self._maskToolsDockWidget + def getStatsWidget(self): + """Returns a BasicStatsWidget connected to this plot + + :rtype: BasicStatsWidget + """ + if self._statsWidget is None: + dockWidget = qt.QDockWidget(parent=self) + dockWidget.setWindowTitle("Curves stats") + dockWidget.layout().setContentsMargins(0, 0, 0, 0) + self._statsWidget = BasicStatsWidget(parent=self, plot=self) + dockWidget.setWidget(self._statsWidget) + dockWidget.hide() + self.addTabbedDockWidget(dockWidget) + return self._statsWidget + # getters for actions @property + @deprecated(replacement="getInteractiveModeToolBar().getZoomModeAction()", + since_version="0.8.0") + def zoomModeAction(self): + return self.getInteractiveModeToolBar().getZoomModeAction() + + @property + @deprecated(replacement="getInteractiveModeToolBar().getPanModeAction()", + since_version="0.8.0") + def panModeAction(self): + return self.getInteractiveModeToolBar().getPanModeAction() + + @property @deprecated(replacement="getConsoleAction()", since_version="0.4.0") def consoleAction(self): return self.getConsoleAction() @@ -545,6 +608,14 @@ class PlotWindow(PlotWidget): def roiAction(self): return self.getRoiAction() + def getStatsAction(self): + if self._statsAction is None: + self._statsAction = qt.QAction('Curves stats', self) + self._statsAction.setCheckable(True) + self._statsAction.setChecked(self.getStatsWidget().parent().isVisible()) + self._statsAction.toggled.connect(self._toggleStatsVisibility) + return self._statsAction + def getRoiAction(self): """QAction toggling curve ROI dock widget @@ -667,21 +738,21 @@ class PlotWindow(PlotWidget): :rtype: actions.PlotAction """ - return self.copyAction + return self.getOutputToolBar().getCopyAction() def getSaveAction(self): """Action to save plot :rtype: actions.PlotAction """ - return self.saveAction + return self.getOutputToolBar().getSaveAction() def getPrintAction(self): """Action to print plot :rtype: actions.PlotAction """ - return self.printAction + return self.getOutputToolBar().getPrintAction() def getFitAction(self): """Action to fit selected curve @@ -757,7 +828,7 @@ class Plot2D(PlotWindow): posInfo = [ ('X', lambda x, y: x), ('Y', lambda x, y: y), - ('Data', self._getImageValue)] + ('Data', WeakMethodProxy(self._getImageValue))] super(Plot2D, self).__init__(parent=parent, backend=backend, resetzoom=True, autoScale=False, @@ -772,6 +843,9 @@ class Plot2D(PlotWindow): self.getXAxis().setLabel('Columns') self.getYAxis().setLabel('Rows') + if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == 'downward': + self.getYAxis().setInverted(True) + self.profile = ProfileToolBar(plot=self) self.addToolBar(self.profile) @@ -780,10 +854,41 @@ class Plot2D(PlotWindow): # Put colorbar action after colormap action actions = self.toolBar().actions() - for index, action in enumerate(actions): + for action in actions: if action is self.getColormapAction(): break + self.sigActiveImageChanged.connect(self.__activeImageChanged) + + def __activeImageChanged(self, previous, legend): + """Handle change of active image + + :param Union[str,None] previous: Legend of previous active image + :param Union[str,None] legend: Legend of current active image + """ + if previous is not None: + item = self.getImage(previous) + if item is not None: + item.sigItemChanged.disconnect(self.__imageChanged) + + if legend is not None: + item = self.getImage(legend) + item.sigItemChanged.connect(self.__imageChanged) + + positionInfo = self.getPositionInfoWidget() + if positionInfo is not None: + positionInfo.updateInfo() + + def __imageChanged(self, event): + """Handle update of active image item + + :param event: Type of changed event + """ + if event == items.ItemChangedType.DATA: + positionInfo = self.getPositionInfoWidget() + if positionInfo is not None: + positionInfo.updateInfo() + def _getImageValue(self, x, y): """Get status bar value of top most image at position (x, y) diff --git a/silx/gui/plot/Profile.py b/silx/gui/plot/Profile.py index f61412d..5a733fe 100644 --- a/silx/gui/plot/Profile.py +++ b/silx/gui/plot/Profile.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2017 European Synchrotron Radiation Facility +# 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 @@ -28,7 +28,7 @@ and stacks of images""" __authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel", "H. Payno"] __license__ = "MIT" -__date__ = "17/08/2017" +__date__ = "24/04/2018" import weakref @@ -40,7 +40,7 @@ from silx.image.bilinear import BilinearImage from .. import icons from .. import qt from . import items -from .Colors import cursorColorForColormap +from ..colors import cursorColorForColormap from . import actions from .PlotToolButtons import ProfileToolButton from .ProfileMainWindow import ProfileMainWindow @@ -637,6 +637,12 @@ class ProfileToolBar(qt.QToolBar): colormap=colormap) else: coords = numpy.arange(len(profile[0]), dtype=numpy.float32) + # Scale horizontal and vertical profile coordinates + if self._roiInfo[2] == 'X': + coords = coords * scale[0] + origin[0] + elif self._roiInfo[2] == 'Y': + coords = coords * scale[1] + origin[1] + self.getProfilePlot().addCurve(coords, profile[0], legend=profileName, diff --git a/silx/gui/plot/ProfileMainWindow.py b/silx/gui/plot/ProfileMainWindow.py index 835de2c..3738511 100644 --- a/silx/gui/plot/ProfileMainWindow.py +++ b/silx/gui/plot/ProfileMainWindow.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# Copyright (c) 2017-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 @@ -73,6 +73,8 @@ class ProfileMainWindow(qt.QMainWindow): self._plot2D.setParent(None) # necessary to avoid widget destruction if self._plot1D is None: self._plot1D = Plot1D() + self._plot1D.setGraphYLabel('Profile') + self._plot1D.setGraphXLabel('') self.setCentralWidget(self._plot1D) elif self._profileType == "2D": if self._plot1D is not None: diff --git a/silx/gui/plot/ScatterMaskToolsWidget.py b/silx/gui/plot/ScatterMaskToolsWidget.py index a9c1073..2a10f6d 100644 --- a/silx/gui/plot/ScatterMaskToolsWidget.py +++ b/silx/gui/plot/ScatterMaskToolsWidget.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# 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 @@ -35,7 +35,7 @@ from __future__ import division __authors__ = ["P. Knobel"] __license__ = "MIT" -__date__ = "07/04/2017" +__date__ = "24/04/2018" import math @@ -45,10 +45,11 @@ import numpy import sys from .. import qt +from ...math.combo import min_max from ...image import shapes from ._BaseMaskToolsWidget import BaseMask, BaseMaskToolsWidget, BaseMaskToolsDockWidget -from .Colors import cursorColorForColormap, rgba +from ..colors import cursorColorForColormap, rgba _logger = logging.getLogger(__name__) @@ -186,13 +187,18 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): self._z = 2 # Mask layer in plot self._data_scatter = None """plot Scatter item for data""" + + self._data_extent = None + """Maximum extent of the data i.e., max(xMax-xMin, yMax-yMin)""" + self._mask_scatter = None """plot Scatter item for representing the mask""" def setSelectionMask(self, mask, copy=True): """Set the mask to a new array. - :param numpy.ndarray mask: The array to use for the mask. + :param numpy.ndarray mask: + The array to use for the mask or None to reset the mask. :type mask: numpy.ndarray of uint8, C-contiguous. Array of other types are converted. :param bool copy: True (the default) to copy the array, @@ -201,6 +207,10 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): The mask can be cropped or padded to fit active scatter, the returned shape is that of the scatter data. """ + if mask is None: + self.resetSelectionMask() + return self._data_scatter.getXData(copy=False).shape + mask = numpy.array(mask, copy=False, dtype=numpy.uint8) if self._data_scatter.getXData(copy=False).shape == (0,) \ @@ -216,7 +226,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): def _updatePlotMask(self): """Update mask image in plot""" mask = self.getSelectionMask(copy=False) - if len(mask): + if mask is not None: self.plot.addScatter(self._data_scatter.getXData(), self._data_scatter.getYData(), mask, @@ -226,8 +236,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): self._mask_scatter = self.plot._getItem(kind="scatter", legend=self._maskName) self._mask_scatter.setSymbolSize( - self._data_scatter.getSymbolSize() * 4.0 - ) + self._data_scatter.getSymbolSize() + 2.0) elif self.plot._getItem(kind="scatter", legend=self._maskName) is not None: self.plot.remove(self._maskName, kind='scatter') @@ -248,7 +257,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): if not self.browseAction.isChecked(): self.browseAction.trigger() # Disable drawing tool - if len(self.getSelectionMask(copy=False)): + if self.getSelectionMask(copy=False) is not None: self.plot.sigActiveScatterChanged.connect( self._activeScatterChangedAfterCare) @@ -265,6 +274,9 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): # No active scatter or active scatter is the mask... self.plot.sigActiveScatterChanged.disconnect( self._activeScatterChangedAfterCare) + self._data_extent = None + self._data_scatter = None + else: colormap = activeScatter.getColormap() self._defaultOverlayColor = rgba(cursorColorForColormap(colormap['name'])) @@ -274,13 +286,22 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): self._z = activeScatter.getZValue() + 1 self._data_scatter = activeScatter - if self._data_scatter.getXData(copy=False).shape != self.getSelectionMask(copy=False).shape: + + # Adjust brush size to data range + xMin, xMax = min_max(self._data_scatter.getXData(copy=False)) + yMin, yMax = min_max(self._data_scatter.getYData(copy=False)) + self._data_extent = max(xMax - xMin, yMax - yMin) + + if self._data_scatter.getXData(copy=False).shape != self._mask.getMask(copy=False).shape: # scatter has not the same size, remove mask and stop listening if self.plot._getItem(kind="scatter", legend=self._maskName): self.plot.remove(self._maskName, kind='scatter') self.plot.sigActiveScatterChanged.disconnect( self._activeScatterChangedAfterCare) + self._data_extent = None + self._data_scatter = None + else: # Refresh in case z changed self._mask.setDataItem(self._data_scatter) @@ -295,6 +316,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): self.setEnabled(False) self._data_scatter = None + self._data_extent = None self._mask.reset() self._mask.commit() @@ -309,8 +331,19 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): self._z = activeScatter.getZValue() + 1 self._data_scatter = activeScatter + + # Adjust brush size to data range + xData = self._data_scatter.getXData(copy=False) + yData = self._data_scatter.getYData(copy=False) + if xData.size > 0 and yData.size > 0: + xMin, xMax = min_max(xData) + yMin, yMax = min_max(yData) + self._data_extent = max(xMax - xMin, yMax - yMin) + else: + self._data_extent = None + self._mask.setDataItem(self._data_scatter) - if self._data_scatter.getXData(copy=False).shape != self.getSelectionMask(copy=False).shape: + if self._data_scatter.getXData(copy=False).shape != self._mask.getMask(copy=False).shape: self._mask.reset(self._data_scatter.getXData(copy=False).shape) self._mask.commit() else: @@ -439,6 +472,16 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): shape=self._data_scatter.getXData(copy=False).shape) self._mask.commit() + def _getPencilWidth(self): + """Returns the width of the pencil to use in data coordinates` + + :rtype: float + """ + width = super(ScatterMaskToolsWidget, self)._getPencilWidth() + if self._data_extent is not None: + width *= 0.01 * self._data_extent + return width + def _plotDrawEvent(self, event): """Handle draw events from the plot""" if (self._drawingMode is None or @@ -467,7 +510,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): event['event'] == 'drawingFinished'): doMask = self._isMasking() vertices = event['points'] - vertices = vertices.astype(numpy.int)[:, (1, 0)] # (y, x) + vertices = vertices[:, (1, 0)] # (y, x) self._mask.updatePolygon(level, vertices, doMask) self._mask.commit() @@ -475,7 +518,8 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): doMask = self._isMasking() # convert from plot to array coords x, y = event['points'][-1] - brushSize = self.pencilSpinBox.value() + + brushSize = self._getPencilWidth() if self._lastPencilPos != (y, x): if self._lastPencilPos is not None: diff --git a/silx/gui/plot/ScatterView.py b/silx/gui/plot/ScatterView.py new file mode 100644 index 0000000..f830cb3 --- /dev/null +++ b/silx/gui/plot/ScatterView.py @@ -0,0 +1,353 @@ +# 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. +# +# ###########################################################################*/ +"""A widget dedicated to display scatter plots + +It is based on a :class:`~silx.gui.plot.PlotWidget` with additional tools +for scatter plots. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "14/06/2018" + + +import logging +import weakref + +import numpy + +from . import items +from . import PlotWidget +from . import tools +from .tools.profile import ScatterProfileToolBar +from .ColorBar import ColorBarWidget +from .ScatterMaskToolsWidget import ScatterMaskToolsWidget + +from ..widgets.BoxLayoutDockWidget import BoxLayoutDockWidget +from .. import qt, icons + + +_logger = logging.getLogger(__name__) + + +class ScatterView(qt.QMainWindow): + """Main window with a PlotWidget and tools specific for scatter plots. + + :param parent: The parent of this widget + :param backend: The backend to use for the plot (default: matplotlib). + See :class:`~silx.gui.plot.PlotWidget` for the list of supported backend. + :type backend: Union[str,~silx.gui.plot.backends.BackendBase.BackendBase] + """ + + _SCATTER_LEGEND = ' ' + """Legend used for the scatter item""" + + def __init__(self, parent=None, backend=None): + super(ScatterView, self).__init__(parent=parent) + if parent is not None: + # behave as a widget + self.setWindowFlags(qt.Qt.Widget) + else: + self.setWindowTitle('ScatterView') + + # Create plot widget + plot = PlotWidget(parent=self, backend=backend) + self._plot = weakref.ref(plot) + + # Add an empty scatter + plot.addScatter(x=(), y=(), value=(), legend=self._SCATTER_LEGEND) + + # Create colorbar widget with white background + self._colorbar = ColorBarWidget(parent=self, plot=plot) + self._colorbar.setAutoFillBackground(True) + palette = self._colorbar.palette() + palette.setColor(qt.QPalette.Background, qt.Qt.white) + palette.setColor(qt.QPalette.Window, qt.Qt.white) + self._colorbar.setPalette(palette) + + # Create PositionInfo widget + self.__lastPickingPos = None + self.__pickingCache = None + self._positionInfo = tools.PositionInfo( + plot=plot, + converters=(('X', lambda x, y: x), + ('Y', lambda x, y: y), + ('Data', lambda x, y: self._getScatterValue(x, y)), + ('Index', lambda x, y: self._getScatterIndex(x, y)))) + + # Combine plot, position info and colorbar into central widget + gridLayout = qt.QGridLayout() + gridLayout.setSpacing(0) + gridLayout.setContentsMargins(0, 0, 0, 0) + gridLayout.addWidget(plot, 0, 0) + gridLayout.addWidget(self._colorbar, 0, 1) + gridLayout.addWidget(self._positionInfo, 1, 0, 1, -1) + gridLayout.setRowStretch(0, 1) + gridLayout.setColumnStretch(0, 1) + centralWidget = qt.QWidget(self) + centralWidget.setLayout(gridLayout) + self.setCentralWidget(centralWidget) + + # Create mask tool dock widget + self._maskToolsWidget = ScatterMaskToolsWidget(parent=self, plot=plot) + self._maskDock = BoxLayoutDockWidget() + self._maskDock.setWindowTitle('Scatter Mask') + self._maskDock.setWidget(self._maskToolsWidget) + self._maskDock.setVisible(False) + self.addDockWidget(qt.Qt.BottomDockWidgetArea, self._maskDock) + + self._maskAction = self._maskDock.toggleViewAction() + self._maskAction.setIcon(icons.getQIcon('image-mask')) + self._maskAction.setToolTip("Display/hide mask tools") + + # Create toolbars + self._interactiveModeToolBar = tools.InteractiveModeToolBar( + parent=self, plot=plot) + + self._scatterToolBar = tools.ScatterToolBar( + parent=self, plot=plot) + self._scatterToolBar.addAction(self._maskAction) + + self._profileToolBar = ScatterProfileToolBar(parent=self, plot=plot) + + self._outputToolBar = tools.OutputToolBar(parent=self, plot=plot) + + # Activate shortcuts in PlotWindow widget: + for toolbar in (self._interactiveModeToolBar, + self._scatterToolBar, + self._profileToolBar, + self._outputToolBar): + self.addToolBar(toolbar) + for action in toolbar.actions(): + self.addAction(action) + + def _pickScatterData(self, x, y): + """Get data and index and value of top most scatter plot at position (x, y) + + :param float x: X position in plot coordinates + :param float y: Y position in plot coordinates + :return: The data index and value at that point or None + """ + pickingPos = x, y + if self.__lastPickingPos != pickingPos: + self.__pickingCache = None + self.__lastPickingPos = pickingPos + + plot = self.getPlotWidget() + if plot is not None: + pixelPos = plot.dataToPixel(x, y) + if pixelPos is not None: + # Start from top-most item + for item, indices in reversed(plot._pick(*pixelPos)): + if isinstance(item, items.Scatter): + # Get last index + # with matplotlib it should be the top-most point + dataIndex = indices[-1] + self.__pickingCache = ( + dataIndex, + item.getValueData(copy=False)[dataIndex]) + break + + return self.__pickingCache + + def _getScatterValue(self, x, y): + """Get data value of top most scatter plot at position (x, y) + + :param float x: X position in plot coordinates + :param float y: Y position in plot coordinates + :return: The data value at that point or '-' + """ + picking = self._pickScatterData(x, y) + return '-' if picking is None else picking[1] + + def _getScatterIndex(self, x, y): + """Get data index of top most scatter plot at position (x, y) + + :param float x: X position in plot coordinates + :param float y: Y position in plot coordinates + :return: The data index at that point or '-' + """ + picking = self._pickScatterData(x, y) + return '-' if picking is None else picking[0] + + _PICK_OFFSET = 3 # Offset in pixel used for picking + + def _mouseInPlotArea(self, x, y): + """Clip mouse coordinates to plot area coordinates + + :param float x: X position in pixels + :param float y: Y position in pixels + :return: (x, y) in data coordinates + """ + plot = self.getPlotWidget() + left, top, width, height = plot.getPlotBoundsInPixels() + xPlot = numpy.clip(x, left, left + width - 1) + yPlot = numpy.clip(y, top, top + height - 1) + return xPlot, yPlot + + def getPlotWidget(self): + """Returns the :class:`~silx.gui.plot.PlotWidget` this window is based on. + + :rtype: ~silx.gui.plot.PlotWidget + """ + return self._plot() + + def getPositionInfoWidget(self): + """Returns the widget display mouse coordinates information. + + :rtype: ~silx.gui.plot.tools.PositionInfo + """ + return self._positionInfo + + def getMaskToolsWidget(self): + """Returns the widget controlling mask drawing + + :rtype: ~silx.gui.plot.ScatterMaskToolsWidget + """ + return self._maskToolsWidget + + def getInteractiveModeToolBar(self): + """Returns QToolBar controlling interactive mode. + + :rtype: ~silx.gui.plot.tools.InteractiveModeToolBar + """ + return self._interactiveModeToolBar + + def getScatterToolBar(self): + """Returns QToolBar providing scatter plot tools. + + :rtype: ~silx.gui.plot.tools.ScatterToolBar + """ + return self._scatterToolBar + + def getScatterProfileToolBar(self): + """Returns QToolBar providing scatter profile tools. + + :rtype: ~silx.gui.plot.tools.profile.ScatterProfileToolBar + """ + return self._profileToolBar + + def getOutputToolBar(self): + """Returns QToolBar containing save, copy and print actions + + :rtype: ~silx.gui.plot.tools.OutputToolBar + """ + return self._outputToolBar + + def setColormap(self, colormap=None): + """Set the colormap for the displayed scatter and the + default plot colormap. + + :param ~silx.gui.colors.Colormap colormap: + The description of the colormap. + """ + self.getScatterItem().setColormap(colormap) + # Resilient to call to PlotWidget API (e.g., clear) + self.getPlotWidget().setDefaultColormap(colormap) + + def getColormap(self): + """Return the :class:`.Colormap` in use. + + :return: Colormap currently in use + :rtype: ~silx.gui.colors.Colormap + """ + self.getScatterItem().getColormap() + + # Control displayed scatter plot + + def setData(self, x, y, value, xerror=None, yerror=None, copy=True): + """Set the data of the scatter plot. + + To reset the scatter plot, set x, y and value to None. + + :param Union[numpy.ndarray,None] x: X coordinates. + :param Union[numpy.ndarray,None] y: Y coordinates. + :param Union[numpy.ndarray,None] value: + The data corresponding to the value of the data points. + :param xerror: Values with the uncertainties on the x values. + If it is an array, it can either be a 1D array of + same length as the data or a 2D array with 2 rows + of same length as the data: row 0 for positive errors, + row 1 for negative errors. + :type xerror: A float, or a numpy.ndarray of float32. + + :param yerror: Values with the uncertainties on the y values + :type yerror: A float, or a numpy.ndarray of float32. See xerror. + :param bool copy: True make a copy of the data (default), + False to use provided arrays. + """ + x = () if x is None else x + y = () if y is None else y + value = () if value is None else value + + self.getScatterItem().setData( + x=x, y=y, value=value, xerror=xerror, yerror=yerror, copy=copy) + + def getData(self, *args, **kwargs): + return self.getScatterItem().getData(*args, **kwargs) + + getData.__doc__ = items.Scatter.getData.__doc__ + + def getScatterItem(self): + """Returns the plot item displaying the scatter data. + + This allows to set the style of the displayed scatter. + + :rtype: ~silx.gui.plot.items.Scatter + """ + plot = self.getPlotWidget() + scatter = plot._getItem(kind='scatter', legend=self._SCATTER_LEGEND) + if scatter is None: # Resilient to call to PlotWidget API (e.g., clear) + plot.addScatter(x=(), y=(), value=(), legend=self._SCATTER_LEGEND) + scatter = plot._getItem( + kind='scatter', legend=self._SCATTER_LEGEND) + return scatter + + # Convenient proxies + + def getXAxis(self, *args, **kwargs): + return self.getPlotWidget().getXAxis(*args, **kwargs) + + getXAxis.__doc__ = PlotWidget.getXAxis.__doc__ + + def getYAxis(self, *args, **kwargs): + return self.getPlotWidget().getYAxis(*args, **kwargs) + + getYAxis.__doc__ = PlotWidget.getYAxis.__doc__ + + def setGraphTitle(self, *args, **kwargs): + return self.getPlotWidget().setGraphTitle(*args, **kwargs) + + setGraphTitle.__doc__ = PlotWidget.setGraphTitle.__doc__ + + def getGraphTitle(self, *args, **kwargs): + return self.getPlotWidget().getGraphTitle(*args, **kwargs) + + getGraphTitle.__doc__ = PlotWidget.getGraphTitle.__doc__ + + def resetZoom(self, *args, **kwargs): + return self.getPlotWidget().resetZoom(*args, **kwargs) + + resetZoom.__doc__ = PlotWidget.resetZoom.__doc__ diff --git a/silx/gui/plot/StackView.py b/silx/gui/plot/StackView.py index 1fb188c..d1e8e3c 100644 --- a/silx/gui/plot/StackView.py +++ b/silx/gui/plot/StackView.py @@ -69,16 +69,18 @@ Example:: __authors__ = ["P. Knobel", "H. Payno"] __license__ = "MIT" -__date__ = "15/02/2018" +__date__ = "26/04/2018" import numpy +import logging +import silx from silx.gui import qt from .. import icons from . import items, PlotWindow, actions -from .Colormap import Colormap -from .Colors import cursorColorForColormap -from .PlotTools import LimitsToolBar +from ..colors import Colormap +from ..colors import cursorColorForColormap +from .tools import LimitsToolBar from .Profile import Profile3DToolBar from ..widgets.FrameBrowser import HorizontalSliderWithBrowser @@ -96,6 +98,8 @@ except ImportError: else: from silx.io.utils import is_dataset +_logger = logging.getLogger(__name__) + class StackView(qt.QMainWindow): """Stack view widget, to display and browse through stack of @@ -156,6 +160,12 @@ class StackView(qt.QMainWindow): integer. """ + sigFrameChanged = qt.Signal(int) + """Signal emitter when the frame number has changed. + + This signal provides the current frame number. + """ + def __init__(self, parent=None, resetzoom=True, backend=None, autoScale=False, logScale=False, grid=False, colormap=True, aspectRatio=True, yinverted=True, @@ -206,6 +216,9 @@ class StackView(qt.QMainWindow): self.sigActiveImageChanged = self._plot.sigActiveImageChanged self.sigPlotSignal = self._plot.sigPlotSignal + if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == 'downward': + self._plot.getYAxis().setInverted(True) + self._addColorBarAction() self._plot.profile = Profile3DToolBar(parent=self._plot, @@ -221,6 +234,7 @@ class StackView(qt.QMainWindow): self._browser_label = qt.QLabel("Image index (Dim0):") self._browser = HorizontalSliderWithBrowser(central_widget) + self._browser.setRange(0, 0) self._browser.valueChanged[int].connect(self.__updateFrameNumber) self._browser.setEnabled(False) @@ -313,7 +327,7 @@ class StackView(qt.QMainWindow): assert self._stack is not None assert 0 <= self._perspective < 3 - # ensure we have the stack encapsulated in an array like object + # ensure we have the stack encapsulated in an array-like object # having a transpose() method if isinstance(self._stack, numpy.ndarray): self.__transposed_view = self._stack @@ -324,7 +338,7 @@ class StackView(qt.QMainWindow): elif isinstance(self._stack, ListOfImages): self.__transposed_view = ListOfImages(self._stack) - # transpose the array like object if necessary + # transpose the array-like object if necessary if self._perspective == 1: self.__transposed_view = self.__transposed_view.transpose((1, 0, 2)) elif self._perspective == 2: @@ -338,13 +352,16 @@ class StackView(qt.QMainWindow): :param index: index of the frame to be displayed """ - assert self.__transposed_view is not None + if self.__transposed_view is None: + # no data set + return self._plot.addImage(self.__transposed_view[index, :, :], origin=self._getImageOrigin(), scale=self._getImageScale(), legend=self.__imageLegend, - resetzoom=False, replace=False) + resetzoom=False) self._updateTitle() + self.sigFrameChanged.emit(index) def _set3DScaleAndOrigin(self, calibrations): """Set scale and origin for all 3 axes, to be used when plotting @@ -358,7 +375,7 @@ class StackView(qt.QMainWindow): calibration.NoCalibration()) else: self.calibrations3D = [] - for calib in calibrations: + for i, calib in enumerate(calibrations): if hasattr(calib, "__len__") and len(calib) == 2: calib = calibration.LinearCalibration(calib[0], calib[1]) elif calib is None: @@ -367,9 +384,19 @@ class StackView(qt.QMainWindow): raise TypeError("calibration must be a 2-tuple, None or" + " an instance of an AbstractCalibration " + "subclass") + elif not calib.is_affine(): + _logger.warning( + "Calibration for dimension %d is not linear, " + "it will be ignored for scaling the graph axes.", + i) self.calibrations3D.append(calib) def _getXYZCalibs(self): + """Return calibrations sorted in the XYZ graph order. + + If the X or Y calibration is not linear, it will be replaced + with a :class:`calibration.NoCalibration` object + and as a result the corresponding axis will not be scaled.""" xy_dims = [0, 1, 2] xy_dims.remove(self._perspective) @@ -377,6 +404,12 @@ class StackView(qt.QMainWindow): ycalib = self.calibrations3D[min(xy_dims)] zcalib = self.calibrations3D[self._perspective] + # filter out non-linear calibration for graph axes + if not xcalib.is_affine(): + xcalib = calibration.NoCalibration() + if not ycalib.is_affine(): + ycalib = calibration.NoCalibration() + return xcalib, ycalib, zcalib def _getImageScale(self): @@ -469,6 +502,7 @@ class StackView(qt.QMainWindow): colormap=self.getColormap(), origin=self._getImageOrigin(), scale=self._getImageScale(), + replace=True, resetzoom=False) self._plot.setActiveImage(self.__imageLegend) self._plot.setGraphTitle("Image z=%g" % self._getImageZ(0)) @@ -586,6 +620,14 @@ class StackView(qt.QMainWindow): """ self._browser.setValue(number) + def getFrameNumber(self): + """Set the frame selection to a specific value + + :return: Index of currently displayed frame + :rtype: int + """ + return self._browser.value() + def setFirstStackDimension(self, first_stack_dimension): """When viewing the last 3 dimensions of an n-D array (n>3), you can use this method to change the text in the combobox. @@ -641,6 +683,8 @@ class StackView(qt.QMainWindow): self.__transposed_view = None self._perspective = 0 self._browser.setEnabled(False) + # reset browser range + self._browser.setRange(0, 0) self._plot.clear() def setLabels(self, labels=None): @@ -1101,17 +1145,17 @@ class StackViewMainWindow(StackView): self.statusBar() menu = self.menuBar().addMenu('File') - menu.addAction(self._plot.saveAction) - menu.addAction(self._plot.printAction) + menu.addAction(self._plot.getOutputToolBar().getSaveAction()) + menu.addAction(self._plot.getOutputToolBar().getPrintAction()) menu.addSeparator() action = menu.addAction('Quit') action.triggered[bool].connect(qt.QApplication.instance().quit) menu = self.menuBar().addMenu('Edit') - menu.addAction(self._plot.copyAction) + menu.addAction(self._plot.getOutputToolBar().getCopyAction()) menu.addSeparator() - menu.addAction(self._plot.resetZoomAction) - menu.addAction(self._plot.colormapAction) + menu.addAction(self._plot.getResetZoomAction()) + menu.addAction(self._plot.getColormapAction()) menu.addAction(self.getColorBarAction()) menu.addAction(actions.control.KeepAspectRatioAction(self._plot, self)) diff --git a/silx/gui/plot/StatsWidget.py b/silx/gui/plot/StatsWidget.py new file mode 100644 index 0000000..a36dd9f --- /dev/null +++ b/silx/gui/plot/StatsWidget.py @@ -0,0 +1,572 @@ +# 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. +# +# ###########################################################################*/ +""" +Module containing widgets displaying stats from items of a plot. +""" + +__authors__ = ["H. Payno"] +__license__ = "MIT" +__date__ = "12/06/2018" + + +import functools +import logging +import numpy +from collections import OrderedDict + +import silx.utils.weakref +from silx.gui import qt +from silx.gui import icons +from silx.gui.plot.items.curve import Curve as CurveItem +from silx.gui.plot.items.histogram import Histogram as HistogramItem +from silx.gui.plot.items.image import ImageBase as ImageItem +from silx.gui.plot.items.scatter import Scatter as ScatterItem +from silx.gui.plot import stats as statsmdl +from silx.gui.widgets.TableWidget import TableWidget +from silx.gui.plot.stats.statshandler import StatsHandler, StatFormatter + +logger = logging.getLogger(__name__) + + +class StatsWidget(qt.QWidget): + """ + Widget displaying a set of :class:`Stat` to be displayed on a + :class:`StatsTable` and to be apply on items contained in the :class:`Plot` + Also contains options to: + + * compute statistics on all the data or on visible data only + * show statistics of all items or only the active one + + :param parent: Qt parent + :param plot: the plot containing items on which we want statistics. + """ + + NUMBER_FORMAT = '{0:.3f}' + + class OptionsWidget(qt.QToolBar): + + def __init__(self, parent=None): + qt.QToolBar.__init__(self, parent) + self.setIconSize(qt.QSize(16, 16)) + + action = qt.QAction(self) + action.setIcon(icons.getQIcon("stats-active-items")) + action.setText("Active items only") + action.setToolTip("Display stats for active items only.") + action.setCheckable(True) + action.setChecked(True) + self.__displayActiveItems = action + + action = qt.QAction(self) + action.setIcon(icons.getQIcon("stats-whole-items")) + action.setText("All items") + action.setToolTip("Display stats for all available items.") + action.setCheckable(True) + self.__displayWholeItems = action + + action = qt.QAction(self) + action.setIcon(icons.getQIcon("stats-visible-data")) + action.setText("Use the visible data range") + action.setToolTip("Use the visible data range.<br/>" + "If activated the data is filtered to only use" + "visible data of the plot." + "The filtering is a data sub-sampling." + "No interpolation is made to fit data to" + "boundaries.") + action.setCheckable(True) + self.__useVisibleData = action + + action = qt.QAction(self) + action.setIcon(icons.getQIcon("stats-whole-data")) + action.setText("Use the full data range") + action.setToolTip("Use the full data range.") + action.setCheckable(True) + action.setChecked(True) + self.__useWholeData = action + + self.addAction(self.__displayWholeItems) + self.addAction(self.__displayActiveItems) + self.addSeparator() + self.addAction(self.__useVisibleData) + self.addAction(self.__useWholeData) + + self.itemSelection = qt.QActionGroup(self) + self.itemSelection.setExclusive(True) + self.itemSelection.addAction(self.__displayActiveItems) + self.itemSelection.addAction(self.__displayWholeItems) + + self.dataRangeSelection = qt.QActionGroup(self) + self.dataRangeSelection.setExclusive(True) + self.dataRangeSelection.addAction(self.__useWholeData) + self.dataRangeSelection.addAction(self.__useVisibleData) + + def isActiveItemMode(self): + return self.itemSelection.checkedAction() is self.__displayActiveItems + + def isVisibleDataRangeMode(self): + return self.dataRangeSelection.checkedAction() is self.__useVisibleData + + def __init__(self, parent=None, plot=None, stats=None): + qt.QWidget.__init__(self, parent) + self.setLayout(qt.QVBoxLayout()) + self.layout().setContentsMargins(0, 0, 0, 0) + self._options = self.OptionsWidget(parent=self) + self.layout().addWidget(self._options) + self._statsTable = StatsTable(parent=self, plot=plot) + self.setStats = self._statsTable.setStats + self.setStats(stats) + + self.layout().addWidget(self._statsTable) + self.setPlot = self._statsTable.setPlot + + self._options.itemSelection.triggered.connect( + self._optSelectionChanged) + self._options.dataRangeSelection.triggered.connect( + self._optDataRangeChanged) + self._optSelectionChanged() + self._optDataRangeChanged() + + self.setDisplayOnlyActiveItem = self._statsTable.setDisplayOnlyActiveItem + self.setStatsOnVisibleData = self._statsTable.setStatsOnVisibleData + + def _optSelectionChanged(self, action=None): + self._statsTable.setDisplayOnlyActiveItem(self._options.isActiveItemMode()) + + def _optDataRangeChanged(self, action=None): + self._statsTable.setStatsOnVisibleData(self._options.isVisibleDataRangeMode()) + + +class BasicStatsWidget(StatsWidget): + """ + Widget defining a simple set of :class:`Stat` to be displayed on a + :class:`StatsWidget`. + + :param parent: Qt parent + :param plot: the plot containing items on which we want statistics. + """ + + STATS = StatsHandler(( + (statsmdl.StatMin(), StatFormatter()), + statsmdl.StatCoordMin(), + (statsmdl.StatMax(), StatFormatter()), + statsmdl.StatCoordMax(), + (('std', numpy.std), StatFormatter()), + (('mean', numpy.mean), StatFormatter()), + statsmdl.StatCOM() + )) + + def __init__(self, parent=None, plot=None): + StatsWidget.__init__(self, parent=parent, plot=plot, stats=self.STATS) + + +class StatsTable(TableWidget): + """ + TableWidget displaying for each curves contained by the Plot some + information: + + * legend + * minimal value + * maximal value + * standard deviation (std) + + :param parent: The widget's parent. + :param plot: :class:`.PlotWidget` instance on which to operate + """ + + COMPATIBLE_KINDS = { + 'curve': CurveItem, + 'image': ImageItem, + 'scatter': ScatterItem, + 'histogram': HistogramItem + } + + COMPATIBLE_ITEMS = tuple(COMPATIBLE_KINDS.values()) + + def __init__(self, parent=None, plot=None): + TableWidget.__init__(self, parent) + """Next freeID for the curve""" + self.plot = None + self._displayOnlyActItem = False + self._statsOnVisibleData = False + self._lgdAndKindToItems = {} + """Associate to a tuple(legend, kind) the items legend""" + self.callbackImage = None + self.callbackScatter = None + self.callbackCurve = None + """Associate the curve legend to his first item""" + self._statsHandler = None + self._legendsSet = [] + """list of legends actually displayed""" + self._resetColumns() + + self.setColumnCount(len(self._columns)) + self.setSelectionBehavior(qt.QAbstractItemView.SelectRows) + self.setPlot(plot) + self.setSortingEnabled(True) + + def _resetColumns(self): + self._columns_index = OrderedDict([('legend', 0), ('kind', 1)]) + self._columns = self._columns_index.keys() + self.setColumnCount(len(self._columns)) + + def setStats(self, statsHandler): + """ + + :param statsHandler: Set the statistics to be displayed and how to + format them using + :rtype: :class:`StatsHandler` + """ + _statsHandler = statsHandler + if statsHandler is None: + _statsHandler = StatsHandler(statFormatters=()) + if isinstance(_statsHandler, (list, tuple)): + _statsHandler = StatsHandler(_statsHandler) + assert isinstance(_statsHandler, StatsHandler) + self._resetColumns() + self.clear() + + for statName, stat in list(_statsHandler.stats.items()): + assert isinstance(stat, statsmdl.StatBase) + self._columns_index[statName] = len(self._columns_index) + self._statsHandler = _statsHandler + self._columns = self._columns_index.keys() + self.setColumnCount(len(self._columns)) + + self._updateItemObserve() + self._updateAllStats() + + def getStatsHandler(self): + return self._statsHandler + + def _updateAllStats(self): + for (legend, kind) in self._lgdAndKindToItems: + self._updateStats(legend, kind) + + @staticmethod + def _getKind(myItem): + if isinstance(myItem, CurveItem): + return 'curve' + elif isinstance(myItem, ImageItem): + return 'image' + elif isinstance(myItem, ScatterItem): + return 'scatter' + elif isinstance(myItem, HistogramItem): + return 'histogram' + else: + return None + + def setPlot(self, plot): + """ + Define the plot to interact with + + :param plot: the plot containing the items on which statistics are + applied + :rtype: :class:`.PlotWidget` + """ + if self.plot: + self._dealWithPlotConnection(create=False) + self.plot = plot + self.clear() + if self.plot: + self._dealWithPlotConnection(create=True) + self._updateItemObserve() + + def _updateItemObserve(self): + if self.plot: + self.clear() + if self._displayOnlyActItem is True: + activeCurve = self.plot.getActiveCurve(just_legend=False) + activeScatter = self.plot._getActiveItem(kind='scatter', + just_legend=False) + activeImage = self.plot.getActiveImage(just_legend=False) + if activeCurve: + self._addItem(activeCurve) + if activeImage: + self._addItem(activeImage) + if activeScatter: + self._addItem(activeScatter) + else: + [self._addItem(curve) for curve in self.plot.getAllCurves()] + [self._addItem(image) for image in self.plot.getAllImages()] + scatters = self.plot._getItems(kind='scatter', + just_legend=False, + withhidden=True) + [self._addItem(scatter) for scatter in scatters] + histograms = self.plot._getItems(kind='histogram', + just_legend=False, + withhidden=True) + [self._addItem(histogram) for histogram in histograms] + + def _dealWithPlotConnection(self, create=True): + """ + Manage connection to plot signals + + Note: connection on Item are managed by the _removeItem function + """ + if self.plot is None: + return + if self._displayOnlyActItem: + if create is True: + if self.callbackImage is None: + self.callbackImage = functools.partial(self._activeItemChanged, 'image') + self.callbackScatter = functools.partial(self._activeItemChanged, 'scatter') + self.callbackCurve = functools.partial(self._activeItemChanged, 'curve') + self.plot.sigActiveImageChanged.connect(self.callbackImage) + self.plot.sigActiveScatterChanged.connect(self.callbackScatter) + self.plot.sigActiveCurveChanged.connect(self.callbackCurve) + else: + if self.callbackImage is not None: + self.plot.sigActiveImageChanged.disconnect(self.callbackImage) + self.plot.sigActiveScatterChanged.disconnect(self.callbackScatter) + self.plot.sigActiveCurveChanged.disconnect(self.callbackCurve) + self.callbackImage = None + self.callbackScatter = None + self.callbackCurve = None + else: + if create is True: + self.plot.sigContentChanged.connect(self._plotContentChanged) + else: + self.plot.sigContentChanged.disconnect(self._plotContentChanged) + if create is True: + self.plot.sigPlotSignal.connect(self._zoomPlotChanged) + else: + self.plot.sigPlotSignal.disconnect(self._zoomPlotChanged) + + def clear(self): + """ + Clear all existing items + """ + lgdsAndKinds = list(self._lgdAndKindToItems.keys()) + for lgdAndKind in lgdsAndKinds: + self._removeItem(legend=lgdAndKind[0], kind=lgdAndKind[1]) + self._lgdAndKindToItems = {} + qt.QTableWidget.clear(self) + self.setRowCount(0) + + # It have to called befor3e accessing to the header items + self.setHorizontalHeaderLabels(self._columns) + + if self._statsHandler is not None: + for columnId, name in enumerate(self._columns): + item = self.horizontalHeaderItem(columnId) + if name in self._statsHandler.stats: + stat = self._statsHandler.stats[name] + text = stat.name[0].upper() + stat.name[1:] + if stat.description is not None: + tooltip = stat.description + else: + tooltip = "" + else: + text = name[0].upper() + name[1:] + tooltip = "" + item.setToolTip(tooltip) + item.setText(text) + + if hasattr(self.horizontalHeader(), 'setSectionResizeMode'): # Qt5 + self.horizontalHeader().setSectionResizeMode(qt.QHeaderView.ResizeToContents) + else: # Qt4 + self.horizontalHeader().setResizeMode(qt.QHeaderView.ResizeToContents) + self.setColumnHidden(self._columns_index['kind'], True) + + def _addItem(self, item): + assert isinstance(item, self.COMPATIBLE_ITEMS) + if (item.getLegend(), self._getKind(item)) in self._lgdAndKindToItems: + self._updateStats(item.getLegend(), self._getKind(item)) + return + + self.setRowCount(self.rowCount() + 1) + indexTable = self.rowCount() - 1 + kind = self._getKind(item) + + self._lgdAndKindToItems[(item.getLegend(), kind)] = {} + + # the get item will manage the item creation of not existing + _createItem = self._getItem + for itemName in self._columns: + _createItem(name=itemName, legend=item.getLegend(), kind=kind, + indexTable=indexTable) + + self._updateStats(legend=item.getLegend(), kind=kind) + + callback = functools.partial( + silx.utils.weakref.WeakMethodProxy(self._updateStats), + item.getLegend(), kind) + item.sigItemChanged.connect(callback) + self.setColumnHidden(self._columns_index['kind'], + item.getLegend() not in self._legendsSet) + self._legendsSet.append(item.getLegend()) + + def _getItem(self, name, legend, kind, indexTable): + if (legend, kind) not in self._lgdAndKindToItems: + self._lgdAndKindToItems[(legend, kind)] = {} + if not (name in self._lgdAndKindToItems[(legend, kind)] and + self._lgdAndKindToItems[(legend, kind)]): + if name in ('legend', 'kind'): + _item = qt.QTableWidgetItem(type=qt.QTableWidgetItem.Type) + if name == 'legend': + _item.setText(legend) + else: + assert name == 'kind' + _item.setText(kind) + else: + if self._statsHandler.formatters[name]: + _item = self._statsHandler.formatters[name].tabWidgetItemClass() + else: + _item = qt.QTableWidgetItem() + tooltip = self._statsHandler.stats[name].getToolTip(kind=kind) + if tooltip is not None: + _item.setToolTip(tooltip) + + _item.setFlags(qt.Qt.ItemIsEnabled | qt.Qt.ItemIsSelectable) + self.setItem(indexTable, self._columns_index[name], _item) + self._lgdAndKindToItems[(legend, kind)][name] = _item + + return self._lgdAndKindToItems[(legend, kind)][name] + + def _removeItem(self, legend, kind): + if (legend, kind) not in self._lgdAndKindToItems or not self.plot: + return + + self.firstItem = self._lgdAndKindToItems[(legend, kind)]['legend'] + del self._lgdAndKindToItems[(legend, kind)] + self.removeRow(self.firstItem.row()) + self._legendsSet.remove(legend) + self.setColumnHidden(self._columns_index['kind'], + legend not in self._legendsSet) + + def _updateCurrentStats(self): + for lgdAndKind in self._lgdAndKindToItems: + self._updateStats(lgdAndKind[0], lgdAndKind[1]) + + def _updateStats(self, legend, kind, event=None): + if self._statsHandler is None: + return + + assert kind in ('curve', 'image', 'scatter', 'histogram') + if kind == 'curve': + item = self.plot.getCurve(legend) + elif kind == 'image': + item = self.plot.getImage(legend) + elif kind == 'scatter': + item = self.plot.getScatter(legend) + elif kind == 'histogram': + item = self.plot.getHistogram(legend) + else: + raise ValueError('kind not managed') + + if not item or (item.getLegend(), kind) not in self._lgdAndKindToItems: + return + + assert isinstance(item, self.COMPATIBLE_ITEMS) + + statsValDict = self._statsHandler.calculate(item, self.plot, + self._statsOnVisibleData) + + lgdItem = self._lgdAndKindToItems[(item.getLegend(), kind)]['legend'] + assert lgdItem + rowStat = lgdItem.row() + + for statName, statVal in list(statsValDict.items()): + assert statName in self._lgdAndKindToItems[(item.getLegend(), kind)] + tableItem = self._getItem(name=statName, legend=item.getLegend(), + kind=kind, indexTable=rowStat) + tableItem.setText(str(statVal)) + + def currentChanged(self, current, previous): + if current.row() >= 0: + legendItem = self.item(current.row(), self._columns_index['legend']) + assert legendItem + kindItem = self.item(current.row(), self._columns_index['kind']) + kind = kindItem.text() + if kind == 'curve': + self.plot.setActiveCurve(legendItem.text()) + elif kind == 'image': + self.plot.setActiveImage(legendItem.text()) + elif kind == 'scatter': + self.plot._setActiveItem('scatter', legendItem.text()) + elif kind == 'histogram': + # active histogram not managed by the plot actually + pass + else: + raise ValueError('kind not managed') + qt.QTableWidget.currentChanged(self, current, previous) + + def setDisplayOnlyActiveItem(self, displayOnlyActItem): + """ + + :param bool displayOnlyActItem: True if we want to only show active + item + """ + if self._displayOnlyActItem == displayOnlyActItem: + return + self._displayOnlyActItem = displayOnlyActItem + self._dealWithPlotConnection(create=False) + self._updateItemObserve() + self._dealWithPlotConnection(create=True) + + def setStatsOnVisibleData(self, b): + """ + .. warning:: When visible data is activated we will process to a simple + filtering of visible data by the user. The filtering is a + simple data sub-sampling. No interpolation is made to fit + data to boundaries. + + :param bool b: True if we want to apply statistics only on visible data + """ + if self._statsOnVisibleData != b: + self._statsOnVisibleData = b + self._updateCurrentStats() + + def _activeItemChanged(self, kind): + """Callback used when plotting only the active item""" + assert kind in ('curve', 'image', 'scatter', 'histogram') + self._updateItemObserve() + + def _plotContentChanged(self, action, kind, legend): + """Callback used when plotting all the plot items""" + if kind not in ('curve', 'image', 'scatter', 'histogram'): + return + if kind == 'curve': + item = self.plot.getCurve(legend) + elif kind == 'image': + item = self.plot.getImage(legend) + elif kind == 'scatter': + item = self.plot.getScatter(legend) + elif kind == 'histogram': + item = self.plot.getHistogram(legend) + else: + raise ValueError('kind not managed') + + if action == 'add': + if item is None: + raise ValueError('Item from legend "%s" do not exists' % legend) + self._addItem(item) + elif action == 'remove': + self._removeItem(legend, kind) + + def _zoomPlotChanged(self, event): + if self._statsOnVisibleData is True: + if 'event' in event and event['event'] == 'limitsChanged': + self._updateCurrentStats() diff --git a/silx/gui/plot/_BaseMaskToolsWidget.py b/silx/gui/plot/_BaseMaskToolsWidget.py index 35a48ae..da0dbf5 100644 --- a/silx/gui/plot/_BaseMaskToolsWidget.py +++ b/silx/gui/plot/_BaseMaskToolsWidget.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# Copyright (c) 2017-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 @@ -29,16 +29,17 @@ from __future__ import division __authors__ = ["T. Vincent", "P. Knobel"] __license__ = "MIT" -__date__ = "02/10/2017" +__date__ = "24/04/2018" import os +import weakref import numpy from silx.gui import qt, icons from silx.gui.widgets.FloatEdit import FloatEdit -from silx.gui.plot.Colormap import Colormap -from silx.gui.plot.Colors import rgba +from silx.gui.colors import Colormap +from silx.gui.colors import rgba from .actions.mode import PanModeAction @@ -372,7 +373,7 @@ class BaseMaskToolsWidget(qt.QWidget): # as parent have to be the first argument of the widget to fit # QtDesigner need but here plot can't be None by default. assert plot is not None - self._plot = plot + self._plotRef = weakref.ref(plot) self._maskName = '__MASK_TOOLS_%d' % id(self) # Legend of the mask self._colormap = Colormap(name="", @@ -409,12 +410,21 @@ class BaseMaskToolsWidget(qt.QWidget): :param bool copy: True (default) to get a copy of the mask. If False, the returned array MUST not be modified. - :return: The array of the mask with dimension of the 'active' plot item. - If there is no active image or scatter, an empty array is - returned. - :rtype: numpy.ndarray of uint8 + :return: The mask (as an array of uint8) with dimension of + the 'active' plot item. + If there is no active image or scatter, it returns None. + :rtype: Union[numpy.ndarray,None] """ - return self._mask.getMask(copy=copy) + mask = self._mask.getMask(copy=copy) + return None if mask.size == 0 else mask + + def setSelectionMask(self, mask): + """Set the mask: Must be implemented in subclass""" + raise NotImplementedError() + + def resetSelectionMask(self): + """Reset the mask: Must be implemented in subclass""" + raise NotImplementedError() def multipleMasks(self): """Return the current mode of multiple masks support. @@ -453,7 +463,11 @@ class BaseMaskToolsWidget(qt.QWidget): @property def plot(self): """The :class:`.PlotWindow` this widget is attached to.""" - return self._plot + plot = self._plotRef() + if plot is None: + raise RuntimeError( + 'Mask widget attached to a PlotWidget that no longer exists') + return plot def setDirection(self, direction=qt.QBoxLayout.LeftToRight): """Set the direction of the layout of the widget @@ -604,8 +618,8 @@ class BaseMaskToolsWidget(qt.QWidget): self.polygonAction.setShortcut(qt.QKeySequence(qt.Qt.Key_S)) self.polygonAction.setToolTip( 'Polygon selection tool: (Un)Mask a polygonal region <b>S</b><br>' - 'Left-click to place polygon corners<br>' - 'Right-click to place the last corner') + 'Left-click to place new polygon corners<br>' + 'Left-click on first corner to close the polygon') self.polygonAction.setCheckable(True) self.polygonAction.triggered.connect(self._activePolygonMode) self.addAction(self.polygonAction) @@ -962,13 +976,20 @@ class BaseMaskToolsWidget(qt.QWidget): self.plot.setInteractiveMode('draw', shape='polygon', source=self, color=color) self._updateDrawingModeWidgets() + def _getPencilWidth(self): + """Returns the width of the pencil to use in data coordinates` + + :rtype: float + """ + return self.pencilSpinBox.value() + def _activePencilMode(self): """Handle pencil action mode triggering""" self._releaseDrawingMode() self._drawingMode = 'pencil' self.plot.sigPlotSignal.connect(self._plotDrawEvent) color = self.getCurrentMaskColor() - width = self.pencilSpinBox.value() + width = self._getPencilWidth() self.plot.setInteractiveMode( 'draw', shape='pencil', source=self, color=color, width=width) self._updateDrawingModeWidgets() diff --git a/silx/gui/plot/__init__.py b/silx/gui/plot/__init__.py index b03392d..3a141b3 100644 --- a/silx/gui/plot/__init__.py +++ b/silx/gui/plot/__init__.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# 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 @@ -37,6 +37,7 @@ List of Qt widgets: - :mod:`.PlotWindow`: A :mod:`.PlotWidget` with a configurable set of tools. - :class:`.Plot1D`: A widget with tools for curves. - :class:`.Plot2D`: A widget with tools for images. +- :class:`.ScatterView`: A widget with tools for scatter plot. - :class:`.ImageView`: A widget with tools for images and a side histogram. - :class:`.StackView`: A widget with tools for a stack of images. @@ -61,8 +62,10 @@ __date__ = "03/05/2017" from .PlotWidget import PlotWidget # noqa from .PlotWindow import PlotWindow, Plot1D, Plot2D # noqa +from .items.axis import TickMode from .ImageView import ImageView # noqa from .StackView import StackView # noqa +from .ScatterView import ScatterView # noqa __all__ = ['ImageView', 'PlotWidget', 'PlotWindow', 'Plot1D', 'Plot2D', - 'StackView'] + 'StackView', 'ScatterView', 'TickMode'] diff --git a/silx/gui/plot/_utils/dtime_ticklayout.py b/silx/gui/plot/_utils/dtime_ticklayout.py new file mode 100644 index 0000000..95fc235 --- /dev/null +++ b/silx/gui/plot/_utils/dtime_ticklayout.py @@ -0,0 +1,438 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-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 implements date-time labels layout on graph axes.""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["P. Kenter"] +__license__ = "MIT" +__date__ = "04/04/2018" + + +import datetime as dt +import logging +import math +import time + +import dateutil.tz + +from dateutil.relativedelta import relativedelta + +from silx.third_party import enum +from .ticklayout import niceNumGeneric + +_logger = logging.getLogger(__name__) + + +MICROSECONDS_PER_SECOND = 1000000 +SECONDS_PER_MINUTE = 60 +SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE +SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR +SECONDS_PER_YEAR = 365.25 * SECONDS_PER_DAY +SECONDS_PER_MONTH_AVERAGE = SECONDS_PER_YEAR / 12 # Seconds per average month + + +# No dt.timezone in Python 2.7 so we use dateutil.tz.tzutc +_EPOCH = dt.datetime(1970, 1, 1, tzinfo=dateutil.tz.tzutc()) + +def timestamp(dtObj): + """ Returns POSIX timestamp of a datetime objects. + + If the dtObj object has a timestamp() method (python 3.3), this is + used. Otherwise (e.g. python 2.7) it is calculated here. + + The POSIX timestamp is a floating point value of the number of seconds + since the start of an epoch (typically 1970-01-01). For details see: + https://docs.python.org/3/library/datetime.html#datetime.datetime.timestamp + + :param datetime.datetime dtObj: date-time representation. + :return: POSIX timestamp + :rtype: float + """ + if hasattr(dtObj, "timestamp"): + return dtObj.timestamp() + else: + # Back ported from Python 3.5 + if dtObj.tzinfo is None: + return time.mktime((dtObj.year, dtObj.month, dtObj.day, + dtObj.hour, dtObj.minute, dtObj.second, + -1, -1, -1)) + dtObj.microsecond / 1e6 + else: + return (dtObj - _EPOCH).total_seconds() + + +@enum.unique +class DtUnit(enum.Enum): + YEARS = 0 + MONTHS = 1 + DAYS = 2 + HOURS = 3 + MINUTES = 4 + SECONDS = 5 + MICRO_SECONDS = 6 # a fraction of a second + + +def getDateElement(dateTime, unit): + """ Picks the date element with the unit from the dateTime + + E.g. getDateElement(datetime(1970, 5, 6), DtUnit.Day) will return 6 + + :param datetime dateTime: date/time to pick from + :param DtUnit unit: The unit describing the date element. + """ + if unit == DtUnit.YEARS: + return dateTime.year + elif unit == DtUnit.MONTHS: + return dateTime.month + elif unit == DtUnit.DAYS: + return dateTime.day + elif unit == DtUnit.HOURS: + return dateTime.hour + elif unit == DtUnit.MINUTES: + return dateTime.minute + elif unit == DtUnit.SECONDS: + return dateTime.second + elif unit == DtUnit.MICRO_SECONDS: + return dateTime.microsecond + else: + raise ValueError("Unexpected DtUnit: {}".format(unit)) + + +def setDateElement(dateTime, value, unit): + """ Returns a copy of dateTime with the tickStep unit set to value + + :param datetime.datetime: date time object + :param int value: value to set + :param DtUnit unit: unit + :return: datetime.datetime + """ + intValue = int(value) + _logger.debug("setDateElement({}, {} (int={}), {})" + .format(dateTime, value, intValue, unit)) + + year = dateTime.year + month = dateTime.month + day = dateTime.day + hour = dateTime.hour + minute = dateTime.minute + second = dateTime.second + microsecond = dateTime.microsecond + + if unit == DtUnit.YEARS: + year = intValue + elif unit == DtUnit.MONTHS: + month = intValue + elif unit == DtUnit.DAYS: + day = intValue + elif unit == DtUnit.HOURS: + hour = intValue + elif unit == DtUnit.MINUTES: + minute = intValue + elif unit == DtUnit.SECONDS: + second = intValue + elif unit == DtUnit.MICRO_SECONDS: + microsecond = intValue + else: + raise ValueError("Unexpected DtUnit: {}".format(unit)) + + _logger.debug("creating date time {}" + .format((year, month, day, hour, minute, second, microsecond))) + + return dt.datetime(year, month, day, hour, minute, second, microsecond, + tzinfo=dateTime.tzinfo) + + + +def roundToElement(dateTime, unit): + """ Returns a copy of dateTime with the + + :param datetime.datetime: date time object + :param DtUnit unit: unit + :return: datetime.datetime + """ + year = dateTime.year + month = dateTime.month + day = dateTime.day + hour = dateTime.hour + minute = dateTime.minute + second = dateTime.second + microsecond = dateTime.microsecond + + if unit.value < DtUnit.YEARS.value: + pass # Never round years + if unit.value < DtUnit.MONTHS.value: + month = 1 + if unit.value < DtUnit.DAYS.value: + day = 1 + if unit.value < DtUnit.HOURS.value: + hour = 0 + if unit.value < DtUnit.MINUTES.value: + minute = 0 + if unit.value < DtUnit.SECONDS.value: + second = 0 + if unit.value < DtUnit.MICRO_SECONDS.value: + microsecond = 0 + + result = dt.datetime(year, month, day, hour, minute, second, microsecond, + tzinfo=dateTime.tzinfo) + + return result + + +def addValueToDate(dateTime, value, unit): + """ Adds a value with unit to a dateTime. + + Uses dateutil.relativedelta.relativedelta from the standard library to do + the actual math. This function doesn't allow for fractional month or years, + so month and year are truncated to integers before adding. + + :param datetime dateTime: date time + :param float value: value to be added + :param DtUnit unit: of the value + :return: + """ + #logger.debug("addValueToDate({}, {}, {})".format(dateTime, value, unit)) + + if unit == DtUnit.YEARS: + intValue = int(value) # floats not implemented in relativeDelta(years) + return dateTime + relativedelta(years=intValue) + elif unit == DtUnit.MONTHS: + intValue = int(value) # floats not implemented in relativeDelta(mohths) + return dateTime + relativedelta(months=intValue) + elif unit == DtUnit.DAYS: + return dateTime + relativedelta(days=value) + elif unit == DtUnit.HOURS: + return dateTime + relativedelta(hours=value) + elif unit == DtUnit.MINUTES: + return dateTime + relativedelta(minutes=value) + elif unit == DtUnit.SECONDS: + return dateTime + relativedelta(seconds=value) + elif unit == DtUnit.MICRO_SECONDS: + return dateTime + relativedelta(microseconds=value) + else: + raise ValueError("Unexpected DtUnit: {}".format(unit)) + + +def bestUnit(durationInSeconds): + """ Gets the best tick spacing given a duration in seconds. + + :param durationInSeconds: time span duration in seconds + :return: DtUnit enumeration. + """ + + # Based on; https://stackoverflow.com/a/2144398/ + # If the duration is longer than two years the tick spacing will be in + # years. Else, if the duration is longer than two months, the spacing will + # be in months, Etcetera. + # + # This factor differs per unit. As a baseline it is 2, but for instance, + # for Months this needs to be higher (3>), This because it is impossible to + # have partial months so the tick spacing is always at least 1 month. A + # duration of two months would result in two ticks, which is too few. + # months would then results + + if durationInSeconds > SECONDS_PER_YEAR * 3: + return (durationInSeconds / SECONDS_PER_YEAR, DtUnit.YEARS) + elif durationInSeconds > SECONDS_PER_MONTH_AVERAGE * 3: + return (durationInSeconds / SECONDS_PER_MONTH_AVERAGE, DtUnit.MONTHS) + elif durationInSeconds > SECONDS_PER_DAY * 2: + return (durationInSeconds / SECONDS_PER_DAY, DtUnit.DAYS) + elif durationInSeconds > SECONDS_PER_HOUR * 2: + return (durationInSeconds / SECONDS_PER_HOUR, DtUnit.HOURS) + elif durationInSeconds > SECONDS_PER_MINUTE * 2: + return (durationInSeconds / SECONDS_PER_MINUTE, DtUnit.MINUTES) + elif durationInSeconds > 1 * 2: + return (durationInSeconds, DtUnit.SECONDS) + else: + return (durationInSeconds * MICROSECONDS_PER_SECOND, + DtUnit.MICRO_SECONDS) + + +NICE_DATE_VALUES = { + DtUnit.YEARS: [1, 2, 5, 10], + DtUnit.MONTHS: [1, 2, 3, 4, 6, 12], + DtUnit.DAYS: [1, 2, 3, 7, 14, 28], + DtUnit.HOURS: [1, 2, 3, 4, 6, 12], + DtUnit.MINUTES: [1, 2, 3, 5, 10, 15, 30], + DtUnit.SECONDS: [1, 2, 3, 5, 10, 15, 30], + DtUnit.MICRO_SECONDS : [1.0, 2.0, 5.0, 10.0], # floats for microsec +} + + +def bestFormatString(spacing, unit): + """ Finds the best format string given the spacing and DtUnit. + + If the spacing is a fractional number < 1 the format string will take this + into account + + :param spacing: spacing between ticks + :param DtUnit unit: + :return: Format string for use in strftime + :rtype: str + """ + isSmall = spacing < 1 + + if unit == DtUnit.YEARS: + return "%Y-m" if isSmall else "%Y" + elif unit == DtUnit.MONTHS: + return "%Y-%m-%d" if isSmall else "%Y-%m" + elif unit == DtUnit.DAYS: + return "%H:%M" if isSmall else "%Y-%m-%d" + elif unit == DtUnit.HOURS: + return "%H:%M" if isSmall else "%H:%M" + elif unit == DtUnit.MINUTES: + return "%H:%M:%S" if isSmall else "%H:%M" + elif unit == DtUnit.SECONDS: + return "%S.%f" if isSmall else "%H:%M:%S" + elif unit == DtUnit.MICRO_SECONDS: + return "%S.%f" + else: + raise ValueError("Unexpected DtUnit: {}".format(unit)) + + +def niceDateTimeElement(value, unit, isRound=False): + """ Uses the Nice Numbers algorithm to determine a nice value. + + The fractions are optimized for the unit of the date element. + """ + + niceValues = NICE_DATE_VALUES[unit] + elemValue = niceNumGeneric(value, niceValues, isRound=isRound) + + if unit == DtUnit.YEARS or unit == DtUnit.MONTHS: + elemValue = max(1, int(elemValue)) + + return elemValue + + +def findStartDate(dMin, dMax, nTicks): + """ Rounds a date down to the nearest nice number of ticks + """ + assert dMax > dMin, \ + "dMin ({}) should come before dMax ({})".format(dMin, dMax) + + delta = dMax - dMin + lengthSec = delta.total_seconds() + _logger.debug("findStartDate: {}, {} (duration = {} sec, {} days)" + .format(dMin, dMax, lengthSec, lengthSec / SECONDS_PER_DAY)) + + length, unit = bestUnit(delta.total_seconds()) + niceLength = niceDateTimeElement(length, unit) + + _logger.debug("Length: {:8.3f} {} (nice = {})" + .format(length, unit.name, niceLength)) + + niceSpacing = niceDateTimeElement(niceLength / nTicks, unit, isRound=True) + + _logger.debug("Spacing: {:8.3f} {} (nice = {})" + .format(niceLength / nTicks, unit.name, niceSpacing)) + + dVal = getDateElement(dMin, unit) + + if unit == DtUnit.MONTHS: # TODO: better rounding? + niceVal = math.floor((dVal-1) / niceSpacing) * niceSpacing + 1 + elif unit == DtUnit.DAYS: + niceVal = math.floor((dVal-1) / niceSpacing) * niceSpacing + 1 + else: + niceVal = math.floor(dVal / niceSpacing) * niceSpacing + + _logger.debug("StartValue: dVal = {}, niceVal: {} ({})" + .format(dVal, niceVal, unit.name)) + + startDate = roundToElement(dMin, unit) + startDate = setDateElement(startDate, niceVal, unit) + + return startDate, niceSpacing, unit + + +def dateRange(dMin, dMax, step, unit, includeFirstBeyond = False): + """ Generates a range of dates + + :param datetime dMin: start date + :param datetime dMax: end date + :param int step: the step size + :param DtUnit unit: the unit of the step size + :param bool includeFirstBeyond: if True the first date later than dMax will + be included in the range. If False (the default), the last generated + datetime will always be smaller than dMax. + :return: + """ + if (unit == DtUnit.YEARS or unit == DtUnit.MONTHS or + unit == DtUnit.MICRO_SECONDS): + + # Month and years will be converted to integers + assert int(step) > 0, "Integer value or tickstep is 0" + else: + assert step > 0, "tickstep is 0" + + dateTime = dMin + while dateTime < dMax: + yield dateTime + dateTime = addValueToDate(dateTime, step, unit) + + if includeFirstBeyond: + yield dateTime + + + +def calcTicks(dMin, dMax, nTicks): + """Returns tick positions. + + :param datetime.datetime dMin: The min value on the axis + :param datetime.datetime dMax: The max value on the axis + :param int nTicks: The target number of ticks. The actual number of found + ticks may differ. + :returns: (list of datetimes, DtUnit) tuple + """ + _logger.debug("Calc calcTicks({}, {}, nTicks={})" + .format(dMin, dMax, nTicks)) + + startDate, niceSpacing, unit = findStartDate(dMin, dMax, nTicks) + + result = [] + for d in dateRange(startDate, dMax, niceSpacing, unit, + includeFirstBeyond=True): + result.append(d) + + assert result[0] <= dMin, \ + "First nice date ({}) should be <= dMin {}".format(result[0], dMin) + + assert result[-1] >= dMax, \ + "Last nice date ({}) should be >= dMax {}".format(result[-1], dMax) + + return result, niceSpacing, unit + + +def calcTicksAdaptive(dMin, dMax, axisLength, tickDensity): + """ Calls calcTicks with a variable number of ticks, depending on axisLength + """ + # At least 2 ticks + nticks = max(2, int(round(tickDensity * axisLength))) + return calcTicks(dMin, dMax, nticks) + + + + + diff --git a/silx/gui/plot/_utils/test/__init__.py b/silx/gui/plot/_utils/test/__init__.py index 4a443ac..624dbcb 100644 --- a/silx/gui/plot/_utils/test/__init__.py +++ b/silx/gui/plot/_utils/test/__init__.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# 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 @@ -32,10 +32,12 @@ __date__ = "18/10/2016" import unittest +from .test_dtime_ticklayout import suite as test_dtime_ticklayout_suite from .test_ticklayout import suite as test_ticklayout_suite def suite(): testsuite = unittest.TestSuite() + testsuite.addTest(test_dtime_ticklayout_suite()) testsuite.addTest(test_ticklayout_suite()) return testsuite diff --git a/silx/gui/plot/_utils/test/testColormap.py b/silx/gui/plot/_utils/test/testColormap.py new file mode 100644 index 0000000..d77fa65 --- /dev/null +++ b/silx/gui/plot/_utils/test/testColormap.py @@ -0,0 +1,648 @@ +# 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. +# +# ###########################################################################*/ + +import logging +import time +import unittest + +import numpy +from PyMca5 import spslut + +from silx.image.colormap import dataToRGBAColormap + +_logger = logging.getLogger(__name__) + +# TODOs: +# what to do with max < min: as SPS LUT or also invert outside boundaries? +# test usedMin and usedMax +# benchmark + + +# common ###################################################################### + +class _TestColormap(unittest.TestCase): + # Array data types to test + FLOATING_DTYPES = numpy.float16, numpy.float32, numpy.float64 + SIGNED_DTYPES = FLOATING_DTYPES + (numpy.int8, numpy.int16, + numpy.int32, numpy.int64) + UNSIGNED_DTYPES = numpy.uint8, numpy.uint16, numpy.uint32, numpy.uint64 + DTYPES = SIGNED_DTYPES + UNSIGNED_DTYPES + + # Array sizes to test + SIZES = 2, 10, 256, 1024 # , 2048, 4096 + + # Colormaps definitions + _LUT_RED_256 = numpy.zeros((256, 4), dtype=numpy.uint8) + _LUT_RED_256[:, 0] = numpy.arange(256, dtype=numpy.uint8) + _LUT_RED_256[:, 3] = 255 + + _LUT_RGB_3 = numpy.array(((255, 0, 0, 255), + (0, 255, 0, 255), + (0, 0, 255, 255)), dtype=numpy.uint8) + + _LUT_RGB_768 = numpy.zeros((768, 4), dtype=numpy.uint8) + _LUT_RGB_768[0:256, 0] = numpy.arange(256, dtype=numpy.uint8) + _LUT_RGB_768[256:512, 1] = numpy.arange(256, dtype=numpy.uint8) + _LUT_RGB_768[512:768, 1] = numpy.arange(256, dtype=numpy.uint8) + _LUT_RGB_768[:, 3] = 255 + + COLORMAPS = { + 'red 256': _LUT_RED_256, + 'rgb 3': _LUT_RGB_3, + 'rgb 768': _LUT_RGB_768, + } + + @staticmethod + def _log(*args): + """Logging used by test for debugging.""" + _logger.debug(str(args)) + + @staticmethod + def buildControlPixmap(data, colormap, start=None, end=None, + isLog10=False): + """Generate a pixmap used to test C pixmap.""" + if isLog10: # Convert to log + if start is None: + posValue = data[numpy.nonzero(data > 0)] + if posValue.size != 0: + start = numpy.nanmin(posValue) + else: + start = 0. + + if end is None: + end = numpy.nanmax(data) + + start = 0. if start <= 0. else numpy.log10(start, + dtype=numpy.float64) + end = 0. if end <= 0. else numpy.log10(end, + dtype=numpy.float64) + + data = numpy.log10(data, dtype=numpy.float64) + else: + if start is None: + start = numpy.nanmin(data) + if end is None: + end = numpy.nanmax(data) + + start, end = float(start), float(end) + min_, max_ = min(start, end), max(start, end) + + if start == end: + indices = numpy.asarray((len(colormap) - 1) * (data >= max_), + dtype=numpy.int) + else: + clipData = numpy.clip(data, min_, max_) # Clip first avoid overflow + scale = len(colormap) / (end - start) + normData = scale * (numpy.asarray(clipData, numpy.float64) - start) + + # Clip again to makes sure <= len(colormap) - 1 + indices = numpy.asarray(numpy.clip(normData, + 0, len(colormap) - 1), + dtype=numpy.uint32) + + pixmap = numpy.take(colormap, indices, axis=0) + pixmap.shape = data.shape + (4,) + return numpy.ascontiguousarray(pixmap) + + @staticmethod + def buildSPSLUTRedPixmap(data, start=None, end=None, isLog10=False): + """Generate a pixmap with SPS LUT. + Only supports red colormap with 256 colors. + """ + colormap = spslut.RED + mapping = spslut.LOG if isLog10 else spslut.LINEAR + + if start is None and end is None: + autoScale = 1 + start, end = 0, 1 + else: + autoScale = 0 + if start is None: + start = data.min() + if end is None: + end = data.max() + + pixmap, size, minMax = spslut.transform(data, + (1, 0), + (mapping, 3.0), + 'RGBX', + colormap, + autoScale, + (start, end), + (0, 255), + 1) + pixmap.shape = data.shape[0], data.shape[1], 4 + + return pixmap + + def _testColormap(self, data, colormap, start, end, control=None, + isLog10=False, nanColor=None): + """Test pixmap built with C code against SPS LUT if possible, + else against Python control code.""" + startTime = time.time() + pixmap = dataToRGBAColormap(data, + colormap, + start, + end, + isLog10, + nanColor) + duration = time.time() - startTime + + # Compare with result + controlType = 'array' + if control is None: + startTime = time.time() + + # Compare with SPS LUT if possible + if (colormap.shape == self.COLORMAPS['red 256'].shape and + numpy.all(numpy.equal(colormap, self.COLORMAPS['red 256'])) and + data.size % 2 == 0 and + data.dtype in (numpy.float32, numpy.float64)): + # Only works with red colormap and even size + # as it needs 2D data + if len(data.shape) == 1: + data.shape = data.size // 2, -1 + pixmap.shape = data.shape + (4,) + control = self.buildSPSLUTRedPixmap(data, start, end, isLog10) + controlType = 'SPS LUT' + + # Compare with python test implementation + else: + control = self.buildControlPixmap(data, colormap, start, end, + isLog10) + controlType = 'Python control code' + + controlDuration = time.time() - startTime + if duration >= controlDuration: + self._log('duration', duration, 'control', controlDuration) + # Allows duration to be 20% over SPS LUT duration + # self.assertTrue(duration < 1.2 * controlDuration) + + difference = numpy.fabs(numpy.asarray(pixmap, dtype=numpy.float64) - + numpy.asarray(control, dtype=numpy.float64)) + if numpy.any(difference != 0.0): + self._log('control', controlType) + self._log('data', data) + self._log('pixmap', pixmap) + self._log('control', control) + self._log('errors', numpy.ravel(difference)) + self._log('errors', difference[difference != 0]) + self._log('in pixmap', pixmap[difference != 0]) + self._log('in control', control[difference != 0]) + self._log('Max error', difference.max()) + + # Allows a difference of 1 per channel + self.assertTrue(numpy.all(difference <= 1.0)) + + return duration + + +# TestColormap ################################################################ + +class TestColormap(_TestColormap): + """Test common limit case for colormap in C with both linear and log mode. + + Test with different: data types, sizes, colormaps (with different sizes), + mapping range. + """ + + def testNoData(self): + """Test pixmap generation with empty data.""" + self._log("TestColormap.testNoData") + cmapName = 'red 256' + colormap = self.COLORMAPS[cmapName] + + for dtype in self.DTYPES: + for isLog10 in (False, True): + data = numpy.array((), dtype=dtype) + result = numpy.array((), dtype=numpy.uint8) + result.shape = 0, 4 + duration = self._testColormap(data, colormap, + None, None, result, isLog10) + self._log('No data', 'red 256', dtype, len(data), (None, None), + 'isLog10:', isLog10, duration) + + def testNaN(self): + """Test pixmap generation with NaN values and no NaN color.""" + self._log("TestColormap.testNaN") + cmapName = 'red 256' + colormap = self.COLORMAPS[cmapName] + + for dtype in self.FLOATING_DTYPES: + for isLog10 in (False, True): + # All NaNs + data = numpy.array((float('nan'),) * 4, dtype=dtype) + result = numpy.array(((0, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, colormap, + None, None, result, isLog10) + self._log('All NaNs', 'red 256', dtype, len(data), + (None, None), 'isLog10:', isLog10, duration) + + # Some NaNs + data = numpy.array((1., float('nan'), 0., float('nan')), + dtype=dtype) + result = numpy.array(((255, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, colormap, + None, None, result, isLog10) + self._log('Some NaNs', 'red 256', dtype, len(data), + (None, None), 'isLog10:', isLog10, duration) + + def testNaNWithColor(self): + """Test pixmap generation with NaN values with a NaN color.""" + self._log("TestColormap.testNaNWithColor") + cmapName = 'red 256' + colormap = self.COLORMAPS[cmapName] + + for dtype in self.FLOATING_DTYPES: + for isLog10 in (False, True): + # All NaNs + data = numpy.array((float('nan'),) * 4, dtype=dtype) + result = numpy.array(((128, 128, 128, 255), + (128, 128, 128, 255), + (128, 128, 128, 255), + (128, 128, 128, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, colormap, + None, None, result, isLog10, + nanColor=(128, 128, 128, 255)) + self._log('All NaNs', 'red 256', dtype, len(data), + (None, None), 'isLog10:', isLog10, duration) + + # Some NaNs + data = numpy.array((1., float('nan'), 0., float('nan')), + dtype=dtype) + result = numpy.array(((255, 0, 0, 255), + (128, 128, 128, 255), + (0, 0, 0, 255), + (128, 128, 128, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, colormap, + None, None, result, isLog10, + nanColor=(128, 128, 128, 255)) + self._log('Some NaNs', 'red 256', dtype, len(data), + (None, None), 'isLog10:', isLog10, duration) + + +# TestLinearColormap ########################################################## + +class TestLinearColormap(_TestColormap): + """Test fill pixmap with colormap in C with linear mode. + + Test with different: data types, sizes, colormaps (with different sizes), + mapping range. + """ + + # Colormap ranges to map + RANGES = (None, None), (1, 10) + + def test1DData(self): + """Test pixmap generation for 1D data of different size and types.""" + self._log("TestLinearColormap.test1DData") + for cmapName, colormap in self.COLORMAPS.items(): + for size in self.SIZES: + for dtype in self.DTYPES: + for start, end in self.RANGES: + # Increasing values + data = numpy.arange(size, dtype=dtype) + duration = self._testColormap(data, colormap, + start, end) + + self._log('1D', cmapName, dtype, size, (start, end), + duration) + + # Reverse order + data = data[::-1] + duration = self._testColormap(data, colormap, + start, end) + + self._log('1D', cmapName, dtype, size, (start, end), + duration) + + def test2DData(self): + """Test pixmap generation for 2D data of different size and types.""" + self._log("TestLinearColormap.test2DData") + for cmapName, colormap in self.COLORMAPS.items(): + for size in self.SIZES: + for dtype in self.DTYPES: + for start, end in self.RANGES: + # Increasing values + data = numpy.arange(size * size, dtype=dtype) + data = numpy.nan_to_num(data) + data.shape = size, size + duration = self._testColormap(data, colormap, + start, end) + + self._log('2D', cmapName, dtype, size, (start, end), + duration) + + # Reverse order + data = data[::-1, ::-1] + duration = self._testColormap(data, colormap, + start, end) + + self._log('2D', cmapName, dtype, size, (start, end), + duration) + + def testInf(self): + """Test pixmap generation with Inf values.""" + self._log("TestLinearColormap.testInf") + + for dtype in self.FLOATING_DTYPES: + # All positive Inf + data = numpy.array((float('inf'),) * 4, dtype=dtype) + result = numpy.array(((255, 0, 0, 255), + (255, 0, 0, 255), + (255, 0, 0, 255), + (255, 0, 0, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, self.COLORMAPS['red 256'], + None, None, result) + self._log('All +Inf', 'red 256', dtype, len(data), (None, None), + duration) + + # All negative Inf + data = numpy.array((float('-inf'),) * 4, dtype=dtype) + result = numpy.array(((255, 0, 0, 255), + (255, 0, 0, 255), + (255, 0, 0, 255), + (255, 0, 0, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, self.COLORMAPS['red 256'], + None, None, result) + self._log('All -Inf', 'red 256', dtype, len(data), (None, None), + duration) + + # All +/-Inf + data = numpy.array((float('inf'), float('-inf'), + float('-inf'), float('inf')), dtype=dtype) + result = numpy.array(((255, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255), + (255, 0, 0, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, self.COLORMAPS['red 256'], + None, None, result) + self._log('All +/-Inf', 'red 256', dtype, len(data), (None, None), + duration) + + # Some +/-Inf + data = numpy.array((float('inf'), 0., float('-inf'), -10.), + dtype=dtype) + result = numpy.array(((255, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, self.COLORMAPS['red 256'], + None, None, + result) # Seg Fault with SPS + self._log('Some +/-Inf', 'red 256', dtype, len(data), (None, None), + duration) + + @unittest.skip("Not for reproductible tests") + def test1DDataRandom(self): + """Test pixmap generation for 1D data of different size and types.""" + self._log("TestLinearColormap.test1DDataRandom") + for cmapName, colormap in self.COLORMAPS.items(): + for size in self.SIZES: + for dtype in self.DTYPES: + for start, end in self.RANGES: + try: + dtypeMax = numpy.iinfo(dtype).max + except ValueError: + dtypeMax = numpy.finfo(dtype).max + data = numpy.asarray(numpy.random.rand(size) * dtypeMax, + dtype=dtype) + duration = self._testColormap(data, colormap, + start, end) + + self._log('1D Random', cmapName, dtype, size, + (start, end), duration) + + +# TestLog10Colormap ########################################################### + +class TestLog10Colormap(_TestColormap): + """Test fill pixmap with colormap in C with log mode. + + Test with different: data types, sizes, colormaps (with different sizes), + mapping range. + """ + # Colormap ranges to map + RANGES = (None, None), (1, 10) # , (10, 1) + + def test1DDataAllPositive(self): + """Test pixmap generation for all positive 1D data.""" + self._log("TestLog10Colormap.test1DDataAllPositive") + for cmapName, colormap in self.COLORMAPS.items(): + for size in self.SIZES: + for dtype in self.DTYPES: + for start, end in self.RANGES: + # Increasing values + data = numpy.arange(size, dtype=dtype) + 1 + duration = self._testColormap(data, colormap, + start, end, + isLog10=True) + + self._log('1D', cmapName, dtype, size, (start, end), + duration) + + # Reverse order + data = data[::-1] + duration = self._testColormap(data, colormap, + start, end, + isLog10=True) + + self._log('1D', cmapName, dtype, size, (start, end), + duration) + + def test2DDataAllPositive(self): + """Test pixmap generation for all positive 2D data.""" + self._log("TestLog10Colormap.test2DDataAllPositive") + for cmapName, colormap in self.COLORMAPS.items(): + for size in self.SIZES: + for dtype in self.DTYPES: + for start, end in self.RANGES: + # Increasing values + data = numpy.arange(size * size, dtype=dtype) + 1 + data = numpy.nan_to_num(data) + data.shape = size, size + duration = self._testColormap(data, colormap, + start, end, + isLog10=True) + + self._log('2D', cmapName, dtype, size, (start, end), + duration) + + # Reverse order + data = data[::-1, ::-1] + duration = self._testColormap(data, colormap, + start, end, + isLog10=True) + + self._log('2D', cmapName, dtype, size, (start, end), + duration) + + def testAllNegative(self): + """Test pixmap generation for all negative 1D data.""" + self._log("TestLog10Colormap.testAllNegative") + for cmapName, colormap in self.COLORMAPS.items(): + for size in self.SIZES: + for dtype in self.SIGNED_DTYPES: + for start, end in self.RANGES: + # Increasing values + data = numpy.arange(-size, 0, dtype=dtype) + duration = self._testColormap(data, colormap, + start, end, + isLog10=True) + + self._log('1D', cmapName, dtype, size, (start, end), + duration) + + # Reverse order + data = data[::-1] + duration = self._testColormap(data, colormap, + start, end, + isLog10=True) + + self._log('1D', cmapName, dtype, size, (start, end), + duration) + + def testCrossingZero(self): + """Test pixmap generation for 1D data with negative and zero.""" + self._log("TestLog10Colormap.testCrossingZero") + for cmapName, colormap in self.COLORMAPS.items(): + for size in self.SIZES: + for dtype in self.SIGNED_DTYPES: + for start, end in self.RANGES: + # Increasing values + data = numpy.arange(-size/2, size/2 + 1, dtype=dtype) + duration = self._testColormap(data, colormap, + start, end, + isLog10=True) + + self._log('1D', cmapName, dtype, size, (start, end), + duration) + + # Reverse order + data = data[::-1] + duration = self._testColormap(data, colormap, + start, end, + isLog10=True) + + self._log('1D', cmapName, dtype, size, (start, end), + duration) + + @unittest.skip("Not for reproductible tests") + def test1DDataRandom(self): + """Test pixmap generation for 1D data of different size and types.""" + self._log("TestLog10Colormap.test1DDataRandom") + for cmapName, colormap in self.COLORMAPS.items(): + for size in self.SIZES: + for dtype in self.DTYPES: + for start, end in self.RANGES: + try: + dtypeMax = numpy.iinfo(dtype).max + dtypeMin = numpy.iinfo(dtype).min + except ValueError: + dtypeMax = numpy.finfo(dtype).max + dtypeMin = numpy.finfo(dtype).min + if dtypeMin < 0: + data = numpy.asarray(-dtypeMax/2. + + numpy.random.rand(size) * dtypeMax, + dtype=dtype) + else: + data = numpy.asarray(numpy.random.rand(size) * dtypeMax, + dtype=dtype) + + duration = self._testColormap(data, colormap, + start, end, + isLog10=True) + + self._log('1D Random', cmapName, dtype, size, + (start, end), duration) + + def testInf(self): + """Test pixmap generation with Inf values.""" + self._log("TestLog10Colormap.testInf") + + for dtype in self.FLOATING_DTYPES: + # All positive Inf + data = numpy.array((float('inf'),) * 4, dtype=dtype) + result = numpy.array(((255, 0, 0, 255), + (255, 0, 0, 255), + (255, 0, 0, 255), + (255, 0, 0, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, self.COLORMAPS['red 256'], + None, None, result, isLog10=True) + self._log('All +Inf', 'red 256', dtype, len(data), (None, None), + duration) + + # All negative Inf + data = numpy.array((float('-inf'),) * 4, dtype=dtype) + result = numpy.array(((0, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, self.COLORMAPS['red 256'], + None, None, result, isLog10=True) + self._log('All -Inf', 'red 256', dtype, len(data), (None, None), + duration) + + # All +/-Inf + data = numpy.array((float('inf'), float('-inf'), + float('-inf'), float('inf')), dtype=dtype) + result = numpy.array(((255, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255), + (255, 0, 0, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, self.COLORMAPS['red 256'], + None, None, result, isLog10=True) + self._log('All +/-Inf', 'red 256', dtype, len(data), (None, None), + duration) + + # Some +/-Inf + data = numpy.array((float('inf'), 0., float('-inf'), -10.), + dtype=dtype) + result = numpy.array(((255, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, self.COLORMAPS['red 256'], + None, None, result, isLog10=True) + self._log('Some +/-Inf', 'red 256', dtype, len(data), (None, None), + duration) + + +def suite(): + testSuite = unittest.TestSuite() + for testClass in (TestColormap, TestLinearColormap): # , TestLog10Colormap): + testSuite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(testClass)) + return testSuite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/plot/_utils/test/test_dtime_ticklayout.py b/silx/gui/plot/_utils/test/test_dtime_ticklayout.py new file mode 100644 index 0000000..2b87148 --- /dev/null +++ b/silx/gui/plot/_utils/test/test_dtime_ticklayout.py @@ -0,0 +1,93 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-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. +# +# ###########################################################################*/ + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["P. Kenter"] +__license__ = "MIT" +__date__ = "06/04/2018" + + +import datetime as dt +import unittest + + +from silx.gui.plot._utils.dtime_ticklayout import ( + calcTicks, DtUnit, SECONDS_PER_YEAR) + + +class DtTestTickLayout(unittest.TestCase): + """Test ticks layout algorithms""" + + def testSmallMonthlySpacing(self): + """ Tests a range that did result in a spacing of less than 1 month. + It is impossible to add fractional month so the unit must be in days + """ + from dateutil import parser + d1 = parser.parse("2017-01-03 13:15:06.000044") + d2 = parser.parse("2017-03-08 09:16:16.307584") + _ticks, _units, spacing = calcTicks(d1, d2, nTicks=4) + + self.assertEqual(spacing, DtUnit.DAYS) + + + def testNoCrash(self): + """ Creates many combinations of and number-of-ticks and end-dates; + tests that it doesn't give an exception and returns a reasonable number + of ticks. + """ + d1 = dt.datetime(2017, 1, 3, 13, 15, 6, 44) + + value = 100e-6 # Start at 100 micro sec range. + + while value <= 200 * SECONDS_PER_YEAR: + + d2 = d1 + dt.timedelta(microseconds=value*1e6) # end date range + + for numTicks in range(2, 12): + ticks, _, _ = calcTicks(d1, d2, numTicks) + + margin = 2.5 + self.assertTrue( + numTicks/margin <= len(ticks) <= numTicks*margin, + "Condition {} <= {} <= {} failed for # ticks={} and d2={}:" + .format(numTicks/margin, len(ticks), numTicks * margin, + numTicks, d2)) + + value = value * 1.5 # let date period grow exponentially + + + + + +def suite(): + testsuite = unittest.TestSuite() + testsuite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(DtTestTickLayout)) + return testsuite + + +if __name__ == '__main__': + unittest.main() diff --git a/silx/gui/plot/_utils/ticklayout.py b/silx/gui/plot/_utils/ticklayout.py index 6e9f654..c9fd3e6 100644 --- a/silx/gui/plot/_utils/ticklayout.py +++ b/silx/gui/plot/_utils/ticklayout.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# Copyright (c) 2014-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 @@ -51,28 +51,65 @@ def numberOfDigits(tickSpacing): # Nice Numbers ################################################################ -def _niceNum(value, isRound=False): - expvalue = math.floor(math.log10(value)) - frac = value/pow(10., expvalue) - if isRound: - if frac < 1.5: - nicefrac = 1. - elif frac < 3.: - nicefrac = 2. - elif frac < 7.: - nicefrac = 5. - else: - nicefrac = 10. +# This is the original niceNum implementation. For the date time ticks a more +# generic implementation was needed. +# +# def _niceNum(value, isRound=False): +# expvalue = math.floor(math.log10(value)) +# frac = value/pow(10., expvalue) +# if isRound: +# if frac < 1.5: +# nicefrac = 1. +# elif frac < 3.: # In niceNumGeneric this is (2+5)/2 = 3.5 +# nicefrac = 2. +# elif frac < 7.: +# nicefrac = 5. # In niceNumGeneric this is (5+10)/2 = 7.5 +# else: +# nicefrac = 10. +# else: +# if frac <= 1.: +# nicefrac = 1. +# elif frac <= 2.: +# nicefrac = 2. +# elif frac <= 5.: +# nicefrac = 5. +# else: +# nicefrac = 10. +# return nicefrac * pow(10., expvalue) + + +def niceNumGeneric(value, niceFractions=None, isRound=False): + """ A more generic implementation of the _niceNum function + + Allows the user to specify the fractions instead of using a hardcoded + list of [1, 2, 5, 10.0]. + """ + if value == 0: + return value + + if niceFractions is None: # Use default values + niceFractions = 1., 2., 5., 10. + roundFractions = (1.5, 3., 7., 10.) if isRound else niceFractions + else: - if frac <= 1.: - nicefrac = 1. - elif frac <= 2.: - nicefrac = 2. - elif frac <= 5.: - nicefrac = 5. - else: - nicefrac = 10. - return nicefrac * pow(10., expvalue) + roundFractions = list(niceFractions) + if isRound: + # Take the average with the next element. The last remains the same. + for i in range(len(roundFractions) - 1): + roundFractions[i] = (niceFractions[i] + niceFractions[i+1]) / 2 + + highest = niceFractions[-1] + value = float(value) + + expvalue = math.floor(math.log(value, highest)) + frac = value / pow(highest, expvalue) + + for niceFrac, roundFrac in zip(niceFractions, roundFractions): + if frac <= roundFrac: + return niceFrac * pow(highest, expvalue) + + # should not come here + assert False, "should not come here" def niceNumbers(vMin, vMax, nTicks=5): @@ -89,8 +126,8 @@ def niceNumbers(vMin, vMax, nTicks=5): number of fractional digit to show :rtype: tuple """ - vrange = _niceNum(vMax - vMin, False) - spacing = _niceNum(vrange / nTicks, True) + vrange = niceNumGeneric(vMax - vMin, isRound=False) + spacing = niceNumGeneric(vrange / nTicks, isRound=True) graphmin = math.floor(vMin / spacing) * spacing graphmax = math.ceil(vMax / spacing) * spacing nfrac = numberOfDigits(spacing) diff --git a/silx/gui/plot/actions/control.py b/silx/gui/plot/actions/control.py index ac6dc2f..6e08f21 100644 --- a/silx/gui/plot/actions/control.py +++ b/silx/gui/plot/actions/control.py @@ -50,12 +50,11 @@ from __future__ import division __authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] __license__ = "MIT" -__date__ = "15/02/2018" +__date__ = "24/04/2018" from . import PlotAction import logging from silx.gui.plot import items -from silx.gui.plot.ColormapDialog import ColormapDialog from silx.gui.plot._utils import applyZoomToPlot as _applyZoomToPlot from silx.gui import qt from silx.gui import icons @@ -328,6 +327,7 @@ class ColormapAction(PlotAction): triggered=self._actionTriggered, checkable=True, parent=parent) self.plot.sigActiveImageChanged.connect(self._updateColormap) + self.plot.sigActiveScatterChanged.connect(self._updateColormap) def setColorDialog(self, colorDialog): """Set a specific color dialog instead of using the default dialog.""" @@ -344,6 +344,7 @@ class ColormapAction(PlotAction): :parent QWidget parent: Parent of the new colormap :rtype: ColormapDialog """ + from silx.gui.dialog.ColormapDialog import ColormapDialog dialog = ColormapDialog(parent=parent) dialog.setModal(False) return dialog @@ -393,10 +394,19 @@ class ColormapAction(PlotAction): else: # No active image or active image is RGBA, - # set dialog from default info - colormap = self.plot.getDefaultColormap() - # Reset histogram and range if any - self._dialog.setData(None) + # Check for active scatter plot + scatter = self.plot._getActiveItem(kind='scatter') + if scatter is not None: + colormap = scatter.getColormap() + data = scatter.getValueData(copy=False) + self._dialog.setData(data) + + else: + # No active data image nor scatter, + # set dialog from default info + colormap = self.plot.getDefaultColormap() + # Reset histogram and range if any + self._dialog.setData(None) self._dialog.setColormap(colormap) @@ -408,7 +418,7 @@ class ColorBarAction(PlotAction): :param parent: See :class:`QAction` """ def __init__(self, plot, parent=None): - self._dialog = None # To store an instance of ColormapDialog + self._dialog = None # To store an instance of ColorBar super(ColorBarAction, self).__init__( plot, icon='colorbar', text='Colorbar', tooltip="Show/Hide the colorbar", diff --git a/silx/gui/plot/actions/histogram.py b/silx/gui/plot/actions/histogram.py index 40ef873..d6e3269 100644 --- a/silx/gui/plot/actions/histogram.py +++ b/silx/gui/plot/actions/histogram.py @@ -34,7 +34,7 @@ The following QAction are available: from __future__ import division __authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] -__date__ = "27/06/2017" +__date__ = "30/04/2018" __license__ = "MIT" from . import PlotAction @@ -129,7 +129,7 @@ class PixelIntensitiesHistoAction(PlotAction): edges=edges, legend='pixel intensity', fill=True, - color='red') + color='#66aad7') plot.resetZoom() def eventFilter(self, qobject, event): diff --git a/silx/gui/plot/actions/io.py b/silx/gui/plot/actions/io.py index d6d5909..ac06942 100644 --- a/silx/gui/plot/actions/io.py +++ b/silx/gui/plot/actions/io.py @@ -44,13 +44,16 @@ from silx.io.utils import save1D, savespec from silx.io.nxdata import save_NXdata import logging import sys +import os.path from collections import OrderedDict import traceback import numpy -from silx.gui import qt +from silx.utils.deprecation import deprecated +from silx.gui import qt, printer +from silx.gui.dialog.GroupDialog import GroupDialog from silx.third_party.EdfFile import EdfFile from silx.third_party.TiffIO import TiffIO -from silx.gui._utils import convertArrayToQImage +from ...utils._image import convertArrayToQImage if sys.version_info[0] == 3: from io import BytesIO else: @@ -60,10 +63,26 @@ else: _logger = logging.getLogger(__name__) -_NEXUS_HDF5_EXT = [".nx5", ".nxs", ".hdf", ".hdf5", ".cxi", ".h5"] +_NEXUS_HDF5_EXT = [".h5", ".nx5", ".nxs", ".hdf", ".hdf5", ".cxi"] _NEXUS_HDF5_EXT_STR = ' '.join(['*' + ext for ext in _NEXUS_HDF5_EXT]) +def selectOutputGroup(h5filename): + """Open a dialog to prompt the user to select a group in + which to output data. + + :param str h5filename: name of an existing HDF5 file + :rtype: str + :return: Name of output group, or None if the dialog was cancelled + """ + dialog = GroupDialog() + dialog.addFile(h5filename) + dialog.setWindowTitle("Select an output group") + if not dialog.exec_(): + return None + return dialog.getSelectedDataUrl().data_path() + + class SaveAction(PlotAction): """QAction for saving Plot content. @@ -72,12 +91,11 @@ class SaveAction(PlotAction): :param plot: :class:`.PlotWidget` instance on which to operate. :param parent: See :class:`QAction`. """ - # TODO find a way to make the filter list selectable and extensible SNAPSHOT_FILTER_SVG = 'Plot Snapshot as SVG (*.svg)' SNAPSHOT_FILTER_PNG = 'Plot Snapshot as PNG (*.png)' - SNAPSHOT_FILTERS = (SNAPSHOT_FILTER_PNG, SNAPSHOT_FILTER_SVG) + DEFAULT_ALL_FILTERS = (SNAPSHOT_FILTER_PNG, SNAPSHOT_FILTER_SVG) # Dict of curve filters with CSV-like format # Using ordered dict to guarantee filters order @@ -101,10 +119,10 @@ class SaveAction(PlotAction): CURVE_FILTER_NXDATA = 'Curve as NXdata (%s)' % _NEXUS_HDF5_EXT_STR - CURVE_FILTERS = list(CURVE_FILTERS_TXT.keys()) + [CURVE_FILTER_NPY, - CURVE_FILTER_NXDATA] + DEFAULT_CURVE_FILTERS = list(CURVE_FILTERS_TXT.keys()) + [ + CURVE_FILTER_NPY, CURVE_FILTER_NXDATA] - ALL_CURVES_FILTERS = ("All curves as SpecFile (*.dat)", ) + DEFAULT_ALL_CURVES_FILTERS = ("All curves as SpecFile (*.dat)",) IMAGE_FILTER_EDF = 'Image data as EDF (*.edf)' IMAGE_FILTER_TIFF = 'Image data as TIFF (*.tif)' @@ -114,23 +132,53 @@ class SaveAction(PlotAction): IMAGE_FILTER_CSV_SEMICOLON = 'Image data as ;-separated CSV (*.csv)' IMAGE_FILTER_CSV_TAB = 'Image data as tab-separated CSV (*.csv)' IMAGE_FILTER_RGB_PNG = 'Image as PNG (*.png)' - IMAGE_FILTER_RGB_TIFF = 'Image as TIFF (*.tif)' IMAGE_FILTER_NXDATA = 'Image as NXdata (%s)' % _NEXUS_HDF5_EXT_STR - IMAGE_FILTERS = (IMAGE_FILTER_EDF, - IMAGE_FILTER_TIFF, - IMAGE_FILTER_NUMPY, - IMAGE_FILTER_ASCII, - IMAGE_FILTER_CSV_COMMA, - IMAGE_FILTER_CSV_SEMICOLON, - IMAGE_FILTER_CSV_TAB, - IMAGE_FILTER_RGB_PNG, - IMAGE_FILTER_RGB_TIFF, - IMAGE_FILTER_NXDATA) + DEFAULT_IMAGE_FILTERS = (IMAGE_FILTER_EDF, + IMAGE_FILTER_TIFF, + IMAGE_FILTER_NUMPY, + IMAGE_FILTER_ASCII, + IMAGE_FILTER_CSV_COMMA, + IMAGE_FILTER_CSV_SEMICOLON, + IMAGE_FILTER_CSV_TAB, + IMAGE_FILTER_RGB_PNG, + IMAGE_FILTER_NXDATA) SCATTER_FILTER_NXDATA = 'Scatter as NXdata (%s)' % _NEXUS_HDF5_EXT_STR - SCATTER_FILTERS = (SCATTER_FILTER_NXDATA, ) + DEFAULT_SCATTER_FILTERS = (SCATTER_FILTER_NXDATA,) + + # filters for which we don't want an "overwrite existing file" warning + DEFAULT_APPEND_FILTERS = (CURVE_FILTER_NXDATA, IMAGE_FILTER_NXDATA, + SCATTER_FILTER_NXDATA) def __init__(self, plot, parent=None): + self._filters = { + 'all': OrderedDict(), + 'curve': OrderedDict(), + 'curves': OrderedDict(), + 'image': OrderedDict(), + 'scatter': OrderedDict()} + + # Initialize filters + for nameFilter in self.DEFAULT_ALL_FILTERS: + self.setFileFilter( + dataKind='all', nameFilter=nameFilter, func=self._saveSnapshot) + + for nameFilter in self.DEFAULT_CURVE_FILTERS: + self.setFileFilter( + dataKind='curve', nameFilter=nameFilter, func=self._saveCurve) + + for nameFilter in self.DEFAULT_ALL_CURVES_FILTERS: + self.setFileFilter( + dataKind='curves', nameFilter=nameFilter, func=self._saveCurves) + + for nameFilter in self.DEFAULT_IMAGE_FILTERS: + self.setFileFilter( + dataKind='image', nameFilter=nameFilter, func=self._saveImage) + + for nameFilter in self.DEFAULT_SCATTER_FILTERS: + self.setFileFilter( + dataKind='scatter', nameFilter=nameFilter, func=self._saveScatter) + super(SaveAction, self).__init__( plot, icon='document-save', text='Save as...', tooltip='Save curve/image/plot snapshot dialog', @@ -148,7 +196,7 @@ class SaveAction(PlotAction): msg.setDetailedText(traceback.format_exc()) msg.exec_() - def _saveSnapshot(self, filename, nameFilter): + def _saveSnapshot(self, plot, filename, nameFilter): """Save a snapshot of the :class:`PlotWindow` widget. :param str filename: The name of the file to write @@ -165,10 +213,51 @@ class SaveAction(PlotAction): 'Saving plot snapshot failed: format not supported') return False - self.plot.saveGraph(filename, fileFormat=fileFormat) + plot.saveGraph(filename, fileFormat=fileFormat) return True - def _saveCurve(self, filename, nameFilter): + def _getAxesLabels(self, item): + # If curve has no associated label, get the default from the plot + xlabel = item.getXLabel() or self.plot.getXAxis().getLabel() + ylabel = item.getYLabel() or self.plot.getYAxis().getLabel() + return xlabel, ylabel + + def _selectWriteableOutputGroup(self, filename): + if os.path.exists(filename) and os.path.isfile(filename) \ + and os.access(filename, os.W_OK): + entryPath = selectOutputGroup(filename) + if entryPath is None: + _logger.info("Save operation cancelled") + return None + return entryPath + elif not os.path.exists(filename): + # create new entry in new file + return "/entry" + else: + self._errorMessage('Save failed (file access issue)\n') + return None + + def _saveCurveAsNXdata(self, curve, filename): + entryPath = self._selectWriteableOutputGroup(filename) + if entryPath is None: + return False + + xlabel, ylabel = self._getAxesLabels(curve) + + return save_NXdata( + filename, + nxentry_name=entryPath, + signal=curve.getYData(copy=False), + axes=[curve.getXData(copy=False)], + signal_name="y", + axes_names=["x"], + signal_long_name=ylabel, + axes_long_names=[xlabel], + signal_errors=curve.getYErrorData(copy=False), + axes_errors=[curve.getXErrorData(copy=True)], + title=self.plot.getGraphTitle()) + + def _saveCurve(self, plot, filename, nameFilter): """Save a curve from the plot. :param str filename: The name of the file to write @@ -176,15 +265,15 @@ class SaveAction(PlotAction): :return: False if format is not supported or save failed, True otherwise. """ - if nameFilter not in self.CURVE_FILTERS: + if nameFilter not in self.DEFAULT_CURVE_FILTERS: return False # Check if a curve is to be saved - curve = self.plot.getActiveCurve() + curve = plot.getActiveCurve() # before calling _saveCurve, if there is no selected curve, we # make sure there is only one curve on the graph if curve is None: - curves = self.plot.getAllCurves() + curves = plot.getAllCurves() if not curves: self._errorMessage("No curve to be saved") return False @@ -199,26 +288,10 @@ class SaveAction(PlotAction): # .npy or nxdata fmt, csvdelim, autoheader = ("", "", False) - # If curve has no associated label, get the default from the plot - xlabel = curve.getXLabel() - if xlabel is None: - xlabel = self.plot.getXAxis().getLabel() - ylabel = curve.getYLabel() - if ylabel is None: - ylabel = self.plot.getYAxis().getLabel() + xlabel, ylabel = self._getAxesLabels(curve) if nameFilter == self.CURVE_FILTER_NXDATA: - return save_NXdata( - filename, - signal=curve.getYData(copy=False), - axes=[curve.getXData(copy=False)], - signal_name="y", - axes_names=["x"], - signal_long_name=ylabel, - axes_long_names=[xlabel], - signal_errors=curve.getYErrorData(copy=False), - axes_errors=[curve.getXErrorData(copy=True)], - title=self.plot.getGraphTitle()) + return self._saveCurveAsNXdata(curve, filename) try: save1D(filename, @@ -233,7 +306,7 @@ class SaveAction(PlotAction): return True - def _saveCurves(self, filename, nameFilter): + def _saveCurves(self, plot, filename, nameFilter): """Save all curves from the plot. :param str filename: The name of the file to write @@ -241,10 +314,10 @@ class SaveAction(PlotAction): :return: False if format is not supported or save failed, True otherwise. """ - if nameFilter not in self.ALL_CURVES_FILTERS: + if nameFilter not in self.DEFAULT_ALL_CURVES_FILTERS: return False - curves = self.plot.getAllCurves() + curves = plot.getAllCurves() if not curves: self._errorMessage("No curves to be saved") return False @@ -252,8 +325,8 @@ class SaveAction(PlotAction): curve = curves[0] scanno = 1 try: - xlabel = curve.getXLabel() or self.plot.getGraphXLabel() - ylabel = curve.getYLabel() or self.plot.getGraphYLabel(curve.getYAxis()) + xlabel = curve.getXLabel() or plot.getGraphXLabel() + ylabel = curve.getYLabel() or plot.getGraphYLabel(curve.getYAxis()) specfile = savespec(filename, curve.getXData(copy=False), curve.getYData(copy=False), @@ -269,8 +342,8 @@ class SaveAction(PlotAction): for curve in curves[1:]: try: scanno += 1 - xlabel = curve.getXLabel() or self.plot.getGraphXLabel() - ylabel = curve.getYLabel() or self.plot.getGraphYLabel(curve.getYAxis()) + xlabel = curve.getXLabel() or plot.getGraphXLabel() + ylabel = curve.getYLabel() or plot.getGraphYLabel(curve.getYAxis()) specfile = savespec(specfile, curve.getXData(copy=False), curve.getYData(copy=False), @@ -286,7 +359,7 @@ class SaveAction(PlotAction): return True - def _saveImage(self, filename, nameFilter): + def _saveImage(self, plot, filename, nameFilter): """Save an image from the plot. :param str filename: The name of the file to write @@ -294,13 +367,13 @@ class SaveAction(PlotAction): :return: False if format is not supported or save failed, True otherwise. """ - if nameFilter not in self.IMAGE_FILTERS: + if nameFilter not in self.DEFAULT_IMAGE_FILTERS: return False - image = self.plot.getActiveImage() + image = plot.getActiveImage() if image is None: qt.QMessageBox.warning( - self.plot, "No Data", "No image to be saved") + plot, "No Data", "No image to be saved") return False data = image.getData(copy=False) @@ -325,21 +398,24 @@ class SaveAction(PlotAction): return True elif nameFilter == self.IMAGE_FILTER_NXDATA: + entryPath = self._selectWriteableOutputGroup(filename) + if entryPath is None: + return False xorigin, yorigin = image.getOrigin() xscale, yscale = image.getScale() xaxis = xorigin + xscale * numpy.arange(data.shape[1]) yaxis = yorigin + yscale * numpy.arange(data.shape[0]) - xlabel = image.getXLabel() or self.plot.getGraphXLabel() - ylabel = image.getYLabel() or self.plot.getGraphYLabel() + xlabel, ylabel = self._getAxesLabels(image) interpretation = "image" if len(data.shape) == 2 else "rgba-image" return save_NXdata(filename, + nxentry_name=entryPath, signal=data, axes=[yaxis, xaxis], signal_name="image", axes_names=["y", "x"], axes_long_names=[ylabel, xlabel], - title=self.plot.getGraphTitle(), + title=plot.getGraphTitle(), interpretation=interpretation) elif nameFilter in (self.IMAGE_FILTER_ASCII, @@ -368,19 +444,13 @@ class SaveAction(PlotAction): return False return True - elif nameFilter in (self.IMAGE_FILTER_RGB_PNG, - self.IMAGE_FILTER_RGB_TIFF): + elif nameFilter == self.IMAGE_FILTER_RGB_PNG: # Get displayed image rgbaImage = image.getRgbaImageData(copy=False) # Convert RGB QImage qimage = convertArrayToQImage(rgbaImage[:, :, :3]) - if nameFilter == self.IMAGE_FILTER_RGB_PNG: - fileFormat = 'PNG' - else: - fileFormat = 'TIFF' - - if qimage.save(filename, fileFormat): + if qimage.save(filename, 'PNG'): return True else: _logger.error('Failed to save image as %s', filename) @@ -391,7 +461,7 @@ class SaveAction(PlotAction): return False - def _saveScatter(self, filename, nameFilter): + def _saveScatter(self, plot, filename, nameFilter): """Save an image from the plot. :param str filename: The name of the file to write @@ -399,12 +469,15 @@ class SaveAction(PlotAction): :return: False if format is not supported or save failed, True otherwise. """ - if nameFilter not in self.SCATTER_FILTERS: + if nameFilter not in self.DEFAULT_SCATTER_FILTERS: return False if nameFilter == self.SCATTER_FILTER_NXDATA: - scatter = self.plot.getScatter() - # TODO: we could get all scatters on this plot and concatenate their (x, y, values) + entryPath = self._selectWriteableOutputGroup(filename) + if entryPath is None: + return False + scatter = plot.getScatter() + x = scatter.getXData(copy=False) y = scatter.getYData(copy=False) z = scatter.getValueData(copy=False) @@ -417,51 +490,92 @@ class SaveAction(PlotAction): if isinstance(yerror, float): yerror = yerror * numpy.ones(x.shape, dtype=numpy.float32) - xlabel = self.plot.getGraphXLabel() - ylabel = self.plot.getGraphYLabel() + xlabel = plot.getGraphXLabel() + ylabel = plot.getGraphYLabel() return save_NXdata( filename, + nxentry_name=entryPath, signal=z, axes=[x, y], signal_name="values", axes_names=["x", "y"], axes_long_names=[xlabel, ylabel], axes_errors=[xerror, yerror], - title=self.plot.getGraphTitle()) + title=plot.getGraphTitle()) + + def setFileFilter(self, dataKind, nameFilter, func): + """Set a name filter to add/replace a file format support + + :param str dataKind: + The kind of data for which the provided filter is valid. + One of: 'all', 'curve', 'curves', 'image', 'scatter' + :param str nameFilter: The name filter in the QFileDialog. + See :meth:`QFileDialog.setNameFilters`. + :param callable func: The function to call to perform saving. + Expected signature is: + bool func(PlotWidget plot, str filename, str nameFilter) + """ + assert dataKind in ('all', 'curve', 'curves', 'image', 'scatter') + + self._filters[dataKind][nameFilter] = func + + def getFileFilters(self, dataKind): + """Returns the nameFilter and associated function for a kind of data. + + :param str dataKind: + The kind of data for which the provided filter is valid. + On of: 'all', 'curve', 'curves', 'image', 'scatter' + :return: {nameFilter: function} associations. + :rtype: collections.OrderedDict + """ + assert dataKind in ('all', 'curve', 'curves', 'image', 'scatter') + + return self._filters[dataKind].copy() def _actionTriggered(self, checked=False): """Handle save action.""" # Set-up filters - filters = [] + filters = OrderedDict() # Add image filters if there is an active image if self.plot.getActiveImage() is not None: - filters.extend(self.IMAGE_FILTERS) + filters.update(self._filters['image'].items()) # Add curve filters if there is a curve to save if (self.plot.getActiveCurve() is not None or len(self.plot.getAllCurves()) == 1): - filters.extend(self.CURVE_FILTERS) + filters.update(self._filters['curve'].items()) if len(self.plot.getAllCurves()) > 1: - filters.extend(self.ALL_CURVES_FILTERS) + filters.update(self._filters['curves'].items()) # Add scatter filters if there is a scatter # todo: CSV if self.plot.getScatter() is not None: - filters.extend(self.SCATTER_FILTERS) + filters.update(self._filters['scatter'].items()) - filters.extend(self.SNAPSHOT_FILTERS) + filters.update(self._filters['all'].items()) # Create and run File dialog dialog = qt.QFileDialog(self.plot) + dialog.setOption(dialog.DontUseNativeDialog) dialog.setWindowTitle("Output File Selection") dialog.setModal(1) - dialog.setNameFilters(filters) + dialog.setNameFilters(list(filters.keys())) dialog.setFileMode(dialog.AnyFile) dialog.setAcceptMode(dialog.AcceptSave) + def onFilterSelection(filt_): + # disable overwrite confirmation for NXdata types, + # because we append the data to existing files + if filt_ in self.DEFAULT_APPEND_FILTERS: + dialog.setOption(dialog.DontConfirmOverwrite) + else: + dialog.setOption(dialog.DontConfirmOverwrite, False) + + dialog.filterSelected.connect(onFilterSelection) + if not dialog.exec_(): return False @@ -469,34 +583,25 @@ class SaveAction(PlotAction): filename = dialog.selectedFiles()[0] dialog.close() - # Forces the filename extension to match the chosen filter - if "NXdata" in nameFilter: - has_allowed_ext = False - for ext in _NEXUS_HDF5_EXT: + if '(' in nameFilter and ')' == nameFilter.strip()[-1]: + # Check for correct file extension + # Extract file extensions as .something + extensions = [ext[ext.find('.'):] for ext in + nameFilter[nameFilter.find('(')+1:-1].split()] + for ext in extensions: if (len(filename) > len(ext) and filename[-len(ext):].lower() == ext.lower()): - has_allowed_ext = True - if not has_allowed_ext: - filename += ".h5" - else: - default_extension = nameFilter.split()[-1][2:-1] - if (len(filename) <= len(default_extension) or - filename[-len(default_extension):].lower() != default_extension.lower()): - filename += default_extension + break + else: # filename has no extension supported in nameFilter, add one + if len(extensions) >= 1: + filename += extensions[0] # Handle save - if nameFilter in self.SNAPSHOT_FILTERS: - return self._saveSnapshot(filename, nameFilter) - elif nameFilter in self.CURVE_FILTERS: - return self._saveCurve(filename, nameFilter) - elif nameFilter in self.ALL_CURVES_FILTERS: - return self._saveCurves(filename, nameFilter) - elif nameFilter in self.IMAGE_FILTERS: - return self._saveImage(filename, nameFilter) - elif nameFilter in self.SCATTER_FILTERS: - return self._saveScatter(filename, nameFilter) + func = filters.get(nameFilter, None) + if func is not None: + return func(self.plot, filename, nameFilter) else: - _logger.warning('Unsupported file filter: %s', nameFilter) + _logger.error('Unsupported file filter: %s', nameFilter) return False @@ -526,9 +631,6 @@ class PrintAction(PlotAction): :param parent: See :class:`QAction`. """ - # Share QPrinter instance to propose latest used as default - _printer = None - def __init__(self, plot, parent=None): super(PrintAction, self).__init__( plot, icon='document-print', text='Print...', @@ -538,15 +640,17 @@ class PrintAction(PlotAction): self.setShortcut(qt.QKeySequence.Print) self.setShortcutContext(qt.Qt.WidgetShortcut) - @property - def printer(self): - """The QPrinter instance used by the actions. + def getPrinter(self): + """The QPrinter instance used by the PrintAction. - This is shared accross all instances of PrintAct + :rtype: QPrinter """ - if self._printer is None: - PrintAction._printer = qt.QPrinter() - return self._printer + return printer.getDefaultPrinter() + + @property + @deprecated(replacement="getPrinter()", since_version="0.8.0") + def printer(self): + return self.getPrinter() def printPlotAsWidget(self): """Open the print dialog and print the plot. @@ -555,7 +659,7 @@ class PrintAction(PlotAction): :return: True if successful """ - dialog = qt.QPrintDialog(self.printer, self.plot) + dialog = qt.QPrintDialog(self.getPrinter(), self.plot) dialog.setWindowTitle('Print Plot') if not dialog.exec_(): return False @@ -564,10 +668,10 @@ class PrintAction(PlotAction): widget = self.plot.centralWidget() painter = qt.QPainter() - if not painter.begin(self.printer): + if not painter.begin(self.getPrinter()): return False - pageRect = self.printer.pageRect() + pageRect = self.getPrinter().pageRect() xScale = pageRect.width() / widget.width() yScale = pageRect.height() / widget.height() scale = min(xScale, yScale) @@ -588,7 +692,7 @@ class PrintAction(PlotAction): :return: True if successful """ # Init printer and start printer dialog - dialog = qt.QPrintDialog(self.printer, self.plot) + dialog = qt.QPrintDialog(self.getPrinter(), self.plot) dialog.setWindowTitle('Print Plot') if not dialog.exec_(): return False @@ -599,13 +703,13 @@ class PrintAction(PlotAction): pixmap = qt.QPixmap() pixmap.loadFromData(pngData, 'png') - xScale = self.printer.pageRect().width() / pixmap.width() - yScale = self.printer.pageRect().height() / pixmap.height() + xScale = self.getPrinter().pageRect().width() / pixmap.width() + yScale = self.getPrinter().pageRect().height() / pixmap.height() scale = min(xScale, yScale) # Draw pixmap with painter painter = qt.QPainter() - if not painter.begin(self.printer): + if not painter.begin(self.getPrinter()): return False painter.drawPixmap(0, 0, diff --git a/silx/gui/plot/actions/mode.py b/silx/gui/plot/actions/mode.py index 026a94d..ee05256 100644 --- a/silx/gui/plot/actions/mode.py +++ b/silx/gui/plot/actions/mode.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2017 European Synchrotron Radiation Facility +# 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 @@ -69,7 +69,9 @@ class ZoomModeAction(PlotAction): self.blockSignals(old) def _actionTriggered(self, checked=False): - self.plot.setInteractiveMode('zoom', source=self) + plot = self.plot + if plot is not None: + plot.setInteractiveMode('zoom', source=self) class PanModeAction(PlotAction): @@ -97,4 +99,6 @@ class PanModeAction(PlotAction): self.blockSignals(old) def _actionTriggered(self, checked=False): - self.plot.setInteractiveMode('pan', source=self) + plot = self.plot + if plot is not None: + plot.setInteractiveMode('pan', source=self) diff --git a/silx/gui/plot/backends/BackendBase.py b/silx/gui/plot/backends/BackendBase.py index 45bf785..8352ea0 100644 --- a/silx/gui/plot/backends/BackendBase.py +++ b/silx/gui/plot/backends/BackendBase.py @@ -31,8 +31,7 @@ This API is a simplified version of PyMca PlotBackend API. __authors__ = ["V.A. Sole", "T. Vincent"] __license__ = "MIT" -__date__ = "16/08/2017" - +__date__ = "24/04/2018" import weakref from ... import qt @@ -59,6 +58,7 @@ class BackendBase(object): self.__yLimits = {'left': (1., 100.), 'right': (1., 100.)} self.__yAxisInverted = False self.__keepDataAspectRatio = False + self._xAxisTimeZone = None self._axesDisplayed = True # Store a weakref to get access to the plot state. self._setPlot(plot) @@ -109,7 +109,7 @@ class BackendBase(object): :param str legend: The legend to be associated to the curve :param color: color(s) to be used :type color: string ("#RRGGBB") or (npoints, 4) unsigned byte array or - one of the predefined color names defined in Colors.py + one of the predefined color names defined in colors.py :param str symbol: Symbol to be drawn at each (x, y) position:: - ' ' or '' no symbol @@ -252,7 +252,7 @@ class BackendBase(object): :param bool flag: Toggle the display of a crosshair cursor. :param color: The color to use for the crosshair. - :type color: A string (either a predefined color name in Colors.py + :type color: A string (either a predefined color name in colors.py or "#RRGGBB")) or a 4 columns unsigned byte array. :param int linewidth: The width of the lines of the crosshair. :param linestyle: Type of line:: @@ -406,6 +406,39 @@ class BackendBase(object): # Graph axes + + def getXAxisTimeZone(self): + """Returns tzinfo that is used if the X-Axis plots date-times. + + None means the datetimes are interpreted as local time. + + :rtype: datetime.tzinfo of None. + """ + return self._xAxisTimeZone + + def setXAxisTimeZone(self, tz): + """Sets tzinfo that is used if the X-Axis plots date-times. + + Use None to let the datetimes be interpreted as local time. + + :rtype: datetime.tzinfo of None. + """ + self._xAxisTimeZone = tz + + def isXAxisTimeSeries(self): + """Return True if the X-axis scale shows datetime objects. + + :rtype: bool + """ + raise NotImplementedError() + + def setXAxisTimeSeries(self, isTimeSeries): + """Set whether the X-axis is a time series + + :param bool flag: True to switch to time series, False for regular axis. + """ + raise NotImplementedError() + def setXAxisLogarithmic(self, flag): """Set the X axis scale between linear and log. @@ -503,4 +536,4 @@ class BackendBase(object): are displayed and not the other. This only check status set to axes from the public API """ - return self._axesDisplayed
\ No newline at end of file + return self._axesDisplayed diff --git a/silx/gui/plot/backends/BackendMatplotlib.py b/silx/gui/plot/backends/BackendMatplotlib.py index f9a1fe5..49c4540 100644 --- a/silx/gui/plot/backends/BackendMatplotlib.py +++ b/silx/gui/plot/backends/BackendMatplotlib.py @@ -32,9 +32,11 @@ __date__ = "18/10/2017" import logging - +import datetime as dt import numpy +from pkg_resources import parse_version as _parse_version + _logger = logging.getLogger(__name__) @@ -42,7 +44,6 @@ _logger = logging.getLogger(__name__) from ... import qt # First of all init matplotlib and set its backend -from ..matplotlib import Colormap as MPLColormap from ..matplotlib import FigureCanvasQTAgg import matplotlib from matplotlib.container import Container @@ -52,10 +53,103 @@ from matplotlib.image import AxesImage from matplotlib.backend_bases import MouseEvent from matplotlib.lines import Line2D from matplotlib.collections import PathCollection, LineCollection +from matplotlib.ticker import Formatter, ScalarFormatter, Locator + + from ..matplotlib.ModestImage import ModestImage from . import BackendBase from .._utils import FLOAT32_MINPOS +from .._utils.dtime_ticklayout import calcTicks, bestFormatString, timestamp + + + +class NiceDateLocator(Locator): + """ + Matplotlib Locator that uses Nice Numbers algorithm (adapted to dates) + to find the tick locations. This results in the same number behaviour + as when using the silx Open GL backend. + + Expects the data to be posix timestampes (i.e. seconds since 1970) + """ + def __init__(self, numTicks=5, tz=None): + """ + :param numTicks: target number of ticks + :param datetime.tzinfo tz: optional time zone. None is local time. + """ + super(NiceDateLocator, self).__init__() + self.numTicks = numTicks + + self._spacing = None + self._unit = None + self.tz = tz + + @property + def spacing(self): + """ The current spacing. Will be updated when new tick value are made""" + return self._spacing + + @property + def unit(self): + """ The current DtUnit. Will be updated when new tick value are made""" + return self._unit + + def __call__(self): + """Return the locations of the ticks""" + vmin, vmax = self.axis.get_view_interval() + return self.tick_values(vmin, vmax) + + def tick_values(self, vmin, vmax): + """ Calculates tick values + """ + if vmax < vmin: + vmin, vmax = vmax, vmin + + # vmin and vmax should be timestamps (i.e. seconds since 1 Jan 1970) + dtMin = dt.datetime.fromtimestamp(vmin, tz=self.tz) + dtMax = dt.datetime.fromtimestamp(vmax, tz=self.tz) + dtTicks, self._spacing, self._unit = \ + calcTicks(dtMin, dtMax, self.numTicks) + + # Convert datetime back to time stamps. + ticks = [timestamp(dtTick) for dtTick in dtTicks] + return ticks + + + +class NiceAutoDateFormatter(Formatter): + """ + Matplotlib FuncFormatter that is linked to a NiceDateLocator and gives the + best possible formats given the locators current spacing an date unit. + """ + + def __init__(self, locator, tz=None): + """ + :param niceDateLocator: a NiceDateLocator object + :param datetime.tzinfo tz: optional time zone. None is local time. + """ + super(NiceAutoDateFormatter, self).__init__() + self.locator = locator + self.tz = tz + + @property + def formatString(self): + if self.locator.spacing is None or self.locator.unit is None: + # Locator has no spacing or units yet. Return elaborate fmtString + return "Y-%m-%d %H:%M:%S" + else: + return bestFormatString(self.locator.spacing, self.locator.unit) + + + def __call__(self, x, pos=None): + """Return the format for tick val *x* at position *pos* + Expects x to be a POSIX timestamp (seconds since 1 Jan 1970) + """ + dateTime = dt.datetime.fromtimestamp(x, tz=self.tz) + tickStr = dateTime.strftime(self.formatString) + return tickStr + + class _MarkerContainer(Container): @@ -130,6 +224,7 @@ class BackendMatplotlib(BackendBase.BackendBase): # when getting the limits at the expense of a replot self._dirtyLimits = True self._axesDisplayed = True + self._matplotlibVersion = _parse_version(matplotlib.__version__) self.fig = Figure() self.fig.set_facecolor("w") @@ -153,7 +248,7 @@ class BackendMatplotlib(BackendBase.BackendBase): self.ax2.set_autoscaley_on(True) self.ax.set_zorder(1) # this works but the figure color is left - if matplotlib.__version__[0] < '2': + if self._matplotlibVersion < _parse_version('2'): self.ax.set_axis_bgcolor('none') else: self.ax.set_facecolor('none') @@ -165,9 +260,9 @@ class BackendMatplotlib(BackendBase.BackendBase): self._colormaps = {} self._graphCursor = tuple() - self.matplotlibVersion = matplotlib.__version__ self._enableAxis('right', False) + self._isXAxisTimeSeries = False # Add methods @@ -235,7 +330,7 @@ class BackendMatplotlib(BackendBase.BackendBase): color=actualColor, marker=symbol, picker=picker, - s=symbolsize) + s=symbolsize**2) artists.append(scatter) if fill: @@ -286,7 +381,7 @@ class BackendMatplotlib(BackendBase.BackendBase): # No transparent colormap with matplotlib < 1.2.0 # Add support for transparent colormap for uint8 data with # colormap with 256 colors, linear norm, [0, 255] range - if matplotlib.__version__ < '1.2.0': + if self._matplotlibVersion < _parse_version('1.2.0'): if (len(data.shape) == 2 and colormap.getName() is None and colormap.getColormapLUT() is not None): colors = colormap.getColormapLUT() @@ -313,29 +408,14 @@ class BackendMatplotlib(BackendBase.BackendBase): else: imageClass = AxesImage - # the normalization can be a source of time waste - # Two possibilities, we receive data or a ready to show image - if len(data.shape) == 3: # RGBA image - image = imageClass(self.ax, - label="__IMAGE__" + legend, - interpolation='nearest', - picker=picker, - zorder=z, - origin='lower') + # All image are shown as RGBA image + image = imageClass(self.ax, + label="__IMAGE__" + legend, + interpolation='nearest', + picker=picker, + zorder=z, + origin='lower') - else: - # Convert colormap argument to matplotlib colormap - scalarMappable = MPLColormap.getScalarMappable(colormap, data) - - # try as data - image = imageClass(self.ax, - label="__IMAGE__" + legend, - interpolation='nearest', - cmap=scalarMappable.cmap, - picker=picker, - zorder=z, - norm=scalarMappable.norm, - origin='lower') if alpha < 1: image.set_alpha(alpha) @@ -359,14 +439,17 @@ class BackendMatplotlib(BackendBase.BackendBase): ystep = 1 if scale[1] >= 0. else -1 data = data[::ystep, ::xstep] - if matplotlib.__version__ < "2.1": + if self._matplotlibVersion < _parse_version('2.1'): # matplotlib 1.4.2 do not support float128 dtype = data.dtype if dtype.kind == "f" and dtype.itemsize >= 16: _logger.warning("Your matplotlib version do not support " - "float128. Data converted to floa64.") + "float128. Data converted to float64.") data = data.astype(numpy.float64) + if data.ndim == 2: # Data image, convert to RGBA image + data = colormap.applyToData(data) + image.set_data(data) self.ax.add_artist(image) @@ -671,11 +754,39 @@ class BackendMatplotlib(BackendBase.BackendBase): # Graph axes + def setXAxisTimeZone(self, tz): + super(BackendMatplotlib, self).setXAxisTimeZone(tz) + + # Make new formatter and locator with the time zone. + self.setXAxisTimeSeries(self.isXAxisTimeSeries()) + + def isXAxisTimeSeries(self): + return self._isXAxisTimeSeries + + def setXAxisTimeSeries(self, isTimeSeries): + self._isXAxisTimeSeries = isTimeSeries + if self._isXAxisTimeSeries: + # We can't use a matplotlib.dates.DateFormatter because it expects + # the data to be in datetimes. Silx works internally with + # timestamps (floats). + locator = NiceDateLocator(tz=self.getXAxisTimeZone()) + self.ax.xaxis.set_major_locator(locator) + self.ax.xaxis.set_major_formatter( + NiceAutoDateFormatter(locator, tz=self.getXAxisTimeZone())) + else: + try: + scalarFormatter = ScalarFormatter(useOffset=False) + except: + _logger.warning('Cannot disabled axes offsets in %s ' % + matplotlib.__version__) + scalarFormatter = ScalarFormatter() + self.ax.xaxis.set_major_formatter(scalarFormatter) + def setXAxisLogarithmic(self, flag): # Workaround for matplotlib 2.1.0 when one tries to set an axis # to log scale with both limits <= 0 # In this case a draw with positive limits is needed first - if flag and matplotlib.__version__ >= '2.1.0': + if flag and self._matplotlibVersion >= _parse_version('2.1.0'): xlim = self.ax.get_xlim() if xlim[0] <= 0 and xlim[1] <= 0: self.ax.set_xlim(1, 10) @@ -685,15 +796,17 @@ class BackendMatplotlib(BackendBase.BackendBase): self.ax.set_xscale('log' if flag else 'linear') def setYAxisLogarithmic(self, flag): - # Workaround for matplotlib 2.1.0 when one tries to set an axis - # to log scale with both limits <= 0 - # In this case a draw with positive limits is needed first - if flag and matplotlib.__version__ >= '2.1.0': + # Workaround for matplotlib 2.0 issue with negative bounds + # before switching to log scale + if flag and self._matplotlibVersion >= _parse_version('2.0.0'): redraw = False - for axis in (self.ax, self.ax2): + for axis, dataRangeIndex in ((self.ax, 1), (self.ax2, 2)): ylim = axis.get_ylim() - if ylim[0] <= 0 and ylim[1] <= 0: - axis.set_ylim(1, 10) + if ylim[0] <= 0 or ylim[1] <= 0: + dataRange = self._plot.getDataRange()[dataRangeIndex] + if dataRange is None: + dataRange = 1, 100 # Fallback + axis.set_ylim(*dataRange) redraw = True if redraw: self.draw() @@ -722,16 +835,31 @@ class BackendMatplotlib(BackendBase.BackendBase): # Data <-> Pixel coordinates conversion + def _mplQtYAxisCoordConversion(self, y): + """Qt origin (top) to/from matplotlib origin (bottom) conversion. + + :rtype: float + """ + height = self.fig.get_window_extent().height + return height - y + def dataToPixel(self, x, y, axis): ax = self.ax2 if axis == "right" else self.ax pixels = ax.transData.transform_point((x, y)) xPixel, yPixel = pixels.T + + # Convert from matplotlib origin (bottom) to Qt origin (top) + yPixel = self._mplQtYAxisCoordConversion(yPixel) + return xPixel, yPixel def pixelToData(self, x, y, axis, check): ax = self.ax2 if axis == "right" else self.ax + # Convert from Qt origin (top) to matplotlib origin (bottom) + y = self._mplQtYAxisCoordConversion(y) + inv = ax.transData.inverted() x, y = inv.transform_point((x, y)) @@ -745,12 +873,12 @@ class BackendMatplotlib(BackendBase.BackendBase): return x, y def getPlotBoundsInPixels(self): - bbox = self.ax.get_window_extent().transformed( - self.fig.dpi_scale_trans.inverted()) - dpi = self.fig.dpi + bbox = self.ax.get_window_extent() # Warning this is not returning int... - return (bbox.bounds[0] * dpi, bbox.bounds[1] * dpi, - bbox.bounds[2] * dpi, bbox.bounds[3] * dpi) + return (bbox.xmin, + self._mplQtYAxisCoordConversion(bbox.ymax), + bbox.width, + bbox.height) def setAxesDisplayed(self, displayed): """Display or not the axes. @@ -822,7 +950,8 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): def _onMousePress(self, event): self._plot.onMousePress( - event.x, event.y, self._MPL_TO_PLOT_BUTTONS[event.button]) + event.x, self._mplQtYAxisCoordConversion(event.y), + self._MPL_TO_PLOT_BUTTONS[event.button]) def _onMouseMove(self, event): if self._graphCursor: @@ -839,14 +968,17 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): self._plot._setDirtyPlot(overlayOnly=True) # onMouseMove must trigger replot if dirty flag is raised - self._plot.onMouseMove(event.x, event.y) + self._plot.onMouseMove( + event.x, self._mplQtYAxisCoordConversion(event.y)) def _onMouseRelease(self, event): self._plot.onMouseRelease( - event.x, event.y, self._MPL_TO_PLOT_BUTTONS[event.button]) + event.x, self._mplQtYAxisCoordConversion(event.y), + self._MPL_TO_PLOT_BUTTONS[event.button]) def _onMouseWheel(self, event): - self._plot.onMouseWheel(event.x, event.y, event.step) + self._plot.onMouseWheel( + event.x, self._mplQtYAxisCoordConversion(event.y), event.step) def leaveEvent(self, event): """QWidget event handler""" @@ -880,7 +1012,8 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): self._picked = [] # Weird way to do an explicit picking: Simulate a button press event - mouseEvent = MouseEvent('button_press_event', self, x, y) + mouseEvent = MouseEvent('button_press_event', + self, x, self._mplQtYAxisCoordConversion(y)) cid = self.mpl_connect('pick_event', self._onPick) self.fig.pick(mouseEvent) self.mpl_disconnect(cid) @@ -924,7 +1057,7 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): """ # Starting with mpl 2.1.0, toggling autoscale raises a ValueError # in some situations. See #1081, #1136, #1163, - if matplotlib.__version__ >= "2.0.0": + if self._matplotlibVersion >= _parse_version("2.0.0"): try: FigureCanvasQTAgg.draw(self) except ValueError as err: @@ -956,7 +1089,6 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): if yRightLimits != self.ax2.get_ybound(): self._plot.getYAxis(axis='right')._emitLimitsChanged() - self._drawOverlays() def replot(self): @@ -975,6 +1107,12 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): elif dirtyFlag: # Need full redraw self.draw() + # Workaround issue of rendering overlays with some matplotlib versions + if (_parse_version('1.5') <= self._matplotlibVersion < _parse_version('2.1') and + not hasattr(self, '_firstReplot')): + self._firstReplot = False + if self._overlays or self._graphCursor: + qt.QTimer.singleShot(0, self.draw) # Request async draw # cursor diff --git a/silx/gui/plot/backends/BackendOpenGL.py b/silx/gui/plot/backends/BackendOpenGL.py index 3c18f4f..0001bb9 100644 --- a/silx/gui/plot/backends/BackendOpenGL.py +++ b/silx/gui/plot/backends/BackendOpenGL.py @@ -28,7 +28,7 @@ from __future__ import division __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "16/08/2017" +__date__ = "24/04/2018" from collections import OrderedDict, namedtuple from ctypes import c_void_p @@ -38,8 +38,7 @@ import numpy from .._utils import FLOAT32_MINPOS from . import BackendBase -from .. import Colors -from ..Colormap import Colormap +from ... import colors from ... import qt from ..._glutils import gl @@ -355,7 +354,6 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): self._markers = OrderedDict() self._items = OrderedDict() self._plotContent = PlotDataContent() # For images and curves - self._selectionAreas = OrderedDict() self._glGarbageCollector = [] self._plotFrame = GLPlotFrame2D( @@ -399,7 +397,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): previousMousePosInPixels = self._mousePosInPixels self._mousePosInPixels = (xPixel, yPixel) if isCursorInPlot else None if (self._crosshairCursor is not None and - previousMousePosInPixels != self._crosshairCursor): + previousMousePosInPixels != self._mousePosInPixels): # Avoid replot when cursor remains outside plot area self._plot._setDirtyPlot(overlayOnly=True) @@ -431,14 +429,6 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): # OpenGLWidget API - @staticmethod - def _setBlendFuncGL(): - # gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) - gl.glBlendFuncSeparate(gl.GL_SRC_ALPHA, - gl.GL_ONE_MINUS_SRC_ALPHA, - gl.GL_ONE, - gl.GL_ONE) - def initializeGL(self): gl.testGL() @@ -446,7 +436,11 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): gl.glClearStencil(0) gl.glEnable(gl.GL_BLEND) - self._setBlendFuncGL() + # gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) + gl.glBlendFuncSeparate(gl.GL_SRC_ALPHA, + gl.GL_ONE_MINUS_SRC_ALPHA, + gl.GL_ONE, + gl.GL_ONE) # For lines gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST) @@ -500,7 +494,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): gl.glUniform1i(self._progTex.uniforms['tex'], texUnit) gl.glUniformMatrix4fv(self._progTex.uniforms['matrix'], 1, gl.GL_TRUE, - mat4Identity()) + mat4Identity().astype(numpy.float32)) stride = self._plotVertices.shape[-1] * self._plotVertices.itemsize gl.glEnableVertexAttribArray(self._progTex.attributes['position']) @@ -649,24 +643,20 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] - isXLog = self._plotFrame.xAxis.isLog - isYLog = self._plotFrame.yAxis.isLog - # Render in plot area gl.glScissor(self._plotFrame.margins.left, self._plotFrame.margins.bottom, plotWidth, plotHeight) gl.glEnable(gl.GL_SCISSOR_TEST) - gl.glViewport(self._plotFrame.margins.left, - self._plotFrame.margins.bottom, - plotWidth, plotHeight) + gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) # Prepare vertical and horizontal markers rendering self._progBase.use() - gl.glUniformMatrix4fv(self._progBase.uniforms['matrix'], 1, gl.GL_TRUE, - self._plotFrame.transformedDataProjMat) - gl.glUniform2i(self._progBase.uniforms['isLog'], isXLog, isYLog) + gl.glUniformMatrix4fv( + self._progBase.uniforms['matrix'], 1, gl.GL_TRUE, + self.matScreenProj.astype(numpy.float32)) + gl.glUniform2i(self._progBase.uniforms['isLog'], False, False) gl.glUniform1i(self._progBase.uniforms['hatchStep'], 0) gl.glUniform1f(self._progBase.uniforms['tickLen'], 0.) posAttrib = self._progBase.attributes['position'] @@ -677,10 +667,12 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): for marker in self._markers.values(): xCoord, yCoord = marker['x'], marker['y'] - if ((isXLog and xCoord is not None and - xCoord < FLOAT32_MINPOS) or - (isYLog and yCoord is not None and - yCoord < FLOAT32_MINPOS)): + if ((self._plotFrame.xAxis.isLog and + xCoord is not None and + xCoord <= 0) or + (self._plotFrame.yAxis.isLog and + yCoord is not None and + yCoord <= 0)): # Do not render markers with negative coords on log axis continue @@ -706,9 +698,9 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): align=RIGHT, valign=BOTTOM) labels.append(label) - xMin, xMax = self._plotFrame.dataRanges.x - vertices = numpy.array(((xMin, yCoord), - (xMax, yCoord)), + width = self._plotFrame.size[0] + vertices = numpy.array(((0, pixelPos[1]), + (width, pixelPos[1])), dtype=numpy.float32) else: # yCoord is None: vertical line in data space @@ -721,13 +713,12 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): align=LEFT, valign=TOP) labels.append(label) - yMin, yMax = self._plotFrame.dataRanges.y - vertices = numpy.array(((xCoord, yMin), - (xCoord, yMax)), + height = self._plotFrame.size[1] + vertices = numpy.array(((pixelPos[0], 0), + (pixelPos[0], height)), dtype=numpy.float32) self._progBase.use() - gl.glUniform4f(self._progBase.uniforms['color'], *marker['color']) @@ -759,13 +750,12 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): # For now simple implementation: using a curve for each marker # Should pack all markers to a single set of points markerCurve = GLPlotCurve2D( - numpy.array((xCoord,), dtype=numpy.float32), - numpy.array((yCoord,), dtype=numpy.float32), + numpy.array((pixelPos[0],), dtype=numpy.float64), + numpy.array((pixelPos[1],), dtype=numpy.float64), marker=marker['symbol'], markerColor=marker['color'], markerSize=11) - markerCurve.render(self._plotFrame.transformedDataProjMat, - isXLog, isYLog) + markerCurve.render(self.matScreenProj, False, False) markerCurve.discard() gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) @@ -777,8 +767,8 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): gl.glDisable(gl.GL_SCISSOR_TEST) def _renderOverlayGL(self): - # Render selection area and crosshair cursor - if self._selectionAreas or self._crosshairCursor is not None: + # Render crosshair cursor + if self._crosshairCursor is not None: plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] # Scissor to plot area @@ -788,41 +778,21 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): gl.glEnable(gl.GL_SCISSOR_TEST) self._progBase.use() - gl.glUniform2i(self._progBase.uniforms['isLog'], - self._plotFrame.xAxis.isLog, - self._plotFrame.yAxis.isLog) + gl.glUniform2i(self._progBase.uniforms['isLog'], False, False) gl.glUniform1f(self._progBase.uniforms['tickLen'], 0.) posAttrib = self._progBase.attributes['position'] matrixUnif = self._progBase.uniforms['matrix'] colorUnif = self._progBase.uniforms['color'] hatchStepUnif = self._progBase.uniforms['hatchStep'] - # Render selection area in plot area - if self._selectionAreas: - gl.glViewport(self._plotFrame.margins.left, - self._plotFrame.margins.bottom, - plotWidth, plotHeight) - - gl.glUniformMatrix4fv(matrixUnif, 1, gl.GL_TRUE, - self._plotFrame.transformedDataProjMat) - - for shape in self._selectionAreas.values(): - if shape.isVideoInverted: - gl.glBlendFunc(gl.GL_ONE_MINUS_DST_COLOR, gl.GL_ZERO) - - shape.render(posAttrib, colorUnif, hatchStepUnif) - - if shape.isVideoInverted: - self._setBlendFuncGL() - - # Render crosshair cursor is screen frame but with scissor + # Render crosshair cursor in screen frame but with scissor if (self._crosshairCursor is not None and self._mousePosInPixels is not None): gl.glViewport( 0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) gl.glUniformMatrix4fv(matrixUnif, 1, gl.GL_TRUE, - self.matScreenProj) + self.matScreenProj.astype(numpy.float32)) color, lineWidth = self._crosshairCursor gl.glUniform4f(colorUnif, *color) @@ -881,31 +851,30 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): isXLog, isYLog) # Render Items + gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) + self._progBase.use() gl.glUniformMatrix4fv(self._progBase.uniforms['matrix'], 1, gl.GL_TRUE, - self._plotFrame.transformedDataProjMat) - gl.glUniform2i(self._progBase.uniforms['isLog'], - self._plotFrame.xAxis.isLog, - self._plotFrame.yAxis.isLog) + self.matScreenProj.astype(numpy.float32)) + gl.glUniform2i(self._progBase.uniforms['isLog'], False, False) gl.glUniform1f(self._progBase.uniforms['tickLen'], 0.) for item in self._items.values(): - shape2D = item.get('_shape2D') - if shape2D is None: - closed = item['shape'] != 'polylines' - shape2D = Shape2D(tuple(zip(item['x'], item['y'])), - fill=item['fill'], - fillColor=item['color'], - stroke=True, - strokeColor=item['color'], - strokeClosed=closed) - item['_shape2D'] = shape2D - - if ((isXLog and shape2D.xMin < FLOAT32_MINPOS) or - (isYLog and shape2D.yMin < FLOAT32_MINPOS)): + if ((isXLog and numpy.min(item['x']) < FLOAT32_MINPOS) or + (isYLog and numpy.min(item['y']) < FLOAT32_MINPOS)): # Ignore items <= 0. on log axes continue + closed = item['shape'] != 'polylines' + points = [self.dataToPixel(x, y, axis='left', check=False) + for (x, y) in zip(item['x'], item['y'])] + shape2D = Shape2D(points, + fill=item['fill'], + fillColor=item['color'], + stroke=True, + strokeColor=item['color'], + strokeClosed=closed) + posAttrib = self._progBase.attributes['position'] colorUnif = self._progBase.uniforms['color'] hatchStepUnif = self._progBase.uniforms['hatchStep'] @@ -944,6 +913,21 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): # Add methods + @staticmethod + def _castArrayTo(v): + """Returns best floating type to cast the array to. + + :param numpy.ndarray v: Array to cast + :rtype: numpy.dtype + :raise ValueError: If dtype is not supported + """ + if numpy.issubdtype(v.dtype, numpy.floating): + return numpy.float32 if v.itemsize <= 4 else numpy.float64 + elif numpy.issubdtype(v.dtype, numpy.integer): + return numpy.float32 if v.itemsize <= 2 else numpy.float64 + else: + raise ValueError('Unsupported data type') + def addCurve(self, x, y, legend, color, symbol, linewidth, linestyle, yaxis, @@ -954,8 +938,21 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): assert parameter is not None assert yaxis in ('left', 'right') - x = numpy.array(x, dtype=numpy.float32, copy=False, order='C') - y = numpy.array(y, dtype=numpy.float32, copy=False, order='C') + # Convert input data + x = numpy.array(x, copy=False) + y = numpy.array(y, copy=False) + + # Check if float32 is enough + if (self._castArrayTo(x) is numpy.float32 and + self._castArrayTo(y) is numpy.float32): + dtype = numpy.float32 + else: + dtype = numpy.float64 + + x = numpy.array(x, dtype=dtype, copy=False, order='C') + y = numpy.array(y, dtype=dtype, copy=False, order='C') + + # Convert errors to float32 if xerror is not None: xerror = numpy.array( xerror, dtype=numpy.float32, copy=False, order='C') @@ -963,6 +960,47 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): yerror = numpy.array( yerror, dtype=numpy.float32, copy=False, order='C') + # Handle axes log scale: convert data + + if self._plotFrame.xAxis.isLog: + logX = numpy.log10(x) + + if xerror is not None: + # Transform xerror so that + # log10(x) +/- xerror' = log10(x +/- xerror) + if hasattr(xerror, 'shape') and len(xerror.shape) == 2: + xErrorMinus, xErrorPlus = xerror[0], xerror[1] + else: + xErrorMinus, xErrorPlus = xerror, xerror + xErrorMinus = logX - numpy.log10(x - xErrorMinus) + xErrorPlus = numpy.log10(x + xErrorPlus) - logX + xerror = numpy.array((xErrorMinus, xErrorPlus), + dtype=numpy.float32) + + x = logX + + isYLog = (yaxis == 'left' and self._plotFrame.yAxis.isLog) or ( + yaxis == 'right' and self._plotFrame.y2Axis.isLog) + + if isYLog: + logY = numpy.log10(y) + + if yerror is not None: + # Transform yerror so that + # log10(y) +/- yerror' = log10(y +/- yerror) + if hasattr(yerror, 'shape') and len(yerror.shape) == 2: + yErrorMinus, yErrorPlus = yerror[0], yerror[1] + else: + yErrorMinus, yErrorPlus = yerror, yerror + yErrorMinus = logY - numpy.log10(y - yErrorMinus) + yErrorPlus = numpy.log10(y + yErrorPlus) - logY + yerror = numpy.array((yErrorMinus, yErrorPlus), + dtype=numpy.float32) + + y = logY + + # TODO check if need more filtering of error (e.g., clip to positive) + # TODO check and improve this if (len(color) == 4 and type(color[3]) in [type(1), numpy.uint8, numpy.int8]): @@ -973,7 +1011,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): color = None else: colorArray = None - color = Colors.rgba(color) + color = colors.rgba(color) if alpha < 1.: # Apply image transparency if colorArray is not None and colorArray.shape[1] == 4: @@ -995,7 +1033,8 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): marker=symbol, markerColor=color, markerSize=symbolsize, - fillColor=color if fill else None) + fillColor=color if fill else None, + isYLog=isYLog) curve.info = { 'legend': legend, 'zOrder': z, @@ -1054,7 +1093,13 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): elif len(data.shape) == 3: # For RGB, RGBA data assert data.shape[2] in (3, 4) - assert data.dtype in (numpy.float32, numpy.uint8) + + if numpy.issubdtype(data.dtype, numpy.floating): + data = numpy.array(data, dtype=numpy.float32, copy=False) + elif numpy.issubdtype(data.dtype, numpy.integer): + data = numpy.array(data, dtype=numpy.uint8, copy=False) + else: + raise ValueError('Unsupported data type') image = GLPlotRGBAImage(data, origin, scale, alpha) @@ -1106,7 +1151,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): self._items[legend] = { 'shape': shape, - 'color': Colors.rgba(color), + 'color': colors.rgba(color), 'fill': 'hatch' if fill else None, 'x': x, 'y': y @@ -1133,19 +1178,12 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): if isConstraint: x, y = constraint(x, y) - if x is not None and self._plotFrame.xAxis.isLog and x <= 0.: - raise RuntimeError( - 'Cannot add marker with X <= 0 with X axis log scale') - if y is not None and self._plotFrame.yAxis.isLog and y <= 0.: - raise RuntimeError( - 'Cannot add marker with Y <= 0 with Y axis log scale') - self._markers[legend] = { 'x': x, 'y': y, 'legend': legend, 'text': text, - 'color': Colors.rgba(color), + 'color': colors.rgba(color), 'behaviors': behaviors, 'constraint': constraint if isConstraint else None, 'symbol': symbol, @@ -1204,7 +1242,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): "BackendOpenGL.setGraphCursor linestyle parameter ignored") if flag: - color = Colors.rgba(color) + color = colors.rgba(color) crosshairCursor = color, linewidth else: crosshairCursor = None @@ -1304,6 +1342,16 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): else: yPickMin, yPickMax = yPick1, yPick0 + # Apply log scale if axis is log + if self._plotFrame.xAxis.isLog: + xPickMin = numpy.log10(xPickMin) + xPickMax = numpy.log10(xPickMax) + + if (yAxis == 'left' and self._plotFrame.yAxis.isLog) or ( + yAxis == 'right' and self._plotFrame.y2Axis.isLog): + yPickMin = numpy.log10(yPickMin) + yPickMax = numpy.log10(yPickMax) + pickedIndices = item.pick(xPickMin, yPickMin, xPickMax, yPickMax) if pickedIndices: @@ -1548,6 +1596,18 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): # Graph axes + def getXAxisTimeZone(self): + return self._plotFrame.xAxis.timeZone + + def setXAxisTimeZone(self, tz): + self._plotFrame.xAxis.timeZone = tz + + def isXAxisTimeSeries(self): + return self._plotFrame.xAxis.isTimeSeries + + def setXAxisTimeSeries(self, isTimeSeries): + self._plotFrame.xAxis.isTimeSeries = isTimeSeries + def setXAxisLogarithmic(self, flag): if flag != self._plotFrame.xAxis.isLog: if flag and self._keepDataAspectRatio: @@ -1657,4 +1717,4 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): def setAxesDisplayed(self, displayed): BackendBase.BackendBase.setAxesDisplayed(self, displayed) - self._plotFrame.displayed = displayed
\ No newline at end of file + self._plotFrame.displayed = displayed diff --git a/silx/gui/plot/backends/glutils/GLPlotCurve.py b/silx/gui/plot/backends/glutils/GLPlotCurve.py index 124a3da..12b6bbe 100644 --- a/silx/gui/plot/backends/glutils/GLPlotCurve.py +++ b/silx/gui/plot/backends/glutils/GLPlotCurve.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# Copyright (c) 2014-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 @@ -26,6 +26,8 @@ This module provides classes to render 2D lines and scatter plots """ +from __future__ import division + __authors__ = ["T. Vincent"] __license__ = "MIT" __date__ = "03/04/2017" @@ -33,73 +35,73 @@ __date__ = "03/04/2017" import math import logging +import warnings import numpy from silx.math.combo import min_max from ...._glutils import gl -from ...._glutils import numpyToGLType, Program, vertexBuffer -from ..._utils import FLOAT32_MINPOS -from .GLSupport import buildFillMaskIndices +from ...._glutils import Program, vertexBuffer +from .GLSupport import buildFillMaskIndices, mat4Identity, mat4Translate _logger = logging.getLogger(__name__) _MPL_NONES = None, 'None', '', ' ' +"""Possible values for None""" -# fill ######################################################################## +def _notNaNSlices(array, length=1): + """Returns slices of none NaN values in the array. -class _Fill2D(object): - _LINEAR, _LOG10_X, _LOG10_Y, _LOG10_X_Y = 0, 1, 2, 3 + :param numpy.ndarray array: 1D array from which to get slices + :param int length: Slices shorter than length gets discarded + :return: Array of (start, end) slice indices + :rtype: numpy.ndarray + """ + isnan = numpy.isnan(numpy.array(array, copy=False).reshape(-1)) + notnan = numpy.logical_not(isnan) + start = numpy.where(numpy.logical_and(isnan[:-1], notnan[1:]))[0] + 1 + if notnan[0]: + start = numpy.append(0, start) + end = numpy.where(numpy.logical_and(notnan[:-1], isnan[1:]))[0] + 1 + if notnan[-1]: + end = numpy.append(end, len(array)) + slices = numpy.transpose((start, end)) + if length > 1: + # discard slices with less than length values + slices = slices[numpy.diff(slices, axis=1).ravel() >= length] + return slices - _SHADERS = { - 'vertexTransforms': { - _LINEAR: """ - vec4 transformXY(float x, float y) { - return vec4(x, y, 0.0, 1.0); - } - """, - _LOG10_X: """ - const float oneOverLog10 = 0.43429448190325176; - vec4 transformXY(float x, float y) { - return vec4(oneOverLog10 * log(x), y, 0.0, 1.0); - } - """, - _LOG10_Y: """ - const float oneOverLog10 = 0.43429448190325176; +# fill ######################################################################## - vec4 transformXY(float x, float y) { - return vec4(x, oneOverLog10 * log(y), 0.0, 1.0); - } - """, - _LOG10_X_Y: """ - const float oneOverLog10 = 0.43429448190325176; +class _Fill2D(object): + """Object rendering curve filling as polygons + + :param numpy.ndarray xData: X coordinates of points + :param numpy.ndarray yData: Y coordinates of points + :param float baseline: Y value of the 'bottom' of the fill. + 0 for linear Y scale, -38 for log Y scale + :param List[float] color: RGBA color as 4 float in [0, 1] + :param List[float] offset: Translation of coordinates (ox, oy) + """ - vec4 transformXY(float x, float y) { - return vec4(oneOverLog10 * log(x), - oneOverLog10 * log(y), - 0.0, 1.0); - } - """ - }, - 'vertex': """ + _PROGRAM = Program( + vertexShader=""" #version 120 uniform mat4 matrix; attribute float xPos; attribute float yPos; - %s - void main(void) { - gl_Position = matrix * transformXY(xPos, yPos); + gl_Position = matrix * vec4(xPos, yPos, 0.0, 1.0); } """, - 'fragment': """ + fragmentShader=""" #version 120 uniform vec4 color; @@ -107,72 +109,95 @@ class _Fill2D(object): void main(void) { gl_FragColor = color; } - """ - } - - _programs = { - _LINEAR: Program( - _SHADERS['vertex'] % _SHADERS['vertexTransforms'][_LINEAR], - _SHADERS['fragment'], attrib0='xPos'), - _LOG10_X: Program( - _SHADERS['vertex'] % _SHADERS['vertexTransforms'][_LOG10_X], - _SHADERS['fragment'], attrib0='xPos'), - _LOG10_Y: Program( - _SHADERS['vertex'] % _SHADERS['vertexTransforms'][_LOG10_Y], - _SHADERS['fragment'], attrib0='xPos'), - _LOG10_X_Y: Program( - _SHADERS['vertex'] % _SHADERS['vertexTransforms'][_LOG10_X_Y], - _SHADERS['fragment'], attrib0='xPos'), - } - - def __init__(self, xFillVboData=None, yFillVboData=None, - xMin=None, yMin=None, xMax=None, yMax=None, - color=(0., 0., 0., 1.)): - self.xFillVboData = xFillVboData - self.yFillVboData = yFillVboData - self.xMin, self.yMin = xMin, yMin - self.xMax, self.yMax = xMax, yMax + """, + attrib0='xPos') + + def __init__(self, xData=None, yData=None, + baseline=0, + color=(0., 0., 0., 1.), + offset=(0., 0.)): + self.xData = xData + self.yData = yData + self._xFillVboData = None + self._yFillVboData = None self.color = color + self.offset = offset - self._bboxVertices = None - self._indices = None - self._indicesType = None + # Offset baseline + self.baseline = baseline - self.offset[1] def prepare(self): - if self._indices is None: - self._indices = buildFillMaskIndices(self.xFillVboData.size) - self._indicesType = numpyToGLType(self._indices.dtype) - - if self._bboxVertices is None: - yMin, yMax = min(self.yMin, 1e-32), max(self.yMax, 1e-32) - self._bboxVertices = numpy.array(((self.xMin, self.xMin, - self.xMax, self.xMax), - (yMin, yMax, yMin, yMax)), - dtype=numpy.float32) - - def render(self, matrix, isXLog, isYLog): + """Rendering preparation: build indices and bounding box vertices""" + if (self._xFillVboData is None and + self.xData is not None and self.yData is not None): + + # Get slices of not NaN values longer than 1 element + isnan = numpy.logical_or(numpy.isnan(self.xData), + numpy.isnan(self.yData)) + notnan = numpy.logical_not(isnan) + start = numpy.where(numpy.logical_and(isnan[:-1], notnan[1:]))[0] + 1 + if notnan[0]: + start = numpy.append(0, start) + end = numpy.where(numpy.logical_and(notnan[:-1], isnan[1:]))[0] + 1 + if notnan[-1]: + end = numpy.append(end, len(isnan)) + slices = numpy.transpose((start, end)) + # discard slices with less than length values + slices = slices[numpy.diff(slices, axis=1).reshape(-1) >= 2] + + # Number of points: slice + 2 * leading and trailing points + # Twice leading and trailing points to produce degenerated triangles + nbPoints = numpy.sum(numpy.diff(slices, axis=1)) + 4 * len(slices) + points = numpy.empty((nbPoints, 2), dtype=numpy.float32) + + offset = 0 + for start, end in slices: + # Duplicate first point for connecting degenerated triangle + points[offset:offset+2] = self.xData[start], self.baseline + + # 2nd point of the polygon is last point + points[offset+2] = self.xData[end-1], self.baseline + + # Add all points from the data + indices = start + buildFillMaskIndices(end - start) + + points[offset+3:offset+3+len(indices), 0] = self.xData[indices] + points[offset+3:offset+3+len(indices), 1] = self.yData[indices] + + # Duplicate last point for connecting degenerated triangle + points[offset+3+len(indices)] = points[offset+3+len(indices)-1] + + offset += len(indices) + 4 + + self._xFillVboData, self._yFillVboData = vertexBuffer(points.T) + + def render(self, matrix): + """Perform rendering + + :param numpy.ndarray matrix: 4x4 transform matrix to use + """ self.prepare() - if isXLog: - transform = self._LOG10_X_Y if isYLog else self._LOG10_X - else: - transform = self._LOG10_Y if isYLog else self._LINEAR + if self._xFillVboData is None: + return # Nothing to display - prog = self._programs[transform] - prog.use() + self._PROGRAM.use() - gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix) + gl.glUniformMatrix4fv( + self._PROGRAM.uniforms['matrix'], 1, gl.GL_TRUE, + numpy.dot(matrix, + mat4Translate(*self.offset)).astype(numpy.float32)) - gl.glUniform4f(prog.uniforms['color'], *self.color) + gl.glUniform4f(self._PROGRAM.uniforms['color'], *self.color) - xPosAttrib = prog.attributes['xPos'] - yPosAttrib = prog.attributes['yPos'] + xPosAttrib = self._PROGRAM.attributes['xPos'] + yPosAttrib = self._PROGRAM.attributes['yPos'] gl.glEnableVertexAttribArray(xPosAttrib) - self.xFillVboData.setVertexAttrib(xPosAttrib) + self._xFillVboData.setVertexAttrib(xPosAttrib) gl.glEnableVertexAttribArray(yPosAttrib) - self.yFillVboData.setVertexAttrib(yPosAttrib) + self._yFillVboData.setVertexAttrib(yPosAttrib) # Prepare fill mask gl.glEnable(gl.GL_STENCIL_TEST) @@ -182,8 +207,7 @@ class _Fill2D(object): gl.glColorMask(gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE) gl.glDepthMask(gl.GL_FALSE) - gl.glDrawElements(gl.GL_TRIANGLE_STRIP, self._indices.size, - self._indicesType, self._indices) + gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, self._xFillVboData.size) gl.glStencilFunc(gl.GL_EQUAL, 1, 1) # Reset stencil while drawing @@ -191,14 +215,30 @@ class _Fill2D(object): gl.glColorMask(gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE) gl.glDepthMask(gl.GL_TRUE) - gl.glVertexAttribPointer(xPosAttrib, 1, gl.GL_FLOAT, gl.GL_FALSE, 0, - self._bboxVertices[0]) - gl.glVertexAttribPointer(yPosAttrib, 1, gl.GL_FLOAT, gl.GL_FALSE, 0, - self._bboxVertices[1]) - gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, self._bboxVertices[0].size) + # Draw directly in NDC + gl.glUniformMatrix4fv(self._PROGRAM.uniforms['matrix'], 1, gl.GL_TRUE, + mat4Identity().astype(numpy.float32)) + + # NDC vertices + gl.glVertexAttribPointer( + xPosAttrib, 1, gl.GL_FLOAT, gl.GL_FALSE, 0, + numpy.array((-1., -1., 1., 1.), dtype=numpy.float32)) + gl.glVertexAttribPointer( + yPosAttrib, 1, gl.GL_FLOAT, gl.GL_FALSE, 0, + numpy.array((-1., 1., -1., 1.), dtype=numpy.float32)) + + gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, 4) gl.glDisable(gl.GL_STENCIL_TEST) + def discard(self): + """Release VBOs""" + if self._xFillVboData is not None: + self._xFillVboData.vbo.discard() + + self._xFillVboData = None + self._yFillVboData = None + # line ######################################################################## @@ -206,44 +246,25 @@ SOLID, DASHED, DASHDOT, DOTTED = '-', '--', '-.', ':' class _Lines2D(object): + """Object rendering curve as a polyline + + :param xVboData: X coordinates VBO + :param yVboData: Y coordinates VBO + :param colorVboData: VBO of colors + :param distVboData: VBO of distance along the polyline + :param str style: Line style in: '-', '--', '-.', ':' + :param List[float] color: RGBA color as 4 float in [0, 1] + :param float width: Line width + :param float dashPeriod: Period of dashes + :param drawMode: OpenGL drawing mode + :param List[float] offset: Translation of coordinates (ox, oy) + """ + STYLES = SOLID, DASHED, DASHDOT, DOTTED """Supported line styles""" - _LINEAR, _LOG10_X, _LOG10_Y, _LOG10_X_Y = 0, 1, 2, 3 - - _SHADERS = { - 'vertexTransforms': { - _LINEAR: """ - vec4 transformXY(float x, float y) { - return vec4(x, y, 0.0, 1.0); - } - """, - _LOG10_X: """ - const float oneOverLog10 = 0.43429448190325176; - - vec4 transformXY(float x, float y) { - return vec4(oneOverLog10 * log(x), y, 0.0, 1.0); - } - """, - _LOG10_Y: """ - const float oneOverLog10 = 0.43429448190325176; - - vec4 transformXY(float x, float y) { - return vec4(x, oneOverLog10 * log(y), 0.0, 1.0); - } - """, - _LOG10_X_Y: """ - const float oneOverLog10 = 0.43429448190325176; - - vec4 transformXY(float x, float y) { - return vec4(oneOverLog10 * log(x), - oneOverLog10 * log(y), - 0.0, 1.0); - } - """ - }, - 'solid': { - 'vertex': """ + _SOLID_PROGRAM = Program( + vertexShader=""" #version 120 uniform mat4 matrix; @@ -253,14 +274,12 @@ class _Lines2D(object): varying vec4 vColor; - %s - void main(void) { - gl_Position = matrix * transformXY(xPos, yPos); + gl_Position = matrix * vec4(xPos, yPos, 0., 1.) ; vColor = color; } """, - 'fragment': """ + fragmentShader=""" #version 120 varying vec4 vColor; @@ -268,15 +287,14 @@ class _Lines2D(object): void main(void) { gl_FragColor = vColor; } - """ - }, - + """, + attrib0='xPos') - # Limitation: Dash using an estimate of distance in screen coord - # to avoid computing distance when viewport is resized - # results in inequal dashes when viewport aspect ratio is far from 1 - 'dashed': { - 'vertex': """ + # Limitation: Dash using an estimate of distance in screen coord + # to avoid computing distance when viewport is resized + # results in inequal dashes when viewport aspect ratio is far from 1 + _DASH_PROGRAM = Program( + vertexShader=""" #version 120 uniform mat4 matrix; @@ -289,10 +307,8 @@ class _Lines2D(object): varying float vDist; varying vec4 vColor; - %s - void main(void) { - gl_Position = matrix * transformXY(xPos, yPos); + gl_Position = matrix * vec4(xPos, yPos, 0., 1.); //Estimate distance in pixels vec2 probe = vec2(matrix * vec4(1., 1., 0., 0.)) * halfViewportSize; @@ -301,7 +317,7 @@ class _Lines2D(object): vColor = color; } """, - 'fragment': """ + fragmentShader=""" #version 120 /* Dashes: [0, x], [y, z] @@ -318,16 +334,14 @@ class _Lines2D(object): } gl_FragColor = vColor; } - """ - } - } - - _programs = {} + """, + attrib0='xPos') def __init__(self, xVboData=None, yVboData=None, colorVboData=None, distVboData=None, style=SOLID, color=(0., 0., 0., 1.), - width=1, dashPeriod=20, drawMode=None): + width=1, dashPeriod=20, drawMode=None, + offset=(0., 0.)): self.xVboData = xVboData self.yVboData = yVboData self.distVboData = distVboData @@ -335,136 +349,83 @@ class _Lines2D(object): self.useColorVboData = colorVboData is not None self.color = color - self._width = 1 self.width = width self._style = None self.style = style self.dashPeriod = dashPeriod + self.offset = offset self._drawMode = drawMode if drawMode is not None else gl.GL_LINE_STRIP @property def style(self): + """Line style (Union[str,None])""" return self._style @style.setter def style(self, style): if style in _MPL_NONES: self._style = None - self.render = self._renderNone else: assert style in self.STYLES self._style = style - if style == SOLID: - self.render = self._renderSolid - else: # DASHED, DASHDOT, DOTTED - self.render = self._renderDash - - @property - def width(self): - return self._width - - @width.setter - def width(self, width): - # try: - # widthRange = self._widthRange - # except AttributeError: - # widthRange = gl.glGetFloatv(gl.GL_ALIASED_LINE_WIDTH_RANGE) - # # Shared among contexts, this should be enough.. - # _Lines2D._widthRange = widthRange - # assert width >= widthRange[0] and width <= widthRange[1] - self._width = width - - @classmethod - def _getProgram(cls, transform, style): - try: - prgm = cls._programs[(transform, style)] - except KeyError: - sources = cls._SHADERS[style] - vertexShdr = sources['vertex'] % \ - cls._SHADERS['vertexTransforms'][transform] - prgm = Program(vertexShdr, sources['fragment'], attrib0='xPos') - cls._programs[(transform, style)] = prgm - return prgm @classmethod def init(cls): + """OpenGL context initialization""" gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST) - def _renderNone(self, matrix, isXLog, isYLog): - pass - - render = _renderNone # Overridden in style setter - - def _renderSolid(self, matrix, isXLog, isYLog): - if isXLog: - transform = self._LOG10_X_Y if isYLog else self._LOG10_X - else: - transform = self._LOG10_Y if isYLog else self._LINEAR - - prog = self._getProgram(transform, 'solid') - prog.use() - - gl.glEnable(gl.GL_LINE_SMOOTH) - - gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix) - - colorAttrib = prog.attributes['color'] - if self.useColorVboData and self.colorVboData is not None: - gl.glEnableVertexAttribArray(colorAttrib) - self.colorVboData.setVertexAttrib(colorAttrib) - else: - gl.glDisableVertexAttribArray(colorAttrib) - gl.glVertexAttrib4f(colorAttrib, *self.color) - - xPosAttrib = prog.attributes['xPos'] - gl.glEnableVertexAttribArray(xPosAttrib) - self.xVboData.setVertexAttrib(xPosAttrib) - - yPosAttrib = prog.attributes['yPos'] - gl.glEnableVertexAttribArray(yPosAttrib) - self.yVboData.setVertexAttrib(yPosAttrib) + def render(self, matrix): + """Perform rendering - gl.glLineWidth(self.width) - gl.glDrawArrays(self._drawMode, 0, self.xVboData.size) + :param numpy.ndarray matrix: 4x4 transform matrix to use + """ + style = self.style + if style is None: + return - gl.glDisable(gl.GL_LINE_SMOOTH) + elif style == SOLID: + program = self._SOLID_PROGRAM + program.use() + + else: # DASHED, DASHDOT, DOTTED + program = self._DASH_PROGRAM + program.use() + + x, y, viewWidth, viewHeight = gl.glGetFloatv(gl.GL_VIEWPORT) + gl.glUniform2f(program.uniforms['halfViewportSize'], + 0.5 * viewWidth, 0.5 * viewHeight) + + if self.style == DOTTED: + dash = (0.1 * self.dashPeriod, + 0.6 * self.dashPeriod, + 0.7 * self.dashPeriod, + self.dashPeriod) + elif self.style == DASHDOT: + dash = (0.3 * self.dashPeriod, + 0.5 * self.dashPeriod, + 0.6 * self.dashPeriod, + self.dashPeriod) + else: + dash = (0.5 * self.dashPeriod, + self.dashPeriod, + self.dashPeriod, + self.dashPeriod) - def _renderDash(self, matrix, isXLog, isYLog): - if isXLog: - transform = self._LOG10_X_Y if isYLog else self._LOG10_X - else: - transform = self._LOG10_Y if isYLog else self._LINEAR + gl.glUniform4f(program.uniforms['dash'], *dash) - prog = self._getProgram(transform, 'dashed') - prog.use() + distAttrib = program.attributes['distance'] + gl.glEnableVertexAttribArray(distAttrib) + self.distVboData.setVertexAttrib(distAttrib) gl.glEnable(gl.GL_LINE_SMOOTH) - gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix) - x, y, viewWidth, viewHeight = gl.glGetFloatv(gl.GL_VIEWPORT) - gl.glUniform2f(prog.uniforms['halfViewportSize'], - 0.5 * viewWidth, 0.5 * viewHeight) - - if self.style == DOTTED: - dash = (0.1 * self.dashPeriod, - 0.6 * self.dashPeriod, - 0.7 * self.dashPeriod, - self.dashPeriod) - elif self.style == DASHDOT: - dash = (0.3 * self.dashPeriod, - 0.5 * self.dashPeriod, - 0.6 * self.dashPeriod, - self.dashPeriod) - else: - dash = (0.5 * self.dashPeriod, - self.dashPeriod, - self.dashPeriod, - self.dashPeriod) + matrix = numpy.dot(matrix, + mat4Translate(*self.offset)).astype(numpy.float32) + gl.glUniformMatrix4fv(program.uniforms['matrix'], + 1, gl.GL_TRUE, matrix) - gl.glUniform4f(prog.uniforms['dash'], *dash) - - colorAttrib = prog.attributes['color'] + colorAttrib = program.attributes['color'] if self.useColorVboData and self.colorVboData is not None: gl.glEnableVertexAttribArray(colorAttrib) self.colorVboData.setVertexAttrib(colorAttrib) @@ -472,15 +433,11 @@ class _Lines2D(object): gl.glDisableVertexAttribArray(colorAttrib) gl.glVertexAttrib4f(colorAttrib, *self.color) - distAttrib = prog.attributes['distance'] - gl.glEnableVertexAttribArray(distAttrib) - self.distVboData.setVertexAttrib(distAttrib) - - xPosAttrib = prog.attributes['xPos'] + xPosAttrib = program.attributes['xPos'] gl.glEnableVertexAttribArray(xPosAttrib) self.xVboData.setVertexAttrib(xPosAttrib) - yPosAttrib = prog.attributes['yPos'] + yPosAttrib = program.attributes['yPos'] gl.glEnableVertexAttribArray(yPosAttrib) self.yVboData.setVertexAttrib(yPosAttrib) @@ -491,6 +448,12 @@ class _Lines2D(object): def _distancesFromArrays(xData, yData): + """Returns distances between each points + + :param numpy.ndarray xData: X coordinate of points + :param numpy.ndarray yData: Y coordinate of points + :rtype: numpy.ndarray + """ deltas = numpy.dstack(( numpy.ediff1d(xData, to_begin=numpy.float32(0.)), numpy.ediff1d(yData, to_begin=numpy.float32(0.))))[0] @@ -506,43 +469,22 @@ H_LINE, V_LINE = '_', '|' class _Points2D(object): + """Object rendering curve markers + + :param xVboData: X coordinates VBO + :param yVboData: Y coordinates VBO + :param colorVboData: VBO of colors + :param str marker: Kind of symbol to use, see :attr:`MARKERS`. + :param List[float] color: RGBA color as 4 float in [0, 1] + :param float size: Marker size + :param List[float] offset: Translation of coordinates (ox, oy) + """ + MARKERS = (DIAMOND, CIRCLE, SQUARE, PLUS, X_MARKER, POINT, PIXEL, ASTERISK, H_LINE, V_LINE) + """List of supported markers""" - _LINEAR, _LOG10_X, _LOG10_Y, _LOG10_X_Y = 0, 1, 2, 3 - - _SHADERS = { - 'vertexTransforms': { - _LINEAR: """ - vec4 transformXY(float x, float y) { - return vec4(x, y, 0.0, 1.0); - } - """, - _LOG10_X: """ - const float oneOverLog10 = 0.43429448190325176; - - vec4 transformXY(float x, float y) { - return vec4(oneOverLog10 * log(x), y, 0.0, 1.0); - } - """, - _LOG10_Y: """ - const float oneOverLog10 = 0.43429448190325176; - - vec4 transformXY(float x, float y) { - return vec4(x, oneOverLog10 * log(y), 0.0, 1.0); - } - """, - _LOG10_X_Y: """ - const float oneOverLog10 = 0.43429448190325176; - - vec4 transformXY(float x, float y) { - return vec4(oneOverLog10 * log(x), - oneOverLog10 * log(y), - 0.0, 1.0); - } - """ - }, - 'vertex': """ + _VERTEX_SHADER = """ #version 120 uniform mat4 matrix; @@ -554,16 +496,14 @@ class _Points2D(object): varying vec4 vColor; - %s - void main(void) { - gl_Position = matrix * transformXY(xPos, yPos); + gl_Position = matrix * vec4(xPos, yPos, 0., 1.); vColor = color; gl_PointSize = size; } - """, + """ - 'fragmentSymbols': { + _FRAGMENT_SHADER_SYMBOLS = { DIAMOND: """ float alphaSymbol(vec2 coord, float size) { vec2 centerCoord = abs(coord - vec2(0.5, 0.5)); @@ -640,9 +580,9 @@ class _Points2D(object): } } """ - }, + } - 'fragment': """ + _FRAGMENT_SHADER_TEMPLATE = """ #version 120 uniform float size; @@ -660,17 +600,17 @@ class _Points2D(object): } } """ - } - _programs = {} + _PROGRAMS = {} def __init__(self, xVboData=None, yVboData=None, colorVboData=None, - marker=SQUARE, color=(0., 0., 0., 1.), size=7): + marker=SQUARE, color=(0., 0., 0., 1.), size=7, + offset=(0., 0.)): self.color = color self._marker = None self.marker = marker - self._size = 1 self.size = size + self.offset = offset self.xVboData = xVboData self.yVboData = yVboData @@ -679,54 +619,37 @@ class _Points2D(object): @property def marker(self): + """Symbol used to display markers (str)""" return self._marker @marker.setter def marker(self, marker): if marker in _MPL_NONES: self._marker = None - self.render = self._renderNone else: assert marker in self.MARKERS self._marker = marker - self.render = self._renderMarkers - - @property - def size(self): - return self._size - - @size.setter - def size(self, size): - # try: - # sizeRange = self._sizeRange - # except AttributeError: - # sizeRange = gl.glGetFloatv(gl.GL_POINT_SIZE_RANGE) - # # Shared among contexts, this should be enough.. - # _Points2D._sizeRange = sizeRange - # assert size >= sizeRange[0] and size <= sizeRange[1] - self._size = size @classmethod - def _getProgram(cls, transform, marker): + def _getProgram(cls, marker): """On-demand shader program creation.""" if marker == PIXEL: marker = SQUARE elif marker == POINT: marker = CIRCLE - try: - prgm = cls._programs[(transform, marker)] - except KeyError: - vertShdr = cls._SHADERS['vertex'] % \ - cls._SHADERS['vertexTransforms'][transform] - fragShdr = cls._SHADERS['fragment'] % \ - cls._SHADERS['fragmentSymbols'][marker] - prgm = Program(vertShdr, fragShdr, attrib0='xPos') - - cls._programs[(transform, marker)] = prgm - return prgm + + if marker not in cls._PROGRAMS: + cls._PROGRAMS[marker] = Program( + vertexShader=cls._VERTEX_SHADER, + fragmentShader=(cls._FRAGMENT_SHADER_TEMPLATE % + cls._FRAGMENT_SHADER_SYMBOLS[marker]), + attrib0='xPos') + + return cls._PROGRAMS[marker] @classmethod def init(cls): + """OpenGL context initialization""" version = gl.glGetString(gl.GL_VERSION) majorVersion = int(version[0]) assert majorVersion >= 2 @@ -735,30 +658,31 @@ class _Points2D(object): if majorVersion >= 3: # OpenGL 3 gl.glEnable(gl.GL_PROGRAM_POINT_SIZE) - def _renderNone(self, matrix, isXLog, isYLog): - pass + def render(self, matrix): + """Perform rendering - render = _renderNone + :param numpy.ndarray matrix: 4x4 transform matrix to use + """ + if self.marker is None: + return - def _renderMarkers(self, matrix, isXLog, isYLog): - if isXLog: - transform = self._LOG10_X_Y if isYLog else self._LOG10_X - else: - transform = self._LOG10_Y if isYLog else self._LINEAR + program = self._getProgram(self.marker) + program.use() + + matrix = numpy.dot(matrix, + mat4Translate(*self.offset)).astype(numpy.float32) + gl.glUniformMatrix4fv(program.uniforms['matrix'], 1, gl.GL_TRUE, matrix) - prog = self._getProgram(transform, self.marker) - prog.use() - gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix) if self.marker == PIXEL: size = 1 elif self.marker == POINT: size = math.ceil(0.5 * self.size) + 1 # Mimic Matplotlib point else: size = self.size - gl.glUniform1f(prog.uniforms['size'], size) + gl.glUniform1f(program.uniforms['size'], size) # gl.glPointSize(self.size) - cAttrib = prog.attributes['color'] + cAttrib = program.attributes['color'] if self.useColorVboData and self.colorVboData is not None: gl.glEnableVertexAttribArray(cAttrib) self.colorVboData.setVertexAttrib(cAttrib) @@ -766,11 +690,11 @@ class _Points2D(object): gl.glDisableVertexAttribArray(cAttrib) gl.glVertexAttrib4f(cAttrib, *self.color) - xAttrib = prog.attributes['xPos'] + xAttrib = program.attributes['xPos'] gl.glEnableVertexAttribArray(xAttrib) self.xVboData.setVertexAttrib(xAttrib) - yAttrib = prog.attributes['yPos'] + yAttrib = program.attributes['yPos'] gl.glEnableVertexAttribArray(yAttrib) self.yVboData.setVertexAttrib(yAttrib) @@ -786,40 +710,35 @@ class _ErrorBars(object): This is using its own VBO as opposed to fill/points/lines. There is no picking on error bars. - As is, there is no way to update data and errors, but it handles - log scales by removing data <= 0 and clipping error bars to positive - range. It uses 2 vertices per error bars and uses :class:`_Lines2D` to render error bars and :class:`_Points2D` to render the ends. + + :param numpy.ndarray xData: X coordinates of the data. + :param numpy.ndarray yData: Y coordinates of the data. + :param xError: The absolute error on the X axis. + :type xError: A float, or a numpy.ndarray of float32. + If it is an array, it can either be a 1D array of + same length as the data or a 2D array with 2 rows + of same length as the data: row 0 for negative errors, + row 1 for positive errors. + :param yError: The absolute error on the Y axis. + :type yError: A float, or a numpy.ndarray of float32. See xError. + :param float xMin: The min X value already computed by GLPlotCurve2D. + :param float yMin: The min Y value already computed by GLPlotCurve2D. + :param List[float] color: RGBA color as 4 float in [0, 1] + :param List[float] offset: Translation of coordinates (ox, oy) """ def __init__(self, xData, yData, xError, yError, xMin, yMin, - color=(0., 0., 0., 1.)): - """Initialization. - - :param numpy.ndarray xData: X coordinates of the data. - :param numpy.ndarray yData: Y coordinates of the data. - :param xError: The absolute error on the X axis. - :type xError: A float, or a numpy.ndarray of float32. - If it is an array, it can either be a 1D array of - same length as the data or a 2D array with 2 rows - of same length as the data: row 0 for negative errors, - row 1 for positive errors. - :param yError: The absolute error on the Y axis. - :type yError: A float, or a numpy.ndarray of float32. See xError. - :param float xMin: The min X value already computed by GLPlotCurve2D. - :param float yMin: The min Y value already computed by GLPlotCurve2D. - :param color: The color to use for both lines and ending points. - :type color: tuple of 4 floats - """ + color=(0., 0., 0., 1.), + offset=(0., 0.)): self._attribs = None - self._isXLog, self._isYLog = False, False self._xMin, self._yMin = xMin, yMin + self.offset = offset if xError is not None or yError is not None: - assert len(xData) == len(yData) self._xData = numpy.array( xData, order='C', dtype=numpy.float32, copy=False) self._yData = numpy.array( @@ -834,61 +753,19 @@ class _ErrorBars(object): self._xData, self._yData = None, None self._xError, self._yError = None, None - self._lines = _Lines2D(None, None, color=color, drawMode=gl.GL_LINES) - self._xErrPoints = _Points2D(None, None, color=color, marker=V_LINE) - self._yErrPoints = _Points2D(None, None, color=color, marker=H_LINE) + self._lines = _Lines2D( + None, None, color=color, drawMode=gl.GL_LINES, offset=offset) + self._xErrPoints = _Points2D( + None, None, color=color, marker=V_LINE, offset=offset) + self._yErrPoints = _Points2D( + None, None, color=color, marker=H_LINE, offset=offset) - def _positiveValueFilter(self, onlyXPos, onlyYPos): - """Filter data (x, y) and errors (xError, yError) to remove - negative and null data values on required axis (onlyXPos, onlyYPos). + def _buildVertices(self): + """Generates error bars vertices""" + nbLinesPerDataPts = (0 if self._xError is None else 2) + \ + (0 if self._yError is None else 2) - Returned arrays might be NOT contiguous. - - :return: Filtered xData, yData, xError and yError arrays. - """ - if ((not onlyXPos or self._xMin > 0.) and - (not onlyYPos or self._yMin > 0.)): - # No need to filter, all values are > 0 on log axes - return self._xData, self._yData, self._xError, self._yError - - _logger.warning( - 'Removing values <= 0 of curve with error bars on a log axis.') - - x, y = self._xData, self._yData - xError, yError = self._xError, self._yError - - # First remove negative data - if onlyXPos and onlyYPos: - mask = (x > 0.) & (y > 0.) - elif onlyXPos: - mask = x > 0. - else: # onlyYPos - mask = y > 0. - x, y = x[mask], y[mask] - - # Remove corresponding values from error arrays - if xError is not None and xError.size != 1: - if len(xError.shape) == 1: - xError = xError[mask] - else: # 2 rows - xError = xError[:, mask] - if yError is not None and yError.size != 1: - if len(yError.shape) == 1: - yError = yError[mask] - else: # 2 rows - yError = yError[:, mask] - - return x, y, xError, yError - - def _buildVertices(self, isXLog, isYLog): - """Generates error bars vertices according to log scales.""" - xData, yData, xError, yError = self._positiveValueFilter( - isXLog, isYLog) - - nbLinesPerDataPts = 1 if xError is not None else 0 - nbLinesPerDataPts += 1 if yError is not None else 0 - - nbDataPts = len(xData) + nbDataPts = len(self._xData) # interleave coord+error, coord-error. # xError vertices first if any, then yError vertices if any. @@ -897,64 +774,61 @@ class _ErrorBars(object): yCoords = numpy.empty(nbDataPts * nbLinesPerDataPts * 2, dtype=numpy.float32) - if xError is not None: # errors on the X axis - if len(xError.shape) == 2: - xErrorMinus, xErrorPlus = xError[0], xError[1] + if self._xError is not None: # errors on the X axis + if len(self._xError.shape) == 2: + xErrorMinus, xErrorPlus = self._xError[0], self._xError[1] else: # numpy arrays of len 1 or len(xData) - xErrorMinus, xErrorPlus = xError, xError + xErrorMinus, xErrorPlus = self._xError, self._xError # Interleave vertices for xError - endXError = 2 * nbDataPts - xCoords[0:endXError-1:2] = xData + xErrorPlus + endXError = 4 * nbDataPts + xCoords[0:endXError-3:4] = self._xData + xErrorPlus + xCoords[1:endXError-2:4] = self._xData + xCoords[2:endXError-1:4] = self._xData + xCoords[3:endXError:4] = self._xData - xErrorMinus - minValues = xData - xErrorMinus - if isXLog: - # Clip min bounds to positive value - minValues[minValues <= 0] = FLOAT32_MINPOS - xCoords[1:endXError:2] = minValues + yCoords[0:endXError-3:4] = self._yData + yCoords[1:endXError-2:4] = self._yData + yCoords[2:endXError-1:4] = self._yData + yCoords[3:endXError:4] = self._yData - yCoords[0:endXError-1:2] = yData - yCoords[1:endXError:2] = yData else: endXError = 0 - if yError is not None: # errors on the Y axis - if len(yError.shape) == 2: - yErrorMinus, yErrorPlus = yError[0], yError[1] + if self._yError is not None: # errors on the Y axis + if len(self._yError.shape) == 2: + yErrorMinus, yErrorPlus = self._yError[0], self._yError[1] else: # numpy arrays of len 1 or len(yData) - yErrorMinus, yErrorPlus = yError, yError + yErrorMinus, yErrorPlus = self._yError, self._yError # Interleave vertices for yError - xCoords[endXError::2] = xData - xCoords[endXError+1::2] = xData - yCoords[endXError::2] = yData + yErrorPlus - minValues = yData - yErrorMinus - if isYLog: - # Clip min bounds to positive value - minValues[minValues <= 0] = FLOAT32_MINPOS - yCoords[endXError+1::2] = minValues + xCoords[endXError::4] = self._xData + xCoords[endXError+1::4] = self._xData + xCoords[endXError+2::4] = self._xData + xCoords[endXError+3::4] = self._xData + + yCoords[endXError::4] = self._yData + yErrorPlus + yCoords[endXError+1::4] = self._yData + yCoords[endXError+2::4] = self._yData + yCoords[endXError+3::4] = self._yData - yErrorMinus return xCoords, yCoords - def prepare(self, isXLog, isYLog): + def prepare(self): + """Rendering preparation: build indices and bounding box vertices""" if self._xData is None: return - if self._isXLog != isXLog or self._isYLog != isYLog: - # Log state has changed - self._isXLog, self._isYLog = isXLog, isYLog - - self.discard() # discard existing VBOs - if self._attribs is None: - xCoords, yCoords = self._buildVertices(isXLog, isYLog) + xCoords, yCoords = self._buildVertices() xAttrib, yAttrib = vertexBuffer((xCoords, yCoords)) self._attribs = xAttrib, yAttrib - self._lines.xVboData, self._lines.yVboData = xAttrib, yAttrib + self._lines.xVboData = xAttrib + self._lines.yVboData = yAttrib # Set xError points using the same VBO as lines self._xErrPoints.xVboData = xAttrib.copy() @@ -972,13 +846,20 @@ class _ErrorBars(object): self._yErrPoints.yVboData.offset += (yAttrib.itemsize * yAttrib.size // 2) - def render(self, matrix, isXLog, isYLog): + def render(self, matrix): + """Perform rendering + + :param numpy.ndarray matrix: 4x4 transform matrix to use + """ + self.prepare() + if self._attribs is not None: - self._lines.render(matrix, isXLog, isYLog) - self._xErrPoints.render(matrix, isXLog, isYLog) - self._yErrPoints.render(matrix, isXLog, isYLog) + self._lines.render(matrix) + self._xErrPoints.render(matrix) + self._yErrPoints.render(matrix) def discard(self): + """Release VBOs""" if self._attribs is not None: self._lines.xVboData, self._lines.yVboData = None, None self._xErrPoints.xVboData, self._xErrPoints.yVboData = None, None @@ -1014,71 +895,80 @@ def _proxyProperty(*componentsAttributes): class GLPlotCurve2D(object): def __init__(self, xData, yData, colorData=None, xError=None, yError=None, - lineStyle=None, lineColor=None, - lineWidth=None, lineDashPeriod=None, - marker=None, markerColor=None, markerSize=None, - fillColor=None): - self._isXLog = False - self._isYLog = False - self.xData, self.yData, self.colorData = xData, yData, colorData - - if fillColor is not None: - self.fill = _Fill2D(color=fillColor) - else: - self.fill = None + lineStyle=SOLID, + lineColor=(0., 0., 0., 1.), + lineWidth=1, + lineDashPeriod=20, + marker=SQUARE, + markerColor=(0., 0., 0., 1.), + markerSize=7, + fillColor=None, + isYLog=False): + + self.colorData = colorData # Compute x bounds if xError is None: - result = min_max(xData, min_positive=True) - self.xMin = result.minimum - self.xMinPos = result.min_positive - self.xMax = result.maximum + self.xMin, self.xMax = min_max(xData, min_positive=False) else: # Takes the error into account if hasattr(xError, 'shape') and len(xError.shape) == 2: - xErrorPlus, xErrorMinus = xError[0], xError[1] + xErrorMinus, xErrorPlus = xError[0], xError[1] else: - xErrorPlus, xErrorMinus = xError, xError - result = min_max(xData - xErrorMinus, min_positive=True) - self.xMin = result.minimum - self.xMinPos = result.min_positive - self.xMax = (xData + xErrorPlus).max() + xErrorMinus, xErrorPlus = xError, xError + self.xMin = numpy.nanmin(xData - xErrorMinus) + self.xMax = numpy.nanmax(xData + xErrorPlus) # Compute y bounds if yError is None: - result = min_max(yData, min_positive=True) - self.yMin = result.minimum - self.yMinPos = result.min_positive - self.yMax = result.maximum + self.yMin, self.yMax = min_max(yData, min_positive=False) else: # Takes the error into account if hasattr(yError, 'shape') and len(yError.shape) == 2: - yErrorPlus, yErrorMinus = yError[0], yError[1] + yErrorMinus, yErrorPlus = yError[0], yError[1] else: - yErrorPlus, yErrorMinus = yError, yError - result = min_max(yData - yErrorMinus, min_positive=True) - self.yMin = result.minimum - self.yMinPos = result.min_positive - self.yMax = (yData + yErrorPlus).max() - - self._errorBars = _ErrorBars(xData, yData, xError, yError, - self.xMin, self.yMin) - - kwargs = {'style': lineStyle} - if lineColor is not None: - kwargs['color'] = lineColor - if lineWidth is not None: - kwargs['width'] = lineWidth - if lineDashPeriod is not None: - kwargs['dashPeriod'] = lineDashPeriod - self.lines = _Lines2D(**kwargs) - - kwargs = {'marker': marker} - if markerColor is not None: - kwargs['color'] = markerColor - if markerSize is not None: - kwargs['size'] = markerSize - self.points = _Points2D(**kwargs) + yErrorMinus, yErrorPlus = yError, yError + self.yMin = numpy.nanmin(yData - yErrorMinus) + self.yMax = numpy.nanmax(yData + yErrorPlus) + + # Handle data offset + if xData.itemsize > 4 or yData.itemsize > 4: # Use normalization + # offset data, do not offset error as it is relative + self.offset = self.xMin, self.yMin + self.xData = (xData - self.offset[0]).astype(numpy.float32) + self.yData = (yData - self.offset[1]).astype(numpy.float32) + + else: # float32 + self.offset = 0., 0. + self.xData = xData + self.yData = yData + + if fillColor is not None: + # Use different baseline depending of Y log scale + self.fill = _Fill2D(self.xData, self.yData, + baseline=-38 if isYLog else 0, + color=fillColor, + offset=self.offset) + else: + self.fill = None + + self._errorBars = _ErrorBars(self.xData, self.yData, + xError, yError, + self.xMin, self.yMin, + offset=self.offset) + + self.lines = _Lines2D() + self.lines.style = lineStyle + self.lines.color = lineColor + self.lines.width = lineWidth + self.lines.dashPeriod = lineDashPeriod + self.lines.offset = self.offset + + self.points = _Points2D() + self.points.marker = marker + self.points.color = markerColor + self.points.size = markerSize + self.points.offset = self.offset xVboData = _proxyProperty(('lines', 'xVboData'), ('points', 'xVboData')) @@ -1108,123 +998,53 @@ class GLPlotCurve2D(object): @classmethod def init(cls): + """OpenGL context initialization""" _Lines2D.init() _Points2D.init() - @staticmethod - def _logFilterData(x, y, color=None, xLog=False, yLog=False): - # Copied from Plot.py - if xLog and yLog: - idx = numpy.nonzero((x > 0) & (y > 0))[0] - x = numpy.take(x, idx) - y = numpy.take(y, idx) - elif yLog: - idx = numpy.nonzero(y > 0)[0] - x = numpy.take(x, idx) - y = numpy.take(y, idx) - elif xLog: - idx = numpy.nonzero(x > 0)[0] - x = numpy.take(x, idx) - y = numpy.take(y, idx) - else: - idx = None - - if idx is not None and isinstance(color, numpy.ndarray): - colors = numpy.zeros((x.size, 4), color.dtype) - colors[:, 0] = color[idx, 0] - colors[:, 1] = color[idx, 1] - colors[:, 2] = color[idx, 2] - colors[:, 3] = color[idx, 3] - else: - colors = color - return x, y, colors - - def prepare(self, isXLog, isYLog): - # init only supports updating isXLog, isYLog - xData, yData, colorData = self.xData, self.yData, self.colorData - - if self._isXLog != isXLog or self._isYLog != isYLog: - # Log state has changed - self._isXLog, self._isYLog = isXLog, isYLog - - # Check if data <= 0. with log scale - if (isXLog and self.xMin <= 0.) or (isYLog and self.yMin <= 0.): - # Filtering data is needed - xData, yData, colorData = self._logFilterData( - self.xData, self.yData, self.colorData, - self._isXLog, self._isYLog) - - self.discard() # discard existing VBOs - + def prepare(self): + """Rendering preparation: build indices and bounding box vertices""" if self.xVboData is None: xAttrib, yAttrib, cAttrib, dAttrib = None, None, None, None if self.lineStyle in (DASHED, DASHDOT, DOTTED): - dists = _distancesFromArrays(xData, yData) + dists = _distancesFromArrays(self.xData, self.yData) if self.colorData is None: xAttrib, yAttrib, dAttrib = vertexBuffer( - (xData, yData, dists), - prefix=(1, 1, 0), suffix=(1, 1, 0)) + (self.xData, self.yData, dists)) else: xAttrib, yAttrib, cAttrib, dAttrib = vertexBuffer( - (xData, yData, colorData, dists), - prefix=(1, 1, 0, 0), suffix=(1, 1, 0, 0)) + (self.xData, self.yData, self.colorData, dists)) elif self.colorData is None: - xAttrib, yAttrib = vertexBuffer( - (xData, yData), prefix=(1, 1), suffix=(1, 1)) + xAttrib, yAttrib = vertexBuffer((self.xData, self.yData)) else: xAttrib, yAttrib, cAttrib = vertexBuffer( - (xData, yData, colorData), prefix=(1, 1, 0)) - - # Shrink VBO - self.xVboData = xAttrib.copy() - self.xVboData.size -= 2 - self.xVboData.offset += xAttrib.itemsize + (self.xData, self.yData, self.colorData)) - self.yVboData = yAttrib.copy() - self.yVboData.size -= 2 - self.yVboData.offset += yAttrib.itemsize + self.xVboData = xAttrib + self.yVboData = yAttrib + self.distVboData = dAttrib - if cAttrib is not None and colorData.dtype.kind == 'u': + if cAttrib is not None and self.colorData.dtype.kind == 'u': cAttrib.normalization = True # Normalize uint to [0, 1] self.colorVboData = cAttrib self.useColorVboData = cAttrib is not None - self.distVboData = dAttrib - - if self.fill is not None: - xData = xData.reshape(xData.size, 1) - zero = numpy.array((1e-32,), dtype=self.yData.dtype) - - # Add one point before data: (x0, 0.) - xAttrib.vbo.update(xData[0], xAttrib.offset, - xData[0].itemsize) - yAttrib.vbo.update(zero, yAttrib.offset, zero.itemsize) - - # Add one point after data: (xN, 0.) - xAttrib.vbo.update(xData[-1], - xAttrib.offset + - (xAttrib.size - 1) * xAttrib.itemsize, - xData[-1].itemsize) - yAttrib.vbo.update(zero, - yAttrib.offset + - (yAttrib.size - 1) * yAttrib.itemsize, - zero.itemsize) - - self.fill.xFillVboData = xAttrib - self.fill.yFillVboData = yAttrib - self.fill.xMin, self.fill.yMin = self.xMin, self.yMin - self.fill.xMax, self.fill.yMax = self.xMax, self.yMax - - self._errorBars.prepare(isXLog, isYLog) def render(self, matrix, isXLog, isYLog): - self.prepare(isXLog, isYLog) + """Perform rendering + + :param numpy.ndarray matrix: 4x4 transform matrix to use + :param bool isXLog: + :param bool isYLog: + """ + self.prepare() if self.fill is not None: - self.fill.render(matrix, isXLog, isYLog) - self._errorBars.render(matrix, isXLog, isYLog) - self.lines.render(matrix, isXLog, isYLog) - self.points.render(matrix, isXLog, isYLog) + self.fill.render(matrix) + self._errorBars.render(matrix) + self.lines.render(matrix) + self.points.render(matrix) def discard(self): + """Release VBOs""" if self.xVboData is not None: self.xVboData.vbo.discard() @@ -1234,6 +1054,8 @@ class GLPlotCurve2D(object): self.distVboData = None self._errorBars.discard() + if self.fill is not None: + self.fill.discard() def pick(self, xPickMin, yPickMin, xPickMax, yPickMax): """Perform picking on the curve according to its rendering. @@ -1251,19 +1073,29 @@ class GLPlotCurve2D(object): if (self.marker is None and self.lineStyle is None) or \ self.xMin > xPickMax or xPickMin > self.xMax or \ self.yMin > yPickMax or yPickMin > self.yMax: - # Note: With log scale the bounding box is too large if - # some data <= 0. return None - elif self.lineStyle is not None: + # offset picking bounds + xPickMin = xPickMin - self.offset[0] + xPickMax = xPickMax - self.offset[0] + yPickMin = yPickMin - self.offset[1] + yPickMax = yPickMax - self.offset[1] + + if self.lineStyle is not None: # Using Cohen-Sutherland algorithm for line clipping - codes = ((self.yData > yPickMax) << 3) | \ + with warnings.catch_warnings(): # Ignore NaN comparison warnings + warnings.simplefilter('ignore', category=RuntimeWarning) + codes = ((self.yData > yPickMax) << 3) | \ ((self.yData < yPickMin) << 2) | \ ((self.xData > xPickMax) << 1) | \ (self.xData < xPickMin) + notNaN = numpy.logical_not(numpy.logical_or( + numpy.isnan(self.xData), numpy.isnan(self.yData))) + # Add all points that are inside the picking area - indices = numpy.nonzero(codes == 0)[0].tolist() + indices = numpy.nonzero( + numpy.logical_and(codes == 0, notNaN))[0].tolist() # Segment that might cross the area with no end point inside it segToTestIdx = numpy.nonzero((codes[:-1] != 0) & @@ -1309,9 +1141,11 @@ class GLPlotCurve2D(object): indices.sort() else: - indices = numpy.nonzero((self.xData >= xPickMin) & - (self.xData <= xPickMax) & - (self.yData >= yPickMin) & - (self.yData <= yPickMax))[0].tolist() + with warnings.catch_warnings(): # Ignore NaN comparison warnings + warnings.simplefilter('ignore', category=RuntimeWarning) + indices = numpy.nonzero((self.xData >= xPickMin) & + (self.xData <= xPickMax) & + (self.yData >= yPickMin) & + (self.yData <= yPickMax))[0].tolist() return indices diff --git a/silx/gui/plot/backends/glutils/GLPlotFrame.py b/silx/gui/plot/backends/glutils/GLPlotFrame.py index eb101c4..4ad1547 100644 --- a/silx/gui/plot/backends/glutils/GLPlotFrame.py +++ b/silx/gui/plot/backends/glutils/GLPlotFrame.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# Copyright (c) 2014-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 @@ -35,6 +35,7 @@ __date__ = "03/04/2017" # keep aspect ratio managed here? # smarter dirty flag handling? +import datetime as dt import math import weakref import logging @@ -47,7 +48,8 @@ from ..._utils import FLOAT32_SAFE_MIN, FLOAT32_MINPOS, FLOAT32_SAFE_MAX from .GLSupport import mat4Ortho from .GLText import Text2D, CENTER, BOTTOM, TOP, LEFT, RIGHT, ROTATE_270 from ..._utils.ticklayout import niceNumbersAdaptative, niceNumbersForLog10 - +from ..._utils.dtime_ticklayout import calcTicksAdaptive, bestFormatString +from ..._utils.dtime_ticklayout import timestamp _logger = logging.getLogger(__name__) @@ -68,6 +70,8 @@ class PlotAxis(object): self._plot = weakref.ref(plot) + self._isDateTime = False + self._timeZone = None self._isLog = False self._dataRange = 1., 100. self._displayCoords = (0., 0.), (1., 0.) @@ -110,6 +114,29 @@ class PlotAxis(object): self._dirtyTicks() @property + def timeZone(self): + """Returnss datetime.tzinfo that is used if this axis plots date times.""" + return self._timeZone + + @timeZone.setter + def timeZone(self, tz): + """Sets dateetime.tzinfo that is used if this axis plots date times.""" + self._timeZone = tz + self._dirtyTicks() + + @property + def isTimeSeries(self): + """Whether the axis is showing floats as datetime objects""" + return self._isDateTime + + @isTimeSeries.setter + def isTimeSeries(self, isTimeSeries): + isTimeSeries = bool(isTimeSeries) + if isTimeSeries != self._isDateTime: + self._isDateTime = isTimeSeries + self._dirtyTicks() + + @property def displayCoords(self): """The coordinates of the start and end points of the axis in display space (i.e., in pixels) as a tuple of 2 tuples of @@ -235,6 +262,10 @@ class PlotAxis(object): (x0, y0), (x1, y1) = self.displayCoords if self.isLog: + + if self.isTimeSeries: + _logger.warning("Time series not implemented for log-scale") + logMin, logMax = math.log10(dataMin), math.log10(dataMax) tickMin, tickMax, step, _ = niceNumbersForLog10(logMin, logMax) @@ -269,19 +300,41 @@ class PlotAxis(object): # Density of 1.3 label per 92 pixels # i.e., 1.3 label per inch on a 92 dpi screen - tickMin, tickMax, step, nbFrac = niceNumbersAdaptative( - dataMin, dataMax, nbPixels, 1.3 / 92) - - for dataPos in self._frange(tickMin, tickMax, step): - if dataMin <= dataPos <= dataMax: - xPixel = x0 + (dataPos - dataMin) * xScale - yPixel = y0 + (dataPos - dataMin) * yScale - - if nbFrac == 0: - text = '%g' % dataPos - else: - text = ('%.' + str(nbFrac) + 'f') % dataPos - yield ((xPixel, yPixel), dataPos, text) + tickDensity = 1.3 / 92 + + if not self.isTimeSeries: + tickMin, tickMax, step, nbFrac = niceNumbersAdaptative( + dataMin, dataMax, nbPixels, tickDensity) + + for dataPos in self._frange(tickMin, tickMax, step): + if dataMin <= dataPos <= dataMax: + xPixel = x0 + (dataPos - dataMin) * xScale + yPixel = y0 + (dataPos - dataMin) * yScale + + if nbFrac == 0: + text = '%g' % dataPos + else: + text = ('%.' + str(nbFrac) + 'f') % dataPos + yield ((xPixel, yPixel), dataPos, text) + else: + # Time series + dtMin = dt.datetime.fromtimestamp(dataMin, tz=self.timeZone) + dtMax = dt.datetime.fromtimestamp(dataMax, tz=self.timeZone) + + tickDateTimes, spacing, unit = calcTicksAdaptive( + dtMin, dtMax, nbPixels, tickDensity) + + for tickDateTime in tickDateTimes: + if dtMin <= tickDateTime <= dtMax: + + dataPos = timestamp(tickDateTime) + xPixel = x0 + (dataPos - dataMin) * xScale + yPixel = y0 + (dataPos - dataMin) * yScale + + fmtStr = bestFormatString(spacing, unit) + text = tickDateTime.strftime(fmtStr) + + yield ((xPixel, yPixel), dataPos, text) # GLPlotFrame ################################################################# @@ -501,7 +554,8 @@ class GLPlotFrame(object): gl.glLineWidth(self._LINE_WIDTH) - gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matProj) + gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, + matProj.astype(numpy.float32)) gl.glUniform4f(prog.uniforms['color'], 0., 0., 0., 1.) gl.glUniform1f(prog.uniforms['tickFactor'], 0.) @@ -534,7 +588,8 @@ class GLPlotFrame(object): prog.use() gl.glLineWidth(self._LINE_WIDTH) - gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matProj) + gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, + matProj.astype(numpy.float32)) gl.glUniform4f(prog.uniforms['color'], 0.7, 0.7, 0.7, 1.) gl.glUniform1f(prog.uniforms['tickFactor'], 0.) # 1/2.) # 1/tickLen @@ -810,11 +865,11 @@ class GLPlotFrame2D(GLPlotFrame): # Non-orthogonal axes if self.baseVectors != self.DEFAULT_BASE_VECTORS: (xx, xy), (yx, yy) = self.baseVectors - mat = mat * numpy.matrix(( + mat = numpy.dot(mat, numpy.array(( (xx, yx, 0., 0.), (xy, yy, 0., 0.), (0., 0., 1., 0.), - (0., 0., 0., 1.)), dtype=numpy.float32) + (0., 0., 0., 1.)), dtype=numpy.float64)) self._transformedDataProjMat = mat @@ -839,11 +894,11 @@ class GLPlotFrame2D(GLPlotFrame): # Non-orthogonal axes if self.baseVectors != self.DEFAULT_BASE_VECTORS: (xx, xy), (yx, yy) = self.baseVectors - mat = mat * numpy.matrix(( + mat = numpy.dot(mat, numpy.matrix(( (xx, yx, 0., 0.), (xy, yy, 0., 0.), (0., 0., 1., 0.), - (0., 0., 0., 1.)), dtype=numpy.float32) + (0., 0., 0., 1.)), dtype=numpy.float64)) self._transformedDataY2ProjMat = mat diff --git a/silx/gui/plot/backends/glutils/GLPlotImage.py b/silx/gui/plot/backends/glutils/GLPlotImage.py index df5b289..6f3c487 100644 --- a/silx/gui/plot/backends/glutils/GLPlotImage.py +++ b/silx/gui/plot/backends/glutils/GLPlotImage.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# Copyright (c) 2014-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 @@ -350,8 +350,11 @@ class GLPlotColormap(_GLPlotData2D): gl.glUniform1i(prog.uniforms['data'], self._DATA_TEX_UNIT) - mat = matrix * mat4Translate(*self.origin) * mat4Scale(*self.scale) - gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, mat) + mat = numpy.dot(numpy.dot(matrix, + mat4Translate(*self.origin)), + mat4Scale(*self.scale)) + gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, + mat.astype(numpy.float32)) gl.glUniform1f(prog.uniforms['alpha'], self.alpha) @@ -377,9 +380,11 @@ class GLPlotColormap(_GLPlotData2D): gl.glUniform1i(prog.uniforms['data'], self._DATA_TEX_UNIT) - gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix) - mat = mat4Translate(ox, oy) * mat4Scale(*self.scale) - gl.glUniformMatrix4fv(prog.uniforms['matOffset'], 1, gl.GL_TRUE, mat) + gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, + matrix.astype(numpy.float32)) + mat = numpy.dot(mat4Translate(ox, oy), mat4Scale(*self.scale)) + gl.glUniformMatrix4fv(prog.uniforms['matOffset'], 1, gl.GL_TRUE, + mat.astype(numpy.float32)) gl.glUniform2i(prog.uniforms['isLog'], isXLog, isYLog) @@ -598,8 +603,10 @@ class GLPlotRGBAImage(_GLPlotData2D): gl.glUniform1i(prog.uniforms['tex'], self._DATA_TEX_UNIT) - mat = matrix * mat4Translate(*self.origin) * mat4Scale(*self.scale) - gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, mat) + mat = numpy.dot(numpy.dot(matrix, mat4Translate(*self.origin)), + mat4Scale(*self.scale)) + gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, + mat.astype(numpy.float32)) gl.glUniform1f(prog.uniforms['alpha'], self.alpha) @@ -617,9 +624,11 @@ class GLPlotRGBAImage(_GLPlotData2D): gl.glUniform1i(prog.uniforms['tex'], self._DATA_TEX_UNIT) - gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix) - mat = mat4Translate(ox, oy) * mat4Scale(*self.scale) - gl.glUniformMatrix4fv(prog.uniforms['matOffset'], 1, gl.GL_TRUE, mat) + gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, + matrix.astype(numpy.float32)) + mat = numpy.dot(mat4Translate(ox, oy), mat4Scale(*self.scale)) + gl.glUniformMatrix4fv(prog.uniforms['matOffset'], 1, gl.GL_TRUE, + mat.astype(numpy.float32)) gl.glUniform2i(prog.uniforms['isLog'], isXLog, isYLog) diff --git a/silx/gui/plot/backends/glutils/GLSupport.py b/silx/gui/plot/backends/glutils/GLSupport.py index 3f473be..18c5eb7 100644 --- a/silx/gui/plot/backends/glutils/GLSupport.py +++ b/silx/gui/plot/backends/glutils/GLSupport.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# Copyright (c) 2014-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 @@ -36,11 +36,20 @@ import numpy from ...._glutils import gl -def buildFillMaskIndices(nIndices): - if nIndices <= numpy.iinfo(numpy.uint16).max + 1: - dtype = numpy.uint16 - else: - dtype = numpy.uint32 +def buildFillMaskIndices(nIndices, dtype=None): + """Returns triangle strip indices for rendering a filled polygon mask + + :param int nIndices: Number of points + :param Union[numpy.dtype,None] dtype: + If specified the dtype of the returned indices array + :return: 1D array of indices constructing a triangle strip + :rtype: numpy.ndarray + """ + if dtype is None: + if nIndices <= numpy.iinfo(numpy.uint16).max + 1: + dtype = numpy.uint16 + else: + dtype = numpy.uint32 lastIndex = nIndices - 1 splitIndex = lastIndex // 2 + 1 @@ -158,35 +167,35 @@ class Shape2D(object): def mat4Ortho(left, right, bottom, top, near, far): """Orthographic projection matrix (row-major)""" - return numpy.matrix(( + return numpy.array(( (2./(right - left), 0., 0., -(right+left)/float(right-left)), (0., 2./(top - bottom), 0., -(top+bottom)/float(top-bottom)), (0., 0., -2./(far-near), -(far+near)/float(far-near)), - (0., 0., 0., 1.)), dtype=numpy.float32) + (0., 0., 0., 1.)), dtype=numpy.float64) def mat4Translate(x=0., y=0., z=0.): """Translation matrix (row-major)""" - return numpy.matrix(( + return numpy.array(( (1., 0., 0., x), (0., 1., 0., y), (0., 0., 1., z), - (0., 0., 0., 1.)), dtype=numpy.float32) + (0., 0., 0., 1.)), dtype=numpy.float64) def mat4Scale(sx=1., sy=1., sz=1.): """Scale matrix (row-major)""" - return numpy.matrix(( + return numpy.array(( (sx, 0., 0., 0.), (0., sy, 0., 0.), (0., 0., sz, 0.), - (0., 0., 0., 1.)), dtype=numpy.float32) + (0., 0., 0., 1.)), dtype=numpy.float64) def mat4Identity(): """Identity matrix""" - return numpy.matrix(( + return numpy.array(( (1., 0., 0., 0.), (0., 1., 0., 0.), (0., 0., 1., 0.), - (0., 0., 0., 1.)), dtype=numpy.float32) + (0., 0., 0., 1.)), dtype=numpy.float64) diff --git a/silx/gui/plot/backends/glutils/GLText.py b/silx/gui/plot/backends/glutils/GLText.py index cef0c5a..1540e26 100644 --- a/silx/gui/plot/backends/glutils/GLText.py +++ b/silx/gui/plot/backends/glutils/GLText.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# Copyright (c) 2014-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 @@ -195,8 +195,9 @@ class Text2D(object): gl.glUniform1i(prog.uniforms['texText'], texUnit) + mat = numpy.dot(matrix, mat4Translate(int(self.x), int(self.y))) gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, - matrix * mat4Translate(int(self.x), int(self.y))) + mat.astype(numpy.float32)) gl.glUniform4f(prog.uniforms['color'], *self.color) if self.bgColor is not None: diff --git a/silx/gui/plot/items/axis.py b/silx/gui/plot/items/axis.py index d7e6eff..3d9fe14 100644 --- a/silx/gui/plot/items/axis.py +++ b/silx/gui/plot/items/axis.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# Copyright (c) 2017-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 @@ -29,12 +29,24 @@ __authors__ = ["V. Valls"] __license__ = "MIT" __date__ = "06/12/2017" +import datetime as dt import logging + +import dateutil.tz + from ... import qt +from silx.third_party import enum + _logger = logging.getLogger(__name__) +class TickMode(enum.Enum): + """Determines if ticks are regular number or datetimes.""" + DEFAULT = 0 # Ticks are regular numbers + TIME_SERIES = 1 # Ticks are datetime objects + + class Axis(qt.QObject): """This class describes and controls a plot axis. @@ -82,7 +94,23 @@ class Axis(qt.QObject): # Store currently displayed labels # Current label can differ from input one with active curve handling self._currentLabel = '' - self._plot = plot + + def _getPlot(self): + """Returns the PlotWidget this Axis belongs to. + + :rtype: PlotWidget + """ + plot = self.parent() + if plot is None: + raise RuntimeError("Axis no longer attached to a PlotWidget") + return plot + + def _getBackend(self): + """Returns the backend + + :rtype: BackendBase + """ + return self._getPlot()._backend def getLimits(self): """Get the limits of this axis. @@ -102,7 +130,7 @@ class Axis(qt.QObject): return self._internalSetLimits(vmin, vmax) - self._plot._setDirtyPlot() + self._getPlot()._setDirtyPlot() self._emitLimitsChanged() @@ -110,7 +138,7 @@ class Axis(qt.QObject): """Emit axis sigLimitsChanged and PlotWidget limitsChanged event""" vmin, vmax = self.getLimits() self.sigLimitsChanged.emit(vmin, vmax) - self._plot._notifyLimitsChanged(emitSignal=False) + self._getPlot()._notifyLimitsChanged(emitSignal=False) def _checkLimits(self, vmin, vmax): """Makes sure axis range is not empty @@ -172,7 +200,7 @@ class Axis(qt.QObject): """ self._defaultLabel = label self._setCurrentLabel(label) - self._plot._setDirtyPlot() + self._getPlot()._setDirtyPlot() def _setCurrentLabel(self, label): """Define the label currently displayed. @@ -207,6 +235,14 @@ class Axis(qt.QObject): # For the backward compatibility signal emitLog = self._scale == self.LOGARITHMIC or scale == self.LOGARITHMIC + self._scale = scale + + # TODO hackish way of forcing update of curves and images + plot = self._getPlot() + for item in plot._getItems(withhidden=True): + item._updated() + plot._invalidateDataRange() + if scale == self.LOGARITHMIC: self._internalSetLogarithmic(True) elif scale == self.LINEAR: @@ -214,13 +250,7 @@ class Axis(qt.QObject): else: raise ValueError("Scale %s unsupported" % scale) - self._scale = scale - - # TODO hackish way of forcing update of curves and images - for item in self._plot._getItems(withhidden=True): - item._updated() - self._plot._invalidateDataRange() - self._plot._forceResetZoom() + plot._forceResetZoom() self.sigScaleChanged.emit(self._scale) if emitLog: @@ -241,6 +271,40 @@ class Axis(qt.QObject): flag = bool(flag) self.setScale(self.LOGARITHMIC if flag else self.LINEAR) + def getTimeZone(self): + """Sets tzinfo that is used if this axis plots date times. + + None means the datetimes are interpreted as local time. + + :rtype: datetime.tzinfo of None. + """ + raise NotImplementedError() + + def setTimeZone(self, tz): + """Sets tzinfo that is used if this axis' tickMode is TIME_SERIES + + The tz must be a descendant of the datetime.tzinfo class, "UTC" or None. + Use None to let the datetimes be interpreted as local time. + Use the string "UTC" to let the date datetimes be in UTC time. + + :param tz: datetime.tzinfo, "UTC" or None. + """ + raise NotImplementedError() + + def getTickMode(self): + """Determines if axis ticks are number or datetimes. + + :rtype: TickMode enum. + """ + raise NotImplementedError() + + def setTickMode(self, tickMode): + """Determines if axis ticks are number or datetimes. + + :param TickMode tickMode: tick mode enum. + """ + raise NotImplementedError() + def isAutoScale(self): """Return True if axis is automatically adjusting its limits. @@ -271,7 +335,7 @@ class Axis(qt.QObject): """ updated = self._setLimitsConstraints(minPos, maxPos) if updated: - plot = self._plot + plot = self._getPlot() xMin, xMax = plot.getXAxis().getLimits() yMin, yMax = plot.getYAxis().getLimits() y2Min, y2Max = plot.getYAxis('right').getLimits() @@ -294,7 +358,7 @@ class Axis(qt.QObject): """ updated = self._setRangeConstraints(minRange, maxRange) if updated: - plot = self._plot + plot = self._getPlot() xMin, xMax = plot.getXAxis().getLimits() yMin, yMax = plot.getYAxis().getLimits() y2Min, y2Max = plot.getYAxis('right').getLimits() @@ -308,25 +372,51 @@ class XAxis(Axis): # TODO With some changes on the backend, it will be able to remove all this # specialised implementations (prefixel by '_internal') + def getTimeZone(self): + return self._getBackend().getXAxisTimeZone() + + def setTimeZone(self, tz): + if isinstance(tz, str) and tz.upper() == "UTC": + tz = dateutil.tz.tzutc() + elif not(tz is None or isinstance(tz, dt.tzinfo)): + raise TypeError("tz must be a dt.tzinfo object, None or 'UTC'.") + + self._getBackend().setXAxisTimeZone(tz) + self._getPlot()._setDirtyPlot() + + def getTickMode(self): + if self._getBackend().isXAxisTimeSeries(): + return TickMode.TIME_SERIES + else: + return TickMode.DEFAULT + + def setTickMode(self, tickMode): + if tickMode == TickMode.DEFAULT: + self._getBackend().setXAxisTimeSeries(False) + elif tickMode == TickMode.TIME_SERIES: + self._getBackend().setXAxisTimeSeries(True) + else: + raise ValueError("Unexpected TickMode: {}".format(tickMode)) + def _internalSetCurrentLabel(self, label): - self._plot._backend.setGraphXLabel(label) + self._getBackend().setGraphXLabel(label) def _internalGetLimits(self): - return self._plot._backend.getGraphXLimits() + return self._getBackend().getGraphXLimits() def _internalSetLimits(self, xmin, xmax): - self._plot._backend.setGraphXLimits(xmin, xmax) + self._getBackend().setGraphXLimits(xmin, xmax) def _internalSetLogarithmic(self, flag): - self._plot._backend.setXAxisLogarithmic(flag) + self._getBackend().setXAxisLogarithmic(flag) def _setLimitsConstraints(self, minPos=None, maxPos=None): - constrains = self._plot._getViewConstraints() + constrains = self._getPlot()._getViewConstraints() updated = constrains.update(xMin=minPos, xMax=maxPos) return updated def _setRangeConstraints(self, minRange=None, maxRange=None): - constrains = self._plot._getViewConstraints() + constrains = self._getPlot()._getViewConstraints() updated = constrains.update(minXRange=minRange, maxXRange=maxRange) return updated @@ -338,16 +428,16 @@ class YAxis(Axis): # specialised implementations (prefixel by '_internal') def _internalSetCurrentLabel(self, label): - self._plot._backend.setGraphYLabel(label, axis='left') + self._getBackend().setGraphYLabel(label, axis='left') def _internalGetLimits(self): - return self._plot._backend.getGraphYLimits(axis='left') + return self._getBackend().getGraphYLimits(axis='left') def _internalSetLimits(self, ymin, ymax): - self._plot._backend.setGraphYLimits(ymin, ymax, axis='left') + self._getBackend().setGraphYLimits(ymin, ymax, axis='left') def _internalSetLogarithmic(self, flag): - self._plot._backend.setYAxisLogarithmic(flag) + self._getBackend().setYAxisLogarithmic(flag) def setInverted(self, flag=True): """Set the axis orientation. @@ -358,8 +448,8 @@ class YAxis(Axis): False for Y axis going from bottom to top """ flag = bool(flag) - self._plot._backend.setYAxisInverted(flag) - self._plot._setDirtyPlot() + self._getBackend().setYAxisInverted(flag) + self._getPlot()._setDirtyPlot() self.sigInvertedChanged.emit(flag) def isInverted(self): @@ -368,15 +458,15 @@ class YAxis(Axis): :rtype: bool """ - return self._plot._backend.isYAxisInverted() + return self._getBackend().isYAxisInverted() def _setLimitsConstraints(self, minPos=None, maxPos=None): - constrains = self._plot._getViewConstraints() + constrains = self._getPlot()._getViewConstraints() updated = constrains.update(yMin=minPos, yMax=maxPos) return updated def _setRangeConstraints(self, minRange=None, maxRange=None): - constrains = self._plot._getViewConstraints() + constrains = self._getPlot()._getViewConstraints() updated = constrains.update(minYRange=minRange, maxYRange=maxRange) return updated @@ -419,13 +509,13 @@ class YRightAxis(Axis): return self.__mainAxis.sigAutoScaleChanged def _internalSetCurrentLabel(self, label): - self._plot._backend.setGraphYLabel(label, axis='right') + self._getBackend().setGraphYLabel(label, axis='right') def _internalGetLimits(self): - return self._plot._backend.getGraphYLimits(axis='right') + return self._getBackend().getGraphYLimits(axis='right') def _internalSetLimits(self, ymin, ymax): - self._plot._backend.setGraphYLimits(ymin, ymax, axis='right') + self._getBackend().setGraphYLimits(ymin, ymax, axis='right') def setInverted(self, flag=True): """Set the Y axis orientation. diff --git a/silx/gui/plot/items/complex.py b/silx/gui/plot/items/complex.py index ba57e85..535b0a9 100644 --- a/silx/gui/plot/items/complex.py +++ b/silx/gui/plot/items/complex.py @@ -29,7 +29,7 @@ from __future__ import absolute_import __authors__ = ["Vincent Favre-Nicolin", "T. Vincent"] __license__ = "MIT" -__date__ = "19/01/2018" +__date__ = "14/06/2018" import logging @@ -37,7 +37,7 @@ import numpy from silx.third_party import enum -from ..Colormap import Colormap +from ...colors import Colormap from .core import ColormapMixIn, ItemChangedType from .image import ImageBase @@ -229,7 +229,7 @@ class ImageComplexData(ImageBase, ColormapMixIn): def setColormap(self, colormap, mode=None): """Set the colormap for this specific mode. - :param ~silx.gui.plot.Colormap.Colormap colormap: The colormap + :param ~silx.gui.colors.Colormap colormap: The colormap :param Mode mode: If specified, set the colormap of this specific mode. Default: current mode. @@ -249,7 +249,7 @@ class ImageComplexData(ImageBase, ColormapMixIn): :param Mode mode: If specified, get the colormap of this specific mode. Default: current mode. - :rtype: ~silx.gui.plot.Colormap.Colormap + :rtype: ~silx.gui.colors.Colormap """ if mode is None: mode = self.getVisualizationMode() diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py index bcb6dd1..4ed0914 100644 --- a/silx/gui/plot/items/core.py +++ b/silx/gui/plot/items/core.py @@ -27,18 +27,19 @@ __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "27/06/2017" +__date__ = "14/06/2018" import collections from copy import deepcopy import logging +import warnings import weakref import numpy from silx.third_party import six, enum from ... import qt -from .. import Colors -from ..Colormap import Colormap +from ... import colors +from ...colors import Colormap _logger = logging.getLogger(__name__) @@ -409,7 +410,7 @@ class ColormapMixIn(ItemMixInBase): def setColormap(self, colormap): """Set the colormap of this image - :param silx.gui.plot.Colormap.Colormap colormap: colormap description + :param silx.gui.colors.Colormap colormap: colormap description """ if isinstance(colormap, dict): colormap = Colormap._fromDict(colormap) @@ -619,17 +620,17 @@ class ColorMixIn(ItemMixInBase): :param color: color(s) to be used :type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or - one of the predefined color names defined in Colors.py + one of the predefined color names defined in colors.py :param bool copy: True (Default) to get a copy, False to use internal representation (do not modify!) """ if isinstance(color, six.string_types): - color = Colors.rgba(color) + color = colors.rgba(color) else: color = numpy.array(color, copy=copy) # TODO more checks + improve color array support if color.ndim == 1: # Single RGBA color - color = Colors.rgba(color) + color = colors.rgba(color) else: # Array of colors assert color.ndim == 2 @@ -767,7 +768,10 @@ class Points(Item, SymbolMixIn, AlphaMixIn): error = numpy.ravel(error) # Supports error being scalar, N or 2xN array - errorClipped = (value - numpy.atleast_2d(error)[0]) <= 0 + valueMinusError = value - numpy.atleast_2d(error)[0] + errorClipped = numpy.isnan(valueMinusError) + mask = numpy.logical_not(errorClipped) + errorClipped[mask] = valueMinusError[mask] <= 0 if numpy.any(errorClipped): # Need filtering @@ -805,10 +809,20 @@ class Points(Item, SymbolMixIn, AlphaMixIn): """ assert xPositive or yPositive if (xPositive, yPositive) not in self._clippedCache: - x = self.getXData(copy=False) - y = self.getYData(copy=False) - xclipped = (x <= 0) if xPositive else False - yclipped = (y <= 0) if yPositive else False + xclipped, yclipped = False, False + + if xPositive: + x = self.getXData(copy=False) + with warnings.catch_warnings(): # Ignore NaN warnings + warnings.simplefilter('ignore', category=RuntimeWarning) + xclipped = x <= 0 + + if yPositive: + y = self.getYData(copy=False) + with warnings.catch_warnings(): # Ignore NaN warnings + warnings.simplefilter('ignore', category=RuntimeWarning) + yclipped = y <= 0 + self._clippedCache[(xPositive, yPositive)] = \ numpy.logical_or(xclipped, yclipped) return self._clippedCache[(xPositive, yPositive)] diff --git a/silx/gui/plot/items/curve.py b/silx/gui/plot/items/curve.py index 0ba475d..50ad86d 100644 --- a/silx/gui/plot/items/curve.py +++ b/silx/gui/plot/items/curve.py @@ -27,13 +27,13 @@ __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "06/03/2017" +__date__ = "24/04/2018" import logging import numpy -from .. import Colors +from ... import colors from .core import (Points, LabelsMixIn, ColorMixIn, YAxisMixIn, FillMixIn, LineMixIn, ItemChangedType) @@ -170,9 +170,9 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn): :param color: color(s) to be used for highlight :type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or - one of the predefined color names defined in Colors.py + one of the predefined color names defined in colors.py """ - color = Colors.rgba(color) + color = colors.rgba(color) if color != self._highlightColor: self._highlightColor = color self._updated(ItemChangedType.HIGHLIGHTED_COLOR) diff --git a/silx/gui/plot/items/histogram.py b/silx/gui/plot/items/histogram.py index ad89677..3545345 100644 --- a/silx/gui/plot/items/histogram.py +++ b/silx/gui/plot/items/histogram.py @@ -29,7 +29,6 @@ __authors__ = ["H. Payno", "T. Vincent"] __license__ = "MIT" __date__ = "27/06/2017" - import logging import numpy @@ -37,7 +36,6 @@ import numpy from .core import (Item, AlphaMixIn, ColorMixIn, FillMixIn, LineMixIn, YAxisMixIn, ItemChangedType) - _logger = logging.getLogger(__name__) @@ -290,5 +288,40 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn, self._histogram = histogram self._edges = edges + self._alignement = align self._updated(ItemChangedType.DATA) + + def getAlignment(self): + """ + + :return: histogram alignement. Value in ('center', 'left', 'right'). + """ + return self._alignement + + def _revertComputeEdges(self, x, histogramType): + """Compute the edges from a set of xs and a rule to generate the edges + + :param x: the x value of the curve to transform into an histogram + :param histogramType: the type of histogram we wan't to generate. + This define the way to center the histogram values compared to the + curve value. Possible values can be:: + + - 'left' + - 'right' + - 'center' + + :return: the edges for the given x and the histogramType + """ + # for now we consider that the spaces between xs are constant + edges = x.copy() + if histogramType is 'left': + return edges[1:] + if histogramType is 'center': + edges = (edges[1:] + edges[:-1]) / 2.0 + if histogramType is 'right': + width = 1 + if len(x) > 1: + width = x[-1] + x[-2] + edges = edges[:-1] + return edges diff --git a/silx/gui/plot/items/roi.py b/silx/gui/plot/items/roi.py new file mode 100644 index 0000000..f55ef91 --- /dev/null +++ b/silx/gui/plot/items/roi.py @@ -0,0 +1,1416 @@ +# 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 ROI item for the :class:`~silx.gui.plot.PlotWidget`. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "28/06/2018" + + +import functools +import itertools +import logging +import collections +import numpy + +from ....utils.weakref import WeakList +from ... import qt +from .. import items +from ...colors import rgba + + +logger = logging.getLogger(__name__) + + +class RegionOfInterest(qt.QObject): + """Object describing a region of interest in a plot. + + :param QObject parent: + The RegionOfInterestManager that created this object + """ + + _kind = None + """Label for this kind of ROI. + + Should be setted by inherited classes to custom the ROI manager widget. + """ + + sigRegionChanged = qt.Signal() + """Signal emitted everytime the shape or position of the ROI changes""" + + def __init__(self, parent=None): + # Avoid circular dependancy + from ..tools import roi as roi_tools + assert parent is None or isinstance(parent, roi_tools.RegionOfInterestManager) + super(RegionOfInterest, self).__init__(parent) + self._color = rgba('red') + self._items = WeakList() + self._editAnchors = WeakList() + self._points = None + self._label = '' + self._labelItem = None + self._editable = False + + def __del__(self): + # Clean-up plot items + self._removePlotItems() + + def setParent(self, parent): + """Set the parent of the RegionOfInterest + + :param Union[None,RegionOfInterestManager] parent: + """ + # Avoid circular dependancy + from ..tools import roi as roi_tools + if (parent is not None and not isinstance(parent, roi_tools.RegionOfInterestManager)): + raise ValueError('Unsupported parent') + + self._removePlotItems() + super(RegionOfInterest, self).setParent(parent) + self._createPlotItems() + + @classmethod + def _getKind(cls): + """Return an human readable kind of ROI + + :rtype: str + """ + return cls._kind + + def getColor(self): + """Returns the color of this ROI + + :rtype: QColor + """ + return qt.QColor.fromRgbF(*self._color) + + def _getAnchorColor(self, color): + """Returns the anchor color from the base ROI color + + :param Union[numpy.array,Tuple,List]: color + :rtype: Union[numpy.array,Tuple,List] + """ + return color[:3] + (0.5,) + + def setColor(self, color): + """Set the color used for this ROI. + + :param color: The color to use for ROI shape as + either a color name, a QColor, a list of uint8 or float in [0, 1]. + """ + color = rgba(color) + if color != self._color: + self._color = color + + # Update color of shape items in the plot + rgbaColor = rgba(color) + for item in list(self._items): + if isinstance(item, items.ColorMixIn): + item.setColor(rgbaColor) + item = self._getLabelItem() + if isinstance(item, items.ColorMixIn): + item.setColor(rgbaColor) + + rgbaColor = self._getAnchorColor(rgbaColor) + for item in list(self._editAnchors): + if isinstance(item, items.ColorMixIn): + item.setColor(rgbaColor) + + def getLabel(self): + """Returns the label displayed for this ROI. + + :rtype: str + """ + return self._label + + def setLabel(self, label): + """Set the label displayed with this ROI. + + :param str label: The text label to display + """ + label = str(label) + if label != self._label: + self._label = label + self._updateLabelItem(label) + + def isEditable(self): + """Returns whether the ROI is editable by the user or not. + + :rtype: bool + """ + return self._editable + + def setEditable(self, editable): + """Set whether the ROI can be changed interactively. + + :param bool editable: True to allow edition by the user, + False to disable. + """ + editable = bool(editable) + if self._editable != editable: + self._editable = editable + # Recreate plot items + # This can be avoided once marker.setDraggable is public + self._createPlotItems() + + def _getControlPoints(self): + """Returns the current ROI control points. + + It returns an empty tuple if there is currently no ROI. + + :return: Array of (x, y) position in plot coordinates + :rtype: numpy.ndarray + """ + return None if self._points is None else numpy.array(self._points) + + @classmethod + def showFirstInteractionShape(cls): + """Returns True if the shape created by the first interaction and + managed by the plot have to be visible. + + :rtype: bool + """ + return True + + @classmethod + def getFirstInteractionShape(cls): + """Returns the shape kind which will be used by the very first + interaction with the plot. + + This interactions are hardcoded inside the plot + + :rtype: str + """ + return cls._plotShape + + def setFirstShapePoints(self, points): + """"Initialize the ROI using the points from the first interaction. + + This interaction is constains by the plot API and only supports few + shapes. + """ + points = self._createControlPointsFromFirstShape(points) + self._setControlPoints(points) + + def _createControlPointsFromFirstShape(self, points): + """Returns the list of control points from the very first shape + provided. + + This shape is provided by the plot interaction and constained by the + class of the ROI itself. + """ + return points + + def _setControlPoints(self, points): + """Set this ROI control points. + + :param points: Iterable of (x, y) control points + """ + points = numpy.array(points) + + nbPointsChanged = (self._points is None or + points.shape != self._points.shape) + + if nbPointsChanged or not numpy.all(numpy.equal(points, self._points)): + self._points = points + + self._updateShape() + if self._items and not nbPointsChanged: # Update plot items + item = self._getLabelItem() + if item is not None: + markerPos = self._getLabelPosition() + item.setPosition(*markerPos) + + if self._editAnchors: # Update anchors + for anchor, point in zip(self._editAnchors, points): + old = anchor.blockSignals(True) + anchor.setPosition(*point) + anchor.blockSignals(old) + + else: # No items or new point added + # re-create plot items + self._createPlotItems() + + self.sigRegionChanged.emit() + + def _updateShape(self): + """Called when shape must be updated. + + Must be reimplemented if a shape item have to be updated. + """ + return + + def _getLabelPosition(self): + """Compute position of the label + + :return: (x, y) position of the marker + """ + return None + + def _createPlotItems(self): + """Create items displaying the ROI in the plot. + + It first removes any existing plot items. + """ + roiManager = self.parent() + if roiManager is None: + return + plot = roiManager.parent() + + self._removePlotItems() + + legendPrefix = "__RegionOfInterest-%d__" % id(self) + itemIndex = 0 + + controlPoints = self._getControlPoints() + + if self._labelItem is None: + self._labelItem = self._createLabelItem() + if self._labelItem is not None: + self._labelItem._setLegend(legendPrefix + "label") + plot._add(self._labelItem) + + self._items = WeakList() + plotItems = self._createShapeItems(controlPoints) + for item in plotItems: + item._setLegend(legendPrefix + str(itemIndex)) + plot._add(item) + self._items.append(item) + itemIndex += 1 + + self._editAnchors = WeakList() + if self.isEditable(): + plotItems = self._createAnchorItems(controlPoints) + color = rgba(self.getColor()) + color = self._getAnchorColor(color) + for index, item in enumerate(plotItems): + item._setLegend(legendPrefix + str(itemIndex)) + item.setColor(color) + plot._add(item) + item.sigItemChanged.connect(functools.partial( + self._controlPointAnchorChanged, index)) + self._editAnchors.append(item) + itemIndex += 1 + + def _updateLabelItem(self, label): + """Update the marker displaying the label. + + Inherite this method to custom the way the ROI display the label. + + :param str label: The new label to use + """ + item = self._getLabelItem() + if item is not None: + item.setText(label) + + def _createLabelItem(self): + """Returns a created marker which will be used to dipslay the label of + this ROI. + + Inherite this method to return nothing if no new items have to be + created, or your own marker. + + :rtype: Union[None,Marker] + """ + # Add label marker + markerPos = self._getLabelPosition() + marker = items.Marker() + marker.setPosition(*markerPos) + marker.setText(self.getLabel()) + marker.setColor(rgba(self.getColor())) + marker.setSymbol('') + marker._setDraggable(False) + return marker + + def _getLabelItem(self): + """Returns the marker displaying the label of this ROI. + + Inherite this method to choose your own item. In case this item is also + a control point. + """ + return self._labelItem + + def _createShapeItems(self, points): + """Create shape items from the current control points. + + :rtype: List[PlotItem] + """ + return [] + + def _createAnchorItems(self, points): + """Create anchor items from the current control points. + + :rtype: List[Marker] + """ + return [] + + def _controlPointAnchorChanged(self, index, event): + """Handle update of position of an edition anchor + + :param int index: Index of the anchor + :param ItemChangedType event: Event type + """ + if event == items.ItemChangedType.POSITION: + anchor = self._editAnchors[index] + previous = self._points[index].copy() + current = anchor.getPosition() + self._controlPointAnchorPositionChanged(index, current, previous) + + def _controlPointAnchorPositionChanged(self, index, current, previous): + """Called when an anchor is manually edited. + + This function have to be inherited to change the behaviours of the + control points. This function have to call :meth:`_getControlPoints` to + reach the previous state of the control points. Updated the positions + of the changed control points. Then call :meth:`_setControlPoints` to + update the anchors and send signals. + """ + points = self._getControlPoints() + points[index] = current + self._setControlPoints(points) + + def _removePlotItems(self): + """Remove items from their plot.""" + for item in itertools.chain(list(self._items), + list(self._editAnchors)): + + plot = item.getPlot() + if plot is not None: + plot._remove(item) + self._items = WeakList() + self._editAnchors = WeakList() + + if self._labelItem is not None: + item = self._labelItem + plot = item.getPlot() + if plot is not None: + plot._remove(item) + self._labelItem = None + + def __str__(self): + """Returns parameters of the ROI as a string.""" + points = self._getControlPoints() + params = '; '.join('(%f; %f)' % (pt[0], pt[1]) for pt in points) + return "%s(%s)" % (self.__class__.__name__, params) + + +class PointROI(RegionOfInterest): + """A ROI identifying a point in a 2D plot.""" + + _kind = "Point" + """Label for this kind of ROI""" + + _plotShape = "point" + """Plot shape which is used for the first interaction""" + + def getPosition(self): + """Returns the position of this ROI + + :rtype: numpy.ndarray + """ + return self._points[0].copy() + + def setPosition(self, pos): + """Set the position of this ROI + + :param numpy.ndarray pos: 2d-coordinate of this point + """ + controlPoints = numpy.array([pos]) + self._setControlPoints(controlPoints) + + def _createLabelItem(self): + return None + + def _updateLabelItem(self, label): + if self.isEditable(): + item = self._editAnchors[0] + else: + item = self._items[0] + item.setText(label) + + def _createShapeItems(self, points): + if self.isEditable(): + return [] + marker = items.Marker() + marker.setPosition(points[0][0], points[0][1]) + marker.setText(self.getLabel()) + marker.setColor(rgba(self.getColor())) + marker._setDraggable(False) + return [marker] + + def _createAnchorItems(self, points): + marker = items.Marker() + marker.setPosition(points[0][0], points[0][1]) + marker.setText(self.getLabel()) + marker._setDraggable(self.isEditable()) + return [marker] + + def __str__(self): + points = self._getControlPoints() + params = '%f %f' % (points[0, 0], points[0, 1]) + return "%s(%s)" % (self.__class__.__name__, params) + + +class LineROI(RegionOfInterest): + """A ROI identifying a line in a 2D plot. + + This ROI provides 1 anchor for each boundary of the line, plus an center + in the center to translate the full ROI. + """ + + _kind = "Line" + """Label for this kind of ROI""" + + _plotShape = "line" + """Plot shape which is used for the first interaction""" + + def _createControlPointsFromFirstShape(self, points): + center = numpy.mean(points, axis=0) + controlPoints = numpy.array([points[0], points[1], center]) + return controlPoints + + def setEndPoints(self, startPoint, endPoint): + """Set this line location using the endding points + + :param numpy.ndarray startPoint: Staring bounding point of the line + :param numpy.ndarray endPoint: Endding bounding point of the line + """ + assert(startPoint.shape == (2,) and endPoint.shape == (2,)) + shapePoints = numpy.array([startPoint, endPoint]) + controlPoints = self._createControlPointsFromFirstShape(shapePoints) + self._setControlPoints(controlPoints) + + def getEndPoints(self): + """Returns bounding points of this ROI. + + :rtype: Tuple(numpy.ndarray,numpy.ndarray) + """ + startPoint = self._points[0].copy() + endPoint = self._points[1].copy() + return (startPoint, endPoint) + + def _getLabelPosition(self): + points = self._getControlPoints() + return points[-1] + + def _updateShape(self): + if len(self._items) == 0: + return + shape = self._items[0] + points = self._getControlPoints() + points = self._getShapeFromControlPoints(points) + shape.setPoints(points) + + def _getShapeFromControlPoints(self, points): + # Remove the center from the control points + return points[0:2] + + def _createShapeItems(self, points): + shapePoints = self._getShapeFromControlPoints(points) + item = items.Shape("polylines") + item.setPoints(shapePoints) + item.setColor(rgba(self.getColor())) + item.setFill(False) + item.setOverlay(True) + return [item] + + def _createAnchorItems(self, points): + anchors = [] + for point in points[0:-1]: + anchor = items.Marker() + anchor.setPosition(*point) + anchor.setText('') + anchor.setSymbol('s') + anchor._setDraggable(True) + anchors.append(anchor) + + # Add an anchor to the center of the rectangle + center = numpy.mean(points, axis=0) + anchor = items.Marker() |