Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changes/dev/14308.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix several problems with flat surfaces in :class:`mne.viz.Brain`: 1. They could rotate incorrectly with the arrow keys or :meth:`mne.viz.Brain.show_view`, 2. lose their 2D interaction mode when ``interaction`` was enabled, 3. orientation/silhouette options either did nothing or caused errors 4. triangle adjacency was calculated incorrectly, by `Payam Sadeghi-Shabestari`_.
1 change: 1 addition & 0 deletions doc/changes/dev/14308.newfeature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:class:`mne.viz.Brain` now offers ``'flat'`` surf option for subjects that have the required patch files, by `Payam Sadeghi-Shabestari`_.
14 changes: 13 additions & 1 deletion mne/viz/_3d_overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ def _clean(self):
self._polydata = None
self._renderer = None

def update_geometry(self, vertices, normals):
def update_geometry(self, vertices, normals, triangles=None):
"""Update the mesh's vertex positions and normals in place.

Parameters
Expand All @@ -285,12 +285,24 @@ def update_geometry(self, vertices, normals):
New vertex coordinates. Must match the existing vertex count.
normals : array, shape (n_vertices, 3)
New vertex normals.
triangles : array, shape (n_triangles, 3) | None
New triangulation. Only needed when the new geometry is not just a
displacement of the old one, e.g. a flat patch of the cortex, whose
triangles outside the patch are dropped. The vertex count must stay
the same either way, so the overlays keep their scalars.

.. versionadded:: 1.13
"""
self._vertices = vertices
self._normals = normals
self._polydata.points = vertices
self._polydata.point_data["Normals"] = normals
self._polydata.GetPointData().SetActiveNormals("Normals")
if triangles is not None:
self._triangles = triangles
self._polydata.faces = np.hstack(
[np.full((len(triangles), 1), 3), triangles]
).ravel()

