From 0c31c92ed9998b8bac6a34ce88ed1f2da0ee3d56 Mon Sep 17 00:00:00 2001 From: Shristi Baral Date: Sun, 10 Aug 2025 14:03:35 +0300 Subject: [PATCH 01/10] Add function to plot statistical clusters on brain surface --- doc/api/visualization.rst | 1 + mne/viz/_3d.py | 156 ++++++++++++++++++ mne/viz/__init__.pyi | 2 + mne/viz/tests/test_3d.py | 51 ++++++ .../20_cluster_1samp_spatiotemporal.py | 36 +++- 5 files changed, 241 insertions(+), 5 deletions(-) diff --git a/doc/api/visualization.rst b/doc/api/visualization.rst index 280ed51f590..7c2cf53265f 100644 --- a/doc/api/visualization.rst +++ b/doc/api/visualization.rst @@ -68,6 +68,7 @@ Visualization plot_volume_source_estimates plot_vector_source_estimates plot_sparse_source_estimates + plot_stat_cluster plot_tfr_topomap plot_topo_image_epochs plot_topomap diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py index f844d9b54e5..c556d0cf1cf 100644 --- a/mne/viz/_3d.py +++ b/mne/viz/_3d.py @@ -83,6 +83,7 @@ ) from ._dipole import _check_concat_dipoles, _plot_dipole_3d, _plot_dipole_mri_outlines from .evoked_field import EvokedField +from .ui_events import subscribe from .utils import ( _check_time_unit, _get_cmap, @@ -4301,3 +4302,158 @@ def _get_3d_option(key): else: opt = opt.lower() == "true" return opt + + +def plot_stat_cluster(cluster, src, brain, time="max-extent", color="magenta", width=1): + """Plot the spatial extent of a cluster on top of a brain. + + Parameters + ---------- + cluster : tuple (time_idx, vertex_idx) + The cluster to plot. + src : SourceSpaces + The source space that was used for the inverse computation. + brain : Brain + The brain figure on which to plot the cluster. + time : float | "interactive" | "max-extent" + The time (in seconds) at which to plot the spatial extent of the cluster. + If set to ``"interactive"`` the time will follow the selected time in the brain + figure. + By default, ``"max-extent"``, the time of maximal spatial extent is chosen. + color : str + A maplotlib-style color specification indicating the color to use when plotting + the spatial extent of the cluster. + width : int + The width of the lines used to draw the outlines. + + Returns + ------- + brain : Brain + The brain figure, now with the cluster plotted on top of it. + """ + # Here due to circular import + from ..label import Label + + # args check + if isinstance(cluster, tuple): + if len(cluster) != 2: + raise ValueError( + "A cluster is a tuple of two elements, a list time " + "indices and list of vertex indices" + ) + else: + raise TypeError(f"Tuple expected, got {type(cluster)} instead.") + + cluster_time_idx, cluster_vertex_index = cluster + + # A cluster is defined both in space and time. If we want to plot the boundaries of + # the cluster in space, we must choose a specific time for which to show the + # boundaries (as they change over time). + if time == "max-extent": + time_idx, n_vertices = np.unique(cluster_time_idx, return_counts=True) + time_idx = time_idx[np.argmax(n_vertices)] + elif time == "interactive": + time_idx = brain._data["time_idx"] + elif isinstance(time, float): + time_idx = np.searchsorted(brain._times[:-1], time) + else: + raise ValueError( + "Time should be 'max-extent', 'interactive', or floating point" + f" value, got '{time}' instead." + ) + + # Select only the vertex indices at the chosen time + draw_vertex_index = [ + v for v, t in zip(cluster_vertex_index, cluster_time_idx) if t == time_idx + ] + + # Let's create an anatomical label containing these vertex indices. + # Problem 1): a label must be defined for either the left or right hemisphere. It + # cannot span both hemispheres. So we must filter the vertices based on their + # hemisphere. + # Problem 2): we have vertex *indices* that need to be transformed into proper + # vertex numbers. Not every vertex in the original high-resolution brain mesh is a + # source point in the source estimate. Do draw nice smooth curves, we need to + # interpolate the vertex indices. + + # Both problems can be solved by accessing the vertices defined in the source space + # object. The source space object is actually a list of two source spaces. + src_lh, src_rh = src + + # Split the vertices based on the hemisphere in which they are located. + lh_verts, rh_verts = src_lh["vertno"], src_rh["vertno"] + n_lh_verts = len(lh_verts) + draw_lh_verts = [lh_verts[v] for v in draw_vertex_index if v < n_lh_verts] + draw_rh_verts = [ + rh_verts[v - n_lh_verts] for v in draw_vertex_index if v >= n_lh_verts + ] + + # Vertices in a label must be unique and in increasing order + draw_lh_verts = np.unique(draw_lh_verts) + draw_rh_verts = np.unique(draw_rh_verts) + + # We are now ready to create the anatomical label objects + cluster_index = 0 + for label in brain.labels["lh"] + brain.labels["rh"]: + if label.name.startswith("cluster-"): + try: + cluster_index = max(cluster_index, int(label.name.split("-", 1)[1])) + except ValueError: + pass + lh_label = Label(draw_lh_verts, hemi="lh", name=f"cluster-{cluster_index}") + rh_label = Label(draw_rh_verts, hemi="rh", name=f"cluster-{cluster_index}") + + # Interpolate the vertices in each label to the full resolution mesh + if len(lh_label) > 0: + lh_label = lh_label.smooth( + smooth=3, subject=brain._subject, subjects_dir=brain._subjects_dir + ) + brain.add_label(lh_label, borders=width, color=color) + if len(rh_label) > 0: + rh_label = rh_label.smooth( + smooth=3, subject=brain._subject, subjects_dir=brain._subjects_dir + ) + brain.add_label(rh_label, borders=width, color=color) + + def on_time_change(event): + time_idx = np.searchsorted(brain._times, event.time) + for hemi in brain._hemis: + mesh = brain._layered_meshes[hemi] + for i, label in enumerate(brain.labels[hemi]): + if label.name == f"cluster-{cluster_index}": + del brain.labels[hemi][i] + mesh.remove_overlay(label.name) + + # Select only the vertex indices at the chosen time + draw_vertex_index = [ + v for v, t in zip(cluster_vertex_index, cluster_time_idx) if t == time_idx + ] + draw_lh_verts = [lh_verts[v] for v in draw_vertex_index if v < n_lh_verts] + draw_rh_verts = [ + rh_verts[v - n_lh_verts] for v in draw_vertex_index if v >= n_lh_verts + ] + + # Vertices in a label must be unique and in increasing order + draw_lh_verts = np.unique(draw_lh_verts) + draw_rh_verts = np.unique(draw_rh_verts) + lh_label = Label(draw_lh_verts, hemi="lh", name=f"cluster-{cluster_index}") + rh_label = Label(draw_rh_verts, hemi="rh", name=f"cluster-{cluster_index}") + if len(lh_label) > 0: + lh_label = lh_label.smooth( + smooth=3, + subject=brain._subject, + subjects_dir=brain._subjects_dir, + verbose=False, + ) + brain.add_label(lh_label, borders=width, color=color) + if len(rh_label) > 0: + rh_label = rh_label.smooth( + smooth=3, + subject=brain._subject, + subjects_dir=brain._subjects_dir, + verbose=False, + ) + brain.add_label(rh_label, borders=width, color=color) + + if time == "interactive": + subscribe(brain, "time_change", on_time_change) diff --git a/mne/viz/__init__.pyi b/mne/viz/__init__.pyi index c58ad7d0e54..8a00d5a4f3d 100644 --- a/mne/viz/__init__.pyi +++ b/mne/viz/__init__.pyi @@ -72,6 +72,7 @@ __all__ = [ "plot_source_estimates", "plot_source_spectrogram", "plot_sparse_source_estimates", + "plot_stat_cluster", "plot_tfr_topomap", "plot_topo_image_epochs", "plot_topomap", @@ -97,6 +98,7 @@ from ._3d import ( plot_head_positions, plot_source_estimates, plot_sparse_source_estimates, + plot_stat_cluster, plot_vector_source_estimates, plot_volume_source_estimates, set_3d_options, diff --git a/mne/viz/tests/test_3d.py b/mne/viz/tests/test_3d.py index 01d6d5a960d..72804695e38 100644 --- a/mne/viz/tests/test_3d.py +++ b/mne/viz/tests/test_3d.py @@ -49,6 +49,7 @@ plot_head_positions, plot_source_estimates, plot_sparse_source_estimates, + plot_stat_cluster, snapshot_brain_montage, ) from mne.viz._3d import _get_map_ticks, _linearize_map, _process_clim @@ -1413,3 +1414,53 @@ def test_link_brains(renderer_interactive): with pytest.raises(TypeError, match="type is Brain"): link_brains("foo") link_brains(brain, time=True, camera=True) + + +@testing.requires_testing_data +def test_plot_stat_cluster(renderer_interactive): + """Test plotting clusters on brain in static and interactive mode.""" + pytest.importorskip("nibabel") + sample_src = read_source_spaces(src_fname) + vertices = [s["vertno"] for s in sample_src] + n_time = 5 + n_verts = sum(len(v) for v in vertices) + + # simulate stc data + stc_data = np.zeros(n_verts * n_time) + stc_size = stc_data.size + stc_data[(np.random.rand(stc_size // 20) * stc_size).astype(int)] = ( + np.random.RandomState(0).rand(stc_data.size // 20) + ) + stc_data.shape = (n_verts, n_time) + stc = SourceEstimate(stc_data, vertices, 1, 1) + + # Simulate a cluster + cluster_time_idx = [1, 1, 2, 3] + cluster_vertex_idx = [0, 1, 2, 3] + cluster = (cluster_time_idx, cluster_vertex_idx) + + brain = plot_source_estimates( + stc, + "sample", + background=(1, 1, 0), + subjects_dir=subjects_dir, + colorbar=True, + clim="auto", + ) + # Test for incorrect argument in time + with pytest.raises(ValueError): + plot_stat_cluster(cluster, sample_src, brain, "foo") + + # test for incorrect shape of cluster + with pytest.raises(ValueError): + plot_stat_cluster(([1]), sample_src, brain) + + # test for incorrect data type of cluster + with pytest.raises(ValueError): + plot_stat_cluster([[1, 2, 3], [1, 2, 3]], sample_src, brain) + + # All correct + plot_stat_cluster(cluster, sample_src, brain) + + brain.close() + del brain diff --git a/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py b/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py index 53e90f78d01..52ae7890ff3 100644 --- a/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py +++ b/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py @@ -29,6 +29,7 @@ from mne.epochs import equalize_epoch_counts from mne.minimum_norm import apply_inverse, read_inverse_operator from mne.stats import spatio_temporal_cluster_1samp_test, summarize_clusters_stc +from mne.viz import plot_stat_cluster # %% # Set parameters @@ -142,19 +143,18 @@ # Read the source space we are morphing to src = mne.read_source_spaces(src_fname) fsave_vertices = [s["vertno"] for s in src] -morph_mat = mne.compute_source_morph( +morph = mne.compute_source_morph( src=inverse_operator["src"], subject_to="fsaverage", spacing=fsave_vertices, subjects_dir=subjects_dir, -).morph_mat - -n_vertices_fsave = morph_mat.shape[0] +) +n_vertices_fsave = morph.morph_mat.shape[0] # We have to change the shape for the dot() to work properly X = X.reshape(n_vertices_sample, n_times * n_subjects * 2) print("Morphing data.") -X = morph_mat.dot(X) # morph_mat is a sparse matrix +X = morph.morph_mat.dot(X) # morph_mat is a sparse matrix X = X.reshape(n_vertices_fsave, n_times, n_subjects, 2) # %% @@ -264,3 +264,29 @@ # We could save this via the following: # brain.save_image('clusters.png') + +# %% +# Alternatively, you may wish to observe clusters are considered statistically +# significant under the permutation distribution with resect all the source estimates. +# This can easily be done by plotting the cluster boundary on top of the source +# estimates using the code snippet below. +# ---------------------------------------------------------- + +difference = morph.apply(condition1 - condition2) +difference_plot = difference.plot( + hemi="both", + views="lateral", + subjects_dir=subjects_dir, + size=(800, 800), + initial_time=0.1, +) + +# We are plotting only 1st clusters here for illustration purpose. +plot_stat_cluster( + good_clusters[0], src, difference_plot, time="max-extent", color="magenta", width=1 +) + +# Plotting second cluster on the interactive mode for illustration purpose. +plot_stat_cluster( + good_clusters[1], src, difference_plot, time="interactive", color="magenta", width=1 +) From 990f08e5c932c698c37d70e5f17a3206f58a0105 Mon Sep 17 00:00:00 2001 From: Shristi Baral Date: Sun, 10 Aug 2025 14:38:32 +0300 Subject: [PATCH 02/10] Added enhancement PR with number --- doc/changes/dev/13366.enhancement.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/dev/13366.enhancement.rst diff --git a/doc/changes/dev/13366.enhancement.rst b/doc/changes/dev/13366.enhancement.rst new file mode 100644 index 00000000000..7e8809e7d77 --- /dev/null +++ b/doc/changes/dev/13366.enhancement.rst @@ -0,0 +1 @@ +Make :func:`~mne.viz._3d.plot_stat_cluster` that plots spatial extent of a cluster on top of a brain by `Shristi Baral`_. \ No newline at end of file From 7ccd14f955a4c66ddba9f75cbc2150584ffc06cf Mon Sep 17 00:00:00 2001 From: Shristi Baral Date: Sun, 10 Aug 2025 14:42:50 +0300 Subject: [PATCH 03/10] Added newfeature PR with number --- doc/changes/dev/{13366.enhancement.rst => 13366.newfeature.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename doc/changes/dev/{13366.enhancement.rst => 13366.newfeature.rst} (100%) diff --git a/doc/changes/dev/13366.enhancement.rst b/doc/changes/dev/13366.newfeature.rst similarity index 100% rename from doc/changes/dev/13366.enhancement.rst rename to doc/changes/dev/13366.newfeature.rst From 8e840df774681f58c730b32d876e2636a0a613a5 Mon Sep 17 00:00:00 2001 From: Shristi Baral Date: Sun, 10 Aug 2025 15:27:08 +0300 Subject: [PATCH 04/10] Fix Error type --- mne/viz/tests/test_3d.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mne/viz/tests/test_3d.py b/mne/viz/tests/test_3d.py index 72804695e38..881ef3a6988 100644 --- a/mne/viz/tests/test_3d.py +++ b/mne/viz/tests/test_3d.py @@ -1452,11 +1452,11 @@ def test_plot_stat_cluster(renderer_interactive): plot_stat_cluster(cluster, sample_src, brain, "foo") # test for incorrect shape of cluster - with pytest.raises(ValueError): + with pytest.raises(TypeError): plot_stat_cluster(([1]), sample_src, brain) # test for incorrect data type of cluster - with pytest.raises(ValueError): + with pytest.raises(TypeError): plot_stat_cluster([[1, 2, 3], [1, 2, 3]], sample_src, brain) # All correct From 1b8edc11f324d5319a3aa74504dd1c9687025e78 Mon Sep 17 00:00:00 2001 From: Shristi Baral Date: Sun, 10 Aug 2025 16:50:45 +0300 Subject: [PATCH 05/10] change function reference --- doc/changes/dev/13366.newfeature.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changes/dev/13366.newfeature.rst b/doc/changes/dev/13366.newfeature.rst index 7e8809e7d77..257913d024b 100644 --- a/doc/changes/dev/13366.newfeature.rst +++ b/doc/changes/dev/13366.newfeature.rst @@ -1 +1 @@ -Make :func:`~mne.viz._3d.plot_stat_cluster` that plots spatial extent of a cluster on top of a brain by `Shristi Baral`_. \ No newline at end of file +Make :func:`~mne.viz.plot_stat_cluster` that plots spatial extent of a cluster on top of a brain by `Shristi Baral`_. \ No newline at end of file From 90b77cf81ad9f24a95636272d7cb900df37ddda9 Mon Sep 17 00:00:00 2001 From: Shristi Baral Date: Mon, 11 Aug 2025 18:10:41 +0300 Subject: [PATCH 06/10] Unit test update Comments rearranged Docstring updated Code cleanup --- mne/viz/_3d.py | 38 ++++++++++++++++++++------------------ mne/viz/tests/test_3d.py | 26 +++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py index c556d0cf1cf..f949984deae 100644 --- a/mne/viz/_3d.py +++ b/mne/viz/_3d.py @@ -4309,8 +4309,10 @@ def plot_stat_cluster(cluster, src, brain, time="max-extent", color="magenta", w Parameters ---------- - cluster : tuple (time_idx, vertex_idx) - The cluster to plot. + cluster : tuple + The cluster to plot. A cluster is a tuple of two list of arrays, a list time + indices and list of vertex indices, same as returned from cluster + permutation test. src : SourceSpaces The source space that was used for the inverse computation. brain : Brain @@ -4335,16 +4337,15 @@ def plot_stat_cluster(cluster, src, brain, time="max-extent", color="magenta", w from ..label import Label # args check - if isinstance(cluster, tuple): - if len(cluster) != 2: - raise ValueError( - "A cluster is a tuple of two elements, a list time " - "indices and list of vertex indices" - ) - else: + if not isinstance(cluster, tuple): raise TypeError(f"Tuple expected, got {type(cluster)} instead.") - - cluster_time_idx, cluster_vertex_index = cluster + elif len(cluster) != 2: + raise ValueError( + "A cluster is a tuple of two elements, a list time indices " + "and list of vertex indices." + ) + else: + cluster_time_idx, cluster_vertex_index = cluster # A cluster is defined both in space and time. If we want to plot the boundaries of # the cluster in space, we must choose a specific time for which to show the @@ -4371,13 +4372,9 @@ def plot_stat_cluster(cluster, src, brain, time="max-extent", color="magenta", w # Problem 1): a label must be defined for either the left or right hemisphere. It # cannot span both hemispheres. So we must filter the vertices based on their # hemisphere. - # Problem 2): we have vertex *indices* that need to be transformed into proper - # vertex numbers. Not every vertex in the original high-resolution brain mesh is a - # source point in the source estimate. Do draw nice smooth curves, we need to - # interpolate the vertex indices. - # Both problems can be solved by accessing the vertices defined in the source space - # object. The source space object is actually a list of two source spaces. + # The source space object is actually a list of two source spaces, left and right + # hemisphere. src_lh, src_rh = src # Split the vertices based on the hemisphere in which they are located. @@ -4403,7 +4400,12 @@ def plot_stat_cluster(cluster, src, brain, time="max-extent", color="magenta", w lh_label = Label(draw_lh_verts, hemi="lh", name=f"cluster-{cluster_index}") rh_label = Label(draw_rh_verts, hemi="rh", name=f"cluster-{cluster_index}") - # Interpolate the vertices in each label to the full resolution mesh + # Problem 2): We have vertex *indices* that need to be transformed into proper + # vertex numbers. Not every vertex in the original high-resolution brain mesh is a + # source point in the source estimate. Do draw nice smooth curves, we need to + # interpolate the vertex indices. + + # Here, we interpolate the vertices in each label to the full resolution mesh if len(lh_label) > 0: lh_label = lh_label.smooth( smooth=3, subject=brain._subject, subjects_dir=brain._subjects_dir diff --git a/mne/viz/tests/test_3d.py b/mne/viz/tests/test_3d.py index 881ef3a6988..4ac39f95e4d 100644 --- a/mne/viz/tests/test_3d.py +++ b/mne/viz/tests/test_3d.py @@ -1459,8 +1459,32 @@ def test_plot_stat_cluster(renderer_interactive): with pytest.raises(TypeError): plot_stat_cluster([[1, 2, 3], [1, 2, 3]], sample_src, brain) - # All correct + # All arguments are correct plot_stat_cluster(cluster, sample_src, brain) + # check for missing brain objects + missing = [] + for key in ("lh", "rh"): + for attr, desc in [ + ("labels", "brain.labels"), + ("_hemis", "brain._hemis"), + ("_layered_meshes", "brain._layered_meshes"), + ]: + if key not in getattr(brain, attr): + missing.append(f"{key} is missing from '{desc}'") + if not brain._subject: + missing.append("Subject name is missing from brain._subject") + if not brain._subjects_dir: + missing.append("Subject directory name is missing from brain._subjects_dir") + if brain._times is None or brain._times.size == 0: + missing.append("Time is missing from brain._times") + + for label in brain.labels["lh"] + brain.labels["rh"]: + if not label.name.startswith("cluster-"): + missing.append( + f"Unexpected cluster label `{label.name}` found in label.name :" + ) + assert not missing, "Brain object check failed:\n" + "\n".join(missing) + brain.close() del brain From 2caecf12c739a3e9d9a28f9be3870013ac401a32 Mon Sep 17 00:00:00 2001 From: Shristi Baral Date: Mon, 11 Aug 2025 19:07:37 +0300 Subject: [PATCH 07/10] Update brain plot --- mne/viz/tests/test_3d.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mne/viz/tests/test_3d.py b/mne/viz/tests/test_3d.py index 4ac39f95e4d..45f74097f70 100644 --- a/mne/viz/tests/test_3d.py +++ b/mne/viz/tests/test_3d.py @@ -1442,6 +1442,7 @@ def test_plot_stat_cluster(renderer_interactive): brain = plot_source_estimates( stc, "sample", + hemi="both", background=(1, 1, 0), subjects_dir=subjects_dir, colorbar=True, From f40aab4a554eaea1da7e213abc764d7b7e15d729 Mon Sep 17 00:00:00 2001 From: Shristi Baral Date: Tue, 12 Aug 2025 09:13:39 +0300 Subject: [PATCH 08/10] Updated cluster index for visualization --- mne/viz/tests/test_3d.py | 2 +- .../20_cluster_1samp_spatiotemporal.py | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/mne/viz/tests/test_3d.py b/mne/viz/tests/test_3d.py index 45f74097f70..1609226a5fb 100644 --- a/mne/viz/tests/test_3d.py +++ b/mne/viz/tests/test_3d.py @@ -1476,7 +1476,7 @@ def test_plot_stat_cluster(renderer_interactive): if not brain._subject: missing.append("Subject name is missing from brain._subject") if not brain._subjects_dir: - missing.append("Subject directory name is missing from brain._subjects_dir") + missing.append("Subject directory path is missing from brain._subjects_dir") if brain._times is None or brain._times.size == 0: missing.append("Time is missing from brain._times") diff --git a/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py b/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py index 52ae7890ff3..c3415594ed3 100644 --- a/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py +++ b/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py @@ -270,7 +270,6 @@ # significant under the permutation distribution with resect all the source estimates. # This can easily be done by plotting the cluster boundary on top of the source # estimates using the code snippet below. -# ---------------------------------------------------------- difference = morph.apply(condition1 - condition2) difference_plot = difference.plot( @@ -281,12 +280,12 @@ initial_time=0.1, ) -# We are plotting only 1st clusters here for illustration purpose. +# We are plotting only one clusters here for illustration purpose. plot_stat_cluster( - good_clusters[0], src, difference_plot, time="max-extent", color="magenta", width=1 + good_clusters[2], src, difference_plot, time="max-extent", color="magenta", width=1 ) -# Plotting second cluster on the interactive mode for illustration purpose. +# Plotting the same cluster on the interactive mode for illustration purpose. plot_stat_cluster( - good_clusters[1], src, difference_plot, time="interactive", color="magenta", width=1 + good_clusters[2], src, difference_plot, time="interactive", color="magenta", width=1 ) From 8c913a4ed7d41434f8350326045ce9830a071939 Mon Sep 17 00:00:00 2001 From: Shristi Baral Date: Wed, 13 Aug 2025 11:07:56 +0300 Subject: [PATCH 09/10] Addressed suggestion by @wmvanvliet --- doc/changes/dev/13366.newfeature.rst | 2 +- mne/viz/_3d.py | 17 +++++------ mne/viz/tests/test_3d.py | 29 +++---------------- .../20_cluster_1samp_spatiotemporal.py | 13 ++++----- 4 files changed, 19 insertions(+), 42 deletions(-) diff --git a/doc/changes/dev/13366.newfeature.rst b/doc/changes/dev/13366.newfeature.rst index 257913d024b..22796aa1fc8 100644 --- a/doc/changes/dev/13366.newfeature.rst +++ b/doc/changes/dev/13366.newfeature.rst @@ -1 +1 @@ -Make :func:`~mne.viz.plot_stat_cluster` that plots spatial extent of a cluster on top of a brain by `Shristi Baral`_. \ No newline at end of file +Add :func:`~mne.viz.plot_stat_cluster` that plots the spatial extent of a cluster on top of a brain by `Shristi Baral`_. \ No newline at end of file diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py index f949984deae..f50efbb2de5 100644 --- a/mne/viz/_3d.py +++ b/mne/viz/_3d.py @@ -4310,9 +4310,9 @@ def plot_stat_cluster(cluster, src, brain, time="max-extent", color="magenta", w Parameters ---------- cluster : tuple - The cluster to plot. A cluster is a tuple of two list of arrays, a list time - indices and list of vertex indices, same as returned from cluster - permutation test. + The cluster to plot. A cluster is a tuple of two elements: + an array of time indices + and an array of vertex indices. src : SourceSpaces The source space that was used for the inverse computation. brain : Brain @@ -4368,10 +4368,9 @@ def plot_stat_cluster(cluster, src, brain, time="max-extent", color="magenta", w v for v, t in zip(cluster_vertex_index, cluster_time_idx) if t == time_idx ] - # Let's create an anatomical label containing these vertex indices. - # Problem 1): a label must be defined for either the left or right hemisphere. It - # cannot span both hemispheres. So we must filter the vertices based on their - # hemisphere. + # Create the anatomical label containing the vertex indices belonging to the + # cluster. A label cannot span both hemispheres. + # So we must filter the vertices based on their hemisphere. # The source space object is actually a list of two source spaces, left and right # hemisphere. @@ -4400,8 +4399,8 @@ def plot_stat_cluster(cluster, src, brain, time="max-extent", color="magenta", w lh_label = Label(draw_lh_verts, hemi="lh", name=f"cluster-{cluster_index}") rh_label = Label(draw_rh_verts, hemi="rh", name=f"cluster-{cluster_index}") - # Problem 2): We have vertex *indices* that need to be transformed into proper - # vertex numbers. Not every vertex in the original high-resolution brain mesh is a + # Transform vertex indices into proper vertex numbers. + # Not every vertex in the original high-resolution brain mesh is a # source point in the source estimate. Do draw nice smooth curves, we need to # interpolate the vertex indices. diff --git a/mne/viz/tests/test_3d.py b/mne/viz/tests/test_3d.py index 1609226a5fb..9c25f22af0e 100644 --- a/mne/viz/tests/test_3d.py +++ b/mne/viz/tests/test_3d.py @@ -1419,7 +1419,6 @@ def test_link_brains(renderer_interactive): @testing.requires_testing_data def test_plot_stat_cluster(renderer_interactive): """Test plotting clusters on brain in static and interactive mode.""" - pytest.importorskip("nibabel") sample_src = read_source_spaces(src_fname) vertices = [s["vertno"] for s in sample_src] n_time = 5 @@ -1442,7 +1441,6 @@ def test_plot_stat_cluster(renderer_interactive): brain = plot_source_estimates( stc, "sample", - hemi="both", background=(1, 1, 0), subjects_dir=subjects_dir, colorbar=True, @@ -1463,29 +1461,10 @@ def test_plot_stat_cluster(renderer_interactive): # All arguments are correct plot_stat_cluster(cluster, sample_src, brain) - # check for missing brain objects - missing = [] - for key in ("lh", "rh"): - for attr, desc in [ - ("labels", "brain.labels"), - ("_hemis", "brain._hemis"), - ("_layered_meshes", "brain._layered_meshes"), - ]: - if key not in getattr(brain, attr): - missing.append(f"{key} is missing from '{desc}'") - if not brain._subject: - missing.append("Subject name is missing from brain._subject") - if not brain._subjects_dir: - missing.append("Subject directory path is missing from brain._subjects_dir") - if brain._times is None or brain._times.size == 0: - missing.append("Time is missing from brain._times") - - for label in brain.labels["lh"] + brain.labels["rh"]: - if not label.name.startswith("cluster-"): - missing.append( - f"Unexpected cluster label `{label.name}` found in label.name :" - ) - assert not missing, "Brain object check failed:\n" + "\n".join(missing) + # Check that the proper anatomical label has been constructed. + assert len(brain.labels["lh"]) == 1 + assert len(brain.labels["rh"]) == 0 + assert brain.labels["lh"][0].name == "cluster-0" brain.close() del brain diff --git a/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py b/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py index c3415594ed3..d43f6ecb3dc 100644 --- a/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py +++ b/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py @@ -266,10 +266,9 @@ # brain.save_image('clusters.png') # %% -# Alternatively, you may wish to observe clusters are considered statistically -# significant under the permutation distribution with resect all the source estimates. -# This can easily be done by plotting the cluster boundary on top of the source -# estimates using the code snippet below. +# Alternatively, you may wish to observe the spatial and temporal extent of +# single clusters. The code below demonstrates how to plot the cluster +# boundary on top of an existing source estimate. difference = morph.apply(condition1 - condition2) difference_plot = difference.plot( @@ -280,12 +279,12 @@ initial_time=0.1, ) -# We are plotting only one clusters here for illustration purpose. +# Plot one cluster at the time of maximal spatial extent of that cluster plot_stat_cluster( good_clusters[2], src, difference_plot, time="max-extent", color="magenta", width=1 ) - -# Plotting the same cluster on the interactive mode for illustration purpose. +# %% +# Plotting the cluster in interactive mode allows scrolling through time plot_stat_cluster( good_clusters[2], src, difference_plot, time="interactive", color="magenta", width=1 ) From 49be698c6df748cf646bb8ef84ed9e53199c776a Mon Sep 17 00:00:00 2001 From: Shristi Baral Date: Wed, 13 Aug 2025 12:25:57 +0300 Subject: [PATCH 10/10] Removed cell magic --- tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py b/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py index d43f6ecb3dc..f5315b65689 100644 --- a/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py +++ b/tutorials/stats-source-space/20_cluster_1samp_spatiotemporal.py @@ -283,7 +283,7 @@ plot_stat_cluster( good_clusters[2], src, difference_plot, time="max-extent", color="magenta", width=1 ) -# %% + # Plotting the cluster in interactive mode allows scrolling through time plot_stat_cluster( good_clusters[2], src, difference_plot, time="interactive", color="magenta", width=1