diff --git a/.gitignore b/.gitignore index 21275b21c0b..b74868ec19d 100644 --- a/.gitignore +++ b/.gitignore @@ -103,4 +103,5 @@ venv/ .hypothesis/ .ruff_cache/ .ipynb_checkpoints/ -/.claude/ \ No newline at end of file +/.claude/ +uv.lock diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index 1771f50ddda..51e171e31ea 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -631,7 +631,6 @@ def setup_time_viewer(self, time_viewer=True, show_traces=True): for hemi in ("lh", "rh"): for ri, ci, v in self._iter_views(hemi): self.show_view(view=v, row=ri, col=ci) - self._renderer._process_events() self._renderer._update() # finally, show the MplCanvas @@ -1028,7 +1027,7 @@ def _refresh_colormap_widgets(self): fmid=self._data["fmid"], fmax=self._data["fmax"], ) - self._renderer._process_events() + self._renderer._update() def _configure_dock_trace_widget(self, name): if not self.show_traces: @@ -1520,7 +1519,7 @@ def _on_time_change(self, event): if "current_time" in self.widgets: self.widgets["current_time"].set_value(f"{self._current_time: .3f}") self.plot_time_line(update=True) - self._renderer._process_events() + self._renderer._update() def _on_colormap_range(self, event): """Respond to the colormap_range UI event.""" @@ -1541,7 +1540,7 @@ def _on_colormap_range(self, event): self.widgets[entry_key].set_value(val * self._data["fscale"]) # Update the render. self._update_colormap_range(**lims) - self._renderer._process_events() + self._renderer._update() def _on_vertex_select(self, event): """Respond to vertex_select UI event.""" @@ -3683,7 +3682,6 @@ def show_view( self._set_camera(**view_params, align=align) if update: self._renderer._update() - self._renderer._process_events() def _set_camera( self, diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index 00a77dd2c1f..f7578067cee 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -916,7 +916,9 @@ def test_image_screenshot( """Test screenshot and image saving.""" size = (300, 300) brain = _create_testing_brain(hemi="rh", show_traces=False, size=size) - azimuth, elevation = 180.0, 90.0 + for mesh in brain.layered_meshes.values(): + mesh._actor.SetVisibility(True) + azimuth, elevation = 360.0, 90.0 fname = tmp_path / "test.png" assert not fname.is_file() brain.save_image(fname) @@ -938,6 +940,17 @@ def test_image_screenshot( div = 2 if np.allclose(img.shape[:2], want_size[:2] / 2.0, atol=15) else 1 want_size[:2] /= div assert_allclose(img.shape, want_size, atol=15) + + # Test whether the renderer has properly updated before the screenshot was taken. + brain.set_time(1) + img1 = brain.screenshot(mode="rgba") + brain.set_time(2) + img2 = brain.screenshot(mode="rgba") + brain.set_time(1) + img3 = brain.screenshot(mode="rgba") + assert not np.array_equal(img1, img2) + assert_array_equal(img1, img3) + brain.close() diff --git a/mne/viz/backends/_pyvista.py b/mne/viz/backends/_pyvista.py index e48b4b5339c..5192a5c3897 100644 --- a/mne/viz/backends/_pyvista.py +++ b/mne/viz/backends/_pyvista.py @@ -18,7 +18,12 @@ import numpy as np import pyvista -from pyvista import Line, Plotter, PolyData, close_all # noqa: F401 # re-exported +from pyvista import ( + Line, + Plotter, # noqa: F401 # re-exported + PolyData, # noqa: F401 # re-exported + close_all, +) from pyvista.plotting.plotter import _ALL_PLOTTERS from pyvistaqt import BackgroundPlotter from vtkmodules.util.numpy_support import numpy_to_vtk @@ -50,12 +55,7 @@ from ...fixes import _compare_version from ...surface import _vtk_smooth from ...transforms import _cart_to_sph, _sph_to_cart, apply_trans -from ...utils import ( - _check_option, - _require_version, - _validate_type, - warn, -) +from ...utils import _check_option, _require_version, _validate_type, warn from ._abstract import Figure3D, _AbstractRenderer from ._utils import ( ALLOWED_QUIVER_MODES, @@ -156,7 +156,6 @@ def _build(self): if self.plotter.iren is not None: self.plotter.iren.initialize() _process_events(self.plotter) - _process_events(self.plotter) return self.plotter def _is_active(self): @@ -289,14 +288,10 @@ def _hide_axes(self): def _update(self): for plotter in self._all_plotters: # PyVistaQt resolves plotter.update() to QWidget.update(), which only - # schedules a repaint, and it makes Plotter.render() asynchronous (the - # synchronous one being _render()). So render synchronously to update the - # scene, schedule the repaint, then flush it: without the flush the paint - # is delivered whenever events happen to be processed next, which can be - # long after the scene has changed again. - getattr(plotter, "_render", plotter.render)() - plotter.update() - _process_events(plotter) + # schedules a repaint, and it makes Plotter.render() asynchronous. This is + # probably fine for most cases. If you want to synchronously, i.e. wait + # until it has actually gone through, use Plotter._render(). + plotter.render() def _index_to_loc(self, idx): _ncols = self.figure._ncols @@ -990,9 +985,9 @@ def _actor(self, mapper=None): _hide_testing_actor(actor) return actor - def _process_events(self): + def _process_events(self, level=0): for plotter in self._all_plotters: - _process_events(plotter) + _process_events(plotter, level=level + 1) def _update_picking_callback( self, on_mouse_move, on_button_press, on_button_release, on_pick @@ -1394,7 +1389,6 @@ def _set_3d_view( if update: figure.plotter.update() - _process_events(figure.plotter) def _set_3d_title(figure, title, size=16, *, color="white", position="upper_left"): @@ -1406,7 +1400,6 @@ def _set_3d_title(figure, title, size=16, *, color="white", position="upper_left name="title", ) figure.plotter.update() - _process_events(figure.plotter) return handle @@ -1416,7 +1409,7 @@ def _check_3d_figure(figure): def _clear_3d_figure(figure): figure.plotter.clear() # remove all actors, lights are restored on the next plot - _process_events(figure.plotter) + figure.plotter.update() def _close_3d_figure(figure): @@ -1428,17 +1421,19 @@ def _close_3d_figure(figure): # free memory and deregister from the scraper plotter.deep_clean() # remove internal references _ALL_PLOTTERS.pop(plotter._id_name, None) - _process_events(plotter) def _take_3d_screenshot(figure, mode="rgb", filename=None): - _process_events(figure.plotter) + # force the render to happen right now if it's an option (not available on + # notebooks) + meth = getattr(figure.plotter, "_render", figure.plotter.render) + meth() return figure.plotter.screenshot( transparent_background=(mode == "rgba"), filename=filename ) -def _process_events(plotter): +def _process_events(plotter, level=0): if hasattr(plotter, "app"): with warnings.catch_warnings(record=True): warnings.filterwarnings("ignore", "constrained_layout") diff --git a/mne/viz/evoked_field.py b/mne/viz/evoked_field.py index bec3ea56080..e7eb16ce787 100644 --- a/mne/viz/evoked_field.py +++ b/mne/viz/evoked_field.py @@ -229,7 +229,7 @@ def current_time_func(): current_time_func=current_time_func, times=evoked.times, ) - if not self._in_brain_figure or "time_slider" not in fig.widgets: + # Draw the time label self._time_label = time_label if time_label is not None: @@ -373,7 +373,7 @@ def _update(self): x_window=0.01, y_window=0.01, text=time_label ) - self._renderer.plotter.update() + self._renderer._update() def _configure_dock(self): """Configure the widgets shown in the dock on the left.""" @@ -383,7 +383,7 @@ def _configure_dock(self): r._dock_initialize() # Fieldline configuration - layout = r._dock_add_group_box("Fieldlines") + layout = r._dock_add_group_box("Fieldlines", collapse=True) r._dock_add_label(value="max value", align=True, layout=layout) diff --git a/tools/vulture_allowlist.py b/tools/vulture_allowlist.py index a42a635ef20..f0dac30eeb7 100644 --- a/tools/vulture_allowlist.py +++ b/tools/vulture_allowlist.py @@ -40,6 +40,7 @@ _use_test_3d_backend verbose_debug metadata_routing +Plotter # Decoding _.multi_class