def update_overlay(
self, name, scalars=None, colormap=None, opacity=None, rng=None, update=True
Expand Down
164 changes: 142 additions & 22 deletions mne/viz/_brain/_brain.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,11 @@ class Brain:
In the case of 'split' hemispheres are displayed side-by-side
in different viewing panes.
surf : str
FreeSurfer surface mesh name (ie 'white', 'inflated', etc.).
FreeSurfer surface mesh name (ie 'white', 'inflated', etc.). Can also be
``'flat'`` to show a flat patch of the cortex, which requires the
``?h.cortex.patch.flat`` and ``?h.sphere`` files to be present in the
subject's ``surf`` directory. A flat surface is always shown with
``views='flat'`` and is not rotatable.
title : str
Title for the window.
cortex : str, list, dict
Expand Down Expand Up @@ -410,6 +414,8 @@ def __init__(
self.silhouette = True
else:
self.silhouette = silhouette
if self.silhouette and surf == "flat":
raise ValueError('silhouette is not supported for surf="flat"')
self._silhouette_actors = []
self._scalar_bar = None
self._scalar_bar_ticks = None
Expand Down Expand Up @@ -502,6 +508,7 @@ def __init__(

if surf == "flat":
self._renderer.set_interaction("rubber_band_2d")
self._fit_flat_camera()

self._renderer._update()

Expand Down Expand Up @@ -874,13 +881,35 @@ def set_orientation(value, orientation_data=orientation_data):
layout=layout,
)

def _has_flatmaps(self):
"""Whether this subject has the patch files a flat surface needs."""
return all(
op.isfile(
op.join(
self._subjects_dir, self._subject, "surf", f"{h}.cortex.patch.flat"
)
)
for h in self._hemis
if h != "vol"
)

def _update_flat_widgets(self):
"""Grey out the dock controls that cannot act on a flat patch."""
enabled = self._surf != "flat"
for key in ("orientation", "silhouette"):
if key in self.widgets:
self.widgets[key].set_enabled(enabled)

def _configure_dock_surface_widget(self, name):
layout = self._renderer._dock_add_group_box(name, collapse=True)
if self._surf in ("pial", "white", "inflated"):
surfs = ["pial", "white", "inflated"]
if self._has_flatmaps():
surfs.append("flat")
if self._surf in surfs:
self.widgets["surf"] = self._renderer._dock_add_combo_box(
name="Surf",
value=self._surf,
rng=("pial", "white", "inflated"),
rng=surfs,
callback=self.set_surf,
layout=layout,
)
Expand All @@ -906,6 +935,9 @@ def _configure_dock_surface_widget(self, name):
callback=self.set_silhouette_line_width,
layout=layout,
)
# controls that cannot act on a flat patch are greyed out rather than
# dropped, since the surface can be switched back and forth live
self._update_flat_widgets()

def _configure_dock_colormap_widget(self, name):
self._active_data_key = next(iter(self._all_data))
Expand Down Expand Up @@ -1457,14 +1489,23 @@ def _configure_shortcuts(self):
self.plotter.add_key_event("r", self.restore_user_scaling)
self.plotter.add_key_event("c", self.clear_glyphs)
self.plotter.add_key_event("v", self._toggle_hover_info)
self._configure_arrow_keys()

def _configure_arrow_keys(self):
"""(Re)bind the arrow keys, which cannot rotate a flat patch."""
if getattr(self.plotter, "iren", None) is None:
return
for key, which, amt in (
("Left", "azimuth", 10),
("Right", "azimuth", -10),
("Up", "elevation", 10),
("Down", "elevation", -10),
):
# always clear, so PyVista's own bindings cannot rotate a flat map
self.plotter.clear_events_for_key(key)
self.plotter.add_key_event(key, partial(self._rotate_camera, which, amt))
if self._surf != "flat":
func = partial(self._rotate_camera, which, amt)
self.plotter.add_key_event(key, func)

def _configure_status_bar(self):
self._renderer._status_bar_initialize()
Expand Down Expand Up @@ -1667,6 +1708,7 @@ def _remove_label_glyph(self, hemi, label_id):
self.color_cycle.restore(label._color)
self.mpl_canvas.update_plot()
self.layered_meshes[hemi].remove_overlay(label.name)
self._renderer._update() # mirrors add_label; see _add_vertex_glyph

def _add_vertex_glyph(self, hemi, mesh, vertex_id, update=True):
_ensure_int(vertex_id)
Expand Down Expand Up @@ -1735,6 +1777,8 @@ def _add_vertex_glyph(self, hemi, mesh, vertex_id, update=True):

_ensure_int(vertex_id)
self._picked_points[(hemi, vertex_id)] = spheres
if update:
self._renderer._update()
return sphere

def _remove_vertex_glyph(self, *, hemi, vertex_id, render=True):
Expand Down Expand Up @@ -1942,16 +1986,30 @@ def _configure_help(self):
("n", "Shift the time forward by the playback speed"),
("b", "Shift the time backward by the playback speed"),
("Space", "Start/Pause playback"),
("Up", "Decrease camera elevation angle"),
("Down", "Increase camera elevation angle"),
("Left", "Decrease camera azimuth angle"),
("Right", "Increase camera azimuth angle"),
]
mouse_pairs = [
("Left-click-and-drag", "Rotate the view"),
("Middle-click-and-drag", "Pan the view"),
("Right-click-and-drag / scroll", "Zoom the view"),
]
if self._surf == "flat":
# a flat map is 2D: the arrow keys are not bound and the camera
# uses the rubber-band style rather than rotation
mouse_pairs = [
("Middle-click-and-drag", "Pan the view"),
("Right-click-and-drag / scroll", "Zoom the view"),
]
else:
pairs += [
("Up", "Decrease camera elevation angle"),
("Down", "Increase camera elevation angle"),
("Left", "Decrease camera azimuth angle"),
("Right", "Increase camera azimuth angle"),
]
mouse_pairs = [
("Left-click-and-drag", "Rotate the view"),
("Middle-click-and-drag", "Pan the view"),
("Right-click-and-drag / scroll", "Zoom the view"),
]
if self.help_canvas is not None: # rebuilt when the bindings change
close = getattr(self.help_canvas, "close", None)
if close is not None:
close()
self.help_canvas = self._renderer._window_get_help_canvas(pairs, mouse_pairs)

def help(self):
Expand Down Expand Up @@ -1981,7 +2039,18 @@ def interaction(self):
def interaction(self, interaction):
"""Set the interaction style."""
_validate_type(interaction, str, "interaction")
_check_option("interaction", interaction, ("trackball", "terrain"))
if self._surf == "flat":
# a flat map is 2D: the rubber-band style is the only one that makes
# sense, and a 3D style would leave it rotatable with no way back
if interaction != self._interaction:
warn(
f'interaction="{interaction}" is ignored for surf="flat", '
"which is always shown in 2D"
)
interaction = "rubber_band_2d"
else:
_check_option("interaction", interaction, ("trackball", "terrain"))
self._interaction = interaction
for _ in self._iter_views("vol"): # will traverse all
self._renderer.set_interaction(interaction)

