Repository: nilearn/nilearn

Issue:
Allow user to specify view angle explicitly (for surface plotting)
<!--Provide a brief description what you would like changes and why.-->
Allow users to pass (elev, azim) as a tuple or list (or generally, sequence of length 2), instead of only a string. 

<!--Please fill in the following information, to the best of your ability.-->
### Benefits to the change
This will help for visualizing features not captured entirely in any of the limited number of default views. I generate many images, and it is infeasible to manually (interactively) select the appropriate view for each image individually. Rather, I would prefer to do this programatically. 


### Pseudocode for the new behavior, if applicable

```python
view = nilearn.plotting.plot_surf(mesh, map, view=(elev, azim))
```

I will submit a PR to this effect, but would appreciate nilearn maintainer's help in completing it.



Discussion:

Resolving patch:
diff --git a/examples/01_plotting/plot_surf_atlas.py b/examples/01_plotting/plot_surf_atlas.py
--- a/examples/01_plotting/plot_surf_atlas.py
+++ b/examples/01_plotting/plot_surf_atlas.py
@@ -76,6 +76,14 @@
                        darkness=.5)
 plotting.show()
 
+###############################################################################
+# Display Destrieux parcellation with custom view: explicitly set angle
+elev, azim = 210.0, 90.0  # appropriate for visualizing, e.g., the OTS
+plotting.plot_surf_roi(fsaverage['infl_left'], roi_map=parcellation,
+                       hemi='left', view=(elev, azim),
+                       bg_map=fsaverage['sulc_left'], bg_on_data=True,
+                       darkness=.5)
+
 ###############################################################################
 # Display connectome from surface parcellation
 #
diff --git a/nilearn/_utils/docs.py b/nilearn/_utils/docs.py
--- a/nilearn/_utils/docs.py
+++ b/nilearn/_utils/docs.py
@@ -567,10 +567,15 @@ def custom_function(vertices):
 
 # view
 docdict['view'] = """
-view : {'lateral', 'medial', 'dorsal', 'ventral',\
-        'anterior', 'posterior'}, optional
+view : :obj:`str` or a pair of :obj:`float`, optional.
+    If a string, must be in {'lateral', 'medial', 'dorsal', 'ventral',\
+    'anterior', 'posterior'}.
+    If a sequence, must be a pair (elev, azim) of float
+    angles in degrees that will manually set a custom view.
+    E.g., view=[270.0, 90.0] or view=(0.0, -180.0).
     View of the surface that is rendered.
     Default='lateral'.
+
 """
 
 # bg_on_data
diff --git a/nilearn/plotting/surf_plotting.py b/nilearn/plotting/surf_plotting.py
--- a/nilearn/plotting/surf_plotting.py
+++ b/nilearn/plotting/surf_plotting.py
@@ -2,8 +2,11 @@
 Functions for surface visualization.
 """
 import itertools
+import math
 import warnings
 
+from collections.abc import Sequence
+
 import matplotlib as mpl
 import matplotlib.pyplot as plt
 import numpy as np
@@ -35,36 +38,26 @@
 VALID_HEMISPHERES = "left", "right"
 
 
-AXIS_CONFIG = {
-    "showgrid": False,
-    "showline": False,
-    "ticks": "",
-    "title": "",
-    "showticklabels": False,
-    "zeroline": False,
-    "showspikes": False,
-    "spikesides": False,
-    "showbackground": False,
+MATPLOTLIB_VIEWS = {
+    "left": {
+        "lateral": (0, 180),
+        "medial": (0, 0),
+        "dorsal": (90, 0),
+        "ventral": (270, 0),
+        "anterior": (0, 90),
+        "posterior": (0, 270)
+    },
+    "right": {
+        "lateral": (0, 0),
+        "medial": (0, 180),
+        "dorsal": (90, 0),
+        "ventral": (270, 0),
+        "anterior": (0, 90),
+        "posterior": (0, 270)
+    }
 }
 
 
-MATPLOTLIB_VIEWS = {"right": {"lateral": (0, 0),
-                              "medial": (0, 180),
-                              "dorsal": (90, 0),
-                              "ventral": (270, 0),
-                              "anterior": (0, 90),
-                              "posterior": (0, 270)
-                              },
-                    "left": {"medial": (0, 0),
-                             "lateral": (0, 180),
-                             "dorsal": (90, 0),
-                             "ventral": (270, 0),
-                             "anterior": (0, 90),
-                             "posterior": (0, 270)
-                             }
-                    }
-
-
 CAMERAS = {
     "left": {
         "eye": {"x": -1.5, "y": 0, "z": 0},
@@ -78,12 +71,12 @@
     },
     "dorsal": {
         "eye": {"x": 0, "y": 0, "z": 1.5},
-        "up": {"x": 0, "y": 1, "z": 0},
+        "up": {"x": -1, "y": 0, "z": 0},
         "center": {"x": 0, "y": 0, "z": 0},
     },
     "ventral": {
         "eye": {"x": 0, "y": 0, "z": -1.5},
-        "up": {"x": 0, "y": 1, "z": 0},
+        "up": {"x": 1, "y": 0, "z": 0},
         "center": {"x": 0, "y": 0, "z": 0},
     },
     "anterior": {
@@ -99,31 +92,100 @@
 }
 
 
+AXIS_CONFIG = {
+    "showgrid": False,
+    "showline": False,
+    "ticks": "",
+    "title": "",
+    "showticklabels": False,
+    "zeroline": False,
+    "showspikes": False,
+    "spikesides": False,
+    "showbackground": False,
+}
+
+
 LAYOUT = {
-    "scene": {f"{dim}axis": AXIS_CONFIG for dim in ("x", "y", "z")},
+    "scene": {
+        "dragmode": "orbit",
+        **{f"{dim}axis": AXIS_CONFIG for dim in ("x", "y", "z")}
+    },
     "paper_bgcolor": "#fff",
     "hovermode": False,
     "margin": {"l": 0, "r": 0, "b": 0, "t": 0, "pad": 0},
 }
 
 
-def _set_view_plot_surf_plotly(hemi, view):
-    """Helper function for plot_surf with plotly engine.
+def _get_camera_view_from_string_view(hemi, view):
+    """Return plotly camera parameters from string view."""
+    if view == 'lateral':
+        return CAMERAS[hemi]
+    elif view == 'medial':
+        return CAMERAS[
+            VALID_HEMISPHERES[0]
+            if hemi == VALID_HEMISPHERES[1]
+            else VALID_HEMISPHERES[1]
+        ]
+    else:
+        return CAMERAS[view]
+
+
+def _get_camera_view_from_elevation_and_azimut(view):
+    """Compute plotly camera parameters from elevation and azimut."""
+    elev, azim = view
+    # The radius is useful only when using a "perspective" projection,
+    # otherwise, if projection is "orthographic",
+    # one should tweak the "aspectratio" to emulate zoom
+    r = 1.5
+    # The camera position and orientation is set by three 3d vectors,
+    # whose coordinates are independent of the plotted data.
+    return {
+        # Where the camera should look at
+        # (it should always be looking at the center of the scene)
+        "center": {"x": 0, "y": 0, "z": 0},
+        # Where the camera should be located
+        "eye": {
+            "x": (
+                r
+                * math.cos(azim / 360 * 2 * math.pi)
+                * math.cos(elev / 360 * 2 * math.pi)
+            ),
+            "y": (
+                r
+                * math.sin(azim / 360 * 2 * math.pi)
+                * math.cos(elev / 360 * 2 * math.pi)
+            ),
+            "z": r * math.sin(elev / 360 * 2 * math.pi),
+        },
+        # How the camera should be rotated.
+        # It is determined by a 3d vector indicating which direction
+        # should look up in the generated plot
+        "up": {
+            "x": math.sin(elev / 360 * 2 * math.pi) * math.cos(
+                azim / 360 * 2 * math.pi + math.pi
+            ),
+            "y": math.sin(elev / 360 * 2 * math.pi) * math.sin(
+                azim / 360 * 2 * math.pi + math.pi
+            ),
+            "z": math.cos(elev / 360 * 2 * math.pi),
+        },
+        # "projection": {"type": "perspective"},
+        "projection": {"type": "orthographic"},
+    }
+
+
+def _get_view_plot_surf_plotly(hemi, view):
+    """
+    Get camera parameters from hemi and view for the plotly engine.
 
     This function checks the selected hemisphere and view, and
     returns the cameras view.
     """
-    if hemi not in VALID_HEMISPHERES:
-        raise ValueError(f"hemi must be one of {VALID_HEMISPHERES}")
-    if view == 'lateral':
-        view = hemi
-    elif view == 'medial':
-        view = (VALID_HEMISPHERES[0]
-                if hemi == VALID_HEMISPHERES[1]
-                else VALID_HEMISPHERES[1])
-    if view not in CAMERAS:
-        raise ValueError(f"view must be one of {VALID_VIEWS}")
-    return view
+    _check_views([view])
+    _check_hemispheres([hemi])
+    if isinstance(view, str):
+        return _get_camera_view_from_string_view(hemi, view)
+    return _get_camera_view_from_elevation_and_azimut(view)
 
 
 def _configure_title_plotly(title, font_size, color="black"):
