yabplot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yabplot/__init__.py +9 -0
- yabplot/data/__init__.py +371 -0
- yabplot/plotting.py +614 -0
- yabplot/scene.py +124 -0
- yabplot/utils.py +170 -0
- yabplot-0.1.0.dist-info/METADATA +97 -0
- yabplot-0.1.0.dist-info/RECORD +10 -0
- yabplot-0.1.0.dist-info/WHEEL +5 -0
- yabplot-0.1.0.dist-info/licenses/LICENSE +19 -0
- yabplot-0.1.0.dist-info/top_level.txt +1 -0
yabplot/plotting.py
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import gc
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import nibabel as nib
|
|
6
|
+
import pyvista as pv
|
|
7
|
+
from matplotlib.colors import ListedColormap
|
|
8
|
+
|
|
9
|
+
from .data import (
|
|
10
|
+
_resolve_resource_path, _find_cortical_files,
|
|
11
|
+
_find_subcortical_files, _find_tract_files
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from .utils import (
|
|
15
|
+
load_gii, load_gii2pv, prep_data,
|
|
16
|
+
generate_distinct_colors, parse_lut, map_values_to_surface,
|
|
17
|
+
lines_from_streamlines, make_cortical_mesh
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from .scene import (
|
|
21
|
+
get_view_configs, setup_plotter, add_context_to_view,
|
|
22
|
+
set_camera, finalize_plot, get_shading_preset
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# --- plot for cortical surface ---
|
|
27
|
+
|
|
28
|
+
def plot_cortical(data=None, atlas=None, custom_atlas_path=None, views=None, layout=None,
|
|
29
|
+
figsize=(1000, 600), cmap='RdYlBu_r', vminmax=[None, None],
|
|
30
|
+
nan_color=(1.0, 1.0, 1.0), style='default', zoom=1.2,
|
|
31
|
+
display_type='static', export_path=None):
|
|
32
|
+
"""
|
|
33
|
+
Visualize data on the cortical surface using a specified atlas.
|
|
34
|
+
|
|
35
|
+
This function maps scalar values to cortical regions (parcellations) on a standard
|
|
36
|
+
surface mesh (Conte69). It supports both pre-existing atlases and custom local atlases.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
data : dict, list, numpy.ndarray, optional
|
|
41
|
+
Data to map onto the cortex.
|
|
42
|
+
If dict: Keys must match region names in the atlas (see `yabplot.get_atlas_regions`).
|
|
43
|
+
If array/list: Must match the exact length and order of the atlas regions.
|
|
44
|
+
If None: The atlas is plotted with categorical colors (one color per region).
|
|
45
|
+
atlas : str, optional
|
|
46
|
+
Name of the standard atlas to use (e.g., 'schaefer_100',
|
|
47
|
+
see 'yabplot.get_available_resources' for more).
|
|
48
|
+
Defaults to 'aparc' if neither atlas nor custom_atlas_path is provided.
|
|
49
|
+
custom_atlas_path : str, optional
|
|
50
|
+
Path to a local directory containing custom atlas files. The directory must
|
|
51
|
+
contain a CSV mapping regions to vertices and a LUT text file. If provided, `atlas` is ignored.
|
|
52
|
+
views : list of str, optional
|
|
53
|
+
Views to display. Can be a list of presets ('left_lateral', 'right_medial', etc.)
|
|
54
|
+
or a dictionary of camera configurations. Defaults to all views.
|
|
55
|
+
layout : tuple (rows, cols), optional
|
|
56
|
+
Grid layout for subplots. If None, automatically calculated based on the number of views.
|
|
57
|
+
figsize : tuple (width, height), optional
|
|
58
|
+
Window size in pixels. Default is (1000, 600).
|
|
59
|
+
cmap : str or matplotlib.colors.Colormap, optional
|
|
60
|
+
Colormap for continuous data. Ignored if `data` is None. Default is 'RdYlBu_r'.
|
|
61
|
+
vminmax : list [min, max], optional
|
|
62
|
+
Manual lower and upper bounds for the colormap. If [None, None],
|
|
63
|
+
bounds are inferred from the data range.
|
|
64
|
+
nan_color : tuple or str, optional
|
|
65
|
+
Color for regions with missing (NaN) data or the medial wall. Default is white.
|
|
66
|
+
style : str, optional
|
|
67
|
+
Lighting preset ('default', 'matte', 'glossy', 'sculpted', 'flat').
|
|
68
|
+
zoom : float, optional
|
|
69
|
+
Camera zoom level. >1.0 zooms in, <1.0 zooms out. Default is 1.2.
|
|
70
|
+
display_type : {'static', 'interactive', 'none'}, optional
|
|
71
|
+
'static': Returns a static image (good for notebooks).
|
|
72
|
+
'interactive': Opens an interactive viewer.
|
|
73
|
+
'none': Renders off-screen (useful for batch export).
|
|
74
|
+
export_path : str, optional
|
|
75
|
+
If provided, saves the final figure to this path (e.g., 'figure.png').
|
|
76
|
+
|
|
77
|
+
Returns
|
|
78
|
+
-------
|
|
79
|
+
pyvista.Plotter
|
|
80
|
+
The plotter instance used for rendering.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
# defaults
|
|
84
|
+
if atlas is None and custom_atlas_path is None:
|
|
85
|
+
atlas = 'aparc'
|
|
86
|
+
|
|
87
|
+
# load brain mesh
|
|
88
|
+
bmesh_path = _resolve_resource_path('conte69', 'bmesh')
|
|
89
|
+
lh_v, lh_f = load_gii(os.path.join(bmesh_path, 'conte69.lh.gii'))
|
|
90
|
+
rh_v, rh_f = load_gii(os.path.join(bmesh_path, 'conte69.rh.gii'))
|
|
91
|
+
|
|
92
|
+
# resolve atlas path (either download or custom directory)
|
|
93
|
+
atlas_dir = _resolve_resource_path(atlas, 'cortical', custom_path=custom_atlas_path)
|
|
94
|
+
|
|
95
|
+
# locate files
|
|
96
|
+
check_name = None if custom_atlas_path else atlas
|
|
97
|
+
csv_path, lut_path = _find_cortical_files(atlas_dir, strict_name=check_name)
|
|
98
|
+
|
|
99
|
+
# load mapping data
|
|
100
|
+
tar_labels = np.loadtxt(csv_path, dtype=int)
|
|
101
|
+
lut_ids, lut_colors, lut_names, max_id = parse_lut(lut_path)
|
|
102
|
+
|
|
103
|
+
# map data
|
|
104
|
+
all_vals = map_values_to_surface(data, tar_labels, lut_ids, lut_names)
|
|
105
|
+
lh_vals = all_vals[:len(lh_v)]
|
|
106
|
+
rh_vals = all_vals[len(lh_v):]
|
|
107
|
+
|
|
108
|
+
# setup colors
|
|
109
|
+
is_categorical = (data is None)
|
|
110
|
+
n_colors = 256
|
|
111
|
+
if is_categorical:
|
|
112
|
+
_lut_colors = lut_colors.copy()
|
|
113
|
+
_lut_colors[0] = nan_color
|
|
114
|
+
cmap = ListedColormap(_lut_colors)
|
|
115
|
+
n_colors = len(_lut_colors)
|
|
116
|
+
vmin, vmax = 0, max_id
|
|
117
|
+
else:
|
|
118
|
+
if cmap is None: cmap = 'RdYlBu_r'
|
|
119
|
+
vmin = vminmax[0] if vminmax[0] is not None else np.nanmin(all_vals)
|
|
120
|
+
vmax = vminmax[1] if vminmax[1] is not None else np.nanmax(all_vals)
|
|
121
|
+
|
|
122
|
+
# create meshes
|
|
123
|
+
lh_mesh = make_cortical_mesh(lh_v, lh_f, lh_vals)
|
|
124
|
+
rh_mesh = make_cortical_mesh(rh_v, rh_f, rh_vals)
|
|
125
|
+
|
|
126
|
+
# setup plotter
|
|
127
|
+
sel_views = get_view_configs(views)
|
|
128
|
+
plotter, ncols, nrows = setup_plotter(sel_views, layout, figsize, display_type)
|
|
129
|
+
shading_params = get_shading_preset(style)
|
|
130
|
+
scalar_bar_mapper = None
|
|
131
|
+
|
|
132
|
+
for i, (name, cfg) in enumerate(sel_views.items()):
|
|
133
|
+
plotter.subplot(i // ncols, i % ncols)
|
|
134
|
+
|
|
135
|
+
meshes = []
|
|
136
|
+
if cfg['side'] in ['L', 'both']: meshes.append(lh_mesh)
|
|
137
|
+
if cfg['side'] in ['R', 'both']: meshes.append(rh_mesh)
|
|
138
|
+
|
|
139
|
+
for mesh in meshes:
|
|
140
|
+
actor = plotter.add_mesh(
|
|
141
|
+
mesh,
|
|
142
|
+
scalars='Data',
|
|
143
|
+
cmap=cmap,
|
|
144
|
+
clim=(vmin, vmax),
|
|
145
|
+
n_colors=n_colors,
|
|
146
|
+
nan_color=nan_color,
|
|
147
|
+
show_scalar_bar=False,
|
|
148
|
+
rgb=False,
|
|
149
|
+
smooth_shading=True,
|
|
150
|
+
show_edges=False,
|
|
151
|
+
interpolate_before_map=False,
|
|
152
|
+
**shading_params
|
|
153
|
+
)
|
|
154
|
+
if scalar_bar_mapper is None: scalar_bar_mapper = actor.mapper
|
|
155
|
+
|
|
156
|
+
set_camera(plotter, cfg, zoom=zoom)
|
|
157
|
+
plotter.hide_axes()
|
|
158
|
+
|
|
159
|
+
if not is_categorical and scalar_bar_mapper:
|
|
160
|
+
plotter.subplot(nrows - 1, 0)
|
|
161
|
+
plotter.add_scalar_bar(mapper=scalar_bar_mapper, title='', n_labels=2,
|
|
162
|
+
vertical=False, position_x=0.3, position_y=0.25,
|
|
163
|
+
height=0.5, width=0.4,color='black',
|
|
164
|
+
label_font_size=20)
|
|
165
|
+
|
|
166
|
+
return finalize_plot(plotter, export_path, display_type)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --- plot for subcortical structures ---
|
|
171
|
+
|
|
172
|
+
def plot_subcortical(data=None, atlas=None, custom_atlas_path=None, views=None, layout=None,
|
|
173
|
+
figsize=(1000, 600), cmap='coolwarm', vminmax=[None, None], nan_color='#cccccc',
|
|
174
|
+
nan_alpha=1.0, legend=False, style='default', bmesh_type='conte69',
|
|
175
|
+
bmesh_alpha=0.1, bmesh_color='lightgray', zoom=1.2, display_type='static',
|
|
176
|
+
export_path=None, custom_atlas_proc=dict(smooth_i=15, smooth_f=0.6)):
|
|
177
|
+
"""
|
|
178
|
+
Visualize data on the subcortical structures using a specified atlas.
|
|
179
|
+
|
|
180
|
+
Renders volumetric structures as 3D meshes. Supports pre-existing atlases and
|
|
181
|
+
on-the-fly conversion of GIfTI surfaces to smooth meshes for custom atlases.
|
|
182
|
+
|
|
183
|
+
Parameters
|
|
184
|
+
----------
|
|
185
|
+
data : dict, list, numpy.ndarray, pandas.Series, pandas.DataFrame, optional
|
|
186
|
+
Scalar values for each subcortical region.
|
|
187
|
+
If dict/pd.Series/pd.DataFrame: Values according to region names.
|
|
188
|
+
If array/list: Must strictly match the sorted order of regions in the atlas.
|
|
189
|
+
atlas : str, optional
|
|
190
|
+
Name of the standard atlas to use (e.g., 'musus_100',
|
|
191
|
+
see 'yabplot.get_available_resources' for more).
|
|
192
|
+
Defaults to 'aseg' if neither atlas nor custom_atlas_path is provided.
|
|
193
|
+
custom_atlas_path : str, optional
|
|
194
|
+
Path to a local directory containing .vtk or .gii mesh files for each region.
|
|
195
|
+
views : list of str, optional
|
|
196
|
+
Views to display. Can be a list of presets ('left_lateral', 'right_medial', etc.)
|
|
197
|
+
or a dictionary of camera configurations. Defaults to all views.
|
|
198
|
+
layout : tuple (rows, cols), optional
|
|
199
|
+
Grid layout for subplots. If None, automatically calculated based on the number of views.
|
|
200
|
+
figsize : tuple (width, height), optional
|
|
201
|
+
Window size in pixels. Default is (1000, 600).
|
|
202
|
+
cmap : str or matplotlib.colors.Colormap, optional
|
|
203
|
+
Colormap for continuous data. Ignored if `data` is None. Default is 'coolwarm'.
|
|
204
|
+
vminmax : list [min, max], optional
|
|
205
|
+
Manual lower and upper bounds for the colormap. If [None, None],
|
|
206
|
+
bounds are inferred from the data range.
|
|
207
|
+
nan_color : str or tuple, optional
|
|
208
|
+
Color for regions with no data (NaN). Default is light grey '#cccccc'.
|
|
209
|
+
nan_alpha : float, optional
|
|
210
|
+
Opacity (0.0 to 1.0) for regions with no data. Set to 0.0 to hide them.
|
|
211
|
+
legend : bool, optional
|
|
212
|
+
If True (and data is None), displays a legend of region names and colors.
|
|
213
|
+
style : str, optional
|
|
214
|
+
Lighting preset ('default', 'matte', 'glossy', 'sculpted', 'flat').
|
|
215
|
+
bmesh_type : str or None, optional
|
|
216
|
+
Name of the background context brain mesh (e.g., 'conte69').
|
|
217
|
+
Set to None to hide the context brain.
|
|
218
|
+
bmesh_alpha : float, optional
|
|
219
|
+
Opacity of the context brain mesh. Default is 0.1.
|
|
220
|
+
bmesh_color : str, optional
|
|
221
|
+
Color of the context brain mesh.
|
|
222
|
+
zoom : float, optional
|
|
223
|
+
Camera zoom level. >1.0 zooms in, <1.0 zooms out. Default is 1.2.
|
|
224
|
+
display_type : {'static', 'interactive', 'none'}, optional
|
|
225
|
+
'static': Returns a static image (good for notebooks).
|
|
226
|
+
'interactive': Opens an interactive viewer.
|
|
227
|
+
'none': Renders off-screen (useful for batch export).
|
|
228
|
+
export_path : str, optional
|
|
229
|
+
If provided, saves the final figure to this path (e.g., 'figure.png').
|
|
230
|
+
custom_atlas_proc : dict, optional
|
|
231
|
+
Parameters for processing custom GIfTI files.
|
|
232
|
+
Keys: 'smooth_i' (iterations) and 'smooth_f' (relaxation factor).
|
|
233
|
+
Default is {'smooth_i': 15, 'smooth_f': 0.6}.
|
|
234
|
+
|
|
235
|
+
Returns
|
|
236
|
+
-------
|
|
237
|
+
pyvista.Plotter
|
|
238
|
+
The active plotter instance.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
# defaults
|
|
242
|
+
if atlas is None and custom_atlas_path is None:
|
|
243
|
+
atlas = 'aseg'
|
|
244
|
+
|
|
245
|
+
# load context brain mesh (if requested)
|
|
246
|
+
bmesh = {}
|
|
247
|
+
if bmesh_type:
|
|
248
|
+
bmesh_path = _resolve_resource_path(bmesh_type, 'bmesh')
|
|
249
|
+
for h in ['lh', 'rh']:
|
|
250
|
+
fpath = os.path.join(bmesh_path, f'{bmesh_type}.{h}.gii')
|
|
251
|
+
if os.path.exists(fpath):
|
|
252
|
+
bmesh[h] = load_gii2pv(fpath)
|
|
253
|
+
|
|
254
|
+
# load regional atlas meshes
|
|
255
|
+
|
|
256
|
+
# resolve atlas path (either download or custom directory)
|
|
257
|
+
atlas_dir = _resolve_resource_path(atlas, 'subcortical', custom_path=custom_atlas_path)
|
|
258
|
+
|
|
259
|
+
# locate mesh files, returns dict: {'Left_Thalamus': '/path/to/Left_Thalamus.vtk', ...}
|
|
260
|
+
file_map = _find_subcortical_files(atlas_dir)
|
|
261
|
+
rmesh_names = sorted(list(file_map.keys()))
|
|
262
|
+
|
|
263
|
+
# load meshes (and convert gii2pv if gii files)
|
|
264
|
+
meshes = {}
|
|
265
|
+
for name, fpath in file_map.items():
|
|
266
|
+
if fpath.endswith('.vtk'):
|
|
267
|
+
meshes[name] = pv.read(fpath)
|
|
268
|
+
elif fpath.endswith('.gii'):
|
|
269
|
+
mesh = load_gii2pv(fpath, **custom_atlas_proc)
|
|
270
|
+
meshes[name] = mesh
|
|
271
|
+
|
|
272
|
+
# prepare colors / map data
|
|
273
|
+
if data is not None:
|
|
274
|
+
d_data = prep_data(data, rmesh_names, atlas, 'subcortical')
|
|
275
|
+
|
|
276
|
+
valid_vals = [v for v in d_data.values() if pd.notna(v)]
|
|
277
|
+
if vminmax[0] is None: vminmax[0] = min(valid_vals) if valid_vals else 0
|
|
278
|
+
if vminmax[1] is None: vminmax[1] = max(valid_vals) if valid_vals else 1
|
|
279
|
+
else:
|
|
280
|
+
colors = generate_distinct_colors(len(rmesh_names), seed=42)
|
|
281
|
+
d_atlas_colors = {name: color for name, color in zip(rmesh_names, colors)}
|
|
282
|
+
|
|
283
|
+
# setup plotter
|
|
284
|
+
sel_views = get_view_configs(views)
|
|
285
|
+
needs_bottom = (data is not None) or legend
|
|
286
|
+
plotter, ncols, nrows = setup_plotter(sel_views, layout, figsize, display_type,
|
|
287
|
+
needs_bottom_row=needs_bottom)
|
|
288
|
+
|
|
289
|
+
# get shading parameters from style
|
|
290
|
+
shading_params = get_shading_preset(style)
|
|
291
|
+
|
|
292
|
+
scalar_bar_mapper = None
|
|
293
|
+
plotted_regions = {}
|
|
294
|
+
|
|
295
|
+
# plotting loop
|
|
296
|
+
for i, (view_name, cfg) in enumerate(sel_views.items()):
|
|
297
|
+
plotter.subplot(i // ncols, i % ncols)
|
|
298
|
+
|
|
299
|
+
# add context (uses style kwargs for consistent lighting)
|
|
300
|
+
add_context_to_view(plotter, bmesh, cfg['side'], bmesh_alpha, bmesh_color,
|
|
301
|
+
**shading_params)
|
|
302
|
+
|
|
303
|
+
# add regions
|
|
304
|
+
for name, mesh in meshes.items():
|
|
305
|
+
# side filter
|
|
306
|
+
# TODO: make the hemisphere specific name check more robust
|
|
307
|
+
name_lower = name.lower()
|
|
308
|
+
is_left = any(x in name_lower for x in ['left', '_l', '-l', 'l_']) or name_lower.endswith('l')
|
|
309
|
+
is_right = any(x in name_lower for x in ['right', '_r', '-r', 'r_']) or name_lower.endswith('r')
|
|
310
|
+
|
|
311
|
+
if cfg['side'] == 'L' and is_right and not is_left: continue
|
|
312
|
+
if cfg['side'] == 'R' and is_left and not is_right: continue
|
|
313
|
+
|
|
314
|
+
# determine properties for this mesh
|
|
315
|
+
props = shading_params.copy()
|
|
316
|
+
|
|
317
|
+
if data is not None:
|
|
318
|
+
val = d_data.get(name, np.nan) if pd.notna(d_data.get(name)) else np.nan
|
|
319
|
+
has_val = not np.isnan(val)
|
|
320
|
+
|
|
321
|
+
mesh['Data'] = np.full(mesh.n_points, val)
|
|
322
|
+
|
|
323
|
+
props.update({
|
|
324
|
+
'scalars': 'Data', 'cmap': cmap, 'clim': vminmax,
|
|
325
|
+
'nan_color': nan_color, 'opacity': 1.0 if has_val else nan_alpha,
|
|
326
|
+
'show_scalar_bar': False
|
|
327
|
+
})
|
|
328
|
+
else:
|
|
329
|
+
color = d_atlas_colors[name]
|
|
330
|
+
props.update({'color': color, 'opacity': 1.0})
|
|
331
|
+
plotted_regions[name] = color
|
|
332
|
+
|
|
333
|
+
actor = plotter.add_mesh(mesh, **props)
|
|
334
|
+
|
|
335
|
+
if data is not None and scalar_bar_mapper is None and 'scalars' in props:
|
|
336
|
+
scalar_bar_mapper = actor.mapper
|
|
337
|
+
|
|
338
|
+
set_camera(plotter, cfg, zoom=zoom)
|
|
339
|
+
plotter.hide_axes()
|
|
340
|
+
|
|
341
|
+
# bottom row: legend or colorbar
|
|
342
|
+
if needs_bottom:
|
|
343
|
+
plotter.subplot(nrows - 1, 0)
|
|
344
|
+
|
|
345
|
+
if data is not None:
|
|
346
|
+
if scalar_bar_mapper:
|
|
347
|
+
plotter.add_scalar_bar(mapper=scalar_bar_mapper, title='', n_labels=5,
|
|
348
|
+
vertical=False, position_x=0.3, position_y=0.25,
|
|
349
|
+
height=0.5, width=0.4, color='black',
|
|
350
|
+
label_font_size=20)
|
|
351
|
+
elif legend:
|
|
352
|
+
legend_entries = [[r, c] for r, c in plotted_regions.items()]
|
|
353
|
+
if legend_entries:
|
|
354
|
+
plotter.add_legend(legend_entries, size=(0.8, 0.8), bcolor=None)
|
|
355
|
+
|
|
356
|
+
return finalize_plot(plotter, export_path, display_type)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# --- plot for white matter tracts ---
|
|
361
|
+
|
|
362
|
+
_TRACT_CACHE = {}
|
|
363
|
+
def clear_tract_cache():
|
|
364
|
+
"""manually clears the global geometry cache to free ram."""
|
|
365
|
+
global _TRACT_CACHE
|
|
366
|
+
_TRACT_CACHE = {}
|
|
367
|
+
gc.collect()
|
|
368
|
+
print("Tract cache cleared.")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def plot_tracts(data=None, atlas=None, custom_atlas_path=None, views=None, layout=None,
|
|
372
|
+
figsize=(1000, 800), cmap='coolwarm', alpha=1.0, vminmax=[None, None],
|
|
373
|
+
nan_color='#BDBDBD', nan_alpha=1.0, legend=False, style='default',
|
|
374
|
+
bmesh_type='conte69', bmesh_alpha=0.2, bmesh_color='lightgray',
|
|
375
|
+
zoom=1.2, orientation_coloring=False, display_type='static',
|
|
376
|
+
tract_kwargs=dict(render_lines_as_tubes=True, line_width=1.2),
|
|
377
|
+
export_path=None):
|
|
378
|
+
"""
|
|
379
|
+
Visualize data on the white matter tractography bundles using a specified atlas.
|
|
380
|
+
|
|
381
|
+
Renders streamlines from .trk files. Can color tracts by scalar values,
|
|
382
|
+
categorically, or by local fiber orientation.
|
|
383
|
+
|
|
384
|
+
Parameters
|
|
385
|
+
----------
|
|
386
|
+
data : dict, list, numpy.ndarray, pandas.Series, pandas.DataFrame, optional
|
|
387
|
+
Scalar values for each tract.
|
|
388
|
+
If dict: Keys must match tract names.
|
|
389
|
+
If array/list: Must strictly match the sorted list of tracts in the atlas.
|
|
390
|
+
If None: Tracts are colored by category (distinct colors) or orientation.
|
|
391
|
+
atlas : str, optional
|
|
392
|
+
Name of the standard tract atlas (e.g., 'hcp1065_small',
|
|
393
|
+
see 'yabplot.get_available_resources' for more).
|
|
394
|
+
Defaults to 'xtract_tiny'.
|
|
395
|
+
custom_atlas_path : str, optional
|
|
396
|
+
Path to a local directory containing .trk files for each tract.
|
|
397
|
+
views : list of str, optional
|
|
398
|
+
Views to display. Can be a list of presets ('left_lateral', 'right_medial', etc.)
|
|
399
|
+
or a dictionary of camera configurations. Defaults to all views.
|
|
400
|
+
layout : tuple (rows, cols), optional
|
|
401
|
+
Grid layout for subplots. If None, automatically calculated based on the number of views.
|
|
402
|
+
figsize : tuple (width, height), optional
|
|
403
|
+
Window size in pixels. Default is (1000, 600).
|
|
404
|
+
cmap : str or matplotlib.colors.Colormap, optional
|
|
405
|
+
Colormap for continuous data. Ignored if `data` is None. Default is 'coolwarm'.
|
|
406
|
+
alpha : float, optional
|
|
407
|
+
Opacity of the tracts (0.0 to 1.0).
|
|
408
|
+
vminmax : list [min, max], optional
|
|
409
|
+
Manual lower and upper bounds for the colormap. If [None, None],
|
|
410
|
+
bounds are inferred from the data range.
|
|
411
|
+
nan_color : str, optional
|
|
412
|
+
Color for tracts with missing data (NaN). Default is grey '#BDBDBD'.
|
|
413
|
+
nan_alpha : float, optional
|
|
414
|
+
Opacity (0.0 to 1.0) for regions with no data. Set to 0.0 to hide them.
|
|
415
|
+
legend : bool, optional
|
|
416
|
+
If True (and data is None), displays a legend of region names and colors.
|
|
417
|
+
style : str, optional
|
|
418
|
+
Lighting preset ('default', 'matte', 'glossy', 'sculpted', 'flat').
|
|
419
|
+
bmesh_type : str or None, optional
|
|
420
|
+
Name of the background context brain mesh (e.g., 'conte69').
|
|
421
|
+
Set to None to hide the context brain.
|
|
422
|
+
bmesh_alpha : float, optional
|
|
423
|
+
Opacity of the context brain mesh. Default is 0.2.
|
|
424
|
+
bmesh_color : str, optional
|
|
425
|
+
Color of the context brain mesh.
|
|
426
|
+
zoom : float, optional
|
|
427
|
+
Camera zoom level. >1.0 zooms in, <1.0 zooms out. Default is 1.2.
|
|
428
|
+
orientation_coloring : bool, optional
|
|
429
|
+
If True, ignores `data` and colors fibers based on their local directional
|
|
430
|
+
orientation (Red=L/R, Green=A/P, Blue=S/I).
|
|
431
|
+
tract_kwargs : dict, optional
|
|
432
|
+
Additional arguments passed to PyVista's `add_mesh`.
|
|
433
|
+
Default configures tubes: `{'render_lines_as_tubes': True, 'line_width': 1.2}`.
|
|
434
|
+
display_type : {'static', 'interactive', 'none'}, optional
|
|
435
|
+
'static': Returns a static image (good for notebooks).
|
|
436
|
+
'interactive': Opens an interactive viewer.
|
|
437
|
+
'none': Renders off-screen (useful for batch export).
|
|
438
|
+
export_path : str, optional
|
|
439
|
+
If provided, saves the final figure to this path (e.g., 'figure.png').
|
|
440
|
+
|
|
441
|
+
Returns
|
|
442
|
+
-------
|
|
443
|
+
pyvista.Plotter
|
|
444
|
+
The active plotter instance.
|
|
445
|
+
"""
|
|
446
|
+
|
|
447
|
+
# defaults
|
|
448
|
+
if atlas is None and custom_atlas_path is None:
|
|
449
|
+
atlas = 'xtract_tiny'
|
|
450
|
+
|
|
451
|
+
# resolve atlas path (either download or custom directory)
|
|
452
|
+
atlas_dir = _resolve_resource_path(atlas, 'tracts', custom_path=custom_atlas_path)
|
|
453
|
+
|
|
454
|
+
# locate tract files, returns dict eg {'CST_L': '/path/to/CST_L.trk', ...}
|
|
455
|
+
file_map = _find_tract_files(atlas_dir)
|
|
456
|
+
tract_names = sorted(list(file_map.keys()))
|
|
457
|
+
|
|
458
|
+
# prepare colors / map data
|
|
459
|
+
if data is not None:
|
|
460
|
+
d_data = prep_data(data, tract_names, atlas, 'tracts')
|
|
461
|
+
|
|
462
|
+
valid_vals = [v for v in d_data.values() if pd.notna(v)]
|
|
463
|
+
vmin = vminmax[0] if vminmax[0] is not None else min(valid_vals)
|
|
464
|
+
vmax = vminmax[1] if vminmax[1] is not None else max(valid_vals)
|
|
465
|
+
# categorical/orientation mode
|
|
466
|
+
else:
|
|
467
|
+
vmin, vmax = 0, 1
|
|
468
|
+
colors = generate_distinct_colors(len(tract_names), seed=42)
|
|
469
|
+
d_atlas_colors = {name: color for name, color in zip(tract_names, colors)}
|
|
470
|
+
|
|
471
|
+
# load context brain mesh (if requested)
|
|
472
|
+
bmesh = {}
|
|
473
|
+
if bmesh_type:
|
|
474
|
+
bmesh_path = _resolve_resource_path(bmesh_type, 'bmesh')
|
|
475
|
+
for h in ['lh', 'rh']:
|
|
476
|
+
fpath = os.path.join(bmesh_path, f'{bmesh_type}.{h}.gii')
|
|
477
|
+
if os.path.exists(fpath):
|
|
478
|
+
bmesh[h] = load_gii2pv(fpath)
|
|
479
|
+
|
|
480
|
+
# setup plotter
|
|
481
|
+
sel_views = get_view_configs(views)
|
|
482
|
+
needs_bottom = (data is not None and not orientation_coloring) or legend
|
|
483
|
+
plotter, ncols, nrows = setup_plotter(sel_views, layout, figsize, display_type,
|
|
484
|
+
needs_bottom_row=needs_bottom)
|
|
485
|
+
plotter.enable_depth_peeling(number_of_peels=10)
|
|
486
|
+
plotter.enable_anti_aliasing('msaa') # smooth lines
|
|
487
|
+
shading_params = get_shading_preset(style)
|
|
488
|
+
|
|
489
|
+
scalar_bar_mapper = None
|
|
490
|
+
plotted_regions = {} # for legend
|
|
491
|
+
|
|
492
|
+
def _retrieve_tract_mesh(atlas_key, name, file_map):
|
|
493
|
+
"""
|
|
494
|
+
Retrieves a mesh from cache or loads from disk using file_map.
|
|
495
|
+
"""
|
|
496
|
+
# check RAM cache
|
|
497
|
+
if name in _TRACT_CACHE.get(atlas_key, {}):
|
|
498
|
+
return _TRACT_CACHE[atlas_key][name]
|
|
499
|
+
|
|
500
|
+
# init cache dict
|
|
501
|
+
if atlas_key not in _TRACT_CACHE: _TRACT_CACHE[atlas_key] = {}
|
|
502
|
+
|
|
503
|
+
# load from disk
|
|
504
|
+
try:
|
|
505
|
+
fpath = file_map.get(name)
|
|
506
|
+
if not fpath: return None
|
|
507
|
+
|
|
508
|
+
tractogram = nib.streamlines.load(fpath)
|
|
509
|
+
points, lines, tangents = lines_from_streamlines(tractogram.streamlines)
|
|
510
|
+
if len(points) == 0: return None
|
|
511
|
+
|
|
512
|
+
base_mesh = pv.PolyData(points, lines=lines)
|
|
513
|
+
base_mesh.point_data['tangents'] = np.abs(tangents)
|
|
514
|
+
|
|
515
|
+
# store in global cache
|
|
516
|
+
_TRACT_CACHE[atlas_key][name] = base_mesh
|
|
517
|
+
return base_mesh
|
|
518
|
+
|
|
519
|
+
except Exception as e:
|
|
520
|
+
print(f"Failed to load tract {name}: {e}")
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
# plotting loop
|
|
524
|
+
cache_key = 'custom' if custom_atlas_path else atlas
|
|
525
|
+
for i, (view_name, cfg) in enumerate(sel_views.items()):
|
|
526
|
+
plotter.subplot(i // ncols, i % ncols)
|
|
527
|
+
|
|
528
|
+
# add context (passed shading params to context mesh)
|
|
529
|
+
add_context_to_view(plotter, bmesh, cfg['side'], bmesh_alpha, bmesh_color, **shading_params)
|
|
530
|
+
|
|
531
|
+
# add tracts
|
|
532
|
+
for name in tract_names:
|
|
533
|
+
# optimization: early exit for hidden tracts
|
|
534
|
+
has_value = False
|
|
535
|
+
val = np.nan
|
|
536
|
+
|
|
537
|
+
if data is not None and not orientation_coloring:
|
|
538
|
+
if name in d_data and pd.notna(d_data[name]):
|
|
539
|
+
val = d_data[name]
|
|
540
|
+
has_value = True
|
|
541
|
+
elif nan_alpha == 0:
|
|
542
|
+
continue
|
|
543
|
+
|
|
544
|
+
# side filtering
|
|
545
|
+
name_lower = name.lower()
|
|
546
|
+
is_left = any(x in name_lower for x in ['left', '_l', '-l', 'l_']) or name_lower.endswith('l')
|
|
547
|
+
is_right = any(x in name_lower for x in ['right', '_r', '-r', 'r_']) or name_lower.endswith('r')
|
|
548
|
+
if cfg['side'] == 'L' and is_right and not is_left: continue
|
|
549
|
+
if cfg['side'] == 'R' and is_left and not is_right: continue
|
|
550
|
+
|
|
551
|
+
# load mesh
|
|
552
|
+
base_mesh = _retrieve_tract_mesh(cache_key, name, file_map)
|
|
553
|
+
if base_mesh is None: continue
|
|
554
|
+
pv_mesh = base_mesh.copy(deep=False)
|
|
555
|
+
|
|
556
|
+
# start with style presets, then override with tract_kwargs and dynamic props
|
|
557
|
+
props = shading_params.copy()
|
|
558
|
+
props.update(tract_kwargs)
|
|
559
|
+
|
|
560
|
+
if orientation_coloring:
|
|
561
|
+
pv_mesh['Data'] = pv_mesh.point_data['tangents']
|
|
562
|
+
props.update({
|
|
563
|
+
'scalars': 'Data', 'rgb': True, 'opacity': alpha
|
|
564
|
+
})
|
|
565
|
+
legend_color = 'gray'
|
|
566
|
+
|
|
567
|
+
elif data is not None:
|
|
568
|
+
pv_mesh['Data'] = np.full(pv_mesh.n_points, val)
|
|
569
|
+
current_opacity = alpha if has_value else nan_alpha
|
|
570
|
+
|
|
571
|
+
props.update({
|
|
572
|
+
'scalars': 'Data', 'cmap': cmap, 'clim': (vmin, vmax),
|
|
573
|
+
'nan_color': nan_color, 'opacity': current_opacity, 'show_scalar_bar': False
|
|
574
|
+
})
|
|
575
|
+
legend_color = None
|
|
576
|
+
|
|
577
|
+
else:
|
|
578
|
+
color = d_atlas_colors[name]
|
|
579
|
+
props.update({
|
|
580
|
+
'color': color, 'opacity': alpha
|
|
581
|
+
})
|
|
582
|
+
legend_color = color
|
|
583
|
+
|
|
584
|
+
actor = plotter.add_mesh(pv_mesh, **props)
|
|
585
|
+
|
|
586
|
+
if legend_color: plotted_regions[name] = legend_color
|
|
587
|
+
if data is not None and not orientation_coloring and scalar_bar_mapper is None and 'scalars' in props:
|
|
588
|
+
scalar_bar_mapper = actor.mapper
|
|
589
|
+
|
|
590
|
+
set_camera(plotter, cfg, zoom=zoom, distance=150)
|
|
591
|
+
plotter.hide_axes()
|
|
592
|
+
|
|
593
|
+
# bottom row: legend or colorbar
|
|
594
|
+
if needs_bottom:
|
|
595
|
+
plotter.subplot(nrows - 1, 0)
|
|
596
|
+
|
|
597
|
+
if data is not None and not orientation_coloring:
|
|
598
|
+
if scalar_bar_mapper:
|
|
599
|
+
plotter.add_scalar_bar(mapper=scalar_bar_mapper, title='', n_labels=5, vertical=False,
|
|
600
|
+
position_x=0.3, position_y=0.25, height=0.5, width=0.4,
|
|
601
|
+
color='black', label_font_size=20)
|
|
602
|
+
elif legend and not orientation_coloring:
|
|
603
|
+
legend_entries = [[r, c] for r, c in plotted_regions.items()]
|
|
604
|
+
if legend_entries:
|
|
605
|
+
plotter.add_legend(legend_entries, size=(0.8, 0.8), bcolor=None)
|
|
606
|
+
|
|
607
|
+
# finalize
|
|
608
|
+
ret_val = finalize_plot(plotter, export_path, display_type)
|
|
609
|
+
|
|
610
|
+
if display_type != 'interactive':
|
|
611
|
+
del plotter
|
|
612
|
+
gc.collect()
|
|
613
|
+
|
|
614
|
+
return ret_val
|