Skip to content

Commit 852423a

Browse files
committed
Merge remote-tracking branch 'upstream/main' into xfit-enh
2 parents db0fed6 + f06fb38 commit 852423a

8 files changed

Lines changed: 269 additions & 6 deletions

File tree

doc/api/statistics.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,12 @@ Compute ``adjacency`` matrices for cluster-level statistics:
6868

6969
channels.find_ch_adjacency
7070
channels.read_ch_adjacency
71+
label_adjacency
7172
spatial_dist_adjacency
7273
spatial_src_adjacency
7374
spatial_tris_adjacency
7475
spatial_inter_hemi_adjacency
7576
spatio_temporal_src_adjacency
7677
spatio_temporal_tris_adjacency
7778
spatio_temporal_dist_adjacency
79+
volume_label_adjacency
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Add :func:`mne.label_adjacency` and :func:`mne.volume_label_adjacency` for determining
2+
whether labels are adjacent (touching) or not, by :newcontrib:`Raphaël Bordas` and `Marijn van
3+
Vliet`_

doc/changes/names.inc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@
364364
.. _Ram Pari: https://github.com/ramkpari
365365
.. _Ramiro Gatti: https://github.com/ragatti
366366
.. _ramonapariciog: https://github.com/ramonapariciog
367+
.. _Raphaël Bordas: https://github.com/raphbrd
367368
.. _Rasmus Aagaard: https://github.com/rasgaard
368369
.. _Rasmus Zetter: https://github.com/rzetter
369370
.. _Reza Nasri: https://github.com/rznas