Expand Down Expand Up @@ -2727,7 +2796,9 @@ def add_label(
scalars = np.zeros(self.geo[hemi].coords.shape[0])
scalars[ids] = 1
if borders:
keep_idx = _mesh_borders(self.geo[hemi].faces, scalars)
# orig_faces, not faces: a flat patch drops the triangles outside
# it, and adjacency cannot be computed from a subset of them
keep_idx = _mesh_borders(self.geo[hemi].orig_faces, scalars)
show = np.zeros(scalars.size, dtype=np.int64)
if isinstance(borders, int):
for _ in range(borders):
Expand Down Expand Up @@ -3751,6 +3822,11 @@ def show_view(
focalpoint=focalpoint,
)
if view is not None: # view_params take precedence
if self._surf == "flat" and view != "flat":
# every views dict holds all the 3D view names, so without this
# a flat map would happily be rotated edge-on
warn(f'view="{view}" is ignored for surf="flat"')
return
view_params = {
param: val for param, val in view_params.items() if val is not None
} # no overwriting with None
Expand Down Expand Up @@ -3787,8 +3863,16 @@ def reset_view(self):
for h in self._hemis:
for _, _, v in self._iter_views(h):
self._set_camera(**views_dicts[h][v])
self._fit_flat_camera()
self._renderer._update()

def _fit_flat_camera(self):
"""Frame a flat patch, which the camera distance cannot do."""
if self._surf != "flat":
return
for renderer in self._renderer._all_renderers:
renderer.reset_camera()

def save_image(self, filename=None, mode="rgb"):
"""Save view from all panels to disk.

Expand Down Expand Up @@ -3916,11 +4000,18 @@ def set_surf(self, surf):
Parameters
----------
surf : str
One of ``'pial'``, ``'white'``, ``'inflated'``. To use a flat
surface, close this figure and construct a new one with
``Brain(..., surf="flat", views="flat")``.
One of ``'pial'``, ``'white'``, ``'inflated'``, or ``'flat'``.
``'flat'`` needs the ``?h.cortex.patch.flat`` and ``?h.sphere``
files in the subject's ``surf`` directory, and switches the view to
a non-rotatable 2D one for as long as it is shown.
"""
_check_option("surf", surf, ("pial", "white", "inflated"))
surfs = ("pial", "white", "inflated", "flat")
_check_option("surf", surf, surfs)
if surf == "flat" and not self._has_flatmaps():
raise FileNotFoundError(
f"Subject {self._subject} has no flatmap surface file "
f"({{hemi}}.cortex.patch.flat) in {self._subjects_dir}"
)
if surf == self._surf:
return
if any(self._labels[h] for h in self._hemis) or any(
Expand All @@ -3930,6 +4021,7 @@ def set_surf(self, surf):
"Foci and label/annotation outlines do not move when the "
"surface representation changes and may now be misaligned."
)
flat_change = "flat" in (surf, self._surf)
offset = _resolve_offset(self._offset_request, surf, self._hemi)
for h in self._hemis:
geo = _Surface(
Expand All @@ -3944,9 +4036,14 @@ def set_surf(self, surf):
geo.load_geometry()
geo.load_curvature()
self.geo[h] = geo
self.layered_meshes[h].update_geometry(geo.coords, geo.nn)
self.layered_meshes[h].update_geometry(
geo.coords, geo.nn, geo.faces if flat_change else None
)

for (pt_hemi, vertex_id), spheres in self._picked_points.items():
# picked points only exist once the time viewer has been set up
for (pt_hemi, vertex_id), spheres in getattr(
self, "_picked_points", {}
).items():
if pt_hemi != h:
continue
center = np.array(geo.coords[vertex_id])
Expand All @@ -3964,12 +4061,33 @@ def set_surf(self, surf):
vertices = slice(None) if vertices is None else vertices
glyph_dataset.points = np.array(geo.coords)[vertices]
self._surf = surf
if flat_change: # switch the camera, interaction and controls to 2D/3D
if surf == "flat":
self._pre_flat = (list(self._views), self._interaction)
self._views = ["flat"] * len(self._views)
if self.silhouette: # would look for a nonexistent {hemi}.flat
for actor in self._silhouette_actors:
self.plotter.remove_actor(actor)
self._silhouette_actors = []
self.silhouette = False
interaction = "rubber_band_2d"
else:
self._views, interaction = getattr(self, "_pre_flat", None) or (
["lateral"] * len(self._views),
"trackball",
)
self._interaction = interaction
for _ in self._iter_views("vol"): # will traverse all
self._renderer.set_interaction(interaction)
self._configure_arrow_keys()
if self.time_viewer:
self._update_flat_widgets()
self._configure_help()
if self.silhouette:
for actor in self._silhouette_actors:
self.plotter.remove_actor(actor)
self._add_silhouette()
self.reset_view()
self._renderer._update()

def _add_silhouette(self):
self._silhouette_actors = []
Expand Down Expand Up @@ -4009,6 +4127,8 @@ def set_silhouette_line_width(self, line_width):
The silhouette line width. A value of ``0`` hides the
silhouette entirely.
"""
if self._surf == "flat":
raise ValueError('silhouette is not supported for surf="flat"')
line_width = float(line_width)
self._silhouette["line_width"] = line_width
if line_width <= 0:
Expand Down
Loading
Loading