Skip to content

Commit eff1e3f

Browse files
authored
Speed up label adjacency computation (#14229)
1 parent c4f5ba1 commit eff1e3f

4 files changed

Lines changed: 173 additions & 162 deletions

File tree

mne/label.py

Lines changed: 127 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -1710,36 +1710,13 @@ def _verts_within_dist(graph, sources, max_dist):
17101710
dist : array
17111711
Distances from source vertex.
17121712
"""
1713-
dist_map = {}
1714-
verts_added_last = []
1715-
for source in sources:
1716-
dist_map[source] = 0
1717-
verts_added_last.append(source)
1718-
1719-
# add neighbors until no more neighbors within max_dist can be found
1720-
while len(verts_added_last) > 0:
1721-
verts_added = []
1722-
for i in verts_added_last:
1723-
v_dist = dist_map[i]
1724-
row = graph[[i], :]
1725-
neighbor_vert = row.indices
1726-
neighbor_dist = row.data
1727-
for j, d in zip(neighbor_vert, neighbor_dist):
1728-
n_dist = v_dist + d
1729-
if j in dist_map:
1730-
if n_dist < dist_map[j]:
1731-
dist_map[j] = n_dist
1732-
else:
1733-
if n_dist <= max_dist:
1734-
dist_map[j] = n_dist
1735-
# we found a new vertex within max_dist
1736-
verts_added.append(j)
1737-
verts_added_last = verts_added
1738-
1739-
verts = np.sort(np.array(list(dist_map.keys()), int))
1740-
dist = np.array([dist_map[v] for v in verts], int)
1713+
from scipy.sparse.csgraph import dijkstra
17411714

1742-
return verts, dist
1715+
# ``min_only`` gives the distance to the closest of ``sources`` for every vertex,
1716+
# and ``limit`` leaves the ones beyond max_dist at infinity
1717+
dist = dijkstra(graph, indices=sources, min_only=True, limit=max_dist)
1718+
verts = np.flatnonzero(np.isfinite(dist))
1719+
return verts, dist[verts].astype(int)
17431720

17441721

17451722
def _grow_labels(seeds, extents, hemis, names, dist, vert, subject):
@@ -1931,6 +1908,8 @@ def _grow_nonoverlapping_labels(
19311908
subject, seeds_, extents_, hemis, vertices_, graphs, names_
19321909
):
19331910
"""Grow labels while ensuring that they don't overlap."""
1911+
from scipy.sparse.csgraph import dijkstra
1912+
19341913
labels = []
19351914
for hemi in set(hemis):
19361915
hemi_index = hemis == hemi
@@ -1941,56 +1920,34 @@ def _grow_nonoverlapping_labels(
19411920
n_vertices = len(vertices_[hemi])
19421921
n_labels = len(seeds)
19431922

1944-
# prepare parcellation
1945-
parc = np.empty(n_vertices, dtype="int32")
1946-
parc[:] = -1
1947-
1948-
# initialize active sources
1949-
sources = {} # vert -> (label, dist_from_seed)
1950-
edge = [] # queue of vertices to process
1923+
# which label each seed vertex belongs to
1924+
seed_label = np.full(n_vertices, -1, int)
19511925
for label, seed in enumerate(seeds):
1952-
if np.any(parc[seed] >= 0):
1926+
if np.any(seed_label[seed] >= 0):
19531927
raise ValueError("Overlapping seeds")
1954-
parc[seed] = label
1955-
for s in np.atleast_1d(seed):
1956-
sources[s] = (label, 0.0)
1957-
edge.append(s)
1958-
1959-
# grow from sources
1960-
while edge:
1961-
vert_from = edge.pop(0)
1962-
label, old_dist = sources[vert_from]
1963-
1964-
# add neighbors within allowable distance
1965-
row = graph[[vert_from], :]
1966-
for vert_to, dist in zip(row.indices, row.data):
1967-
# Prevent adding a point that has already been used
1968-
# (prevents infinite loop)
1969-
if (vert_to == seeds[label]).any():
1970-
continue
1971-
new_dist = old_dist + dist
1972-
1973-
# abort if outside of extent
1974-
if new_dist > extents[label]:
1975-
continue
1976-
1977-
vert_to_label = parc[vert_to]
1978-
if vert_to_label >= 0:
1979-
_, vert_to_dist = sources[vert_to]
1980-
# abort if the vertex is occupied by a closer seed
1981-
if new_dist > vert_to_dist:
1982-
continue
1983-
elif vert_to in edge:
1984-
edge.remove(vert_to)
1985-
1986-
# assign label value
1987-
parc[vert_to] = label
1988-
sources[vert_to] = (label, new_dist)
1989-
edge.append(vert_to)
1928+
seed_label[seed] = label
1929+
1930+
# A multi-source Dijkstra with min_only gives, for every vertex, its distance
1931+
# to the closest seed vertex and which seed vertex that was, i.e. exactly the
1932+
# non-overlapping assignment we want: each label grows outward until it runs
1933+
# into a vertex that some other label reaches sooner.
1934+
dist, _, sources = dijkstra(
1935+
graph,
1936+
indices=np.flatnonzero(seed_label >= 0),
1937+
min_only=True,
1938+
return_predecessors=True,
1939+
)
1940+
parc = np.full(n_vertices, -1, int)
1941+
reached = np.isfinite(dist)
1942+
parc[reached] = seed_label[sources[reached]]
1943+
# then drop vertices that are further away than their own label's extent
1944+
parc[reached] = np.where(
1945+
dist[reached] <= extents[parc[reached]], parc[reached], -1
1946+
)
19901947

19911948
# convert parc to labels
19921949
for i in range(n_labels):
1993-
vertices = np.nonzero(parc == i)[0]
1950+
vertices = np.flatnonzero(parc == i)
19941951
name = str(names[i])
19951952
label_ = Label(vertices, hemi=hemi, name=name, subject=subject)
19961953
labels.append(label_)
@@ -2118,16 +2075,21 @@ def _cortex_parcellation(subject, n_parcel, hemis, vertices_, graphs, rng):
21182075
rest -= 1
21192076

21202077
# merging small labels
2121-
# label adjacency matrix
2078+
# label adjacency matrix: every vertex belongs to exactly one label at this
2079+
# point, so mapping both ends of each graph edge through parc marks all pairs
2080+
# of adjacent labels at once. Functionally equivalent to, but much faster than:
2081+
#
2082+
# for i in range(n_labels):
2083+
# vertices = np.nonzero(parc == i)[0]
2084+
# label_sizes[i] = len(vertices)
2085+
# neighbor_labels = np.unique(parc[graph[vertices, :].indices])
2086+
# label_conn[i, neighbor_labels] = 1
2087+
#
21222088
n_labels = label_idx + 1
2123-
label_sizes = np.empty(n_labels, dtype=int)
2089+
label_sizes = np.bincount(parc, minlength=n_labels)
21242090
label_conn = np.zeros([n_labels, n_labels], dtype="bool")
2125-
for i in range(n_labels):
2126-
vertices = np.nonzero(parc == i)[0]
2127-
label_sizes[i] = len(vertices)
2128-
neighbor_vertices = graph[vertices, :].indices
2129-
neighbor_labels = np.unique(np.array(parc[neighbor_vertices]))
2130-
label_conn[i, neighbor_labels] = 1
2091+
edges = graph.tocoo()
2092+
label_conn[parc[edges.row], parc[edges.col]] = True
21312093
np.fill_diagonal(label_conn, 0)
21322094

21332095
# merging
@@ -2616,30 +2578,53 @@ def _check_values_labels(values, n_labels):
26162578
)
26172579

26182580

2619-
def _labels_to_stc_surf(labels, values, tmin, tstep, subject):
2581+
def _label_membership(label_indices, n_vertices):
2582+
"""Get a sparse (n_labels, n_vertices) matrix of which vertices are in each label.
2583+
2584+
``label_indices[li]`` holds the indices, into some length-``n_vertices`` array of
2585+
vertices, of the vertices belonging to label ``li``. Multiplying by this matrix (or
2586+
its transpose) is how the label-wise operations in this module avoid looping over
2587+
labels; see ``_labels_to_stc_surf`` and ``_label_adjacency`` for examples.
2588+
"""
26202589
from scipy import sparse
26212590

2591+
n_labels = len(label_indices)
2592+
rows = np.repeat(np.arange(n_labels), [len(ind) for ind in label_indices])
2593+
cols = np.concatenate([np.empty(0, int)] + list(label_indices))
2594+
return sparse.csr_array(
2595+
(np.ones(len(cols)), (rows, cols)), shape=(n_labels, n_vertices)
2596+
)
2597+
2598+
2599+
def _labels_to_stc_surf(labels, values, tmin, tstep, subject):
26222600
subject = _check_labels_subject(labels, subject, "subject")
26232601
_check_values_labels(values, len(labels))
2624-
vertices = dict(lh=[], rh=[])
2625-
data = dict(lh=[], rh=[])
2626-
for li, label in enumerate(labels):
2627-
data[label.hemi].append(
2628-
np.repeat(values[li][np.newaxis], len(label.vertices), axis=0)
2602+
vertices = list()
2603+
data = list()
2604+
for hemi in ("lh", "rh"):
2605+
idx = np.array(
2606+
[li for li, label in enumerate(labels) if label.hemi == hemi], int
2607+
)
2608+
label_vertices = [labels[li].vertices for li in idx]
2609+
these_vertices = np.unique(np.concatenate([np.empty(0, int)] + label_vertices))
2610+
membership = _label_membership(
2611+
[np.searchsorted(these_vertices, verts) for verts in label_vertices],
2612+
len(these_vertices),
26292613
)
2630-
vertices[label.hemi].append(label.vertices)
2631-
hemis = ("lh", "rh")
2632-
for hemi in hemis:
2633-
vertices[hemi] = np.concatenate(vertices[hemi], axis=0)
2634-
data[hemi] = np.concatenate(data[hemi], axis=0).astype(float)
2635-
cols = np.arange(len(vertices[hemi]))
2636-
vertices[hemi], rows = np.unique(vertices[hemi], return_inverse=True)
2637-
mat = sparse.coo_array((np.ones(len(rows)), (rows, cols))).tocsr()
2638-
mat *= 1.0 / mat.sum(axis=-1)
2639-
data[hemi] = mat @ data[hemi]
2640-
vertices = [vertices[hemi] for hemi in hemis]
2641-
data = np.concatenate([data[hemi] for hemi in hemis], axis=0)
2642-
return data, vertices, subject
2614+
# ``membership.T @ values[idx]`` sums, for each vertex, the values of every
2615+
# label containing it, and ``n_used`` is how many labels that was, so the two
2616+
# together give the mean. Functionally equivalent to, but much faster than:
2617+
#
2618+
# for vi, vertex in enumerate(these_vertices):
2619+
# in_label = [
2620+
# ii for ii, verts in enumerate(label_vertices) if vertex in verts
2621+
# ]
2622+
# this_data[vi] = values[idx[in_label]].mean(axis=0)
2623+
#
2624+
n_used = membership.sum(axis=0) # number of labels containing each vertex
2625+
vertices.append(these_vertices)
2626+
data.append((membership.T @ values[idx]) / n_used[:, np.newaxis])
2627+
return np.concatenate(data), vertices, subject
26432628

26442629

26452630
_DEFAULT_TABLE_NAME = "MNE-Python Colortable"
@@ -3048,6 +3033,37 @@ def select_sources(
30483033
return new_label
30493034

30503035

3036+
def _label_adjacency(label_src_ind, src_adjacency):
3037+
"""Turn per-label source space indices plus source adjacency into label adjacency.
3038+
3039+
Two labels are adjacent if any vertex of one is adjacent to any vertex of the
3040+
other. ``label_src_ind[li]`` holds the indices into the source space of the
3041+
vertices belonging to label ``li``.
3042+
"""
3043+
from scipy import sparse
3044+
3045+
n_labels = len(label_src_ind)
3046+
membership = _label_membership(label_src_ind, src_adjacency.shape[0])
3047+
# ``counts[i, j]`` is the number of adjacent (vertex in label i, vertex in label j)
3048+
# vertex pairs, which is nonzero exactly when the two labels are adjacent.
3049+
# Functionally equivalent to, but much faster than:
3050+
#
3051+
# src_adjacency = src_adjacency.tocsr()
3052+
# counts = np.zeros((n_labels, n_labels))
3053+
# for i in range(n_labels):
3054+
# for j in range(n_labels):
3055+
# counts[i, j] = src_adjacency[label_src_ind[i]][
3056+
# :, label_src_ind[j]
3057+
# ].sum()
3058+
#
3059+
# which needs O(n_labels ** 2) sparse slices, each costing O(nnz(src_adjacency)).
3060+
counts = membership @ src_adjacency.tocsr() @ membership.T
3061+
row, col = counts.nonzero() # ignores any explicitly stored zeros
3062+
return sparse.coo_matrix(
3063+
(np.ones(len(row)), (row, col)), shape=(n_labels, n_labels)
3064+
)
3065+
3066+
30513067
def label_adjacency(labels, src):
30523068
"""Compute adjacency between labels.
30533069
@@ -3075,9 +3091,7 @@ def label_adjacency(labels, src):
30753091
-----
30763092
.. versionadded:: 1.13
30773093
"""
3078-
from scipy.sparse import coo_matrix
3079-
3080-
src_adjacency = spatial_src_adjacency(src).tocsr()
3094+
src_adjacency = spatial_src_adjacency(src)
30813095
label_src_ind = list()
30823096
for label in labels:
30833097
src_hemi = src[0] if label.hemi == "lh" else src[1]
@@ -3086,22 +3100,10 @@ def label_adjacency(labels, src):
30863100
if label.hemi == "rh":
30873101
src_ind += src[0]["nuse"]
30883102
label_src_ind.append(src_ind)
3089-
3090-
adjacent_label_inds = list() # list of pairs of label indices
3091-
for ind1, label1 in enumerate(labels):
3092-
for ind2, label2 in enumerate(labels):
3093-
# If the labels are on different hemispheres, they are not adjacent.
3094-
if label1.hemi != label2.hemi:
3095-
continue
3096-
3097-
# Get adjacent vertices if any.
3098-
adj_verts = src_adjacency[label_src_ind[ind1], :][:, label_src_ind[ind2]]
3099-
if adj_verts.data.any():
3100-
adjacent_label_inds.append((ind1, ind2))
3101-
return coo_matrix(
3102-
(np.ones(len(adjacent_label_inds)), tuple(zip(*adjacent_label_inds))),
3103-
shape=(len(labels), len(labels)),
3104-
)
3103+
# Labels in different hemispheres are never adjacent because the source space
3104+
# adjacency has no inter-hemispheric edges, so no explicit hemisphere check is
3105+
# needed here.
3106+
return _label_adjacency(label_src_ind, src_adjacency)
31053107

31063108

31073109
@fill_doc
@@ -3136,8 +3138,6 @@ def volume_label_adjacency(src, subject, subjects_dir, *, aseg="auto", labels=No
31363138
-----
31373139
.. versionadded:: 1.13
31383140
"""
3139-
from scipy import sparse
3140-
31413141
subjects_dir = Path(get_subjects_dir(subjects_dir, raise_error=True))
31423142
if aseg == "auto": # use aparc+aseg if auto
31433143
aseg = _check_fname(
@@ -3154,22 +3154,8 @@ def volume_label_adjacency(src, subject, subjects_dir, *, aseg="auto", labels=No
31543154
labels = get_volume_labels_from_aseg(aseg)
31553155

31563156
vol_labels = _volume_labels(src, (aseg, labels), mri_resolution=False)
3157-
src_adjacency = spatial_src_adjacency(src).tocsr()
3158-
3159-
label_verts = list()
3160-
for label in vol_labels:
3161-
label_verts.append(np.searchsorted(src[0]["vertno"], label.vertices))
3162-
3163-
adjacent_label_inds = list() # list of pairs of label indices
3164-
for ind1, verts1 in enumerate(label_verts):
3165-
for ind2, verts2 in enumerate(label_verts):
3166-
# Get adjacent vertices if any.
3167-
adj_verts = src_adjacency[verts1, :][:, verts2]
3168-
if adj_verts.data.any():
3169-
adjacent_label_inds.append((ind1, ind2))
3170-
3171-
adj = sparse.coo_matrix(
3172-
(np.ones(len(adjacent_label_inds)), tuple(zip(*adjacent_label_inds))),
3173-
shape=(len(labels), len(labels)),
3174-
)
3175-
return adj, labels
3157+
src_adjacency = spatial_src_adjacency(src)
3158+
label_src_ind = [
3159+
np.searchsorted(src[0]["vertno"], label.vertices) for label in vol_labels
3160+
]
3161+
return _label_adjacency(label_src_ind, src_adjacency), labels

mne/morph.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1425,12 +1425,20 @@ def _surf_upsampling_mat(idx_from, e, smooth):
14251425

14261426
def _sparse_argmax_nnz_row(csr_mat):
14271427
"""Return index of the maximum non-zero index in each row."""
1428-
n_rows = csr_mat.shape[0]
1429-
idx = np.empty(n_rows, dtype=np.int64)
1430-
for k in range(n_rows):
1431-
row = csr_mat[[k]].tocoo()
1432-
idx[k] = row.col[np.argmax(row.data)]
1433-
return idx
1428+
# Equivalent to, but orders of magnitude faster than, slicing out each row and
1429+
# taking ``row.indices[np.argmax(row.data)]``:
1430+
#
1431+
# for k in range(csr_mat.shape[0]):
1432+
# row = csr_mat[[k]].tocoo()
1433+
# idx[k] = row.col[np.argmax(row.data)]
1434+
#
1435+
starts, counts = csr_mat.indptr[:-1], np.diff(csr_mat.indptr)
1436+
assert (counts > 0).all() # reduceat below needs every row to be non-empty
1437+
row_max = np.maximum.reduceat(csr_mat.data, starts)
1438+
# position of the first stored entry in each row that attains the row maximum
1439+
is_max = csr_mat.data == np.repeat(row_max, counts)
1440+
pos = np.where(is_max, np.arange(len(is_max)), len(is_max))
1441+
return csr_mat.indices[np.minimum.reduceat(pos, starts)]
14341442

14351443

14361444
def _get_subject_sphere_tris(subject, subjects_dir):

0 commit comments

Comments
 (0)