Skip to content
Merged
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
657 changes: 532 additions & 125 deletions mne/gui/_dipolefit.py

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions mne/gui/_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
168 changes: 166 additions & 2 deletions mne/gui/tests/test_dipolefit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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]
Expand Down Expand Up @@ -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()


Expand All @@ -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"]
Expand Down Expand Up @@ -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
Expand All @@ -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"])
Expand Down Expand Up @@ -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()


Expand Down
6 changes: 5 additions & 1 deletion mne/viz/_3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
53 changes: 51 additions & 2 deletions mne/viz/backends/_abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading