Repository: mne-tools/mne-python

Issue:
[ENH] Add focalpoint to `mne.viz.Brain`
It would be nice to be able to set the focal point of the camera in the rendering without relying on any `pyvista` internal calls/calls to `brain._renderer`.


Discussion:
@alexrockhill do you want to take this? I think in theory it will be straightforward

Resolving patch:
diff --git a/mne/utils/docs.py b/mne/utils/docs.py
--- a/mne/utils/docs.py
+++ b/mne/utils/docs.py
@@ -1654,6 +1654,33 @@
 """
 
 # Brain plotting
+docdict["view"] = """
+view : str
+    The name of the view to show (e.g. "lateral"). Other arguments
+    take precedence and modify the camera starting from the ``view``.
+"""
+docdict["roll"] = """
+roll : float | None
+    The roll of the camera rendering the view in degrees.
+"""
+docdict["distance"] = """
+distance : float | None
+    The distance from the camera rendering the view to the focalpoint
+    in plot units (either m or mm).
+"""
+docdict["azimuth"] = """
+azimuth : float
+    The azimuthal angle of the camera rendering the view in degrees.
+"""
+docdict["elevation"] = """
+elevation : float
+    The The zenith angle of the camera rendering the view in degrees.
+"""
+docdict["focalpoint"] = """
+focalpoint : tuple, shape (3,) | None
+    The focal point of the camera rendering the view: (x, y, z) in
+    plot units (either m or mm).
+"""
 docdict["clim"] = """
 clim : str | dict
     Colorbar properties specification. If 'auto', set clim automatically
diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py
--- a/mne/viz/_brain/_brain.py
+++ b/mne/viz/_brain/_brain.py
@@ -399,16 +399,14 @@ def __init__(self, subject_id, hemi='both', surf='pial', title=None,
         from .._3d import _get_cmap
         from matplotlib.colors import colorConverter
 
-        _validate_type(hemi, (str, None), 'hemi')
         if hemi is None:
-            self._hemis = tuple()
+            hemi = 'vol'
+        hemi = self._check_hemi(hemi, extras=('both', 'split', 'vol'))
+        if hemi in ('both', 'split'):
+            self._hemis = ('lh', 'rh')
         else:
-            hemi = self._check_hemi(hemi, extras=('both', 'split'))
-            if hemi in ('both', 'split'):
-                self._hemis = ('lh', 'rh')
-            else:
-                assert hemi in ('lh', 'rh')
-                self._hemis = (hemi, )
+            assert hemi in ('lh', 'rh', 'vol')
+            self._hemis = (hemi, )
         self._view_layout = _check_option('view_layout', view_layout,
                                           ('vertical', 'horizontal'))
 
@@ -429,8 +427,8 @@ def __init__(self, subject_id, hemi='both', surf='pial', title=None,
             foreground = colorConverter.to_rgb(foreground)
         self._fg_color = foreground
         views = _check_views(surf, views, hemi)
-        col_dict = dict(lh=1, rh=1, both=1, split=2)
-        shape = (len(views), 1 if hemi is None else col_dict[hemi])
+        col_dict = dict(lh=1, rh=1, both=1, split=2, vol=1)
+        shape = (len(views), col_dict[hemi])
         if self._view_layout == 'horizontal':
             shape = shape[::-1]
         self._subplot_shape = shape
@@ -504,10 +502,14 @@ def __init__(self, subject_id, hemi='both', surf='pial', title=None,
         self.plotter = self._renderer.plotter
 
         self._setup_canonical_rotation()
-        for h in self._hemis:
+
+        # plot hemis
+        for h in ('lh', 'rh'):
+            if h not in self._hemis:
+                continue  # don't make surface if not chosen
             # Initialize a Surface object as the geometry
-            geo = _Surface(subject_id, h, surf, subjects_dir, offset,
-                           units=self._units, x_dir=self._rigid[0, :3])
+            geo = _Surface(self._subject_id, h, surf, self._subjects_dir,
+                           offset, units=self._units, x_dir=self._rigid[0, :3])
             # Load in the geometry and curvature
             geo.load_geometry()
             geo.load_curvature()
@@ -2023,22 +2025,18 @@ def add_data(self, array, fmin=None, fmid=None, fmax=None,
         self.update_lut(alpha=alpha)
 
     def _iter_views(self, hemi):
-        # which rows and columns each type of visual needs to be added to
+        """Iterate over rows and columns that need to be added to."""
+        hemi_dict = dict(lh=[0], rh=[0], vol=[0])
         if self._hemi == 'split':
-            hemi_dict = dict(lh=[0], rh=[1], vol=[0, 1])
-        else:
-            hemi_dict = dict(lh=[0], rh=[0], vol=[0])
+            hemi_dict.update(rh=[1], vol=[0, 1])
         for vi, view in enumerate(self._views):
+            view_dict = dict(lh=[vi], rh=[vi], vol=[vi])
             if self._hemi == 'split':
-                view_dict = dict(lh=[vi], rh=[vi], vol=[vi, vi])
-            else:
-                view_dict = dict(lh=[vi], rh=[vi], vol=[vi])
+                view_dict.update(vol=[vi, vi])
             if self._view_layout == 'vertical':
-                rows = view_dict  # views are rows
-                cols = hemi_dict  # hemis are columns
+                rows, cols = view_dict, hemi_dict  # views are rows, hemis cols
             else:
-                rows = hemi_dict  # hemis are rows
-                cols = view_dict  # views are columns
+                rows, cols = hemi_dict, view_dict  # hemis are rows, views cols
             for ri, ci in zip(rows[hemi], cols[hemi]):
                 yield ri, ci, view
 
@@ -2325,10 +2323,7 @@ def add_volume_labels(self, aseg='aparc+aseg', labels=None, colors=None,
 
         Parameters
         ----------
-        aseg : str
-            The anatomical segmentation file. Default ``aparc+aseg``. This may
-            be any anatomical segmentation file in the mri subdirectory of the
-            subject directory from the Freesurfer recon-all.
+        %(aseg)s
         labels : list
             Labeled regions of interest to plot. See
             :func:`mne.get_montage_volume_labels`
@@ -2392,9 +2387,11 @@ def add_volume_labels(self, aseg='aparc+aseg', labels=None, colors=None,
             mask = (aseg_data == val)
             verts, triangles = marching_cubes(mask, level=0.5, smooth=smooth)
             verts = apply_trans(vox_mri_t, verts)
-            self._renderer.mesh(
-                *verts.T, triangles=triangles, color=color, opacity=alpha,
-                reset_camera=False, render=False)
+            for h in self._hemis:
+                for ri, ci, v in self._iter_views(h):
+                    self._renderer.mesh(
+                        *verts.T, triangles=triangles, color=color,
+                        opacity=alpha, reset_camera=False, render=False)
 
         if legend or isinstance(legend, dict):
             # use empty kwargs for legend = True
@@ -2482,10 +2479,11 @@ def add_text(self, x, y, text, name=None, color=None, opacity=1.0,
             background color).
         opacity : float
             Opacity of the text (default 1.0).
-        row : int
-            Row index of which brain to use.
-        col : int
-            Column index of which brain to use.
+        row : int | None
+            Row index of which brain to use. Default is the bottom row.
+        col : int | None
+            Column index of which brain to use. Default is the left-most
+            column.
         font_size : float | None
             The font size to use.
         justification : str | None
@@ -2495,8 +2493,13 @@ def add_text(self, x, y, text, name=None, color=None, opacity=1.0,
         # are implemented
         # _check_option('name', name, [None])
 
-        self._renderer.text2d(x_window=x, y_window=y, text=text, color=color,
-                              size=font_size, justification=justification)
+        for h in self._hemis:
+            for ri, ci, v in self._iter_views(h):
+                if (row is None or row == ri) and (col is None or col == ci):
+                    self._renderer.subplot(ri, ci)
+                    self._renderer.text2d(x_window=x, y_window=y, text=text,
+                                          color=color, size=font_size,
+                                          justification=justification)
 
     def _configure_label_time_course(self):
         from ...label import read_labels_from_annot
@@ -2654,29 +2657,31 @@ def show(self):
         """Display the window."""
         self._renderer.show()
 
-    def show_view(self, view=None, roll=None, distance=None, row=0, col=0,
-                  hemi=None, align=True):
+    @fill_doc
+    def show_view(self, view=None, roll=None, distance=None,
+                  row='deprecated', col='deprecated', hemi=None, align=True,
+                  azimuth=None, elevation=None, focalpoint=None):
         """Orient camera to display view.
 
         Parameters
         ----------
-        view : str | dict
-            String view, or a dict with azimuth and elevation.
-        roll : float | None
-            The roll.
-        distance : float | None
-            The distance.
-        row : int
-            The row to set.
-        col : int
-            The column to set.
-        hemi : str
-            Which hemi to use for string lookup (when in "both" mode).
+        %(view)s
+        %(roll)s
+        %(distance)s
+        row : int | None
+            The row to set. Default all rows.
+        col : int | None
+            The column to set. Default all columns.
+        hemi : str | None
+            Which hemi to use for view lookup (when in "both" mode).
         align : bool
             If True, consider view arguments relative to canonical MRI
             directions (closest to MNI for the subject) rather than native MRI
             space. This helps when MRIs are not in standard orientation (e.g.,
             have large rotations).
+        %(azimuth)s
+        %(elevation)s
+        %(focalpoint)s
         """
         hemi = self._hemi if hemi is None else hemi
         if hemi == 'split':
@@ -2685,16 +2690,37 @@ def show_view(self, view=None, roll=None, distance=None, row=0, col=0,
                 hemi = 'rh'
             else:
                 hemi = 'lh'
-        if isinstance(view, str):
-            view = views_dicts[hemi].get(view)
-        view = view.copy()
-        if roll is not None:
-            view.update(roll=roll)
-        if distance is not None:
-            view.update(distance=distance)
-        self._renderer.subplot(row, col)
+        if isinstance(view, dict):  # deprecate at version 0.25
+            warn('`view` is a dict is deprecated, please use `azimuth` and '
+                 '`elevation` as arguments directly to `show_view`',
+                 DeprecationWarning)
+            if azimuth is None and 'azimuth' in view:
+                azimuth = view['azimuth']
+            if elevation is None and 'elevation' in view:
+                elevation = view['elevation']
+            view = None
+        if (row == 'deprecated' or col == 'deprecated') and \
+                len([_ for h in self._hemis for _ in self._iter_views(h)]) > 1:
+            warn('`row` and `col` default behavior is changing, in version '
+                 '0.25 the default behavior will be to apply `show_view` to '
+                 'all views', DeprecationWarning)
+        if row == 'deprecated':
+            row = None
+        if col == 'deprecated':
+            col = None
+        view_params = dict(azimuth=azimuth, elevation=elevation, roll=roll,
+                           distance=distance, focalpoint=focalpoint)
+        if view is not None:  # view string takes precedence
+            view_params = {param: val for param, val in view_params.items()
+                           if val is not None}  # no overwriting with None
+            view_params = dict(views_dicts[hemi].get(view), **view_params)
         xfm = self._rigid if align else None
-        self._renderer.set_camera(**view, reset_camera=False, rigid=xfm)
+        for h in self._hemis:
+            for ri, ci, v in self._iter_views(h):
+                if (row is None or row == ri) and (col is None or col == ci):
+                    self._renderer.subplot(ri, ci)
+                    self._renderer.set_camera(
+                        **view_params, reset_camera=False, rigid=xfm)
         self._renderer._update()
 
     def reset_view(self):
diff --git a/mne/viz/backends/renderer.py b/mne/viz/backends/renderer.py
--- a/mne/viz/backends/renderer.py
+++ b/mne/viz/backends/renderer.py
@@ -14,7 +14,7 @@
 
 from ._utils import VALID_3D_BACKENDS
 from ...utils import (logger, verbose, get_config, _check_option,
-                      _require_version)
+                      _require_version, fill_doc)
 
 MNE_3D_BACKEND = None
 MNE_3D_BACKEND_TESTING = False
@@ -205,6 +205,7 @@ def _use_test_3d_backend(backend_name, interactive=False):
         MNE_3D_BACKEND_TESTING = orig_testing
 
 
+@fill_doc
 def set_3d_view(figure, azimuth=None, elevation=None,
                 focalpoint=None, distance=None, roll=None,
                 reset_camera=True):
@@ -214,16 +215,11 @@ def set_3d_view(figure, azimuth=None, elevation=None,
     ----------
     figure : object
         The scene which is modified.
-    azimuth : float
-        The azimuthal angle of the view.
-    elevation : float
-        The zenith angle of the view.
-    focalpoint : tuple, shape (3,)
-        The focal point of the view: (x, y, z).
-    distance : float
-        The distance to the focal point.
-    roll : float
-        The view roll.
+    %(azimuth)s
+    %(elevation)s
+    %(focalpoint)s
+    %(distance)s
+    %(roll)s
     reset_camera : bool
        If True, reset the camera properties beforehand.
     """


Test patch:
diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py
--- a/mne/viz/_brain/tests/test_brain.py
+++ b/mne/viz/_brain/tests/test_brain.py
@@ -340,16 +340,18 @@ def __init__(self):
     for a, b, p, color in zip(annots, borders, alphas, colors):
         brain.add_annotation(a, b, p, color=color)
 
-    view_args = dict(view=dict(focalpoint=(1e-5, 1e-5, 1e-5)),
-                     roll=1, distance=500)
+    view_args = dict(roll=1, distance=500, focalpoint=(1e-5, 1e-5, 1e-5))
     cam = brain._renderer.figure.plotter.camera
     previous_roll = cam.GetRoll()
     brain.show_view(**view_args)
-    assert np.allclose(cam.GetFocalPoint(), view_args["view"]["focalpoint"])
-    assert np.allclose(cam.GetDistance(), view_args["distance"])
-    assert np.allclose(cam.GetRoll(), previous_roll + view_args["roll"])
+    assert_allclose(cam.GetFocalPoint(), view_args["focalpoint"])
+    assert_allclose(cam.GetDistance(), view_args["distance"])
+    assert_allclose(cam.GetRoll(), previous_roll + view_args["roll"])
     del view_args
 
+    with pytest.warns(DeprecationWarning, match='`view` is a dict'):
+        brain.show_view(view=dict(azimuth=180., elevation=90.))
+
     # image and screenshot
     fname = path.join(str(tmpdir), 'test.png')
     assert not path.isfile(fname)
@@ -358,12 +360,14 @@ def __init__(self):
     fp = np.array(
         brain._renderer.figure.plotter.renderer.ComputeVisiblePropBounds())
     fp = (fp[1::2] + fp[::2]) * 0.5
-    view_args = dict(azimuth=180., elevation=90., focalpoint='auto')
-    brain.show_view(view=view_args)
-    assert np.allclose(brain._renderer.figure._azimuth, view_args["azimuth"])
-    assert np.allclose(
-        brain._renderer.figure._elevation, view_args["elevation"])
-    assert np.allclose(cam.GetFocalPoint(), fp)
+    azimuth, elevation = 180., 90.
+    for view_args in (dict(azimuth=azimuth, elevation=elevation,
+                           focalpoint='auto'),
+                      dict(view='lateral', hemi='lh')):
+        brain.show_view(**view_args)
+        assert_allclose(brain._renderer.figure._azimuth, azimuth)
+        assert_allclose(brain._renderer.figure._elevation, elevation)
+        assert_allclose(cam.GetFocalPoint(), fp)
     del view_args
     img = brain.screenshot(mode='rgba')
     want_size = np.array([size[0] * pixel_ratio, size[1] * pixel_ratio, 4])
@@ -563,7 +567,9 @@ def test_brain_time_viewer(renderer_interactive_pyvista, pixel_ratio,
     # (it keeps the window size and expands the 3D area when the interface
     # is toggled off)
     brain.toggle_interface(value=True)
-    brain.show_view(view=dict(azimuth=180., elevation=90.))
+    with pytest.warns(DeprecationWarning,
+                      match='`row` and `col` default behavior is changing'):
+        brain.show_view(azimuth=180., elevation=90.)
     img = brain.screenshot(mode='rgb')
     want_shape = np.array([300 * pixel_ratio, 300 * pixel_ratio, 3])
     assert_allclose(img.shape, want_shape)