yabplot 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+ }