Skip to content

Commit 7e8848c

Browse files
committed
[ENH] Adds geodesic parcel centroid calc
1 parent 5472a03 commit 7e8848c

File tree

1 file changed

+86
-10
lines changed

1 file changed

+86
-10
lines changed

netneurotools/freesurfer.py

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,25 @@
33
Functions for working with FreeSurfer data and parcellations
44
"""
55

6+
import itertools
67
import os
78
import os.path as op
89
import warnings
910

1011
from nibabel.freesurfer import read_annot, read_geometry
1112
import numpy as np
13+
from scipy import sparse
1214
from scipy.ndimage.measurements import _stats, labeled_comprehension
1315
from scipy.spatial.distance import cdist
1416

1517
from .datasets import fetch_fsaverage
1618
from .stats import gen_spinsamples
1719
from .utils import check_fs_subjid, run
1820

21+
FSIGNORE = [
22+
'unknown', 'corpuscallosum', 'Background+FreeSurfer_Defined_Medial_Wall'
23+
]
24+
1925

2026
def apply_prob_atlas(subject_id, gcs, hemi, *, orig='white', annot=None,
2127
ctab=None, subjects_dir=None, use_cache=True,
@@ -106,8 +112,8 @@ def _decode_list(vals):
106112
return [l.decode() if hasattr(l, 'decode') else l for l in vals]
107113

108114

109-
def find_parcel_centroids(*, lhannot, rhannot, version='fsaverage',
110-
surf='sphere', drop=None):
115+
def find_parcel_centroids(*, lhannot, rhannot, method='surface',
116+
version='fsaverage', surf='sphere', drop=None):
111117
"""
112118
Returns vertex coords corresponding to centroids of parcels in annotations
113119
@@ -121,6 +127,9 @@ def find_parcel_centroids(*, lhannot, rhannot, version='fsaverage',
121127
Path to .annot file containing labels of parcels on the {left,right}
122128
hemisphere. These must be specified as keyword arguments to avoid
123129
accidental order switching.
130+
method : {'average', 'surface', 'geodesic'}, optional
131+
Method for calculation of parcel centroid. See Notes for more
132+
information. Default: 'surface'
124133
version : str, optional
125134
Specifies which version of `fsaverage` provided annotation files
126135
correspond to. Must be one of {'fsaverage', 'fsaverage3', 'fsaverage4',
@@ -130,8 +139,8 @@ def find_parcel_centroids(*, lhannot, rhannot, version='fsaverage',
130139
parcel centroids. Default: 'sphere'
131140
drop : list, optional
132141
Specifies regions in {lh,rh}annot for which the parcel centroid should
133-
not be calculated. If not specified, centroids for 'unknown' and
134-
'corpuscallosum' are not calculated. Default: None
142+
not be calculated. If not specified, centroids for parcels defined in
143+
`netneurotools.freesurfer.FSIGNORE` are not calculated. Default: None
135144
136145
Returns
137146
-------
@@ -141,13 +150,38 @@ def find_parcel_centroids(*, lhannot, rhannot, version='fsaverage',
141150
hemiid : (N,) numpy.ndarray
142151
Array denoting hemisphere designation of coordinates in `centroids`,
143152
where `hemiid=0` denotes the left and `hemiid=1` the right hemisphere
153+
154+
Notes
155+
-----
156+
The following methods can be used for finding parcel centroids:
157+
158+
1. ``method='average'``
159+
160+
Uses the arithmetic mean of the coordinates for the vertices in each
161+
parcel. Note that in this case the calculated centroids will not act
162+
actually fall on the surface of `surf`.
163+
164+
2. ``method='surface'``
165+
166+
Calculates the 'average' coordinates and then finds the closest vertex
167+
on `surf`, where closest is defined as the vertex with the minimum
168+
Euclidean distance.
169+
170+
3. ``method='geodesic'``
171+
172+
Uses the coordinates of the vertex with the minimum average geodesic
173+
distance to all other vertices in the parcel. Note that this is slightly
174+
more time-consuming than the other two methods, especially for
175+
high-resolution meshes.
144176
"""
145177

178+
methods = ['average', 'surface', 'geodesic']
179+
if method not in methods:
180+
raise ValueError('Provided method for centroid calculation {} is '
181+
'invalid. Must be one of {}'.format(methods, methods))
182+
146183
if drop is None:
147-
drop = [
148-
'unknown', 'corpuscallosum', # default FreeSurfer
149-
'Background+FreeSurfer_Defined_Medial_Wall' # common alternative
150-
]
184+
drop = FSIGNORE
151185
drop = _decode_list(drop)
152186

153187
surfaces = fetch_fsaverage(version)[surf]
@@ -161,14 +195,56 @@ def find_parcel_centroids(*, lhannot, rhannot, version='fsaverage',
161195
for lab in np.unique(labels):
162196
if names[lab] in drop:
163197
continue
164-
coords = np.atleast_2d(vertices[labels == lab].mean(axis=0))
165-
roi = vertices[np.argmin(cdist(vertices, coords), axis=0)[0]]
198+
if method in ['average', 'surface']:
199+
roi = np.atleast_2d(vertices[labels == lab].mean(axis=0))
200+
if method == 'surface': # find closest vertex on the sphere
201+
roi = vertices[np.argmin(cdist(vertices, roi), axis=0)[0]]
202+
elif method == 'geodesic':
203+
inds, = np.where(labels == lab)
204+
roi = _geodesic_parcel_centroid(vertices, faces, inds)
166205
centroids.append(roi)
167206
hemiid.append(n)
168207

169208
return np.row_stack(centroids), np.asarray(hemiid)
170209

171210

211+
def _geodesic_parcel_centroid(vertices, faces, inds):
212+
"""
213+
Calculates parcel centroids based on surface distance
214+
215+
Parameters
216+
----------
217+
vertices : (N, 3)
218+
Coordinates of vertices defining surface
219+
faces : (F, 3)
220+
Triangular faces defining surface
221+
inds : (R,)
222+
Indices of `vertices` that belong to parcel
223+
224+
Returns
225+
--------
226+
roi : (3,) numpy.ndarray
227+
Vertex corresponding to centroid of parcel
228+
"""
229+
230+
# get the edges in our graph
231+
keep = faces[np.sum(np.isin(faces, inds), axis=1) > 1]
232+
edges = np.row_stack([list(itertools.combinations(f, 2)) for f in keep])
233+
edges = np.row_stack([edges, np.fliplr(edges)])
234+
weights = np.linalg.norm(np.diff(vertices[edges], axis=1), axis=-1)
235+
236+
# construct our sparse matrix on which to calculate shortest paths
237+
mat = sparse.csc_matrix((np.squeeze(weights), (edges[:, 0], edges[:, 1])),
238+
shape=(len(vertices), len(vertices)))
239+
paths = sparse.csgraph.dijkstra(mat, directed=False, indices=inds)[:, inds]
240+
241+
# the selected vertex is the one with the minimum average shortest path
242+
# to the other vertices in the parcel
243+
roi = vertices[inds[paths.mean(axis=1).argmin()]]
244+
245+
return roi
246+
247+
172248
def parcels_to_vertices(data, *, lhannot, rhannot, drop=None):
173249
"""
174250
Projects parcellated `data` to vertices defined in annotation files

0 commit comments

Comments
 (0)