diff --git a/doc/changes/devel/12848.newfeature.rst b/doc/changes/devel/12848.newfeature.rst new file mode 100644 index 00000000000..7b0398c714f --- /dev/null +++ b/doc/changes/devel/12848.newfeature.rst @@ -0,0 +1 @@ +Add source space(s) visualization(s) in :func:`mne.Report.add_forward`, by `Victor Ferat`_. \ No newline at end of file diff --git a/mne/report/report.py b/mne/report/report.py index be7c716cdc0..02c729660b0 100644 --- a/mne/report/report.py +++ b/mne/report/report.py @@ -475,7 +475,17 @@ def _fig_to_img( def _get_bem_contour_figs_as_arrays( - *, sl, n_jobs, mri_fname, surfaces, orientation, src, show, show_orientation, width + *, + sl, + n_jobs, + mri_fname, + surfaces, + orientation, + src, + trans, + show, + show_orientation, + width, ): """Render BEM surface contours on MRI slices. @@ -494,6 +504,7 @@ def _get_bem_contour_figs_as_arrays( surfaces=surfaces, orientation=orientation, src=src, + trans=trans, show=show, show_orientation=show_orientation, width=width, @@ -507,6 +518,21 @@ def _get_bem_contour_figs_as_arrays( return out +def _iterate_alignment_views(function, alpha, **kwargs): + """Auxiliary function to iterate over views in trans fig.""" + from ..viz.backends.renderer import MNE_3D_BACKEND_TESTING + + # TODO: Eventually maybe we should expose the size option? + size = (80, 80) if MNE_3D_BACKEND_TESTING else (800, 800) + fig = create_3d_figure(size, bgcolor=(0.5, 0.5, 0.5)) + from ..viz.backends.renderer import backend + + try: + return _itv(function, fig, **kwargs) + finally: + backend._close_3d_figure(fig) + + def _iterate_trans_views(function, alpha, **kwargs): """Auxiliary function to iterate over views in trans fig.""" from ..viz.backends.renderer import MNE_3D_BACKEND_TESTING @@ -530,7 +556,18 @@ def _itv(function, fig, *, max_width=MAX_IMG_WIDTH, max_res=MAX_IMG_RES, **kwarg function(fig=fig, **kwargs) - views = ("frontal", "lateral", "medial", "axial", "rostral", "coronal") + views = ( + "lateral_r", + "frontlat_r", + "frontal", + "frontlat_l", + "lateral_l", + "top", + "backlat_r", + "back", + "backlat_l", + "bot", + ) images = [] for view in views: @@ -543,7 +580,7 @@ def _itv(function, fig, *, max_width=MAX_IMG_WIDTH, max_res=MAX_IMG_RES, **kwarg images.append(im) images = np.concatenate( - [np.concatenate(images[:3], axis=1), np.concatenate(images[3:], axis=1)], axis=0 + [np.concatenate(images[:5], axis=1), np.concatenate(images[5:], axis=1)], axis=0 ) try: @@ -2639,6 +2676,8 @@ def add_bem( self._add_bem( subject=subject, subjects_dir=subjects_dir, + src=None, + trans=None, decim=decim, n_jobs=n_jobs, width=width, @@ -3229,6 +3268,8 @@ def _render_one_bem_axis( surfaces, image_format, orientation, + src=None, + trans=None, decim=2, n_jobs=None, width=512, @@ -3249,7 +3290,8 @@ def _render_one_bem_axis( mri_fname=mri_fname, surfaces=surfaces, orientation=orientation, - src=None, + src=src, + trans=trans, show=False, show_orientation="always", width=width, @@ -3558,6 +3600,89 @@ def _add_forward( replace=replace, ) + if subject: + src = forward["src"] + trans = forward["mri_head_t"] + # Alignment + kwargs = dict( + info=forward["info"], + trans=trans, + src=src, + subject=subject, + subjects_dir=subjects_dir, + meg=["helmet", "sensors"], + show_axes=True, + eeg=dict(original=0.2, projected=0.8), + coord_frame="mri", + ) + img, _ = _iterate_trans_views( + function=plot_alignment, + alpha=0.5, + max_width=self.img_max_width, + max_res=self.img_max_res, + **kwargs, + ) + self._add_image( + img=img, + title="Alignment", + section=section, + caption=None, + image_format="png", + tags=tags, + replace=replace, + ) + # Source space + kwargs = dict( + trans=trans, + subjects_dir=subjects_dir, + ) + + self._add_bem( + subject=subject, + subjects_dir=subjects_dir, + src=src, + trans=trans, + decim=1, + n_jobs=1, + width=512, + image_format=image_format, + title="Source space(s) (BEM view)", + section=section, + tags=tags, + replace=replace, + ) + + if src.kind == "surface" or src.kind == "mixed": + surfaces = dict(head=0.1, white=0.5) + else: + surfaces = dict(head=0.1) + + kwargs = dict( + trans=trans, + src=src, + subject=subject, + subjects_dir=subjects_dir, + show_axes=False, + coord_frame="mri", + surfaces=surfaces, + ) + img, _ = _iterate_alignment_views( + function=plot_alignment, + alpha=0.5, + max_width=self.img_max_width, + max_res=self.img_max_res, + **kwargs, + ) + self._add_image( + img=img, + title="Source space(s) (3D view)", + section=section, + caption=None, + image_format="png", + tags=tags, + replace=replace, + ) + def _add_inverse_operator( self, *, @@ -4405,13 +4530,15 @@ def _add_bem( *, subject, subjects_dir, + src, + trans, decim, n_jobs, width=512, image_format, title, - tags, section, + tags, replace, ): """Render mri+bem (only PNG).""" @@ -4437,6 +4564,8 @@ def _add_bem( mri_fname=mri_fname, surfaces=surfaces, orientation=orientation, + src=src, + trans=trans, decim=decim, n_jobs=n_jobs, width=width, diff --git a/mne/report/tests/test_report.py b/mne/report/tests/test_report.py index 66f4cd9e336..af00ae69d0e 100644 --- a/mne/report/tests/test_report.py +++ b/mne/report/tests/test_report.py @@ -514,6 +514,29 @@ def test_add_bem_n_jobs(n_jobs, monkeypatch): assert 0.778 < corr < 0.80 +@pytest.mark.filterwarnings("ignore:Distances could not be calculated.*:RuntimeWarning") +@pytest.mark.slowtest +@testing.requires_testing_data +def test_add_forward(): + """Test add_forward.""" + report = Report(subjects_dir=subjects_dir, image_format="png") + report.add_forward( + forward=fwd_fname, + subject="sample", + subjects_dir=subjects_dir, + title="Forward solution", + ) + assert len(report.html) == 4 + + report = Report(subjects_dir=subjects_dir, image_format="png") + report.add_forward( + forward=fwd_fname, + subjects_dir=subjects_dir, + title="Forward solution", + ) + assert len(report.html) == 1 + + @testing.requires_testing_data def test_render_mri_without_bem(tmp_path): """Test rendering MRI without BEM for mne report.""" @@ -882,6 +905,7 @@ def test_survive_pickle(tmp_path): @pytest.mark.slowtest # ~30 s on Azure Windows @testing.requires_testing_data +@pytest.mark.filterwarnings("ignore:Distances could not be calculated.*:RuntimeWarning") def test_manual_report_2d(tmp_path, invisible_fig): """Simulate user manually creating report by adding one file at a time.""" pytest.importorskip("sklearn") diff --git a/mne/source_space/_source_space.py b/mne/source_space/_source_space.py index d64989961cf..98a28847104 100644 --- a/mne/source_space/_source_space.py +++ b/mne/source_space/_source_space.py @@ -329,6 +329,7 @@ def plot( skull=None, subjects_dir=None, trans=None, + fig=None, verbose=None, ): """Plot the source space. @@ -358,6 +359,11 @@ def plot( produced during coregistration. If trans is None, an identity matrix is assumed. This is only needed when the source space is in head coordinates. + fig : Figure3D | None + PyVista scene in which to plot the alignment. + If ``None``, creates a new 600x600 pixel figure with black background. + + .. versionadded:: 1.9 %(verbose)s Returns @@ -427,6 +433,7 @@ def plot( ecog=False, bem=bem, src=self, + fig=fig, ) def __getitem__(self, *args, **kwargs): diff --git a/mne/viz/_brain/view.py b/mne/viz/_brain/view.py index 1550c3868fb..fe045adda26 100644 --- a/mne/viz/_brain/view.py +++ b/mne/viz/_brain/view.py @@ -35,6 +35,20 @@ azimuth=180.0, elevation=0.0, focalpoint=ORIGIN, roll=0, distance=DIST ), } + +_both_views_dict = { + "lateral_r": dict(azimuth=180.0, elevation=90.0, focalpoint=ORIGIN, distance=DIST), + "frontlat_r": dict(azimuth=120.0, elevation=90.0, focalpoint=ORIGIN, distance=DIST), + "frontal": dict(azimuth=90.0, elevation=90.0, focalpoint=ORIGIN, distance=DIST), + "frontlat_l": dict(azimuth=60.0, elevation=90.0, focalpoint=ORIGIN, distance=DIST), + "lateral_l": dict(azimuth=180.0, elevation=-90.0, focalpoint=ORIGIN, distance=DIST), + "backlat_r": dict(azimuth=-120.0, elevation=90.0, focalpoint=ORIGIN, distance=DIST), + "back": dict(azimuth=90.0, elevation=-90.0, focalpoint=ORIGIN, distance=DIST), + "backlat_l": dict(azimuth=-60.0, elevation=90.0, focalpoint=ORIGIN, distance=DIST), + "top": dict(azimuth=180.0, elevation=0.0, focalpoint=ORIGIN, distance=DIST), + "bot": dict(azimuth=180, elevation=180, focalpoint=ORIGIN, distance=DIST), +} + # add short-size version entries into the dict lh_views_dict = _lh_views_dict.copy() for k, v in _lh_views_dict.items(): @@ -49,6 +63,10 @@ rh_views_dict["flat"] = dict( azimuth=0, elevation=0, focalpoint=ORIGIN, roll=0, distance=DIST ) + +both_views_dict = _both_views_dict.copy() + + views_dicts = dict( - lh=lh_views_dict, vol=lh_views_dict, both=lh_views_dict, rh=rh_views_dict + lh=lh_views_dict, vol=lh_views_dict, both=both_views_dict, rh=rh_views_dict ) diff --git a/mne/viz/misc.py b/mne/viz/misc.py index c83a4dfe717..e2fd997869b 100644 --- a/mne/viz/misc.py +++ b/mne/viz/misc.py @@ -32,7 +32,7 @@ from ..fixes import _safe_svd from ..rank import compute_rank from ..surface import read_surface -from ..transforms import _frame_to_str, apply_trans +from ..transforms import apply_trans from ..utils import ( _check_option, _mask_to_onsets_offsets, @@ -360,6 +360,7 @@ def _plot_mri_contours( mri_fname, surfaces, src, + trans=None, orientation="coronal", slices=None, show=True, @@ -439,14 +440,15 @@ def _plot_mri_contours( sources = list() if src is not None: _ensure_src(src, extra=" or None") - # Eventually we can relax this by allowing ``trans`` if need be - if src[0]["coord_frame"] != FIFF.FIFFV_COORD_MRI: - raise ValueError( - "Source space must be in MRI coordinates, got " - f"{_frame_to_str[src[0]['coord_frame']]}" - ) for src_ in src: points = src_["rr"][src_["inuse"].astype(bool)] + if src_["coord_frame"] != FIFF.FIFFV_COORD_MRI: + if trans is None: + raise ValueError( + "Source space must be in MRI coordinates, or provide a trans." + ) + else: + points = apply_trans(np.linalg.inv(trans["trans"]), points) sources.append(apply_trans(mri_rasvox_t, points * 1e3)) sources = np.concatenate(sources, axis=0) @@ -600,6 +602,7 @@ def plot_bem( slices=None, brain_surfaces=None, src=None, + trans=None, show=True, show_indices=True, mri="T1.mgz", @@ -629,6 +632,8 @@ def plot_bem( .. versionchanged:: 0.20 All sources are shown on the nearest slice rather than some being omitted. + %(trans)s + .. versionadded:: 1.9 show : bool Show figure if True. show_indices : bool @@ -722,6 +727,7 @@ def plot_bem( mri_fname=mri_fname, surfaces=surfaces, src=src, + trans=trans, orientation=orientation, slices=slices, show=show, diff --git a/mne/viz/tests/test_misc.py b/mne/viz/tests/test_misc.py index fe179756c53..79fd85ceddf 100644 --- a/mne/viz/tests/test_misc.py +++ b/mne/viz/tests/test_misc.py @@ -169,7 +169,7 @@ def test_plot_bem(): src=src_fname, ) assert len(fig.axes[0].collections) == 4 # 3 BEM surfaces + 1 src contour - with pytest.raises(ValueError, match="MRI coordinates, got head"): + with pytest.raises(ValueError, match="Source space must be in MRI coordinates"): plot_bem(subject="sample", subjects_dir=subjects_dir, src=inv_fname)