yabplot 0.1.5__tar.gz → 0.2.2__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.
- {yabplot-0.1.5/yabplot.egg-info → yabplot-0.2.2}/PKG-INFO +4 -1
- {yabplot-0.1.5 → yabplot-0.2.2}/README.md +3 -0
- {yabplot-0.1.5 → yabplot-0.2.2}/pyproject.toml +2 -2
- {yabplot-0.1.5 → yabplot-0.2.2}/yabplot/__init__.py +2 -1
- yabplot-0.2.2/yabplot/atlas_builder.py +404 -0
- {yabplot-0.1.5 → yabplot-0.2.2}/yabplot/data/__init__.py +48 -1
- {yabplot-0.1.5 → yabplot-0.2.2}/yabplot/data/registry.txt +6 -2
- yabplot-0.2.2/yabplot/wrappers.py +36 -0
- {yabplot-0.1.5 → yabplot-0.2.2/yabplot.egg-info}/PKG-INFO +4 -1
- {yabplot-0.1.5 → yabplot-0.2.2}/yabplot.egg-info/SOURCES.txt +2 -0
- {yabplot-0.1.5 → yabplot-0.2.2}/LICENSE +0 -0
- {yabplot-0.1.5 → yabplot-0.2.2}/MANIFEST.in +0 -0
- {yabplot-0.1.5 → yabplot-0.2.2}/setup.cfg +0 -0
- {yabplot-0.1.5 → yabplot-0.2.2}/tests/test_smoke.py +0 -0
- {yabplot-0.1.5 → yabplot-0.2.2}/yabplot/plotting.py +0 -0
- {yabplot-0.1.5 → yabplot-0.2.2}/yabplot/scene.py +0 -0
- {yabplot-0.1.5 → yabplot-0.2.2}/yabplot/utils.py +0 -0
- {yabplot-0.1.5 → yabplot-0.2.2}/yabplot.egg-info/dependency_links.txt +0 -0
- {yabplot-0.1.5 → yabplot-0.2.2}/yabplot.egg-info/requires.txt +0 -0
- {yabplot-0.1.5 → yabplot-0.2.2}/yabplot.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: yabplot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.2
|
|
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.
|
|
@@ -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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "yabplot"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.2"
|
|
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"]
|
|
@@ -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(
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
cortical-aparc.zip sha256:9e3e4853c580b6ed7c70a2b398b8ddacfc30e05600f04f722d3b5e8ee28ba119 https://osf.io/5btcf/download
|
|
2
|
-
cortical-brainnetome.zip sha256:
|
|
2
|
+
cortical-brainnetome.zip sha256:fc4bb0043f2efa133771240f9e7178521be4011e76e5cad8102c9c099fce5ef0 https://osf.io/xspn5/download
|
|
3
3
|
cortical-schaefer_100.zip sha256:041c229b900c86f6b80cc44a05dca74bcfccde9aad5d10878da04a184d0f26ba https://osf.io/yxt5p/download
|
|
4
4
|
cortical-schaefer_200.zip sha256:065ee05afa1881a8884bdecee25a062ddd5fd6283bfeaabb59e6eb696f39563f https://osf.io/v45gq/download
|
|
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
|
|
@@ -22,4 +25,5 @@ bmesh-midthickness.zip sha256:ef8209853e1dac8804a60b5cde0b653ef2f6c77b6c7536f7ac
|
|
|
22
25
|
bmesh-pial.zip sha256:5e36ba7883dd2a88485fc45c9166ed48e22db607d45655242961242b51f9f126 https://osf.io/knpg8/download
|
|
23
26
|
bmesh-swm.zip sha256:1a516c070cb63557751d841d5cf75772660872b89ec749ee19c23298d77fa807 https://osf.io/hpbc9/download
|
|
24
27
|
bmesh-very_inflated.zip sha256:8c33a00c718a47f0af118ce2c5804a2adf13d79be05c4665ca26582e3e8dd977 https://osf.io/xp9jr/download
|
|
25
|
-
bmesh-white.zip sha256:b885ac1a4dcdf9e241a97e8d3ca59d7b1c86d8a3ebe17df512993bc5c1c0354a https://osf.io/wfc5t/download
|
|
28
|
+
bmesh-white.zip sha256:b885ac1a4dcdf9e241a97e8d3ca59d7b1c86d8a3ebe17df512993bc5c1c0354a https://osf.io/wfc5t/download
|
|
29
|
+
label-nomedialwall.zip sha256:03bd589b151814a1fc5e48236a78decf1a51791f04c3f4a521d2ed4fb214317b https://osf.io/2rgmc/download
|
|
@@ -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.
|
|
3
|
+
Version: 0.2.2
|
|
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.
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|