Source code for data_slicer.widgets
"""
This file contains classes that represent pyqtgraph widgets which can be used
and combined in Qt applications.
"""
import logging
import pyqtgraph as pg
import pyqtgraph.opengl as gl
import numpy as np
from pyqtgraph.Qt import QtCore, QtGui, QtWidgets
from data_slicer.cmaps import load_cmap, ds_cmap
from data_slicer.cutline import Cutline
from data_slicer.imageplot import ImagePlot, Scalebar
from data_slicer.utilities import make_slice, TracedVariable
logger = logging.getLogger('ds.'+__name__)
#_Parameters____________________________________________________________________
DEFAULT_CMAP = 'Greys'
# Default translation for objects
T = -0.5
#_Classes_______________________________________________________________________
[docs]class ColorSliders(QtWidgets.QWidget) :
""" A simple widget providing a *gamma* and *vmax* slider.
**Signals**
============= =============================================================
gamma_changed emitted when the value of *gamma* changes
vmax_changed emitted when the value of *vmax* changes
============= =============================================================
"""
sig_gamma_changed = QtCore.Signal()
sig_vmax_changed = QtCore.Signal()
def __init__(self, *args, **kwargs) :
super().__init__(*args, **kwargs)
self.gamma = 1
self.vmax = 1
# gamma
gamma_slider = Scalebar()
self.gamma_values = np.concatenate((np.linspace(0.1, 1, 50),
np.linspace(1.1, 10, 50)))
gamma_slider.pos.set_value(0.5)
gamma_slider.pos.sig_value_changed.connect(self.on_gamma_slider_move)
# put a label
gamma_label = pg.TextItem('γ', anchor=(0.5, 0.5))
gamma_label.setPos(0.5, 0.5)
gamma_slider.addItem(gamma_label)
self.gamma_slider = gamma_slider
# vmax (relative colorscale maximum)
vmax_slider = Scalebar()
vmax_slider.pos.set_value(self.vmax)
vmax_slider.pos.sig_value_changed.connect(self.on_vmax_slider_move)
# put a label
vmax_label = pg.TextItem('Colorscale', anchor=(0.5, 0.5))
vmax_label.setPos(0.5, 0.5)
vmax_slider.addItem(vmax_label)
self.vmax_slider = vmax_slider
self.align()
[docs] def align(self) :
layout = QtWidgets.QGridLayout()
self.setLayout(layout)
layout.addWidget(self.gamma_slider, 1, 1)
layout.addWidget(self.vmax_slider, 2, 1)
[docs] def on_gamma_slider_move(self) :
""" When the user moves the gamma slider, update gamma. """
ind = min(int(100*self.gamma_slider.pos.get_value()),
len(self.gamma_values)-1)
self.gamma = self.gamma_values[ind]
self.sig_gamma_changed.emit()
[docs] def on_vmax_slider_move(self) :
""" When the user moves the vmax slider, update vmax. """
vmax = int(np.round(100*self.vmax_slider.pos.get_value()))/100
self.vmax = vmax
self.sig_vmax_changed.emit()
[docs]class ThreeDWidget(QtWidgets.QWidget) :
"""
A widget that contains a :class:`GLViewWidget
<pyqtgraph.opengl.GLViewWidget>` that allows displaying 2D colormeshes in
a three dimensional scene.
This class mostly functions as a base class for more refined variations.
"""
def __init__(self, *args, data=None, **kwargs) :
"""
Set up the :class: `GLViewWidget <pyqtgraph.opengl.GLViewWidget>`
as this widget's central widget.
"""
super().__init__(*args, **kwargs)
# Initialize instance variables
self.data = TracedVariable(None, name='data')
self.cmap = load_cmap(DEFAULT_CMAP)
self.lut = self.cmap.getLookupTable()
self.gloptions = 'translucent'
# Create a GLViewWidget and put it into the layout of this view
self.layout = QtWidgets.QGridLayout()
self.setLayout(self.layout)
self.glview = gl.GLViewWidget()
# Add the scalebar
self._initialize_sub_widgets()
# Put all widgets in place
self.align()
# Add some coordinate axes to the view
self.coordinate_axes = gl.GLAxisItem()
self.glview.addItem(self.coordinate_axes)
self.set_coordinate_axes(True)
# Try to set the default data
if data is not None :
self.set_data(data)
def _initialize_sub_widgets(self) :
""" Create the slider subwidget. This method exists so it can be
overridden by subclasses.
"""
self.slider_xy = Scalebar(name='xy-slider')
self.slider_xy.pos.sig_value_changed.connect(self.update_xy)
self.slider_xy.add_text('xy plane', relpos=(0.5, 0.5))
[docs] def align(self) :
""" Put all sub-widgets and elements into the layout. """
l = self.layout
l.addWidget(self.glview, 0, 0, 5, 5)
l.addWidget(self.slider_xy, 4, 2, 1, 1)
[docs] def set_coordinate_axes(self, on=True) :
"""
Turn the visibility of the coordinate axes on or off.
**Parameters**
== ====================================================================
on bool or one of ('on', 1); if not `True` or any of the values stated,
turn the axes off. Otherwise turn them on.
== ====================================================================
"""
if on in [True, 'on', 1] :
logger.debug('Turning coordinate axes ON')
self.coordinate_axes.show()
else :
logger.debug('Turning coordinate axes OFF')
self.coordinate_axes.hide()
[docs] def set_data(self, data) :
"""
Set this widget's data in a :class:`TracedVariable
<data_slicer.utilities.TracedVariable>` instance to allow direct
updates whenever the data changes.
**Parameters**
==== ==================================================================
data np.array of shape (x, y, z); the data cube to be displayed.
==== ==================================================================
"""
self.data.set_value(data)
self.levels = [data.min(), data.max()]
self.xscale, self.yscale, self.zscale = [1/s for s in data.shape]
self._update_sliders()
self._initialize_planes()
def _initialize_planes(self) :
""" This wrapper exists to be overwritten by subclasses. """
self.initialize_xy()
def _update_sliders(self) :
""" Update the allowed values for of the plane slider(s). """
data = self.data.get_value()
self.slider_xy.pos.set_allowed_values(range(data.shape[2]))
[docs] def get_slice(self, d, i, integrate=0, silent=True) :
"""
Wrap :func:`make_slice <data_slicer.utilities.make_slice>` to create
slices out of this widget's `self.data`.
Confer respective documentation for details.
"""
return make_slice(self.data.get_value(), d, i, integrate=integrate,
silent=silent)
[docs] def get_xy_slice(self, i, integrate=0) :
""" Shorthand to get an xy slice, i.e. *d=2*. """
self.old_z = i
return self.get_slice(2, i, integrate)
[docs] def make_texture(self, cut) :
""" Wrapper for :func:`makeRGBA <pyqtgraph.makeRGBA>`."""
return pg.makeRGBA(cut, levels=self.levels, lut=self.lut)[0]
# return pg.makeRGBA(cut, levels=self.levels)[0]
[docs] def initialize_xy(self) :
""" Create the xy plane. """
# Get out if no data is present
if self.data.get_value() is None : return
# Remove any old planes
if hasattr(self, 'xy') :
self.glview.removeItem(self.xy)
# Get the data and texture to create the GLImageItem object
cut = self.get_xy_slice(self.slider_xy.pos.get_value())
texture = self.make_texture(cut)
self.xy = gl.GLImageItem(texture) #, glOptions=self.gloptions)
# Scale and translate to origin and add to glview
self.xy.scale(self.xscale, self.yscale, 1)
self.xy.translate(T, T, T)
# Put to position in accordance with slider
self.old_z = 0 #self.slider_xy.pos.get_value()
self.update_xy()
# Add to GLView
self.glview.addItem(self.xy)
[docs] def update_xy(self) :
""" Update both texture and position of the xy plane. """
if not hasattr(self, 'xy') : return
# Get the current position
z = self.slider_xy.pos.get_value()
# Translate
self.xy.translate(0, 0, self.zscale*(z-self.old_z))
# Update the texture (this needs to happen after the translation
# because self.get_xy_slice updates the value of self.old_z)
cut = self.get_xy_slice(z)
texture = self.make_texture(cut)
self.xy.setData(texture)
[docs] def set_cmap(self, cmap) :
""" Change the used colormap to a :class:`ds_cmap
<data_slicer.cmaps.ds_cmap>` instance.
"""
if isinstance(cmap, str) :
self.cmap = load_cmap(cmap)
elif isinstance(cmap, ds_cmap) :
self.cmap = cmap
else :
raise TypeError('*cmap* has to be a valid colormap name or a '
'*ds_cmap* instance')
# Update the necessary elements
self.lut = self.cmap.getLookupTable()
self._on_cmap_change()
def _on_cmap_change(self) :
""" Update all elements affected by the cmap change. """
self.update_xy()
[docs]class ThreeDSliceWidget(ThreeDWidget) :
"""
A :class:`ThreeDWidget <data_slicer.widgets.ThreeDWidget>` that can
slice along x, y and z.
"""
def __init__(self, *args, **kwargs) :
super().__init__(*args, **kwargs)
[docs] def align(self) :
"""
Put all sub-widgets and elements into the layout as follows::
0 1 2
+-----+-----+-----+
| | 0
+ +
| | 1
+ GLView +
| | 2
+ +
| | 3
+-----+-----+-----+
| xy | yz | zx | 4
+-----+-----+-----+
5 units in height
3 units in width
"""
l = self.layout
# for slider in [self.slider_xy, self.slider_yz, self.slider_zx] :
# slider.orientation = 'vertical'
# slider.orientate()
l.addWidget(self.glview, 0, 0, 5, 3)
l.addWidget(self.slider_xy, 4, 0, 1, 1)
l.addWidget(self.slider_yz, 4, 1, 1, 1)
l.addWidget(self.slider_zx, 4, 2, 1, 1)
def _initialize_sub_widgets(self) :
""" Create all three sliders (:class:`Scalebar
<data_slicer.imageplot.Scalebar>` instances.
"""
# xy
super()._initialize_sub_widgets()
# yz
self.slider_yz = Scalebar(name='yz-slider')
self.slider_yz.pos.sig_value_changed.connect(self.update_yz)
self.slider_yz.add_text('yz plane', relpos=(0.5, 0.5))
# zx
self.slider_zx = Scalebar(name='zx-slider')
self.slider_zx.pos.sig_value_changed.connect(self.update_zx)
self.slider_zx.add_text('zx plane', relpos=(0.5, 0.5))
def _initialize_planes(self) :
""" Initialize all three planes. """
self.initialize_xy()
self.initialize_yz()
self.initialize_zx()
[docs] def initialize_yz(self) :
""" Create the yz plane. """
# Get out if no data is present
if self.data.get_value() is None : return
# Remove any old planes
if hasattr(self, 'yz') :
self.glview.removeItem(self.yz)
# Get the data and texture to create the GLImageItem object
cut = self.get_yz_slice(self.slider_yz.pos.get_value())
texture = self.make_texture(cut)
self.yz = gl.GLImageItem(texture, glOptions=self.gloptions)
# Scale and translate to origin and add to glview (this plane has
# shape (y, z))
self.yz.scale(self.yscale, self.zscale, 1)
self.yz.translate(T, T, 0)
self.yz.rotate(90, 0, 1, 0)
self.yz.rotate(90, 1, 0, 0)
self.yz.translate(T, 0, 0)
# Put to position in accordance with slider
self.update_yz()
# Add to GLView
self.glview.addItem(self.yz)
[docs] def get_yz_slice(self, i, integrate=0) :
""" Shorthand to get an yz slice, i.e. *d=0*. """
self.old_x = i
return self.get_slice(0, i, integrate)
[docs] def initialize_zx(self) :
""" Create the zx plane. """
# Get out if no data is present
if self.data.get_value() is None : return
# Remove any old planes
if hasattr(self, 'zx') :
self.glview.removeItem(self.zx)
# Get the data and texture to create the GLImageItem object
cut = self.get_zx_slice(0)
texture = self.make_texture(cut)
self.zx = gl.GLImageItem(texture, glOptions=self.gloptions)
# Scale and translate to origin and add to glview (this plane has
# shape (x, z))
self.zx.scale(self.xscale, self.zscale, 1)
self.zx.translate(T, T, 0)
self.zx.rotate(90, 1, 0, 0)
self.zx.translate(0, T, 0)
# Put to position in accordance with slider
self.update_zx()
# Add to GLView
self.glview.addItem(self.zx)
[docs] def get_zx_slice(self, i, integrate=0) :
""" Shorthand to get an yz slice, i.e. *d=1*. """
self.old_y = i
return self.get_slice(1, i, integrate)
def _update_sliders(self) :
""" Update the allowed values for of the plane slider(s). """
data = self.data.get_value()
self.slider_xy.pos.set_allowed_values(range(data.shape[2]))
self.slider_yz.pos.set_allowed_values(range(data.shape[0]))
self.slider_zx.pos.set_allowed_values(range(data.shape[1]))
[docs] def update_yz(self) :
""" Update both texture and position of the yz plane. """
if not hasattr(self, 'yz') : return
# Get the current position
x = self.slider_yz.pos.get_value()
# Translate
self.yz.translate(self.xscale*(x-self.old_x), 0, 0)
# Update the texture (this needs to happen after the translation
# because self.get_yz_slice updates the value of self.old_x)
cut = self.get_yz_slice(x)
texture = self.make_texture(cut)
self.yz.setData(texture)
[docs] def update_zx(self) :
""" Update both texture and position of the zx plane. """
if not hasattr(self, 'zx') : return
# Get the current position
y = self.slider_zx.pos.get_value()
# Translate
self.zx.translate(0, self.yscale*(y-self.old_y), 0)
# Update the texture (this needs to happen after the translation
# because self.get_zx_slice updates the value of self.old_y)
cut = self.get_zx_slice(y)
texture = self.make_texture(cut)
self.zx.setData(texture)
def _on_cmap_change(self) :
""" Update all elements affected by the cmap change. """
self.update_xy()
self.update_yz()
self.update_zx()
[docs]class FreeSliceWidget(ThreeDWidget) :
""" A :class:`ThreeDWidget <data_slicer.widgets.ThreeDWidget>` which
represents its xy plane in an additional 2D panel.
In this "selector" panel, there's a :class:`Cutline
<data_slicer.cutline.Cutline>` with which arbitrary slices can be
generated, which will in turn be shown in the 3D GLView.
"""
def _initialize_sub_widgets(self) :
""" Create the ImagePlot with the Cutline. """
super()._initialize_sub_widgets()
self.slider_xy.pos.sig_value_changed.connect(self.update_selector)
self.selector = ImagePlot()
[docs] def align(self) :
"""
Layout::
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| | | 0
+ | +
| | | 1
+ Selector | GLView +
| | | 2
+ | +
| | | 3
+ | +---+---+ +
| | | xy | | 4
+---+---+---+---+---+---+---+---+
"""
l = self.layout
l.addWidget(self.selector, 0, 0, 5, 4)
l.addWidget(self.glview, 0, 4, 5, 4)
l.addWidget(self.slider_xy, 4, 5, 1, 2)
def _initialize_planes(self) :
""" Create the xy plane, the arbitrary cut plane and the view in the
selector panel.
"""
# xy
super()._initialize_planes()
# selector
xy = self.get_xy_slice(0)
self.selector.set_image(xy, lut=self.lut)
# cutline in selector
if not hasattr(self, 'cutline') :
self.cutline = Cutline(self.selector)
self.cutline.initialize()
# cut plane
if hasattr(self, 'cutplane') :
self.glview.removeItem(self.cutplane)
cut, coords = self.get_cutline_cut()
cut_texture = self.make_texture(cut)
self.cutplane = gl.GLImageItem(cut_texture, glOptions=self.gloptions)
# Scale and move to origin in upright position
self.cutplane.scale(self.xscale, self.zscale, 1)
self.cutplane.rotate(90, 1, 0, 0)
self.cutplane.translate(T, 0, T)
self.transform0 = self.cutplane.transform()
self.glview.addItem(self.cutplane)
self.cutline.sig_region_changed.connect(self.update_cut)
[docs] def get_cutline_cut(self) :
""" Wrapper for :meth:`~data_slicer.cutline.Cutline.get_array_region`.
"""
data = self.data.get_value()
cut, coords = self.cutline.get_array_region(data,
self.selector.image_item,
returnMappedCoords=True)
return cut, coords
[docs] def update_cut(self) :
"""
Update the texture and position of the cutline cut in the
GLGraphicsView.
"""
## Update the texture
cut, coords = self.get_cutline_cut()
texture = self.make_texture(cut)
self.cutplane.setData(texture)
## Update the position in 3D space
# Get handle positions (in data pixel coordinates (?))
try :
x0, y0 = coords[[0, 1], [0, 0]]
x1, y1 = coords[[0, 1], [-1, -1]]
except IndexError :
return
# Figure out the translation vector
tx = x0*self.xscale
ty = y0*self.yscale
# Calculate the rotation angle
delta_y = y1 - y0
try :
# arctan (delta y) / (delta x)
alpha = np.arctan(delta_y / (x1 - x0))
except ZeroDivisionError :
alpha = np.sign(delta_y) * np.pi/2
# Correct for special cases
if x1 < x0 :
alpha -= np.sign(delta_y) * np.pi
alpha_degree = 180/np.pi * alpha
# Apply the transformations in inverse order
t = QtGui.QMatrix4x4()
t.translate(tx+T, ty+T, T)
t.scale(self.xscale, self.yscale, 1)
t.rotate(alpha_degree, 0, 0, 1)
t.rotate(90, 1, 0, 0)
t.scale(1, self.zscale, 1)
self.cutplane.setTransform(t)
[docs] def update_selector(self) :
""" When the slider position changes, update the image displayed in
the selector to represent the correct cut.
"""
z = self.slider_xy.pos.get_value()
cut = self.get_xy_slice(z)
self.selector.set_image(cut, lut=self.lut)
def _on_cmap_change(self) :
""" Update all elements affected by the cmap change. """
self.update_xy()
self.update_cut()
self.update_selector()
#_Testing_______________________________________________________________________
if __name__ == "__main__" :
import pickle
import pkg_resources
import numpy as np
import data_slicer.set_up_logging
# Set up Qt Application skeleton
app = QtWidgets.QApplication([])
window = QtWidgets.QMainWindow()
window.resize(800, 800)
# Add layouting-widget
cw = QtWidgets.QWidget()
window.setCentralWidget(cw)
layout = QtWidgets.QGridLayout()
cw.setLayout(layout)
# Add our custom widgets
# w = ThreeDSliceWidget()
w = FreeSliceWidget()
layout.addWidget(w, 0, 0, 1, 2)
button1 = QtWidgets.QPushButton()
button1.setText('Reset')
layout.addWidget(button1, 1, 0, 1, 1)
button2 = QtWidgets.QPushButton()
button2.setText('Randomize')
layout.addWidget(button2, 1, 1, 1, 1)
# Load example data
data_path = pkg_resources.resource_filename('data_slicer', 'data/')
datafile = 'testdata_100_150_200.p'
with open(data_path + datafile, 'rb') as f :
data = pickle.load(f)
w.set_data(data)
# Button actions
def reset_data() :
w.set_data(data)
button1.clicked.connect(reset_data)
def randomize_data() :
w.set_data(np.random.rand(42, 123, 234))
button2.clicked.connect(randomize_data)
# Run
window.show()
app.exec_()