Skip to content

Commit f00a0c5

Browse files
authored
Merge pull request #186 from rmarkello/geometry
[ENH] Add geometry/space option to check_atlas
2 parents b448556 + 5652e49 commit f00a0c5

File tree

8 files changed

+220
-79
lines changed

8 files changed

+220
-79
lines changed

abagen/datasets/fetchers.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -465,30 +465,40 @@ def fetch_donor_info():
465465
Surface = namedtuple('Surface', ('vertices', 'faces'))
466466

467467

468-
def fetch_fsaverage5():
468+
def fetch_fsaverage5(load=True):
469469
"""
470-
Fetches and load fsaverage5 surface
470+
Fetches and optionally loads fsaverage5 surface
471+
472+
Parameters
473+
----------
474+
load : bool, optional
475+
Whether to pre-load files. Default: True
471476
472477
Returns
473478
-------
474479
brain : namedtuple ('lh', 'rh')
475-
Where each entry in the tuple is a hemisphere, represented as a
476-
namedtuple with fields ('vertices', 'faces')
480+
If `load` is True, a namedtuple where each entry in the tuple is a
481+
hemisphere, represented as a namedtuple with fields ('vertices',
482+
'faces'). If `load` is False, a namedtuple where entries are filepaths.
477483
"""
478484

479485
hemispheres = []
480486
for hemi in ('lh', 'rh'):
481487
fn = RESOURCE(
482488
os.path.join('data', f'fsaverage5-pial-{hemi}.surf.gii.gz')
483489
)
484-
hemispheres.append(Surface(*load_gifti(fn).agg_data()))
490+
if load:
491+
hemispheres.append(Surface(*load_gifti(fn).agg_data()))
492+
else:
493+
hemispheres.append(fn)
485494

486495
return Brain(*hemispheres)
487496

488497

489-
def fetch_fsnative(donors, surf='pial', data_dir=None, resume=True, verbose=1):
498+
def fetch_fsnative(donors, surf='pial', load=True, data_dir=None, resume=True,
499+
verbose=1):
490500
"""
491-
Fetches and load fsnative surface of `donor`
501+
Fetches and optionally loads fsnative surface of `donor`
492502
493503
Parameters
494504
----------
@@ -497,6 +507,8 @@ def fetch_fsnative(donors, surf='pial', data_dir=None, resume=True, verbose=1):
497507
specify 'all' to download all available donors.
498508
surf : {'orig', 'white', 'pial', 'inflated', 'sphere'}, optional
499509
Which surface to load. Default: 'pial'
510+
load : bool, optional
511+
Whether to pre-load files. Default: True
500512
data_dir : str, optional
501513
Directory where data should be downloaded and unpacked. Default: $HOME/
502514
abagen-data
@@ -508,9 +520,11 @@ def fetch_fsnative(donors, surf='pial', data_dir=None, resume=True, verbose=1):
508520
Returns
509521
-------
510522
brain : namedtuple ('lh', 'rh')
511-
Where each entry in the tuple is a hemisphere, represented as a
512-
namedtuple with fields ('vertices', 'faces'). If multiple donors are
513-
requested a dictionary is returned where keys are donor IDs.
523+
If `load` is True, a namedtuple where each entry in the tuple is a
524+
hemisphere, represented as a namedtuple with fields ('vertices',
525+
'faces'). If `load` is False, a namedtuple where entries are filepaths.
526+
If multiple donors are requested a dictionary is returned where keys
527+
are donor IDs.
514528
"""
515529

516530
donors = check_donors(donors)
@@ -524,6 +538,9 @@ def fetch_fsnative(donors, surf='pial', data_dir=None, resume=True, verbose=1):
524538
hemispheres = []
525539
for hemi in ('lh', 'rh'):
526540
fn = os.path.join(fpath, 'surf', f'{hemi}.{surf}')
527-
hemispheres.append(Surface(*nib.freesurfer.read_geometry(fn)))
541+
if load:
542+
hemispheres.append(Surface(*nib.freesurfer.read_geometry(fn)))
543+
else:
544+
hemispheres.append(fn)
528545

529546
return Brain(*hemispheres)

abagen/images.py

