Building custom interfaces#
This notebook will demonstrate how to create custom interactive interfaces to visualize a data set with plopp
.
[1]:
%matplotlib widget
import plopp as pp
import scipp as sc
import numpy as np
The data is a two-dimensional data array, where the values are generated using a sine function. We also add a small amount of random noise to the values.
[2]:
nx = 200
ny = 150
x = np.arange(float(nx))
y = np.arange(float(ny))
noise = np.random.random((ny, nx))
z = 3.0 * np.sin(np.sqrt(x**2 + y.reshape(ny, 1) ** 2) / 10.0) + noise + 300.0
da = sc.DataArray(
data=sc.array(dims=['y', 'x'], values=z, unit='K'),
coords={
'x': sc.array(dims=['x'], values=x, unit='m'),
'y': sc.array(dims=['y'], values=y, unit='m'),
},
)
da
[2]:
- y: 150
- x: 200
- x(x)float64m0.0, 1.0, ..., 198.0, 199.0
Values:
array([ 0., 1., 2., 3., 4., 5., 6., 7., 8., 9., 10., 11., 12., 13., 14., 15., 16., 17., 18., 19., 20., 21., 22., 23., 24., 25., 26., 27., 28., 29., 30., 31., 32., 33., 34., 35., 36., 37., 38., 39., 40., 41., 42., 43., 44., 45., 46., 47., 48., 49., 50., 51., 52., 53., 54., 55., 56., 57., 58., 59., 60., 61., 62., 63., 64., 65., 66., 67., 68., 69., 70., 71., 72., 73., 74., 75., 76., 77., 78., 79., 80., 81., 82., 83., 84., 85., 86., 87., 88., 89., 90., 91., 92., 93., 94., 95., 96., 97., 98., 99., 100., 101., 102., 103., 104., 105., 106., 107., 108., 109., 110., 111., 112., 113., 114., 115., 116., 117., 118., 119., 120., 121., 122., 123., 124., 125., 126., 127., 128., 129., 130., 131., 132., 133., 134., 135., 136., 137., 138., 139., 140., 141., 142., 143., 144., 145., 146., 147., 148., 149., 150., 151., 152., 153., 154., 155., 156., 157., 158., 159., 160., 161., 162., 163., 164., 165., 166., 167., 168., 169., 170., 171., 172., 173., 174., 175., 176., 177., 178., 179., 180., 181., 182., 183., 184., 185., 186., 187., 188., 189., 190., 191., 192., 193., 194., 195., 196., 197., 198., 199.]) - y(y)float64m0.0, 1.0, ..., 148.0, 149.0
Values:
array([ 0., 1., 2., 3., 4., 5., 6., 7., 8., 9., 10., 11., 12., 13., 14., 15., 16., 17., 18., 19., 20., 21., 22., 23., 24., 25., 26., 27., 28., 29., 30., 31., 32., 33., 34., 35., 36., 37., 38., 39., 40., 41., 42., 43., 44., 45., 46., 47., 48., 49., 50., 51., 52., 53., 54., 55., 56., 57., 58., 59., 60., 61., 62., 63., 64., 65., 66., 67., 68., 69., 70., 71., 72., 73., 74., 75., 76., 77., 78., 79., 80., 81., 82., 83., 84., 85., 86., 87., 88., 89., 90., 91., 92., 93., 94., 95., 96., 97., 98., 99., 100., 101., 102., 103., 104., 105., 106., 107., 108., 109., 110., 111., 112., 113., 114., 115., 116., 117., 118., 119., 120., 121., 122., 123., 124., 125., 126., 127., 128., 129., 130., 131., 132., 133., 134., 135., 136., 137., 138., 139., 140., 141., 142., 143., 144., 145., 146., 147., 148., 149.])
- (y, x)float64K300.623, 300.666, ..., 299.245, 299.256
Values:
array([[300.62251491, 300.66597264, 300.75250699, ..., 303.08284052, 303.04039006, 303.38587406], [300.69104611, 300.48314001, 301.27819345, ..., 302.41987287, 302.89812906, 303.34683205], [301.57612733, 300.95058348, 301.56288487, ..., 302.83447726, 303.36639993, 303.59267435], ..., [303.26138021, 303.08424095, 303.04201616, ..., 299.38196271, 299.52327668, 298.92473179], [302.84080443, 303.25659405, 303.10563925, ..., 299.33543502, 299.38935707, 299.93976364], [302.28388285, 302.86623697, 303.11709203, ..., 298.81519346, 299.24480717, 299.25560862]])
A set of connected nodes#
In plopp
, think of each element in your interface as a set of interconnected nodes in a graph. Each node can have parent nodes, children nodes, and also views attached to them (e.g. figures).
At the most basic level, a graph will contain a node (white rectangle) that provides the input data, and a view (grey ellipse) which will be a figure to display the data visually. Note that the figure takes as input the data_node
.
[3]:
data_node = pp.Node(da)
fig = pp.imagefigure(data_node)
pp.show_graph(data_node) # display the graph
[3]:
When the data in the input node changes, the view is notified about the change. It requests new data from its parent node and updates the visuals on the figure.
The figure can directly be displayed in the notebook:
[4]:
fig
[4]:
Nodes are callables#
Nodes in the graph have to be constructed from callables. When a view requests data from a parent node, the callable is called. Typically, the callable will be a function that takes in a data array as an input, and returns a data array as an output.
Keeping your inputs and outputs as data arrays is useful because figure views will only accept data arrays as input. That said, nodes that produce other outputs are very common, for example when using interactive widgets.
In the small example above, the node at the top of the graph has no parents, and its callable is simply a lambda
function with no arguments that just returns the input data.
Calling any node will return the output from its internal callable (this is very similar to Dask’s delayed.compute()
method). In our example above, calling data_node
will simply return the initial data array
[5]:
data_node()
[5]:
- y: 150
- x: 200
- x(x)float64m0.0, 1.0, ..., 198.0, 199.0
Values:
array([ 0., 1., 2., 3., 4., 5., 6., 7., 8., 9., 10., 11., 12., 13., 14., 15., 16., 17., 18., 19., 20., 21., 22., 23., 24., 25., 26., 27., 28., 29., 30., 31., 32., 33., 34., 35., 36., 37., 38., 39., 40., 41., 42., 43., 44., 45., 46., 47., 48., 49., 50., 51., 52., 53., 54., 55., 56., 57., 58., 59., 60., 61., 62., 63., 64., 65., 66., 67., 68., 69., 70., 71., 72., 73., 74., 75., 76., 77., 78., 79., 80., 81., 82., 83., 84., 85., 86., 87., 88., 89., 90., 91., 92., 93., 94., 95., 96., 97., 98., 99., 100., 101., 102., 103., 104., 105., 106., 107., 108., 109., 110., 111., 112., 113., 114., 115., 116., 117., 118., 119., 120., 121., 122., 123., 124., 125., 126., 127., 128., 129., 130., 131., 132., 133., 134., 135., 136., 137., 138., 139., 140., 141., 142., 143., 144., 145., 146., 147., 148., 149., 150., 151., 152., 153., 154., 155., 156., 157., 158., 159., 160., 161., 162., 163., 164., 165., 166., 167., 168., 169., 170., 171., 172., 173., 174., 175., 176., 177., 178., 179., 180., 181., 182., 183., 184., 185., 186., 187., 188., 189., 190., 191., 192., 193., 194., 195., 196., 197., 198., 199.]) - y(y)float64m0.0, 1.0, ..., 148.0, 149.0
Values:
array([ 0., 1., 2., 3., 4., 5., 6., 7., 8., 9., 10., 11., 12., 13., 14., 15., 16., 17., 18., 19., 20., 21., 22., 23., 24., 25., 26., 27., 28., 29., 30., 31., 32., 33., 34., 35., 36., 37., 38., 39., 40., 41., 42., 43., 44., 45., 46., 47., 48., 49., 50., 51., 52., 53., 54., 55., 56., 57., 58., 59., 60., 61., 62., 63., 64., 65., 66., 67., 68., 69., 70., 71., 72., 73., 74., 75., 76., 77., 78., 79., 80., 81., 82., 83., 84., 85., 86., 87., 88., 89., 90., 91., 92., 93., 94., 95., 96., 97., 98., 99., 100., 101., 102., 103., 104., 105., 106., 107., 108., 109., 110., 111., 112., 113., 114., 115., 116., 117., 118., 119., 120., 121., 122., 123., 124., 125., 126., 127., 128., 129., 130., 131., 132., 133., 134., 135., 136., 137., 138., 139., 140., 141., 142., 143., 144., 145., 146., 147., 148., 149.])
- (y, x)float64K300.623, 300.666, ..., 299.245, 299.256
Values:
array([[300.62251491, 300.66597264, 300.75250699, ..., 303.08284052, 303.04039006, 303.38587406], [300.69104611, 300.48314001, 301.27819345, ..., 302.41987287, 302.89812906, 303.34683205], [301.57612733, 300.95058348, 301.56288487, ..., 302.83447726, 303.36639993, 303.59267435], ..., [303.26138021, 303.08424095, 303.04201616, ..., 299.38196271, 299.52327668, 298.92473179], [302.84080443, 303.25659405, 303.10563925, ..., 299.33543502, 299.38935707, 299.93976364], [302.28388285, 302.86623697, 303.11709203, ..., 298.81519346, 299.24480717, 299.25560862]])
But for more complex graphs, the call will walk the tree, requesting all the pieces of data it needs to compute the final result.
Expanding the graph#
Next, say we wish to add a gaussian smoothing step in our graph, before showing the data on the figure. We start with the same data_node
, but add a second node that performs the smoothing operation before attaching the figure. Because the gaussian_filter
function requires a kernel width sigma
as input, we set it to 5 via a keyword argument (note here that it is not necessary to wrap it into a Node
, this will automatically be handled internally).
[6]:
from scipp.scipy.ndimage import gaussian_filter
data_node = pp.Node(da)
smooth_node = pp.Node(gaussian_filter, data_node, sigma=5)
fig = pp.imagefigure(smooth_node)
The resulting graph has two input nodes (one for the data array and one for the kernel width), a smoothing node, and a figure:
[7]:
pp.show_graph(data_node)
[7]:
And the resulting figure displays the smoothed data:
[8]:
fig
[8]:
Adding interactive widgets#
In the example above, the kernel size sigma
for the gaussian smoothing was frozen to 5
. But we would actually want to control this via a slider widget.
In this case, the smoothing node now needs two inputs: the raw data, and the sigma
. It gets the raw data from the data_node
, and the sigma
from a widget_node
, which is coupled to a slider from the ipywidgets
library.
[9]:
import ipywidgets as ipw
data_node = pp.Node(da)
slider = ipw.IntSlider(min=1, max=20)
slider_node = pp.widget_node(slider)
smooth_node = pp.Node(gaussian_filter, data_node, sigma=slider_node)
fig = pp.imagefigure(smooth_node)
As expected, the smoothing node now has a widget as one of its parent nodes instead of the fixed-value input node:
[10]:
pp.show_graph(fig)
[10]:
And we can display the figure and the slider inside the same container:
[11]:
ipw.VBox([slider, fig])
[11]:
When a change occurs in one of the nodes, all the nodes below it in the graph are notified about the change (the children nodes receive a notification, and they, in turn, notify their own children). It is then up to each view to decide whether they are interested in the notification or not (usually, most views are interested in all notifications from parents). If they are, they request data from their parent nodes, which in turn request data from their parents, and so on, until the request has reached the top of the graph.
As a result, when the slider is dragged, the smoothing node gets notified and tells the figure that a change has occurred. The figure tells smooth_node
that it wants updated data. smooth_node
asks nodes data_node
and slider_node
for their data. data_node
returns the raw data, while slider_node
returns the integer value for the kernel size. smooth_node
then simply sends the inputs to the gaussian_filter
function, and forwards the result to the figure.
Warning
The figure will not update when dragging the slider in the documentation pages. This will only work inside a Jupyter notebook.
Multiple views#
To go one step further, we now wish to add a one-dimensional figure that will display the sum of the two-dimensional data along the vertical dimension. On this figure, we would like to display both the original (unsmoothed) data, as well as the smoothed data.
[12]:
data_node = pp.Node(da)
slider = ipw.IntSlider(min=1, max=20, value=10)
slider_node = pp.widget_node(slider)
smooth_node = pp.Node(gaussian_filter, data_node, sigma=slider_node)
fig2d = pp.imagefigure(smooth_node)
# Sum the raw data along the vertical dimension
sum_raw = pp.Node(sc.sum, data_node, dim='y')
# Sum the smoothed data along the vertical dimension
sum_smoothed = pp.Node(sc.sum, smooth_node, dim='y')
# Give two nodes to a figure to display both on the same axes
fig1d = pp.linefigure(sum_raw, sum_smoothed)
We check the graph again to make sure that the one-dimensional figure has two inputs, and that both are performing a sum along the y
dimension.
[13]:
pp.show_graph(slider_node)
[13]:
[14]:
ipw.VBox([slider, fig2d, fig1d])
[14]:
Because the slider only affects the smoothing part of the graph, only the orange markers will update when we drag the slider.
Multiple controls and the node
decorator#
In this section, masks will be added to the raw data, and a widget made of checkboxes will be used to toggle the masks on and off.
We make a slightly different interface, where we remove the gaussian smoothing, and instead use the slider to slice rows in the input data.
[15]:
da.masks['close_to_300'] = abs(da.data - sc.scalar(300.0, unit='K')) < sc.scalar(
1.0, unit='K'
)
da.masks['large_x'] = da.coords['x'] > sc.scalar(150.0, unit='m')
da
[15]:
- y: 150
- x: 200
- x(x)float64m0.0, 1.0, ..., 198.0, 199.0
Values:
array([ 0., 1., 2., 3., 4., 5., 6., 7., 8., 9., 10., 11., 12., 13., 14., 15., 16., 17., 18., 19., 20., 21., 22., 23., 24., 25., 26., 27., 28., 29., 30., 31., 32., 33., 34., 35., 36., 37., 38., 39., 40., 41., 42., 43., 44., 45., 46., 47., 48., 49., 50., 51., 52., 53., 54., 55., 56., 57., 58., 59., 60., 61., 62., 63., 64., 65., 66., 67., 68., 69., 70., 71., 72., 73., 74., 75., 76., 77., 78., 79., 80., 81., 82., 83., 84., 85., 86., 87., 88., 89., 90., 91., 92., 93., 94., 95., 96., 97., 98., 99., 100., 101., 102., 103., 104., 105., 106., 107., 108., 109., 110., 111., 112., 113., 114., 115., 116., 117., 118., 119., 120., 121., 122., 123., 124., 125., 126., 127., 128., 129., 130., 131., 132., 133., 134., 135., 136., 137., 138., 139., 140., 141., 142., 143., 144., 145., 146., 147., 148., 149., 150., 151., 152., 153., 154., 155., 156., 157., 158., 159., 160., 161., 162., 163., 164., 165., 166., 167., 168., 169., 170., 171., 172., 173., 174., 175., 176., 177., 178., 179., 180., 181., 182., 183., 184., 185., 186., 187., 188., 189., 190., 191., 192., 193., 194., 195., 196., 197., 198., 199.]) - y(y)float64m0.0, 1.0, ..., 148.0, 149.0
Values:
array([ 0., 1., 2., 3., 4., 5., 6., 7., 8., 9., 10., 11., 12., 13., 14., 15., 16., 17., 18., 19., 20., 21., 22., 23., 24., 25., 26., 27., 28., 29., 30., 31., 32., 33., 34., 35., 36., 37., 38., 39., 40., 41., 42., 43., 44., 45., 46., 47., 48., 49., 50., 51., 52., 53., 54., 55., 56., 57., 58., 59., 60., 61., 62., 63., 64., 65., 66., 67., 68., 69., 70., 71., 72., 73., 74., 75., 76., 77., 78., 79., 80., 81., 82., 83., 84., 85., 86., 87., 88., 89., 90., 91., 92., 93., 94., 95., 96., 97., 98., 99., 100., 101., 102., 103., 104., 105., 106., 107., 108., 109., 110., 111., 112., 113., 114., 115., 116., 117., 118., 119., 120., 121., 122., 123., 124., 125., 126., 127., 128., 129., 130., 131., 132., 133., 134., 135., 136., 137., 138., 139., 140., 141., 142., 143., 144., 145., 146., 147., 148., 149.])
- (y, x)float64K300.623, 300.666, ..., 299.245, 299.256
Values:
array([[300.62251491, 300.66597264, 300.75250699, ..., 303.08284052, 303.04039006, 303.38587406], [300.69104611, 300.48314001, 301.27819345, ..., 302.41987287, 302.89812906, 303.34683205], [301.57612733, 300.95058348, 301.56288487, ..., 302.83447726, 303.36639993, 303.59267435], ..., [303.26138021, 303.08424095, 303.04201616, ..., 299.38196271, 299.52327668, 298.92473179], [302.84080443, 303.25659405, 303.10563925, ..., 299.33543502, 299.38935707, 299.93976364], [302.28388285, 302.86623697, 303.11709203, ..., 298.81519346, 299.24480717, 299.25560862]])
- close_to_300(y, x)boolTrue, True, ..., True, True
Values:
array([[ True, True, True, ..., False, False, False], [ True, True, False, ..., False, False, False], [False, True, False, ..., False, False, False], ..., [False, False, False, ..., True, True, False], [False, False, False, ..., True, True, True], [False, False, False, ..., False, True, True]]) - large_x(x)boolFalse, False, ..., True, True
Values:
array([False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, False, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True])
The data node remains the same:
[16]:
data_node = pp.Node(da)
Next, we create a Checkboxes
widget which takes in a set of keys/strings as input. It will create a checkbox per entry.
[17]:
from plopp.widgets import Checkboxes, Box
masks_widget = Checkboxes(da.masks.keys())
masks_widget
[17]:
The .value
of the widget simply contains the values for the individual checkboxes:
[18]:
masks_widget.value
[18]:
{'close_to_300': True, 'large_x': True}
We make a node from the checkboxes widget using widget_node
once again.
We can supply custom functions to the Node
constructor, but it is often useful to decorate our functions with the plopp.node
decorator, and then call the functions directly.
We therefore define a function that will hide masks depending on the checkboxes values, and decorate it with @pp.node
so that it can accept data_node
and masks_node
as inputs.
[19]:
masks_node = pp.widget_node(masks_widget)
@pp.node
def hide_masks(data_array, masks):
out = data_array.copy(deep=False)
for name, value in masks.items():
if name in out.masks and (not value):
del out.masks[name]
return out
hide_node = hide_masks(data_array=data_node, masks=masks_node)
We then connect the slider to a slicing function, and add that below the hide_node
in the graph:
[20]:
@pp.node
def slice_y(data_array, index):
return data_array['y', index]
slider = ipw.IntSlider(min=0, max=da.sizes['y'] - 1)
slider_node = pp.widget_node(slider)
slice_node = slice_y(hide_node, index=slider_node)
pp.show_graph(masks_node)
[20]:
Finally, we add an image figure to the hide_node
, and a line figure after the slicing has been performed.
[21]:
fig2d = pp.imagefigure(hide_node)
fig1d = pp.linefigure(slice_node)
pp.show_graph(masks_node)
[21]:
[22]:
Box([[slider, masks_widget], fig2d, fig1d])
[22]:
Note that the masks update on both the 1D and 2D figures, because the checkboxes widget lies at the very top of the graph.