summaryrefslogtreecommitdiff
path: root/silx/gui/plot/backends/BackendMatplotlib.py
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui/plot/backends/BackendMatplotlib.py')
-rw-r--r--silx/gui/plot/backends/BackendMatplotlib.py238
1 files changed, 188 insertions, 50 deletions
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