Lines changed: 83 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,8 @@ def check_surface(atlas):
256256
raise ValueError('Provided GIFTIs do not seem to be valid '
257257
'label.gii files')
258258
adata.append(data)
259-
labs.append(hemi.labeltable.get_labels_as_dict())
259+
ldict = hemi.labeltable.get_labels_as_dict()
260+
labs.append({k: ldict.get(k) for k in np.unique(data)})
260261

261262
# we need each hemisphere to have unique values so they don't get averaged
262263
# check to see if the two hemispheres have more than 1 overlapping value
@@ -273,7 +274,8 @@ def check_surface(atlas):
273274
return adata, atlas_info
274275

275276

276-
def check_atlas(atlas, atlas_info=None, donor=None, data_dir=None):
277+
def check_atlas(atlas, atlas_info=None, geometry=None, space=None, donor=None,
278+
data_dir=None):
277279
"""
278280
Checks that `atlas` is a valid atlas
279281
@@ -285,9 +287,14 @@ def check_atlas(atlas, atlas_info=None, donor=None, data_dir=None):
285287
atlas_info : {os.PathLike, pandas.DataFrame, None}, optional
286288
Filepath or dataframe containing information about `atlas`. Must have
287289
at least columns ['id', 'hemisphere', 'structure'] containing
288-
information mapping `atlas` IDs to hemisphere (i.e., "L" or "R") and
290+
information mapping `atlas` IDs to hemisphere (i.e., "L", "R", "B") and
289291
broad structural class (i.e.., "cortex", "subcortex/brainstem",
290292
"cerebellum", "white matter", or "other"). Default: None
293+
geometry : (2,) tuple-of-GIFTI, optional
294+
Surfaces files defining geometry of `atlas`, if `atlas` is a tuple of
295+
GIFTI images. Default: None
296+
space : {'fsaverage', 'fsnative', 'fslr'}, optional
297+
If `geometry` is supplied, what space files are in. Default: None
291298
donor : str, optional
292299
If specified, indicates which donor the specified `atlas` belongs to.
293300
Only relevant when `atlas` is surface-based, to ensure the correct
@@ -314,21 +321,18 @@ def check_atlas(atlas, atlas_info=None, donor=None, data_dir=None):
314321
coords = triangles = None
315322
except TypeError:
316323
atlas, info = check_surface(atlas)
317-
if donor is None:
318-
data = fetch_fsaverage5()
319-
coords = transforms.fsaverage_to_mni152(
320-
np.row_stack([hemi.vertices for hemi in data])
321-
)
322-
else:
323-
data = fetch_fsnative(donor, data_dir=data_dir)
324-
coords = transforms.fsnative_to_xyz(
325-
np.row_stack([hemi.vertices for hemi in data]), donor
326-
)
327-
triangles, offset = [], 0
328-
for hemi in data:
329-
triangles.append(hemi.faces + offset)
330-
offset += hemi.vertices.shape[0]
331-
triangles = np.row_stack(triangles)
324+
# backwards compatibility for `donor` keyword
325+
if geometry is None and donor is None:
326+
geometry = fetch_fsaverage5()
327+
space = 'fsaverage5'
328+
elif geometry is None and donor is not None:
329+
geometry = fetch_fsnative(donor, data_dir=data_dir)
330+
space = 'fsnative'
331+
elif geometry is not None and space is None:
332+
raise ValueError('If providing geometry files space parameter '
333+
'must be specified')
334+
coords, triangles = check_geometry(geometry, space, donor=donor,
335+
data_dir=data_dir)
332336
if atlas_info is None and info is not None:
333337
atlas_info = info
334338

@@ -340,6 +344,63 @@ def check_atlas(atlas, atlas_info=None, donor=None, data_dir=None):
340344
return atlas
341345

342346

347+
def check_geometry(surface, space, donor=None, data_dir=None):
348+
"""
349+
Loads geometry `surface` files and transforms coordinates in `space`
350+
351+
Parameters
352+
----------
353+
surface : (2,) tuple-of-GIFTI
354+
Surface geometry files in GIFTI format (lh, rh)
355+
space : {'fsaverage', 'fsnative', 'fslr'}
356+
What space `surface` files are in; used to apply appropriate transform
357+
to MNI152 space. If 'fsnative' then `donor` must be supplied as well
358+
donor : str, optional
359+
If specified, indicates which donor the specified `surface` belongs to
360+
data_dir : str, optional
361+
Directory where donor-specific FreeSurfer data exists (or should be
362+
downloaded and unpacked). Only used if provided `donor` is not None.
363+
Default: $HOME/abagen-data
364+
365+
Returns
366+
-------
367+
coords : (N, 3) np.ndarray
368+
Coordinates from `surface` files
369+
triangles : (T, 3) np.ndarray
370+
Triangles from `surface` files
371+
"""
372+
373+
if len(surface) != 2:
374+
raise TypeError('Must provide a tuple of geometry files')
375+
376+
# fsaverage5, fsaverage6, etc
377+
if 'fsaverage' in space and space != 'fsaverage':
378+
space = 'fsaverage'
379+
space_opts = ('fsaverage', 'fsnative', 'fslr')
380+
if space not in space_opts:
381+
raise ValueError(f'Provided space must be one of {space_opts}.')
382+
if space == 'fsnative' and donor is None:
383+
raise ValueError('Specified space is "fsnative" but no donor ID '
384+
'supplied')
385+
386+
try:
387+
coords, triangles = map(list, zip(*[
388+
load_gifti(img).agg_data() for img in surface
389+
]))
390+
except TypeError:
391+
coords, triangles = map(list, zip(*[i for i in surface]))
392+
393+
triangles[-1] += coords[0].shape[0]
394+
coords, triangles = np.row_stack(coords), np.row_stack(triangles)
395+
396+
if space == 'fsaverage':
397+
coords = transforms.fsaverage_to_mni152(coords)
398+
elif space == 'fsnative':
399+
coords = transforms.fsnative_to_xyz(coords, donor, data_dir=data_dir)
400+
401+
return coords, triangles
402+
403+
343404
def check_atlas_info(atlas_info, labels):
344405
"""
345406
Checks whether provided `atlas_info` is correct format for processing
@@ -461,11 +522,11 @@ def coerce_atlas_to_dict(atlas, donors, atlas_info=None, data_dir=None):
461522
donors = check_donors(donors)
462523
group_atlas = True
463524

