diff --git a/mne/gui/_dipolefit.py b/mne/gui/_dipolefit.py index 7e138e258bb..0d5f263806c 100644 --- a/mne/gui/_dipolefit.py +++ b/mne/gui/_dipolefit.py @@ -2,6 +2,7 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +from contextlib import contextmanager from copy import deepcopy from functools import partial from pathlib import Path @@ -16,7 +17,7 @@ make_sphere_model, read_bem_solution, ) -from ..cov import _ensure_cov, make_ad_hoc_cov +from ..cov import _ensure_cov, compute_whitener, make_ad_hoc_cov from ..dipole import Dipole, fit_dipole from ..evoked import Evoked from ..forward import convert_forward_solution, make_field_map @@ -38,11 +39,32 @@ logger, verbose, ) -from ..viz import EvokedField, create_3d_figure -from ..viz._3d import _plot_head_surface, _plot_sensors_3d -from ..viz.backends._utils import _qt_app_exec +from ..viz import EvokedField +from ..viz._3d import _get_3d_option, _plot_head_surface, _plot_sensors_3d +from ..viz.backends._utils import _qt_app_exec, _qt_safe_window, _splash_message from ..viz.ui_events import ChannelsSelect, TimeChange, link, publish, subscribe -from ..viz.utils import _get_color_list +from ..viz.utils import _get_color_list, _is_dark + +# Message shown in the status bar when the GUI is not busy doing something else. +_STATUS_IDLE = "Ready" +# Meshes that start out hidden (everything not listed here starts out visible). +_MESH_VISIBLE = dict(colorbar=False) +# If any others are added here, the calls will need to be added to the correct places +# as well +_MESH_ALPHA = dict(brain=0.25) +# Meshes for which opacity cannot meaningfully be set (2D overlays). +_MESH_NO_OPACITY = ("colorbar",) +# Line width and marker size of the dipole traces, when not/when hovered. +_TRACE_LINEWIDTH, _TRACE_LINEWIDTH_HOVER = 1.5, 2.5 +_TRACE_MARKERSIZE, _TRACE_MARKERSIZE_HOVER = 4, 7 +# Standard views of the head, in the "mri" coordinate frame used by the 3D display. +_CAMERA_PRESETS = { + "Left": dict(azimuth=180, elevation=90, roll=90), + "Right": dict(azimuth=0, elevation=90, roll=270), + "Front": dict(azimuth=90, elevation=90, roll=0), + "Back": dict(azimuth=270, elevation=90, roll=180), + "Top": dict(azimuth=90, elevation=0, roll=0), +} @fill_doc @@ -98,6 +120,7 @@ class DipoleFitUI: All currently enabled dipoles in the model. """ + @_qt_safe_window(splash="_splash", window="_init_renderer.figure.plotter") def __init__( self, evoked, @@ -156,7 +179,16 @@ def __init__( if ch_type is not None: evoked = evoked.copy().pick(ch_type) + # Everything below is potentially slow, so bring up the window (still hidden) + # and its splash screen first, and narrate the progress on it. + self._busy_depth = 0 + self._busy_cursor = None + self._refit_pending = False + self._status_label = None + self._configure_window(show=show) + if surf_maps is None: + self._set_status("Computing field maps...") surf_maps = make_field_map( evoked, trans=trans, @@ -175,6 +207,7 @@ def __init__( if stc is not None: _validate_type(stc, ("path-like", _BaseSurfaceSourceEstimate), "stc") if not isinstance(stc, _BaseSurfaceSourceEstimate): + self._set_status("Loading source estimate...") stc = read_source_estimate(stc) if len(stc.times) != len(evoked.times) or not np.allclose( @@ -196,6 +229,7 @@ def __init__( evoked.info, head_mri_t, coord_frame="mri" ) + self._set_status("Preparing forward model...") self.fwd = _ForwardModeler( info=evoked.info, trans=trans, @@ -206,6 +240,7 @@ def __init__( # Initialize all the private attributes. self._actors = dict() + self._mesh_widgets = dict() self._bem = bem self._ch_type = ch_type self._cov = cov @@ -221,20 +256,37 @@ def __init__( self._subjects_dir = subjects_dir self._subject = subject self._time_line = None + self._time_text = None + self._gof_ax = None + self._gof_line = None self._head_mri_t = head_mri_t self._to_cf_t = to_cf_t self._rank = rank self._verbose = verbose self._n_jobs = n_jobs - # Configure the GUI. - self._configure_main_display( - show_sensors=show_sensors, show=show - ) # sets self._fig + # Configure the GUI. The window stays hidden until it is fully composed: + # `stc.plot` and `EvokedField` do not show figures they did not create + # themselves (we pass ours in), so the window only appears at the `show()` + # at the very end, all at once. + self._configure_main_display(show_sensors=show_sensors) # sets self._fig self._configure_dock() + self._set_status() # must be done last if show: + # Settle all pending widget layouts, still hidden and synchronously. + self._renderer._window_settle_layouts() + # Render the scene into the framebuffer now that the layouts (and hence + # the 3D view size) are final: the first paint after showing blits + # whatever the framebuffer holds, and for complex scenes the fresh + # render in `show()` only completes after that first paint, which would + # briefly show a stale, mis-framed image otherwise. + for plotter in self._renderer._all_plotters: + plotter._render() + # Hand the splash screen back to the renderer, which closes it once the + # window has actually appeared on screen (see `_qt_safe_window`). + self._renderer.figure.splash = self._splash self._renderer.show() if block and self._renderer._kind != "notebook": _qt_app_exec(self._renderer.figure.store["app"]) @@ -243,42 +295,138 @@ def __init__( def _renderer(self): return self._fig._renderer + def _configure_window(self, *, show): + """Create the (still hidden) main window, its splash screen and status bar.""" + from ..viz.backends.renderer import _get_renderer + + splash = "Initializing dipole fitting GUI..." if show else False + self._init_renderer = renderer = _get_renderer( + size=(1080, 720), + bgcolor="white", + smooth_shading=_get_3d_option("smooth_shading"), + # The window is only shown at the very end of ``__init__``, when it is + # fully drawn: a window that pops up empty and then slowly fills itself in + # looks broken. + show=False, + splash=splash, + ) + # Showing any window closes the splash screen (see `_qt_safe_window`), so keep + # it to ourselves until the main window is ready to be shown. + self._splash = getattr(renderer.figure, "splash", None) + if not hasattr(self._splash, "showMessage"): # not the Qt backend + self._splash = None + renderer.figure.splash = False + renderer.set_interaction("terrain") + self._fig3d = renderer.scene() + + # Status bar, narrating what the GUI is doing (see `_set_status`). + renderer._status_bar_initialize() + self._status_label = renderer._status_bar_add_label(_STATUS_IDLE, stretch=1) + + def _set_status(self, message=_STATUS_IDLE): + """Show what the GUI is currently doing, or ``"Ready"`` when it is idle. + + During startup the main window is not up yet, so the message is shown on the + splash screen as well (the status bar shows it once the window appears). + """ + if self._status_label is not None: + self._status_label.set_value(message) + # Repaint just this widget: unlike processing the event queue, this cannot + # re-enter any of the event handlers of the GUI. + self._status_label.update() + # `_qt_safe_window` deletes `_splash` when `__init__` is done, hence getattr. + splash = getattr(self, "_splash", None) + if splash is not None: + _splash_message(splash, message) + + @contextmanager + def _busy(self, message): + """Show ``message`` and block interaction while a slow operation runs. + + Nested uses collapse into the outermost one, so that operations that trigger + one another (e.g. fitting a dipole refits all timecourses) show a single + message and restore the cursor only once. + """ + r = self._renderer + # Increment the depth *before* processing events below: an event handler that + # runs during that processing and uses `_busy` itself must see itself as + # nested, or it would tear the busy state down mid-operation. + self._busy_depth += 1 + try: + if self._busy_depth == 1: + self._busy_cursor = r._window_get_cursor() + self._set_status(message) + r._window_set_enabled(False) + r._window_set_cursor(r._window_new_cursor("WaitCursor")) + # Paint the busy state before starting the computation. The window is + # disabled, so no user input can be delivered while we do this. + r._process_events() + yield + finally: + self._busy_depth -= 1 + if self._busy_depth == 0: + r._window_set_cursor(self._busy_cursor) + r._window_set_enabled(True) + self._set_status() + @property def dipoles(self): """A list of all the fitted dipoles that are enabled in the GUI.""" return [d["dip"] for d in self._dipoles.values() if d["active"]] - def _configure_main_display(self, show_sensors=True, show=True): + def _configure_main_display(self, show_sensors=True): """Configure main 3D display of the GUI.""" - fig_into = create_3d_figure((1080, 720), bgcolor="white", show=show) + fig_into = self._fig3d self._stc_brain = None if self._stc is not None: + self._set_status("Plotting source estimate...") kwargs = dict( subject=self._subject, subjects_dir=self._subjects_dir, hemi="both", time_viewer=False, initial_time=self._current_time, - brain_kwargs=dict(units="m"), + time_label=None, # the traces plot shows the current time + brain_kwargs=dict(units="m", show=False, alpha=0.5), figure=fig_into, + # the GUI renders on a white figure, so the Brain (and hence its + # colorbar) needs to select a black foreground color + background="white", ) if isinstance(self._stc, SourceEstimate): kwargs["surface"] = "white" self._stc_brain = self._stc.plot(**kwargs) self._actors["brain"] = self._stc_brain._actors["data"] + # a translucent cortex keeps the dipole arrows inside it visible, + # set here in addition to "alpha" for Brain (that only controls the + # alpha of the brain surface, not its overlay) + self.set_mesh_opacity("brain", _MESH_ALPHA["brain"], update=False) + colorbar = [ + actor + for actor in ( + self._stc_brain._scalar_bar, + self._stc_brain._scalar_bar_ticks, + ) + if actor is not None + ] + if len(colorbar) > 0: + self._actors["colorbar"] = colorbar fig_into = self._stc_brain # plot into the brain instead + self._set_status("Plotting field lines...") fig_ef = EvokedField( self._evoked, self._surf_maps, time=self._current_time, + time_label=None, # the time is shown on the time line of the traces plot interpolation="linear", alpha=0, + contour_line_opacity=0.5, show_density=self._show_density, foreground="black", background="white", - fig=fig_into, # can be Figure3D or Brain instance + fig=fig_into, # can be Figure3D or Brain instance; we own its window ) del fig_into fig_ef.separate_canvas = False # needed to plot the timeline later @@ -324,6 +472,7 @@ def _configure_main_display(self, show_sensors=True, show=True): head_surf = m["surf"] break else: + self._set_status("Plotting head surface...") self._actors["head"], _, head_surf = _plot_head_surface( renderer=fig_ef._renderer, head="head", @@ -337,6 +486,7 @@ def _configure_main_display(self, show_sensors=True, show=True): self._actors["head"].prop.culling = "back" if show_sensors: + self._set_status("Plotting sensors...") sensors = _plot_sensors_3d( renderer=fig_ef._renderer, info=self._evoked.info, @@ -362,35 +512,83 @@ def _configure_main_display(self, show_sensors=True, show=True): ) self._actors["sensors"] = sum(sensors.values(), []) - # Adjust camera - fig_ef._renderer.set_camera( - azimuth=180, elevation=90, roll=90, distance=0.55, focalpoint=[0, 0, 0.03] - ) - subscribe(fig_ef, "time_change", self._on_time_change) subscribe(fig_ef, "channels_select", self._on_channels_select) self._fig = fig_ef + # Adjust camera (needs self._fig, hence after setting it) + self._set_camera_preset("Left") + for name, visible in _MESH_VISIBLE.items(): + if not visible and name in self._actors: + self.toggle_mesh(name, show=False) + def _configure_dock(self): """Configure the left and right dock areas of the GUI.""" + self._set_status("Setting up controls...") r = self._renderer - # Toggle buttons for various meshes + # Visibility and opacity controls for the various meshes, one row per mesh. layout = r._dock_add_group_box("Meshes", collapse=True) + grid = r._layout_create("grid") + r._layout_add_widget(layout, grid) + r._dock_add_label("visible", layout=grid, row=0, col=0) + r._dock_add_label("opacity", layout=grid, row=0, col=1) + + @_auto_weakref + def _toggle_mesh(show, name): + self.toggle_mesh(name, show=bool(show)) @_auto_weakref - def _toggle_mesh(_, name, show=None): - self.toggle_mesh(name, show=show) + def _set_mesh_opacity(opacity, name): + self.set_mesh_opacity(name, opacity) + row = 0 for actor_name in self._actors: - if actor_name == "occlusion_surf": + if actor_name == "occlusion_surf": # implementation detail, not a "mesh" continue - r._dock_add_check_box( - name=actor_name, - value=True, - callback=partial(_toggle_mesh, name=actor_name), - layout=layout, + row += 1 + widgets = [ + r._dock_add_check_box( + name=actor_name, + value=_MESH_VISIBLE.get(actor_name, True), + callback=partial(_toggle_mesh, name=actor_name), + layout=grid, + row=row, + col=0, + ) + ] + # 2D overlays like the colorbar get a visibility checkbox only. + if actor_name not in _MESH_NO_OPACITY: + widgets.append( + r._dock_add_slider( + name=None, + value=self._get_mesh_opacity(actor_name), + rng=[0, 1], + callback=partial(_set_mesh_opacity, name=actor_name), + double=True, + layout=grid, + row=row, + col=1, + ) + ) + self._mesh_widgets[actor_name] = widgets + + # Camera presets + camera_layout = r._dock_add_layout(vertical=False) + + @_auto_weakref + def _set_camera_preset(name): + self._set_camera_preset(name) + + for preset in _CAMERA_PRESETS: + r._dock_add_button( + name=preset, + callback=partial(_set_camera_preset, name=preset), + style="toolbutton", + tooltip=f"View the {preset.lower()} of the head", + layout=camera_layout, ) + r._layout_add_widget(r._dock_layout, camera_layout) # Right dock r._dock_initialize(name="Dipole fitting", area="right") @@ -402,7 +600,7 @@ def _toggle_mesh(_, name, show=None): def _on_select_method(method): self._on_select_method(method) - r._dock_add_combo_box( + self._method_combo = r._dock_add_combo_box( "Dipole model", value="Multi dipole (MNE)", rng=methods, @@ -436,16 +634,52 @@ def toggle_mesh(self, name, show=None): show : bool | None Whether to show the mesh. If None, the visibility of the mesh is toggled. """ + actors = self._get_actors(name) + if show is None: + show = not actors[0].GetVisibility() + for act in actors: + act.SetVisibility(show) + self._renderer._update() + + def set_mesh_opacity(self, name, opacity, *, update=True): + """Set the opacity of a mesh. + + Parameters + ---------- + name : str + Name of the mesh. + opacity : float + The opacity of the mesh, between 0 (fully transparent) and 1 (opaque). + update : bool + If True, update the display immediately. + """ + # The actors are a mix of PyVista wrappers and plain VTK actors, so stick to + # the VTK API here (which both understand). + for act in self._get_actors(name): + act.GetProperty().SetOpacity(float(opacity)) + if update: + self._renderer._update() + + def _get_actors(self, name): + """Get the actors of a mesh as a list.""" _check_option("name", name, self._actors.keys()) actors = self._actors[name] # self._actors[name] is sometimes a list and sometimes not. Make it # always be a list to simplify the code. if not isinstance(actors, list): actors = [actors] - if show is None: - show = not actors[0].GetVisibility() - for act in actors: - act.SetVisibility(show) + return actors + + def _get_mesh_opacity(self, name): + """Get the current opacity of a mesh.""" + return self._get_actors(name)[0].GetProperty().GetOpacity() + + def _set_camera_preset(self, name): + """Point the camera at one of the standard views of the head.""" + _check_option("name", name, list(_CAMERA_PRESETS)) + self._renderer.set_camera( + **_CAMERA_PRESETS[name], distance=0.55, focalpoint=(0, 0, 0.03) + ) self._renderer._update() def set_time(self, time): @@ -465,12 +699,25 @@ def set_time(self, time): def _on_time_change(self, event): new_time = np.clip(event.time, self._evoked.times[0], self._evoked.times[-1]) self._current_time = new_time - print("gui time change to", new_time) if self._time_line is not None: self._time_line.set_xdata([new_time]) + self._update_time_text() self._renderer._mplcanvas.update_plot() self._update_arrows() + def _update_time_text(self): + """Label the time line with the current time and goodness-of-fit.""" + if self._time_text is None: + return + text = f"{self._current_time * 1e3:.0f} ms" + if self._gof_line is not None and self._gof_line.get_visible(): + gof = np.interp( + self._current_time, self._evoked.times, self._gof_line.get_ydata() + ) + text += f" · GOF {gof:.0f}%" + self._time_text.set_x(self._current_time) + self._time_text.set_text(text) + # TODO: Need to expose a public method for opening the sensor-data window and for # programmatically selecting the channels to fit dipoles to. def _on_sensor_data(self): @@ -512,28 +759,29 @@ def fit_dipole(self): sensors when no selection is active). The newly fitted dipole is appended to the :attr:`dipoles` attribute. """ - evoked_picked = self._evoked.copy() - cov_picked = self._cov.copy() - if self._fig_sensors is not None: - picks = self._fig_sensors.lasso.selection - if len(picks) > 0: - evoked_picked = evoked_picked.pick(picks) - evoked_picked.info.normalize_proj() - cov_picked = cov_picked.pick_channels(picks, ordered=False) - cov_picked["projs"] = evoked_picked.info["projs"] - evoked_picked.crop(self._current_time, self._current_time) - - dip = fit_dipole( - evoked_picked, - cov_picked, - self._bem, - trans=self._head_mri_t, - rank=self._rank, - n_jobs=self._n_jobs, - verbose=False, - )[0] - - self.add_dipole(dip) + with self._busy("Fitting dipole..."): + evoked_picked = self._evoked.copy() + cov_picked = self._cov.copy() + if self._fig_sensors is not None: + picks = self._fig_sensors.lasso.selection + if len(picks) > 0: + evoked_picked = evoked_picked.pick(picks) + evoked_picked.info.normalize_proj() + cov_picked = cov_picked.pick_channels(picks, ordered=False) + cov_picked["projs"] = evoked_picked.info["projs"] + evoked_picked.crop(self._current_time, self._current_time) + + dip = fit_dipole( + evoked_picked, + cov_picked, + self._bem, + trans=self._head_mri_t, + rank=self._rank, + n_jobs=self._n_jobs, + verbose=False, + )[0] + + self.add_dipole(dip) def add_dipole(self, dipole, name=None): """Add a dipole (or multiple dipoles) to the GUI. @@ -548,6 +796,8 @@ def add_dipole(self, dipole, name=None): this should be a list containing the name for each dipole. When ``None``, the ``.name`` attribute of the ``Dipole`` object itself will be used. """ + from matplotlib.colors import to_hex + _validate_type(name, (str, list, None), "name") if isinstance(name, str): names = [name] @@ -588,6 +838,10 @@ def _on_dipole_toggle_fix_orientation(fix, dip_num): def _on_dipole_delete(dip_num): return self._on_dipole_delete(dip_num) + @_auto_weakref + def _on_dipole_hover(dip_num, hover): + return self._on_dipole_hover(dip_num, hover) + new_dipoles = list() for dip, name in zip(dipole, names): # Coordinates needed to draw the big arrow on the helmet. @@ -645,6 +899,19 @@ def _on_dipole_delete(dip_num): layout=hlayout, ) ) + # Give the name field the color of the dipole's trace, so the rows in the + # dipole list can be matched up with the traces at a glance. + widgets[-1].set_style( + { + "background-color": to_hex(dip_color), + "color": "white" if _is_dark(dip_color) else "black", + } + ) + # Hovering the row emphasizes the traces belonging to this dipole. + widgets[-1].set_hover_callbacks( + enter=partial(_on_dipole_hover, dip_num=dip_num, hover=True), + leave=partial(_on_dipole_hover, dip_num=dip_num, hover=False), + ) widgets.append( r._dock_add_check_box( name="Fix ori", @@ -720,9 +987,15 @@ def _fit_timecourses(self): self._save_button.set_enabled(len(self.dipoles) > 0) active_dips = [d for d in self._dipoles.values() if d["active"]] if len(active_dips) == 0: + if self._gof_line is not None: + self._gof_line.set_visible(False) + self._update_time_text() + self._renderer._mplcanvas.update_plot() return - if self._multi_dipole_method == "Multi dipole (MNE)": + with self._busy(f"Fitting {self._multi_dipole_method} model..."): + # Forward solution for the active dipoles. It is needed for the multi-dipole + # fit below, and in both fitting modes for computing the goodness-of-fit. # TODO: When two active dipoles have (nearly) identical positions, they # collapse to a single point in the discrete source space below, which # errors out. Ideal behavior unclear: merge them, or error informatively? @@ -742,78 +1015,156 @@ def _fit_timecourses(self): this_fwd = self.fwd.compute(this_src) this_fwd = convert_forward_solution(this_fwd, surf_ori=False) - inv = make_inverse_operator( - self._evoked.info, - # fwd, - this_fwd, - self._cov, - fixed=False, - loose=1.0, - depth=0, - rank=self._rank, - ) - stc = apply_inverse( - self._evoked, - inv, - method="MNE", - lambda2=1e-6, - pick_ori="vector", - ) - - timecourses = stc.magnitude().data - orientations = (stc.data / timecourses[:, np.newaxis, :]).transpose(0, 2, 1) - fixed_timecourses = stc.project( - np.array([dip["dip"].ori[0] for dip in active_dips]) - )[0].data - - for i, dip in enumerate(active_dips): - if dip["fix_ori"]: - dip["timecourse"] = fixed_timecourses[i] - dip["orientation"] = dip["dip"].ori.repeat(len(stc.times), axis=0) - else: - dip["timecourse"] = timecourses[i] - dip["orientation"] = orientations[i] - else: - assert self._multi_dipole_method == "Single dipole" # only other option - for dip in active_dips: - dip_with_timecourse, _ = fit_dipole( - self._evoked, + if self._multi_dipole_method == "Multi dipole (MNE)": + inv = make_inverse_operator( + self._evoked.info, + # fwd, + this_fwd, self._cov, - self._bem, - pos=dip["dip"].pos[0], # position is always fixed - ori=dip["dip"].ori[0] if dip["fix_ori"] else None, - trans=self._head_mri_t, + fixed=False, + loose=1.0, + depth=0, rank=self._rank, - n_jobs=self._n_jobs, - verbose=True, ) - if dip["fix_ori"]: - dip["timecourse"] = dip_with_timecourse.data[0] - dip["orientation"] = dip["dip"].ori.repeat( - len(dip_with_timecourse.times), axis=0 - ) - else: - dip["timecourse"] = dip_with_timecourse.amplitude - dip["orientation"] = dip_with_timecourse.ori - - # Update matplotlib canvas at the bottom of the window - canvas = self._setup_mplcanvas() - ymin, ymax = 0, 0 - for dip in active_dips: - if "line_artist" in dip: - dip["line_artist"].set_ydata(dip["timecourse"]) + stc = apply_inverse( + self._evoked, + inv, + method="MNE", + lambda2=1e-6, + pick_ori="vector", + ) + + timecourses = stc.magnitude().data + orientations = (stc.data / timecourses[:, np.newaxis, :]).transpose( + 0, 2, 1 + ) + fixed_timecourses = stc.project( + np.array([dip["dip"].ori[0] for dip in active_dips]) + )[0].data + + for i, dip in enumerate(active_dips): + if dip["fix_ori"]: + dip["timecourse"] = fixed_timecourses[i] + dip["orientation"] = dip["dip"].ori.repeat( + len(stc.times), axis=0 + ) + else: + dip["timecourse"] = timecourses[i] + dip["orientation"] = orientations[i] else: - dip["line_artist"] = canvas.plot( - self._evoked.times, - dip["timecourse"], - label=dip["dip"].name, - color=dip["color"], + assert self._multi_dipole_method == "Single dipole" # only other option + for dip in active_dips: + dip_with_timecourse, _ = fit_dipole( + self._evoked, + self._cov, + self._bem, + pos=dip["dip"].pos[0], # position is always fixed + ori=dip["dip"].ori[0] if dip["fix_ori"] else None, + trans=self._head_mri_t, + rank=self._rank, + n_jobs=self._n_jobs, + verbose=True, + ) + if dip["fix_ori"]: + dip["timecourse"] = dip_with_timecourse.data[0] + dip["orientation"] = dip["dip"].ori.repeat( + len(dip_with_timecourse.times), axis=0 + ) + else: + dip["timecourse"] = dip_with_timecourse.amplitude + dip["orientation"] = dip_with_timecourse.ori + + # Update matplotlib canvas at the bottom of the window. Timecourses are + # stored in SI units (Am), but shown in nAm, hence the 1e9 scaling at the + # display boundary. + canvas = self._setup_mplcanvas() + ymin, ymax = 0, 0 + for dip in active_dips: + # The dot marks the time at which the dipole was fitted. + fit_time = dip["dip"].times[0] + fit_value = np.interp( + fit_time, self._evoked.times, dip["timecourse"] * 1e9 ) - ymin = min(ymin, 1.1 * dip["timecourse"].min()) - ymax = max(ymax, 1.1 * dip["timecourse"].max()) - canvas.axes.set_ylim(ymin, ymax) - canvas.update_plot() - self._update_arrows() + if "line_artist" in dip: + dip["line_artist"].set_ydata(dip["timecourse"] * 1e9) + dip["dot_artist"].set_ydata([fit_value]) + else: + dip["line_artist"] = canvas.plot( + self._evoked.times, + dip["timecourse"] * 1e9, + label=dip["dip"].name, + color=dip["color"], + linewidth=_TRACE_LINEWIDTH, + update=False, + ) + # Labels starting with "_" are hidden from the legend. + (dip["dot_artist"],) = canvas.axes.plot( + [fit_time], + [fit_value], + "o", + label=f"_{dip['dip'].name} fit time", + color=dip["color"], + markersize=_TRACE_MARKERSIZE, + zorder=dip["line_artist"].get_zorder() + 1, + ) + ymin = min(ymin, 1.1 * dip["timecourse"].min() * 1e9) + ymax = max(ymax, 1.1 * dip["timecourse"].max() * 1e9) + canvas.axes.set_ylim(ymin, ymax) + self._update_gof(canvas, active_dips, this_fwd) + canvas.update_plot() + self._update_arrows() + + def _update_gof(self, canvas, active_dips, fwd): + """Draw the goodness-of-fit of the combined dipole model on a twin axis.""" + gof = self._compute_gof(active_dips, fwd) + if self._gof_ax is None: + self._gof_ax = canvas.axes.twinx() + self._gof_ax.set_ylim(0, 100) + self._gof_ax.set_ylabel("GOF (%)", color="gray") + self._gof_ax.tick_params(axis="y", colors="gray") + self._gof_ax.spines["top"].set_visible(False) + self._gof_ax.spines["right"].set_visible(False) + self._gof_ax.spines["bottom"].set_visible(False) + self._gof_ax.spines["left"].set_visible(False) + # Twin axes are drawn on top by default. Flip that around (the classic + # matplotlib recipe) so the activation traces stay on top of the GOF line. + canvas.axes.set_zorder(self._gof_ax.get_zorder() + 1) + canvas.axes.patch.set_visible(False) + if self._gof_line is None: + (self._gof_line,) = self._gof_ax.plot( + self._evoked.times, gof, color="gray", alpha=0.5 + ) + else: + self._gof_line.set_ydata(gof) + self._gof_line.set_visible(True) + self._update_time_text() + + def _compute_gof(self, active_dips, fwd): + """Compute the goodness-of-fit timecourse of the combined dipole model.""" + # Moments (in head coordinates, like `fwd["sol"]["data"]`) of all dipoles. + q = np.concatenate( + [ + (dip["orientation"] * dip["timecourse"][:, np.newaxis]).T + for dip in active_dips + ] + ) + # Bad channels are in the forward solution, but never in the whitener (nor in + # the channels `fit_dipole` uses), so drop them before whitening. + picks = [ + c for c in fwd["sol"]["row_names"] if c not in self._evoked.info["bads"] + ] + W, ch_names = compute_whitener( + self._cov, self._evoked.info, picks=picks, rank=self._rank, verbose=False + ) + data = self._evoked.data[[self._evoked.ch_names.index(c) for c in ch_names]] + gain = fwd["sol"]["data"][[fwd["sol"]["row_names"].index(c) for c in ch_names]] + residual = W @ (data - gain @ q) + data = W @ data + gof = np.zeros(data.shape[1]) + denom = np.sum(data**2, axis=0) + good = denom > 0 # a field of exactly zero has no fit quality to speak of + gof[good] = 100 * (1 - np.sum(residual[:, good] ** 2, axis=0) / denom[good]) + return gof @verbose def save(self, fname, verbose=None): @@ -900,7 +1251,20 @@ def _update_arrows(self): # TODO: Need to expose a public method for setting the multi-dipole method def _on_select_method(self, method): """Select the method to use for multi-dipole timecourse fitting.""" + _check_option("method", method, ("Multi dipole (MNE)", "Single dipole")) + if method == self._multi_dipole_method: + return self._multi_dipole_method = method + # Defer the (slow) refit to the event loop instead of running it here, inside + # the combo box's signal handler: this lets the combo box finish closing its + # popup and repaint before the computation starts. + if not self._refit_pending: + self._refit_pending = True + self._renderer._window_defer(self._deferred_refit) + + def _deferred_refit(self): + """Refit the timecourses, deferred so that widgets can settle first.""" + self._refit_pending = False self._fit_timecourses() # TODO: Need to expose public methods for toggling, renaming, (un)fixing the @@ -911,6 +1275,7 @@ def _on_dipole_toggle(self, active, dip_num): active = bool(active) dipole["active"] = active dipole["line_artist"].set_visible(active) + dipole["dot_artist"].set_visible(active) # Labels starting with "_" are hidden from the legend. dipole["line_artist"].set_label(("" if active else "_") + dipole["dip"].name) dipole["brain_arrow_actor"].visibility = active @@ -934,6 +1299,7 @@ def _on_dipole_delete(self, dip_num): """Delete previously fitted dipole.""" dipole = self._dipoles[dip_num] dipole["line_artist"].remove() + dipole["dot_artist"].remove() dipole["brain_arrow_actor"].visibility = False if dipole["helmet_arrow_actor"] is not None: # no helmet arrow for EEG dipole["helmet_arrow_actor"].visibility = False @@ -944,18 +1310,59 @@ def _on_dipole_delete(self, dip_num): self._renderer._update() self._renderer._mplcanvas.update_plot() + def _on_dipole_hover(self, dip_num, hover): + """Emphasize the traces of the dipole whose row is being hovered.""" + dipole = self._dipoles.get(dip_num) + if dipole is None or "line_artist" not in dipole: + return + dipole["line_artist"].set_linewidth( + _TRACE_LINEWIDTH_HOVER if hover else _TRACE_LINEWIDTH + ) + dipole["dot_artist"].set_markersize( + _TRACE_MARKERSIZE_HOVER if hover else _TRACE_MARKERSIZE + ) + self._renderer._mplcanvas.update_plot() + def _setup_mplcanvas(self): """Configure the matplotlib canvas at the bottom of the window.""" + from matplotlib.transforms import offset_copy + if self._renderer._mplcanvas is None: self._renderer._mplcanvas = self._renderer._window_get_mplcanvas( - self._fig, 0.3, False, False + self._fig, 0.22, False, False ) self._renderer._window_adjust_mplcanvas_layout() + canvas = self._renderer._mplcanvas + # Dipole moments are stored in Am, but displayed in nAm (see + # `_fit_timecourses`). + canvas.axes.set_ylabel("Activation (nAm)") + canvas.axes.set_xlim(self._evoked.times[0], self._evoked.times[-1]) + canvas.axes.spines["top"].set_visible(False) + canvas.axes.spines["right"].set_visible(False) + canvas.axes.axhline(0, linewidth=1, color="gray", zorder=0) if self._time_line is None: - self._time_line = self._renderer._mplcanvas.plot_time_line( + canvas = self._renderer._mplcanvas + self._time_line = canvas.plot_time_line( self._current_time, label="time", color="black", + linewidth=1, + ) + # Label the time line, with a small offset so it does not overlap the line. + self._time_text = canvas.axes.text( + self._current_time, + 0.97, + f"{self._current_time * 1e3:.0f} ms", + transform=offset_copy( + canvas.axes.get_xaxis_transform(), + fig=canvas.fig, + x=3, + units="points", + ), + va="top", + ha="left", + fontsize=8, + color="black", ) return self._renderer._mplcanvas diff --git a/mne/gui/_gui.py b/mne/gui/_gui.py index 6f3b5474b83..7ae50ae9765 100644 --- a/mne/gui/_gui.py +++ b/mne/gui/_gui.py @@ -437,6 +437,11 @@ def __call__(self, block, block_vars, gallery_conf): plotter = gui._renderer.plotter plotter.screenshot(img_fname) sub_pixmap = QtGui.QPixmap(img_fname) + # The screenshot is in physical pixels, but QPainter works in + # logical pixels, so on HiDPI displays (e.g., Retina, where + # devicePixelRatio == 2) the screenshot must be marked with the + # window's scale factor or it is composited at twice its size. + sub_pixmap.setDevicePixelRatio(window.devicePixelRatio()) # https://doc.qt.io/qt-5/qwidget.html#mapTo # https://doc.qt.io/qt-5/qpainter.html#drawPixmap-1 QtGui.QPainter(pixmap).drawPixmap( diff --git a/mne/gui/tests/test_dipolefit.py b/mne/gui/tests/test_dipolefit.py index a3bfc8a8e9b..bf9c533f6f4 100644 --- a/mne/gui/tests/test_dipolefit.py +++ b/mne/gui/tests/test_dipolefit.py @@ -2,8 +2,11 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +import re + import numpy as np import pytest +from matplotlib.colors import to_hex from numpy.testing import assert_allclose, assert_equal import mne @@ -70,6 +73,7 @@ def test_dipolefit_gui_basic( ): """Test basic functionality of the dipole fitting GUI.""" from mne.gui import dipolefit + from mne.gui._dipolefit import _STATUS_IDLE # Test basic interface elements. evoked = sample_evoked @@ -87,9 +91,56 @@ def test_dipolefit_gui_basic( g.toggle_mesh("sensors") # show=None toggles the current visibility assert g._actors["sensors"][0].GetVisibility() + # The GUI starts out idle, with the splash screen (if any: in testing mode + # `show=False`, so there was none) closed and forgotten by `_qt_safe_window`. + assert g._status_label.get_value() == _STATUS_IDLE + assert not hasattr(g, "_splash") + + # Slow operations are announced in the status bar and make the GUI + # un-interactable. Nested uses of `_busy` collapse into the outermost one. + window = g._renderer._window + assert window.isEnabled() + cursor = g._renderer._window_get_cursor().shape() + with g._busy("Busy..."): + assert g._status_label.get_value() == "Busy..." + assert not window.isEnabled() + assert g._renderer._window_get_cursor().shape() != cursor # busy cursor + with g._busy("Nested..."): + assert g._status_label.get_value() == "Busy..." # the outermost one wins + assert not window.isEnabled() # only the outermost one restores the GUI + assert g._status_label.get_value() == _STATUS_IDLE + assert window.isEnabled() + assert g._renderer._window_get_cursor().shape() == cursor + + # An event handler that runs while `_busy` paints the busy state (it processes + # events once) must see itself as nested, not tear the busy state down. + orig_process = g._renderer._process_events + reentered = list() + + def process_and_reenter(): + orig_process() + if not reentered: + reentered.append(True) + with g._busy("Nested during repaint..."): + pass + + g._renderer._process_events = process_and_reenter + try: + with g._busy("Busy..."): + assert reentered + assert g._status_label.get_value() == "Busy..." + assert not window.isEnabled() + finally: + g._renderer._process_events = orig_process + assert g._status_label.get_value() == _STATUS_IDLE + assert window.isEnabled() + assert g._renderer._window_get_cursor().shape() == cursor + # Test fitting a single dipole. assert len(g._dipoles) == len(g.dipoles) == 0 g.fit_dipole() + assert g._renderer._window_get_cursor().shape() == cursor # busy cursor restored + assert g._status_label.get_value() == _STATUS_IDLE assert len(g._dipoles) == len(g.dipoles) == 1 dip = g.dipoles[0] assert dip.name == "Left Auditory" @@ -113,6 +164,14 @@ def test_dipolefit_gui_basic( assert _selected_sensors(g) == sorted(picks) ui_events.publish(g._fig, ui_events.TimeChange(0.09)) # change time assert g._current_time == 0.09 + + # The time (and the goodness-of-fit, once there are dipoles) is labeled on the time + # line of the traces plot, not in the 3D view. + assert g._fig._time_label is None + assert not hasattr(g._fig, "_time_label_actor") + assert re.fullmatch(r"90 ms · GOF \d+%", g._time_text.get_text()) + assert g._time_text.get_position()[0] == 0.09 + g.fit_dipole() assert len(g._dipoles) == len(g.dipoles) == 2 dip2 = g.dipoles[1] @@ -145,18 +204,66 @@ def test_dipolefit_gui_basic( assert dip1_dict["color"] == _get_color_list()[0] assert dip2_dict["color"] == _get_color_list()[1] + # The name field of each dipole is styled with the color of its trace. + for dip_dict in (dip1_dict, dip2_dict): + style = dip_dict["widgets"][1].widget.styleSheet() + assert to_hex(dip_dict["color"]) in style + assert "color:black;" in style # both colors are light enough for black text + + # Timecourses are stored in Am, but displayed in nAm, with the goodness-of-fit of + # the combined model shown on a twin axis. + for dip_dict in (dip1_dict, dip2_dict): + assert_allclose( + dip_dict["line_artist"].get_ydata(), dip_dict["timecourse"] * 1e9, atol=0 + ) + assert g._gof_ax.get_ylim() == (0, 100) + assert g._gof_line.get_ydata().max() <= 100 + # Fitted dipoles have goodness-of-fit information that should be saved along. fname = tmp_path / "fitted.dip" g.save(fname) assert mne.read_dipole(fname).khi2 is not None - # Test changing dipole model + # Test changing the dipole model through the dropdown widget (like a user would). + # The status bar should name the model that is being fitted. + messages = list() + orig_set_status = g._set_status + + def record_status(message=_STATUS_IDLE): + messages.append(message) + orig_set_status(message) + + g._set_status = record_status assert g._multi_dipole_method == "Multi dipole (MNE)" old_timecourses = np.vstack((dip1_dict["timecourse"], dip2_dict["timecourse"])) - g._on_select_method("Single dipole") + g._method_combo.set_value("Single dipole") + assert g._multi_dipole_method == "Single dipole" + # The refit is deferred to the event loop so that the combo box popup can close + # and repaint before the slow computation starts. + assert g._refit_pending + assert messages == [] + g._renderer._process_events() # run the deferred refit + assert not g._refit_pending + assert "Fitting Single dipole model..." in messages new_timecourses = np.vstack((dip1_dict["timecourse"], dip2_dict["timecourse"])) assert not np.allclose(old_timecourses, new_timecourses, atol=1e-10) + # Selecting the method that is already active does not pointlessly refit. + messages.clear() + g._on_select_method("Single dipole") + assert not g._refit_pending + assert messages == [] + with pytest.raises(ValueError, match="Invalid value for the 'method'"): + g._on_select_method("foo") + + # Switching back refits (and reproduces) the multi-dipole model. + g._method_combo.set_value("Multi dipole (MNE)") + g._renderer._process_events() + assert "Fitting Multi dipole (MNE) model..." in messages + roundtrip = np.vstack((dip1_dict["timecourse"], dip2_dict["timecourse"])) + assert np.allclose(roundtrip, old_timecourses, atol=0) + g._set_status = orig_set_status + g.close() @@ -180,12 +287,51 @@ def test_dipolefit_gui_dipole_controls( with pytest.raises(ValueError, match="Invalid value for the 'name' parameter"): g.toggle_mesh("non existent") + # Each mesh also gets an opacity slider, initialized to its current opacity. The + # head surface is drawn translucent (see `_plot_head_surface`). + assert_allclose(g._get_mesh_opacity("head"), 0.2, atol=0) + g._mesh_widgets["head"][1].set_value(0.4) # [checkbox, opacity slider] + assert_allclose(g._actors["head"].GetProperty().GetOpacity(), 0.4, atol=1e-4) + + # Camera presets. + g._set_camera_preset("Top") + with pytest.raises(ValueError, match="Invalid value for the 'name' parameter"): + g._set_camera_preset("Sideways") + # Test toggling dipoles off and on. This is done through the GUI widgets, which are # ordered: [active, name, fix orientation, delete]. dip = mne.read_dipole(fname_dip)[[12, 15]] # 80ms and 90ms g.add_dipole(dip, name=["rh", "lh"]) dip1, dip2 = g._dipoles.values() assert dip1["active"] and dip2["active"] + + # Each trace is marked with a dot at the time the dipole was fitted, and hovering + # the dipole's row in the GUI emphasizes both. + for dip_dict in (dip1, dip2): + assert dip_dict["dot_artist"].get_xdata() == [dip_dict["dip"].times[0]] + assert_allclose( + dip_dict["dot_artist"].get_ydata(), + np.interp( + dip_dict["dip"].times[0], + evoked.times, + dip_dict["line_artist"].get_ydata(), + ), + atol=0, + ) + from qtpy.QtCore import QEvent + from qtpy.QtWidgets import QApplication + + lw, ms = dip1["line_artist"].get_linewidth(), dip1["dot_artist"].get_markersize() + # Hover the actual Qt widget (the dipole's name field), so that the enter/leave + # event filter is exercised as well. + name_widget = dip1["widgets"][1]._widget + QApplication.sendEvent(name_widget, QEvent(QEvent.Type.Enter)) + assert dip1["line_artist"].get_linewidth() > lw + assert dip1["dot_artist"].get_markersize() > ms + QApplication.sendEvent(name_widget, QEvent(QEvent.Type.Leave)) + assert dip1["line_artist"].get_linewidth() == lw + assert dip1["dot_artist"].get_markersize() == ms + g._on_dipole_hover(99, True) # deleted dipole: no-op rather than an error old_timecourses = np.vstack((dip1["timecourse"], dip2["timecourse"])) dip2["widgets"][0].set_value(False) assert not dip2["active"] @@ -224,7 +370,10 @@ def test_dipolefit_gui_dipole_controls( assert dip2["line_artist"].get_label() == "dipole2" # Remove a dipole (through the "delete" button). + line, dot = dip1["line_artist"], dip1["dot_artist"] dip1["widgets"][3].set_value(None) + assert line not in g._renderer._mplcanvas.axes.lines + assert dot not in g._renderer._mplcanvas.axes.lines assert len(g.dipoles) == 1 assert 1 in g._dipoles # dipole number should not change assert list(g._dipoles.keys())[0] == 1 @@ -237,6 +386,7 @@ def test_dipolefit_gui_dipole_controls( # Fitting the timecourse of a single dipole, with a free orientation. g._on_dipole_toggle(False, 2) # only leave a single dipole active g._on_select_method("Single dipole") + g._renderer._process_events() # run the deferred refit assert dip2["fix_ori"] assert_allclose(dip2["orientation"], dip2["dip"].ori.repeat(len(evoked.times), 0)) g._on_dipole_toggle_fix_orientation(False, dip2["num"]) @@ -411,6 +561,20 @@ def test_dipolefit_stc( assert isinstance(g._stc, mne.SourceEstimate) assert not g._bem["is_sphere"] assert "solution" in g._bem + + # The cortex is drawn translucent so the dipole arrows inside it stay visible. + assert g._stc_brain._alpha == 0.5 + + # The colorbar of the source estimate is registered as a "mesh" that can be toggled, + # and starts out hidden as it takes up a lot of space. + assert g._actors["colorbar"] == [ + g._stc_brain._scalar_bar, + g._stc_brain._scalar_bar_ticks, + ] + assert not any(actor.GetVisibility() for actor in g._actors["colorbar"]) + assert len(g._mesh_widgets["colorbar"]) == 1 # checkbox only, no opacity slider + g._mesh_widgets["colorbar"][0].set_value(True) + assert all(actor.GetVisibility() for actor in g._actors["colorbar"]) g.close() diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py index 2d097d453a5..ca82230e53a 100644 --- a/mne/viz/_3d.py +++ b/mne/viz/_3d.py @@ -2709,6 +2709,10 @@ def _plot_stc( } if brain_kwargs is not None: kwargs.update(brain_kwargs) + # The window is shown at the end instead (unless the caller opted out entirely + # with ``brain_kwargs=dict(show=False)``, e.g. to embed the plot in a larger + # GUI whose window it shows itself, like mne.gui.dipolefit). + show = kwargs.get("show", True) kwargs["show"] = False kwargs["view_layout"] = view_layout with warnings.catch_warnings(record=True): # traits warnings @@ -2771,7 +2775,7 @@ def _plot_stc( if time_viewer: brain.setup_time_viewer(time_viewer=time_viewer, show_traces=show_traces) - else: + elif show: brain.show() return brain diff --git a/mne/viz/backends/_abstract.py b/mne/viz/backends/_abstract.py index f86e4d47ca8..7edb4c879c5 100644 --- a/mne/viz/backends/_abstract.py +++ b/mne/viz/backends/_abstract.py @@ -1124,7 +1124,16 @@ def _dock_add_layout(self, vertical=True): pass @abstractmethod - def _dock_add_label(self, value, *, align=False, layout=None, selectable=False): + def _dock_add_label( + self, + value, + *, + align=False, + layout=None, + selectable=False, + row=None, + col=None, + ): pass @abstractmethod @@ -1156,11 +1165,15 @@ def _dock_add_slider( double=False, tooltip=None, layout=None, + row=None, + col=None, ): pass @abstractmethod - def _dock_add_check_box(self, name, value, callback, *, tooltip=None, layout=None): + def _dock_add_check_box( + self, name, value, callback, *, tooltip=None, layout=None, row=None, col=None + ): pass @abstractmethod @@ -1370,6 +1383,14 @@ def set_tooltip(self, tooltip: str): def set_style(self, style): pass + def set_hover_callbacks(self, enter, leave): + """Call ``enter``/``leave`` when the pointer enters/leaves the widget. + + Hovering is a pointer-only affordance, so backends that have no notion of it + (e.g. notebooks) simply do nothing here. + """ + pass + @abstractmethod def set_items(self, items): pass @@ -1641,6 +1662,34 @@ def _window_set_cursor(self, cursor): def _window_new_cursor(self, name): pass + def _window_set_enabled(self, enabled): + """Enable or disable user interaction with the whole window. + + Blocking input is a pointer/keyboard affordance, so backends that have no + notion of it (e.g. notebooks) simply do nothing here. + """ + pass + + def _window_settle_layouts(self): + """Recompute all pending widget layouts of the window, synchronously. + + Qt lays widgets out lazily, when the posted ``LayoutRequest`` events are + delivered. Calling this before showing a freshly-built window makes it appear + fully composed, instead of visibly assembling on screen. Backends without + lazy layouts (e.g. notebooks) do nothing here. + """ + pass + + def _window_defer(self, callback): + """Run ``callback`` from the event loop instead of the current call stack. + + Use this to run a slow operation triggered by a widget *after* that widget has + finished reacting to the interaction (e.g. a combo box closing its popup) — the + callback runs the next time events are processed. Backends without an event + loop run the callback immediately. + """ + callback() + @abstractmethod def _window_ensure_minimum_sizes(self): pass diff --git a/mne/viz/backends/_notebook.py b/mne/viz/backends/_notebook.py index 9811fd2e066..8c585da2742 100644 --- a/mne/viz/backends/_notebook.py +++ b/mne/viz/backends/_notebook.py @@ -1030,11 +1030,20 @@ def _dock_add_stretch(self, layout=None): def _dock_add_layout(self, vertical=True): return VBox() if vertical else HBox() - def _dock_add_label(self, value, *, align=False, layout=None, selectable=False): + def _dock_add_label( + self, + value, + *, + align=False, + layout=None, + selectable=False, + row=None, + col=None, + ): layout = self._dock_layout if layout is None else layout widget = HTML(value=value, disabled=True) widget.layout.width = "100px" - self._layout_add_widget(layout, widget) + self._layout_add_widget(layout, widget, row=row, col=col) return _IpyWidget(widget) def _dock_add_button( @@ -1080,6 +1089,8 @@ def _dock_add_slider( double=False, tooltip=None, layout=None, + row=None, + col=None, ): layout = self._dock_named_layout(name=name, layout=layout, compact=compact) klass = FloatSlider if double else IntSlider @@ -1090,15 +1101,17 @@ def _dock_add_slider( readout=False, ) widget.observe(_generate_callback(callback), names="value") - self._layout_add_widget(layout, widget) + self._layout_add_widget(layout, widget, row=row, col=col) return _IpyWidget(widget) - def _dock_add_check_box(self, name, value, callback, *, tooltip=None, layout=None): + def _dock_add_check_box( + self, name, value, callback, *, tooltip=None, layout=None, row=None, col=None + ): layout = self._dock_layout if layout is None else layout widget = Checkbox(value=value, description=name, indent=False, disabled=False) hbox = HBox([widget]) # fix stretching to the right widget.observe(_generate_callback(callback), names="value") - self._layout_add_widget(layout, hbox) + self._layout_add_widget(layout, hbox, row=row, col=col) return _IpyWidget(widget) def _dock_add_spin_box( diff --git a/mne/viz/backends/_qt.py b/mne/viz/backends/_qt.py index da433f0053e..38d10dcf95e 100644 --- a/mne/viz/backends/_qt.py +++ b/mne/viz/backends/_qt.py @@ -995,7 +995,16 @@ def _dock_add_layout(self, vertical=True): layout = QVBoxLayout() if vertical else QHBoxLayout() return layout - def _dock_add_label(self, value, *, align=False, layout=None, selectable=False): + def _dock_add_label( + self, + value, + *, + align=False, + layout=None, + selectable=False, + row=None, + col=None, + ): layout = self._dock_layout if layout is None else layout widget = QLabel() if align: @@ -1004,7 +1013,7 @@ def _dock_add_label(self, value, *, align=False, layout=None, selectable=False): widget.setWordWrap(True) if selectable: widget.setTextInteractionFlags(Qt.TextSelectableByMouse) - self._layout_add_widget(layout, widget) + self._layout_add_widget(layout, widget, row=row, col=col) return _QtWidget(widget) def _dock_add_button( @@ -1057,6 +1066,8 @@ def _dock_add_slider( double=False, tooltip=None, layout=None, + row=None, + col=None, ): layout = self._dock_named_layout(name=name, layout=layout, compact=compact) slider_class = QFloatSlider if double else QSlider @@ -1070,16 +1081,18 @@ def _dock_add_slider( widget.floatValueChanged.connect(callback) else: widget.valueChanged.connect(callback) - self._layout_add_widget(layout, widget) + self._layout_add_widget(layout, widget, row=row, col=col) return _QtWidget(widget) - def _dock_add_check_box(self, name, value, callback, *, tooltip=None, layout=None): + def _dock_add_check_box( + self, name, value, callback, *, tooltip=None, layout=None, row=None, col=None + ): layout = self._dock_layout if layout is None else layout widget = QCheckBox(name) _set_widget_tooltip(widget, tooltip) widget.setChecked(value) widget.stateChanged.connect(callback) - self._layout_add_widget(layout, widget) + self._layout_add_widget(layout, widget, row=row, col=col) return _QtWidget(widget) def _dock_add_spin_box( @@ -1808,6 +1821,19 @@ def _window_set_cursor(self, cursor): def _window_new_cursor(self, name): return _qcursor(name) + def _window_set_enabled(self, enabled): + self._window.setEnabled(enabled) + + def _window_settle_layouts(self): + # Activate the deepest layouts first, so that parent layouts (activated + # bottom-up by _qt_activate_layouts below) see settled children. + for layout in reversed(self._window.findChildren(QLayout)): + layout.activate() + _qt_activate_layouts(self._window, self._interactor) + + def _window_defer(self, callback): + QTimer.singleShot(0, callback) + @contextmanager def _window_ensure_minimum_sizes(self): sz = self.figure.store["window_size"] @@ -1986,6 +2012,15 @@ def set_style(self, style): for key, val in style.items(): stylesheet = stylesheet + f"{key}:{val};" self._widget.setStyleSheet(stylesheet) + # Restyling a QLineEdit can scroll it to the end of its text; scroll back so + # the beginning of the text stays visible. + if hasattr(self._widget, "setCursorPosition"): + self._widget.setCursorPosition(0) + + def set_hover_callbacks(self, enter, leave): + # keep a reference, otherwise the filter is garbage collected right away + self._hover_filter = _QtHoverFilter(enter, leave) + self._widget.installEventFilter(self._hover_filter) def set_items(self, items): self._widget.blockSignals(True) @@ -1994,6 +2029,23 @@ def set_items(self, items): self._widget.blockSignals(False) +class _QtHoverFilter(QObject): + """Translate Qt enter/leave events into plain callbacks.""" + + def __init__(self, enter, leave): + super().__init__() + self._enter = enter + self._leave = leave + + def eventFilter(self, obj, event): # noqa: N802 + """Handle enter and leave events (Qt API).""" + if event.type() == QEvent.Type.Enter: + self._enter() + elif event.type() == QEvent.Type.Leave: + self._leave() + return False # never consume the event + + class _QtDialogCommunicator(QObject): signal_show = Signal() diff --git a/mne/viz/backends/_utils.py b/mne/viz/backends/_utils.py index 43ce4690a55..590b5a96665 100644 --- a/mne/viz/backends/_utils.py +++ b/mne/viz/backends/_utils.py @@ -228,8 +228,7 @@ def _init_mne_qtapp(enable_icon=True, pg_app=False, splash=False): qsplash = _splash_class()(*args) qsplash.setAttribute(Qt.WA_ShowWithoutActivating, True) if isinstance(splash, str): - alignment = int(Qt.AlignBottom | Qt.AlignHCenter) - qsplash.showMessage(splash, alignment=alignment, color=Qt.white) + _splash_message(qsplash, splash) qsplash.show() app.processEvents() out = (out, qsplash) @@ -237,6 +236,18 @@ def _init_mne_qtapp(enable_icon=True, pg_app=False, splash=False): return out +def _splash_message(splash, message): + """Show a message at the bottom of a splash screen from ``_init_mne_qtapp``. + + ``QSplashScreen.showMessage`` repaints the splash screen synchronously, so this + can be used to narrate the startup of a GUI while its window is not up yet. + """ + from qtpy.QtCore import Qt + + alignment = int(Qt.AlignBottom | Qt.AlignHCenter) + splash.showMessage(message, alignment=alignment, color=Qt.white) + + def _display_is_valid(): # Adapted from matplotilb _c_internal_utils.py if sys.platform != "linux": diff --git a/mne/viz/evoked_field.py b/mne/viz/evoked_field.py index 3f658485b8c..5e3901dc79b 100644 --- a/mne/viz/evoked_field.py +++ b/mne/viz/evoked_field.py @@ -52,9 +52,14 @@ class EvokedField: %(n_jobs)s fig : instance of Figure3D | None If None (default), a new figure will be created, otherwise it will - plot into the given figure. + plot into the given figure. When a figure is given, the caller is in charge + of its presentation: the camera, the interaction style, and showing the + window. .. versionadded:: 0.20 + .. versionchanged:: 1.13 + When a figure is given, the camera and interaction style are no longer + changed, and the figure is no longer shown. vmax : float | dict | None Maximum intensity. Can be a dictionary with two entries ``"eeg"`` and ``"meg"`` to specify separate values for EEG and MEG fields respectively. Can be @@ -71,6 +76,10 @@ class EvokedField: contour_line_width : float The line_width of the contour lines. + .. versionadded:: 1.12 + contour_line_opacity : float + The opacity of the contour lines (between 0 and 1). + .. versionadded:: 1.12 show_density : bool Whether to draw the field density as an overlay on top of the helmet/head @@ -127,6 +136,7 @@ def __init__( vmax=None, n_contours=21, contour_line_width=1, + contour_line_opacity=1.0, show_density=True, alpha=None, interpolation="nearest", @@ -151,6 +161,7 @@ def __init__( self._vmax = _validate_type(vmax, (None, "numeric", dict), "vmax") self._n_contours = _ensure_int(n_contours, "n_contours") self._contour_line_width = contour_line_width + self._contour_line_opacity = contour_line_opacity self._time_interpolation = _check_option( "interpolation", interpolation, @@ -204,9 +215,11 @@ def __init__( from ._brain import Brain + # When plotting into an existing figure (Brain or Figure3D), the caller owns + # the presentation: the camera, the interaction style and showing the window. + self._own_figure = fig is None if isinstance(fig, Brain): self._renderer = fig._renderer - self._in_brain_figure = True self._units = fig._units if _get_3d_backend() == "notebook": raise NotImplementedError( @@ -215,7 +228,6 @@ def __init__( ) else: self._renderer = _get_renderer(fig, bgcolor=background, size=(600, 600)) - self._in_brain_figure = False self._units = "m" self.interaction = interaction @@ -263,7 +275,7 @@ def current_time_func(): subscribe(self, "colormap_range", self._on_colormap_range) subscribe(self, "contours", self._on_contours) - if not self._in_brain_figure: + if self._own_figure: self._renderer.set_interaction(interaction) self._renderer.set_camera(azimuth=10, elevation=60, distance="auto") self._renderer.show() @@ -349,6 +361,7 @@ def _prepare_surf_map(self, surf_map, color, alpha): vmax=map_vmax, colormap=self._colormap_lines, width=self._contour_line_width, + opacity=self._contour_line_opacity, ) else: contours = None # noqa @@ -384,6 +397,8 @@ def _update(self): vmin=-surf_map["map_vmax"], vmax=surf_map["map_vmax"], colormap=self._colormap_lines, + width=self._contour_line_width, + opacity=self._contour_line_opacity, ) if self._time_label is not None: if hasattr(self, "_time_label_actor"): @@ -484,6 +499,19 @@ def _set_contour_line_width(line_width): double=True, layout=layout, ) + + @_auto_weakref + def _set_contour_line_opacity(opacity): + self.set_contour_line_opacity(opacity) + + self._widgets["contour_line_opacity"] = r._dock_add_slider( + name="Opacity", + value=self._contour_line_opacity, + rng=[0, 1], + callback=_set_contour_line_opacity, + double=True, + layout=layout, + ) r._dock_finalize() def _on_time_change(self, event): @@ -632,3 +660,19 @@ def set_contour_line_width(self, line_width): """ self._contour_line_width = line_width self.set_contours(self._n_contours) + + def set_contour_line_opacity(self, opacity): + """Set the opacity of the contour lines. + + Parameters + ---------- + opacity : float + The desired opacity of the contour lines (between 0 and 1). + """ + self._contour_line_opacity = opacity + widget = self._widgets.get("contour_line_opacity", None) + if widget is not None and widget.get_value() != opacity: + # this re-enters this method through the widget callback, which is where + # the redraw below then happens + widget.set_value(opacity) + self._update() diff --git a/mne/viz/tests/test_3d.py b/mne/viz/tests/test_3d.py index d9b23fd6bcd..f0d98188c91 100644 --- a/mne/viz/tests/test_3d.py +++ b/mne/viz/tests/test_3d.py @@ -227,11 +227,17 @@ def test_plot_evoked_field(renderer): maps, time_viewer=True, contour_line_width=2, + contour_line_opacity=0.5, background="white", foreground="black", ) assert fig._contour_line_width == 2 assert fig._widgets["contour_line_width"].get_value() == 2 + assert fig._contour_line_opacity == 0.5 + assert fig._widgets["contour_line_opacity"].get_value() == 0.5 + fig.set_contour_line_opacity(0.8) + assert fig._contour_line_opacity == 0.8 + assert fig._widgets["contour_line_opacity"].get_value() == 0.8 fig._rescale() fig.set_time(0.05) assert fig._current_time == 0.05 diff --git a/tools/vulture_allowlist.py b/tools/vulture_allowlist.py index 1fa94c4a34e..5d529ceb9b1 100644 --- a/tools/vulture_allowlist.py +++ b/tools/vulture_allowlist.py @@ -189,3 +189,6 @@ # Read by numpydoc's ClassDoc (also set in doc/conf.py) _.extra_public_methods + +# Accessed through an attribute-path string by the _qt_safe_window decorator +_._init_renderer diff --git a/tutorials/inverse/21_interactive_dipole_fit.py b/tutorials/inverse/21_interactive_dipole_fit.py index 62ce94d449a..89e765d6617 100644 --- a/tutorials/inverse/21_interactive_dipole_fit.py +++ b/tutorials/inverse/21_interactive_dipole_fit.py @@ -115,6 +115,18 @@ fitting_gui.set_time(0.085) fitting_gui.fit_dipole() +# %% +# Adjusting the 3D view +# ~~~~~~~~~~~~~~~~~~~~~ +# Each dipole is drawn deep inside the head, so the "Meshes" panel on the left offers a +# visibility checkbox and an opacity slider for every surface (the cortex, the head, the +# MEG helmet, the sensors, and the colorbar of the distributed source estimate) to +# uncover whatever the fit needs. Underneath are buttons for the five standard views of +# the head. Both have programmatic equivalents: + +fitting_gui.toggle_mesh("helmet", show=False) +fitting_gui.set_mesh_opacity("head", 0.1) + # %% # Selecting channels to guide the ECD modeling # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~