yabplot 0.1.4__tar.gz → 0.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yabplot
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: yet another brain plot
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -55,6 +55,9 @@ pip install yabplot --upgrade # to update
55
55
 
56
56
  dependencies: python 3.11 with ipywidgets, nibabel, pandas, pooch, pyvista, scikit-image, trame, trame-vtk, trame-vuetify
57
57
 
58
+ (Connectome Workbench (`wb_command`) is a requirement to create custom cortical atlases unless you plan to only use pre-loaded atlases; see more in docs)
59
+
60
+
58
61
  ## quick start
59
62
 
60
63
  please refer to the [documentation](https://teanijarv.github.io/yabplot/) for more comprehensive guides.
@@ -63,32 +66,41 @@ please refer to the [documentation](https://teanijarv.github.io/yabplot/) for mo
63
66
  import yabplot as yab
64
67
  import numpy as np
65
68
 
66
- # see available cortical atlases
67
- atlases = yab.get_available_resources(category='cortical')
68
-
69
- # see the region names within the aseg atlas
70
- regions = yab.get_atlas_regions(atlas='aseg', category='subcortical')
71
-
72
- # plot data on cortical regions
73
- data = np.arange(0, 1, 0.001)
74
- yab.plot_cortical(data=data, atlas='schaefer_1000', figsize=(600, 300),
75
- cmap='viridis', vminmax=[0, 1], style='default',
76
- views=['left_lateral', 'superior', 'right_lateral'])
77
-
78
-
79
- # plot values for specific subcortical regions
80
- data = {'Left_Amygdala': 0.8, 'Right_Hippocampus': 0.5,
81
- 'Right_Thalamus': -0.5, 'Left_Putamen': -1}
82
- yab.plot_subcortical(data=data, atlas='aseg', figsize=(600, 450), layout=(2, 2),
83
- views=['superior', 'anterior', 'left_lateral', 'right_lateral'],
84
- cmap='coolwarm', vminmax=[-1, 1], style='matte')
85
-
86
- # plot data on white matter bundles
87
- regions = yab.get_atlas_regions(atlas='xtract_tiny', category='tracts')
88
- data = np.arange(0, len(regions))
89
- yab.plot_tracts(data=data, atlas='xtract_tiny', figsize=(600, 300),
90
- views=['superior', 'anterior', 'left_lateral'], nan_color='#cccccc',
91
- bmesh_type='fsaverage', style='default', cmap='plasma')
69
+ # check that you have the latest version
70
+ print(yab.__version__)
71
+
72
+ # see available atlases and brain meshes
73
+ print(yab.get_available_resources())
74
+
75
+ # see the region names for a specific atlas
76
+ print(yab.get_atlas_regions(atlas='aseg', category='subcortical'))
77
+
78
+ # cortical surfaces
79
+ atlas = 'aparc'
80
+ dmap1 = {'L_lateraloccipital': 0.265, 'L_postcentral': 0.086, ...}
81
+ dmap2 = {'L_fusiform': 0.218, 'L_supramarginal': 0.119, ...}
82
+ yab.plot_cortical(data=dmap1, atlas=atlas, vminmax=[-0.1, 0.3],
83
+ bmesh_type='midthickness', views=['left_lateral', 'left_medial'],
84
+ figsize=(600, 300), cmap='viridis', proc_vertices='sharp')
85
+ yab.plot_cortical(data=dmap2, atlas=atlas, vminmax=[-0.1, 0.3],
86
+ bmesh_type='swm', views=['left_lateral', 'left_medial'],
87
+ figsize=(1200, 600), cmap='viridis', proc_vertices='sharp')
88
+
89
+ # subcortical structures
90
+ atlas = 'aseg'
91
+ regs = yab.get_atlas_regions(atlas=atlas, category='subcortical')
92
+ data = np.arange(1, len(regs)+1)
93
+ yab.plot_subcortical(data=data, atlas=atlas, vminmax=[2, 14],
94
+ views=['left_lateral', 'superior', 'right_lateral'],
95
+ bmesh_alpha=0.1, figsize=(600, 300), cmap='viridis')
96
+
97
+ # white matter bundles
98
+ atlas = 'xtract_tiny'
99
+ regs = yab.get_atlas_regions(atlas=atlas, category='tracts')
100
+ data = {reg: np.sin(i) for i, reg in enumerate(regs)}
101
+ yab.plot_tracts(data=data, atlas=atlas, style='matte',
102
+ views=['left_lateral', 'anterior', 'superior'], bmesh_type='pial',
103
+ bmesh_alpha=0.1, figsize=(1600, 800), cmap='viridis')
92
104
 
93
105
  ```
94
106
 
@@ -32,6 +32,9 @@ pip install yabplot --upgrade # to update
32
32
 
33
33
  dependencies: python 3.11 with ipywidgets, nibabel, pandas, pooch, pyvista, scikit-image, trame, trame-vtk, trame-vuetify
34
34
 
35
+ (Connectome Workbench (`wb_command`) is a requirement to create custom cortical atlases unless you plan to only use pre-loaded atlases; see more in docs)
36
+
37
+
35
38
  ## quick start
36
39
 
37
40
  please refer to the [documentation](https://teanijarv.github.io/yabplot/) for more comprehensive guides.
@@ -40,32 +43,41 @@ please refer to the [documentation](https://teanijarv.github.io/yabplot/) for mo
40
43
  import yabplot as yab
41
44
  import numpy as np
42
45
 
43
- # see available cortical atlases
44
- atlases = yab.get_available_resources(category='cortical')
45
-
46
- # see the region names within the aseg atlas
47
- regions = yab.get_atlas_regions(atlas='aseg', category='subcortical')
48
-
49
- # plot data on cortical regions
50
- data = np.arange(0, 1, 0.001)
51
- yab.plot_cortical(data=data, atlas='schaefer_1000', figsize=(600, 300),
52
- cmap='viridis', vminmax=[0, 1], style='default',
53
- views=['left_lateral', 'superior', 'right_lateral'])
54
-
55
-
56
- # plot values for specific subcortical regions
57
- data = {'Left_Amygdala': 0.8, 'Right_Hippocampus': 0.5,
58
- 'Right_Thalamus': -0.5, 'Left_Putamen': -1}
59
- yab.plot_subcortical(data=data, atlas='aseg', figsize=(600, 450), layout=(2, 2),
60
- views=['superior', 'anterior', 'left_lateral', 'right_lateral'],
61
- cmap='coolwarm', vminmax=[-1, 1], style='matte')
62
-
63
- # plot data on white matter bundles
64
- regions = yab.get_atlas_regions(atlas='xtract_tiny', category='tracts')
65
- data = np.arange(0, len(regions))
66
- yab.plot_tracts(data=data, atlas='xtract_tiny', figsize=(600, 300),
67
- views=['superior', 'anterior', 'left_lateral'], nan_color='#cccccc',
68
- bmesh_type='fsaverage', style='default', cmap='plasma')
46
+ # check that you have the latest version
47
+ print(yab.__version__)
48
+
49
+ # see available atlases and brain meshes
50
+ print(yab.get_available_resources())
51
+
52
+ # see the region names for a specific atlas
53
+ print(yab.get_atlas_regions(atlas='aseg', category='subcortical'))
54
+
55
+ # cortical surfaces
56
+ atlas = 'aparc'
57
+ dmap1 = {'L_lateraloccipital': 0.265, 'L_postcentral': 0.086, ...}
58
+ dmap2 = {'L_fusiform': 0.218, 'L_supramarginal': 0.119, ...}
59
+ yab.plot_cortical(data=dmap1, atlas=atlas, vminmax=[-0.1, 0.3],
60
+ bmesh_type='midthickness', views=['left_lateral', 'left_medial'],
61
+ figsize=(600, 300), cmap='viridis', proc_vertices='sharp')
62
+ yab.plot_cortical(data=dmap2, atlas=atlas, vminmax=[-0.1, 0.3],
63
+ bmesh_type='swm', views=['left_lateral', 'left_medial'],
64
+ figsize=(1200, 600), cmap='viridis', proc_vertices='sharp')
65
+
66
+ # subcortical structures
67
+ atlas = 'aseg'
68
+ regs = yab.get_atlas_regions(atlas=atlas, category='subcortical')
69
+ data = np.arange(1, len(regs)+1)
70
+ yab.plot_subcortical(data=data, atlas=atlas, vminmax=[2, 14],
71
+ views=['left_lateral', 'superior', 'right_lateral'],
72
+ bmesh_alpha=0.1, figsize=(600, 300), cmap='viridis')
73
+
74
+ # white matter bundles
75
+ atlas = 'xtract_tiny'
76
+ regs = yab.get_atlas_regions(atlas=atlas, category='tracts')
77
+ data = {reg: np.sin(i) for i, reg in enumerate(regs)}
78
+ yab.plot_tracts(data=data, atlas=atlas, style='matte',
79
+ views=['left_lateral', 'anterior', 'superior'], bmesh_type='pial',
80
+ bmesh_alpha=0.1, figsize=(1600, 800), cmap='viridis')
69
81
 
70
82
  ```
71
83
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "yabplot"
3
- version = "0.1.4"
3
+ version = "0.2.0"
4
4
  description = "yet another brain plot"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -29,4 +29,4 @@ where = ["."]
29
29
  include = ["yabplot*"]
30
30
 
31
31
  [tool.setuptools.package-data]
32
- "yabplot" = ["*.txt", "*.json"]
32
+ "yabplot" = ["*.txt", "*.json"]
@@ -23,16 +23,16 @@ def test_plot_cortical():
23
23
  """
24
24
  Integration test: Downloads 'aparc' and plots it.
25
25
  """
26
- yab.plot_cortical(atlas='aparc', display_type='none')
26
+ yab.plot_cortical(atlas='aparc', display_type=None)
27
27
 
28
28
  def test_plot_subcortical():
29
29
  """
30
30
  Integration test: Downloads 'aseg' and plots it.
31
31
  """
32
- yab.plot_subcortical(atlas='aseg', display_type='none')
32
+ yab.plot_subcortical(atlas='aseg', display_type=None)
33
33
 
34
34
  def test_plot_tracts():
35
35
  """
36
36
  Integration test: Downloads 'xtract_tiny' and plots it.
37
37
  """
38
- yab.plot_tracts(atlas='xtract_tiny', display_type='none')
38
+ yab.plot_tracts(atlas='xtract_tiny', display_type=None)
@@ -1,7 +1,8 @@
1
1
  from importlib.metadata import version, PackageNotFoundError
2
2
 
3
3
  from .plotting import plot_cortical, plot_subcortical, plot_tracts, clear_tract_cache
4
- from .data import get_available_resources, get_atlas_regions
4
+ from .data import get_available_resources, get_atlas_regions, get_surface_paths
5
+ from .atlas_builder import build_cortical_atlas, build_subcortical_atlas
5
6
 
6
7
  try:
7
8
  __version__ = version("yabplot")
@@ -0,0 +1,404 @@
1
+ import os
2
+ import glob
3
+ import numpy as np
4
+ import nibabel as nib
5
+ import scipy.sparse as sp
6
+ import pyvista as pv
7
+ from skimage import measure
8
+ from .wrappers import run_wb_import, run_wb_projection
9
+ from .data import get_surface_paths
10
+ from .plotting import plot_cortical, plot_subcortical
11
+
12
+ ### CORTICAL
13
+
14
+ def _build_adjacency(surf_path, n_vert=32492):
15
+ """internal helper to build surface adjacency matrix."""
16
+ surf = nib.load(surf_path)
17
+ faces = surf.darrays[1].data.astype(int)
18
+ edges = np.vstack([faces[:, [0, 1]], faces[:, [1, 2]], faces[:, [2, 0]]])
19
+ row, col = np.concatenate([edges[:, 0], edges[:, 1]]), np.concatenate([edges[:, 1], edges[:, 0]])
20
+ return sp.coo_matrix((np.ones(len(row), dtype=int), (row, col)), shape=(n_vert, n_vert)).tocsr()
21
+
22
+ def build_cortical_atlas(nii_path, wb_txt_path, out_dir, include_list=None, exclude_list=None, atlasname='atlas'):
23
+ """
24
+ builds a custom yabplot cortical atlas from a volumetric NIfTI file.
25
+
26
+ projects a volumetric NIfTI atlas to standard fsLR32k surfaces using connectome workbench,
27
+ cleans the medial wall, and applies majority-vote boundary smoothing to remove voxel artifacts.
28
+
29
+ parameters
30
+ ----------
31
+ nii_path : str
32
+ absolute path to the 3D NIfTI volume of the atlas.
33
+ wb_txt_path : str
34
+ absolute path to the text file formatted specifically for connectome workbench.
35
+ out_dir : str
36
+ directory where the final .csv map and .txt LUT will be saved.
37
+ include_list : list of str, optional
38
+ keywords of regions to strictly include. all other regions are ignored.
39
+ exclude_list : list of str, optional
40
+ keywords of regions to strictly exclude. all other regions are kept.
41
+ atlasname : str, optional
42
+ prefix name for the output files. default is 'atlas'.
43
+
44
+ raises
45
+ ------
46
+ ValueError
47
+ if both include_list and exclude_list are provided.
48
+ """
49
+ if include_list and exclude_list:
50
+ raise ValueError("please provide either 'include_list' or 'exclude_list', not both.")
51
+
52
+ os.makedirs(out_dir, exist_ok=True)
53
+
54
+ # define intermediate and output paths
55
+ labeled_nii = os.path.join(out_dir, 'temp_labeled.nii.gz')
56
+ lh_gii = os.path.join(out_dir, 'lh_temp.label.gii')
57
+ rh_gii = os.path.join(out_dir, 'rh_temp.label.gii')
58
+ out_csv = os.path.join(out_dir, f'{atlasname}.csv')
59
+ out_lut = os.path.join(out_dir, f'{atlasname}.txt')
60
+
61
+ # fetch standard fsLR32k surfaces and masks via yabplot data system
62
+ print("fetching standard surfaces...")
63
+ lh_mid, rh_mid = get_surface_paths('midthickness', 'bmesh')
64
+ lh_white, rh_white = get_surface_paths('white', 'bmesh')
65
+ lh_pial, rh_pial = get_surface_paths('pial', 'bmesh')
66
+ lh_mask_path, rh_mask_path = get_surface_paths('nomedialwall', 'label')
67
+
68
+ # run wb_command wrappers
69
+ print("running volume-to-surface projection...")
70
+ run_wb_import(nii_path, wb_txt_path, labeled_nii)
71
+ run_wb_projection(labeled_nii, lh_mid, lh_gii, lh_white, lh_pial)
72
+ run_wb_projection(labeled_nii, rh_mid, rh_gii, rh_white, rh_pial)
73
+
74
+ # extract LUT and apply include/exclude filtering
75
+ labels_dict = nib.load(lh_gii).labeltable.get_labels_as_dict()
76
+ valid_ids = []
77
+ lut_dict = {}
78
+
79
+ for rid, name in labels_dict.items():
80
+ if rid == 0 or name == '???': continue
81
+
82
+ # filter logic
83
+ if include_list:
84
+ if not any(inc in name for inc in include_list):
85
+ continue
86
+ elif exclude_list:
87
+ if any(exc in name for exc in exclude_list):
88
+ continue
89
+
90
+ clean_name = name.replace(' ', '_').replace('/', '-')
91
+ np.random.seed(rid)
92
+ r, g, b = np.random.randint(50, 255, 3)
93
+
94
+ # store the string in the dictionary instead of writing to a file yet
95
+ lut_dict[rid] = f"{rid} {clean_name} {r} {g} {b} 0"
96
+ valid_ids.append(rid)
97
+
98
+ print(f"found {len(valid_ids)} initial cortical regions. mapping and cleaning...")
99
+
100
+ # merge LH and RH, then apply masks
101
+ data = np.concatenate([
102
+ nib.load(lh_gii).darrays[0].data.astype(int).flatten(),
103
+ nib.load(rh_gii).darrays[0].data.astype(int).flatten()
104
+ ])
105
+
106
+ mask = np.concatenate([
107
+ nib.load(lh_mask_path).darrays[0].data.astype(int).flatten() != 0,
108
+ nib.load(rh_mask_path).darrays[0].data.astype(int).flatten() != 0
109
+ ])
110
+
111
+ data[~mask] = 0
112
+ data[~np.isin(data, valid_ids)] = 0
113
+
114
+ # build adjacency and run hole-filling
115
+ print("building surface adjacency and filling holes...")
116
+ adj = sp.block_diag((_build_adjacency(lh_mid), _build_adjacency(rh_mid))).tocsr()
117
+ adj.setdiag(1)
118
+ n_vert = len(data)
119
+
120
+ for _ in range(20):
121
+ holes = (data == 0) & mask
122
+ if not np.any(holes): break
123
+
124
+ unique, inv = np.unique(data, return_inverse=True)
125
+ one_hot = sp.coo_matrix((np.ones(n_vert), (np.arange(n_vert), inv)), shape=(n_vert, len(unique))).tocsr()
126
+ votes = (adj @ one_hot).toarray()
127
+
128
+ zero_idx = np.where(unique == 0)[0][0]
129
+ votes[:, zero_idx] = 0
130
+
131
+ winner = np.argmax(votes, axis=1)
132
+ fill_vals = unique[winner]
133
+ data[holes] = fill_vals[holes]
134
+
135
+ # smooth final boundaries
136
+ print("smoothing boundaries...")
137
+ for _ in range(10):
138
+ unique, inv = np.unique(data, return_inverse=True)
139
+ one_hot = sp.coo_matrix((np.ones(n_vert), (np.arange(n_vert), inv)), shape=(n_vert, len(unique))).tocsr()
140
+ winner = np.argmax((adj @ one_hot).toarray(), axis=1)
141
+ data = unique[winner]
142
+ data[~mask] = 0
143
+
144
+ # save the final vertex map
145
+ np.savetxt(out_csv, data, fmt='%i')
146
+
147
+ # find out which regions actually survived the smoothing/masking
148
+ surviving_ids = np.unique(data)
149
+
150
+ # filter the LUT lines to only include survivors
151
+ final_lines = []
152
+ dropped_count = 0
153
+
154
+ for rid, line_str in lut_dict.items():
155
+ if rid in surviving_ids:
156
+ final_lines.append(line_str)
157
+ else:
158
+ region_name = line_str.split()[1]
159
+ print(f"[WARNING] {region_name} (id {rid}) lost during smoothing/masking. dropping from lut.")
160
+ dropped_count += 1
161
+
162
+ # write the perfectly clean file
163
+ with open(out_lut, 'w') as f:
164
+ f.write("\n".join(final_lines))
165
+
166
+ print(f"final polished atlas saved to: {out_dir}")
167
+ print(f"saved {len(final_lines)} regions ({dropped_count} empty regions dropped).")
168
+
169
+ # cleanup intermediate Workbench files to save space
170
+ for temp_file in [labeled_nii, lh_gii, rh_gii]:
171
+ if os.path.exists(temp_file):
172
+ os.remove(temp_file)
173
+
174
+ def qc_custom_cortical_atlas(atlas_dir, atlasname='atlas'):
175
+ """
176
+ generates a quality control report for a custom cortical atlas.
177
+
178
+ reads the generated vertex map and lookup table, counts the vertices for each region,
179
+ saves a summary text file, and generates individual static plots for every region
180
+ to help identify mapping dropouts or anatomical bleed.
181
+
182
+ parameters
183
+ ----------
184
+ atlas_dir : str
185
+ absolute path to the custom atlas directory containing the .csv and .txt files.
186
+ atlasname : str, optional
187
+ prefix name of the files to check. default is 'atlas'.
188
+ """
189
+
190
+ csv_path = os.path.join(atlas_dir, f"{atlasname}.csv")
191
+ lut_path = os.path.join(atlas_dir, f"{atlasname}.txt")
192
+ qc_dir = os.path.join(atlas_dir, "qc_report")
193
+
194
+ os.makedirs(qc_dir, exist_ok=True)
195
+
196
+ # load the mapped data and the lookup table
197
+ labels = np.loadtxt(csv_path).astype(int)
198
+
199
+ regions = {}
200
+ with open(lut_path, 'r') as f:
201
+ for line in f:
202
+ parts = line.strip().split()
203
+ if len(parts) >= 2:
204
+ regions[int(parts[0])] = parts[1]
205
+
206
+ print(f"starting qc for {len(regions)} regions...\n")
207
+
208
+ report_path = os.path.join(qc_dir, "_vertex_counts.txt")
209
+
210
+ with open(report_path, 'w') as f_out:
211
+ f_out.write("region_name\tid\tvertex_count\n")
212
+ f_out.write("-" * 40 + "\n")
213
+
214
+ for rid, name in regions.items():
215
+ count = np.sum(labels == rid)
216
+ f_out.write(f"{name}\t{rid}\t{count}\n")
217
+
218
+ print(f"[{name}] id: {rid} | vertices: {count}")
219
+
220
+ if count == 0:
221
+ print(f"[WARNING] {name} is empty! skipping plot.")
222
+ continue
223
+
224
+ plot_file = os.path.join(qc_dir, f"{rid:03d}_{name}.png")
225
+
226
+ try:
227
+ plot_cortical(
228
+ data={name: 1},
229
+ custom_atlas_path=atlas_dir,
230
+ cmap='binary',vminmax=[0, 1],
231
+ export_path=plot_file
232
+ )
233
+ except Exception as e:
234
+ print(f" -> failed to plot {name}: {e}")
235
+
236
+ print(f"\nqc complete! check the '{qc_dir}' folder for the report and images.")
237
+
238
+
239
+ ### SUBCORTICAL
240
+
241
+ def build_subcortical_atlas(nii_path, labels_dict, out_dir, include_list=None, exclude_list=None,
242
+ smooth_i=15, smooth_f=0.6):
243
+ """
244
+ extracts 3D subcortical meshes from a volumetric nifti atlas.
245
+
246
+ uses the marching cubes algorithm to generate 3D surface meshes for specific
247
+ regions, applies laplacian smoothing to remove voxel artifacts, and saves them as .vtk files.
248
+
249
+ parameters
250
+ ----------
251
+ nii_path : str
252
+ absolute path to the 3D nifti volume.
253
+ labels_dict : dict
254
+ dictionary mapping integer region IDs to string names (e.g., {1: 'thalamus_l'}).
255
+ out_dir : str
256
+ directory where the .vtk mesh files will be saved.
257
+ include_list : list of str, optional
258
+ keywords of regions to strictly include. all other regions are ignored.
259
+ exclude_list : list of str, optional
260
+ keywords of regions to strictly exclude. all other regions are kept.
261
+ smooth_i : int, optional
262
+ number of iterations for laplacian mesh smoothing. default is 15.
263
+ smooth_f : float, optional
264
+ relaxation factor for laplacian mesh smoothing (0.0 to 1.0). default is 0.6.
265
+
266
+ raises
267
+ ------
268
+ ValueError
269
+ if both include_list and exclude_list are provided.
270
+ """
271
+ if include_list and exclude_list:
272
+ raise ValueError("please provide either 'include_list' or 'exclude_list', not both.")
273
+
274
+ os.makedirs(out_dir, exist_ok=True)
275
+
276
+ # apply the include/exclude filters to the provided dictionary
277
+ targets = {}
278
+ for rid, name in labels_dict.items():
279
+ if include_list:
280
+ if not any(inc in name for inc in include_list):
281
+ continue
282
+ elif exclude_list:
283
+ if any(exc in name for exc in exclude_list):
284
+ continue
285
+
286
+ targets[rid] = name
287
+
288
+ print(f"filtered down to {len(targets)} subcortical regions to extract.")
289
+
290
+ # load the nifti volume and its affine matrix
291
+ img = nib.load(nii_path)
292
+ data = img.get_fdata()
293
+ affine = img.affine
294
+
295
+ # loop through targets, extract meshes, and save
296
+ for rid, name in targets.items():
297
+ # create a binary mask for just this region
298
+ mask = (data == rid).astype(np.uint8)
299
+
300
+ # skip if empty
301
+ if np.sum(mask) == 0:
302
+ print(f"[WARNING] {name} is empty in the volume!")
303
+ continue
304
+
305
+ print(f"extracting: {name} (id {rid})...")
306
+
307
+ # run marching cubes to get raw vertices and faces
308
+ verts, faces, normals, values = measure.marching_cubes(mask, level=0.5)
309
+
310
+ # apply the nifti affine matrix cleanly using nibabel
311
+ verts_mni = nib.affines.apply_affine(affine, verts)
312
+
313
+ # format faces for pyvista: [n_points, p1, p2, p3, n_points, p1, p2, p3...]
314
+ faces_pv = np.column_stack((np.full(len(faces), 3), faces)).flatten()
315
+
316
+ # create the 3d pyvista mesh
317
+ mesh = pv.PolyData(verts_mni, faces_pv)
318
+
319
+ # apply laplacian smoothing to melt away the blocky voxel edges
320
+ mesh = mesh.smooth(n_iter=smooth_i, relaxation_factor=smooth_f)
321
+ mesh.compute_normals(inplace=True)
322
+
323
+ # we remove super small structures which would not be visible
324
+ if mesh.n_points < 4 or abs(mesh.volume) < 0.01:
325
+ print(f"[WARNING] {name} is too small to form a 3D mesh (volume: {abs(mesh.volume):.4f} mm³). dropping from atlas.")
326
+ continue
327
+
328
+ # save as a vtk file
329
+ out_file = os.path.join(out_dir, f"{name}.vtk")
330
+ mesh.save(out_file)
331
+
332
+ print(f"\nsubcortical atlas successfully saved to: {out_dir}")
333
+
334
+ def qc_custom_subcortical_atlas(atlas_dir):
335
+ """
336
+ generates a quality control report for a custom subcortical atlas.
337
+
338
+ reads the generated .vtk meshes, calculates their geometric properties
339
+ (vertices, faces, volume), saves a summary text file, and generates
340
+ individual static plots for every region to help identify corrupt meshes
341
+ or anatomical artifacts.
342
+
343
+ parameters
344
+ ----------
345
+ atlas_dir : str
346
+ absolute path to the custom atlas directory containing the .vtk files.
347
+ """
348
+
349
+ qc_dir = os.path.join(atlas_dir, "qc_report")
350
+ os.makedirs(qc_dir, exist_ok=True)
351
+
352
+ # find all vtk files in the atlas directory
353
+ vtk_files = glob.glob(os.path.join(atlas_dir, "*.vtk"))
354
+
355
+ if not vtk_files:
356
+ print(f"no .vtk files found in {atlas_dir}. cannot run qc.")
357
+ return
358
+
359
+ print(f"starting qc for {len(vtk_files)} subcortical meshes...\n")
360
+
361
+ report_path = os.path.join(qc_dir, "_mesh_properties.txt")
362
+
363
+ with open(report_path, 'w') as f_out:
364
+ # header for our text report
365
+ f_out.write("region_name\tvertices\tfaces\tvolume_mm3\n")
366
+ f_out.write("-" * 55 + "\n")
367
+
368
+ for vtk_path in sorted(vtk_files):
369
+ filename = os.path.basename(vtk_path)
370
+ region_name = os.path.splitext(filename)[0]
371
+
372
+ # read mesh to extract physical properties
373
+ try:
374
+ mesh = pv.read(vtk_path)
375
+ n_verts = mesh.n_points
376
+ n_faces = mesh.n_cells
377
+ volume = mesh.volume
378
+ except Exception as e:
379
+ print(f" -> error reading {filename}: {e}")
380
+ f_out.write(f"{region_name}\tERROR\tERROR\tERROR\n")
381
+ continue
382
+
383
+ f_out.write(f"{region_name}\t{n_verts}\t{n_faces}\t{volume:.2f}\n")
384
+ print(f"[{region_name}] vertices: {n_verts} | volume: {volume:.1f} mm³")
385
+
386
+ # check for empty or severely corrupted meshes
387
+ if n_verts == 0:
388
+ print(f"[WARNING] {region_name} mesh is empty! skipping plot.")
389
+ continue
390
+
391
+ plot_file = os.path.join(qc_dir, f"{region_name}.png")
392
+
393
+ try:
394
+ plot_subcortical(
395
+ data={region_name: 1},
396
+ custom_atlas_path=atlas_dir,
397
+ cmap='binary', vminmax=[0, 1],
398
+ nan_alpha=0.2,
399
+ export_path=plot_file
400
+ )
401
+ except Exception as e:
402
+ print(f" -> failed to plot {region_name}: {e}")
403
+
404
+ print(f"\nqc complete! check the '{qc_dir}' folder for the report and images.")
@@ -70,6 +70,52 @@ def get_available_resources(category=None):
70
70
 
71
71
  return all_resources
72
72
 
73
+ def get_surface_paths(name, category):
74
+ """
75
+ Fetches and returns the paths to the Left and Right hemisphere files
76
+ for a given surface resource (meshes or labels).
77
+
78
+ Parameters
79
+ ----------
80
+ name : str
81
+ Name of the resource (e.g., 'midthickness', 'nomedialwall').
82
+ category : str
83
+ Must be 'bmesh' or 'label'.
84
+
85
+ Returns
86
+ -------
87
+ tuple
88
+ (lh_path, rh_path) containing absolute paths to the files.
89
+ """
90
+ if category not in ['bmesh', 'label']:
91
+ raise ValueError("Category must be 'bmesh' or 'label' to fetch surface paths.")
92
+
93
+ # Download/unpack the zip and get the folder path
94
+ directory = _resolve_resource_path(name, category)
95
+
96
+ lh_path = None
97
+ rh_path = None
98
+
99
+ # Traverse the unzipped directory to find L and R files
100
+ for root, dirs, files in os.walk(directory):
101
+ # Ignore hidden folders like .git or __MACOSX
102
+ dirs[:] = [d for d in dirs if not d.startswith(('.', '__'))]
103
+ for file in files:
104
+ # Ignore hidden files
105
+ if file.startswith('.'):
106
+ continue
107
+
108
+ # Robust checking for Left and Right hemisphere indicators
109
+ if '.L.' in file or '_L_' in file or 'hemi-L' in file:
110
+ lh_path = os.path.join(root, file)
111
+ elif '.R.' in file or '_R_' in file or 'hemi-R' in file:
112
+ rh_path = os.path.join(root, file)
113
+
114
+ if not lh_path or not rh_path:
115
+ raise FileNotFoundError(f"Could not locate both Left and Right hemisphere files for '{name}' in {directory}")
116
+
117
+ return lh_path, rh_path
118
+
73
119
  def get_atlas_regions(atlas, category, custom_atlas_path=None):
74
120
  """
75
121
  Returns the list of region names for a given atlas in the specific order
@@ -191,7 +237,8 @@ def _resolve_resource_path(name, category, custom_path=None):
191
237
  'cortical': 'Cortical parcellations (vertices)',
192
238
  'subcortical': 'Subcortical segmentations (volumes)',
193
239
  'tracts': 'White matter bundles (tracts)',
194
- 'bmesh': 'Brain meshes'
240
+ 'bmesh': 'Brain meshes',
241
+ 'label': 'Surface labels'
195
242
  }.get(category, category)
196
243
 
197
244
  raise ValueError(
@@ -5,11 +5,14 @@ cortical-schaefer_200.zip sha256:065ee05afa1881a8884bdecee25a062ddd5fd6283bfeaab
5
5
  cortical-schaefer_300.zip sha256:5430c66eaef58f008b9e20dd5c59c670c7f7154b02f4b310dda97250331b6eed https://osf.io/9djtr/download
6
6
  cortical-schaefer_400.zip sha256:ae2b7011919d49e9930ee5aed1b19731180427f07f0d1c4497130dd9cd5e878d https://osf.io/pzmc5/download
7
7
  cortical-schaefer_1000.zip sha256:a10448f101a874499d41bc62508c3af29120634955f48beb282ece9a4cc2bb0e https://osf.io/j9ygz/download
8
+ cortical-aal3.zip sha256:925e475e099b127925e20e6ec4d179b1a58971f2bc32b2a61f56a479f3e8d4fb https://osf.io/z9jxh/download
8
9
  subcortical-aseg.zip sha256:a901a7fc6a39f9bdaf2ef2bafbcde1fbc085b225d99e6730c727650d9be047d5 https://osf.io/5cs7y/download
9
10
  subcortical-brainnetome_sc.zip sha256:8301fdf6af109af52a2cf9b06d15486345d457b69cb86169ed38332a34a681c0 https://osf.io/2fsg5/download
10
11
  subcortical-jhu.zip sha256:b0ca292589a9f041851dba8159bb605a4657323e4305ba1570a304944b28d0de https://osf.io/x5fhg/download
11
12
  subcortical-musus100_dbn.zip sha256:1d865832a35570a8c67f79d5049b58c548d25cf4fe1853164c384193b6712e40 https://osf.io/eutmb/download
12
13
  subcortical-tian2020_sc.zip sha256:8b5caf8bf0cdcf8e259a3532fbcb232f8746d68fc734ca9806d10e70cf8707a3 https://osf.io/jrvgp/download
14
+ subcortical-aal3.zip sha256:48abe400656d913cc459ee8766e373cc5eabbeb9e4f770aa7396da3d4a0ab3ca https://osf.io/39ebh/download
15
+ subcortical-aal3_nocer.zip sha256:b87de55861cdd18ddb78934964b0e0855f770560bdc878570fcbb8f27fefb674 https://osf.io/7jdxz/download
13
16
  tracts-hcp1065_medium.zip sha256:366bb100074cf7b1e55586e5468653f0c342e42cfdd91dca6d99b6fde82f06ff https://osf.io/kjf8e/download
14
17
  tracts-hcp1065_small.zip sha256:020f23059c3a20ee0dda8ad12cd3df99b9fe5d3b37e7a6b9ae34b8a52b4b4346 https://osf.io/ynpa5/download
15
18
  tracts-hcp1065_tiny.zip sha256:54380d82f5cd234029c6fc011910e00f0ad859fdb6c22c6aaa6452d055449ae9 https://osf.io/jzk7p/download
@@ -17,5 +20,10 @@ tracts-xtract_large.zip sha256:e2d59e9f90b024018788af87e389f93b2fa9ac21360460196
17
20
  tracts-xtract_medium.zip sha256:f095028d3dfc0f974ebef1feffe4006b5bac1366325a784ea2301c56f583b1e7 https://osf.io/7tbjg/download
18
21
  tracts-xtract_small.zip sha256:9d3fe2c57acb87e8d13eb40a49a6c2c354dbe1a020122b693e9bf713e57cad5b https://osf.io/bmn3a/download
19
22
  tracts-xtract_tiny.zip sha256:469f9ed8ed5ceb7f8e17c9a0a92f1491d540814f832ef56441a7539532111f41 https://osf.io/a73x2/download
20
- bmesh-conte69.zip sha256:5ba9ffc89ae7f640590a80fc188d277b1b49a34a0bd99c0c9e5cfb52fc963cac https://osf.io/h4ugr/download
21
- bmesh-fsaverage.zip sha256:bccec9e3fc10ffc9d1cbdb6ad2b0ffd81c93ca0fa8d26fc530d9b4d58dfcd7a0 https://osf.io/sq7am/download
23
+ bmesh-inflated.zip sha256:ba72f9e75f16fe767f6bbcec5c98381f6a20b2f5e2092505b4692844a1686461 https://osf.io/kfzjg/download
24
+ bmesh-midthickness.zip sha256:ef8209853e1dac8804a60b5cde0b653ef2f6c77b6c7536f7acaa69a46dbb06c0 https://osf.io/xaktf/download
25
+ bmesh-pial.zip sha256:5e36ba7883dd2a88485fc45c9166ed48e22db607d45655242961242b51f9f126 https://osf.io/knpg8/download
26
+ bmesh-swm.zip sha256:1a516c070cb63557751d841d5cf75772660872b89ec749ee19c23298d77fa807 https://osf.io/hpbc9/download
27
+ bmesh-very_inflated.zip sha256:8c33a00c718a47f0af118ce2c5804a2adf13d79be05c4665ca26582e3e8dd977 https://osf.io/xp9jr/download
28
+ bmesh-white.zip sha256:b885ac1a4dcdf9e241a97e8d3ca59d7b1c86d8a3ebe17df512993bc5c1c0354a https://osf.io/wfc5t/download
29
+ label-nomedialwall.zip sha256:03bd589b151814a1fc5e48236a78decf1a51791f04c3f4a521d2ed4fb214317b https://osf.io/2rgmc/download
@@ -14,7 +14,8 @@ from .data import (
14
14
  from .utils import (
15
15
  load_gii, load_gii2pv, prep_data,
16
16
  generate_distinct_colors, parse_lut, map_values_to_surface,
17
- lines_from_streamlines, make_cortical_mesh
17
+ get_puzzle_pieces, apply_internal_blur, apply_dilation,
18
+ get_smooth_mask, lines_from_streamlines, make_cortical_mesh
18
19
  )
19
20
 
20
21
  from .scene import (
@@ -26,8 +27,8 @@ from .scene import (
26
27
  # --- plot for cortical surface ---
27
28
 
28
29
  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,
30
+ bmesh_type='midthickness', figsize=(1000, 600), cmap='coolwarm', vminmax=[None, None],
31
+ nan_color=(1.0, 1.0, 1.0), style='default', zoom=1.2, proc_vertices=None,
31
32
  display_type='static', export_path=None):
32
33
  """
33
34
  Visualize data on the cortical surface using a specified atlas.
@@ -54,6 +55,9 @@ def plot_cortical(data=None, atlas=None, custom_atlas_path=None, views=None, lay
54
55
  or a dictionary of camera configurations. Defaults to all views.
55
56
  layout : tuple (rows, cols), optional
56
57
  Grid layout for subplots. If None, automatically calculated based on the number of views.
58
+ bmesh_type : str
59
+ Name of the background context brain mesh (e.g., 'midthickness', 'white', 'swm', etc).
60
+ Default is 'midthickness'.
57
61
  figsize : tuple (width, height), optional
58
62
  Window size in pixels. Default is (1000, 600).
59
63
  cmap : str or matplotlib.colors.Colormap, optional
@@ -67,6 +71,11 @@ def plot_cortical(data=None, atlas=None, custom_atlas_path=None, views=None, lay
67
71
  Lighting preset ('default', 'matte', 'glossy', 'sculpted', 'flat').
68
72
  zoom : float, optional
69
73
  Camera zoom level. >1.0 zooms in, <1.0 zooms out. Default is 1.2.
74
+ proc_vertices : str or None, optional
75
+ Whether to process the vertices edges according to geometry of bmesh.
76
+ Set to None to not perform (default).
77
+ 'blur': Applies simple blurring between different color vertices (low performance impact).
78
+ 'sharp': Applies sharpening of the resolution of different color vertices (high performance impact).
70
79
  display_type : {'static', 'interactive', 'none'}, optional
71
80
  'static': Returns a static image (good for notebooks).
72
81
  'interactive': Opens an interactive viewer.
@@ -80,19 +89,18 @@ def plot_cortical(data=None, atlas=None, custom_atlas_path=None, views=None, lay
80
89
  The plotter instance used for rendering.
81
90
  """
82
91
 
83
- # defaults
92
+ # atlas and categorical check
84
93
  if atlas is None and custom_atlas_path is None:
85
94
  atlas = 'aparc'
95
+ is_cat = (data is None)
86
96
 
87
97
  # 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'))
98
+ bmesh_path = _resolve_resource_path(bmesh_type, 'bmesh')
99
+ lh_v, lh_f = load_gii(os.path.join(bmesh_path, f'fs_LR.32k.L.{bmesh_type}.surf.gii'))
100
+ rh_v, rh_f = load_gii(os.path.join(bmesh_path, f'fs_LR.32k.R.{bmesh_type}.surf.gii'))
91
101
 
92
- # resolve atlas path (either download or custom directory)
102
+ # resolve atlas
93
103
  atlas_dir = _resolve_resource_path(atlas, 'cortical', custom_path=custom_atlas_path)
94
-
95
- # locate files
96
104
  check_name = None if custom_atlas_path else atlas
97
105
  csv_path, lut_path = _find_cortical_files(atlas_dir, strict_name=check_name)
98
106
 
@@ -102,28 +110,43 @@ def plot_cortical(data=None, atlas=None, custom_atlas_path=None, views=None, lay
102
110
 
103
111
  # map data
104
112
  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):]
113
+ lh_vals_raw = all_vals[:len(lh_v)]
114
+ rh_vals_raw = all_vals[len(lh_v):]
115
+
116
+ # vertices processing
117
+ results = []
118
+ for v, f, raw in [(lh_v, lh_f, lh_vals_raw), (rh_v, rh_f, rh_vals_raw)]:
119
+ if proc_vertices == 'sharp':
120
+ # razor-sharp puzzle mode
121
+ base, pieces = get_puzzle_pieces(v, f, raw)
122
+ results.append((base, pieces))
123
+ else:
124
+ # single clipper mode (blur or none)
125
+ v_proc = apply_internal_blur(f, raw, iterations=3, weight=0.3) if proc_vertices == 'blur' else raw
126
+ dilated = apply_dilation(f, v_proc, iterations=4)
127
+ o_guide = get_smooth_mask(f, np.where(np.isnan(raw), 0.0, 1.0), iterations=4)
128
+
129
+ mesh = make_cortical_mesh(v, f, dilated)
130
+ mesh['Slice_Mask'] = o_guide
131
+ data_p = mesh.clip_scalar(scalars='Slice_Mask', value=0.5, invert=False)
132
+ base_p = mesh.clip_scalar(scalars='Slice_Mask', value=0.5, invert=True)
133
+ if base_p.n_points > 0: base_p['Data'] = np.full(base_p.n_points, np.nan)
134
+ results.append((base_p, [data_p]))
135
+ (lh_base, lh_parts), (rh_base, rh_parts) = results
107
136
 
108
137
  # setup colors
109
- is_categorical = (data is None)
110
138
  n_colors = 256
111
- if is_categorical:
139
+ if is_cat:
112
140
  _lut_colors = lut_colors.copy()
113
- _lut_colors[0] = nan_color
141
+ _lut_colors[0] = nan_color # TODO: check nancolor
114
142
  cmap = ListedColormap(_lut_colors)
115
143
  n_colors = len(_lut_colors)
116
144
  vmin, vmax = 0, max_id
117
145
  else:
118
- if cmap is None: cmap = 'RdYlBu_r'
119
146
  vmin = vminmax[0] if vminmax[0] is not None else np.nanmin(all_vals)
120
147
  vmax = vminmax[1] if vminmax[1] is not None else np.nanmax(all_vals)
121
148
 
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
149
+ # plotter setup
127
150
  sel_views = get_view_configs(views)
128
151
  plotter, ncols, nrows = setup_plotter(sel_views, layout, figsize, display_type)
129
152
  shading_params = get_shading_preset(style)
@@ -132,46 +155,46 @@ def plot_cortical(data=None, atlas=None, custom_atlas_path=None, views=None, lay
132
155
  for i, (name, cfg) in enumerate(sel_views.items()):
133
156
  plotter.subplot(i // ncols, i % ncols)
134
157
 
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:
158
+ view_bases = []
159
+ view_pieces = []
160
+ if cfg['side'] in ['L', 'both']:
161
+ if lh_base.n_points > 0: view_bases.append(lh_base)
162
+ view_pieces.extend(lh_parts)
163
+ if cfg['side'] in ['R', 'both']:
164
+ if rh_base.n_points > 0: view_bases.append(rh_base)
165
+ view_pieces.extend(rh_parts)
166
+
167
+ # brain meshes
168
+ for b_mesh in view_bases:
169
+ plotter.add_mesh(b_mesh, color=nan_color, smooth_shading=True, **shading_params)
170
+
171
+ # data vertices
172
+ for p_mesh in view_pieces:
173
+ if p_mesh.n_points == 0: continue
174
+ interp = (proc_vertices == 'blur') # only blur mode uses interpolation
175
+
140
176
  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
- )
177
+ p_mesh, scalars='Data', cmap=cmap, clim=(vmin, vmax),
178
+ n_colors=n_colors, nan_color=nan_color, show_scalar_bar=False,
179
+ smooth_shading=True, interpolate_before_map=interp, **shading_params
180
+ ) # rgb=False, show_edges=False, interpolate_before_map=False,
154
181
  if scalar_bar_mapper is None: scalar_bar_mapper = actor.mapper
155
182
 
156
183
  set_camera(plotter, cfg, zoom=zoom)
157
184
  plotter.hide_axes()
158
-
159
- if not is_categorical and scalar_bar_mapper:
185
+
186
+ if not is_cat and scalar_bar_mapper:
160
187
  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)
188
+ plotter.add_scalar_bar(mapper=scalar_bar_mapper, vertical=False, position_x=0.3, position_y=0.25, height=0.5, width=0.4)
165
189
 
166
190
  return finalize_plot(plotter, export_path, display_type)
167
191
 
168
192
 
169
-
170
193
  # --- plot for subcortical structures ---
171
194
 
172
195
  def plot_subcortical(data=None, atlas=None, custom_atlas_path=None, views=None, layout=None,
173
196
  figsize=(1000, 600), cmap='coolwarm', vminmax=[None, None], nan_color='#cccccc',
174
- nan_alpha=1.0, legend=False, style='default', bmesh_type='conte69',
197
+ nan_alpha=1.0, legend=False, style='default', bmesh_type='midthickness',
175
198
  bmesh_alpha=0.1, bmesh_color='lightgray', zoom=1.2, display_type='static',
176
199
  export_path=None, custom_atlas_proc=dict(smooth_i=15, smooth_f=0.6)):
177
200
  """
@@ -213,8 +236,8 @@ def plot_subcortical(data=None, atlas=None, custom_atlas_path=None, views=None,
213
236
  style : str, optional
214
237
  Lighting preset ('default', 'matte', 'glossy', 'sculpted', 'flat').
215
238
  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.
239
+ Name of the background context brain mesh (e.g., 'midthickness', 'white', 'swm', etc).
240
+ Set to None to hide the context brain. Default is 'midthickness'.
218
241
  bmesh_alpha : float, optional
219
242
  Opacity of the context brain mesh. Default is 0.1.
220
243
  bmesh_color : str, optional
@@ -246,8 +269,8 @@ def plot_subcortical(data=None, atlas=None, custom_atlas_path=None, views=None,
246
269
  bmesh = {}
247
270
  if bmesh_type:
248
271
  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')
272
+ for h in ['L', 'R']:
273
+ fpath = os.path.join(bmesh_path, f'fs_LR.32k.{h}.{bmesh_type}.surf.gii')
251
274
  if os.path.exists(fpath):
252
275
  bmesh[h] = load_gii2pv(fpath)
253
276
 
@@ -371,7 +394,7 @@ def clear_tract_cache():
371
394
  def plot_tracts(data=None, atlas=None, custom_atlas_path=None, views=None, layout=None,
372
395
  figsize=(1000, 800), cmap='coolwarm', alpha=1.0, vminmax=[None, None],
373
396
  nan_color='#BDBDBD', nan_alpha=1.0, legend=False, style='default',
374
- bmesh_type='conte69', bmesh_alpha=0.2, bmesh_color='lightgray',
397
+ bmesh_type='midthickness', bmesh_alpha=0.2, bmesh_color='lightgray',
375
398
  zoom=1.2, orientation_coloring=False, display_type='static',
376
399
  tract_kwargs=dict(render_lines_as_tubes=True, line_width=1.2),
377
400
  export_path=None):
@@ -417,8 +440,8 @@ def plot_tracts(data=None, atlas=None, custom_atlas_path=None, views=None, layou
417
440
  style : str, optional
418
441
  Lighting preset ('default', 'matte', 'glossy', 'sculpted', 'flat').
419
442
  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.
443
+ Name of the background context brain mesh (e.g., 'midthickness', 'white', 'swm', etc).
444
+ Set to None to hide the context brain. Default is 'midthickness'.
422
445
  bmesh_alpha : float, optional
423
446
  Opacity of the context brain mesh. Default is 0.2.
424
447
  bmesh_color : str, optional
@@ -472,8 +495,8 @@ def plot_tracts(data=None, atlas=None, custom_atlas_path=None, views=None, layou
472
495
  bmesh = {}
473
496
  if bmesh_type:
474
497
  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')
498
+ for h in ['L', 'R']:
499
+ fpath = os.path.join(bmesh_path, f'fs_LR.32k.{h}.{bmesh_type}.surf.gii')
477
500
  if os.path.exists(fpath):
478
501
  bmesh[h] = load_gii2pv(fpath)
479
502
 
@@ -91,7 +91,7 @@ def setup_plotter(sel_views, layout, figsize, display_type, needs_bottom_row=Tru
91
91
  row_weights = None
92
92
 
93
93
  plotter = pv.Plotter(shape=(nrows, ncols), groups=groups, row_weights=row_weights,
94
- off_screen=(display_type=='none'), window_size=figsize, border=False)
94
+ off_screen=(display_type=='object'), window_size=figsize, border=False)
95
95
  plotter.set_background('white')
96
96
  return plotter, ncols, nrows
97
97
 
@@ -101,7 +101,7 @@ def add_context_to_view(plotter, bmesh, view_side, alpha, color, **kwargs):
101
101
  """
102
102
  if not bmesh: return
103
103
  for h, mesh in bmesh.items():
104
- if (view_side == 'L' and h == 'rh') or (view_side == 'R' and h == 'lh'): continue
104
+ if (view_side == 'L' and h == 'L') or (view_side == 'R' and h == 'R'): continue
105
105
  plotter.add_mesh(mesh, color=color, opacity=alpha,
106
106
  smooth_shading=True, show_edges=False,
107
107
  **kwargs)
@@ -115,10 +115,19 @@ def set_camera(plotter, view_cfg, zoom=1.0, distance=200):
115
115
  plotter.camera.zoom(zoom)
116
116
 
117
117
  def finalize_plot(plotter, export_path, display_type):
118
- if export_path: plotter.screenshot(export_path, transparent_background=True)
118
+ if export_path:
119
+ plotter.screenshot(export_path, transparent_background=True)
119
120
 
120
- if display_type == 'static': plotter.show(jupyter_backend='static')
121
- elif display_type == 'interactive': plotter.show(jupyter_backend='trame')
122
- elif display_type == 'none': plotter.close()
123
- return plotter
124
-
121
+ if display_type == 'static':
122
+ out = plotter.show(jupyter_backend='static')
123
+ plotter.close()
124
+ elif display_type == 'interactive':
125
+ out = plotter.show(jupyter_backend='trame')
126
+ elif display_type == 'object':
127
+ plotter.render()
128
+ return plotter
129
+ else:
130
+ plotter.close()
131
+ out = None
132
+
133
+ return out
@@ -3,6 +3,7 @@ import numpy as np
3
3
  import pandas as pd
4
4
  import nibabel as nib
5
5
  import pyvista as pv
6
+ import scipy.sparse as sp
6
7
  import matplotlib.pyplot as plt
7
8
  from importlib.resources import files
8
9
 
@@ -143,6 +144,93 @@ def map_values_to_surface(data, target_labels, lut_ids, dense_lut_names):
143
144
  lookup_table[valid_ids] = source_values
144
145
  return lookup_table[target_labels]
145
146
 
147
+ def get_adj(faces, n_v):
148
+ """build adjacency matrix from faces."""
149
+ row, col = [], []
150
+ for tri in faces:
151
+ row.extend([tri[0], tri[1], tri[2], tri[0], tri[1], tri[2]])
152
+ col.extend([tri[1], tri[2], tri[0], tri[2], tri[0], tri[1]])
153
+ adj = sp.csc_matrix((np.ones_like(row), (row, col)), shape=(n_v, n_v))
154
+ adj.data = np.ones_like(adj.data)
155
+ return adj
156
+
157
+ def get_smooth_mask(faces, data, iterations=4):
158
+ """blur binary mask for guide of geometric slicing."""
159
+ n_v = len(data)
160
+ mask = data.astype(np.float64)
161
+ adj = get_adj(faces, n_v)
162
+ deg = np.array(adj.sum(axis=1)).flatten()
163
+ deg[deg == 0] = 1.0
164
+ for _ in range(iterations):
165
+ mask = (mask + (adj.dot(mask) / deg)) / 2.0
166
+ return mask
167
+
168
+ def apply_internal_blur(faces, data, iterations=1, weight=0.2):
169
+ """blur data only on borders where different regions touch."""
170
+ data_out = np.copy(data)
171
+ n_v = len(data)
172
+ adj = get_adj(faces, n_v)
173
+ rows, cols = adj.nonzero()
174
+ valid = ~np.isnan(data_out)
175
+ diff = valid[rows] & valid[cols] & ~np.isclose(data_out[rows], data_out[cols], atol=1e-5)
176
+ b_verts = np.unique(rows[diff])
177
+
178
+ if len(b_verts) == 0: return data_out
179
+
180
+ for _ in range(iterations):
181
+ temp = np.nan_to_num(data_out, nan=0.0)
182
+ v_counts = adj.dot(valid.astype(float))
183
+ v_counts[v_counts == 0] = 1.0
184
+ n_mean = adj.dot(temp) / v_counts
185
+ data_out[b_verts] = (1 - weight) * data_out[b_verts] + weight * n_mean[b_verts]
186
+ return data_out
187
+
188
+ def apply_dilation(faces, data, iterations=4):
189
+ """push values into NaN space to keep geometric cut pure."""
190
+ data_out = np.copy(data)
191
+ n_v = len(data)
192
+ adj = get_adj(faces, n_v)
193
+ for _ in range(iterations):
194
+ nan_m = np.isnan(data_out)
195
+ temp = np.nan_to_num(data_out, nan=0.0)
196
+ v_counts = adj.dot((~nan_m).astype(float))
197
+ s_neighbors = adj.dot(temp)
198
+ u_mask = nan_m & (v_counts > 0)
199
+ data_out[u_mask] = s_neighbors[u_mask] / v_counts[u_mask]
200
+ return data_out
201
+
202
+ def get_puzzle_pieces(v, f, raw_vals):
203
+ """carve out geometric pieces with slight overlap to prevent gaps."""
204
+ pieces = []
205
+ valid_mask = ~np.isnan(raw_vals) & (raw_vals != 0.0)
206
+ u_vals = np.unique(raw_vals[valid_mask])
207
+ master = make_cortical_mesh(v, f, np.zeros_like(raw_vals))
208
+
209
+ for val in u_vals:
210
+ r_mask = np.where(raw_vals == val, 1.0, 0.0)
211
+ s_mask = get_smooth_mask(f, r_mask, iterations=4)
212
+ temp = master.copy()
213
+ temp['Slice_Mask'] = s_mask
214
+ # reduce search space
215
+ patch = temp.threshold(0.01, scalars='Slice_Mask')
216
+ if patch.n_points > 0:
217
+ # use 0.48 (slightly expanded) for pieces to seal cracks
218
+ piece = patch.clip_scalar(scalars='Slice_Mask', value=0.48, invert=False)
219
+ if piece.n_points > 0:
220
+ piece['Data'] = np.full(piece.n_points, val)
221
+ pieces.append(piece)
222
+
223
+ # slice base brain
224
+ all_mask = np.where(valid_mask, 1.0, 0.0)
225
+ s_all = get_smooth_mask(f, all_mask, iterations=4)
226
+ master['Slice_Mask'] = s_all
227
+ # use 0.52 (slightly contracted) for the hole to ensure colored pieces cover the edge
228
+ base_p = master.clip_scalar(scalars='Slice_Mask', value=0.52, invert=True)
229
+ if base_p.n_points > 0:
230
+ base_p['Data'] = np.full(base_p.n_points, np.nan)
231
+
232
+ return base_p, pieces
233
+
146
234
  def lines_from_streamlines(streamlines):
147
235
  if len(streamlines) == 0: return np.array([]), np.array([]), np.array([])
148
236
 
@@ -155,7 +243,7 @@ def lines_from_streamlines(streamlines):
155
243
  cells.append(np.hstack([[length], np.arange(offset, offset + length)]))
156
244
  lines = np.hstack(cells)
157
245
 
158
- # Calculate tangents
246
+ # calculate tangents
159
247
  tangents = []
160
248
  for s in streamlines:
161
249
  if len(s) < 2:
@@ -0,0 +1,36 @@
1
+ import subprocess
2
+ import shutil
3
+ import os
4
+
5
+ def check_workbench():
6
+ """
7
+ Checks if Connectome Workbench (wb_command) is installed and available in PATH.
8
+
9
+ Raises
10
+ ------
11
+ EnvironmentError
12
+ If wb_command is not found, providing instructions for installation.
13
+ """
14
+ if shutil.which('wb_command') is None:
15
+ raise EnvironmentError(
16
+ "Connectome Workbench ('wb_command') was not found in your system PATH.\n"
17
+ "This is required for volume-to-surface projection (necessary for creating a custom cortical atlas).\n"
18
+ "Please download it from: https://humanconnectome.org/software/get-connectome-workbench\n"
19
+ "After installing, ensure the 'bin' folder is added to your PATH environment variable."
20
+ )
21
+
22
+ def run_wb_import(input_nii, label_list, output_nii):
23
+ """Wrapper for wb_command -volume-label-import"""
24
+ check_workbench()
25
+ cmd = ["wb_command", "-volume-label-import", input_nii, label_list, output_nii]
26
+ subprocess.run(cmd, check=True)
27
+
28
+ def run_wb_projection(input_nii, midthickness, output_gii, white, pial):
29
+ """Wrapper for wb_command -volume-label-to-surface-mapping (ribbon-constrained)"""
30
+ check_workbench()
31
+ cmd = [
32
+ "wb_command", "-volume-label-to-surface-mapping",
33
+ input_nii, midthickness, output_gii,
34
+ "-ribbon-constrained", white, pial
35
+ ]
36
+ subprocess.run(cmd, check=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yabplot
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: yet another brain plot
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -55,6 +55,9 @@ pip install yabplot --upgrade # to update
55
55
 
56
56
  dependencies: python 3.11 with ipywidgets, nibabel, pandas, pooch, pyvista, scikit-image, trame, trame-vtk, trame-vuetify
57
57
 
58
+ (Connectome Workbench (`wb_command`) is a requirement to create custom cortical atlases unless you plan to only use pre-loaded atlases; see more in docs)
59
+
60
+
58
61
  ## quick start
59
62
 
60
63
  please refer to the [documentation](https://teanijarv.github.io/yabplot/) for more comprehensive guides.
@@ -63,32 +66,41 @@ please refer to the [documentation](https://teanijarv.github.io/yabplot/) for mo
63
66
  import yabplot as yab
64
67
  import numpy as np
65
68
 
66
- # see available cortical atlases
67
- atlases = yab.get_available_resources(category='cortical')
68
-
69
- # see the region names within the aseg atlas
70
- regions = yab.get_atlas_regions(atlas='aseg', category='subcortical')
71
-
72
- # plot data on cortical regions
73
- data = np.arange(0, 1, 0.001)
74
- yab.plot_cortical(data=data, atlas='schaefer_1000', figsize=(600, 300),
75
- cmap='viridis', vminmax=[0, 1], style='default',
76
- views=['left_lateral', 'superior', 'right_lateral'])
77
-
78
-
79
- # plot values for specific subcortical regions
80
- data = {'Left_Amygdala': 0.8, 'Right_Hippocampus': 0.5,
81
- 'Right_Thalamus': -0.5, 'Left_Putamen': -1}
82
- yab.plot_subcortical(data=data, atlas='aseg', figsize=(600, 450), layout=(2, 2),
83
- views=['superior', 'anterior', 'left_lateral', 'right_lateral'],
84
- cmap='coolwarm', vminmax=[-1, 1], style='matte')
85
-
86
- # plot data on white matter bundles
87
- regions = yab.get_atlas_regions(atlas='xtract_tiny', category='tracts')
88
- data = np.arange(0, len(regions))
89
- yab.plot_tracts(data=data, atlas='xtract_tiny', figsize=(600, 300),
90
- views=['superior', 'anterior', 'left_lateral'], nan_color='#cccccc',
91
- bmesh_type='fsaverage', style='default', cmap='plasma')
69
+ # check that you have the latest version
70
+ print(yab.__version__)
71
+
72
+ # see available atlases and brain meshes
73
+ print(yab.get_available_resources())
74
+
75
+ # see the region names for a specific atlas
76
+ print(yab.get_atlas_regions(atlas='aseg', category='subcortical'))
77
+
78
+ # cortical surfaces
79
+ atlas = 'aparc'
80
+ dmap1 = {'L_lateraloccipital': 0.265, 'L_postcentral': 0.086, ...}
81
+ dmap2 = {'L_fusiform': 0.218, 'L_supramarginal': 0.119, ...}
82
+ yab.plot_cortical(data=dmap1, atlas=atlas, vminmax=[-0.1, 0.3],
83
+ bmesh_type='midthickness', views=['left_lateral', 'left_medial'],
84
+ figsize=(600, 300), cmap='viridis', proc_vertices='sharp')
85
+ yab.plot_cortical(data=dmap2, atlas=atlas, vminmax=[-0.1, 0.3],
86
+ bmesh_type='swm', views=['left_lateral', 'left_medial'],
87
+ figsize=(1200, 600), cmap='viridis', proc_vertices='sharp')
88
+
89
+ # subcortical structures
90
+ atlas = 'aseg'
91
+ regs = yab.get_atlas_regions(atlas=atlas, category='subcortical')
92
+ data = np.arange(1, len(regs)+1)
93
+ yab.plot_subcortical(data=data, atlas=atlas, vminmax=[2, 14],
94
+ views=['left_lateral', 'superior', 'right_lateral'],
95
+ bmesh_alpha=0.1, figsize=(600, 300), cmap='viridis')
96
+
97
+ # white matter bundles
98
+ atlas = 'xtract_tiny'
99
+ regs = yab.get_atlas_regions(atlas=atlas, category='tracts')
100
+ data = {reg: np.sin(i) for i, reg in enumerate(regs)}
101
+ yab.plot_tracts(data=data, atlas=atlas, style='matte',
102
+ views=['left_lateral', 'anterior', 'superior'], bmesh_type='pial',
103
+ bmesh_alpha=0.1, figsize=(1600, 800), cmap='viridis')
92
104
 
93
105
  ```
94
106
 
@@ -4,9 +4,11 @@ README.md
4
4
  pyproject.toml
5
5
  tests/test_smoke.py
6
6
  yabplot/__init__.py
7
+ yabplot/atlas_builder.py
7
8
  yabplot/plotting.py
8
9
  yabplot/scene.py
9
10
  yabplot/utils.py
11
+ yabplot/wrappers.py
10
12
  yabplot.egg-info/PKG-INFO
11
13
  yabplot.egg-info/SOURCES.txt
12
14
  yabplot.egg-info/dependency_links.txt
File without changes
File without changes
File without changes