464-
# FIXME: so that we're not depending on type checks so much :grimacing:
465-
if isinstance(atlas, dict):
525+
try:
466526
atlas = {
467527
WELL_KNOWN_IDS.subj[donor]: check_atlas(atl, atlas_info,
468-
donor, data_dir)
528+
donor=donor,
529+
data_dir=data_dir)
469530
for donor, atl in atlas.items()
470531
}
471532
# if it's a group atlas they should all be the same object
@@ -477,7 +538,7 @@ def coerce_atlas_to_dict(atlas, donors, atlas_info=None, data_dir=None):
477538
f'requested donors. Missing donors: {donors}.')
478539
LGR.info('Donor-specific atlases provided; using native coords for '
479540
'tissue samples')
480-
else:
541+
except AttributeError:
481542
atlas = check_atlas(atlas, atlas_info)
482543
atlas = {donor: atlas for donor in donors}
483544
LGR.info('Group-level atlas provided; using MNI coords for '

abagen/matching.py

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,38 +40,34 @@ class AtlasTree:
4040
def __init__(self, atlas, coords=None, *, triangles=None, atlas_info=None):
4141
from .images import check_img
4242

43-
graph = None
43+
self._full_coords = self._graph = None
4444
try: # let's first check if it's an image
4545
atlas = check_img(atlas)
46-
data, affine = np.asarray(atlas.dataobj), atlas.affine
47-
self._shape = atlas.shape
48-
nz = data.nonzero()
46+
atlas, affine = np.asarray(atlas.dataobj), atlas.affine
4947
if coords is not None:
5048
warnings.warn('Volumetric image supplied to `AtlasTree` '
5149
'constructor but `coords` is not None. Ignoring '
5250
'supplied `coords` and using coordinates '
5351
'derived from image.')
54-
atlas, coords = data[nz], transforms.ijk_to_xyz(np.c_[nz], affine)
5552
self._volumetric = True
53+
self._shape = atlas.shape
54+
nz = atlas.nonzero()
55+
atlas, coords = atlas[nz], transforms.ijk_to_xyz(np.c_[nz], affine)
5656
except TypeError:
5757
atlas = np.asarray(atlas)
58+
self._full_coords = coords
5859
if coords is None:
5960
raise ValueError('When providing a surface atlas you must '
6061
'also supply relevant geometry `coords`.')
6162
if len(atlas) != len(coords):
6263
raise ValueError('Provided `atlas` and `coords` are of '
6364
'differing length.')
65+
self._volumetric = False
6466
self._shape = atlas.shape
6567
nz = atlas.nonzero()
66-
if triangles is not None:
67-
graph = surfaces.make_surf_graph(coords, triangles,
68-
atlas == 0)[nz].T[nz].T
6968
atlas, coords = atlas[nz], coords[nz]
70-
self._volumetric = False
7169

72-
self._triangles = triangles
7370
self._nz = nz
74-
self._graph = graph
7571
self._tree = cKDTree(coords)
7672
self._atlas = np.asarray(atlas)
7773
self._labels = np.unique(self.atlas).astype(int)
@@ -82,6 +78,7 @@ def __init__(self, atlas, coords=None, *, triangles=None, atlas_info=None):
8278
_, idx = self.tree.query(centroids, k=1)
8379
self._centroids = dict(zip(self.labels, self.coords[idx]))
8480
self.atlas_info = atlas_info
81+
self.triangles = triangles
8582

8683
def __repr__(self):
8784
if self.volumetric:
@@ -121,6 +118,12 @@ def centroids(self):
121118
"""
122119
return self._centroids
123120

121+
@property
122+
def graph(self):
123+
""" Returns graph of underlying parcellation
124+
"""
125+
return self._graph
126+
124127
@property
125128
def coords(self):
126129
""" Returns coordinates of underlying cKDTree
@@ -131,13 +134,41 @@ def coords(self):
131134
def coords(self, pts):
132135
""" Sets underlying cKDTree to represent provided `pts`
133136
"""
134-
if len(pts) != len(self.atlas):
137+
pts = np.asarray(pts)
138+
if pts.shape[0] != self.atlas.shape[0]:
135139
raise ValueError('Provided coordinates do not match length of '
136140
'current atlas. Expected {}. Received {}'
137141
.format(len(self.atlas), len(pts)))
138142
if not np.allclose(pts, self.coords):
139143
self._tree = cKDTree(pts)
140144
self._centroids = get_centroids(self.atlas, pts)
145+
# update graph with new coordinates (if relevant)
146+
self.triangles = self.triangles
147+
148+
@property
149+
def triangles(self):
150+
""" Returns triangles of underlying graph (if applicable)
151+
"""
152+
return self._triangles
153+
154+
@triangles.setter
155+
def triangles(self, tris):
156+
""" Sets triangles of underlying graph (if applicable)
157+
"""
158+
if self.volumetric or tris is None:
159+
self._triangles = None
160+
return
161+
162+
tris = np.asarray(tris)
163+
atlas = np.zeros(self._shape)
164+
atlas[self._nz] = self.atlas
165+
if np.any(tris.max(axis=0) >= self._full_coords.shape[0]):
166+
raise ValueError('Cannot provide triangles with indices greater '
167+
'than tree coordinate array')
168+
self._triangles = tris
169+
self._graph = surfaces.make_surf_graph(
170+
self._full_coords, self._triangles, atlas == 0
171+
)[self._nz].T[self._nz].T
141172

142173
@property
143174
def atlas_info(self):

abagen/tests/datasets/test_fetchers.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@ def test_fetch_fsaverage5():
171171
assert hasattr(hemi, attr)
172172
assert getattr(hemi, attr).shape == (exp, 3)
173173

174+
fs5 = fetchers.fetch_fsaverage5(load=False)
175+
for hemi in ('lh', 'rh'):
176+
assert hasattr(fs5, hemi)
177+
hemi = getattr(fs5, hemi)
178+
assert Path(hemi).is_file()
179+
174180

175181
def test_fetch_fsnative():
176182
fsn = fetchers.fetch_fsnative(donors=['12876'])
@@ -184,3 +190,9 @@ def test_fetch_fsnative():
184190
hemi = getattr(fsn, hemi)
185191
for attr in ('vertices', 'faces'):
186192
assert hasattr(hemi, attr)
193+
194+
fsn = fetchers.fetch_fsnative(donors=['12876'], load=False)
195+
for hemi in ('lh', 'rh'):
196+
assert hasattr(fsn, hemi)
197+
hemi = getattr(fsn, hemi)
198+
assert Path(hemi).is_file()

0 commit comments

Comments
 (0)