33Functions for working with FreeSurfer data and parcellations
44"""
55
6+ import itertools
67import os
78import os .path as op
89import warnings
910
1011from nibabel .freesurfer import read_annot , read_geometry
1112import numpy as np
13+ from scipy import sparse
1214from scipy .ndimage .measurements import _stats , labeled_comprehension
1315from scipy .spatial .distance import cdist
1416
1517from .datasets import fetch_fsaverage
1618from .stats import gen_spinsamples
1719from .utils import check_fs_subjid , run
1820
21+ FSIGNORE = [
22+ 'unknown' , 'corpuscallosum' , 'Background+FreeSurfer_Defined_Medial_Wall'
23+ ]
24+
1925
2026def 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+
172248def parcels_to_vertices (data , * , lhannot , rhannot , drop = None ):
173249 """
174250 Projects parcellated `data` to vertices defined in annotation files
0 commit comments