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/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