""" matplotlib pcolormesh equivalent in pyqtgraph (more or less) """
import logging
import matplotlib.pyplot as plt
import pyqtgraph as pg
from matplotlib.colors import ListedColormap
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
from matplotlib.figure import Figure
from numpy import arange, array, clip, inf, linspace, ndarray
from pyqtgraph import Qt as qt #import QtCore
from pyqtgraph import PlotDataItem
from pyqtgraph.graphicsItems.ImageItem import ImageItem
from pyqtgraph.widgets import PlotWidget, GraphicsView
from data_slicer.dsviewbox import DSViewBox
from data_slicer.utilities import get_lines, TracedVariable, indexof
logger = logging.getLogger('ds.'+__name__)
BASE_LINECOLOR = (255,255,0,255)
HOVER_COLOR = (195,155,0,255)
[docs]class MPLExportDialog(qt.QtWidgets.QDialog) :
""" A dialog that provides a GUI to
:meth:`~data_slicer.imageplot.ImagePlot.mpl_export`.
"""
figwidth = 5
figheight = 5
def __init__(self, imageplot, *args, **kwargs) :
super().__init__(*args, **kwargs)
self.imageplot = imageplot
# Convert the lookuptable (lut) to a matplotlib colormap
lut = self.imageplot.image_item.lut
lut = lut/lut.max()
cmap_array = array([[a[0], a[1], a[2], 1.] for a in lut])
self.cmap = ListedColormap(cmap_array)
## Dialog window layout
self.setWindowTitle('MPL Export Options')
# Title textbox
self.label_title = qt.QtWidgets.QLabel('Title')
self.box_title = qt.QtWidgets.QLineEdit(self)
# x- and y-axis textboxes
self.label_xlabel = qt.QtWidgets.QLabel('x axis label')
self.box_xlabel = qt.QtWidgets.QLineEdit(self)
self.label_ylabel = qt.QtWidgets.QLabel('y axis label')
self.box_ylabel = qt.QtWidgets.QLineEdit(self)
# Invert and transpose checkboxes
self.checkbox_invertx = qt.QtWidgets.QCheckBox('invert x')
self.checkbox_inverty = qt.QtWidgets.QCheckBox('invert y')
self.checkbox_transpose = qt.QtWidgets.QCheckBox('transpose')
# x limits
self.label_xlim = qt.QtWidgets.QLabel('x limits')
self.box_xmin = qt.QtWidgets.QLineEdit()
self.box_xmin.setValidator(qt.QtGui.QDoubleValidator())
self.box_xmax = qt.QtWidgets.QLineEdit()
self.box_xmax.setValidator(qt.QtGui.QDoubleValidator())
# y limits
self.label_ylim = qt.QtWidgets.QLabel('y limits')
self.box_ymin = qt.QtWidgets.QLineEdit()
self.box_ymin.setValidator(qt.QtGui.QDoubleValidator())
self.box_ymax = qt.QtWidgets.QLineEdit()
self.box_ymax.setValidator(qt.QtGui.QDoubleValidator())
# Figsize
self.label_width = qt.QtWidgets.QLabel('Figure width (inch)')
self.box_width = qt.QtWidgets.QLineEdit()
self.box_width.setValidator(qt.QtGui.QDoubleValidator(0, 99, 2))
self.box_width.setText(str(self.figwidth))
self.label_height = qt.QtWidgets.QLabel('Figure height (inch)')
self.box_height = qt.QtWidgets.QLineEdit()
self.box_height.setValidator(qt.QtGui.QDoubleValidator(0, 99, 2))
self.box_height.setText(str(self.figheight))
# Make changes update the figure
for box in [self.box_title, self.box_xlabel, self.box_ylabel,
self.box_xmin, self.box_xmax, self.box_ymin,
self.box_ymax, self.box_width, self.box_height] :
# box.editingFinished.connect(self.plot_preview)
box.textChanged.connect(self.plot_preview)
for checkbox in [self.checkbox_invertx, self.checkbox_inverty,
self.checkbox_transpose] :
checkbox.stateChanged.connect(self.plot_preview)
# Figsize warning label
self.label_figsize = qt.QtWidgets.QLabel('Preview figure size is not to '
'scale.')
# Preview canvas
self.figure = Figure(figsize=(self.figwidth, self.figheight),
constrained_layout=True)
self.canvas = FigureCanvasQTAgg(self.figure)
self.ax = self.figure.add_subplot(111)
# 'OK' and 'Cancel' buttons
QBtn = qt.QtWidgets.QDialogButtonBox.Ok | qt.QtWidgets.QDialogButtonBox.Cancel
self.button_box = qt.QtWidgets.QDialogButtonBox(QBtn)
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
self.plot_preview()
self.align()
[docs] def align(self) :
""" Create and apply the dialog's layout. """
layout = qt.QtWidgets.QGridLayout()
ncol = 4
i = 1
# Title
layout.addWidget(self.label_title, i, 1, 1, 1)
layout.addWidget(self.box_title, i, 2, 1, 1)
i += 1
# x label
layout.addWidget(self.label_xlabel, i, 1, 1, 1)
layout.addWidget(self.box_xlabel, i, 2, 1, 1)
i += 1
# y label
layout.addWidget(self.label_ylabel, i, 1, 1, 1)
layout.addWidget(self.box_ylabel, i, 2, 1, 1)
i += 1
# Invert and transpose checkboxes
layout.addWidget(self.checkbox_invertx, i, 1, 1, 1)
layout.addWidget(self.checkbox_inverty, i, 2, 1, 1)
layout.addWidget(self.checkbox_transpose, i, 3, 1, 1)
i += 1
# Limits
layout.addWidget(self.label_xlim, i, 1, 1, 1)
xlims = qt.QtWidgets.QHBoxLayout()
xlims.addWidget(self.box_xmin)
xlims.addWidget(self.box_xmax)
layout.addLayout(xlims, i, 2, 1, 1)
layout.addWidget(self.label_ylim, i, 3, 1, 1)
ylims = qt.QtWidgets.QHBoxLayout()
ylims.addWidget(self.box_ymin)
ylims.addWidget(self.box_ymax)
layout.addLayout(ylims, i, 4, 1, 1)
i += 1
# Figsize fields
layout.addWidget(self.label_width, i, 1, 1, 1)
layout.addWidget(self.box_width, i, 2, 1, 1)
layout.addWidget(self.label_height, i, 3, 1, 1)
layout.addWidget(self.box_height, i, 4, 1, 1)
i += 1
# Preview
layout.addWidget(self.label_figsize, i, 1, 1, ncol)
i += 1
layout.addWidget(self.canvas, i, 1, 1, ncol)
i += 1
# OK and Cancel buttons
layout.addWidget(self.button_box, i, 1, 1, ncol)
self.setLayout(layout)
[docs] def plot_preview(self) :
""" Update the plot in the preview window with the currently selected
options.
"""
# Remove previous plot and get up-to-date data
self.ax.clear()
ip = self.imageplot
xaxis, yaxis, data = ip.xscale, ip.yscale, ip.image_data.T
# Fix unassigned x- and yscales
if xaxis is None :
xaxis = range(data.shape[1])
if yaxis is None :
yaxis = range(data.shape[0])
# Transpose
if self.checkbox_transpose.isChecked() :
xaxis, yaxis, data = yaxis, xaxis, data.T
mesh = self.ax.pcolormesh(xaxis, yaxis, data, cmap=self.cmap)
# Limits
limits = [b.text() for b in [self.box_xmin, self.box_xmax,
self.box_ymin, self.box_ymax]]
datalimits = [xaxis[0], xaxis[-1], yaxis[0], yaxis[-1]]
for i,lim in enumerate(limits) :
if lim == '' :
limits[i] = datalimits[i]
else :
limits[i] = float(lim)
self.ax.set_xlim([limits[0], limits[1]])
self.ax.set_ylim([limits[2], limits[3]])
# Apply options
invertx = self.checkbox_invertx.isChecked()
inverty = self.checkbox_inverty.isChecked()
if invertx : self.ax.invert_xaxis()
if inverty : self.ax.invert_yaxis()
# Labels
self.ax.set_title(self.box_title.text())
self.ax.set_xlabel(self.box_xlabel.text())
self.ax.set_ylabel(self.box_ylabel.text())
self.canvas.draw()
[docs]class Crosshair() :
""" Crosshair made up of two InfiniteLines. """
def __init__(self, pos=(0,0)) :
# Store the positions in TracedVariables
self.hpos = TracedVariable(pos[1], name='hpos')
self.vpos = TracedVariable(pos[0], name='vpos')
# Initialize the InfiniteLines
self.hline = pg.InfiniteLine(pos[1], movable=True, angle=0)
self.vline = pg.InfiniteLine(pos[0], movable=True, angle=90)
# Set the color
self.set_color(BASE_LINECOLOR, HOVER_COLOR)
# Register some callbacks
self.hpos.sig_value_changed.connect(self.update_position_h)
self.vpos.sig_value_changed.connect(self.update_position_v)
self.hline.sigDragged.connect(self.on_dragged_h)
self.vline.sigDragged.connect(self.on_dragged_v)
[docs] def add_to(self, widget) :
""" Add this crosshair to a Qt widget. """
for line in [self.hline, self.vline] :
line.setZValue(1)
widget.addItem(line)
[docs] def remove_from(self, widget) :
""" Remove this crosshair from a pyqtgraph widget. """
for line in [self.hline, self.vline] :
widget.removeItem(line)
[docs] def set_color(self, linecolor=BASE_LINECOLOR, hover_color=HOVER_COLOR) :
""" Set the color and hover color of both InfiniteLines that make up
the crosshair. The arguments can be any pyqtgraph compatible color
specifiers.
"""
for line in [self.hline, self.vline] :
line.setPen(linecolor)
line.setHoverPen(hover_color)
[docs] def set_movable(self, movable=True) :
""" Set whether or not this crosshair can be dragged by the mouse. """
for line in [self.hline, self.vline] :
line.setMovable = movable
[docs] def move_to(self, pos) :
"""
**Parameters**
=== ===================================================================
pos 2-tuple; x and y coordinates of the desired location of the
crosshair in data coordinates.
=== ===================================================================
"""
self.hpos.set_value(pos[1])
self.vpos.set_value(pos[0])
[docs] def update_position_h(self) :
""" Callback for the :signal:`sig_value_changed
<data_slicer.utilities.TracedVariable.sig_value_changed>`. Whenever the
value of this TracedVariable is updated (possibly from outside this
Crosshair object), put the crosshair to the appropriate position.
"""
self.hline.setValue(self.hpos.get_value())
[docs] def update_position_v(self) :
""" Confer update_position_h. """
self.vline.setValue(self.vpos.get_value())
[docs] def on_dragged_h(self) :
""" Callback for dragging of InfiniteLines. Their visual position
should be reflected in the TracedVariables self.hpos and self.vpos.
"""
self.hpos.set_value(self.hline.value())
[docs] def on_dragged_v(self) :
""" Callback for dragging of InfiniteLines. Their visual position
should be reflected in the TracedVariables self.hpos and self.vpos.
"""
self.vpos.set_value(self.vline.value())
[docs] def set_bounds(self, xmin, xmax, ymin, ymax) :
""" Set the area in which the infinitelines can be dragged. """
self.hline.setBounds([ymin, ymax])
self.vline.setBounds([xmin, xmax])
[docs]class ImagePlot(pg.PlotWidget) :
"""
A PlotWidget which mostly contains a single 2D image (intensity
distribution) or a 3D array (distribution of RGB values) as well as all
the nice pyqtgraph axes panning/rescaling/zooming functionality.
In addition, this allows one to use custom axes scales as opposed to
being limited to pixel coordinates.
**Signals**
================= =========================================================
sig_image_changed emitted whenever the image is updated
sig_axes_changed emitted when the axes are updated
sig_clicked emitted when user clicks inside the imageplot
================= =========================================================
"""
sig_image_changed = qt.QtCore.Signal()
sig_axes_changed = qt.QtCore.Signal()
sig_clicked = qt.QtCore.Signal(object)
def __init__(self, image=None, parent=None, background='default',
name=None, **kwargs) :
""" Allows setting of the image upon initialization.
**Parameters**
========== ============================================================
image np.ndarray or pyqtgraph.ImageItem instance; the image to be
displayed.
parent QtWidget instance; parent widget of this widget.
background str; confer PyQt documentation
name str; allows giving a name for debug purposes
========== ============================================================
"""
# Initialize instance variables
# np.array, raw image data
self.image_data = None
# pg.ImageItem of *image_data*
self.image_item = None
self.image_kwargs = {}
self.xlim = None
self.ylim = None
self.xscale = None
self.yscale = None
self.transform_factors = []
self.transposed = TracedVariable(False, name='transposed')
self.crosshair_cursor_visible = False
super().__init__(parent=parent, background=background,
viewBox=DSViewBox(imageplot=self), **kwargs)
self.name = name
# Show top and tight axes by default, but without ticklabels
self.showAxis('top')
self.showAxis('right')
self.getAxis('top').setStyle(showValues=False)
self.getAxis('right').setStyle(showValues=False)
if image is not None :
self.set_image(image)
self.sig_axes_changed.connect(self.fix_viewrange)
[docs] def show_cursor(self, show=True) :
"""
Toggle whether or not to show a crosshair cursor that tracks the
mouse movement.
"""
if show :
crosshair_cursor = Crosshair()
crosshair_cursor.set_movable(False)
crosshair_cursor.set_color((255, 255, 255, 255),
(255, 255, 255, 255))
crosshair_cursor.add_to(self)
self.scene().sigMouseMoved.connect(self.on_mouse_move)
self.crosshair_cursor = crosshair_cursor
else :
try :
self.scene().sigMouseMoved.disconnect(self.on_mouse_move)
except TypeError :
pass
try :
self.crosshair_cursor.remove_from(self)
except AttributeError :
pass
self.crosshair_cursor_visible = show
self.plotItem.vb.menu.toggle_cursor.setChecked(show)
[docs] def toggle_cursor(self) :
""" Change the visibility of the crosshair cursor. """
self.show_cursor(not self.crosshair_cursor_visible)
[docs] def on_mouse_move(self, pos) :
""" Slot for mouse movement over the plot. Calculate the mouse
position in data coordinates and move the crosshair_cursor there.
**Parameters**
=== ===================================================================
pos QPointF object; x and y position of the mouse as returned by
:signal:`sigMouseMoved
<data_slicer.imageplot.ImagePlot.sigMouseMoved>`.
=== ===================================================================
"""
if self.plotItem.sceneBoundingRect().contains(pos) :
data_point = self.plotItem.vb.mapSceneToView(pos)
self.crosshair_cursor.move_to((data_point.x(), data_point.y()))
[docs] def mousePressEvent(self, event) :
""" Figure out where the click happened in data coordinates and make
the position available through the signal :signal:`sig_clicked
<data_slicer.imageplot.ImagePlot.sig_clicked>`.
"""
if event.button() == qt.QtCore.Qt.LeftButton :
vb = self.plotItem.vb
last_click = vb.mapToView(vb.mapFromScene(event.localPos()))
message = 'Last click at ( {:.4f} | {:.4f} )'
self.sig_clicked.emit(message.format(last_click.x(), last_click.y()))
super().mousePressEvent(event)
[docs] def remove_image(self) :
""" Removes the current image using the parent's :meth:`removeItem
pyqtgraph.PlotWidget.removeItem` function.
"""
if self.image_item is not None :
self.removeItem(self.image_item)
self.image_item = None
[docs] def set_image(self, image, emit=True, *args, **kwargs) :
""" Expects either np.arrays or pg.ImageItems as input and sets them
correctly to this PlotWidget's Image with `addItem`. Also makes sure
there is only one Image by deleting the previous image.
Emits :signal:`sig_image_changed`
**Parameters**
======== ==============================================================
image np.ndarray or pyqtgraph.ImageItem instance; the image to be
displayed.
emit bool; whether or not to emit :signal:`sig_image_changed`
(kw)args positional and keyword arguments that are passed on to
:class:`pyqtgraph.ImageItem`
======== ==============================================================
"""
# Convert array to ImageItem
if isinstance(image, ndarray) :
if 0 not in image.shape :
image_item = ImageItem(image, *args, **kwargs)
else :
logger.debug(('<{}>.set_image(): image.shape is {}. Not '
'setting image.').format(self.name, image.shape))
return
else :
image_item = image
# Throw an exception if image is not an ImageItem
if not isinstance(image_item, ImageItem) :
message = '''`image` should be a np.array or pg.ImageItem instance,
not {}'''.format(type(image))
raise TypeError(message)
# Transpose if necessary
if self.transposed.get_value() :
image_item = ImageItem(image_item.image.T, *args, **kwargs)
# Replace the image
self.remove_image()
self.image_item = image_item
self.image_data = image_item.image
logger.debug('<{}>Setting image.'.format(self.name))
self.addItem(image_item)
# Reset limits if necessary
if self.xscale is not None and self.yscale is not None :
axes_shape = (len(self.xscale), len(self.yscale))
if axes_shape != self.image_data.shape :
self.xlim = None
self.ylim = None
self._set_axes_scales(emit=emit)
if emit :
logger.info('<{}>Emitting sig_image_changed.'.format(self.name))
self.sig_image_changed.emit()
[docs] def set_xscale(self, xscale, update=False) :
""" Set the xscale of the plot. *xscale* is an array of the length
``len(self.image_item.shape[0])``.
"""
if self.transposed.get_value() :
self._set_yscale(xscale, update)
else :
self._set_xscale(xscale, update)
[docs] def set_yscale(self, yscale, update=False) :
""" Set the yscale of the plot. *yscale* is an array of the length
``len(self.image_item.image.shape[1])``.
"""
if self.transposed.get_value() :
self._set_xscale(yscale, update)
else :
self._set_yscale(yscale, update)
def _set_xscale(self, xscale, update=False, force=False) :
""" Set the scale of the horizontal axis of the plot. *force* can be
used to bypass the length checking.
"""
# Sanity check
if not force and self.image_item is not None and \
len(xscale) != self.image_item.image.shape[0] :
raise TypeError('Shape of xscale does not match data dimensions.')
self.xscale = xscale
# 'Autoscale' the image to the xscale
self.xlim = (xscale[0], xscale[-1])
if update :
self._set_axes_scales(emit=True)
def _set_yscale(self, yscale, update=False, force=False) :
""" Set the scale of the vertical axis of the plot. *force* can be
used to bypass the length checking.
"""
# Sanity check
if not force and self.image_item is not None and \
len(yscale) != self.image_item.image.shape[1] :
raise TypeError('Shape of yscale does not match data dimensions.')
self.yscale = yscale
# 'Autoscale' the image to the xscale
self.ylim = (yscale[0], yscale[-1])
if update :
self._set_axes_scales(emit=True)
[docs] def transpose(self) :
""" Transpose the image, i.e. swap the x- and y-axes. """
self.transposed.set_value(not self.transposed.get_value())
# Swap the scales
new_xscale = self.yscale
new_yscale = self.xscale
self._set_xscale(new_xscale, force=True)
self._set_yscale(new_yscale, force=True)
# Update the image
if not self.transposed.get_value() :
# Take care of the back-transposition here
self.set_image(self.image_item.image.T, lut=self.image_item.lut)
else :
self.set_image(self.image_item, lut=self.image_item.lut)
[docs] def set_xlabel(self, label) :
""" Shorthand for setting this plot's x axis label. """
axis = self.getAxis('bottom')
axis.setLabel(label)
[docs] def set_ylabel(self, label) :
""" Shorthand for setting this plot's y axis label. """
axis = self.getAxis('left')
axis.setLabel(label)
def _set_axes_scales(self, emit=False) :
""" Transform the image such that it matches the desired x and y
scales.
"""
# Get image dimensions and requested origin (x0,y0) and top right
# corner (x1, y1)
nx, ny = self.image_item.image.shape
logger.debug(('<{}>_set_axes_scales(): self.image_item.image.shape={}' +
' x {}').format(self.name, nx, ny))
[[x0, x1], [y0, y1]] = self.get_limits()
# Calculate the scaling factors
sx = (x1-x0)/nx
sy = (y1-y0)/ny
# Ensure nonzero
sx = 1 if sx==0 else sx
sy = 1 if sy==0 else sy
# Define a transformation matrix that scales and translates the image
# such that it appears at the coordinates that match our x and y axes.
transform = qt.QtGui.QTransform()
transform.scale(sx, sy)
# Carry out the translation in scaled coordinates
transform.translate(x0/sx, y0/sy)
# Finally, apply the transformation to the imageItem
self.image_item.setTransform(transform)
self._update_transform_factors()
if emit :
logger.info('<{}>Emitting sig_axes_changed.'.format(self.name))
self.sig_axes_changed.emit()
[docs] def get_limits(self) :
""" Return ``[[x_min, x_max], [y_min, y_max]]``. """
# Default to current viewrange but try to get more accurate values if
# possible
if self.image_item is not None :
x, y = self.image_item.image.shape
else :
x, y = 1, 1
# Set the limits to image pixels if they are not defined
if self.xlim is None :
self.set_xscale(arange(0, x))
x_min, x_max = self.xlim
if self.ylim is None :
self.set_yscale(arange(0, y))
y_min, y_max = self.ylim
logger.debug(('<{}>get_limits(): [[x_min, x_max], [y_min, y_max]] = '
+ '[[{}, {}], [{}, {}]]').format(self.name, x_min, x_max,
y_min, y_max))
return [[x_min, x_max], [y_min, y_max]]
[docs] def fix_viewrange(self) :
""" Prevent zooming out by fixing the limits of the ViewBox. """
logger.debug('<{}>fix_viewrange().'.format(self.name))
[[x_min, x_max], [y_min, y_max]] = self.get_limits()
self.setLimits(xMin=x_min, xMax=x_max, yMin=y_min, yMax=y_max,
maxXRange=x_max-x_min, maxYRange=y_max-y_min)
[docs] def release_viewrange(self) :
""" Undo the effects of :meth:`fix_viewrange
<data_slicer.imageplot.ImagePlot.fix_viewrange>`
"""
logger.debug('<{}>release_viewrange().'.format(self.name))
self.setLimits(xMin=-inf,
xMax=inf,
yMin=-inf,
yMax=inf,
maxXRange=inf,
maxYRange=inf)
def _update_transform_factors(self) :
""" Create a copy of the parameters that are necessary to reproduce
the current transform. This is necessary e.g. for the calculation of
the transform in :meth:`rotate
<data_slicer.imageplot.ImagePlot.rotate>`.
"""
transform = self.image_item.transform()
dx = transform.dx()
dy = transform.dy()
sx = transform.m11()
sy = transform.m22()
wx = self.image_item.width()
wy = self.image_item.height()
self.transform_factors = [dx, dy, sx, sy, wx, wy]
[docs] def rotate(self, alpha=0) :
""" Rotate the image_item by the given angle *alpha* (in degrees). """
# Get the details of the current transformation
if self.transform_factors == [] :
self._update_transform_factors()
dx, dy, sx, sy, wx, wy = self.transform_factors
# Build the transformation anew, adding a rotation
# Remember that the order in which transformations are applied is
# reverted to how they are added in the code, i.e. last transform
# added in the code will come first (this is the reason we have to
# completely rebuild the transformation instead of just adding a
# rotation...)
transform = self.image_item.transform()
transform.reset()
transform.translate(dx, dy)
transform.translate(wx/2*sx, wy/2*sy)
transform.rotate(alpha)
transform.scale(sx, sy)
transform.translate(-wx/2, -wy/2)
self.release_viewrange()
self.image_item.setTransform(transform)
[docs] def mpl_export(self, *args, figsize=(5,5), title='', xlabel='',
ylabel='', dpi=300) :
""" Export the content of this plot to a png image using matplotlib.
The resulting image will have a white background and black ticklabes
and should therefore be more readable than pyqtgraph's native plot
export options.
**Parameters**
======= ===============================================================
figsize tuple of float; (height, width) of figure in inches
title str; figure title
xlabel str; x axis label
ylabel str; y axis label
dpi int; png resolution in pixels per inch
args positional arguments are absorbed and discarded (necessary
to connect this method to signal handling)
======= ===============================================================
"""
logger.debug('<ImagePlot.mpl_export()>')
# Show the dialog with some options
dialog = MPLExportDialog(self, parent=self)
# ok_button = qt.QtWidgets.QPushButton('Done', dialog)
if not dialog.exec_() : return
# Replot to update the figure
dialog.plot_preview()
# Get a filename first
fd = qt.QtWidgets.QFileDialog()
filename = fd.getSaveFileName()[0]
if not filename : return
logger.debug('Outfilename: {}'.format(filename))
# Update figure size before saving
width, height = [float(box.text()) for box in [dialog.box_width,
dialog.box_height]]
dialog.figure.set_figwidth(width)
dialog.figure.set_figheight(height)
dialog.figure.savefig(filename, dpi=dpi)
[docs] def lineplot(self, ax, dim=0, n=10, offset=0.2, lw=0.5, color='k',
label_fmt='{:.2f}', n_ticks=5, **getlines_kwargs) :
"""
Create a matplotlib plot with *n* lines extracted out of one of the
visible plots. The lines are normalized to their global maximum and
shifted from each other by *offset*.
See :func:`get_lines <data_slicer.utilities.get_lines>` for more
options on the extraction of the lines.
**Parameters**
=============== =======================================================
plot str; either "main" or "cut", specifies from which
plot to extract the lines.
dim int; either 0 or 1, specifies in which direction to
take the lines.
ax matplotlib.axes.Axes; the axes in which to plot.
n int; number of lines to extract.
offset float; spacing between neighboring lines.
lw float; linewidth of the plotted lines.
color any color argument understood by matplotlib; color
of the plotted lines.
label_fmt str; a format string for the ticklabels.
n_ticks int; number of ticks to print.
getlines_kwargs other kwargs are passed to :func:`get_lines
<data_slicer.utilities.get_lines>`
=============== =======================================================
**Returns**
=========== ===========================================================
lines2ds list of Line2D objects; the drawn lines.
xticks list of float; locations of the 0 intensity value of
each line.
xtickvalues list of float; if *momenta* were supplied, corresponding
xtick values in units of *momenta*. Otherwise this is
just a copy of *xticks*.
xticklabels list of str; *xtickvalues* formatted according to
*label_fmt*.
=========== ===========================================================
"""
data = self.image_data
# Get the right axes
axes = [self.xscale, self.yscale]
for i,scale in enumerate(axes) :
# Deal with *None*
if scale is None :
axes[i] = np.arange(data.shape[i])
if dim==0 :
data = data.T
elif dim==1 :
axes = axes[::-1]
else :
raise ValueError('*dim* should be one of (0, 1).')
# Get the lines with an utility function
lines, indices = get_lines(data, n, offset=offset, **getlines_kwargs)
# Plot the lines
line2ds = []
for line in lines :
line2d = ax.plot(line, axes[0], lw=lw, color=color)[0]
line2ds.append(line2d)
# Create tick positions and labels
xticks = [i*offset for i in range(n)]
xtickvalues = axes[i][indices]
xticklabels = [label_fmt.format(x) for x in xtickvalues]
# Only render *n_ticks* ticks
nth = int(n/n_ticks)
ax.set_xticks(xticks[::nth])
ax.set_xticklabels(xticklabels[::nth])
return line2ds, xticks, xtickvalues, xticklabels
[docs]class CrosshairImagePlot(ImagePlot) :
""" An imageplot with a draggable crosshair. """
def __init__(self, *args, **kwargs) :
super().__init__(*args, **kwargs)
# Hide the pyqtgraph auto-rescale button
self.getPlotItem().buttonsHidden = True
# Initiliaze a crosshair and add it to this widget
self.crosshair = Crosshair()
self.crosshair.add_to(self)
self.pos = (self.crosshair.vpos, self.crosshair.hpos)
# Initialize range to [0, 1]x[0, 1]
self.set_bounds(0, 1, 0, 1)
# Disable mouse scrolling, panning and zooming for both axes
# self.setMouseEnabled(False, False)
# Connect a slot (callback) to dragging and clicking events
self.sig_axes_changed.connect(
lambda : self.set_bounds(*[x for lst in self.get_limits() for x
in lst]))
self.sig_image_changed.connect(self.update_allowed_values)
[docs] def update_allowed_values(self) :
""" Update the allowed values silently.
This assumes that the displayed image is in pixel coordinates and
sets the allowed values to the available pixels.
"""
logger.debug('{}.update_allowed_values()'.format(self.name))
[[xmin, xmax], [ymin, ymax]] = self.get_limits()
self.pos[0].set_allowed_values(arange(xmin, xmax+1, 1))
self.pos[1].set_allowed_values(arange(ymin, ymax+1, 1))
[docs] def set_bounds(self, xmin, xmax, ymin, ymax) :
""" Set both, the displayed area of the axis as well as the the range
in which the crosshair can be dragged to the intervals [xmin, xmax]
and [ymin, ymax].
"""
logger.debug('{}.set_bounds()'.format(self.name))
self.setXRange(xmin, xmax, padding=0.01)
self.setYRange(ymin, ymax, padding=0.01)
self.crosshair.set_bounds(xmin, xmax, ymin, ymax)
# Put the crosshair in the center
self.pos[0].set_value(0.5*(xmax+xmin))
self.pos[1].set_value(0.5*(ymax+ymin))
[docs]class CursorPlot(pg.PlotWidget) :
""" Implements a simple, draggable scalebar represented by a line
(:class:`pyqtgraph.InfiniteLine) on an axis
(:class:`pyqtgraph.PlotWidget).
The current position of the slider is tracked with the
:class:`TracedVariable <data_slicer.utilities.TracedVariable>` self.pos
and its width with the `TracedVariable` self.slider_width.
"""
hover_color = HOVER_COLOR
def __init__(self, parent=None, background='default', name=None,
orientation='vertical', slider_width=1, **kwargs) :
""" Initialize the slider and set up the visual tweaks to make a
PlotWidget look more like a scalebar.
**Parameters**
=========== ============================================================
parent QtWidget instance; parent widget of this widget
background str; confer PyQt documentation
name str; allows giving a name for debug purposes
orientation str, `horizontal` or `vertical`; orientation of the cursor
=========== ============================================================
"""
super().__init__(parent=parent, background=background, **kwargs)
# Whether to allow changing the slider width with arrow keys
self.change_width_enabled = False
if orientation not in ['horizontal', 'vertical'] :
raise ValueError('Only `horizontal` or `vertical` are allowed for '
'orientation.')
self.orientation = orientation
self.orientate()
if name is not None :
self.name = name
else :
self.name = 'Unnamed'
# Hide the pyqtgraph auto-rescale button
self.getPlotItem().buttonsHidden = True
# Display the right (or top) axis without ticklabels
self.showAxis(self.right_axis)
self.getAxis(self.right_axis).setStyle(showValues=False)
# The position of the slider is stored with a TracedVariable
initial_pos = 0
pos = TracedVariable(initial_pos, name='pos')
self.register_traced_variable(pos)
# Set up the slider
self.slider_width = TracedVariable(slider_width,
name='{}.slider_width'.format(
self.name))
self.slider = pg.InfiniteLine(initial_pos, movable=True, angle=self.angle)
self.set_slider_pen(color=(255,255,0,255), width=slider_width)
# Add a marker. Args are (style, position (from 0-1), size #NOTE
# seems broken
#self.slider.addMarker('o', 0.5, 10)
self.addItem(self.slider)
# Disable mouse scrolling, panning and zooming for both axes
self.setMouseEnabled(False, False)
# Initialize range to [0, 1]
self.set_bounds(initial_pos, initial_pos + 1)
# Connect a slot (callback) to dragging and clicking events
self.slider.sigDragged.connect(self.on_position_change)
# sigMouseReleased seems to not work (maybe because sigDragged is used)
#self.sigMouseReleased.connect(self.onClick)
# The inherited mouseReleaseEvent is probably used for sigDragged
# already. Anyhow, overwriting it here leads to inconsistent behaviour.
#self.mouseReleaseEvent = self.onClick
[docs] def get_data(self) :
""" Get the currently displayed data as a tuple of arrays, one
containing the x values and the other the y values.
**Returns**
= =====================================================================
x array containing the x values.
y array containing the y values.
= =====================================================================
"""
pdi = self.listDataItems()[0]
return pdi.getData()
[docs] def orientate(self) :
""" Define all aspects that are dependent on the orientation. """
if self.orientation == 'vertical' :
self.right_axis = 'right'
self.secondary_axis = 'top'
self.secondary_axis_grid = (1,1)
self.angle = 90
self.slider_axis_index = 0
else :
self.right_axis = 'top'
self.secondary_axis = 'right'
self.secondary_axis_grid = (2,2)
self.angle = 0
self.slider_axis_index = 1
[docs] def register_traced_variable(self, traced_variable) :
""" Set self.pos to the given TracedVariable instance and connect the
relevant slots to the signals. This can be used to share a
TracedVariable among widgets.
"""
self.pos = traced_variable
self.pos.sig_value_changed.connect(self.set_position)
self.pos.sig_allowed_values_changed.connect(self.on_allowed_values_change)
[docs] def on_position_change(self) :
""" Callback for the :signal:`sigDragged
<pyqtgraph.InfiniteLine.sigDragged>`. Set the value of the
TracedVariable instance self.pos to the current slider position.
"""
current_pos = self.slider.value()
# NOTE pos.set_value emits signal sig_value_changed which may lead to
# duplicate processing of the position change.
self.pos.set_value(current_pos)
[docs] def on_allowed_values_change(self) :
""" Callback for the :signal:`sig_allowed_values_changed
<data_slicer.utilities.TracedVariable.sig_allowed_values_changed>`.
With a change of the allowed values in the TracedVariable, we should
update our bounds accordingly.
The number of allowed values can also give us a hint for a reasonable
maximal width for the slider.
"""
# If the allowed values were reset, just exit
if self.pos.allowed_values is None : return
lower = self.pos.min_allowed
upper = self.pos.max_allowed
self.set_bounds(lower, upper)
# Define a max width of the slider and the resulting set of allowed
# widths
max_width = int(len(self.pos.allowed_values)/2)
allowed_widths = [2*i + 1 for i in range(max_width+1)]
self.slider_width.set_allowed_values(allowed_widths)
[docs] def set_position(self) :
""" Callback for the :signal:`sig_value_changed
<data_slicer.utilities.TracedVariable.sig_value_changed>`. Whenever the
value of this TracedVariable is updated (possibly from outside this
Scalebar object), put the slider to the appropriate position.
"""
new_pos = self.pos.get_value()
self.slider.setValue(new_pos)
[docs] def set_bounds(self, lower, upper) :
""" Set both, the displayed area of the axis as well as the the range
in which the slider (InfiniteLine) can be dragged to the interval
[lower, upper].
"""
if self.orientation == 'vertical' :
self.setXRange(lower, upper, padding=0.01)
else :
self.setYRange(lower, upper, padding=0.01)
self.slider.setBounds([lower, upper])
# When the bounds update, the mousewheelspeed should change accordingly
# TODO This should be in a slot to self.pos.sig_value_changed now
self.wheel_frames = 1
# Ensure wheel_frames is at least as big as a step in the allowed
# values. NOTE This assumes allowed_values to be evenly spaced.
av = self.pos.allowed_values
if av is not None and self.wheel_frames <= 1 and len(av) > 1 :
self.wheel_frames = av[1] - av[0]
[docs] def set_secondary_axis(self, min_val=None, max_val=None) :
""" Create (or replace) a second x-axis on the top which ranges from
`min_val` to `max_val`.
This is the right axis in case of the horizontal orientation.
"""
# Get a handle on the underlying plotItem
plotItem = self.plotItem
# Remove the old top-axis
plotItem.layout.removeItem(plotItem.getAxis(self.secondary_axis))
# Create the new axis and set its range
new_axis = pg.AxisItem(orientation=self.secondary_axis)
new_axis.setRange(min_val, max_val)
# Attach it internally to the plotItem and its layout (The arguments
# `*(1, 1)` or `*(2, 2)` refer to the axis' position in the GridLayout)
plotItem.axes[self.secondary_axis]['item'] = new_axis
plotItem.layout.addItem(new_axis, *self.secondary_axis_grid)
[docs] def set_slider_pen(self, color=None, width=None, hover_color=None) :
""" Define the color and thickness of the slider (InfiniteLine
object :class:`pyqtgraph.InfiniteLine`) and store these attribute
in `self.slider_width` and `self.cursor_color`).
"""
# Default to the current values if none are given
if color is None :
color = self.cursor_color
else :
self.cursor_color = color
if width is None :
# width = self.slider_width.get_value()
width = self.pen_width
else :
self.pen_width = width
if hover_color is None :
hover_color = self.hover_color
else :
self.hover_color = hover_color
self.slider.setPen(color=color, width=width)
# Keep the hoverPen-size consistent
self.slider.setHoverPen(color=hover_color, width=width)
[docs] def increase_width(self, step=1) :
""" Increase (or decrease) `self.slider_width` by `step` units of odd
numbers (such that the line always has a well defined center at the
value it is positioned at).
"""
old_width = self.slider_width.get_value()
new_width = old_width + 2*step
if new_width < 0 :
new_width = 1
self.slider_width.set_value(new_width)
# Convert width in steps to width in pixels
dmin, dmax = self.viewRange()[self.slider_axis_index]
pmax = self.rect().getRect()[self.slider_axis_index+2]
pixel_per_step = pmax/(dmax-dmin)
pen_width = new_width * pixel_per_step
self.set_slider_pen(width=pen_width)
[docs] def increase_pos(self, step=1) :
""" Increase (or decrease) `self.pos` by a reasonable amount.
I.e. move `step` steps along the list of allowed values.
"""
allowed_values = self.pos.allowed_values
old_index = indexof(self.pos.get_value(), allowed_values)
new_index = int((old_index + step)%len(allowed_values))
new_value = allowed_values[int(new_index)]
self.pos.set_value(new_value)
[docs] def keyPressEvent(self, event) :
""" Define responses to keyboard interactions. """
key = event.key()
logger.debug('{}.keyPressEvent(): key={}'.format(self.name, key))
if key == qt.QtCore.Qt.Key_Right :
self.increase_pos(1)
elif key == qt.QtCore.Qt.Key_Left :
self.increase_pos(-1)
elif self.change_width_enabled and key == qt.QtCore.Qt.Key_Up :
self.increase_width(1)
elif self.change_width_enabled and key == qt.QtCore.Qt.Key_Down :
self.increase_width(-1)
else :
event.ignore()
return
# If any if-statement matched, we accept the event
event.accept()
[docs] def wheelEvent(self, event) :
""" Override of the Qt wheelEvent method. Fired on mousewheel
scrolling inside the widget.
"""
# Get the relevant coordinate of the mouseWheel scroll
delta = event.angleDelta().y()
logger.debug('<{}>wheelEvent(); delta = {}'.format(self.name, delta))
if delta > 0 :
sign = 1
elif delta < 0 :
sign = -1
else :
# It seems that in some cases delta==0
sign = 0
increment = sign*self.wheel_frames
logger.debug('<{}>wheelEvent(); increment = {}'.format(self.name,
increment))
self.increase_pos(increment)
[docs]class Scalebar(CursorPlot) :
""" Simple subclass of :class:`CursorPlot
<data_slicer.imageplot.CursorPlot>` that is intended to simulate a
scalebar. This is achieved by providing simply a long, flat plot without
any data and no y axis, but the same draggable slider as in CursorPlot.
**Attributes**
========= =================================================================
textItems list of (t, (rx, ry)) tuples; t is a
:class:`pyqtgraph.TextItem` instance and rx, ry are floats in
the range [0, 1] indicating the relative positioning of the
textitems inside the Scalebar.
========= =================================================================
"""
def __init__(self, *args, **kwargs) :
super().__init__(*args, **kwargs)
# Disable the context menu
self.plotItem.vb.setMenuEnabled(False)
self.disableAutoRange()
# Aesthetics and other widget configs
for axis in ['top', 'right', 'left', 'bottom'] :
self.showAxis(axis)
ax = self.getAxis(axis)
#ax.setTicks([[], []])
ax.setStyle(showValues=False, tickLength=0)
self.set_size(300, 50)
self.pos.set_allowed_values(linspace(0, 1, 100))
# Connect signal of changed allowed values to update TextItem positions
self.pos.sig_allowed_values_changed.connect(
self.on_allowed_values_changed)
# Slider appearance
slider_width = 20
self.slider.setPen(color=(100, 100, 100), width=slider_width)
self.slider.setHoverPen(color=(120, 120, 120), width=slider_width)
# Initialize other attributes
self.textItems = []
[docs] def set_size(self, width, height) :
""" Set this widgets size by setting minimum and maximum sizes
simultaneously to the same value.
"""
self.setMinimumSize(width, height)
self.setMaximumSize(width, height)
[docs] def keyPressEvent(self, event) :
""" Override some behaviour of the superclass. """
key = event.key()
if key in [qt.QtCore.Qt.Key_Up, qt.QtCore.Qt.Key_Down] :
event.ignore()
else :
super().keyPressEvent(event)
[docs] def add_text(self, text, relpos=(0.5, 0.5), anchor=(0.5, 0.5)) :
"""
Add text to the scalebar.
**Parameters**
====== ================================================================
text string; the text to be displayed.
pos tuple; (x, y) position of the text relative to the scalebar.
anchor tuple; (x, y) position of the text object's anchor.
====== ================================================================
"""
t = pg.TextItem(text, anchor=anchor)
self.set_relative_position(t, relpos)
self.addItem(t)
self.textItems.append((t, relpos))
[docs] def set_relative_position(self, textItem, relpos) :
"""
Figure out this Scalebar's current size (in data units) and
reposition its textItems accordingly.
"""
height = 1
width = len(self.pos.allowed_values)
x = width * relpos[0]
y = height * relpos[1]
logger.debug(('set_relative_position [{}] - x={:.2f}, '
'y={:.2f}').format(self.name, x, y))
textItem.setPos(x, y)
[docs] def on_allowed_values_changed(self) :
""" Keep TextItems in correct relative position. """
for t, relpos in self.textItems :
self.set_relative_position(t, relpos)