mne/__init__.pyi

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ __all__ = [
9090
"head_to_mri",
9191
"inverse_sparse",
9292
"io",
93+
"label_adjacency",
9394
"label_sign_flip",
9495
"labels_to_stc",
9596
"make_ad_hoc_cov",
@@ -188,6 +189,7 @@ __all__ = [
188189
"verbose",
189190
"vertex_to_mni",
190191
"viz",
192+
"volume_label_adjacency",
191193
"what",
192194
"whiten_evoked",
193195
"write_bem_solution",
@@ -351,6 +353,7 @@ from .label import (
351353
BiHemiLabel,
352354
Label,
353355
grow_labels,
356+
label_adjacency,
354357
label_sign_flip,
355358
labels_to_stc,
356359
morph_labels,
@@ -359,6 +362,7 @@ from .label import (
359362
read_labels_from_annot,
360363
split_label,
361364
stc_to_label,
365+
volume_label_adjacency,
362366
write_label,
363367
write_labels_to_annot,
364368
)

mne/label.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,20 @@
88
import re
99
from collections import defaultdict
1010
from colorsys import hsv_to_rgb, rgb_to_hsv
11+
from pathlib import Path
1112

1213
import numpy as np
1314
from scipy import linalg
1415

16+
from ._freesurfer import get_volume_labels_from_aseg
1517
from .fixes import _safe_svd
1618
from .morph_map import read_morph_map
1719
from .parallel import parallel_func
1820
from .source_estimate import (
1921
SourceEstimate,
2022
VolSourceEstimate,
2123
_center_of_mass,
24+
_volume_labels,
2225
extract_label_time_course,
2326
spatial_src_adjacency,
2427
)
@@ -3043,3 +3046,130 @@ def select_sources(
30433046
)
30443047

30453048
return new_label
3049+
3050+
3051+
def label_adjacency(labels, src):
3052+
"""Compute adjacency between labels.
3053+
3054+
Two labels are considered adjacent if one of their vertices are adjacent in the
3055+
source space.
3056+
3057+
Parameters
3058+
----------
3059+
labels : list of mne.Label
3060+
The labels between which to compute adjacency.
3061+
src : mne.SourceSpaces
3062+
The source space on which the labels are defined.
3063+
3064+
Returns
3065+
-------
3066+
label_adjacency : scipy.sparse.coo_matrix
3067+
A sparse adjacency matrix containing a 1 for labels that are adjacent and 0
3068+
otherwise.
3069+
3070+
See Also
3071+
--------
3072+
volume_label_adjacency
3073+
3074+
Notes
3075+
-----
3076+
.. versionadded:: 1.13
3077+
"""
3078+
from scipy.sparse import coo_matrix
3079+
3080+
src_adjacency = spatial_src_adjacency(src).tocsr()
3081+
label_src_ind = list()
3082+
for label in labels:
3083+
src_hemi = src[0] if label.hemi == "lh" else src[1]
3084+
label_verts = label.get_vertices_used(src_hemi["vertno"])
3085+
src_ind = np.searchsorted(src_hemi["vertno"], label_verts)
3086+
if label.hemi == "rh":
3087+
src_ind += src[0]["nuse"]
3088+
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+
)
3105+
3106+
3107+
@fill_doc
3108+
def volume_label_adjacency(src, subject, subjects_dir, *, aseg="auto", labels=None):
3109+
"""Compute adjacency between volume labels.
3110+
3111+
Two labels are considered adjacent if one of their voxels are adjacent in the
3112+
(volumetric) source space.
3113+
3114+
Parameters
3115+
----------
3116+
src : mne.SourceSpaces
3117+
The volumetric source space on which the labels are defined.
3118+
%(subject)s
3119+
%(subjects_dir)s
3120+
%(aseg)s
3121+
%(labels_aseg)s
3122+
3123+
Returns
3124+
-------
3125+
label_adjacency : scipy.sparse.coo_matrix
3126+
A sparse adjacency matrix containing a 1 for labels that are adjacent and 0
3127+
otherwise.
3128+
labels : list of str
3129+
The names of the labels which contain at least one source point.
3130+
3131+
See Also
3132+
--------
3133+
label_adjacency
3134+
3135+
Notes
3136+
-----
3137+
.. versionadded:: 1.13
3138+
"""
3139+
from scipy import sparse
3140+
3141+
subjects_dir = Path(get_subjects_dir(subjects_dir, raise_error=True))
3142+
if aseg == "auto": # use aparc+aseg if auto
3143+
aseg = _check_fname(
3144+
subjects_dir / subject / "mri" / "aparc+aseg.mgz",
3145+
overwrite="read",
3146+
must_exist=False,
3147+
)
3148+
if not aseg: # if doesn't exist use wmparc
3149+
aseg = subjects_dir / subject / "mri" / "wmparc.mgz"
3150+
else:
3151+
aseg = subjects_dir / subject / "mri" / f"{aseg}.mgz"
3152+
3153+
if labels is None:
3154+
labels = get_volume_labels_from_aseg(aseg)
3155+
3156+
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

mne/tests/test_label.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
)
2121
from scipy import sparse
2222

23+
import mne
2324
from mne import (
2425
grow_labels,
2526
labels_to_stc,
@@ -57,13 +58,17 @@
5758
real_label_fname = data_path / "MEG" / "sample" / "labels" / "Aud-lh.label"
5859
v1_label_fname = subjects_dir / "sample" / "label" / "lh.V1.label"
5960

61+
fname_vsrc = data_path / "MEG" / "sample" / "sample_audvis_trunc-meg-vol-7-fwd.fif"
62+
fname_src_fs = data_path / "subjects" / "fsaverage" / "bem" / "fsaverage-ico-5-src.fif"
63+
6064
fwd_fname = data_path / "MEG" / "sample" / "sample_audvis_trunc-meg-eeg-oct-6-fwd.fif"
6165
src_bad_fname = data_path / "subjects" / "fsaverage" / "bem" / "fsaverage-ico-5-src.fif"
6266
label_dir = subjects_dir / "sample" / "label" / "aparc"
6367

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

71+
6772
# This code was used to generate the "fake" test labels:
6873
# for hemi in ['lh', 'rh']:
6974
# label = Label(np.unique((np.random.rand(100) * 10242).astype(int)),
@@ -1263,3 +1268,119 @@ def test_label_geometry(fname, area):
12631268
)
12641269
assert_array_less(inside_euc, inside_dist)
12651270
assert_array_less(0.25 * inside_dist, inside_euc)
1271+
1272+
1273+
@testing.requires_testing_data
1274+
def test_volume_label_adjacency():
1275+
"""Test label adjacency."""
1276+
pytest.importorskip("nibabel")
1277+
pytest.importorskip("sklearn")
1278+
src = read_source_spaces(fname_vsrc)
1279+
1280+
# aseg=auto uses the aparc+aseg atlas, which does not exist in the testing datasets
1281+
adj, labels = mne.volume_label_adjacency(
1282+
src, subject="sample", subjects_dir=subjects_dir, aseg="aseg"
1283+
)
1284+
n_neighbors = adj.sum(axis=1)
1285+
1286+
assert_equal(len(labels), 46) # default number of labels in aseg.mgz
1287+
assert_equal(adj.shape, (len(labels), len(labels)))
1288+
1289+
assert_equal(n_neighbors.min(), 0)
1290+
assert_equal(np.sum(n_neighbors == 0), 4)
1291+
1292+
# example: 'Left-Thalamus-Proper'
1293+
label_idx = 7
1294+
connected_labels_idx = adj.toarray()[label_idx, :]
1295+
connected_labels = np.array(labels)[np.where(connected_labels_idx == 1)[0]]
1296+
1297+
assert_equal(
1298+
np.sort(connected_labels).tolist(),
1299+
[
1300+
"3rd-Ventricle",
1301+
"Brain-Stem",
1302+
"CSF",
1303+
"Left-Accumbens-area",
1304+
"Left-Cerebral-Cortex",
1305+
"Left-Cerebral-White-Matter",
1306+
"Left-Hippocampus",
1307+
"Left-Lateral-Ventricle",
1308+
"Left-Thalamus-Proper",
1309+
"Left-VentralDC",
1310+
"Unknown",
1311+
],
1312+
)
1313+
1314+
input_labels = [
1315+
"Left-Thalamus-Proper",
1316+
"Left-Hippocampus",
1317+
"Right-Hippocampus",
1318+
]
1319+
adj, labels = mne.volume_label_adjacency(
1320+
src,
1321+
subject="sample",
1322+
subjects_dir=subjects_dir,
1323+
aseg="aseg",
1324+
labels=input_labels,
1325+
)
1326+
1327+
assert_equal(
1328+
adj.toarray(),
1329+
np.array(
1330+
[
1331+
[1, 1, 0],
1332+
[1, 1, 0],
1333+
[0, 0, 1],
1334+
]
1335+
),
1336+
)
1337+
1338+
assert_equal(labels, input_labels)
1339+
1340+
with pytest.raises(FileNotFoundError):
1341+
mne.volume_label_adjacency(
1342+
src,
1343+
subject="sample",
1344+
subjects_dir=subjects_dir,
1345+
aseg="my-aseg",
1346+
labels=input_labels,
1347+
)
1348+
1349+
1350+
@testing.requires_testing_data
1351+
def test_label_adjacency():
1352+
"""Test label adjacency."""
1353+
pytest.importorskip("nibabel")
1354+
src = read_source_spaces(fname_src_fs)
1355+
mne.add_source_space_distances(src, dist_limit=0.01, n_jobs=-1)
1356+
1357+
labels = mne.read_labels_from_annot(
1358+
subject="fsaverage",
1359+
subjects_dir=subjects_dir,
1360+
)
1361+
adj = mne.label_adjacency(labels, src)
1362+
1363+
n_neighbors = adj.sum(axis=1)
1364+
1365+
assert_equal(len(labels), 69) # default number of labels in aseg.mgz
1366+
assert_equal(adj.shape, (len(labels), len(labels)))
1367+
1368+
assert_equal(n_neighbors.min(), 0)
1369+
assert_equal(np.sum(n_neighbors == 0), 1)
1370+
1371+
input_labels = [
1372+
"cuneus-lh",
1373+
"cuneus-rh",
1374+
"precuneus-lh",
1375+
]
1376+
adj = mne.label_adjacency([lab for lab in labels if lab.name in input_labels], src)
1377+
assert_equal(
1378+
adj.toarray(),
1379+
np.array(
1380+
[
1381+
[1, 0, 1],
1382+
[0, 1, 0],
1383+
[1, 0, 1],
1384+
]
1385+
),
1386+
)

mne/utils/docs.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2497,7 +2497,13 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75):
24972497
label_tc : array | list (or generator) of array, shape (n_labels[, n_orient], n_times)
24982498
Extracted time course for each label and source estimate.
24992499
"""
2500-
2500+
docdict["labels_aseg"] = """
2501+
labels : list of str | None
2502+
Labeled regions of interest to plot. See :func:`mne.get_montage_volume_labels`
2503+
for one way to determine regions of interest. Regions can also be chosen from
2504+
the :term:`FreeSurfer LUT`. If ``None``, all labels that are defined in the
2505+
segmentation file are used.
2506+
"""
25012507
docdict["labels_eltc"] = """
25022508
labels : Label | BiHemiLabel | list | tuple | str
25032509
If using a surface or mixed source space, this should be the

mne/viz/_brain/_brain.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2935,11 +2935,7 @@ def add_volume_labels(
29352935
Parameters
29362936
----------
29372937
%(aseg)s
2938-
labels : list
2939-
Labeled regions of interest to plot. See
2940-
:func:`mne.get_montage_volume_labels`
2941-
for one way to determine regions of interest. Regions can also be
2942-
chosen from the :term:`FreeSurfer LUT`.
2938+
%(labels_aseg)s
29432939
colors : list | matplotlib-style color | None
29442940
A list of anything matplotlib accepts: string, RGB, hex, etc.
29452941
(default :term:`FreeSurfer LUT` colors).

0 commit comments

Comments
 (0)