yabplot 0.1.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.1.0/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 Toomas Erik Anijärv
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
14
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
15
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
16
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
17
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
18
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
19
+ OR OTHER DEALINGS IN THE SOFTWARE.
yabplot-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: yabplot
3
+ Version: 0.1.0
4
+ Summary: yet another brain plot
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: ipywidgets>=8.1.8
9
+ Requires-Dist: nibabel>=5.3.2
10
+ Requires-Dist: pandas>=2.3.3
11
+ Requires-Dist: pooch>=1.8.2
12
+ Requires-Dist: pyvista>=0.46.4
13
+ Requires-Dist: scikit-image>=0.25.2
14
+ Requires-Dist: trame>=3.12.0
15
+ Requires-Dist: trame-vtk>=2.10.0
16
+ Requires-Dist: trame-vuetify>=3.1.0
17
+ Provides-Extra: docs
18
+ Requires-Dist: mkdocs; extra == "docs"
19
+ Requires-Dist: mkdocs-jupyter>=0.25.1; extra == "docs"
20
+ Requires-Dist: mkdocs-material; extra == "docs"
21
+ Requires-Dist: mkdocstrings[python]; extra == "docs"
22
+ Dynamic: license-file
23
+
24
+ # yabplot: yet another brain plot
25
+
26
+ ![logo](docs/assets/yabplot_logo.png)
27
+
28
+ [![PyPI version](https://img.shields.io/pypi/v/yabplot.svg)](https://pypi.org/project/yabplot/)
29
+ [![Docs](https://github.com/teanijarv/yabplot/actions/workflows/docs.yml/badge.svg)](https://teanijarv.github.io/yabplot/)
30
+ [![Tests](https://github.com/teanijarv/yabplot/actions/workflows/tests.yml/badge.svg)](https://github.com/teanijarv/yabplot/actions/workflows/tests.yml)
31
+ <!-- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.XXXXXX.svg)](https://doi.org/10.5281/zenodo.XXXXXX) -->
32
+
33
+ **yabplot** is a Python library for creating beautiful, publication-quality 3D brain visualizations. it supports plotting cortical regions, subcortical structures, and white matter bundles.
34
+
35
+ the idea is simple. while there are already amazing visualization tools available, they often focus on specific domains—using one tool for white matter tracts and another for cortical surfaces inevitably leads to inconsistent styles. i wanted a unified, simple-to-use tool that enables me (and hopefully others) to perform most brain visualizations in a single place. recognizing that neuroscience evolves daily, i designed **yabplot** to be modular: it supports standard pre-packaged atlases out of the box, but easily accepts any custom parcellation or tractography dataset you might need.
36
+
37
+ ## features
38
+
39
+ * **pre-existing atlases:** access many commonly used atlases (schaefer2018, brainnetome, aparc, aseg, musus100, xtract, etc) on demand.
40
+ * **simple to use:** plug-n-play functions for cortex, subcortex, and tracts with a unified API.
41
+ * **custom atlases:** easily use your own parcellations, segmentations (.nii/.gii), or tractograms (.trk).
42
+ * **flexible inputs:** accepts data as dictionaries (for partial mapping) or arrays (for strict mapping).
43
+
44
+ ## installation
45
+
46
+ ```bash
47
+ uv add yabplot
48
+ ```
49
+ or
50
+ ```bash
51
+ pip install yabplot
52
+ ```
53
+
54
+ dependencies: python 3.11 with ipywidgets, nibabel, pandas, pooch, pyvista, scikit-image, trame, trame-vtk, trame-vuetify
55
+
56
+ ## quick start
57
+
58
+ please refer to the [documentation](https://teanijarv.github.io/yabplot/) for more comprehensive guides.
59
+
60
+ ```python
61
+ import yabplot as yab
62
+ import numpy as np
63
+
64
+ # see available cortical atlases
65
+ atlases = yab.get_available_resources(category='cortical')
66
+
67
+ # see the region names within the aseg atlas
68
+ regions = yab.get_atlas_regions(atlas='aseg', category='subcortical')
69
+
70
+ # plot data on cortical regions
71
+ data = np.arange(0, 1, 0.001)
72
+ yab.plot_cortical(data=data, atlas='schaefer_1000', figsize=(600, 300),
73
+ cmap='viridis', vminmax=[0, 1], style='default',
74
+ views=['left_lateral', 'superior', 'right_lateral'])
75
+
76
+
77
+ # plot values for specific subcortical regions
78
+ data = {'Left_Amygdala': 0.8, 'Right_Hippocampus': 0.5,
79
+ 'Right_Thalamus': -0.5, 'Left_Putamen': -1}
80
+ yab.plot_subcortical(data=data, atlas='aseg', figsize=(600, 450), layout=(2, 2),
81
+ views=['superior', 'anterior', 'left_lateral', 'right_lateral'],
82
+ cmap='coolwarm', vminmax=[-1, 1], style='matte')
83
+
84
+ # plot data on white matter bundles
85
+ regions = yab.get_atlas_regions(atlas='xtract_tiny', category='tracts')
86
+ data = np.arange(0, len(regions))
87
+ yab.plot_tracts(data=data, atlas='xtract_tiny', figsize=(600, 300),
88
+ views=['superior', 'anterior', 'left_lateral'], nan_color='#cccccc',
89
+ bmesh_type='fsaverage', style='default', cmap='plasma')
90
+
91
+ ```
92
+
93
+ ![examples](docs/assets/examples.png)
94
+
95
+ ## acknowledgements
96
+
97
+ yabplot relies on the extensive work of the neuroimaging community. if you use these atlases in your work, please cite the original authors.
@@ -0,0 +1,74 @@
1
+ # yabplot: yet another brain plot
2
+
3
+ ![logo](docs/assets/yabplot_logo.png)
4
+
5
+ [![PyPI version](https://img.shields.io/pypi/v/yabplot.svg)](https://pypi.org/project/yabplot/)
6
+ [![Docs](https://github.com/teanijarv/yabplot/actions/workflows/docs.yml/badge.svg)](https://teanijarv.github.io/yabplot/)
7
+ [![Tests](https://github.com/teanijarv/yabplot/actions/workflows/tests.yml/badge.svg)](https://github.com/teanijarv/yabplot/actions/workflows/tests.yml)
8
+ <!-- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.XXXXXX.svg)](https://doi.org/10.5281/zenodo.XXXXXX) -->
9
+
10
+ **yabplot** is a Python library for creating beautiful, publication-quality 3D brain visualizations. it supports plotting cortical regions, subcortical structures, and white matter bundles.
11
+
12
+ the idea is simple. while there are already amazing visualization tools available, they often focus on specific domains—using one tool for white matter tracts and another for cortical surfaces inevitably leads to inconsistent styles. i wanted a unified, simple-to-use tool that enables me (and hopefully others) to perform most brain visualizations in a single place. recognizing that neuroscience evolves daily, i designed **yabplot** to be modular: it supports standard pre-packaged atlases out of the box, but easily accepts any custom parcellation or tractography dataset you might need.
13
+
14
+ ## features
15
+
16
+ * **pre-existing atlases:** access many commonly used atlases (schaefer2018, brainnetome, aparc, aseg, musus100, xtract, etc) on demand.
17
+ * **simple to use:** plug-n-play functions for cortex, subcortex, and tracts with a unified API.
18
+ * **custom atlases:** easily use your own parcellations, segmentations (.nii/.gii), or tractograms (.trk).
19
+ * **flexible inputs:** accepts data as dictionaries (for partial mapping) or arrays (for strict mapping).
20
+
21
+ ## installation
22
+
23
+ ```bash
24
+ uv add yabplot
25
+ ```
26
+ or
27
+ ```bash
28
+ pip install yabplot
29
+ ```
30
+
31
+ dependencies: python 3.11 with ipywidgets, nibabel, pandas, pooch, pyvista, scikit-image, trame, trame-vtk, trame-vuetify
32
+
33
+ ## quick start
34
+
35
+ please refer to the [documentation](https://teanijarv.github.io/yabplot/) for more comprehensive guides.
36
+
37
+ ```python
38
+ import yabplot as yab
39
+ import numpy as np
40
+
41
+ # see available cortical atlases
42
+ atlases = yab.get_available_resources(category='cortical')
43
+
44
+ # see the region names within the aseg atlas
45
+ regions = yab.get_atlas_regions(atlas='aseg', category='subcortical')
46
+
47
+ # plot data on cortical regions
48
+ data = np.arange(0, 1, 0.001)
49
+ yab.plot_cortical(data=data, atlas='schaefer_1000', figsize=(600, 300),
50
+ cmap='viridis', vminmax=[0, 1], style='default',
51
+ views=['left_lateral', 'superior', 'right_lateral'])
52
+
53
+
54
+ # plot values for specific subcortical regions
55
+ data = {'Left_Amygdala': 0.8, 'Right_Hippocampus': 0.5,
56
+ 'Right_Thalamus': -0.5, 'Left_Putamen': -1}
57
+ yab.plot_subcortical(data=data, atlas='aseg', figsize=(600, 450), layout=(2, 2),
58
+ views=['superior', 'anterior', 'left_lateral', 'right_lateral'],
59
+ cmap='coolwarm', vminmax=[-1, 1], style='matte')
60
+
61
+ # plot data on white matter bundles
62
+ regions = yab.get_atlas_regions(atlas='xtract_tiny', category='tracts')
63
+ data = np.arange(0, len(regions))
64
+ yab.plot_tracts(data=data, atlas='xtract_tiny', figsize=(600, 300),
65
+ views=['superior', 'anterior', 'left_lateral'], nan_color='#cccccc',
66
+ bmesh_type='fsaverage', style='default', cmap='plasma')
67
+
68
+ ```
69
+
70
+ ![examples](docs/assets/examples.png)
71
+
72
+ ## acknowledgements
73
+
74
+ yabplot relies on the extensive work of the neuroimaging community. if you use these atlases in your work, please cite the original authors.
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "yabplot"
3
+ version = "0.1.0"
4
+ description = "yet another brain plot"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "ipywidgets>=8.1.8",
9
+ "nibabel>=5.3.2",
10
+ "pandas>=2.3.3",
11
+ "pooch>=1.8.2",
12
+ "pyvista>=0.46.4",
13
+ "scikit-image>=0.25.2",
14
+ "trame>=3.12.0",
15
+ "trame-vtk>=2.10.0",
16
+ "trame-vuetify>=3.1.0",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ docs = [
21
+ "mkdocs",
22
+ "mkdocs-jupyter>=0.25.1",
23
+ "mkdocs-material",
24
+ "mkdocstrings[python]",
25
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,20 @@
1
+ import pytest
2
+ import yabplot as yab
3
+ import pyvista as pv
4
+
5
+ # tell PyVista to run in "off-screen" mode so it doesn't try to open a real window
6
+ pv.OFF_SCREEN = True
7
+
8
+ def test_version():
9
+ """Check that the package has a version string."""
10
+ assert yab.__version__ is not None
11
+
12
+ def test_plotter_instantiation():
13
+ """
14
+ Smoke test: Can we create a Plotter without crashing?
15
+ This verifies VTK and PyVista are correctly linked to the system display.
16
+ """
17
+ plotter = pv.Plotter(off_screen=True)
18
+ plotter.add_mesh(pv.Sphere())
19
+ plotter.show()
20
+ plotter.close()
@@ -0,0 +1,9 @@
1
+ from importlib.metadata import version, PackageNotFoundError
2
+
3
+ from .plotting import plot_cortical, plot_subcortical, plot_tracts, clear_tract_cache
4
+ from .data import get_available_resources, get_atlas_regions
5
+
6
+ try:
7
+ __version__ = version("yabplot")
8
+ except PackageNotFoundError:
9
+ __version__ = "unknown"
@@ -0,0 +1,371 @@
1
+ """
2
+ Data management module for fetching and caching remote atlases.
3
+ """
4
+
5
+ import os
6
+ import glob
7
+ import shutil
8
+ import pooch
9
+ from importlib.resources import files
10
+
11
+ from ..utils import parse_lut
12
+
13
+ __all__ = ['get_available_resources']
14
+
15
+ # define cache location
16
+ # e.g., ~/.cache/yabplot
17
+ CACHE_DIR = pooch.os_cache("yabplot")
18
+
19
+ # setup registry
20
+ _REGISTRY_PATH = files('yabplot.data').joinpath('registry.txt')
21
+ _FETCHER = pooch.create(
22
+ path=CACHE_DIR,
23
+ base_url="",
24
+ registry=None,
25
+ )
26
+
27
+ if _REGISTRY_PATH.is_file():
28
+ _FETCHER.load_registry(_REGISTRY_PATH)
29
+
30
+
31
+ def get_available_resources(category=None):
32
+ """
33
+ Returns available resources from the registry.
34
+
35
+ Parameters
36
+ ----------
37
+ category : str or None
38
+ If provided (e.g., 'cortical', 'subcortical', 'tracts', 'bmesh'), returns a list of available names
39
+ for that specific category.
40
+ If None, returns a dictionary containing all categories and their options.
41
+ """
42
+ if not _FETCHER.registry:
43
+ return [] if category else {}
44
+
45
+ # helper to clean names: e.g., "cortical-aparc.zip" -> ("cortical", "aparc")
46
+ def _parse_key(key):
47
+ if "-" not in key: return None, None
48
+ prefix, remainder = key.split("-", 1)
49
+ name = remainder.replace(".zip", "")
50
+ return prefix, name
51
+
52
+ # mode 1: specific category
53
+ if category:
54
+ available = []
55
+ for key in _FETCHER.registry.keys():
56
+ prefix, name = _parse_key(key)
57
+ if prefix == category:
58
+ available.append(name)
59
+ return sorted(available)
60
+
61
+ # mode 2: all categories
62
+ all_resources = {}
63
+ for key in _FETCHER.registry.keys():
64
+ prefix, name = _parse_key(key)
65
+ if prefix and name:
66
+ if prefix not in all_resources:
67
+ all_resources[prefix] = []
68
+ all_resources[prefix].append(name)
69
+
70
+ for k in all_resources:
71
+ all_resources[k].sort()
72
+
73
+ return all_resources
74
+
75
+ def get_atlas_regions(atlas, category, custom_atlas_path=None):
76
+ """
77
+ Returns the list of region names for a given atlas in the specific order
78
+ used for mapping data arrays.
79
+
80
+ Parameters
81
+ ----------
82
+ atlas : str
83
+ Name of the atlas (e.g., 'aparc', 'aseg').
84
+ category : str
85
+ 'cortical', 'subcortical', or 'tracts'.
86
+ custom_atlas_path : str, optional
87
+ Path to custom atlas directory.
88
+
89
+ Returns
90
+ -------
91
+ list
92
+ List of strings containing region names.
93
+ - If input data is a LIST, it must match this order.
94
+ - If input data is a DICT, keys must match these names.
95
+ """
96
+
97
+ # resolve the directory path
98
+ try:
99
+ atlas_dir = _resolve_resource_path(atlas, category, custom_path=custom_atlas_path)
100
+ except Exception as e:
101
+ print(f"Error resolving atlas: {e}")
102
+ return []
103
+
104
+ # --- case 1: cortical ---
105
+ if category == 'cortical':
106
+ check_name = None if custom_atlas_path else atlas
107
+ try:
108
+ _, lut_path = _find_cortical_files(atlas_dir, strict_name=check_name)
109
+
110
+ # use parse_lut to get the IDs and the full names list
111
+ ids, _, names_list, _ = parse_lut(lut_path)
112
+
113
+ # return only the names corresponding to the explicit IDs in the file.
114
+ return [names_list[i] for i in ids]
115
+
116
+ except Exception as e:
117
+ print(f"Error parsing cortical atlas: {e}")
118
+ return []
119
+
120
+ # --- case 2: subcortical ---
121
+ elif category == 'subcortical':
122
+ try:
123
+ file_map = _find_subcortical_files(atlas_dir)
124
+ # the plotting function sorts keys alphabetically
125
+ return sorted(list(file_map.keys()))
126
+ except Exception as e:
127
+ print(f"Error listing subcortical regions: {e}")
128
+ return []
129
+
130
+ # --- case 3: tracts ---
131
+ elif category == 'tracts':
132
+ try:
133
+ file_map = _find_tract_files(atlas_dir)
134
+ # the plotting function sorts keys alphabetically
135
+ return sorted(list(file_map.keys()))
136
+ except Exception as e:
137
+ print(f"Error listing tracts: {e}")
138
+ return []
139
+
140
+ else:
141
+ raise ValueError("Category must be 'cortical', 'subcortical', or 'tracts'")
142
+
143
+
144
+ def _fetch_and_unpack(resource_key):
145
+ """
146
+ Downloads zip, unpacks it, deletes the zip to save space,
147
+ and returns the extraction path.
148
+ """
149
+ extract_dir_name = resource_key.replace(".zip", "")
150
+ extract_path = os.path.join(_FETCHER.path, extract_dir_name)
151
+
152
+ # optimization: check if unpacked folder already exists
153
+ # if yes, skip pooch check entirely to avoid re-downloading
154
+ if os.path.isdir(extract_path) and os.listdir(extract_path):
155
+ return extract_path
156
+
157
+ # fetch and unzip
158
+ try:
159
+ _FETCHER.fetch(
160
+ resource_key,
161
+ processor=pooch.Unzip(extract_dir=extract_dir_name)
162
+ )
163
+ except ValueError:
164
+ # if key not in registry
165
+ available = list(_FETCHER.registry.keys())
166
+ raise ValueError(f"Resource '{resource_key}' not found in registry.")
167
+
168
+ # cleanup: delete the source zip to save space
169
+ zip_path = os.path.join(_FETCHER.path, resource_key)
170
+ if os.path.exists(zip_path):
171
+ os.remove(zip_path)
172
+
173
+ return extract_path
174
+
175
+
176
+ def _resolve_resource_path(name, category, custom_path=None):
177
+ """
178
+ Internal: Resolves atlas path via download or custom location.
179
+ """
180
+ # 1. custom path logic
181
+ if custom_path:
182
+ if os.path.isdir(custom_path):
183
+ return custom_path
184
+ raise FileNotFoundError(f"Custom atlas directory not found: {custom_path}")
185
+
186
+ # 2. standard download logic
187
+ resource_key = f"{category}-{name}.zip"
188
+
189
+ # validate before fetching
190
+ if resource_key not in _FETCHER.registry:
191
+ available = get_available_resources(category)
192
+ human_cat = {
193
+ 'cortical': 'Cortical parcellations (vertices)',
194
+ 'subcortical': 'Subcortical segmentations (volumes)',
195
+ 'tracts': 'White matter bundles (tracts)',
196
+ 'bmesh': 'Brain meshes'
197
+ }.get(category, category)
198
+
199
+ raise ValueError(
200
+ f"Resource '{name}' is not available in {human_cat}.\n"
201
+ f"Available options: {available}"
202
+ )
203
+
204
+ return _fetch_and_unpack(resource_key)
205
+
206
+
207
+ def _find_cortical_files(atlas_dir, strict_name=None):
208
+ """
209
+ Internal: Locates files, ignoring hidden/system folders.
210
+ """
211
+
212
+ def _find_file(directory, pattern):
213
+ """searches root and valid subdirectories."""
214
+ # check root
215
+ candidates = glob.glob(os.path.join(directory, pattern))
216
+
217
+ # check subdirs if empty
218
+ if not candidates:
219
+ try:
220
+ # get all items, filtering out hidden/system ones
221
+ subdirs = [
222
+ os.path.join(directory, d) for d in os.listdir(directory)
223
+ if os.path.isdir(os.path.join(directory, d))
224
+ and not d.startswith(('.', '__'))
225
+ ]
226
+ subdirs.sort()
227
+
228
+ for sd in subdirs:
229
+ candidates.extend(glob.glob(os.path.join(sd, pattern)))
230
+ except FileNotFoundError:
231
+ pass
232
+
233
+ return candidates
234
+
235
+ # --- mode a: strict (standard atlases) ---
236
+ if strict_name:
237
+ csv_name = f'{strict_name}_conte69.csv'
238
+ lut_name = f'{strict_name}_LUT.txt'
239
+
240
+ found_csvs = _find_file(atlas_dir, csv_name)
241
+ if not found_csvs:
242
+ raise FileNotFoundError(f"Corrupt atlas. Missing '{csv_name}' in {atlas_dir}")
243
+
244
+ found_luts = _find_file(atlas_dir, lut_name)
245
+ if not found_luts:
246
+ raise FileNotFoundError(f"Corrupt atlas. Missing '{lut_name}' in {atlas_dir}")
247
+
248
+ return found_csvs[0], found_luts[0]
249
+
250
+ # --- mode b: flexible (custom atlases) ---
251
+
252
+ # find csv
253
+ csv_candidates = _find_file(atlas_dir, "*.csv")
254
+ if len(csv_candidates) == 1:
255
+ csv_path = csv_candidates[0]
256
+ elif len(csv_candidates) > 1:
257
+ # resolve ambiguity
258
+ filtered = [f for f in csv_candidates if 'conte69' in f]
259
+ if len(filtered) == 1:
260
+ csv_path = filtered[0]
261
+ else:
262
+ names = [os.path.basename(c) for c in csv_candidates]
263
+ raise ValueError(f"Ambiguous CSVs found: {names}")
264
+ else:
265
+ raise FileNotFoundError(f"No .csv file found in custom directory: {atlas_dir}")
266
+
267
+ # find lut
268
+ lut_candidates = _find_file(atlas_dir, "*.txt") + _find_file(atlas_dir, "*.lut")
269
+ if len(lut_candidates) == 1:
270
+ lut_path = lut_candidates[0]
271
+ elif len(lut_candidates) > 1:
272
+ # resolve ambiguity
273
+ filtered = [f for f in lut_candidates if 'LUT' in f or 'lut' in f]
274
+ if len(filtered) == 1:
275
+ lut_path = filtered[0]
276
+ else:
277
+ names = [os.path.basename(c) for c in lut_candidates]
278
+ raise ValueError(f"Ambiguous LUTs found: {names}")
279
+ else:
280
+ raise FileNotFoundError(f"No LUT file found in custom directory: {atlas_dir}")
281
+
282
+ return csv_path, lut_path
283
+
284
+
285
+ def _find_subcortical_files(atlas_dir):
286
+ """
287
+ Internal: Scans directory for mesh files (.vtk preferred, then .gii).
288
+ Returns a dictionary: {region_name: file_path}
289
+ """
290
+
291
+ def _scan_for_ext(directory, extension):
292
+ """Recursively finds files with extension, ignoring junk folders."""
293
+ candidates = []
294
+ # check root
295
+ candidates.extend(glob.glob(os.path.join(directory, f"*{extension}")))
296
+
297
+ # check valid subdirectories
298
+ try:
299
+ subdirs = [
300
+ os.path.join(directory, d) for d in os.listdir(directory)
301
+ if os.path.isdir(os.path.join(directory, d))
302
+ and not d.startswith(('.', '__'))
303
+ ]
304
+ for sd in subdirs:
305
+ candidates.extend(glob.glob(os.path.join(sd, f"*{extension}")))
306
+ except FileNotFoundError:
307
+ pass
308
+
309
+ return candidates
310
+
311
+ # try finding VTK files
312
+ vtk_files = _scan_for_ext(atlas_dir, ".vtk")
313
+ if vtk_files:
314
+ # map basename -> full path
315
+ return {
316
+ os.path.splitext(os.path.basename(f))[0]: f
317
+ for f in vtk_files
318
+ }
319
+
320
+ # if no VTKs, try GIfTI files
321
+ gii_files = _scan_for_ext(atlas_dir, ".gii")
322
+ if gii_files:
323
+ # filter for '_surface.surf.gii' but fallback to just .gii if typical naming isn't found
324
+ filtered_gii = [f for f in gii_files if '.surf.gii' in f]
325
+ if not filtered_gii:
326
+ filtered_gii = gii_files
327
+
328
+ return {
329
+ os.path.basename(f).split('.')[0]: f
330
+ for f in filtered_gii
331
+ }
332
+
333
+ raise FileNotFoundError(f"No .vtk or .gii mesh files found in {atlas_dir}")
334
+
335
+ def _find_tract_files(atlas_dir):
336
+ """
337
+ Internal: Scans directory for tractography files (.trk).
338
+ Returns a dictionary: {tract_name: file_path}
339
+ """
340
+
341
+ def _scan_for_ext(directory, extension):
342
+ """Recursively finds files with extension, ignoring junk folders."""
343
+ candidates = []
344
+ # check root
345
+ candidates.extend(glob.glob(os.path.join(directory, f"*{extension}")))
346
+
347
+ # check valid subdirectories
348
+ try:
349
+ subdirs = [
350
+ os.path.join(directory, d) for d in os.listdir(directory)
351
+ if os.path.isdir(os.path.join(directory, d))
352
+ and not d.startswith(('.', '__'))
353
+ ]
354
+ for sd in subdirs:
355
+ candidates.extend(glob.glob(os.path.join(sd, f"*{extension}")))
356
+ except FileNotFoundError:
357
+ pass
358
+
359
+ return candidates
360
+
361
+ # find .trk files
362
+ trk_files = _scan_for_ext(atlas_dir, ".trk")
363
+
364
+ if not trk_files:
365
+ raise FileNotFoundError(f"No .trk files found in {atlas_dir}")
366
+
367
+ # map basename -> full path
368
+ return {
369
+ os.path.splitext(os.path.basename(f))[0]: f
370
+ for f in trk_files
371
+ }