"""Main module containing the Plot class
"""
import pathlib
import warnings
import numpy as np
import matplotlib.pyplot as plt
import matplotlib as mpl
import nibabel as nib
from brainspace.mesh.mesh_io import read_surface
from brainspace.plotting.utils import PTuple
from brainspace.mesh.array_operations import get_labeling_border
from brainspace.vtk_interface.wrappers import BSPolyData
from .surf import plot_surf
def _check_surf(surf):
"""Validate surface type and load if a file name"""
if isinstance(surf, (str, pathlib.Path)):
return read_surface(str(surf))
elif isinstance(surf, BSPolyData) or (surf is None):
return surf
else:
raise ValueError('Surface be a path-like string, an instance of '
'BSPolyData, or None')
def _set_layout(lh, rh, layout, views, mirror=False):
"""Determine hemisphere and view layout based user input"""
valid_layouts = ['grid', 'row', 'column']
if layout not in valid_layouts:
raise ValueError(f'layout must be one of {valid_layouts}')
if isinstance(views, str):
views = [views]
valid_views = ['medial', 'lateral', 'ventral', 'dorsal', 'anterior',
'posterior']
if not set(views) <= set(valid_views):
raise ValueError(f'layout must be one of {valid_views}')
n_hemi = len([x for x in [lh, rh] if x is not None])
n_views = len(views)
# create view (v) and hemisphere (h) matrices for plotting layout
v, h = np.array([], dtype=object), np.array([], dtype=object)
if lh is not None:
v = np.concatenate([v, np.array(views)])
h = np.concatenate([h, np.array(['left'] * n_views)])
if rh is not None:
# flip medial/lateral
view_key = dict(medial='lateral', lateral='medial', dorsal='dorsal',
ventral='ventral', anterior='anterior',
posterior='posterior')
# determine view order
if mirror and (layout != 'grid') and (lh is not None):
rh_views = [view_key[i] for i in reversed(views)]
else:
rh_views = [view_key[i] for i in views]
v = np.concatenate([v, np.array(rh_views)])
h = np.concatenate([h, np.array(['right'] * n_views)])
if layout == 'grid':
v = v.reshape(n_hemi, n_views).T
h = h.reshape(n_hemi, n_views).T
elif layout == 'column':
v = v.reshape(v.shape[0], 1)
h = h.reshape(h.shape[0], 1)
# flatten if applicable (nb: grid layout with 1 hemi is a row)
if ((n_hemi == 1) or (n_views == 1)) and (layout != 'column'):
v = v.ravel()
h = h.ravel()
return v.tolist(), h.tolist()
def _flip_hemispheres(v, h):
"""Flip left and right hemispheres in the horizontal dimension
Parameters
----------
v : list
View layout list
h : list
Hemisphere layout list
Returns
-------
list, list
Flipped view and hemisphere layouts
"""
v = np.array(v)
h = np.array(h)
if (v.ndim == 1) and (v.shape[0] > 1):
# flip row
flip_axis = 0
elif (v.ndim == 2) and (v.shape[1] > 1):
# flip grid
flip_axis = 1
return np.flip(v, flip_axis).tolist(), np.flip(h, flip_axis).tolist()
def _check_data(data):
"""Ensure that data is of appropriate type and return numpy array"""
if isinstance(data, np.ndarray):
return data.astype(float)
elif isinstance(data, (str, pathlib.PosixPath)):
data = nib.load(data)
elif isinstance(data, (nib.Cifti2Image, nib.GiftiImage)):
pass
else:
raise TypeError('data must be a file path to a valid GIFTI or CIFTI '
'file, or an instance of numpy.ndarray, '
'nibabel.Cifti2Image nibabel.GiftiImage')
if isinstance(data, nib.Cifti2Image):
return data.get_fdata().ravel().astype(float)
else:
return data.agg_data().ravel().astype(float)
def _find_color_range(v):
"""Find min and max of both hemispheres"""
hemis = ['left', 'right']
with warnings.catch_warnings():
# not necessary to warn the user about this. NaNs won't impact anything
warnings.filterwarnings('ignore', r'All-NaN (slice|axis) encountered')
vmin = np.nanmin([np.nanmin(v[h]) for h in hemis if h in v])
vmax = np.nanmax([np.nanmax(v[h]) for h in hemis if h in v])
return vmin, vmax
def _set_label_positions(location, rotation):
"""Get rotation, horizontal alignment, and vertical alignment,
respectively, based on orientation and rotation
"""
if location in ['top', 'bottom']:
rotation = 'horizontal' if rotation is None else rotation
return rotation, 'right', 'center'
elif location in ['left', 'right']:
rotation = 90 if rotation is None else rotation
if rotation == 90 or rotation == 0:
return rotation, 'center', 'bottom'
else:
return rotation, 'left', 'bottom'
else:
raise ValueError("`location` must be 'top', 'bottom', 'left' or "
"'right'")
def _set_colorbar_labels(cbar, label, location, fontsize=10, rotation=None):
"""Add colorbar labels to drawn colorbar"""
valid_locations = ['top', 'bottom', 'left', 'right']
if location not in valid_locations:
raise ValueError(f"`location` must be one of {valid_locations}")
rotation, ha, va = _set_label_positions(location, rotation)
label_args = dict(rotation=rotation, ha=ha, va=va, fontsize=fontsize)
if location in ['top', 'bottom']:
cbar.ax.set_ylabel(label, **label_args)
else:
cbar.ax.set_title(label, pad=10, **label_args)
return cbar
[docs]class Plot(object):
"""Plot brain surfaces with data layers
Parameters
----------
surf_lh, surf_rh : str or os.PathLike or BSPolyData, optional
Left and right hemisphere cortical surfaces, either as a file path
to a valid surface file (e.g., .gii. .surf) or a pre-loaded
surface from :func:`brainspace.mesh.mesh_io.read_surface`. At least one
hemisphere must be provided. Default: None
layout : {'grid', 'column', 'row'}, optional
Layout in which to plot brain surfaces. 'row' plots brains as a
single row ordered from left-to-right hemispheres (if applicable),
'column' plots brains as a single column descending from
left-to-right hemispheres (if applicable). 'grid' plots surfaces
as a views-by-hemisphere (left-right) array; if only one
hemipshere is provided, then 'grid' is equivalent to 'row'. By
default 'grid'.
views : {'lateral', 'medial', 'dorsal', 'ventral', 'anterior',
'posterior'}, str or list[str], optional
Views to plot for each provided hemisphere. Views are plotted in
in the order they are provided. If None, then lateral and medial
views are plotted. Default: None
mirror_views : bool, optional
Flip the order of the right hemisphere views for 'row' or 'column'
layouts, such that they mirror the left hemisphere views. Ignored if
`surf_rh` is None and `layout` is 'grid'.
flip : bool, optional
Flip the display order of left and right hemispheres in `grid` or
`row` layouts, if applicable. Useful when showing only 'anterior`
or 'inferior' views. Default: False
size : tuple of int, optional
The size of the space to plot surfaces, defined by (width, height).
Note that this differs from `figsize` in Plot.build(), which
determines the overall figure size for the matplotlib figure.
Default: (500, 400)
zoom : int, optional
Level of zoom to apply. Default: 1.5
background : tuple, optional
Background color, default: (1, 1, 1)
label_text : dict[str, array-like], optional
Brainspace label text for column/row. Possible keys are
{‘left’, ‘right’, ‘top’, ‘bottom’}, which indicate the location.
See brainspace.plotting.surface_plotting.plot_surf for more
details Default: None.
brightness : float, optional
Brightness of plain gray surface. 0 = black, 1 = white. Default:
.5
Raises
------
ValueError
Neither `surf_lh` or `surf_rh` are provided
"""
def __init__(self, surf_lh=None, surf_rh=None, layout='grid', views=None,
mirror_views=False, flip=False, size=(500, 400), zoom=1.5,
background=(1, 1, 1), label_text=None, brightness=.5):
hemi_inputs = zip(['left', 'right'], [surf_lh, surf_rh])
self.surfaces = {k: _check_surf(v)
for k, v in hemi_inputs if v is not None}
if len(self.surfaces) == 0:
raise ValueError('No surfaces are provided')
if views == None:
views = ['lateral', 'medial']
self.plot_layout = _set_layout(surf_lh, surf_rh, layout, views,
mirror_views)
self.flip = flip
# plot_surf args
self.size = size
self.zoom = zoom
self.background = background
self.label_text = label_text
# these are updated with each overlay
self.layers, self.cmaps, self.color_ranges = [], [], []
self._show_cbar, self.cbar_labels = [], []
# add gray surface default:
backdrop = np.ones(sum([v.n_points for v in self.surfaces.values()]))
brightness = 1e-6 if brightness == 0 else brightness
backdrop *= brightness
self.add_layer(backdrop, 'Greys_r', color_range=(0, 1), cbar=False)
[docs] def add_layer(self, data, cmap='viridis', alpha=1, color_range=None,
as_outline=False, zero_transparent=True, cbar=True,
cbar_label=None):
"""Add plotting layer to surface(s)
Parameters
----------
data : str or os.PathLike, numpy.ndarray, dict, nibabel.gifti.gifti.GiftiImage, or nibabel.cifti2.cifti2.Cifti2Image
Vertex data to plot on surfaces. Must be a valid file path of a
GIFTI or CIFTI image, a loaded GIFTI or CIFTI image, a numpy array
with length equal to the total number of vertices in the provided
surfaces (e.g., 32k in left surface + 32k in right surface = 64k
total), or a dictionary with 'left' and/or 'right keys.
If a numpy array, vertices are assumed to be in order of
left-to-right, if applicable. If a dictionary, then values can be
any of the possible types mentioned above, assuming that the
vertices match the vertices of their respective surface.
cmap : matplotlib colormap name or object, optional
Colormap to use for data, default: 'viridis'
alpha : float, optional
Colormap opacity (0 to 1). Default: 1
color_range : tuple[float, float], optional
Minimum and maximum value for color map. If None, then the minimum
and maximum values in `data` are used. Default: None
as_outline : bool, optional
Plot only an outline of contiguous vertices with the same value.
Useful if plotting regions of interests, atlases, or discretized
data. Not recommended for continous data. Default: False
zero_transparent : bool, optional
Set vertices with value of 0 to NaN, which will turn them
transparent on the surface. Useful when value of 0 has no
importance (e.g., thresholded data, an atlas). Default: True
cbar : bool, optional
Show colorbar for layer, default: True
cbar_label : str, optional
Label to include with colorbar if shown. Note that this is not
required for the colorbar to be drawn. Default: None
Raises
------
ValueError
`data` keys must be 'left' and/or 'right'
TypeError
`data` is neither an instance of str or os.PathLike, numpy.ndarray,
dict, nibabel.gifti.gifti.GiftiImage, or
nibabel.cifti2.cifti2.Cifti2Image
"""
# let the name just be the layer number
name = str(len(self.layers))
valid_types = (np.ndarray, str, pathlib.PosixPath, nib.GiftiImage,
nib.Cifti2Image)
if isinstance(data, valid_types):
data = _check_data(data)
vertices = {}
if len(self.surfaces.keys()) == 2:
lh_points = self.surfaces['left'].n_points
rh_points = self.surfaces['right'].n_points
vertices['left'] = data[:lh_points]
vertices['right'] = data[lh_points:lh_points + rh_points]
else:
key = list(self.surfaces.keys())[0]
vertices[key] = data
elif isinstance(data, dict):
if set(data.keys()) <= set(['left', 'right']):
vertices = {k: _check_data(v) for k, v in data.items()}
else:
raise ValueError("Only valid keys for `data` are 'left' "
"and/or 'right'")
else:
raise TypeError("Data type invalid")
for k, v in self.surfaces.items():
if k in vertices.keys():
if as_outline:
x = get_labeling_border(
v, np.nan_to_num(vertices[k])
).astype(float)
else:
x = vertices[k]
if zero_transparent:
x[x == 0] = np.nan
v.append_array(x, name=name, at='p')
else:
# blank layer for unspecified hemisphere
x = np.zeros(v.n_points)
x[x == 0] = np.nan
v.append_array(x, name=name, at='p')
if alpha < 1:
if isinstance(cmap, str):
cmap = plt.get_cmap(cmap)
cmapV = cmap(np.arange(cmap.N))
cmapV[:, -1] = alpha
cmap = mpl.colors.ListedColormap(cmapV)
self.layers.append(name)
self.cmaps.append(cmap)
if color_range is None:
self.color_ranges.append(_find_color_range(vertices))
else:
self.color_ranges.append(color_range)
self._show_cbar.append(cbar)
self.cbar_labels.append(cbar_label)
[docs] def render(self, offscreen=True):
"""Generate surface plot with all provided layers
Parameters
----------
offscreen : bool, optional
Render offscreen. Default: True
Returns
-------
brainspace.plotting.base.Plotter
Surface plot
"""
view_layout, hemi_layout = self.plot_layout
dims = np.array(view_layout).shape
if self.flip and len(self.surfaces) == 2:
view_layout, hemi_layout = _flip_hemispheres(view_layout,
hemi_layout)
# create plot tuples
layers = PTuple(*self.layers)
cmaps = PTuple(*self.cmaps)
crange = PTuple(*self.color_ranges)
if all(i != 1 for i in dims) and (len(dims) == 2):
# grid layout
names = [[layers] * dims[1]] * dims[0]
cmap = [cmaps] * dims[1]
color_range = [crange] * dims[1]
else:
# column or row layout
names = [layers]
cmap = [cmaps]
color_range = [crange]
return plot_surf(surfs=self.surfaces, layout=hemi_layout,
array_name=names, cmap=cmap, color_bar=False,
color_range=color_range, view=view_layout,
background=self.background, zoom=self.zoom,
nan_color=(0, 0, 0, 0), share=False,
label_text=self.label_text, size=self.size,
return_plotter=True, offscreen=offscreen)
[docs] def _add_colorbars(self, location='bottom', label_direction=None,
n_ticks=3, decimals=2, fontsize=10, draw_border=True,
outer_labels_only=False, aspect=20, pad=.08, shrink=.3,
fraction=.05):
"""Draw colorbar(s) for applicable layer(s)
Parameters
----------
location : {'left', 'right', 'top', 'bottom'}, optional
The location, relative to the surface plot. If location is 'top' or
'bottom', then colorbars are horizontal. If location is'left' or
'right', then colorbars are vertical.
label_direction : int or None, optional
Angle to draw label for colorbars, if provided. Horizontal = 0,
vertical = 90. If None and `location` is 'top' or 'bottom', labels
are drawn horizontally. If None and `location` is 'left' or
'right', labels are drawn vertically. Default: None
n_ticks : int, optional
Number of ticks to include on colorbar, default: 3 (minimum,
maximum, and middle values)
decimals : int, optional
Number of decimals to show for colorbal tick values. Set 0 to show
integers. Default: 2
fontsize : int, optional
Font size for colorbar labels and tick labels. Default: 10
draw_border : bool, optional
Draw ticks and black border around colorbar. Default: True
outer_labels_only : bool, optional
Show tick labels for only the outermost colorbar. This cleans up
tick labels when all colorbars are the same scale. Default: False
pad : float, optional
Space that separates each colorbar. Default: .08
aspect : float, optional
Ratio of long to short dimensions. Default: 20
shrink : float, optional
Fraction by which to multiply the size of the colorbar.
Default: .3
fraction : float, optional
Fraction of original axes to use for colorbar. Default: .05
"""
cbar_pads = [.01] + [pad] * (len(self._show_cbar) - 1)
cbar_indices = [i for i, c in enumerate(self._show_cbar) if c]
# draw in reverse order so that outermost colorbar is uppermost layer
for i in cbar_indices[::-1]:
vmin, vmax = self.color_ranges[i]
norm = mpl.colors.Normalize(vmin, vmax)
sm = plt.cm.ScalarMappable(cmap=self.cmaps[i], norm=norm)
sm.set_array([])
ticks = np.linspace(vmin, vmax, n_ticks)
cb = plt.colorbar(sm, ticks=ticks, location=location,
fraction=fraction, pad=cbar_pads[i],
shrink=shrink, aspect=aspect, ax=plt.gca())
tick_labels = np.linspace(vmin, vmax, n_ticks)
if decimals > 0:
tick_labels = np.around(tick_labels, decimals)
else:
tick_labels = tick_labels.astype(int)
if outer_labels_only and i != cbar_indices[-1]:
cb.set_ticklabels([])
else:
cb.set_ticklabels(tick_labels)
cb.ax.tick_params(labelsize=fontsize)
if self.cbar_labels[i] is not None:
cb = _set_colorbar_labels(cb, self.cbar_labels[i], location,
fontsize=fontsize,
rotation=label_direction)
if not draw_border:
cb.outline.set_visible(False)
cb.ax.tick_params(size=0)
[docs] def build(self, figsize=None, colorbar=True, cbar_kws=None, scale=(2, 2)):
"""Build matplotlib figure of surface plot
Parameters
----------
figsize : tuple, optional
Overall figure size, specified by (width, height). Default: None,
which will determine the figure size based on the `size` parameter.
colorbar : bool, optional
Draw colorbars for each applicable layer, default: True
cbar_kws : dict, optional
Keyword arguments for
:func:`~surfplot.plottong.Plot._add_colorbar`. Default: None,
which will plot the default colorbar parameters.
scale : tuple, optional
Amount to scale the surface plot. Default: (2, 2), which is a
good baseline for higher resolution plotting.
Returns
-------
matplotlib.pyplot.figure
Surface plot figure
"""
p = self.render()
p._check_offscreen()
x = p.to_numpy(transparent_bg=True, scale=scale)
if figsize is None:
figsize = tuple((np.array(self.size) / 100) + 1)
fig, ax = plt.subplots(figsize=figsize)
ax.imshow(x)
ax.axis('off')
if colorbar:
cbar_kws = {} if cbar_kws is None else cbar_kws
self._add_colorbars(**cbar_kws)
return fig
[docs] def show(self, embed_nb=False, interactive=True, transparent_bg=True,
scale=(1, 1)):
"""View Brainspace vtk surface rendering
Notes
-----
This only shows the plot created by
brainspace.plotting.surface_plotting.plot_surf, and will not include
colorbars created by :func:`~surfplot.plottong.Plot.plot` or any
other matplotlib components.
Parameters
----------
embed_nb : bool, optional
Whether to embed figure in notebook. Only used if running in a
notebook. Default: False
interactive : bool, optional
Whether to enable interaction, default: True
scale : tuple, optional
Amount to scale the surface plot, default: (1, 1)
Returns
-------
Ipython Image or vtk panel
Brainspace surface plot rendering
"""
p = self.render(offscreen=False)
return p.show(embed_nb, interactive, scale=scale)