Skip to content
2 changes: 2 additions & 0 deletions doc/api/statistics.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,12 @@ Compute ``adjacency`` matrices for cluster-level statistics:

channels.find_ch_adjacency
channels.read_ch_adjacency
label_adjacency
spatial_dist_adjacency
spatial_src_adjacency
spatial_tris_adjacency
spatial_inter_hemi_adjacency
spatio_temporal_src_adjacency
spatio_temporal_tris_adjacency
spatio_temporal_dist_adjacency
volume_label_adjacency
3 changes: 3 additions & 0 deletions doc/changes/dev/14226.newfeature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Add :func:`mne.label_adjacency` and :func:`mne.volume_label_adjacency` for determining
whether labels are adjacent (touching) or not, by :newcontrib:`Raphaël Bordas` and `Marijn van
Vliet`_
1 change: 1 addition & 0 deletions doc/changes/names.inc
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@
.. _Ram Pari: https://github.com/ramkpari
.. _Ramiro Gatti: https://github.com/ragatti
.. _ramonapariciog: https://github.com/ramonapariciog
.. _Raphaël Bordas: https://github.com/raphbrd
.. _Rasmus Aagaard: https://github.com/rasgaard
.. _Rasmus Zetter: https://github.com/rzetter
.. _Reza Nasri: https://github.com/rznas
Expand Down
4 changes: 4 additions & 0 deletions mne/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ __all__ = [
"head_to_mri",
"inverse_sparse",
"io",
"label_adjacency",
"label_sign_flip",
"labels_to_stc",
"make_ad_hoc_cov",
Expand Down Expand Up @@ -188,6 +189,7 @@ __all__ = [
"verbose",
"vertex_to_mni",
"viz",
"volume_label_adjacency",
"what",
"whiten_evoked",
"write_bem_solution",
Expand Down Expand Up @@ -351,6 +353,7 @@ from .label import (
BiHemiLabel,
Label,
grow_labels,
label_adjacency,
label_sign_flip,
labels_to_stc,
morph_labels,
Expand All @@ -359,6 +362,7 @@ from .label import (
read_labels_from_annot,
split_label,
stc_to_label,
volume_label_adjacency,
write_label,
write_labels_to_annot,
)
Expand Down
130 changes: 130 additions & 0 deletions mne/label.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,20 @@
import re
from collections import defaultdict
from colorsys import hsv_to_rgb, rgb_to_hsv
from pathlib import Path

import numpy as np
from scipy import linalg

from ._freesurfer import get_volume_labels_from_aseg
from .fixes import _safe_svd
from .morph_map import read_morph_map
from .parallel import parallel_func
from .source_estimate import (
SourceEstimate,
VolSourceEstimate,
_center_of_mass,
_volume_labels,
extract_label_time_course,
spatial_src_adjacency,
)
Expand Down Expand Up @@ -3043,3 +3046,130 @@ def select_sources(
)

return new_label


def label_adjacency(labels, src):
"""Compute adjacency between labels.

Two labels are considered adjacent if one of their vertices are adjacent in the
source space.

Parameters
----------
labels : list of mne.Label
The labels between which to compute adjacency.
src : mne.SourceSpaces
The source space on which the labels are defined.

Returns
-------
label_adjacency : scipy.sparse.coo_matrix
A sparse adjacency matrix containing a 1 for labels that are adjacent and 0
otherwise.

See Also
--------
volume_label_adjacency

Notes
-----
.. versionadded:: 1.13
"""
from scipy.sparse import coo_matrix

src_adjacency = spatial_src_adjacency(src).tocsr()
label_src_ind = list()
for label in labels:
src_hemi = src[0] if label.hemi == "lh" else src[1]
label_verts = label.get_vertices_used(src_hemi["vertno"])
src_ind = np.searchsorted(src_hemi["vertno"], label_verts)
if label.hemi == "rh":
src_ind += src[0]["nuse"]
label_src_ind.append(src_ind)

adjacent_label_inds = list() # list of pairs of label indices
for ind1, label1 in enumerate(labels):
for ind2, label2 in enumerate(labels):
# If the labels are on different hemispheres, they are not adjacent.
if label1.hemi != label2.hemi:
continue

# Get adjacent vertices if any.
adj_verts = src_adjacency[label_src_ind[ind1], :][:, label_src_ind[ind2]]
if adj_verts.data.any():
adjacent_label_inds.append((ind1, ind2))
return coo_matrix(
(np.ones(len(adjacent_label_inds)), tuple(zip(*adjacent_label_inds))),
shape=(len(labels), len(labels)),
)


@fill_doc
def volume_label_adjacency(src, subject, subjects_dir, *, aseg="auto", labels=None):
"""Compute adjacency between volume labels.

Two labels are considered adjacent if one of their voxels are adjacent in the
(volumetric) source space.

Parameters
----------
src : mne.SourceSpaces
The volumetric source space on which the labels are defined.
%(subject)s
%(subjects_dir)s
%(aseg)s
%(labels_aseg)s

Returns
-------
label_adjacency : scipy.sparse.coo_matrix
A sparse adjacency matrix containing a 1 for labels that are adjacent and 0
otherwise.
labels : list of str
The names of the labels which contain at least one source point.

See Also
--------
label_adjacency

Notes
-----
.. versionadded:: 1.13
"""
from scipy import sparse

subjects_dir = Path(get_subjects_dir(subjects_dir, raise_error=True))
if aseg == "auto": # use aparc+aseg if auto
aseg = _check_fname(
subjects_dir / subject / "mri" / "aparc+aseg.mgz",
overwrite="read",
must_exist=False,
)
if not aseg: # if doesn't exist use wmparc
aseg = subjects_dir / subject / "mri" / "wmparc.mgz"
else:
aseg = subjects_dir / subject / "mri" / f"{aseg}.mgz"

if labels is None:
labels = get_volume_labels_from_aseg(aseg)

vol_labels = _volume_labels(src, (aseg, labels), mri_resolution=False)
src_adjacency = spatial_src_adjacency(src).tocsr()

label_verts = list()
for label in vol_labels:
label_verts.append(np.searchsorted(src[0]["vertno"], label.vertices))

adjacent_label_inds = list() # list of pairs of label indices
for ind1, verts1 in enumerate(label_verts):
for ind2, verts2 in enumerate(label_verts):
# Get adjacent vertices if any.
adj_verts = src_adjacency[verts1, :][:, verts2]
if adj_verts.data.any():
adjacent_label_inds.append((ind1, ind2))

adj = sparse.coo_matrix(
(np.ones(len(adjacent_label_inds)), tuple(zip(*adjacent_label_inds))),
shape=(len(labels), len(labels)),
)
return adj, labels
121 changes: 121 additions & 0 deletions mne/tests/test_label.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
)
from scipy import sparse

import mne
from mne import (
grow_labels,
labels_to_stc,
Expand Down Expand Up @@ -57,13 +58,17 @@
real_label_fname = data_path / "MEG" / "sample" / "labels" / "Aud-lh.label"
v1_label_fname = subjects_dir / "sample" / "label" / "lh.V1.label"

fname_vsrc = data_path / "MEG" / "sample" / "sample_audvis_trunc-meg-vol-7-fwd.fif"
fname_src_fs = data_path / "subjects" / "fsaverage" / "bem" / "fsaverage-ico-5-src.fif"

fwd_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc-meg-eeg-oct-6-fwd.fif"
src_bad_fname = data_path / "subjects" / "fsaverage" / "bem" / "fsaverage-ico-5-src.fif"
label_dir = subjects_dir / "sample" / "label" / "aparc"

test_path = Path(__file__).parents[1] / "io" / "tests" / "data"
label_fname = test_path / "test-lh.label"


# This code was used to generate the "fake" test labels:
# for hemi in ['lh', 'rh']:
# label = Label(np.unique((np.random.rand(100) * 10242).astype(int)),
Expand Down Expand Up @@ -1263,3 +1268,119 @@ def test_label_geometry(fname, area):
)
assert_array_less(inside_euc, inside_dist)
assert_array_less(0.25 * inside_dist, inside_euc)


@testing.requires_testing_data
def test_volume_label_adjacency():
"""Test label adjacency."""
pytest.importorskip("nibabel")
pytest.importorskip("sklearn")
src = read_source_spaces(fname_vsrc)

# aseg=auto uses the aparc+aseg atlas, which does not exist in the testing datasets
adj, labels = mne.volume_label_adjacency(
src, subject="sample", subjects_dir=subjects_dir, aseg="aseg"
)
n_neighbors = adj.sum(axis=1)

assert_equal(len(labels), 46) # default number of labels in aseg.mgz
assert_equal(adj.shape, (len(labels), len(labels)))

assert_equal(n_neighbors.min(), 0)
assert_equal(np.sum(n_neighbors == 0), 4)

# example: 'Left-Thalamus-Proper'
label_idx = 7
connected_labels_idx = adj.toarray()[label_idx, :]
connected_labels = np.array(labels)[np.where(connected_labels_idx == 1)[0]]

assert_equal(
np.sort(connected_labels).tolist(),
[
"3rd-Ventricle",
"Brain-Stem",
"CSF",
"Left-Accumbens-area",
"Left-Cerebral-Cortex",
"Left-Cerebral-White-Matter",
"Left-Hippocampus",
"Left-Lateral-Ventricle",
"Left-Thalamus-Proper",
"Left-VentralDC",
"Unknown",
],
)

input_labels = [
"Left-Thalamus-Proper",
"Left-Hippocampus",
"Right-Hippocampus",
]
adj, labels = mne.volume_label_adjacency(
src,
subject="sample",
subjects_dir=subjects_dir,
aseg="aseg",
labels=input_labels,
)

assert_equal(
adj.toarray(),
np.array(
[
[1, 1, 0],
[1, 1, 0],
[0, 0, 1],
]
),
)

assert_equal(labels, input_labels)

with pytest.raises(FileNotFoundError):
mne.volume_label_adjacency(
src,
subject="sample",
subjects_dir=subjects_dir,
aseg="my-aseg",
labels=input_labels,
)


@testing.requires_testing_data
def test_label_adjacency():
"""Test label adjacency."""
pytest.importorskip("nibabel")
src = read_source_spaces(fname_src_fs)
mne.add_source_space_distances(src, dist_limit=0.01, n_jobs=-1)

labels = mne.read_labels_from_annot(
subject="fsaverage",
subjects_dir=subjects_dir,
)
adj = mne.label_adjacency(labels, src)

n_neighbors = adj.sum(axis=1)

assert_equal(len(labels), 69) # default number of labels in aseg.mgz
assert_equal(adj.shape, (len(labels), len(labels)))

assert_equal(n_neighbors.min(), 0)
assert_equal(np.sum(n_neighbors == 0), 1)

input_labels = [
"cuneus-lh",
"cuneus-rh",
"precuneus-lh",
]
adj = mne.label_adjacency([lab for lab in labels if lab.name in input_labels], src)
assert_equal(
adj.toarray(),
np.array(
[
[1, 0, 1],
[0, 1, 0],
[1, 0, 1],
]
),
)
8 changes: 7 additions & 1 deletion mne/utils/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2497,7 +2497,13 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75):
label_tc : array | list (or generator) of array, shape (n_labels[, n_orient], n_times)
Extracted time course for each label and source estimate.
"""

docdict["labels_aseg"] = """
labels : list of str | None
Labeled regions of interest to plot. See :func:`mne.get_montage_volume_labels`
for one way to determine regions of interest. Regions can also be chosen from
the :term:`FreeSurfer LUT`. If ``None``, all labels that are defined in the
segmentation file are used.
"""
docdict["labels_eltc"] = """
labels : Label | BiHemiLabel | list | tuple | str
If using a surface or mixed source space, this should be the
Expand Down
6 changes: 1 addition & 5 deletions mne/viz/_brain/_brain.py
Original file line number Diff line number Diff line change
Expand Up @@ -2935,11 +2935,7 @@ def add_volume_labels(
Parameters
----------
%(aseg)s
labels : list
Labeled regions of interest to plot. See
:func:`mne.get_montage_volume_labels`
for one way to determine regions of interest. Regions can also be
chosen from the :term:`FreeSurfer LUT`.
%(labels_aseg)s
colors : list | matplotlib-style color | None
A list of anything matplotlib accepts: string, RGB, hex, etc.
(default :term:`FreeSurfer LUT` colors).
Expand Down
Loading