"""Functions for pyvista-based plotting."""
from pathlib import Path
import nibabel as nib
import numpy as np
import matplotlib as mpl
import matplotlib.colors as mcolors
try:
import pyvista as pv
except ImportError:
_has_pyvista = False
else:
_has_pyvista = True
from netneurotools.datasets import (
fetch_civet_curated,
fetch_fsaverage_curated,
fetch_fslr_curated,
)
from netneurotools.datasets.fetch_template import (
_fetch_subcortex_surface
)
def _pv_fetch_template(template, surf="inflated", data_dir=None, verbose=0):
if template in ["fsaverage", "fsaverage6", "fsaverage5", "fsaverage4"]:
_fetch_curr_tpl = fetch_fsaverage_curated
elif template in ["fslr4k", "fslr8k", "fslr32k", "fslr164k"]:
_fetch_curr_tpl = fetch_fslr_curated
elif template in ["civet41k", "civet164k"]:
_fetch_curr_tpl = fetch_civet_curated
else:
raise ValueError(f"Unknown template: {template}")
curr_tpl_surf = _fetch_curr_tpl(
version=template, data_dir=data_dir, verbose=verbose
)[surf]
return curr_tpl_surf
def _pv_load_surface(template, surf="inflated", hemi=None, data_dir=None, verbose=0):
curr_tpl_surf = _pv_fetch_template(
template=template, surf=surf, data_dir=data_dir, verbose=verbose
)
def _gifti_to_polydata(gifti_file):
vertices, faces = nib.load(gifti_file).agg_data()
return pv.PolyData(
vertices, np.c_[np.ones((faces.shape[0],), dtype=int) * 3, faces]
)
if hemi == "L":
return _gifti_to_polydata(curr_tpl_surf.L)
elif hemi == "R":
return _gifti_to_polydata(curr_tpl_surf.R)
else:
return (
_gifti_to_polydata(curr_tpl_surf.L),
_gifti_to_polydata(curr_tpl_surf.R),
)
def _mask_medial_wall(data, template, hemi=None, data_dir=None, verbose=0):
curr_medial = _pv_fetch_template(
template=template, surf="medial", data_dir=data_dir, verbose=verbose
)
if isinstance(data, tuple):
curr_medial_data = (
nib.load(curr_medial.L).agg_data(),
nib.load(curr_medial.R).agg_data(),
)
# Convert to float to support NaN masking for missing/masked vertices
ret_L = data[0].astype(float)
ret_R = data[1].astype(float)
ret_L[np.where(1 - curr_medial_data[0])] = np.nan
ret_R[np.where(1 - curr_medial_data[1])] = np.nan
ret = (ret_L, ret_R)
else:
if hemi == "L":
curr_medial_data = nib.load(curr_medial.L).agg_data()
elif hemi == "R":
curr_medial_data = nib.load(curr_medial.R).agg_data()
else:
curr_medial_data = np.concatenate(
[
nib.load(curr_medial.L).agg_data(),
nib.load(curr_medial.R).agg_data(),
],
axis=1,
)
ret = data.copy()
ret[np.where(1 - curr_medial_data)] = np.nan
return ret
def _pv_update_settings(
panel_size, plotter_shape, scalars,
cmap, _vmin, _vmax, cbar_title,
lighting_style,
jupyter_backend,
plotter_kws, mesh_kws, cbar_kws, silhouette_kws
):
plotter_settings = {
"border": False,
"lighting": "three lights",
}
plotter_settings["window_size"] = (
panel_size[0] * plotter_shape[1],
panel_size[1] * plotter_shape[0]
)
if jupyter_backend is not None:
plotter_settings.update(dict(notebook=True, off_screen=True))
mesh_settings = {
"smooth_shading": True,
"show_scalar_bar": False,
}
mesh_settings.update(
dict(
scalars=scalars,
cmap=cmap,
clim=(_vmin, _vmax),
)
)
lighting_style_keys = [
"ambient",
"diffuse",
"specular",
"specular_power"
]
lighting_style_presets = {
"metallic": [0.1, 0.3, 1.0, 10],
"plastic": [0.3, 0.4, 0.3, 5],
"shiny": [0.2, 0.6, 0.8, 50],
"glossy": [0.1, 0.7, 0.9, 90],
"ambient": [0.8, 0.1, 0.0, 1],
"plain": [0.1, 1.0, 0.05, 5],
}
if lighting_style in ["default", "lightkit"]:
mesh_settings["lighting"] = "light kit"
elif lighting_style == "threelights":
mesh_settings["lighting"] = "three lights"
elif lighting_style in lighting_style_presets.keys():
mesh_settings.update(
{
k: v
for k, v in zip(
lighting_style_keys, lighting_style_presets[lighting_style]
)
}
)
mesh_settings["lighting"] = "light kit"
elif lighting_style == "none":
plotter_settings["lighting"] = "none"
mesh_settings["lighting"] = False
else:
raise ValueError(f"Unknown lighting style: {lighting_style}")
cbar_settings = dict(
title=cbar_title,
n_labels=2,
label_font_size=20,
title_font_size=24,
font_family="arial"
)
silhouette_settings = {}
if plotter_kws is not None:
plotter_settings.update(plotter_kws)
if mesh_kws is not None:
mesh_settings.update(mesh_kws)
if cbar_kws is not None:
cbar_settings.update(cbar_kws)
if silhouette_kws is not None:
silhouette_settings.update(silhouette_kws)
return plotter_settings, mesh_settings, cbar_settings, silhouette_settings
def _pv_get_plotter_shape(hemi, layout):
shapes = {
("default", "both"): (2, 2),
("default", "single_hemi"): (1, 2),
("single", "both"): (1, 1),
("single", "single_hemi"): (1, 1),
("row", "both"): (1, 4),
("row", "single_hemi"): (1, 2),
("column", "both"): (4, 1),
("column", "single_hemi"): (2, 1),
}
key = (layout, "both" if hemi == "both" else "single_hemi")
if key not in shapes:
raise ValueError(f"Unknown layout: {layout}")
return shapes[key]
def _pv_add_colorbar(
pl,
layout,
_vmin,
_vmax,
cmap,
cbar_settings
):
if layout == "default":
pl.subplot(1, 1)
elif layout == "row":
pl.subplot(0, 3)
elif layout == "column":
pl.subplot(3, 0)
elif layout == "single":
pl.subplot(0, 0)
_mesh = pv.PolyData(np.zeros((2, 3)))
_mesh['data'] = (_vmin, _vmax)
actor = pl.add_mesh(
_mesh, scalars=None, show_scalar_bar=False,
cmap=cmap, clim=(_vmin, _vmax)
)
actor.visibility = False
cbar = pl.add_scalar_bar(mapper=actor.mapper, **cbar_settings)
cbar.GetLabelTextProperty().SetItalic(True)
def _pv_save_fig(pl, save_fig):
_fname = Path(save_fig)
raster_formats = {".png", ".jpeg", ".jpg", ".bmp", ".tif", ".tiff"}
vector_formats = {".svg", ".eps", ".ps", ".pdf", ".tex"}
if _fname.suffix in raster_formats:
pl.screenshot(_fname, return_img=False)
elif _fname.suffix in vector_formats:
pl.save_graphic(_fname)
else:
raise ValueError(f"Unknown file format: {save_fig}")
[docs]
def pv_plot_surface(
vertex_data,
template,
surf="inflated",
hemi="both",
layout="default",
mask_medial=True,
cmap="viridis",
clim=None,
panel_size=(700, 500),
zoom_ratio=1.25,
show_colorbar=True,
show_silhouette=False,
cbar_title=None,
show_plot=True,
jupyter_backend="static",
lighting_style="default",
save_fig=None,
plotter_kws=None,
mesh_kws=None,
cbar_kws=None,
silhouette_kws=None,
data_dir=None,
verbose=0,
):
"""
Plot surface data using PyVista.
This function provides a flexible interface for visualizing cortical surface
data on standard neuroimaging templates. It supports multiple hemispheres,
layouts, lighting styles, and customization options.
Parameters
----------
vertex_data : array-like or tuple of array-like
Data array(s) to be plotted on the surface. If `hemi` is "both", this
should be a tuple of two arrays (left, right) or a single concatenated
array. For single hemisphere, provide a single array matching the number
of vertices in that hemisphere.
template : str
Template to use for plotting. Options include 'fsaverage', 'fsaverage6',
'fsaverage5', 'fsaverage4', 'fslr4k', 'fslr8k', 'fslr32k', 'fslr164k',
'civet41k', 'civet164k'.
surf : str, optional
Surface type to plot. Available options depend on template:
- fsaverage templates: 'midthickness', 'pial', 'white', 'inflated', 'sphere'
- fslr templates: 'midthickness', 'pial', 'white', 'inflated', 'veryinflated', 'sphere'
- civet templates: 'midthickness', 'white', 'inflated'
Default is 'inflated'.
hemi : str, optional
Hemisphere to plot. Options: 'L' (left), 'R' (right), 'both'.
Default is 'both'.
layout : str, optional
Layout of the plot panels:
- 'default': 2x2 grid for both hemispheres, 1x2 for single hemisphere
- 'single': Single panel (useful for custom views)
- 'row': Horizontal arrangement of all views
- 'column': Vertical arrangement of all views
Default is 'default'.
mask_medial : bool, optional
Whether to mask the medial wall (set to NaN). Only applies to templates
with medial wall annotations. Default is True.
cmap : str, optional
Matplotlib colormap name. Default is 'viridis'.
clim : tuple of float, optional
Colorbar limits as (vmin, vmax). If None, will be set to 2.5th and
97.5th percentiles of the data. Default is None.
panel_size : tuple of int, optional
Size of each panel in pixels as (width, height). Default is (700, 500).
zoom_ratio : float, optional
Camera zoom level. Values > 1.0 zoom in, < 1.0 zoom out.
Default is 1.25.
show_colorbar : bool, optional
Whether to display the colorbar. Default is True.
cbar_title : str, optional
Title text for the colorbar. Default is None.
show_plot : bool, optional
Whether to display the plot immediately. Set to False to return the
plotter object for further customization. Default is True.
jupyter_backend : str, optional
Backend for Jupyter notebook rendering. See `PyVista documentation
<https://docs.pyvista.org/user-guide/jupyter/index.html#pyvista.set_jupyter_backend>`_
for available options ('html', 'static', 'trame', etc.).
Set to None for non-notebook environments. Default is 'static'.
lighting_style : str, optional
Lighting style preset:
- 'default', 'lightkit': Standard three-point lighting
- 'threelights': Alternative three-light setup
- 'metallic', 'plastic', 'shiny', 'glossy': Material presets
- 'ambient', 'plain': Flat lighting styles
Default is 'default'.
save_fig : str or Path, optional
Path to save the figure. Supported formats: .png, .jpeg, .jpg, .bmp,
.tif, .tiff (raster); .svg, .eps, .ps, .pdf, .tex (vector).
Default is None (no save).
Returns
-------
pl : :class:`pyvista.Plotter`
PyVista plotter object. Can be further customized before calling
`pl.show()` if `show_plot=False`.
Other Parameters
----------------
plotter_kws : dict, optional
Additional keyword arguments to pass to
:class:`pyvista.Plotter`. Default is None.
mesh_kws : dict, optional
Additional keyword arguments to pass to
:meth:`pyvista.Plotter.add_mesh`. Default is None.
cbar_kws : dict, optional
Additional keyword arguments to pass to
:meth:`pyvista.Plotter.add_scalar_bar`. Default is None.
silhouette_kws : dict, optional
Additional keyword arguments to pass to
:meth:`pyvista.Plotter.add_silhouette`. Only used when
`lighting_style='silhouette'`. Default is None.
data_dir : str or Path, optional
Path to use as data directory. If not specified, will check for
environmental variable 'NNT_DATA'; if that is not set, will use
`~/nnt-data` instead. Default: None
verbose : int, optional
Modifies verbosity of download, where higher numbers mean more updates.
Default: 0
Notes
-----
**Template and surface compatibility:**
Not all surface types are available for all templates. The function will
automatically fetch the appropriate template data from neuromaps or use
locally cached data.
**Layouts:**
- 'default': Shows medial and lateral views for each hemisphere
- 'single': Shows only one view (useful for custom camera angles)
- 'row'/'column': Linear arrangements of all standard views
**Data format:**
The `vertex_data` array(s) must match the number of vertices in the surface
template. For example, fsaverage5 has 10,242 vertices per hemisphere.
When `hemi='both'`, vertex_data can be:
- A tuple/list: (left_data, right_data)
- A concatenated array: np.concatenate([left_data, right_data])
The function automatically handles data splitting based on template vertex
counts.
**Parcellated data:**
If you have parcellated/regional data rather than vertex-wise data, use
:func:`netneurotools.interface.parcels_to_vertices` to convert it to
vertex-level data before plotting. Always verify the data before and after
transformation to ensure correct mapping.
**Lighting styles:**
Different lighting presets affect surface appearance through ambient,
diffuse, specular, and specular_power parameters:
- Metallic: Low ambient (0.1), high specular (1.0)
- Plastic: Balanced properties, moderate specular (0.3)
- Shiny: High specular (0.8), high specular_power (50)
**Jupyter notebooks:**
When using in Jupyter, the function automatically sets `notebook=True` and
`off_screen=True` for proper rendering.
There can be various issues when plotting in Jupyter notebooks depending on
your environment. For troubleshooting and detailed configuration options, see:
- `Trame Jupyter Guide <https://kitware.github.io/trame/guide/jupyter/intro.html>`_
- `PyVista Jupyter Documentation
<https://docs.pyvista.org/user-guide/jupyter/>`_
**Backend selection:**
Choose the appropriate `jupyter_backend` for your use case:
- `'trame'`: Best performance and interactivity (recommended)
- `'html'`: Good interactivity, works in most environments
- `'static'`: No interactivity but reliable fallback option
If trame does not work in your environment, try html. The static option
should always work as a last resort.
**Customization with keyword arguments:**
The `plotter_kws`, `mesh_kws`, `cbar_kws`, and `silhouette_kws` parameters
allow flexible overriding of default settings. For example:
- `plotter_kws={'window_size': (2000, 1500)}` for higher resolution
- `mesh_kws={'smooth_shading': False}` to disable smooth shading
- `cbar_kws={'n_labels': 5}` for more colorbar labels
- `silhouette_kws={'feature_angle': 30}` to adjust edge detection sensitivity
Examples
--------
**Basic usage:**
Plot random data on fsaverage5 pial surface:
>>> from netneurotools.plotting import pv_plot_surface # doctest: +SKIP
>>> import numpy as np # doctest: +SKIP
>>> data_L = np.random.random((10242,)) # doctest: +SKIP
>>> data_R = np.random.random((10242,)) # doctest: +SKIP
>>> pl = pv_plot_surface( # doctest: +SKIP
... (data_L, data_R),
... template="fsaverage5",
... surf="pial"
... )
**Available template/surface combinations:**
>>> # fsaverage templates (all densities)
>>> templates = ["fsaverage", "fsaverage6", "fsaverage5", "fsaverage4"] # doctest: +SKIP
>>> surfaces = ["midthickness", "pial", "white", "inflated", "sphere"] # doctest: +SKIP
>>>
>>> # fslr templates
>>> templates = ["fslr4k", "fslr8k", "fslr32k", "fslr164k"] # doctest: +SKIP
>>> surfaces = ["midthickness", "pial", "white", "inflated", # doctest: +SKIP
... "veryinflated", "sphere"]
>>>
>>> # civet templates
>>> templates = ["civet41k", "civet164k"] # doctest: +SKIP
>>> surfaces = ["midthickness", "white", "inflated"] # doctest: +SKIP
**Different layouts:**
Compare all layout options:
>>> for layout in ["default", "single", "row", "column"]: # doctest: +SKIP
... pl = pv_plot_surface(
... (data_L, data_R),
... template="fsaverage5",
... surf="inflated",
... layout=layout,
... cbar_title=f"Layout: {layout}"
... )
**Adjusting zoom:**
Control the camera zoom level:
>>> pl = pv_plot_surface( # doctest: +SKIP
... (data_L, data_R),
... template="fsaverage5",
... surf="inflated",
... zoom_ratio=1.7, # Closer view
... )
**Colorbar control:**
Customize colorbar display and title:
>>> pl = pv_plot_surface( # doctest: +SKIP
... (data_L, data_R),
... template="fsaverage5",
... surf="inflated",
... show_colorbar=True,
... cbar_title="Activation (z-score)",
... )
Hide the colorbar:
>>> pl = pv_plot_surface( # doctest: +SKIP
... (data_L, data_R),
... template="fsaverage5",
... surf="inflated",
... show_colorbar=False,
... )
**Colormap and limits:**
Use different colormaps and set explicit color limits:
>>> pl = pv_plot_surface( # doctest: +SKIP
... (data_L, data_R),
... template="fsaverage5",
... surf="inflated",
... cmap="RdBu_r", # Reverse red-blue colormap
... clim=(-3, 3), # Symmetric limits
... )
Sequential colormap for positive-only data:
>>> pl = pv_plot_surface( # doctest: +SKIP
... (data_L, data_R),
... template="fsaverage5",
... surf="inflated",
... cmap="plasma",
... clim=(0, 1),
... )
**Saving figures:**
Save as high-resolution PNG:
>>> pl = pv_plot_surface( # doctest: +SKIP
... (data_L, data_R),
... template="fsaverage5",
... surf="inflated",
... save_fig="brain_plot.png",
... )
Save as vector graphics (SVG):
>>> pl = pv_plot_surface( # doctest: +SKIP
... (data_L, data_R),
... template="fsaverage5",
... surf="inflated",
... save_fig="brain_plot.svg",
... )
**Lighting styles:**
Explore different lighting presets:
>>> for style in ["metallic", "plastic", "shiny", "glossy"]: # doctest: +SKIP
... pl = pv_plot_surface(
... (data_L, data_R),
... template="fsaverage5",
... surf="inflated",
... lighting_style=style,
... cbar_title=f"Style: {style}",
... save_fig=f"brain_{style}.png",
... )
Silhouette style for presentations (often needs tuning):
>>> pl = pv_plot_surface( # doctest: +SKIP
... (data_L, data_R),
... template="fsaverage5",
... surf="inflated",
... show_silhouette=True, # High-contrast edges
... cmap="coolwarm",
... silhouette_kws={'feature_angle': 30, 'color': 'black'},
... )
**Customizing with keyword arguments:**
Override default settings for higher resolution figures:
>>> pl = pv_plot_surface( # doctest: +SKIP
... (data_L, data_R),
... template="fsaverage5",
... surf="inflated",
... plotter_kws={'window_size': (2000, 1500)}, # Higher resolution
... )
Disable smooth shading for sharper vertex transitions:
>>> pl = pv_plot_surface( # doctest: +SKIP
... (data_L, data_R),
... template="fsaverage5",
... surf="inflated",
... mesh_kws={'smooth_shading': False},
... )
**Advanced customization:**
Combine multiple options:
>>> pl = pv_plot_surface( # doctest: +SKIP
... (data_L, data_R),
... template="fslr32k",
... surf="veryinflated",
... layout="row",
... mask_medial=True,
... cmap="viridis",
... clim=(0.2, 0.8),
... zoom_ratio=1.5,
... show_colorbar=True,
... cbar_title="Correlation",
... lighting_style="shiny",
... save_fig="publication_figure.png",
... jupyter_backend=None, # For script usage
... )
Use plotter object for further customization:
>>> pl = pv_plot_surface( # doctest: +SKIP
... (data_L, data_R),
... template="fsaverage5",
... surf="inflated",
... show_plot=False, # Don't show yet
... )
>>> # Add custom annotations, lights, etc.
>>> pl.add_text("Custom Title", position="upper_edge") # doctest: +SKIP
>>> pl.show() # doctest: +SKIP
""" # noqa: E501
if not _has_pyvista:
raise ImportError("PyVista is required for this function")
if hemi not in ["L", "R", "both"]:
raise ValueError(f"Unknown hemi: {hemi}")
# Prepare and validate surface data for both or single hemisphere
if hemi == "both": # Process both hemispheres
surf_pair = _pv_load_surface(
template=template, surf=surf, data_dir=data_dir, verbose=verbose
)
if len(vertex_data) == 2: # Input is tuple/list of (left_data, right_data)
# Validate data length matches number of vertices for each hemisphere
if not all(len(vertex_data[i]) == surf_pair[i].n_points for i in range(2)):
raise ValueError("Data length mismatch")
else: # Input is single concatenated array
# Validate total data length matches combined vertex count
if len(vertex_data) != surf_pair[0].n_points + surf_pair[1].n_points:
raise ValueError("Data length mismatch")
# Split concatenated array into left and right hemispheres
vertex_data = (
vertex_data[: surf_pair[0].n_points],
vertex_data[surf_pair[0].n_points :],
)
if mask_medial:
vertex_data = _mask_medial_wall(
vertex_data, template, hemi=None, data_dir=data_dir, verbose=verbose
)
surf_pair[0].point_data["vertex_data"] = vertex_data[0]
surf_pair[1].point_data["vertex_data"] = vertex_data[1]
else:
# Process single hemisphere with validation
surf = _pv_load_surface(
template=template, surf=surf, hemi=hemi, data_dir=data_dir, verbose=verbose
)
if len(vertex_data) != surf.n_points:
raise ValueError("Data length mismatch")
if mask_medial:
vertex_data = _mask_medial_wall(
vertex_data, template, hemi=hemi, data_dir=data_dir, verbose=verbose
)
surf.point_data["vertex_data"] = vertex_data
# Determine grid layout based on number of hemispheres and layout preference
plotter_shape = _pv_get_plotter_shape(hemi, layout)
# Set colorbar scale: use provided limits or calculate from data percentiles
if clim is not None:
_vmin, _vmax = clim
else:
if len(vertex_data) == 2:
_values = np.r_[vertex_data[0], vertex_data[1]]
else:
_values = vertex_data
# Use 2.5th and 97.5th percentiles to handle outliers
_vmin, _vmax = np.nanpercentile(_values, [2.5, 97.5])
plotter_settings, mesh_settings, cbar_settings, silhouette_settings = \
_pv_update_settings(
panel_size=panel_size,
plotter_shape=plotter_shape,
scalars="vertex_data",
cmap=cmap,
_vmin=_vmin,
_vmax=_vmax,
cbar_title=cbar_title,
lighting_style=lighting_style,
jupyter_backend=jupyter_backend,
plotter_kws=plotter_kws,
mesh_kws=mesh_kws,
cbar_kws=cbar_kws,
silhouette_kws=silhouette_kws
)
pl = pv.Plotter(shape=plotter_shape, **plotter_settings)
if layout == "single": # Single panel view
if hemi == "both":
_surf = surf_pair[0]
_view_flip = True
else:
_surf = surf
if hemi == "L":
_view_flip = True
else:
_view_flip = False
pl.subplot(0, 0)
pl.add_mesh(_surf, **mesh_settings)
pl.view_yz(negative=_view_flip)
pl.zoom_camera(zoom_ratio)
if show_silhouette:
pl.add_silhouette(_surf, **silhouette_settings)
else: # Multi-panel layout with multiple views
if hemi == "both": # Display 4 panels: 2 views per hemisphere
if layout == "default":
_pos = [(0, 0), (0, 1), (1, 0), (1, 1)]
elif layout == "row":
_pos = [(0, 0), (0, 3), (0, 1), (0, 2)]
elif layout == "column":
_pos = [(0, 0), (2, 0), (1, 0), (3, 0)]
else:
raise ValueError(f"Unknown layout: {layout}")
_surf_list = [
surf_pair[0], surf_pair[1], surf_pair[0], surf_pair[1]
]
_view_flip_list = [True, False, False, True]
for _xy, _surf, _view_flip in zip(_pos, _surf_list, _view_flip_list):
pl.subplot(*_xy)
pl.add_mesh(_surf, **mesh_settings)
pl.view_yz(negative=_view_flip)
pl.zoom_camera(zoom_ratio)
if show_silhouette:
pl.add_silhouette(_surf, **silhouette_settings)
else: # Display 2 panels: medial and lateral views of single hemisphere
if layout == "default":
_pos = [(0, 0), (0, 1)]
elif layout == "row":
_pos = [(0, 0), (0, 1)]
elif layout == "column":
_pos = [(0, 0), (1, 0)]
else:
raise ValueError(f"Unknown layout: {layout}")
_surf_list = [surf, surf]
if hemi == "L":
_view_flip_list = [True, False]
else:
_view_flip_list = [False, True]
for _xy, _surf, _view_flip in zip(_pos, _surf_list, _view_flip_list):
pl.subplot(*_xy)
pl.add_mesh(_surf, **mesh_settings)
pl.view_yz(negative=_view_flip)
pl.zoom_camera(zoom_ratio)
if show_silhouette:
pl.add_silhouette(_surf, **silhouette_settings)
if show_colorbar:
_pv_add_colorbar(
pl=pl,
layout=layout,
_vmin=_vmin,
_vmax=_vmax,
cmap=cmap,
cbar_settings=cbar_settings
)
if show_plot:
if jupyter_backend is not None:
pl.show(jupyter_backend=jupyter_backend)
else:
pl.show()
if save_fig is not None:
_pv_save_fig(pl, save_fig)
return pl
[docs]
def _pv_make_subcortex_surfaces(
atlas, include_keys, custom_params=None
):
"""
Generate surface meshes from volumetric subcortical atlas data.
This function converts volumetric segmentation data (e.g., NIfTI files) into
3D surface meshes suitable for visualization with PyVista. It uses a pipeline
of Gaussian smoothing, marching cubes algorithm, and Taubin smoothing to
create high-quality surface representations.
.. warning::
This function is considered experimental and may be subject to changes anytime.
Parameters
----------
atlas : str or Path
Path to the volumetric atlas file (e.g., NIfTI format) containing
integer-labeled regions.
include_keys : list of int
List of integer region identifiers to convert to surface meshes.
These should match the integer labels in the atlas file.
custom_params : dict, optional
Custom parameters to override defaults for the meshification process.
Can contain:
- 'gaussian_filter': dict with 'sigma' (float) and 'threshold' (float)
- 'taubin_smoothing': dict with 'lamb' (float), 'nu' (float), and
'iterations' (int)
Default is None.
Returns
-------
multiblock : :class:`pyvista.MultiBlock`
PyVista MultiBlock object containing surface meshes for each region.
Keys are string versions of the input region identifiers.
Notes
-----
**Meshification pipeline:**
The function converts volumetric data to surface meshes through three steps:
1. **Gaussian smoothing**: Applies a 3D Gaussian filter to smooth the binary
mask for each region. The `sigma` parameter controls the smoothing strength.
Higher values create smoother, more rounded surfaces but may lose fine
details. Lower values preserve details but may result in blocky surfaces.
2. **Marching cubes**: Extracts an isosurface from the smoothed volume using
the marching cubes algorithm. The `threshold` parameter determines the
isosurface level. Values around 0.5 work well for binary masks after
Gaussian smoothing.
3. **Taubin smoothing**: Applies mesh smoothing to reduce surface artifacts
while preserving volume. The `lamb` parameter controls the amount of
smoothing (positive shrinking step), `nu` controls the inverse step
(negative expansion), and `iterations` determines how many smoothing
passes to apply. More iterations create smoother surfaces but may
over-smooth fine features.
**Default parameters:**
- Gaussian filter: sigma=1.0, threshold=0.5
- Taubin smoothing: lamb=0.75, nu=0.6, iterations=100
These defaults work well for standard 1mm isotropic MNI space atlases.
Adjust parameters based on your atlas resolution and desired smoothness.
Examples
--------
**Basic usage:**
Generate surfaces for specific regions from a FreeSurfer aseg atlas:
>>> from netneurotools.plotting import _pv_make_subcortex_surfaces # doctest: +SKIP
>>> surfaces = _pv_make_subcortex_surfaces( # doctest: +SKIP
... atlas="/path/to/aseg.nii.gz",
... include_keys=[10, 11, 12, 13], # thalamus, caudate, putamen, pallidum
... )
**Custom smoothing parameters:**
Create smoother surfaces by increasing Gaussian sigma:
>>> custom_params = { # doctest: +SKIP
... "gaussian_filter": {"sigma": 1.5, "threshold": 0.5},
... "taubin_smoothing": {"lamb": 0.75, "nu": 0.6, "iterations": 100}
... }
>>> surfaces = _pv_make_subcortex_surfaces( # doctest: +SKIP
... atlas="/path/to/atlas.nii.gz",
... include_keys=[1, 2, 3],
... custom_params=custom_params
... )
**Access individual surfaces:**
The returned MultiBlock can be indexed by string keys:
>>> surfaces = _pv_make_subcortex_surfaces( # doctest: +SKIP
... atlas="/path/to/atlas.nii.gz",
... include_keys=[10, 11]
... )
>>> thalamus_mesh = surfaces['10'] # doctest: +SKIP
>>> caudate_mesh = surfaces['11'] # doctest: +SKIP
**Saving generated surfaces:**
The MultiBlock object can be saved to a file for later use:
>>> surfaces.save("subcortex_surfaces.vtm") # doctest: +SKIP
Note that the .vtm format preserves the MultiBlock structure.
It consists of an XML file (.vtm) and associated vtk mesh files (.vtp)
stored in a folder with the same name as the .vtm file.
To load the saved surfaces later:
>>> import pyvista as pv # doctest: +SKIP
>>> loaded_surfaces = pv.read("subcortex_surfaces.vtm") # doctest: +SKIP
**Use with pv_plot_subcortex:**
Generate custom surfaces and visualize them:
>>> from netneurotools.plotting import pv_plot_subcortex # doctest: +SKIP
>>> surfaces = _pv_make_subcortex_surfaces( # doctest: +SKIP
... atlas="/path/to/custom_atlas.nii.gz",
... include_keys=['1', '2', '3', '4']
... )
>>> parcel_data = {'1': 0.5, '2': 0.7, '3': 0.6, '4': 0.8} # doctest: +SKIP
>>> pl = pv_plot_subcortex( # doctest: +SKIP
... parcel_data,
... template="custom",
... custom_surfaces=surfaces
... )
""" # noqa: E501
try:
from trimesh.voxel.ops import matrix_to_marching_cubes
from trimesh.smoothing import filter_taubin
from scipy.ndimage import gaussian_filter
except ImportError:
raise ImportError(
"trimesh is required for this function"
) from None
default_params = {
"gaussian_filter": {
"sigma": 1.0,
"threshold": 0.5,
},
"taubin_smoothing": {
"lamb": 0.75,
"nu": 0.6,
"iterations": 100,
}
}
default_params.update(custom_params or {})
atlas_fdata = nib.load(atlas).get_fdata()
def region_to_mesh(mask):
return filter_taubin(
matrix_to_marching_cubes(
gaussian_filter(
mask.astype(float),
sigma=default_params["gaussian_filter"]["sigma"]
) > default_params["gaussian_filter"]["threshold"]
),
lamb=default_params["taubin_smoothing"]["lamb"],
nu=default_params["taubin_smoothing"]["nu"],
iterations=default_params["taubin_smoothing"]["iterations"]
)
# Note: input include_keys are integers from the atlas volume, but output keys
# are converted to strings for PyVista MultiBlock compatibility and consistent
# dictionary access patterns across different template formats
multiblock = pv.MultiBlock()
for k in include_keys:
multiblock[str(k)] = pv.wrap(region_to_mesh(atlas_fdata == int(k)))
return multiblock
def _pv_load_subcortex_surfaces(
template, include_keys, force_fetch=False, data_dir=None, verbose=0
):
# Load pre-computed subcortical surface meshes from cache or download if needed
if template in ["aseg", "tianS1", "tianS2", "tianS3", "tianS4"]:
surf_vtm = _fetch_subcortex_surface(
force=force_fetch, data_dir=data_dir, verbose=verbose
)[template]
else:
raise ValueError(f"Unknown template: {template}")
multiblock = pv.read(surf_vtm)
meshes = [multiblock[name] for name in include_keys]
return meshes
def pv_plot_subcortex(
parcel_data,
template,
include_keys=None,
custom_surfaces=None,
hemi="both",
layout="default",
cmap="viridis",
clim=None,
panel_size=(500, 400),
zoom_ratio=1.4,
show_colorbar=True,
show_silhouette=False,
parallel_projection=True,
cbar_title=None,
show_plot=True,
jupyter_backend="static",
lighting_style="default",
save_fig=None,
plotter_kws=None,
mesh_kws=None,
cbar_kws=None,
silhouette_kws=None,
force_fetch=False,
data_dir=None,
verbose=0,
):
"""
Plot subcortical data using PyVista.
This function provides a flexible interface for visualizing parcellated
subcortical data on standard neuroimaging atlases. It supports custom
surfaces, multiple layouts, and various customization options for
publication-quality visualizations.
Parameters
----------
parcel_data : dict
Dictionary mapping region identifiers to data values. Keys should match
the region identifiers in the selected template atlas (e.g., '10' for
a specific region in aseg template).
template : str
Atlas template to use for subcortical visualization. A pre-computed
subcortical surface or a `custom` surface can be used. Options:
- 'aseg': FreeSurfer automatic segmentation
- 'tianS1', 'tianS2', 'tianS3', 'tianS4': Tian et al. subcortical atlas
- 'custom': User-provided custom surfaces (requires `custom_surfaces`)
include_keys : list or tuple of lists, optional
Region identifiers to include in the visualization. If `hemi` is "both",
this should be a tuple of two lists (left, right). If None, will use all
keys from `parcel_data`. Default is None.
custom_surfaces : dict, optional
Dictionary mapping region identifiers (as strings) to PyVista mesh objects.
Only used when `template='custom'`. Should be generated with
:func:`_pv_make_subcortex_surfaces` or similar. Default is None.
hemi : str, optional
Hemisphere to plot. Options: 'L' (left), 'R' (right), 'both'.
Default is 'both'.
layout : str, optional
Layout of the plot panels:
- 'default': 2x2 grid for both hemispheres, 1x2 for single hemisphere
- 'single': Single panel (useful for custom views)
- 'row': Horizontal arrangement of all views
- 'column': Vertical arrangement of all views
Default is 'default'.
cmap : str, optional
Matplotlib colormap name. Default is 'viridis'.
clim : tuple of float, optional
Colorbar limits as (vmin, vmax). If None, will be set to 2.5th and
97.5th percentiles of the data. Default is None.
panel_size : tuple of int, optional
Size of each panel in pixels as (width, height). Default is (500, 400).
zoom_ratio : float, optional
Camera zoom level. Values > 1.0 zoom in, < 1.0 zoom out.
Default is 1.4.
show_colorbar : bool, optional
Whether to display the colorbar. Default is True.
show_silhouette : bool, optional
Whether to add silhouette edges to the meshes for enhanced visibility.
Default is False.
parallel_projection : bool, optional
Whether to use parallel projection for the camera. Default is True.
cbar_title : str, optional
Title text for the colorbar. Default is None.
show_plot : bool, optional
Whether to display the plot immediately. Set to False to return the
plotter object for further customization. Default is True.
jupyter_backend : str, optional
Backend for Jupyter notebook rendering. See `PyVista documentation
<https://docs.pyvista.org/user-guide/jupyter/index.html#pyvista.set_jupyter_backend>`_
for available options ('html', 'static', 'trame', etc.).
Set to None for non-notebook environments. Default is 'static'.
lighting_style : str, optional
Lighting style preset:
- 'default', 'lightkit': Standard three-point lighting
- 'threelights': Alternative three-light setup
- 'metallic', 'plastic', 'shiny', 'glossy': Material presets
- 'ambient', 'plain': Flat lighting styles
Default is 'default'.
save_fig : str or Path, optional
Path to save the figure. Supported formats: .png, .jpeg, .jpg, .bmp,
.tif, .tiff (raster); .svg, .eps, .ps, .pdf, .tex (vector).
Default is None (no save).
Returns
-------
pl : :class:`pyvista.Plotter`
PyVista plotter object. Can be further customized before calling
`pl.show()` if `show_plot=False`.
Other Parameters
----------------
plotter_kws : dict, optional
Additional keyword arguments to pass to
:class:`pyvista.Plotter`. Default is None.
mesh_kws : dict, optional
Additional keyword arguments to pass to
:meth:`pyvista.Plotter.add_mesh`. Default is None.
cbar_kws : dict, optional
Additional keyword arguments to pass to
:meth:`pyvista.Plotter.add_scalar_bar`. Default is None.
silhouette_kws : dict, optional
Additional keyword arguments to pass to
:meth:`pyvista.Plotter.add_silhouette`. Only used when
`show_silhouette=True`. Default is None.
force_fetch : bool, optional
If True, will re-download template data even if cached locally.
Recommended to use periodically to refresh data. Default is False.
data_dir : str or Path, optional
Path to use as data directory. If not specified, will check for
environmental variable 'NNT_DATA'; if that is not set, will use
`~/nnt-data` instead. Default: None
verbose : int, optional
Modifies verbosity of download, where higher numbers mean more updates.
Default: 0
Notes
-----
**Available templates:**
- 'aseg': FreeSurfer's automatic brain segmentation, includes major
subcortical structures (thalamus, striatum, hippocampus, etc.)
Generated from ``tpl-MNI152NLin2009cAsym_res-01_seg-aseg_dseg.nii.gz``
from TemplateFlow. See `FreeSurferColorLUT
<https://surfer.nmr.mgh.harvard.edu/fswiki/FsTutorial/AnatomicalROI/FreeSurferColorLUT>`_
for region IDs.
- 'tianS1-S4': Multi-level atlases from Tian et al. [1]_ providing finer
subdivisions of subcortical structures. Generated from
``Group-Parcellation/3T/Subcortex-Only/Tian_Subcortex_S{1,2,3,4}_3T_2009cAsym.nii.gz``.
- 'custom': User-provided atlas generated from volumetric data using
:func:`_pv_make_subcortex_surfaces`
**Template data updates:**
Subcortical surface templates are periodically updated to add new atlases
and optimize existing surface quality. To ensure you have the latest
versions, it's recommended to set ``force_fetch=True`` occasionally to
re-download and refresh your local cached data. This is especially important
when:
- New atlas templates are announced
- Surface quality improvements are released
- You encounter rendering issues with cached surfaces
- Starting a new publication project
**Data format:**
The `parcel_data` dictionary maps region identifiers to scalar values:
>>> parcel_data = { # doctest: +SKIP
... '10': 0.5, # thalamus
... '11': 0.7, # caudate
... '12': 0.6, # putamen
... }
Region identifiers depend on the selected template (integer IDs from
FreeSurfer, Tian atlas, etc.).
There is no intrinsic left/right distinction in subcortical atlases, so
`include_keys` must specify which regions to plot for each hemisphere.
For example, this is usually how you would set `include_keys`, when you want
left and right structures plotted in their respective hemisphere panels.
>>> include_keys = (['10', '11', '12'], ['49', '50', '51']) # doctest: +SKIP
In contrast, the following will display BOTH left and right structures
in BOTH hemisphere panels:
>>> include_keys = (['10', '11', '12', '49', '50', '51']) # doctest: +SKIP
**Layouts:**
- 'default': Shows medial and lateral views for each hemisphere
- 'single': Shows only one view (useful for custom camera angles)
- 'row'/'column': Linear arrangements of all standard views
**Parallel projection:**
When comparing subcortical structures of very different sizes (e.g.,
thalamus vs. amygdala), `parallel_projection=True` provides scale-invariant
visualization, while `parallel_projection=False` uses perspective projection.
**Lighting styles:**
Different lighting presets affect surface appearance through ambient,
diffuse, specular, and specular_power parameters:
- Metallic: Low ambient (0.1), high specular (1.0)
- Plastic: Balanced properties, moderate specular (0.3)
- Shiny: High specular (0.8), high specular_power (50)
**Jupyter notebooks:**
When using in Jupyter, the function automatically sets `notebook=True` and
`off_screen=True` for proper rendering.
There can be various issues when plotting in Jupyter notebooks depending on
your environment. For troubleshooting and detailed configuration options, see:
- `Trame Jupyter Guide <https://kitware.github.io/trame/guide/jupyter/intro.html>`_
- `PyVista Jupyter Documentation
<https://docs.pyvista.org/user-guide/jupyter/>`_
**Backend selection:**
Choose the appropriate `jupyter_backend` for your use case:
- `'trame'`: Best performance and interactivity (recommended)
- `'html'`: Good interactivity, works in most environments
- `'static'`: No interactivity but reliable fallback option
If trame does not work in your environment, try html. The static option
should always work as a last resort.
**Customization with keyword arguments:**
The `plotter_kws`, `mesh_kws`, `cbar_kws`, and `silhouette_kws` parameters
allow flexible overriding of default settings. For example:
- `plotter_kws={'window_size': (2000, 1500)}` for higher resolution
- `mesh_kws={'ambient': 0.5}` to adjust material properties
- `cbar_kws={'n_labels': 5}` for more colorbar labels
- `silhouette_kws={'feature_angle': 30}` to adjust edge detection sensitivity
References
----------
.. [1] Tian, Y., Margulies, D. S., Breakspear, M., & Zalesky, A. (2020).
Topographic organization of the human subcortex unveiled with
functional connectivity gradients. Nature neuroscience, 23(11),
1421-1432.
Examples
--------
**Basic usage:**
Plot random data on aseg template:
>>> from netneurotools.plotting import pv_plot_subcortex # doctest: +SKIP
>>> parcel_data = { # doctest: +SKIP
... '10': 0.5, '11': 0.7, '12': 0.6, '13': 0.8,
... '49': 0.4, '50': 0.6, '51': 0.5, '52': 0.7
... }
>>> pl = pv_plot_subcortex( # doctest: +SKIP
... parcel_data,
... template="aseg"
... include_keys=(['10', '11', '12', '13'], ['49', '50', '51', '52'])
... )
**Different atlases:**
Use Tian atlas with finer subcortical subdivisions:
>>> parcel_data_tian = {str(k+1): v for k, v in enumerate( # doctest: +SKIP
... np.random.random(16)
... )}
>>> pl = pv_plot_subcortex( # doctest: +SKIP
... parcel_data_tian,
... template="tianS2" # Tian level 2 atlas
... )
**Single hemisphere:**
Plot only left hemisphere with custom include_keys:
>>> include_keys = ['10', '11', '12'] # thalamus, caudate, putamen # doctest: +SKIP
>>> pl = pv_plot_subcortex( # doctest: +SKIP
... parcel_data,
... template="aseg",
... hemi="L",
... include_keys=include_keys,
... )
**Different layouts:**
Compare all layout options:
>>> for layout in ["default", "single", "row", "column"]: # doctest: +SKIP
... pl = pv_plot_subcortex(
... parcel_data,
... template="aseg",
... layout=layout,
... cbar_title=f"Layout: {layout}"
... )
**Custom colorbar and limits:**
Set explicit color limits and colorbar title:
>>> pl = pv_plot_subcortex( # doctest: +SKIP
... parcel_data,
... template="aseg",
... cmap="RdBu_r",
... clim=(-1, 1), # Symmetric limits
... cbar_title="Activation (z-score)",
... )
**Silhouette edges:**
Add edge outlines for clarity, for example, this
simulates the 2D flat drawing style:
>>> pl = pv_plot_subcortex( # doctest: +SKIP
... parcel_data,
... template="aseg",
... lighting_style="none",
... show_silhouette=True,
... silhouette_kws={'color': 'black', 'line_width': 5},
... )
**Saving figures:**
Save as high-resolution PNG:
>>> pl = pv_plot_subcortex( # doctest: +SKIP
... parcel_data,
... template="aseg",
... save_fig="subcortex_plot.png",
... )
Save as vector graphics (SVG):
>>> pl = pv_plot_subcortex( # doctest: +SKIP
... parcel_data,
... template="aseg",
... save_fig="subcortex_plot.svg",
... )
**Lighting styles:**
Explore different lighting presets:
>>> for style in ["metallic", "plastic", "shiny", "glossy"]: # doctest: +SKIP
... pl = pv_plot_subcortex(
... parcel_data,
... template="aseg",
... lighting_style=style,
... cbar_title=f"Style: {style}",
... save_fig=f"subcortex_{style}.png",
... )
**Advanced customization:**
Combine multiple options for publication-quality figures:
>>> pl = pv_plot_subcortex( # doctest: +SKIP
... parcel_data,
... template="tianS2",
... hemi="both",
... layout="row",
... cmap="plasma",
... clim=(0, 1),
... zoom_ratio=1.6,
... show_colorbar=True,
... cbar_title="Regional Connectivity",
... lighting_style="shiny",
... parallel_projection=True,
... save_fig="publication_figure.png",
... jupyter_backend=None, # For script usage
... )
**Custom surfaces:**
Use user-defined subcortical surfaces generated from volumetric data:
>>> from netneurotools.plotting import _pv_make_subcortex_surfaces # doctest: +SKIP
>>> # Assuming atlas.nii.gz contains custom volumetric segmentation
>>> custom_surfs = _pv_make_subcortex_surfaces( # doctest: +SKIP
... "atlas.nii.gz",
... include_keys=[1, 2, 3]
... )
>>> pl = pv_plot_subcortex( # doctest: +SKIP
... parcel_data,
... template="custom",
... custom_surfaces=custom_surfs,
... )
**Plotter customization for further manipulation:**
Return plotter object without showing to add custom elements:
>>> pl = pv_plot_subcortex( # doctest: +SKIP
... parcel_data,
... template="aseg",
... show_plot=False, # Don't show yet
... )
>>> # Add custom annotations
>>> pl.add_text("Custom Title", position="upper_edge") # doctest: +SKIP
>>> pl.show() # doctest: +SKIP
""" # noqa: E501
if not _has_pyvista:
raise ImportError("PyVista is required for this function")
if hemi not in ["L", "R", "both"]:
raise ValueError(f"Unknown hemi: {hemi}")
if include_keys is None:
# If no specific regions selected, use all regions from parcel_data
keys_list = list(parcel_data.keys())
include_keys = (keys_list, keys_list) if hemi == "both" else keys_list
if template in ["aseg", "tianS1", "tianS2", "tianS3", "tianS4"]:
if hemi == "both":
surf_pair = (
_pv_load_subcortex_surfaces(
template, include_keys[0], force_fetch, data_dir, verbose),
_pv_load_subcortex_surfaces(
template, include_keys[1], force_fetch, data_dir, verbose)
)
else:
surf = _pv_load_subcortex_surfaces(
template, include_keys, force_fetch, data_dir, verbose)
elif template == "custom":
if custom_surfaces is None:
raise ValueError(
"Must provide custom_surfaces for template='custom'. "
"Recommended to provide a PyVista MultiBlock object or dict "
"of {key: pyvista mesh} generated with _pv_make_subcortex_surfaces()"
)
if hemi == "both":
surf_pair = (
[custom_surfaces[k] for k in include_keys[0]],
[custom_surfaces[k] for k in include_keys[1]],
)
else:
surf = [custom_surfaces[k] for k in include_keys]
else:
raise ValueError(f"Unknown template: {template}")
# Determine grid layout based on hemisphere and layout preference
plotter_shape = _pv_get_plotter_shape(hemi, layout)
# Extract parcel values and compute color scale
# Determine color limits: use provided clim or calculate from data percentiles
if hemi == "both":
surf_data_pair = (
np.array([parcel_data[str(k)] for k in include_keys[0]]),
np.array([parcel_data[str(k)] for k in include_keys[1]])
)
_values = np.r_[surf_data_pair[0], surf_data_pair[1]]
else:
surf_data = np.array([parcel_data[str(k)] for k in include_keys])
_values = surf_data
if clim is not None:
_vmin, _vmax = clim
else:
_vmin, _vmax = np.nanpercentile(_values, [2.5, 97.5])
cmap = mpl.colormaps[cmap]
cnorm = mcolors.Normalize(vmin=_vmin, vmax=_vmax)
# Map normalized data values to RGBA colors and extract RGB components
def _apply_colormap(arr):
return [cmap(cnorm(val))[:3] for val in arr]
if hemi == "both":
surf_colors_pair = (
_apply_colormap(surf_data_pair[0]),
_apply_colormap(surf_data_pair[1]),
)
else:
surf_colors = _apply_colormap(surf_data)
plotter_settings, mesh_settings, cbar_settings, silhouette_settings = \
_pv_update_settings(
panel_size=panel_size,
plotter_shape=plotter_shape,
scalars=None,
cmap=None,
_vmin=_vmin,
_vmax=_vmax,
cbar_title=cbar_title,
lighting_style=lighting_style,
jupyter_backend=jupyter_backend,
plotter_kws=plotter_kws,
mesh_kws=mesh_kws,
cbar_kws=cbar_kws,
silhouette_kws=silhouette_kws
)
pl = pv.Plotter(shape=plotter_shape, **plotter_settings)
if layout == "single": # Single panel view
if hemi == "both":
_surf = surf_pair[0]
_color = surf_colors_pair[0]
_view_flip = True
else:
_surf = surf
_color = surf_colors
if hemi == "L":
_view_flip = True
else:
_view_flip = False
pl.subplot(0, 0)
for _s, _c in zip(_surf, _color):
pl.add_mesh(_s, color=_c, **mesh_settings)
if show_silhouette:
pl.add_silhouette(_s, **silhouette_settings)
pl.view_yz(negative=_view_flip)
pl.zoom_camera(zoom_ratio)
if parallel_projection:
pl.enable_parallel_projection()
else: # Multi-panel layout with multiple views
if hemi == "both": # Display 4 panels: 2 views per hemisphere
if layout == "default":
_pos = [(0, 0), (1, 0), (1, 1), (0, 1)]
elif layout == "row":
_pos = [(0, 0), (0, 1), (0, 2), (0, 3)]
elif layout == "column":
_pos = [(0, 0), (1, 0), (2, 0), (3, 0)]
else:
raise ValueError(f"Unknown layout: {layout}")
_surf_list = [
surf_pair[0], surf_pair[0], surf_pair[1], surf_pair[1]
]
_color_list = [
surf_colors_pair[0],
surf_colors_pair[0],
surf_colors_pair[1],
surf_colors_pair[1]
]
_view_flip_list = [True, False, True, False]
for _xy, _surf, _color, _view_flip in zip(
_pos, _surf_list, _color_list, _view_flip_list
):
pl.subplot(*_xy)
for _s, _c in zip(_surf, _color):
pl.add_mesh(_s, color=_c, **mesh_settings)
if show_silhouette:
pl.add_silhouette(_s, **silhouette_settings)
pl.view_yz(negative=_view_flip)
pl.zoom_camera(zoom_ratio)
if parallel_projection:
pl.enable_parallel_projection()
else: # Display 2 panels: medial and lateral views of single hemisphere
if layout == "default":
_pos = [(0, 0), (0, 1)]
elif layout == "row":
_pos = [(0, 0), (0, 1)]
elif layout == "column":
_pos = [(0, 0), (1, 0)]
else:
raise ValueError(f"Unknown layout: {layout}")
_surf_list = [surf, surf]
_color_list = [surf_colors, surf_colors]
if hemi == "L":
_view_flip_list = [True, False]
else:
_view_flip_list = [False, True]
for _xy, _surf, _color, _view_flip in zip(
_pos, _surf_list, _color_list, _view_flip_list
):
pl.subplot(*_xy)
for _s, _c in zip(_surf, _color):
pl.add_mesh(_s, color=_c, **mesh_settings)
if show_silhouette:
pl.add_silhouette(_s, **silhouette_settings)
pl.view_yz(negative=_view_flip)
pl.zoom_camera(zoom_ratio)
if parallel_projection:
pl.enable_parallel_projection()
if show_colorbar:
_pv_add_colorbar(
pl=pl,
layout=layout,
_vmin=_vmin,
_vmax=_vmax,
cmap=cmap,
cbar_settings=cbar_settings
)
if show_plot:
if jupyter_backend is not None:
pl.show(jupyter_backend=jupyter_backend)
else:
pl.show()
if save_fig is not None:
_pv_save_fig(pl, save_fig)
return pl