yabplot 0.3.1__tar.gz → 0.4.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.
- {yabplot-0.3.1/yabplot.egg-info → yabplot-0.4.0}/PKG-INFO +16 -10
- {yabplot-0.3.1 → yabplot-0.4.0}/README.md +15 -9
- {yabplot-0.3.1 → yabplot-0.4.0}/pyproject.toml +1 -1
- {yabplot-0.3.1 → yabplot-0.4.0}/tests/test_smoke.py +32 -1
- {yabplot-0.3.1 → yabplot-0.4.0}/yabplot/__init__.py +4 -1
- {yabplot-0.3.1 → yabplot-0.4.0}/yabplot/atlas_builder.py +6 -0
- {yabplot-0.3.1 → yabplot-0.4.0}/yabplot/data/__init__.py +83 -17
- yabplot-0.4.0/yabplot/data/registry.txt +30 -0
- {yabplot-0.3.1 → yabplot-0.4.0}/yabplot/mesh.py +47 -93
- {yabplot-0.3.1 → yabplot-0.4.0}/yabplot/plotting.py +74 -54
- yabplot-0.4.0/yabplot/projection.py +169 -0
- {yabplot-0.3.1 → yabplot-0.4.0}/yabplot/scene.py +1 -1
- {yabplot-0.3.1 → yabplot-0.4.0}/yabplot/utils.py +91 -5
- {yabplot-0.3.1 → yabplot-0.4.0/yabplot.egg-info}/PKG-INFO +16 -10
- {yabplot-0.3.1 → yabplot-0.4.0}/yabplot.egg-info/SOURCES.txt +1 -0
- yabplot-0.3.1/yabplot/data/registry.txt +0 -29
- {yabplot-0.3.1 → yabplot-0.4.0}/LICENSE +0 -0
- {yabplot-0.3.1 → yabplot-0.4.0}/MANIFEST.in +0 -0
- {yabplot-0.3.1 → yabplot-0.4.0}/setup.cfg +0 -0
- {yabplot-0.3.1 → yabplot-0.4.0}/yabplot/wrappers.py +0 -0
- {yabplot-0.3.1 → yabplot-0.4.0}/yabplot.egg-info/dependency_links.txt +0 -0
- {yabplot-0.3.1 → yabplot-0.4.0}/yabplot.egg-info/requires.txt +0 -0
- {yabplot-0.3.1 → yabplot-0.4.0}/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.4.0
|
|
4
4
|
Summary: yet another brain plot
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -38,7 +38,7 @@ the idea is simple. while there are already amazing visualization tools availabl
|
|
|
38
38
|
## features
|
|
39
39
|
|
|
40
40
|
* **pre-existing atlases:** access many commonly used atlases (schaefer2018, brainnetome, aparc, aseg, musus100, xtract, etc) on demand.
|
|
41
|
-
*
|
|
41
|
+
* **vertexwise plotting:** project volume (.nii) to cortical surface and plot.
|
|
42
42
|
* **simple to use:** plug-n-play functions for cortex, subcortex, and tracts with a unified API.
|
|
43
43
|
* **custom atlases:** easily use your own parcellations, segmentations (.nii/.gii), or tractograms (.trk).
|
|
44
44
|
* **flexible inputs:** accepts data as dictionaries (for partial mapping) or arrays (for strict mapping).
|
|
@@ -77,16 +77,12 @@ print(yab.get_available_resources())
|
|
|
77
77
|
# see the region names for a specific atlas
|
|
78
78
|
print(yab.get_atlas_regions(atlas='aseg', category='subcortical'))
|
|
79
79
|
|
|
80
|
-
# cortical
|
|
80
|
+
# cortical surface regions
|
|
81
81
|
atlas = 'aparc'
|
|
82
82
|
dmap1 = {'L_lateraloccipital': 0.265, 'L_postcentral': 0.086, ...}
|
|
83
|
-
dmap2 = {'L_fusiform': 0.218, 'L_supramarginal': 0.119, ...}
|
|
84
83
|
yab.plot_cortical(data=dmap1, atlas=atlas, vminmax=[-0.1, 0.3],
|
|
85
|
-
|
|
86
|
-
figsize=(600, 300), cmap='viridis'
|
|
87
|
-
yab.plot_cortical(data=dmap2, atlas=atlas, vminmax=[-0.1, 0.3],
|
|
88
|
-
bmesh_type='swm', views=['left_lateral', 'left_medial'],
|
|
89
|
-
figsize=(1200, 600), cmap='viridis', proc_vertices='sharp')
|
|
84
|
+
bmesh='midthickness', views=['left_lateral', 'left_medial'],
|
|
85
|
+
figsize=(600, 300), cmap='viridis')
|
|
90
86
|
|
|
91
87
|
# subcortical structures
|
|
92
88
|
atlas = 'aseg'
|
|
@@ -96,12 +92,22 @@ yab.plot_subcortical(data=data, atlas=atlas, vminmax=[2, 14],
|
|
|
96
92
|
views=['left_lateral', 'superior', 'right_lateral'],
|
|
97
93
|
bmesh_alpha=0.1, figsize=(600, 300), cmap='viridis')
|
|
98
94
|
|
|
95
|
+
# vertex-wise surface
|
|
96
|
+
threshold = 4
|
|
97
|
+
b_lh_path, b_rh_path = yab.data.get_surface_paths('midthickness', 'bmesh')
|
|
98
|
+
lh_data, rh_data = yab.project_vol2surf('path/to/yourdata.nii.gz', bmesh='midthickness')
|
|
99
|
+
lh_data = np.where(lh_data > threshold, lh_data, np.nan)
|
|
100
|
+
rh_data = np.where(rh_data > threshold, rh_data, np.nan)
|
|
101
|
+
lh_mesh, rh_mesh = yab.load_vertexwise_mesh(b_lh_path, b_rh_path, lh_data, rh_data)
|
|
102
|
+
yab.plot_vertexwise(lh_mesh, rh_mesh, cmap='viridis', vminmax=[-4, 9],
|
|
103
|
+
views=['left_lateral', 'left_medial'], figsize=(600, 300))
|
|
104
|
+
|
|
99
105
|
# white matter bundles
|
|
100
106
|
atlas = 'xtract_tiny'
|
|
101
107
|
regs = yab.get_atlas_regions(atlas=atlas, category='tracts')
|
|
102
108
|
data = {reg: np.sin(i) for i, reg in enumerate(regs)}
|
|
103
109
|
yab.plot_tracts(data=data, atlas=atlas, style='matte',
|
|
104
|
-
views=['left_lateral', 'anterior', 'superior'],
|
|
110
|
+
views=['left_lateral', 'anterior', 'superior'], bmesh='pial',
|
|
105
111
|
bmesh_alpha=0.1, figsize=(1600, 800), cmap='viridis')
|
|
106
112
|
|
|
107
113
|
```
|
|
@@ -14,7 +14,7 @@ the idea is simple. while there are already amazing visualization tools availabl
|
|
|
14
14
|
## features
|
|
15
15
|
|
|
16
16
|
* **pre-existing atlases:** access many commonly used atlases (schaefer2018, brainnetome, aparc, aseg, musus100, xtract, etc) on demand.
|
|
17
|
-
*
|
|
17
|
+
* **vertexwise plotting:** project volume (.nii) to cortical surface and plot.
|
|
18
18
|
* **simple to use:** plug-n-play functions for cortex, subcortex, and tracts with a unified API.
|
|
19
19
|
* **custom atlases:** easily use your own parcellations, segmentations (.nii/.gii), or tractograms (.trk).
|
|
20
20
|
* **flexible inputs:** accepts data as dictionaries (for partial mapping) or arrays (for strict mapping).
|
|
@@ -53,16 +53,12 @@ print(yab.get_available_resources())
|
|
|
53
53
|
# see the region names for a specific atlas
|
|
54
54
|
print(yab.get_atlas_regions(atlas='aseg', category='subcortical'))
|
|
55
55
|
|
|
56
|
-
# cortical
|
|
56
|
+
# cortical surface regions
|
|
57
57
|
atlas = 'aparc'
|
|
58
58
|
dmap1 = {'L_lateraloccipital': 0.265, 'L_postcentral': 0.086, ...}
|
|
59
|
-
dmap2 = {'L_fusiform': 0.218, 'L_supramarginal': 0.119, ...}
|
|
60
59
|
yab.plot_cortical(data=dmap1, atlas=atlas, vminmax=[-0.1, 0.3],
|
|
61
|
-
|
|
62
|
-
figsize=(600, 300), cmap='viridis'
|
|
63
|
-
yab.plot_cortical(data=dmap2, atlas=atlas, vminmax=[-0.1, 0.3],
|
|
64
|
-
bmesh_type='swm', views=['left_lateral', 'left_medial'],
|
|
65
|
-
figsize=(1200, 600), cmap='viridis', proc_vertices='sharp')
|
|
60
|
+
bmesh='midthickness', views=['left_lateral', 'left_medial'],
|
|
61
|
+
figsize=(600, 300), cmap='viridis')
|
|
66
62
|
|
|
67
63
|
# subcortical structures
|
|
68
64
|
atlas = 'aseg'
|
|
@@ -72,12 +68,22 @@ yab.plot_subcortical(data=data, atlas=atlas, vminmax=[2, 14],
|
|
|
72
68
|
views=['left_lateral', 'superior', 'right_lateral'],
|
|
73
69
|
bmesh_alpha=0.1, figsize=(600, 300), cmap='viridis')
|
|
74
70
|
|
|
71
|
+
# vertex-wise surface
|
|
72
|
+
threshold = 4
|
|
73
|
+
b_lh_path, b_rh_path = yab.data.get_surface_paths('midthickness', 'bmesh')
|
|
74
|
+
lh_data, rh_data = yab.project_vol2surf('path/to/yourdata.nii.gz', bmesh='midthickness')
|
|
75
|
+
lh_data = np.where(lh_data > threshold, lh_data, np.nan)
|
|
76
|
+
rh_data = np.where(rh_data > threshold, rh_data, np.nan)
|
|
77
|
+
lh_mesh, rh_mesh = yab.load_vertexwise_mesh(b_lh_path, b_rh_path, lh_data, rh_data)
|
|
78
|
+
yab.plot_vertexwise(lh_mesh, rh_mesh, cmap='viridis', vminmax=[-4, 9],
|
|
79
|
+
views=['left_lateral', 'left_medial'], figsize=(600, 300))
|
|
80
|
+
|
|
75
81
|
# white matter bundles
|
|
76
82
|
atlas = 'xtract_tiny'
|
|
77
83
|
regs = yab.get_atlas_regions(atlas=atlas, category='tracts')
|
|
78
84
|
data = {reg: np.sin(i) for i, reg in enumerate(regs)}
|
|
79
85
|
yab.plot_tracts(data=data, atlas=atlas, style='matte',
|
|
80
|
-
views=['left_lateral', 'anterior', 'superior'],
|
|
86
|
+
views=['left_lateral', 'anterior', 'superior'], bmesh='pial',
|
|
81
87
|
bmesh_alpha=0.1, figsize=(1600, 800), cmap='viridis')
|
|
82
88
|
|
|
83
89
|
```
|
|
@@ -10,6 +10,37 @@ def test_version():
|
|
|
10
10
|
"""Check that the package has a version string."""
|
|
11
11
|
assert yab.__version__ is not None
|
|
12
12
|
|
|
13
|
+
def test_none_returns_empty_dict():
|
|
14
|
+
"""
|
|
15
|
+
Unit test: Verify that passing None disables the background mesh
|
|
16
|
+
by correctly returning an empty dictionary.
|
|
17
|
+
"""
|
|
18
|
+
result = yab.mesh.load_bmesh(None)
|
|
19
|
+
assert result == {}
|
|
20
|
+
|
|
21
|
+
def test_dict_passthrough():
|
|
22
|
+
"""
|
|
23
|
+
Unit test: Verify that custom dictionary keys for hemispheres
|
|
24
|
+
are properly sanitized to strict 'L' and 'R' keys.
|
|
25
|
+
"""
|
|
26
|
+
mesh_l = pv.Sphere()
|
|
27
|
+
mesh_r = pv.Cube()
|
|
28
|
+
mesh_other = pv.Cone()
|
|
29
|
+
d = {'left': mesh_l, 'RIGHT': mesh_r, 'other': mesh_other}
|
|
30
|
+
result = yab.mesh.load_bmesh(d)
|
|
31
|
+
expected = {'L': mesh_l, 'R': mesh_r, 'other': mesh_other}
|
|
32
|
+
assert result == expected
|
|
33
|
+
|
|
34
|
+
def test_polydata_wrapped_in_both():
|
|
35
|
+
"""
|
|
36
|
+
Unit test: Verify that passing a single PyVista mesh (whole brain)
|
|
37
|
+
safely wraps it in a dictionary with the 'both' key.
|
|
38
|
+
"""
|
|
39
|
+
mesh = pv.Sphere()
|
|
40
|
+
result = yab.mesh.load_bmesh(mesh)
|
|
41
|
+
assert 'both' in result
|
|
42
|
+
assert result['both'] is mesh
|
|
43
|
+
|
|
13
44
|
def test_plotter_instantiation():
|
|
14
45
|
"""
|
|
15
46
|
Smoke test: Can we create a Plotter without crashing?
|
|
@@ -46,4 +77,4 @@ def test_plot_vertexwise():
|
|
|
46
77
|
rh = pv.Sphere()
|
|
47
78
|
lh['Data'] = np.random.rand(lh.n_points)
|
|
48
79
|
rh['Data'] = np.random.rand(rh.n_points)
|
|
49
|
-
yab.plot_vertexwise(lh, rh, display_type=None)
|
|
80
|
+
yab.plot_vertexwise(lh, rh, display_type=None)
|
|
@@ -11,7 +11,10 @@ from .atlas_builder import (
|
|
|
11
11
|
build_cortical_atlas, build_subcortical_atlas
|
|
12
12
|
)
|
|
13
13
|
from .mesh import (
|
|
14
|
-
load_vertexwise_mesh,
|
|
14
|
+
load_vertexwise_mesh, make_cortical_mesh
|
|
15
|
+
)
|
|
16
|
+
from .projection import (
|
|
17
|
+
project_vol2surf, project_vol2tract, project_vol2tract_atlas
|
|
15
18
|
)
|
|
16
19
|
|
|
17
20
|
try:
|
|
@@ -328,6 +328,12 @@ def build_subcortical_atlas(nii_path, labels_dict, out_dir, include_list=None, e
|
|
|
328
328
|
# save as a vtk file
|
|
329
329
|
out_file = os.path.join(out_dir, f"{name}.vtk")
|
|
330
330
|
mesh.save(out_file)
|
|
331
|
+
|
|
332
|
+
# file for ordering the labels
|
|
333
|
+
lut_path = os.path.join(out_dir, "atlas_LUT.txt")
|
|
334
|
+
with open(lut_path, 'w') as f:
|
|
335
|
+
for rid, name in targets.items():
|
|
336
|
+
f.write(f"{rid} {name}\n")
|
|
331
337
|
|
|
332
338
|
print(f"\nsubcortical atlas successfully saved to: {out_dir}")
|
|
333
339
|
|
|
@@ -6,6 +6,7 @@ import os
|
|
|
6
6
|
import glob
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
import pooch
|
|
9
|
+
import shutil
|
|
9
10
|
|
|
10
11
|
from ..utils import parse_lut
|
|
11
12
|
|
|
@@ -165,8 +166,7 @@ def get_atlas_regions(atlas, category, custom_atlas_path=None):
|
|
|
165
166
|
elif category == 'subcortical':
|
|
166
167
|
try:
|
|
167
168
|
file_map = _find_subcortical_files(atlas_dir)
|
|
168
|
-
|
|
169
|
-
return sorted(list(file_map.keys()))
|
|
169
|
+
return _get_ordered_names(atlas_dir, file_map)
|
|
170
170
|
except Exception as e:
|
|
171
171
|
print(f"Error listing subcortical regions: {e}")
|
|
172
172
|
return []
|
|
@@ -175,8 +175,7 @@ def get_atlas_regions(atlas, category, custom_atlas_path=None):
|
|
|
175
175
|
elif category == 'tracts':
|
|
176
176
|
try:
|
|
177
177
|
file_map = _find_tract_files(atlas_dir)
|
|
178
|
-
|
|
179
|
-
return sorted(list(file_map.keys()))
|
|
178
|
+
return _get_ordered_names(atlas_dir, file_map)
|
|
180
179
|
except Exception as e:
|
|
181
180
|
print(f"Error listing tracts: {e}")
|
|
182
181
|
return []
|
|
@@ -188,26 +187,44 @@ def get_atlas_regions(atlas, category, custom_atlas_path=None):
|
|
|
188
187
|
def _fetch_and_unpack(resource_key):
|
|
189
188
|
"""
|
|
190
189
|
Downloads zip, unpacks it, deletes the zip to save space,
|
|
191
|
-
and returns the extraction path.
|
|
190
|
+
and returns the extraction path. Forces a redownload if the
|
|
191
|
+
registry hash changes (indicating an update).
|
|
192
192
|
"""
|
|
193
193
|
extract_dir_name = resource_key.replace(".zip", "")
|
|
194
194
|
extract_path = os.path.join(GOODBOY.path, extract_dir_name)
|
|
195
|
+
hash_file = os.path.join(extract_path, ".registry_hash")
|
|
195
196
|
|
|
196
|
-
#
|
|
197
|
-
|
|
198
|
-
if
|
|
199
|
-
|
|
197
|
+
# get the expected hash from the registry
|
|
198
|
+
expected_hash = GOODBOY.registry.get(resource_key)
|
|
199
|
+
if not expected_hash:
|
|
200
|
+
raise ValueError(f"Resource '{resource_key}' not found in registry.")
|
|
200
201
|
|
|
201
|
-
#
|
|
202
|
+
# check if unpacked folder already exists and is up-to-date
|
|
203
|
+
is_up_to_date = False
|
|
204
|
+
if os.path.isdir(extract_path) and os.path.exists(hash_file):
|
|
205
|
+
with open(hash_file, 'r') as f:
|
|
206
|
+
local_hash = f.read().strip()
|
|
207
|
+
if local_hash == expected_hash:
|
|
208
|
+
is_up_to_date = True
|
|
209
|
+
if is_up_to_date:
|
|
210
|
+
return extract_path
|
|
211
|
+
# if folder exists but hash is wrong (outdated), wipe it clean
|
|
212
|
+
elif os.path.exists(extract_path):
|
|
213
|
+
print(f"Update found for '{extract_dir_name}'. Removing legacy data...")
|
|
214
|
+
shutil.rmtree(extract_path)
|
|
215
|
+
|
|
216
|
+
# fetch and unzip new data
|
|
202
217
|
try:
|
|
203
218
|
GOODBOY.fetch(
|
|
204
219
|
resource_key,
|
|
205
220
|
processor=pooch.Unzip(extract_dir=extract_dir_name)
|
|
206
221
|
)
|
|
207
|
-
except
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
222
|
+
except Exception as e:
|
|
223
|
+
raise RuntimeError(f"Failed to fetch '{resource_key}': {e}")
|
|
224
|
+
|
|
225
|
+
# stamp the new folder with the updated hash
|
|
226
|
+
with open(hash_file, 'w') as f:
|
|
227
|
+
f.write(expected_hash)
|
|
211
228
|
|
|
212
229
|
# cleanup: delete the source zip to save space
|
|
213
230
|
zip_path = os.path.join(GOODBOY.path, resource_key)
|
|
@@ -225,7 +242,9 @@ def _resolve_resource_path(name, category, custom_path=None):
|
|
|
225
242
|
if custom_path:
|
|
226
243
|
if os.path.isdir(custom_path):
|
|
227
244
|
return custom_path
|
|
228
|
-
|
|
245
|
+
if os.path.isfile(custom_path):
|
|
246
|
+
return custom_path
|
|
247
|
+
raise FileNotFoundError(f"Custom atlas directory/file not found: {custom_path}")
|
|
229
248
|
|
|
230
249
|
# 2. standard download logic
|
|
231
250
|
resource_key = f"{category}-{name}.zip"
|
|
@@ -326,6 +345,46 @@ def _find_cortical_files(atlas_dir, strict_name=None):
|
|
|
326
345
|
|
|
327
346
|
return csv_path, lut_path
|
|
328
347
|
|
|
348
|
+
def _get_ordered_names(atlas_dir, file_map):
|
|
349
|
+
"""
|
|
350
|
+
Attempts to read the strict region order from a LUT or order text file.
|
|
351
|
+
Falls back to alphabetical sorting if no file exists.
|
|
352
|
+
"""
|
|
353
|
+
txt_files = []
|
|
354
|
+
|
|
355
|
+
# look for a LUT or order file, ignoring qc reports
|
|
356
|
+
for root, dirs, files in os.walk(atlas_dir):
|
|
357
|
+
dirs[:] = [d for d in dirs if not d.startswith(('.', '__')) and 'qc_report' not in d]
|
|
358
|
+
for file in files:
|
|
359
|
+
if file.endswith('.txt') and 'registry' not in file:
|
|
360
|
+
txt_files.append(os.path.join(root, file))
|
|
361
|
+
def file_priority(filepath):
|
|
362
|
+
name = filepath.lower()
|
|
363
|
+
if 'lut' in name: return 0
|
|
364
|
+
if 'order' in name: return 1
|
|
365
|
+
return 2
|
|
366
|
+
txt_files.sort(key=file_priority)
|
|
367
|
+
|
|
368
|
+
if txt_files:
|
|
369
|
+
ordered_names = []
|
|
370
|
+
with open(txt_files[0], 'r') as f:
|
|
371
|
+
for line in f:
|
|
372
|
+
parts = line.strip().split()
|
|
373
|
+
# assuming standard LUT format (ID Name ...) or simple list (ID Name)
|
|
374
|
+
if len(parts) >= 2:
|
|
375
|
+
name = parts[1]
|
|
376
|
+
if name in file_map and name not in ordered_names:
|
|
377
|
+
ordered_names.append(name)
|
|
378
|
+
|
|
379
|
+
# append any stray files that exist in the directory but weren't in the text file
|
|
380
|
+
for name in sorted(file_map.keys()):
|
|
381
|
+
if name not in ordered_names:
|
|
382
|
+
ordered_names.append(name)
|
|
383
|
+
|
|
384
|
+
return ordered_names
|
|
385
|
+
|
|
386
|
+
# legacy fallback: alphabetical
|
|
387
|
+
return sorted(list(file_map.keys()))
|
|
329
388
|
|
|
330
389
|
def _find_subcortical_files(atlas_dir):
|
|
331
390
|
"""
|
|
@@ -403,8 +462,15 @@ def _find_tract_files(atlas_dir):
|
|
|
403
462
|
|
|
404
463
|
return candidates
|
|
405
464
|
|
|
406
|
-
|
|
407
|
-
|
|
465
|
+
if os.path.isdir(atlas_dir):
|
|
466
|
+
# scan for both .trk and .tck
|
|
467
|
+
found_files = _scan_for_ext(atlas_dir, ".trk") + _scan_for_ext(atlas_dir, ".tck")
|
|
468
|
+
elif os.path.isfile(atlas_dir):
|
|
469
|
+
if atlas_dir.endswith((".trk", ".tck")):
|
|
470
|
+
found_files = [atlas_dir]
|
|
471
|
+
else:
|
|
472
|
+
raise ValueError(f"Invalid atlas directory/file path: {atlas_dir}, no valid tck or trk file found.")
|
|
473
|
+
|
|
408
474
|
|
|
409
475
|
if not found_files:
|
|
410
476
|
raise FileNotFoundError(f"No .trk or .tck files found in {atlas_dir}")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
cortical-aparc.zip sha256:9e3e4853c580b6ed7c70a2b398b8ddacfc30e05600f04f722d3b5e8ee28ba119 https://osf.io/q5a2v/download
|
|
2
|
+
cortical-aal3.zip sha256:da90797ab768cb9b0ad1acf1a9035d62debdb823d0bcc2b96405b7e86c75a7dc https://osf.io/rcjqk/download
|
|
3
|
+
cortical-brainnetome.zip sha256:42b828d5dd5734a34fb61282b1ee92c1961c4c3e3cc35fa73d3f48f6c12c0fcd https://osf.io/5nr4x/download
|
|
4
|
+
cortical-schaefer100.zip sha256:79358c491ee2d9400e8166552a7e4d3f8ad7332e65192f0edf215d657f317896 https://osf.io/782gu/download
|
|
5
|
+
cortical-schaefer200.zip sha256:e24ab0fe4acec59604ce2a98ef9f4c14cc3c3d6dea73fb31c6900bd3879ea138 https://osf.io/9uzc8/download
|
|
6
|
+
cortical-schaefer300.zip sha256:ef096c2b96b0a8e1998417c23162f0e69f0693852911e55cb03f9681cc398669 https://osf.io/jcu8a/download
|
|
7
|
+
cortical-schaefer400.zip sha256:000c1a3b71926d83ea8c73fed63c8688d34c3cd8f0b8f91bd5c7f4c80edf7554 https://osf.io/k8dvw/download
|
|
8
|
+
cortical-schaefer1000.zip sha256:369827006e724e5ac74ef6a3761e2f77b5b30f2825e09d9a02c169dc5d3fbe90 https://osf.io/m3ze7/download
|
|
9
|
+
subcortical-aal3.zip sha256:7d0965016a393e20f86bb4b20761854e5fc74731a83aff51e56eddec9f2d6f21 https://osf.io/yvheg/download
|
|
10
|
+
subcortical-aal3_nocer.zip sha256:742e88052056dae851916dfaaac87173eeda5e74ec17bd44639140a34d70be04 https://osf.io/a59sj/download
|
|
11
|
+
subcortical-aseg.zip sha256:083ba39d80bd42e92344ac5bb46cf520e60ddb31a4cce6f5bcb9dc54892d4e27 https://osf.io/8akre/download
|
|
12
|
+
subcortical-brainnetome_sc.zip sha256:e008a3310bd8b80477af3685097800aa923487519274214eb2e0488109730051 https://osf.io/f9u3m/download
|
|
13
|
+
subcortical-musus100.zip sha256:61ed78eafc9f407e625a0288c0ed2cf9273b833a5341e9712d30a26974976140 https://osf.io/ak3hb/download
|
|
14
|
+
subcortical-musus100_dbn.zip sha256:8c79864c0cf37b2bc23752931742061be8a583a9064974bd52368ca657320555 https://osf.io/9g5z7/download
|
|
15
|
+
subcortical-musus100_tha.zip sha256:14f44f5b7b7ac24eb3870600f87fa66b862d3b5a7403039b3b9c219f2bf28cdf https://osf.io/xrd9j/download
|
|
16
|
+
subcortical-tian2020_s1.zip sha256:90c5c78a3d4636aea94944d54b24a3c0510580f6ca62fdb9192a316fd8e1a9cf https://osf.io/9z7wm/download
|
|
17
|
+
tracts-hcp1065_medium.zip sha256:366bb100074cf7b1e55586e5468653f0c342e42cfdd91dca6d99b6fde82f06ff https://osf.io/kjf8e/download
|
|
18
|
+
tracts-hcp1065_small.zip sha256:020f23059c3a20ee0dda8ad12cd3df99b9fe5d3b37e7a6b9ae34b8a52b4b4346 https://osf.io/ynpa5/download
|
|
19
|
+
tracts-hcp1065_tiny.zip sha256:54380d82f5cd234029c6fc011910e00f0ad859fdb6c22c6aaa6452d055449ae9 https://osf.io/jzk7p/download
|
|
20
|
+
tracts-xtract_large.zip sha256:e2d59e9f90b024018788af87e389f93b2fa9ac213604601966a44d7f81c9d89f https://osf.io/pktq6/download
|
|
21
|
+
tracts-xtract_medium.zip sha256:f095028d3dfc0f974ebef1feffe4006b5bac1366325a784ea2301c56f583b1e7 https://osf.io/7tbjg/download
|
|
22
|
+
tracts-xtract_small.zip sha256:9d3fe2c57acb87e8d13eb40a49a6c2c354dbe1a020122b693e9bf713e57cad5b https://osf.io/bmn3a/download
|
|
23
|
+
tracts-xtract_tiny.zip sha256:469f9ed8ed5ceb7f8e17c9a0a92f1491d540814f832ef56441a7539532111f41 https://osf.io/a73x2/download
|
|
24
|
+
bmesh-inflated.zip sha256:ba72f9e75f16fe767f6bbcec5c98381f6a20b2f5e2092505b4692844a1686461 https://osf.io/kfzjg/download
|
|
25
|
+
bmesh-midthickness.zip sha256:ef8209853e1dac8804a60b5cde0b653ef2f6c77b6c7536f7acaa69a46dbb06c0 https://osf.io/xaktf/download
|
|
26
|
+
bmesh-pial.zip sha256:5e36ba7883dd2a88485fc45c9166ed48e22db607d45655242961242b51f9f126 https://osf.io/knpg8/download
|
|
27
|
+
bmesh-swm.zip sha256:1a516c070cb63557751d841d5cf75772660872b89ec749ee19c23298d77fa807 https://osf.io/hpbc9/download
|
|
28
|
+
bmesh-very_inflated.zip sha256:8c33a00c718a47f0af118ce2c5804a2adf13d79be05c4665ca26582e3e8dd977 https://osf.io/xp9jr/download
|
|
29
|
+
bmesh-white.zip sha256:b885ac1a4dcdf9e241a97e8d3ca59d7b1c86d8a3ebe17df512993bc5c1c0354a https://osf.io/wfc5t/download
|
|
30
|
+
label-nomedialwall.zip sha256:03bd589b151814a1fc5e48236a78decf1a51791f04c3f4a521d2ed4fb214317b https://osf.io/2rgmc/download
|
|
@@ -1,14 +1,53 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import warnings
|
|
3
|
-
|
|
4
1
|
import numpy as np
|
|
5
|
-
import nibabel as nib
|
|
6
2
|
import pyvista as pv
|
|
7
|
-
|
|
8
3
|
import scipy.sparse as sp
|
|
9
|
-
from scipy.ndimage import map_coordinates
|
|
10
4
|
|
|
11
|
-
|
|
5
|
+
def load_bmesh(bmesh):
|
|
6
|
+
"""
|
|
7
|
+
Transforms the `bmesh` parameter into a standardized dictionary of PyVista PolyData meshes.
|
|
8
|
+
|
|
9
|
+
Parameters
|
|
10
|
+
----------
|
|
11
|
+
bmesh : None, str, dict, or pyvista.PolyData
|
|
12
|
+
- None: Returns an empty dictionary (disables background mesh).
|
|
13
|
+
- str: Fetches standard meshes from the registry (e.g., 'midthickness').
|
|
14
|
+
- dict: Maps custom meshes to 'L' and 'R' keys. Values can be pre-loaded
|
|
15
|
+
PyVista PolyData objects or string file paths (which are auto-loaded).
|
|
16
|
+
- pyvista.PolyData: A single unified mesh, mapped to the 'both' key.
|
|
17
|
+
|
|
18
|
+
Returns
|
|
19
|
+
-------
|
|
20
|
+
dict
|
|
21
|
+
Standardized dictionary containing the loaded PyVista meshes.
|
|
22
|
+
"""
|
|
23
|
+
from .data import get_surface_paths
|
|
24
|
+
from .utils import load_gii2pv
|
|
25
|
+
|
|
26
|
+
if bmesh is None:
|
|
27
|
+
return {}
|
|
28
|
+
if isinstance(bmesh, str):
|
|
29
|
+
lh_path, rh_path = get_surface_paths(bmesh, 'bmesh')
|
|
30
|
+
return {'L': load_gii2pv(lh_path), 'R': load_gii2pv(rh_path)}
|
|
31
|
+
if isinstance(bmesh, dict):
|
|
32
|
+
clean_dict = {}
|
|
33
|
+
for k, v in bmesh.items():
|
|
34
|
+
if isinstance(v, str):
|
|
35
|
+
if v.endswith('.gii') or v.endswith('.gii.gz'):
|
|
36
|
+
v = load_gii2pv(v)
|
|
37
|
+
else:
|
|
38
|
+
v = pv.read(v)
|
|
39
|
+
if k.upper() in ['L', 'LEFT']: clean_dict['L'] = v
|
|
40
|
+
elif k.upper() in ['R', 'RIGHT']: clean_dict['R'] = v
|
|
41
|
+
else: clean_dict[k] = v
|
|
42
|
+
return clean_dict
|
|
43
|
+
|
|
44
|
+
return {'both': bmesh}
|
|
45
|
+
|
|
46
|
+
def extract_polydata(mesh_hemi: pv.PolyData):
|
|
47
|
+
"""Return vertices and rotated faces for plotting."""
|
|
48
|
+
v = mesh_hemi.points
|
|
49
|
+
f = mesh_hemi.faces.reshape(-1, 4)[:, 1:]
|
|
50
|
+
return v, f
|
|
12
51
|
|
|
13
52
|
def make_cortical_mesh(verts, faces, scalars, scalar_name='Data'):
|
|
14
53
|
"""
|
|
@@ -60,6 +99,7 @@ def load_vertexwise_mesh(lh_mesh_path, rh_mesh_path, lh_data, rh_data, scalar_na
|
|
|
60
99
|
lh_mesh, rh_mesh : tuple of pyvista.PolyData
|
|
61
100
|
left and right hemisphere meshes ready for `yabplot.plotting.plot_vertexwise`.
|
|
62
101
|
"""
|
|
102
|
+
from .utils import load_gii
|
|
63
103
|
lh = make_cortical_mesh(*load_gii(lh_mesh_path), lh_data, scalar_name)
|
|
64
104
|
rh = make_cortical_mesh(*load_gii(rh_mesh_path), rh_data, scalar_name)
|
|
65
105
|
return lh, rh
|
|
@@ -222,89 +262,3 @@ def lines_from_streamlines(streamlines):
|
|
|
222
262
|
tangents.append(vecs / norms)
|
|
223
263
|
|
|
224
264
|
return points, lines, np.vstack(tangents)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
def project_vol2surf(nii_path, bmesh_type='midthickness', custom_bmesh_paths=None,
|
|
228
|
-
mask_medial_wall=True, interpolation='linear'):
|
|
229
|
-
"""
|
|
230
|
-
Projects a 3D NIfTI volume onto 2D cortical surface vertices.
|
|
231
|
-
|
|
232
|
-
It maps volumetric data directly onto surface meshes by converting real-world coordinates
|
|
233
|
-
using the image affine and sampling the data array at those exact points.
|
|
234
|
-
|
|
235
|
-
Parameters
|
|
236
|
-
----------
|
|
237
|
-
nii_path : str
|
|
238
|
-
absolute path to the 3D or 4D NIfTI volume.
|
|
239
|
-
if 4D, only the first volume/timepoint is used.
|
|
240
|
-
bmesh_type : str, optional
|
|
241
|
-
name of the standard background mesh to use for projection coordinates.
|
|
242
|
-
default is 'midthickness'.
|
|
243
|
-
custom_bmesh_paths : tuple of str, optional
|
|
244
|
-
custom paths for (lh_mesh, rh_mesh) if not using standard yabplot meshes.
|
|
245
|
-
default is None.
|
|
246
|
-
mask_medial_wall : bool, optional
|
|
247
|
-
whether to automatically set the medial wall vertices to NaN to prevent
|
|
248
|
-
subcortical signal from bleeding onto the cortical surface.
|
|
249
|
-
default is True.
|
|
250
|
-
interpolation : {'linear', 'nearest'}, optional
|
|
251
|
-
interpolation method for sampling the volume. 'linear' performs trilinear
|
|
252
|
-
interpolation (smoother, good for continuous t-stats), while 'nearest'
|
|
253
|
-
snaps to the closest voxel center (strictly required for p-values or atlases).
|
|
254
|
-
default is 'linear'.
|
|
255
|
-
|
|
256
|
-
Returns
|
|
257
|
-
-------
|
|
258
|
-
lh_data : numpy.ndarray
|
|
259
|
-
1D array of projected values for the left hemisphere vertices.
|
|
260
|
-
rh_data : numpy.ndarray
|
|
261
|
-
1D array of projected values for the right hemisphere vertices.
|
|
262
|
-
"""
|
|
263
|
-
from .data import get_surface_paths
|
|
264
|
-
|
|
265
|
-
# load volume
|
|
266
|
-
img = nib.load(nii_path)
|
|
267
|
-
vol_data = img.get_fdata()
|
|
268
|
-
|
|
269
|
-
# check for 4d data (e.g. raw fmri timeseries)
|
|
270
|
-
if vol_data.ndim > 3:
|
|
271
|
-
warnings.warn(f"[WARNING] detected {vol_data.ndim}d nifti volume. using the first volume (index 0).")
|
|
272
|
-
vol_data = vol_data[..., 0]
|
|
273
|
-
|
|
274
|
-
# invert affine to go from real-world mm space back to voxel indices
|
|
275
|
-
inv_affine = np.linalg.inv(img.affine)
|
|
276
|
-
|
|
277
|
-
# resolve surfaces
|
|
278
|
-
if custom_bmesh_paths:
|
|
279
|
-
lh_path, rh_path = custom_bmesh_paths
|
|
280
|
-
else:
|
|
281
|
-
lh_path, rh_path = get_surface_paths(bmesh_type, 'bmesh')
|
|
282
|
-
|
|
283
|
-
lh_v, _ = load_gii(lh_path)
|
|
284
|
-
rh_v, _ = load_gii(rh_path)
|
|
285
|
-
|
|
286
|
-
def sample_surface(vertices, volume, inv_aff, interp):
|
|
287
|
-
# convert [x, y, z] to [x, y, z, 1] to allow 4x4 affine matrix multiplication
|
|
288
|
-
coords_homo = np.hstack((vertices, np.ones((vertices.shape[0], 1))))
|
|
289
|
-
|
|
290
|
-
# multiply by inverse affine to get exact decimal voxel coordinates
|
|
291
|
-
vox_coords = inv_aff.dot(coords_homo.T)[:3, :]
|
|
292
|
-
|
|
293
|
-
# set scipy interpolation order (1 = trilinear, 0 = nearest neighbor)
|
|
294
|
-
order = 1 if interp == 'linear' else 0
|
|
295
|
-
|
|
296
|
-
# sample the 3d volume at the calculated decimal coordinates
|
|
297
|
-
sampled_data = map_coordinates(volume, vox_coords, order=order, mode='nearest')
|
|
298
|
-
return sampled_data
|
|
299
|
-
|
|
300
|
-
# projection
|
|
301
|
-
lh_data = sample_surface(lh_v, vol_data, inv_affine, interpolation)
|
|
302
|
-
rh_data = sample_surface(rh_v, vol_data, inv_affine, interpolation)
|
|
303
|
-
|
|
304
|
-
# mask out the medial wall (optional but default true)
|
|
305
|
-
if mask_medial_wall:
|
|
306
|
-
lh_mask_path, rh_mask_path = get_surface_paths('nomedialwall', 'label')
|
|
307
|
-
lh_data[nib.load(lh_mask_path).darrays[0].data == 0] = np.nan
|
|
308
|
-
rh_data[nib.load(rh_mask_path).darrays[0].data == 0] = np.nan
|
|
309
|
-
|
|
310
|
-
return lh_data, rh_data
|
|
@@ -8,18 +8,18 @@ from matplotlib.colors import ListedColormap
|
|
|
8
8
|
|
|
9
9
|
from .data import (
|
|
10
10
|
get_surface_paths, _resolve_resource_path, _find_cortical_files,
|
|
11
|
-
_find_subcortical_files, _find_tract_files
|
|
11
|
+
_find_subcortical_files, _find_tract_files, get_atlas_regions
|
|
12
12
|
)
|
|
13
13
|
|
|
14
14
|
from .utils import (
|
|
15
|
-
load_gii, load_gii2pv, prep_data,
|
|
15
|
+
load_gii, load_gii2pv, prep_data,
|
|
16
16
|
generate_distinct_colors, parse_lut
|
|
17
17
|
)
|
|
18
18
|
|
|
19
19
|
from .mesh import (
|
|
20
20
|
map_values_to_surface, get_puzzle_pieces, apply_internal_blur,
|
|
21
21
|
apply_dilation, get_smooth_mask, lines_from_streamlines,
|
|
22
|
-
make_cortical_mesh
|
|
22
|
+
make_cortical_mesh, load_bmesh, extract_polydata
|
|
23
23
|
)
|
|
24
24
|
|
|
25
25
|
from .scene import (
|
|
@@ -28,7 +28,6 @@ from .scene import (
|
|
|
28
28
|
)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
|
|
32
31
|
def _render_cortical_views(lh_v, lh_f, lh_vals, rh_v, rh_f, rh_vals, is_cat,
|
|
33
32
|
views, layout, figsize, cmap, vminmax, nan_color,
|
|
34
33
|
style, zoom, proc_vertices, display_type, export_path,
|
|
@@ -114,7 +113,7 @@ def _render_cortical_views(lh_v, lh_f, lh_vals, rh_v, rh_f, rh_vals, is_cat,
|
|
|
114
113
|
### PLOT FOR ATLAS-BASED CORTICAL DATA ###
|
|
115
114
|
|
|
116
115
|
def plot_cortical(data=None, atlas=None, custom_atlas_path=None, views=None, layout=None,
|
|
117
|
-
|
|
116
|
+
bmesh='midthickness', figsize=(1000, 600), cmap='coolwarm', vminmax=[None, None],
|
|
118
117
|
nan_color=(1.0, 1.0, 1.0), style='default', zoom=1.2, proc_vertices=None,
|
|
119
118
|
display_type='static', export_path=None):
|
|
120
119
|
"""
|
|
@@ -142,7 +141,7 @@ def plot_cortical(data=None, atlas=None, custom_atlas_path=None, views=None, lay
|
|
|
142
141
|
or a dictionary of camera configurations. Defaults to all views.
|
|
143
142
|
layout : tuple (rows, cols), optional
|
|
144
143
|
Grid layout for subplots. If None, automatically calculated based on the number of views.
|
|
145
|
-
|
|
144
|
+
bmesh : str
|
|
146
145
|
Name of the background context brain mesh (e.g., 'midthickness', 'white', 'swm', etc).
|
|
147
146
|
Default is 'midthickness'.
|
|
148
147
|
figsize : tuple (width, height), optional
|
|
@@ -182,7 +181,7 @@ def plot_cortical(data=None, atlas=None, custom_atlas_path=None, views=None, lay
|
|
|
182
181
|
is_cat = (data is None)
|
|
183
182
|
|
|
184
183
|
# load brain mesh
|
|
185
|
-
b_lh_path, b_rh_path = get_surface_paths(
|
|
184
|
+
b_lh_path, b_rh_path = get_surface_paths(bmesh, 'bmesh')
|
|
186
185
|
lh_v, lh_f = load_gii(b_lh_path)
|
|
187
186
|
rh_v, rh_f = load_gii(b_rh_path)
|
|
188
187
|
|
|
@@ -219,8 +218,8 @@ def plot_vertexwise(lh, rh, scalars='Data', views=None, layout=None, figsize=(10
|
|
|
219
218
|
Visualize arbitrary per-vertex scalar data on a user-supplied brain mesh.
|
|
220
219
|
|
|
221
220
|
Unlike `plot_cortical`, this function requires no atlas. The user provides
|
|
222
|
-
PyVista PolyData meshes
|
|
223
|
-
|
|
221
|
+
PyVista PolyData meshes with per-vertex scalar data stored under the key specified
|
|
222
|
+
by `scalars`.
|
|
224
223
|
|
|
225
224
|
Parameters
|
|
226
225
|
----------
|
|
@@ -281,11 +280,9 @@ def plot_vertexwise(lh, rh, scalars='Data', views=None, layout=None, figsize=(10
|
|
|
281
280
|
"""
|
|
282
281
|
|
|
283
282
|
# extract v, f, raw from PyVista meshes
|
|
284
|
-
lh_v = lh
|
|
285
|
-
lh_f = lh.faces.reshape(-1, 4)[:, 1:]
|
|
283
|
+
lh_v, lh_f = extract_polydata(lh)
|
|
286
284
|
lh_vals_raw = lh[scalars]
|
|
287
|
-
rh_v = rh
|
|
288
|
-
rh_f = rh.faces.reshape(-1, 4)[:, 1:]
|
|
285
|
+
rh_v, rh_f = extract_polydata(rh)
|
|
289
286
|
rh_vals_raw = rh[scalars]
|
|
290
287
|
|
|
291
288
|
# render
|
|
@@ -301,7 +298,7 @@ def plot_vertexwise(lh, rh, scalars='Data', views=None, layout=None, figsize=(10
|
|
|
301
298
|
|
|
302
299
|
def plot_subcortical(data=None, atlas=None, custom_atlas_path=None, views=None, layout=None,
|
|
303
300
|
figsize=(1000, 600), cmap='coolwarm', vminmax=[None, None], nan_color='#cccccc',
|
|
304
|
-
nan_alpha=1.0, style='default',
|
|
301
|
+
nan_alpha=1.0, style='default', bmesh='midthickness',
|
|
305
302
|
bmesh_alpha=0.1, bmesh_color='lightgray', zoom=1.2, display_type='static',
|
|
306
303
|
export_path=None, custom_atlas_proc=dict(smooth_i=15, smooth_f=0.6)):
|
|
307
304
|
"""
|
|
@@ -339,10 +336,11 @@ def plot_subcortical(data=None, atlas=None, custom_atlas_path=None, views=None,
|
|
|
339
336
|
nan_alpha : float, optional
|
|
340
337
|
Opacity (0.0 to 1.0) for regions with no data. Set to 0.0 to hide them.
|
|
341
338
|
style : str, optional
|
|
342
|
-
Lighting preset ('default', 'matte', 'glossy', 'sculpted', 'flat').
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
339
|
+
Lighting preset ('default', 'matte', 'glossy', 'sculpted', 'flat').
|
|
340
|
+
bmesh : pyvista.PolyData or dict, optional
|
|
341
|
+
Configure background context brain mesh. Accepts a string
|
|
342
|
+
(e.g., 'midthickness', 'white', 'swm', etc), single PolyData (used for both hemispheres)
|
|
343
|
+
or a dict with 'L'/'R' keys. Default is 'midthickness'.
|
|
346
344
|
bmesh_alpha : float, optional
|
|
347
345
|
Opacity of the context brain mesh. Default is 0.1.
|
|
348
346
|
bmesh_color : str, optional
|
|
@@ -370,21 +368,16 @@ def plot_subcortical(data=None, atlas=None, custom_atlas_path=None, views=None,
|
|
|
370
368
|
if atlas is None and custom_atlas_path is None:
|
|
371
369
|
atlas = 'aseg'
|
|
372
370
|
|
|
373
|
-
# load context brain mesh (if requested)
|
|
374
|
-
|
|
375
|
-
if bmesh_type:
|
|
376
|
-
b_lh_path, b_rh_path = get_surface_paths(bmesh_type, 'bmesh')
|
|
377
|
-
bmesh['L'] = load_gii2pv(b_lh_path)
|
|
378
|
-
bmesh['R'] = load_gii2pv(b_rh_path)
|
|
371
|
+
# load context brain mesh (if requested) or accept mesh directly
|
|
372
|
+
ctx_meshes = load_bmesh(bmesh)
|
|
379
373
|
|
|
380
374
|
# load regional atlas meshes
|
|
381
|
-
|
|
382
375
|
# resolve atlas path (either download or custom directory)
|
|
383
376
|
atlas_dir = _resolve_resource_path(atlas, 'subcortical', custom_path=custom_atlas_path)
|
|
384
377
|
|
|
385
378
|
# locate mesh files, returns dict: {'Left_Thalamus': '/path/to/Left_Thalamus.vtk', ...}
|
|
386
379
|
file_map = _find_subcortical_files(atlas_dir)
|
|
387
|
-
rmesh_names =
|
|
380
|
+
rmesh_names = get_atlas_regions(atlas, 'subcortical', custom_atlas_path)
|
|
388
381
|
|
|
389
382
|
# load meshes (and convert gii2pv if gii files)
|
|
390
383
|
meshes = {}
|
|
@@ -422,7 +415,7 @@ def plot_subcortical(data=None, atlas=None, custom_atlas_path=None, views=None,
|
|
|
422
415
|
plotter.subplot(i // ncols, i % ncols)
|
|
423
416
|
|
|
424
417
|
# add context (uses style kwargs for consistent lighting)
|
|
425
|
-
add_context_to_view(plotter,
|
|
418
|
+
add_context_to_view(plotter, ctx_meshes, cfg['side'], bmesh_alpha, bmesh_color,
|
|
426
419
|
**shading_params)
|
|
427
420
|
|
|
428
421
|
# add regions
|
|
@@ -430,8 +423,8 @@ def plot_subcortical(data=None, atlas=None, custom_atlas_path=None, views=None,
|
|
|
430
423
|
# side filter
|
|
431
424
|
# TODO: make the hemisphere specific name check more robust
|
|
432
425
|
name_lower = name.lower()
|
|
433
|
-
is_left = any(x in name_lower for x in ['left']) or name_lower.startswith('l-') or name_lower.endswith('_l')
|
|
434
|
-
is_right = any(x in name_lower for x in ['right']) or name_lower.startswith('r-') or name_lower.endswith('_r')
|
|
426
|
+
is_left = any(x in name_lower for x in ['left']) or name_lower.startswith('l-') or name_lower.endswith('_l') or name_lower.endswith('-lh')
|
|
427
|
+
is_right = any(x in name_lower for x in ['right']) or name_lower.startswith('r-') or name_lower.endswith('_r') or name_lower.endswith('-rh')
|
|
435
428
|
|
|
436
429
|
if cfg['side'] == 'L' and is_right and not is_left: continue
|
|
437
430
|
if cfg['side'] == 'R' and is_left and not is_right: continue
|
|
@@ -483,7 +476,7 @@ def clear_tract_cache():
|
|
|
483
476
|
def plot_tracts(data=None, atlas=None, custom_atlas_path=None, views=None, layout=None,
|
|
484
477
|
figsize=(1000, 800), cmap='coolwarm', alpha=1.0, vminmax=[None, None],
|
|
485
478
|
nan_color='#BDBDBD', nan_alpha=1.0, style='default',
|
|
486
|
-
|
|
479
|
+
bmesh='midthickness', bmesh_alpha=0.2, bmesh_color='lightgray',
|
|
487
480
|
zoom=1.2, orientation_coloring=False, display_type='static',
|
|
488
481
|
tract_kwargs=dict(render_lines_as_tubes=True, line_width=1.2),
|
|
489
482
|
export_path=None):
|
|
@@ -496,7 +489,7 @@ def plot_tracts(data=None, atlas=None, custom_atlas_path=None, views=None, layou
|
|
|
496
489
|
Parameters
|
|
497
490
|
----------
|
|
498
491
|
data : dict, list, numpy.ndarray, pandas.Series, pandas.DataFrame, optional
|
|
499
|
-
Scalar values for each tract.
|
|
492
|
+
Scalar values for each tract, or mrtrix3 derived .tsf file path for each tract.
|
|
500
493
|
If dict: Keys must match tract names.
|
|
501
494
|
If array/list: Must strictly match the sorted list of tracts in the atlas.
|
|
502
495
|
If None: Tracts are colored by category (distinct colors) or orientation.
|
|
@@ -526,9 +519,10 @@ def plot_tracts(data=None, atlas=None, custom_atlas_path=None, views=None, layou
|
|
|
526
519
|
Opacity (0.0 to 1.0) for regions with no data. Set to 0.0 to hide them.
|
|
527
520
|
style : str, optional
|
|
528
521
|
Lighting preset ('default', 'matte', 'glossy', 'sculpted', 'flat').
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
522
|
+
bmesh : pyvista.PolyData or dict, optional
|
|
523
|
+
Configure background context brain mesh. Accepts a string
|
|
524
|
+
(e.g., 'midthickness', 'white', 'swm', etc), single PolyData (used for both hemispheres)
|
|
525
|
+
or a dict with 'L'/'R' keys. Default is 'midthickness'.
|
|
532
526
|
bmesh_alpha : float, optional
|
|
533
527
|
Opacity of the context brain mesh. Default is 0.2.
|
|
534
528
|
bmesh_color : str, optional
|
|
@@ -563,14 +557,22 @@ def plot_tracts(data=None, atlas=None, custom_atlas_path=None, views=None, layou
|
|
|
563
557
|
|
|
564
558
|
# locate tract files, returns dict eg {'CST_L': '/path/to/CST_L.trk', ...}
|
|
565
559
|
file_map = _find_tract_files(atlas_dir)
|
|
566
|
-
tract_names =
|
|
560
|
+
tract_names = get_atlas_regions(atlas, 'tracts', custom_atlas_path)
|
|
567
561
|
|
|
568
562
|
# prepare colors and map data
|
|
569
563
|
if data is not None:
|
|
570
564
|
d_data = prep_data(data, tract_names, atlas, 'tracts')
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
565
|
+
all_vals = []
|
|
566
|
+
for v in d_data.values():
|
|
567
|
+
v_arr = np.atleast_1d(v)
|
|
568
|
+
all_vals.append(v_arr[~np.isnan(v_arr)])
|
|
569
|
+
|
|
570
|
+
if all_vals:
|
|
571
|
+
valid_vals = np.concatenate(all_vals)
|
|
572
|
+
vmin = vminmax[0] if vminmax[0] is not None else (np.min(valid_vals) if len(valid_vals) else 0)
|
|
573
|
+
vmax = vminmax[1] if vminmax[1] is not None else (np.max(valid_vals) if len(valid_vals) else 1)
|
|
574
|
+
else:
|
|
575
|
+
vmin, vmax = 0, 1
|
|
574
576
|
c_vlim = [vmin, vmax]
|
|
575
577
|
# categorical/orientation mode
|
|
576
578
|
else:
|
|
@@ -579,11 +581,7 @@ def plot_tracts(data=None, atlas=None, custom_atlas_path=None, views=None, layou
|
|
|
579
581
|
c_vlim = [0, 1]
|
|
580
582
|
|
|
581
583
|
# load context brain mesh (if requested)
|
|
582
|
-
|
|
583
|
-
if bmesh_type:
|
|
584
|
-
b_lh_path, b_rh_path = get_surface_paths(bmesh_type, 'bmesh')
|
|
585
|
-
bmesh['L'] = load_gii2pv(b_lh_path)
|
|
586
|
-
bmesh['R'] = load_gii2pv(b_rh_path)
|
|
584
|
+
ctx_meshes = load_bmesh(bmesh)
|
|
587
585
|
|
|
588
586
|
# setup plotter
|
|
589
587
|
sel_views = get_view_configs(views)
|
|
@@ -632,7 +630,7 @@ def plot_tracts(data=None, atlas=None, custom_atlas_path=None, views=None, layou
|
|
|
632
630
|
plotter.subplot(i // ncols, i % ncols)
|
|
633
631
|
|
|
634
632
|
# add context (passed shading params to context mesh)
|
|
635
|
-
add_context_to_view(plotter,
|
|
633
|
+
add_context_to_view(plotter, ctx_meshes, cfg['side'], bmesh_alpha, bmesh_color, **shading_params)
|
|
636
634
|
|
|
637
635
|
# add tracts
|
|
638
636
|
for name in tract_names:
|
|
@@ -641,16 +639,25 @@ def plot_tracts(data=None, atlas=None, custom_atlas_path=None, views=None, layou
|
|
|
641
639
|
val = np.nan
|
|
642
640
|
|
|
643
641
|
if data is not None and not orientation_coloring:
|
|
644
|
-
|
|
642
|
+
# check data
|
|
643
|
+
if name in d_data and d_data[name] is not None:
|
|
645
644
|
val = d_data[name]
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
645
|
+
if np.isscalar(val) and np.isnan(val):
|
|
646
|
+
has_value = False
|
|
647
|
+
elif not np.isscalar(val) and np.all(np.isnan(val)):
|
|
648
|
+
has_value = False
|
|
649
|
+
else:
|
|
650
|
+
has_value = True
|
|
651
|
+
else:
|
|
652
|
+
has_value = False
|
|
653
|
+
|
|
654
|
+
if not has_value and nan_alpha == 0:
|
|
655
|
+
continue
|
|
649
656
|
|
|
650
657
|
# side filtering
|
|
651
658
|
name_lower = name.lower()
|
|
652
|
-
is_left = any(x in name_lower for x in ['left', '_l', '-l', 'l_']) or name_lower.endswith('l')
|
|
653
|
-
is_right = any(x in name_lower for x in ['right', '_r', '-r', 'r_']) or name_lower.endswith('r')
|
|
659
|
+
is_left = any(x in name_lower for x in ['left', '_l', '-l', 'l_'])# or name_lower.endswith('l')
|
|
660
|
+
is_right = any(x in name_lower for x in ['right', '_r', '-r', 'r_'])# or name_lower.endswith('r')
|
|
654
661
|
if cfg['side'] == 'L' and is_right and not is_left: continue
|
|
655
662
|
if cfg['side'] == 'R' and is_left and not is_right: continue
|
|
656
663
|
|
|
@@ -661,16 +668,29 @@ def plot_tracts(data=None, atlas=None, custom_atlas_path=None, views=None, layou
|
|
|
661
668
|
|
|
662
669
|
# start with style presets, then override with tract_kwargs and dynamic props
|
|
663
670
|
props = shading_params.copy()
|
|
664
|
-
props.update(tract_kwargs)
|
|
665
|
-
|
|
671
|
+
props.update(tract_kwargs)
|
|
672
|
+
|
|
666
673
|
if orientation_coloring:
|
|
667
674
|
pv_mesh['Data'] = pv_mesh.point_data['tangents']
|
|
675
|
+
|
|
668
676
|
props.update({
|
|
669
677
|
'scalars': 'Data', 'rgb': True, 'opacity': alpha
|
|
670
678
|
})
|
|
671
679
|
|
|
672
680
|
elif data is not None:
|
|
673
|
-
|
|
681
|
+
if np.isscalar(val):
|
|
682
|
+
pv_mesh['Data'] = np.full(pv_mesh.n_points, val)
|
|
683
|
+
elif len(val) == 1:
|
|
684
|
+
pv_mesh['Data'] = np.full(pv_mesh.n_points, val[0])
|
|
685
|
+
elif len(val) == pv_mesh.n_points:
|
|
686
|
+
pv_mesh['Data'] = val
|
|
687
|
+
else:
|
|
688
|
+
raise ValueError(
|
|
689
|
+
f"Data shape mismatch for tract '{name}'. Must be a scalar "
|
|
690
|
+
f"or a 1D array matching the number of points. "
|
|
691
|
+
f"Array shape: {np.shape(val)}, mesh points: {pv_mesh.n_points}"
|
|
692
|
+
)
|
|
693
|
+
|
|
674
694
|
current_opacity = alpha if has_value else nan_alpha
|
|
675
695
|
|
|
676
696
|
props.update({
|
|
@@ -703,4 +723,4 @@ def plot_tracts(data=None, atlas=None, custom_atlas_path=None, views=None, layou
|
|
|
703
723
|
del plotter
|
|
704
724
|
gc.collect()
|
|
705
725
|
|
|
706
|
-
return ret_val
|
|
726
|
+
return ret_val
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
import numpy as np
|
|
3
|
+
import nibabel as nib
|
|
4
|
+
from scipy.ndimage import map_coordinates
|
|
5
|
+
|
|
6
|
+
def project_vol2surf(nii_path, bmesh='midthickness', mask_medial_wall=True, interpolation='linear'):
|
|
7
|
+
"""
|
|
8
|
+
Projects a 3D NIfTI volume onto 2D cortical surface vertices.
|
|
9
|
+
|
|
10
|
+
It maps volumetric data directly onto surface meshes by converting real-world coordinates
|
|
11
|
+
using the image affine and sampling the data array at those exact points.
|
|
12
|
+
|
|
13
|
+
Parameters
|
|
14
|
+
----------
|
|
15
|
+
nii_path : str
|
|
16
|
+
absolute path to the 3D or 4D NIfTI volume.
|
|
17
|
+
if 4D, only the first volume/timepoint is used.
|
|
18
|
+
bmesh : str, dict, or pyvista.PolyData, optional
|
|
19
|
+
background mesh to use for projection coordinates. accepts a standard
|
|
20
|
+
string (e.g., 'midthickness') or a dictionary of custom pyvista meshes
|
|
21
|
+
{'L': mesh, 'R': mesh}. default is 'midthickness'.
|
|
22
|
+
mask_medial_wall : bool, optional
|
|
23
|
+
whether to automatically set the medial wall vertices to NaN to prevent
|
|
24
|
+
subcortical signal from bleeding onto the cortical surface.
|
|
25
|
+
Note: only supported if `bmesh` is a standard string. default is True.
|
|
26
|
+
interpolation : {'linear', 'nearest'}, optional
|
|
27
|
+
interpolation method for sampling the volume. 'linear' performs trilinear
|
|
28
|
+
interpolation (smoother, good for continuous t-stats), while 'nearest'
|
|
29
|
+
snaps to the closest voxel center (strictly required for p-values or atlases).
|
|
30
|
+
default is 'linear'.
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
lh_data : numpy.ndarray
|
|
35
|
+
1D array of projected values for the left hemisphere vertices.
|
|
36
|
+
rh_data : numpy.ndarray
|
|
37
|
+
1D array of projected values for the right hemisphere vertices.
|
|
38
|
+
"""
|
|
39
|
+
from .data import get_surface_paths
|
|
40
|
+
from .mesh import load_bmesh, extract_polydata
|
|
41
|
+
|
|
42
|
+
# load volume
|
|
43
|
+
img = nib.load(nii_path)
|
|
44
|
+
vol_data = img.get_fdata()
|
|
45
|
+
|
|
46
|
+
if vol_data.ndim > 3:
|
|
47
|
+
warnings.warn(f"[WARNING] detected {vol_data.ndim}d nifti volume. using the first volume (index 0).")
|
|
48
|
+
vol_data = vol_data[..., 0]
|
|
49
|
+
|
|
50
|
+
inv_affine = np.linalg.inv(img.affine)
|
|
51
|
+
|
|
52
|
+
# load brain mesh
|
|
53
|
+
loaded_meshes = load_bmesh(bmesh)
|
|
54
|
+
|
|
55
|
+
if 'L' not in loaded_meshes or 'R' not in loaded_meshes:
|
|
56
|
+
raise ValueError("project_vol2surf requires both 'L' and 'R' hemispheres in the bmesh dictionary.")
|
|
57
|
+
|
|
58
|
+
# extract raw coordinates for math
|
|
59
|
+
lh_v, _ = extract_polydata(loaded_meshes['L'])
|
|
60
|
+
rh_v, _ = extract_polydata(loaded_meshes['R'])
|
|
61
|
+
|
|
62
|
+
def sample_surface(vertices, volume, inv_aff, interp):
|
|
63
|
+
# convert [x, y, z] to [x, y, z, 1] to allow 4x4 affine matrix multiplication
|
|
64
|
+
coords_homo = np.hstack((vertices, np.ones((vertices.shape[0], 1))))
|
|
65
|
+
# multiply by inverse affine to get exact decimal voxel coordinates
|
|
66
|
+
vox_coords = inv_aff.dot(coords_homo.T)[:3, :]
|
|
67
|
+
# set scipy interpolation order (1 = trilinear, 0 = nearest neighbor)
|
|
68
|
+
order = 1 if interp == 'linear' else 0
|
|
69
|
+
# sample the 3d volume at the calculated decimal coordinates
|
|
70
|
+
return map_coordinates(volume, vox_coords, order=order, mode='nearest')
|
|
71
|
+
|
|
72
|
+
# projection
|
|
73
|
+
lh_data = sample_surface(lh_v, vol_data, inv_affine, interpolation)
|
|
74
|
+
rh_data = sample_surface(rh_v, vol_data, inv_affine, interpolation)
|
|
75
|
+
|
|
76
|
+
# handle the medial wall
|
|
77
|
+
if mask_medial_wall:
|
|
78
|
+
if isinstance(bmesh, str):
|
|
79
|
+
# only if standard fs_LR 32k mesh
|
|
80
|
+
lh_mask_path, rh_mask_path = get_surface_paths('nomedialwall', 'label')
|
|
81
|
+
lh_data[nib.load(lh_mask_path).darrays[0].data == 0] = np.nan
|
|
82
|
+
rh_data[nib.load(rh_mask_path).darrays[0].data == 0] = np.nan
|
|
83
|
+
else:
|
|
84
|
+
warnings.warn("[WARNING] medial wall masking is only automatically supported for standard yabplot string meshes. skipping mask.")
|
|
85
|
+
|
|
86
|
+
return lh_data, rh_data
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def project_vol2tract_atlas(nii_path, atlas='xtract_tiny', custom_atlas_path=None, interpolation='linear'):
|
|
90
|
+
"""
|
|
91
|
+
Samples a 3D volume across all tracts in a specific atlas.
|
|
92
|
+
This is a convenience function around `project_vol2tract` that automatically
|
|
93
|
+
resolves the atlas paths, loops through all available tractograms, and returns
|
|
94
|
+
a dictionary ready to be passed directly to `plot_tracts`.
|
|
95
|
+
|
|
96
|
+
Parameters
|
|
97
|
+
----------
|
|
98
|
+
nii_path : str
|
|
99
|
+
absolute path to the 3D nifti volume (e.g., FA or MD map).
|
|
100
|
+
atlas : str, optional
|
|
101
|
+
name of the standard tract atlas. default is 'xtract_tiny'.
|
|
102
|
+
custom_atlas_path : str, optional
|
|
103
|
+
path to a custom directory of .trk files.
|
|
104
|
+
interpolation : {'linear', 'nearest'}, optional
|
|
105
|
+
trilinear interpolation (default) blends nearby voxels for a smooth map.
|
|
106
|
+
|
|
107
|
+
Returns
|
|
108
|
+
-------
|
|
109
|
+
dict
|
|
110
|
+
dictionary mapping tract names to their 1D sampled data arrays.
|
|
111
|
+
"""
|
|
112
|
+
from .data import _resolve_resource_path, _find_tract_files
|
|
113
|
+
|
|
114
|
+
# resolve the atlas directory and locate all tract files
|
|
115
|
+
atlas_dir = _resolve_resource_path(atlas, 'tracts', custom_path=custom_atlas_path)
|
|
116
|
+
tract_files = _find_tract_files(atlas_dir)
|
|
117
|
+
|
|
118
|
+
tract_data = {}
|
|
119
|
+
|
|
120
|
+
# loop through and map the volume to each tract
|
|
121
|
+
for name, trk_path in tract_files.items():
|
|
122
|
+
tract_data[name] = project_vol2tract(trk_path, nii_path, interpolation)
|
|
123
|
+
|
|
124
|
+
return tract_data
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def project_vol2tract(trk_path, nii_path, interpolation='linear'):
|
|
128
|
+
"""
|
|
129
|
+
Samples a 3D volume natively at every vertex of a tractogram. Maps the streamline
|
|
130
|
+
coordinates directly into the volumetric voxel space using the image affine.
|
|
131
|
+
|
|
132
|
+
Parameters
|
|
133
|
+
----------
|
|
134
|
+
trk_path : str
|
|
135
|
+
absolute path to the .trk or .tck tractography file.
|
|
136
|
+
nii_path : str
|
|
137
|
+
absolute path to the 3D nifti volume (e.g., FA or MD map).
|
|
138
|
+
interpolation : {'linear', 'nearest'}, optional
|
|
139
|
+
trilinear interpolation (default) blends nearby voxels for a smooth map.
|
|
140
|
+
|
|
141
|
+
Returns
|
|
142
|
+
-------
|
|
143
|
+
numpy.ndarray
|
|
144
|
+
1D array of sampled values corresponding exactly to the flattened
|
|
145
|
+
points of the tractogram, ready to be injected into plot_tracts.
|
|
146
|
+
"""
|
|
147
|
+
# load the 3D volume
|
|
148
|
+
img = nib.load(nii_path)
|
|
149
|
+
vol_data = img.get_fdata()
|
|
150
|
+
if vol_data.ndim > 3:
|
|
151
|
+
vol_data = vol_data[..., 0]
|
|
152
|
+
|
|
153
|
+
inv_affine = np.linalg.inv(img.affine)
|
|
154
|
+
|
|
155
|
+
# load the tractogram
|
|
156
|
+
trk = nib.streamlines.load(trk_path)
|
|
157
|
+
|
|
158
|
+
# stack all streamline coordinates into a single (n_points, 3) array
|
|
159
|
+
points = np.vstack(trk.streamlines)
|
|
160
|
+
|
|
161
|
+
# convert coordinates using the inverse affine
|
|
162
|
+
coords_homo = np.hstack((points, np.ones((points.shape[0], 1))))
|
|
163
|
+
vox_coords = inv_affine.dot(coords_homo.T)[:3, :]
|
|
164
|
+
|
|
165
|
+
# sample the volume
|
|
166
|
+
order = 1 if interpolation == 'linear' else 0
|
|
167
|
+
sampled_data = map_coordinates(vol_data, vox_coords, order=order, mode='nearest')
|
|
168
|
+
|
|
169
|
+
return sampled_data
|
|
@@ -102,7 +102,7 @@ def add_context_to_view(plotter, bmesh, view_side, alpha, color, **kwargs):
|
|
|
102
102
|
"""
|
|
103
103
|
if not bmesh: return
|
|
104
104
|
for h, mesh in bmesh.items():
|
|
105
|
-
if (view_side == 'L' and h == '
|
|
105
|
+
if (view_side == 'L' and h == 'R') or (view_side == 'R' and h == 'L'): continue
|
|
106
106
|
plotter.add_mesh(mesh, color=color, opacity=alpha,
|
|
107
107
|
smooth_shading=True, show_edges=False,
|
|
108
108
|
**kwargs)
|
|
@@ -3,6 +3,7 @@ import pandas as pd
|
|
|
3
3
|
import nibabel as nib
|
|
4
4
|
import pyvista as pv
|
|
5
5
|
import matplotlib.pyplot as plt
|
|
6
|
+
import os
|
|
6
7
|
|
|
7
8
|
def load_gii(gii_path):
|
|
8
9
|
"""Load GIfTI geometry (vertices, faces)."""
|
|
@@ -55,11 +56,9 @@ def prep_data(data, regions, atlas, category):
|
|
|
55
56
|
"""Standardize input data to dictionary."""
|
|
56
57
|
if isinstance(data, pd.DataFrame):
|
|
57
58
|
if data.shape[1] >= 2:
|
|
58
|
-
|
|
59
|
+
data = dict(zip(data.iloc[:, 0], data.iloc[:, 1]))
|
|
59
60
|
elif isinstance(data, pd.Series):
|
|
60
|
-
|
|
61
|
-
elif isinstance(data, dict):
|
|
62
|
-
return data
|
|
61
|
+
data = data.to_dict()
|
|
63
62
|
elif isinstance(data, (list, np.ndarray, tuple)):
|
|
64
63
|
if len(data) != len(regions):
|
|
65
64
|
raise ValueError(
|
|
@@ -69,7 +68,13 @@ def prep_data(data, regions, atlas, category):
|
|
|
69
68
|
f"Use `yabplot.get_atlas_regions('{atlas}', '{category}')` to see expected order."
|
|
70
69
|
)
|
|
71
70
|
# map strictly by order
|
|
72
|
-
|
|
71
|
+
data = dict(zip(regions, data))
|
|
72
|
+
|
|
73
|
+
#resolve any present tsf paths:
|
|
74
|
+
if isinstance(data, dict):
|
|
75
|
+
for key, value in data.items():
|
|
76
|
+
if isinstance(value, str):
|
|
77
|
+
data[key] = read_tsf(value)
|
|
73
78
|
|
|
74
79
|
return data
|
|
75
80
|
|
|
@@ -105,3 +110,84 @@ def parse_lut(lut_path):
|
|
|
105
110
|
|
|
106
111
|
return ids, lut_colors, lut_names_list, max_id
|
|
107
112
|
|
|
113
|
+
|
|
114
|
+
def load_tsf(tsf_path: str) -> np.ndarray:
|
|
115
|
+
"""
|
|
116
|
+
Reads an MRtrix3 .tsf (track scalar file). Useful for users who
|
|
117
|
+
have already computed tractometry metrics using MRtrix3's `tcksample`
|
|
118
|
+
command and want to plot the resulting values in yabplot.
|
|
119
|
+
|
|
120
|
+
Parameters
|
|
121
|
+
----------
|
|
122
|
+
tsf_path : str
|
|
123
|
+
absolute path to the .tsf file.
|
|
124
|
+
|
|
125
|
+
Returns
|
|
126
|
+
-------
|
|
127
|
+
numpy.ndarray
|
|
128
|
+
1D array of scalar values for the streamlines.
|
|
129
|
+
"""
|
|
130
|
+
if not os.path.isfile(tsf_path):
|
|
131
|
+
raise FileNotFoundError(f"File not found: {tsf_path}")
|
|
132
|
+
|
|
133
|
+
header: dict[str, str] = {}
|
|
134
|
+
data_offset: int | None = None
|
|
135
|
+
|
|
136
|
+
with open(tsf_path, "rb") as fh:
|
|
137
|
+
# first line must be the magic string
|
|
138
|
+
magic_line = fh.readline().decode("ascii", errors="replace").strip()
|
|
139
|
+
if not magic_line.lower().startswith("mrtrix track scalars"):
|
|
140
|
+
raise ValueError(
|
|
141
|
+
"Not a valid MRtrix TSF file (missing 'mrtrix track scalars' magic)."
|
|
142
|
+
)
|
|
143
|
+
header["magic"] = magic_line
|
|
144
|
+
|
|
145
|
+
while True:
|
|
146
|
+
line = fh.readline()
|
|
147
|
+
if not line:
|
|
148
|
+
raise ValueError("Unexpected end of file while reading header.")
|
|
149
|
+
line = line.decode("ascii", errors="replace").strip()
|
|
150
|
+
if line == "END":
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
# parse "key: value" pairs
|
|
154
|
+
colon_pos = line.find(":")
|
|
155
|
+
if colon_pos > 0:
|
|
156
|
+
key = line[:colon_pos].strip()
|
|
157
|
+
value = line[colon_pos + 1 :].strip()
|
|
158
|
+
header[key] = value
|
|
159
|
+
|
|
160
|
+
# capture the data offset
|
|
161
|
+
if key.lower() == "file":
|
|
162
|
+
parts = value.split()
|
|
163
|
+
data_offset = int(parts[-1])
|
|
164
|
+
|
|
165
|
+
if data_offset is None:
|
|
166
|
+
raise ValueError("Could not determine data offset from header.")
|
|
167
|
+
|
|
168
|
+
# read the binary data
|
|
169
|
+
fh.seek(data_offset)
|
|
170
|
+
raw_bytes = fh.read()
|
|
171
|
+
|
|
172
|
+
# determine byte order from header
|
|
173
|
+
datatype = header.get("datatype", "Float32LE").lower()
|
|
174
|
+
byte_order = ">" if datatype.endswith("be") else "<"
|
|
175
|
+
|
|
176
|
+
if "64" in datatype:
|
|
177
|
+
dtype = np.dtype(f"{byte_order}f8")
|
|
178
|
+
else:
|
|
179
|
+
dtype = np.dtype(f"{byte_order}f4")
|
|
180
|
+
|
|
181
|
+
# trim any trailing bytes that don't fill a complete element
|
|
182
|
+
element_size = dtype.itemsize
|
|
183
|
+
usable = len(raw_bytes) - (len(raw_bytes) % element_size)
|
|
184
|
+
raw_data = np.frombuffer(raw_bytes[:usable], dtype=dtype)
|
|
185
|
+
|
|
186
|
+
# split into per-streamline vectors
|
|
187
|
+
inf_mask = np.isinf(raw_data)
|
|
188
|
+
inf_indices = np.where(inf_mask)[0]
|
|
189
|
+
if inf_indices.size > 0:
|
|
190
|
+
raw_data = raw_data[: inf_indices[0]]
|
|
191
|
+
|
|
192
|
+
nan_mask = np.isnan(raw_data)
|
|
193
|
+
return raw_data[~nan_mask]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: yabplot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: yet another brain plot
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -38,7 +38,7 @@ the idea is simple. while there are already amazing visualization tools availabl
|
|
|
38
38
|
## features
|
|
39
39
|
|
|
40
40
|
* **pre-existing atlases:** access many commonly used atlases (schaefer2018, brainnetome, aparc, aseg, musus100, xtract, etc) on demand.
|
|
41
|
-
*
|
|
41
|
+
* **vertexwise plotting:** project volume (.nii) to cortical surface and plot.
|
|
42
42
|
* **simple to use:** plug-n-play functions for cortex, subcortex, and tracts with a unified API.
|
|
43
43
|
* **custom atlases:** easily use your own parcellations, segmentations (.nii/.gii), or tractograms (.trk).
|
|
44
44
|
* **flexible inputs:** accepts data as dictionaries (for partial mapping) or arrays (for strict mapping).
|
|
@@ -77,16 +77,12 @@ print(yab.get_available_resources())
|
|
|
77
77
|
# see the region names for a specific atlas
|
|
78
78
|
print(yab.get_atlas_regions(atlas='aseg', category='subcortical'))
|
|
79
79
|
|
|
80
|
-
# cortical
|
|
80
|
+
# cortical surface regions
|
|
81
81
|
atlas = 'aparc'
|
|
82
82
|
dmap1 = {'L_lateraloccipital': 0.265, 'L_postcentral': 0.086, ...}
|
|
83
|
-
dmap2 = {'L_fusiform': 0.218, 'L_supramarginal': 0.119, ...}
|
|
84
83
|
yab.plot_cortical(data=dmap1, atlas=atlas, vminmax=[-0.1, 0.3],
|
|
85
|
-
|
|
86
|
-
figsize=(600, 300), cmap='viridis'
|
|
87
|
-
yab.plot_cortical(data=dmap2, atlas=atlas, vminmax=[-0.1, 0.3],
|
|
88
|
-
bmesh_type='swm', views=['left_lateral', 'left_medial'],
|
|
89
|
-
figsize=(1200, 600), cmap='viridis', proc_vertices='sharp')
|
|
84
|
+
bmesh='midthickness', views=['left_lateral', 'left_medial'],
|
|
85
|
+
figsize=(600, 300), cmap='viridis')
|
|
90
86
|
|
|
91
87
|
# subcortical structures
|
|
92
88
|
atlas = 'aseg'
|
|
@@ -96,12 +92,22 @@ yab.plot_subcortical(data=data, atlas=atlas, vminmax=[2, 14],
|
|
|
96
92
|
views=['left_lateral', 'superior', 'right_lateral'],
|
|
97
93
|
bmesh_alpha=0.1, figsize=(600, 300), cmap='viridis')
|
|
98
94
|
|
|
95
|
+
# vertex-wise surface
|
|
96
|
+
threshold = 4
|
|
97
|
+
b_lh_path, b_rh_path = yab.data.get_surface_paths('midthickness', 'bmesh')
|
|
98
|
+
lh_data, rh_data = yab.project_vol2surf('path/to/yourdata.nii.gz', bmesh='midthickness')
|
|
99
|
+
lh_data = np.where(lh_data > threshold, lh_data, np.nan)
|
|
100
|
+
rh_data = np.where(rh_data > threshold, rh_data, np.nan)
|
|
101
|
+
lh_mesh, rh_mesh = yab.load_vertexwise_mesh(b_lh_path, b_rh_path, lh_data, rh_data)
|
|
102
|
+
yab.plot_vertexwise(lh_mesh, rh_mesh, cmap='viridis', vminmax=[-4, 9],
|
|
103
|
+
views=['left_lateral', 'left_medial'], figsize=(600, 300))
|
|
104
|
+
|
|
99
105
|
# white matter bundles
|
|
100
106
|
atlas = 'xtract_tiny'
|
|
101
107
|
regs = yab.get_atlas_regions(atlas=atlas, category='tracts')
|
|
102
108
|
data = {reg: np.sin(i) for i, reg in enumerate(regs)}
|
|
103
109
|
yab.plot_tracts(data=data, atlas=atlas, style='matte',
|
|
104
|
-
views=['left_lateral', 'anterior', 'superior'],
|
|
110
|
+
views=['left_lateral', 'anterior', 'superior'], bmesh='pial',
|
|
105
111
|
bmesh_alpha=0.1, figsize=(1600, 800), cmap='viridis')
|
|
106
112
|
|
|
107
113
|
```
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
cortical-aparc.zip sha256:9e3e4853c580b6ed7c70a2b398b8ddacfc30e05600f04f722d3b5e8ee28ba119 https://osf.io/5btcf/download
|
|
2
|
-
cortical-brainnetome.zip sha256:fc4bb0043f2efa133771240f9e7178521be4011e76e5cad8102c9c099fce5ef0 https://osf.io/xspn5/download
|
|
3
|
-
cortical-schaefer_100.zip sha256:041c229b900c86f6b80cc44a05dca74bcfccde9aad5d10878da04a184d0f26ba https://osf.io/yxt5p/download
|
|
4
|
-
cortical-schaefer_200.zip sha256:065ee05afa1881a8884bdecee25a062ddd5fd6283bfeaabb59e6eb696f39563f https://osf.io/v45gq/download
|
|
5
|
-
cortical-schaefer_300.zip sha256:5430c66eaef58f008b9e20dd5c59c670c7f7154b02f4b310dda97250331b6eed https://osf.io/9djtr/download
|
|
6
|
-
cortical-schaefer_400.zip sha256:ae2b7011919d49e9930ee5aed1b19731180427f07f0d1c4497130dd9cd5e878d https://osf.io/pzmc5/download
|
|
7
|
-
cortical-schaefer_1000.zip sha256:a10448f101a874499d41bc62508c3af29120634955f48beb282ece9a4cc2bb0e https://osf.io/j9ygz/download
|
|
8
|
-
cortical-aal3.zip sha256:925e475e099b127925e20e6ec4d179b1a58971f2bc32b2a61f56a479f3e8d4fb https://osf.io/z9jxh/download
|
|
9
|
-
subcortical-aseg.zip sha256:a901a7fc6a39f9bdaf2ef2bafbcde1fbc085b225d99e6730c727650d9be047d5 https://osf.io/5cs7y/download
|
|
10
|
-
subcortical-brainnetome_sc.zip sha256:8301fdf6af109af52a2cf9b06d15486345d457b69cb86169ed38332a34a681c0 https://osf.io/2fsg5/download
|
|
11
|
-
subcortical-jhu.zip sha256:b0ca292589a9f041851dba8159bb605a4657323e4305ba1570a304944b28d0de https://osf.io/x5fhg/download
|
|
12
|
-
subcortical-musus100_dbn.zip sha256:1d865832a35570a8c67f79d5049b58c548d25cf4fe1853164c384193b6712e40 https://osf.io/eutmb/download
|
|
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
|
|
16
|
-
tracts-hcp1065_medium.zip sha256:366bb100074cf7b1e55586e5468653f0c342e42cfdd91dca6d99b6fde82f06ff https://osf.io/kjf8e/download
|
|
17
|
-
tracts-hcp1065_small.zip sha256:020f23059c3a20ee0dda8ad12cd3df99b9fe5d3b37e7a6b9ae34b8a52b4b4346 https://osf.io/ynpa5/download
|
|
18
|
-
tracts-hcp1065_tiny.zip sha256:54380d82f5cd234029c6fc011910e00f0ad859fdb6c22c6aaa6452d055449ae9 https://osf.io/jzk7p/download
|
|
19
|
-
tracts-xtract_large.zip sha256:e2d59e9f90b024018788af87e389f93b2fa9ac213604601966a44d7f81c9d89f https://osf.io/pktq6/download
|
|
20
|
-
tracts-xtract_medium.zip sha256:f095028d3dfc0f974ebef1feffe4006b5bac1366325a784ea2301c56f583b1e7 https://osf.io/7tbjg/download
|
|
21
|
-
tracts-xtract_small.zip sha256:9d3fe2c57acb87e8d13eb40a49a6c2c354dbe1a020122b693e9bf713e57cad5b https://osf.io/bmn3a/download
|
|
22
|
-
tracts-xtract_tiny.zip sha256:469f9ed8ed5ceb7f8e17c9a0a92f1491d540814f832ef56441a7539532111f41 https://osf.io/a73x2/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|