yabplot 0.3.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yabplot
3
- Version: 0.3.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
- * [new!] **vertexwise plotting:** project volume (.nii) to cortical surface and plot.
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 surfaces
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
- bmesh_type='midthickness', views=['left_lateral', 'left_medial'],
86
- figsize=(600, 300), cmap='viridis', proc_vertices='sharp')
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'], bmesh_type='pial',
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
- * [new!] **vertexwise plotting:** project volume (.nii) to cortical surface and plot.
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 surfaces
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
- bmesh_type='midthickness', views=['left_lateral', 'left_medial'],
62
- figsize=(600, 300), cmap='viridis', proc_vertices='sharp')
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'], bmesh_type='pial',
86
+ views=['left_lateral', 'anterior', 'superior'], bmesh='pial',
81
87
  bmesh_alpha=0.1, figsize=(1600, 800), cmap='viridis')
82
88
 
83
89
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "yabplot"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "yet another brain plot"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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, project_vol2surf
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
- # the plotting function sorts keys alphabetically
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
- # the plotting function sorts keys alphabetically
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
- # optimization: check if unpacked folder already exists
197
- # if yes, skip pooch check entirely to avoid re-downloading
198
- if os.path.isdir(extract_path) and os.listdir(extract_path):
199
- return extract_path
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
- # fetch and unzip
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 ValueError:
208
- # if key not in registry
209
- available = list(GOODBOY.registry.keys())
210
- raise ValueError(f"Resource '{resource_key}' not found in registry.")
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
- raise FileNotFoundError(f"Custom atlas directory not found: {custom_path}")
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
- # scan for both .trk and .tck
407
- found_files = _scan_for_ext(atlas_dir, ".trk") + _scan_for_ext(atlas_dir, ".tck")
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
- from .utils import load_gii
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
- bmesh_type='midthickness', figsize=(1000, 600), cmap='coolwarm', vminmax=[None, None],
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
- bmesh_type : str
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(bmesh_type, 'bmesh')
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 (e.g., from `make_cortical_mesh`) with per-vertex
223
- scalar data stored under the key specified by `scalars`.
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.points
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.points
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', bmesh_type='midthickness',
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
- bmesh_type : str or None, optional
344
- Name of the background context brain mesh (e.g., 'midthickness', 'white', 'swm', etc).
345
- Set to None to hide the context brain. Default is 'midthickness'.
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
- bmesh = {}
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 = sorted(list(file_map.keys()))
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, bmesh, cfg['side'], bmesh_alpha, bmesh_color,
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
- bmesh_type='midthickness', bmesh_alpha=0.2, bmesh_color='lightgray',
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
- bmesh_type : str or None, optional
530
- Name of the background context brain mesh (e.g., 'midthickness', 'white', 'swm', etc).
531
- Set to None to hide the context brain. Default is 'midthickness'.
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 = sorted(list(file_map.keys()))
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
- valid_vals = [v for v in d_data.values() if pd.notna(v)]
572
- vmin = vminmax[0] if vminmax[0] is not None else (min(valid_vals) if valid_vals else 0)
573
- vmax = vminmax[1] if vminmax[1] is not None else (max(valid_vals) if valid_vals else 1)
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
- bmesh = {}
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, bmesh, cfg['side'], bmesh_alpha, bmesh_color, **shading_params)
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
- if name in d_data and pd.notna(d_data[name]):
642
+ # check data
643
+ if name in d_data and d_data[name] is not None:
645
644
  val = d_data[name]
646
- has_value = True
647
- elif nan_alpha == 0:
648
- continue
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
- pv_mesh['Data'] = np.full(pv_mesh.n_points, val)
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 == 'L') or (view_side == 'R' and h == 'R'): continue
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)
@@ -137,6 +137,7 @@ def add_colorbars(plotter, mappers, titles, nrows, figsize):
137
137
 
138
138
  if num_bars == 1:
139
139
  positions_y = [0.15]
140
+ cb_height = 0.4
140
141
  elif num_bars == 2:
141
142
  positions_y = [0.5, 0.01]
142
143
  else:
@@ -147,7 +148,7 @@ def add_colorbars(plotter, mappers, titles, nrows, figsize):
147
148
  mapper=mapper, title=title, vertical=False,
148
149
  position_x=pos_x, position_y=positions_y[i],
149
150
  height=cb_height, width=cb_width, color='black',
150
- title_font_size=13, label_font_size=11, n_labels=5, fmt="%.2f"
151
+ title_font_size=13, label_font_size=11, n_labels=3, fmt="%g"
151
152
  )
152
153
 
153
154
  def finalize_plot(plotter, export_path, display_type):
@@ -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
- return dict(zip(data.iloc[:, 0], data.iloc[:, 1]))
59
+ data = dict(zip(data.iloc[:, 0], data.iloc[:, 1]))
59
60
  elif isinstance(data, pd.Series):
60
- return data.to_dict()
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
- return dict(zip(regions, data))
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.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
- * [new!] **vertexwise plotting:** project volume (.nii) to cortical surface and plot.
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 surfaces
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
- bmesh_type='midthickness', views=['left_lateral', 'left_medial'],
86
- figsize=(600, 300), cmap='viridis', proc_vertices='sharp')
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'], bmesh_type='pial',
110
+ views=['left_lateral', 'anterior', 'superior'], bmesh='pial',
105
111
  bmesh_alpha=0.1, figsize=(1600, 800), cmap='viridis')
106
112
 
107
113
  ```
@@ -7,6 +7,7 @@ yabplot/__init__.py
7
7
  yabplot/atlas_builder.py
8
8
  yabplot/mesh.py
9
9
  yabplot/plotting.py
10
+ yabplot/projection.py
10
11
  yabplot/scene.py
11
12
  yabplot/utils.py
12
13
  yabplot/wrappers.py
@@ -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