From 9aab0eaef2e53a02e4a6f50a07e78f06a53d0063 Mon Sep 17 00:00:00 2001 From: payam Date: Mon, 14 Sep 2026 12:34:34 +0200 Subject: [PATCH 1/4] GUI should support flat surf now --- mne/viz/_3d_overlay.py | 14 +++- mne/viz/_brain/_brain.py | 161 +++++++++++++++++++++++++++++++++------ 2 files changed, 152 insertions(+), 23 deletions(-) diff --git a/mne/viz/_3d_overlay.py b/mne/viz/_3d_overlay.py index 3634d9104fc..0983ffef912 100644 --- a/mne/viz/_3d_overlay.py +++ b/mne/viz/_3d_overlay.py @@ -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 @@ -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 diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index 926e38e9c4d..6f664c4d71a 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -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 @@ -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 @@ -502,6 +508,7 @@ def __init__( if surf == "flat": self._renderer.set_interaction("rubber_band_2d") + self._fit_flat_camera() self._renderer._update() @@ -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, ) @@ -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)) @@ -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() @@ -1942,16 +1983,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): @@ -1981,7 +2036,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) @@ -2727,7 +2793,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): @@ -3751,6 +3819,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 @@ -3787,8 +3860,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. @@ -3916,11 +3997,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( @@ -3930,6 +4018,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( @@ -3944,9 +4033,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]) @@ -3964,12 +4058,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 = [] @@ -4009,6 +4124,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: From e128924ea1fc253d207609a79e8e87a25597f17b Mon Sep 17 00:00:00 2001 From: payam Date: Mon, 14 Sep 2026 12:35:10 +0200 Subject: [PATCH 2/4] tests for flat --- mne/viz/_brain/tests/test_brain.py | 89 ++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index 970596ad305..d34c3a25a40 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -652,6 +652,95 @@ def test_surface_controls(renderer_interactive_pyvistaqt, brain_gc): brain.close() +@testing.requires_testing_data +def test_brain_flat(renderer_interactive_pyvistaqt, brain_gc): + """Test flat surfaces: switching to and from them, and the 2D-only GUI.""" + # "sample" has no patch files, but fsaverage ships the ones a flat map needs + kwargs = dict(hemi="lh", subjects_dir=subjects_dir, size=300) + with pytest.raises(ValueError, match='silhouette is not supported for surf="flat"'): + Brain("fsaverage", surf="flat", silhouette=True, **kwargs) + + # start from a 3D surface: switching *to* flat is what has to reframe the + # camera and retriangulate, so the checks below have to run after a switch + brain = Brain("fsaverage", surf="inflated", **kwargs) + n_vertices = len(brain.geo["lh"].coords) + brain.add_data( + np.zeros((n_vertices, 3)), + vertices=np.arange(n_vertices), + hemi="lh", + colormap="hot", + fmin=0, + fmax=1, + time=[0, 1, 2], + smoothing_steps=1, + ) + brain.setup_time_viewer(show_traces="label") + mesh = brain.layered_meshes["lh"] + n_faces_3d = mesh._polydata.n_cells + coords_3d = brain.geo["lh"].coords.copy() + + # a flat patch drops the triangles outside it, but keeps every vertex, so + # the overlays stay valid + brain.set_surf("flat") + assert brain._surf == "flat" + assert mesh._polydata.n_points == n_vertices + assert mesh._polydata.n_cells < n_faces_3d + assert_allclose(np.ptp(brain.geo["lh"].coords[:, 2]), 0, atol=1e-6) # planar + assert mesh._vertices is brain.geo["lh"].coords + assert brain.views == ["flat"] + n_faces_flat = mesh._polydata.n_cells + + # NB the camera reframing that switching to flat does (see + # Brain._fit_flat_camera) is deliberately not asserted here: the offscreen + # camera never leaves its default state under this fixture, so any check of + # it passes whether or not the reframing happened + + # a flat map is 2D, so it gets the rubber-band style rather than a 3D one + assert brain.interaction == "rubber_band_2d" + with pytest.warns(RuntimeWarning, match='ignored for surf="flat"'): + brain.interaction = "trackball" + assert brain.interaction == "rubber_band_2d" + + # every views dict holds all the 3D view names, so a flat map could + # otherwise be rotated edge-on + camera = brain._renderer.get_camera() + with pytest.warns(RuntimeWarning, match='view="lateral" is ignored'): + brain.show_view("lateral") + assert_allclose(brain._renderer.get_camera()[2], camera[2], atol=1e-6) # azimuth + brain.show_view("flat") # the one valid view still works + brain.reset_view() + + # controls that cannot act on a flat patch are greyed out, not dropped, + # because the surface can be switched back and forth live + assert not brain.widgets["orientation"].is_enabled() + assert not brain.widgets["silhouette"].is_enabled() + with pytest.raises(ValueError, match='silhouette is not supported for surf="flat"'): + brain.set_silhouette_line_width(3.0) + + # picking a parcel must not compute adjacency from the patch-restricted + # triangles, which are only a subset of the full surface + ui_events.publish(brain, ui_events.VertexSelect(hemi="lh", vertex_id=10000)) + assert len(brain._picked_patches["lh"]) == 1 + ui_events.publish(brain, ui_events.VertexSelect(hemi="lh", vertex_id=10000)) + assert len(brain._picked_patches["lh"]) == 0 + + # switching back to 3D restores the triangulation, geometry and controls + # (it warns because the annotation picked above does not move with it) + with pytest.warns(RuntimeWarning, match="Foci and label"): + brain.set_surf("inflated") + assert mesh._polydata.n_cells == n_faces_3d + assert mesh._polydata.n_cells > n_faces_flat + assert_allclose(brain.geo["lh"].coords, coords_3d, atol=0) + assert np.ptp(coords_3d[:, 2]) > 1 # was not planar to begin with + assert brain.views == ["lateral"] + assert brain.interaction == "trackball" + assert brain.widgets["orientation"].is_enabled() + assert brain.widgets["silhouette"].is_enabled() + brain.show_view("medial") # no longer ignored + + brain.close() + + def test_add_annotation(renderer_interactive_pyvistaqt, brain_gc, qtbot): """Test add_annotation.""" annots = [ From 834e78df04dea7f46c68d547ff9a0dfc58c6a039 Mon Sep 17 00:00:00 2001 From: payam Date: Mon, 14 Sep 2026 12:41:51 +0200 Subject: [PATCH 3/4] 2 changelog added for bugs and new feature --- doc/changes/dev/14308.bugfix.rst | 1 + doc/changes/dev/14308.newfeature.rst | 1 + 2 files changed, 2 insertions(+) create mode 100644 doc/changes/dev/14308.bugfix.rst create mode 100644 doc/changes/dev/14308.newfeature.rst diff --git a/doc/changes/dev/14308.bugfix.rst b/doc/changes/dev/14308.bugfix.rst new file mode 100644 index 00000000000..9bf0fead988 --- /dev/null +++ b/doc/changes/dev/14308.bugfix.rst @@ -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`_. diff --git a/doc/changes/dev/14308.newfeature.rst b/doc/changes/dev/14308.newfeature.rst new file mode 100644 index 00000000000..b579c80be47 --- /dev/null +++ b/doc/changes/dev/14308.newfeature.rst @@ -0,0 +1 @@ +:class:`mne.viz.Brain` now offers ``'flat'`` surf option for subjects that have the required patch files, by `Payam Sadeghi-Shabestari`_. From 7c03a30b2c57179dd799da387ba1e8edba81bb59 Mon Sep 17 00:00:00 2001 From: payam Date: Tue, 15 Sep 2026 09:28:50 +0200 Subject: [PATCH 4/4] render when glyph added --- mne/viz/_brain/_brain.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index 6f664c4d71a..693657e06a1 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -1708,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) @@ -1776,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):