@@ -249,9 +311,9 @@ def _plot_surf_plotly(coords, faces, surf_map=None, bg_map=None,
         fig_data.append(dummy)
 
     # instantiate plotly figure
-    cameras_view = _set_view_plot_surf_plotly(hemi, view)
+    camera_view = _get_view_plot_surf_plotly(hemi, view)
     fig = go.Figure(data=fig_data)
-    fig.update_layout(scene_camera=CAMERAS[cameras_view],
+    fig.update_layout(scene_camera=camera_view,
                       title=_configure_title_plotly(title, title_font_size),
                       **LAYOUT)
 
@@ -270,17 +332,17 @@ def _plot_surf_plotly(coords, faces, surf_map=None, bg_map=None,
     return plotly_figure
 
 
-def _set_view_plot_surf_matplotlib(hemi, view):
+def _get_view_plot_surf_matplotlib(hemi, view):
     """Helper function for plot_surf with matplotlib engine.
 
     This function checks the selected hemisphere and view, and
     returns elev and azim.
     """
-    if hemi not in VALID_HEMISPHERES:
-        raise ValueError(f"hemi must be one of {VALID_HEMISPHERES}")
-    if view not in MATPLOTLIB_VIEWS[hemi]:
-        raise ValueError(f"view must be one of {VALID_VIEWS}")
-    return MATPLOTLIB_VIEWS[hemi][view]
+    _check_views([view])
+    _check_hemispheres([hemi])
+    if isinstance(view, str):
+        return MATPLOTLIB_VIEWS[hemi][view]
+    return view
 
 
 def _check_surf_map(surf_map, n_vertices):
@@ -470,8 +532,8 @@ def _plot_surf_matplotlib(coords, faces, surf_map=None, bg_map=None,
     _default_figsize = [4, 4]
     limits = [coords.min(), coords.max()]
 
-    # set view
-    elev, azim = _set_view_plot_surf_matplotlib(hemi, view)
+    # Get elevation and azimut from view
+    elev, azim = _get_view_plot_surf_matplotlib(hemi, view)
 
     # if no cmap is given, set to matplotlib default
     if cmap is None:
@@ -1082,6 +1144,10 @@ def plot_surf_stat_map(surf_mesh, stat_map, bg_map=None,
     return display
 
 
+def _check_hemisphere_is_valid(hemi):
+    return hemi in VALID_HEMISPHERES
+
+
 def _check_hemispheres(hemispheres):
     """Checks whether the hemispheres passed to in plot_img_on_surf are
     correct.
@@ -1090,26 +1156,64 @@ def _check_hemispheres(hemispheres):
         Any combination of 'left' and 'right'.
 
     """
-    invalid_hemi = any([hemi not in VALID_HEMISPHERES for hemi in hemispheres])
-    if invalid_hemi:
-        supported = "Supported hemispheres:\n" + str(VALID_HEMISPHERES)
-        raise ValueError("Invalid hemispheres definition!\n" + supported)
+    invalid_hemis = [
+        not _check_hemisphere_is_valid(hemi) for hemi in hemispheres
+    ]
+    if any(invalid_hemis):
+        raise ValueError(
+            "Invalid hemispheres definition!\n"
+            f"Got: {str(np.array(hemispheres)[invalid_hemis])}\n"
+            f"Supported values are: {str(VALID_HEMISPHERES)}"
+        )
     return hemispheres
 
 
+def _check_view_is_valid(view) -> bool:
+    """Checks whether a single view is one of two
+    valid input types.
+
+    Parameters
+    ----------
+    view: string in {"anterior", "posterior", "medial", "lateral",
+        "dorsal", "ventral" or pair of floats (elev, azim).
+
+    Returns
+    -------
+    valid: True if view is valid, False otherwise.
+    """
+    if isinstance(view, str) and (view in VALID_VIEWS):
+        return True
+    if isinstance(view, Sequence) and len(view) == 2:
+        return True
+    return False
+
+
 def _check_views(views) -> list:
     """Checks whether the views passed to in plot_img_on_surf are
     correct.
 
+    Parameters
+    ----------
     views : list
-        Any combination of "anterior", "posterior", "medial", "lateral",
-        "dorsal", "ventral".
+        Any combination of strings in {"anterior", "posterior", "medial",
+        "lateral", "dorsal", "ventral"} and / or pair of floats (elev, azim).
 
+    Returns
+    -------
+    views: list
+        Views given as inputs.
     """
-    invalid_view = any([view not in VALID_VIEWS for view in views])
-    if invalid_view:
-        supported = "Supported views:\n" + str(VALID_VIEWS)
-        raise ValueError("Invalid view definition!\n" + supported)
+    invalid_views = [not _check_view_is_valid(view) for view in views]
+
+    if any(invalid_views):
+        raise ValueError(
+            "Invalid view definition!\n"
+            f"Got: {str(np.array(views)[invalid_views])}\n"
+            f"Supported values are: {str(VALID_VIEWS)}"
+            " or a sequence of length 2"
+            " setting the elevation and azimut of the camera."
+        )
+
     return views
 
 


Test patch:
diff --git a/nilearn/plotting/tests/test_surf_plotting.py b/nilearn/plotting/tests/test_surf_plotting.py
--- a/nilearn/plotting/tests/test_surf_plotting.py
+++ b/nilearn/plotting/tests/test_surf_plotting.py
@@ -10,11 +10,17 @@
 import unittest.mock as mock
 
 from nilearn.plotting.img_plotting import MNI152TEMPLATE
-from nilearn.plotting.surf_plotting import (plot_surf, plot_surf_stat_map,
-                                            plot_surf_roi, plot_img_on_surf,
-                                            plot_surf_contours,
-                                            _get_ticks_matplotlib,
-                                            _compute_facecolors_matplotlib)
+from nilearn.plotting.surf_plotting import (
+    plot_surf,
+    plot_surf_stat_map,
+    plot_surf_roi,
+    plot_img_on_surf,
+    plot_surf_contours,
+    _get_view_plot_surf_matplotlib,
+    _get_view_plot_surf_plotly,
+    _get_ticks_matplotlib,
+    _compute_facecolors_matplotlib
+)
 from nilearn.datasets import fetch_surf_fsaverage
 from nilearn.surface import load_surf_data, load_surf_mesh
 from nilearn.surface.testing_utils import generate_surf
@@ -43,18 +49,139 @@
 else:
     IPYTHON_INSTALLED = True
 
-EXPECTED_CAMERAS_PLOTLY = {"left": {"anterior": "anterior",
-                                    "posterior": "posterior",
-                                    "medial": "right",
-                                    "lateral": "left",
-                                    "dorsal": "dorsal",
-                                    "ventral": "ventral"},
-                           "right": {"anterior": "anterior",
-                                     "posterior": "posterior",
-                                     "medial": "left",
-                                     "lateral": "right",
-                                     "dorsal": "dorsal",
-                                     "ventral": "ventral"}}
+
+EXPECTED_CAMERAS_PLOTLY = [
+    (
+        "left",
+        "lateral",
+        (0, 180),
+        {
+            "eye": {"x": -1.5, "y": 0, "z": 0},
+            "up": {"x": 0, "y": 0, "z": 1},
+            "center": {"x": 0, "y": 0, "z": 0},
+        },
+    ),
+    (
+        "left",
+        "medial",
+        (0, 0),
+        {
+            "eye": {"x": 1.5, "y": 0, "z": 0},
+            "up": {"x": 0, "y": 0, "z": 1},
+            "center": {"x": 0, "y": 0, "z": 0},
+        },
+    ),
+    # Dorsal left
+    (
+        "left",
+        "dorsal",
+        (90, 0),
+        {
+            "eye": {"x": 0, "y": 0, "z": 1.5},
+            "up": {"x": -1, "y": 0, "z": 0},
+            "center": {"x": 0, "y": 0, "z": 0},
+        },
+    ),
+    # Ventral left
+    (
+        "left",
+        "ventral",
+        (270, 0),
+        {
+            "eye": {"x": 0, "y": 0, "z": -1.5},
+            "up": {"x": 1, "y": 0, "z": 0},
+            "center": {"x": 0, "y": 0, "z": 0},
+        },
+    ),
+    # Anterior left
+    (
+        "left",
+        "anterior",
+        (0, 90),
+        {
+            "eye": {"x": 0, "y": 1.5, "z": 0},
+            "up": {"x": 0, "y": 0, "z": 1},
+            "center": {"x": 0, "y": 0, "z": 0},
+        },
+    ),
+    # Posterior left
+    (
+        "left",
+        "posterior",
+        (0, 270),
+        {
+            "eye": {"x": 0, "y": -1.5, "z": 0},
+            "up": {"x": 0, "y": 0, "z": 1},
+            "center": {"x": 0, "y": 0, "z": 0},
+        },
+    ),
+    # Lateral right
+    (
+        "right",
+        "lateral",
+        (0, 0),
+        {
+            "eye": {"x": 1.5, "y": 0, "z": 0},
+            "up": {"x": 0, "y": 0, "z": 1},
+            "center": {"x": 0, "y": 0, "z": 0},
+        },
+    ),
+    # Medial right
+    (
+        "right",
+        "medial",
+        (0, 180),
+        {
+            "eye": {"x": -1.5, "y": 0, "z": 0},
+            "up": {"x": 0, "y": 0, "z": 1},
+            "center": {"x": 0, "y": 0, "z": 0},
+        },
+    ),
+    # Dorsal right
+    (
+        "right",
+        "dorsal",
+        (90, 0),
+        {
+            "eye": {"x": 0, "y": 0, "z": 1.5},
+            "up": {"x": -1, "y": 0, "z": 0},
+            "center": {"x": 0, "y": 0, "z": 0},
+        },
+    ),
+    # Ventral right
+    (
+        "right",
+        "ventral",
+        (270, 0),
+        {
+            "eye": {"x": 0, "y": 0, "z": -1.5},
+            "up": {"x": 1, "y": 0, "z": 0},
+            "center": {"x": 0, "y": 0, "z": 0},
+        },
+    ),
+    # Anterior right
+    (
+        "right",
+        "anterior",
+        (0, 90),
+        {
+            "eye": {"x": 0, "y": 1.5, "z": 0},
+            "up": {"x": 0, "y": 0, "z": 1},
+            "center": {"x": 0, "y": 0, "z": 0},
+        },
+    ),
+    # Posterior right
+    (
+        "right",
+        "posterior",
+        (0, 270),
+        {
+            "eye": {"x": 0, "y": -1.5, "z": 0},
+            "up": {"x": 0, "y": 0, "z": 1},
+            "center": {"x": 0, "y": 0, "z": 0},
+        },
+    ),
+]
 
 
 EXPECTED_VIEW_MATPLOTLIB = {"left": {"anterior": (0, 90),
@@ -71,16 +198,37 @@
                                       "ventral": (270, 0)}}
 
 
-@pytest.fixture
-def expected_cameras_plotly(hemi, view):
-    return EXPECTED_CAMERAS_PLOTLY[hemi][view]
-
 
-@pytest.mark.parametrize("hemi", VALID_HEMISPHERES)
-@pytest.mark.parametrize("view", VALID_VIEWS)
-def test_set_view_plot_surf_plotly(hemi, view, expected_cameras_plotly):
-    from nilearn.plotting.surf_plotting import _set_view_plot_surf_plotly
-    assert _set_view_plot_surf_plotly(hemi, view) == expected_cameras_plotly
+@pytest.mark.parametrize("full_view", EXPECTED_CAMERAS_PLOTLY)
+def test_get_view_plot_surf_plotly(full_view):
+    from nilearn.plotting.surf_plotting import (
+        _get_camera_view_from_elevation_and_azimut,
+        _get_camera_view_from_string_view,
+        _get_view_plot_surf_plotly,
+    )
+    hemi, view_name, (elev, azim), expected_camera_view = full_view
+    camera_view = _get_view_plot_surf_plotly(hemi, view_name)
+    camera_view_string = _get_camera_view_from_string_view(hemi, view_name)
+    camera_view_elev_azim = _get_camera_view_from_elevation_and_azimut(
+        (elev, azim)
+    )
+    # Check each camera view parameter
+    for k in ["center", "eye", "up"]:
+        # Check default camera view
+        assert np.allclose(
+            list(camera_view[k].values()),
+            list(expected_camera_view[k].values())
+        )
+        # Check camera view obtained from string view
+        assert np.allclose(
+            list(camera_view_string[k].values()),
+            list(expected_camera_view[k].values())
+        )
+        # Check camera view obtained from elevation & azimut
+        assert np.allclose(
+            list(camera_view_elev_azim[k].values()),
+            list(expected_camera_view[k].values())
+        )
 
 
 @pytest.fixture
@@ -90,9 +238,9 @@ def expected_view_matplotlib(hemi, view):
 
 @pytest.mark.parametrize("hemi", VALID_HEMISPHERES)
 @pytest.mark.parametrize("view", VALID_VIEWS)
-def test_set_view_plot_surf_matplotlib(hemi, view, expected_view_matplotlib):
-    from nilearn.plotting.surf_plotting import _set_view_plot_surf_matplotlib
-    assert(_set_view_plot_surf_matplotlib(hemi, view)
+def test_get_view_plot_surf_matplotlib(hemi, view, expected_view_matplotlib):
+    from nilearn.plotting.surf_plotting import _get_view_plot_surf_matplotlib
+    assert(_get_view_plot_surf_matplotlib(hemi, view)
            == expected_view_matplotlib)
 
 
@@ -181,19 +329,58 @@ def test_instantiation_error_plotly_surface_figure(input_obj):
         PlotlySurfaceFigure(input_obj)
 
 
-def test_set_view_plot_surf_errors():
-    from nilearn.plotting.surf_plotting import (_set_view_plot_surf_matplotlib,
-                                                _set_view_plot_surf_plotly)
+
+@pytest.mark.parametrize(
+    "view,is_valid",
+    [
+        ("lateral", True),
+        ("medial", True),
+        ("latreal", False),
+        ((100, 100), True),
+        ((100, 100, 1), False),
+    ]
+)
+def test_check_view_is_valid(view, is_valid):
+    from nilearn.plotting.surf_plotting import _check_view_is_valid
+    assert _check_view_is_valid(view) is is_valid
+
+
+@pytest.mark.parametrize(
+    "hemi,is_valid",
+    [
+        ("left", True),
+        ("right", True),
+        ("lft", False),
+    ]
+)
+def test_check_hemisphere_is_valid(hemi, is_valid):
+    from nilearn.plotting.surf_plotting import _check_hemisphere_is_valid
+    assert _check_hemisphere_is_valid(hemi) is is_valid
+
+
+@pytest.mark.parametrize("hemi,view", [("foo", "medial"), ("bar", "anterior")])
+def test_get_view_plot_surf_hemisphere_errors(hemi, view):
+    from nilearn.plotting.surf_plotting import (_get_view_plot_surf_matplotlib,
+                                                _get_view_plot_surf_plotly)
+    with pytest.raises(ValueError,
+                       match="Invalid hemispheres definition"):
+        _get_view_plot_surf_matplotlib(hemi, view)
     with pytest.raises(ValueError,
-                       match="hemi must be one of"):
-        _set_view_plot_surf_matplotlib("foo", "medial")
-        _set_view_plot_surf_plotly("bar", "anterior")
+                       match="Invalid hemispheres definition"):
+        _get_view_plot_surf_plotly(hemi, view)
+
+
+@pytest.mark.parametrize(
+    "hemi,view,f",
+    [
+        ("left", "foo", _get_view_plot_surf_matplotlib),
+        ("right", "bar", _get_view_plot_surf_plotly),
+    ]
+)
+def test_get_view_plot_surf_view_errors(hemi, view, f):
     with pytest.raises(ValueError,
-                       match="view must be one of"):
-        _set_view_plot_surf_matplotlib("left", "foo")
-        _set_view_plot_surf_matplotlib("right", "bar")
-        _set_view_plot_surf_plotly("left", "foo")
-        _set_view_plot_surf_plotly("right", "bar")
+                       match="Invalid view definition"):
+        f(hemi, view)
 
 
 def test_configure_title_plotly():
@@ -316,9 +503,9 @@ def test_plot_surf_error(engine):
     rng = np.random.RandomState(42)
 
     # Wrong inputs for view or hemi
-    with pytest.raises(ValueError, match='view must be one of'):
+    with pytest.raises(ValueError, match='Invalid view definition'):
         plot_surf(mesh, view='middle', engine=engine)
-    with pytest.raises(ValueError, match='hemi must be one of'):
+    with pytest.raises(ValueError, match='Invalid hemispheres definition'):
         plot_surf(mesh, hemi='lft', engine=engine)
 
     # Wrong size of background image
@@ -647,6 +834,11 @@ def test_plot_img_on_surf_hemispheres_and_orientations():
     plot_img_on_surf(nii,
                      hemispheres=['left', 'right'],
                      views=['dorsal', 'medial'])
+    # Check that manually set view angles work.
+    plot_img_on_surf(nii,
+                     hemispheres=['left', 'right'],
+                     views=[(210.0, 90.0), (15.0, -45.0)])
+
 
 
 def test_plot_img_on_surf_colorbar():