Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cross-figure event passing system #11685

Merged
merged 59 commits into from
Aug 16, 2023
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
28722d2
First version of the events API
wmvanvliet May 10, 2023
47076b5
Add some interaction capabilities to Brain and topomap
wmvanvliet May 10, 2023
e2ff979
Add basic example of linking the event channels of two figures
wmvanvliet May 10, 2023
aed7fad
Event -> UIEvent
wmvanvliet May 10, 2023
76da046
Update UI-events example
wmvanvliet May 10, 2023
c739527
Better cleanup?
wmvanvliet May 10, 2023
1c3dbb8
Attempt at making the example work better
wmvanvliet May 10, 2023
601b5c3
Some tweaks to docs and mark the global UI event channels private
wmvanvliet May 12, 2023
5a3b6fa
Add unit tests for core UI event system
wmvanvliet May 12, 2023
6026fe9
black
wmvanvliet May 12, 2023
216831c
More black
wmvanvliet May 12, 2023
20758df
Even more black
wmvanvliet May 12, 2023
c47da0a
docstyle
wmvanvliet May 12, 2023
08c2c2d
Fix nesting
wmvanvliet May 12, 2023
a919d72
Remove some remaining instances of update_width=True
wmvanvliet May 12, 2023
a516c00
Small tweak to unit test
wmvanvliet May 16, 2023
f5f6e8c
Make events work with the notebook Brain renderer too.
wmvanvliet May 16, 2023
6108aaf
Allow selecting which events to link across figures.
wmvanvliet May 25, 2023
8d706e7
Fix some more callbacks
wmvanvliet Jun 6, 2023
06008a2
Change function argument name
wmvanvliet Jun 6, 2023
3371922
Remove debug print statement
wmvanvliet Jun 6, 2023
753e2fb
More fixes
wmvanvliet Jun 9, 2023
a6ad411
black
wmvanvliet Jun 9, 2023
22c0d83
Fix linked brain viewer
wmvanvliet Jun 9, 2023
de75789
Doest his work better?
wmvanvliet Jun 12, 2023
4d64d27
Small fixes
wmvanvliet Jun 14, 2023
1abb936
Merge branch 'events' of github.com:wmvanvliet/mne-python into events
wmvanvliet Jun 14, 2023
7e5a6c0
Add missing requires_testing_data decorator
wmvanvliet Jun 21, 2023
48cdda8
Mark test as requiring pyvista
wmvanvliet Jun 21, 2023
c1700c2
Merge branch 'main' of github.com:mne-tools/mne-python into events
wmvanvliet Jun 22, 2023
d2ad8df
Merge remote-tracking branch 'upstream/main' into events
wmvanvliet Jul 16, 2023
7fc354d
Merge branch 'main' into events
wmvanvliet Aug 8, 2023
4a04603
Configure renderer during test
wmvanvliet Aug 8, 2023
9eb1c72
Revert edits done to Brain
wmvanvliet Aug 10, 2023
89a8f72
Remove %(figure)s
wmvanvliet Aug 10, 2023
3d59fd0
Merge branch 'main' into events
wmvanvliet Aug 10, 2023
46fbb3f
Restore _abstract.py
wmvanvliet Aug 10, 2023
144eedd
Restore _abstract.py
wmvanvliet Aug 10, 2023
e572271
Merge branch 'events' of github.com:wmvanvliet/mne-python into events
wmvanvliet Aug 10, 2023
430cc68
Update docs
wmvanvliet Aug 10, 2023
4bc359c
Lessen tests
wmvanvliet Aug 10, 2023
be5e5ec
Add more details to the example
wmvanvliet Aug 11, 2023
f0a8a46
Add event information to plot_evoked_topomap
wmvanvliet Aug 11, 2023
24470dc
Update what's new
wmvanvliet Aug 11, 2023
4b243fd
Undo _last_event changes
wmvanvliet Aug 11, 2023
c721c48
Add unsubscribe function
wmvanvliet Aug 14, 2023
b78edd8
Add callback parameter to unsubscribe function
wmvanvliet Aug 14, 2023
588fc51
Merge branch 'main' into events
wmvanvliet Aug 14, 2023
33065df
Update examples/visualization/ui_events.py
wmvanvliet Aug 14, 2023
b67da0f
Update examples/visualization/ui_events.py
wmvanvliet Aug 14, 2023
d3a880f
Apply suggestions from code review
wmvanvliet Aug 14, 2023
7bc5890
Move publication_figure and ui_events examples to the tutorial section
wmvanvliet Aug 15, 2023
e87c82e
Merge branch 'main' into events
wmvanvliet Aug 15, 2023
bf53637
Add disable_ui_events context manager
wmvanvliet Aug 16, 2023
bb724f3
Remove debug print() calls
wmvanvliet Aug 16, 2023
ecc4b3d
Merge branch 'main' into events
wmvanvliet Aug 16, 2023
02f4bc4
Update what's new
wmvanvliet Aug 16, 2023
aead573
MAINT: Avoid globals
larsoner Aug 16, 2023
6bd2195
FIX: Where
larsoner Aug 16, 2023
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
4 changes: 4 additions & 0 deletions doc/visualization.rst
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,7 @@ Visualization
set_browser_backend
get_browser_backend
use_browser_backend
ui_events.publish
ui_events.subscribe
ui_events.link
ui_events.unlink
47 changes: 47 additions & 0 deletions examples/visualization/ui_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""
.. _ex-plot-events:

======================================
Using the event system to link figures
======================================

Many of MNE-Python's figures are interactive. For example, you can select channels or
scroll through time. The event system allows you to link figures together so that
interacting with one figure will simultaneously update another figure.

In this example, we'll be linking an evoked plot to a source estimate plot, such that
selecting the time in one will also update the time in the other.

Since the figures on our website don't have any interaction capabilities, this example
will only work properly when run in an interactive environment.
"""
# Author: Marijn van Vliet <w.m.vanvliet@gmail.com>
#
# License: BSD-3-Clause

# %%
# Load some evoked and source data to plot.
import matplotlib.pyplot as plt
import mne

data_path = mne.datasets.sample.data_path()
evoked = mne.read_evokeds(
data_path / "MEG" / "sample" / "sample_audvis-ave.fif", condition="Left Auditory"
)
evoked.apply_baseline()
stc = mne.read_source_estimate(data_path / "MEG" / "sample" / "sample_audvis-meg-eeg")
evoked.crop(0, stc.times[-1])

# %%
# Enable interactivity. I'm not sure exactly why we need this.
plt.ion()

# %%
# Plot both the source estimate plot, with time interaction enabled, and a sensor-level
# topomap. Then link the figures together, so they can communicate. What kind of
# information is communicated between figures depends on the figure types. In this case,
# the information about the currently selected time is shared.
fig1 = stc.plot("sample", subjects_dir=data_path / "subjects", initial_time=0.1)
fig1.set_time_interpolation("linear")
fig2 = evoked.plot_topomap("interactive")
mne.viz.ui_events.link(fig1, fig2)
2 changes: 2 additions & 0 deletions mne/utils/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -1511,6 +1511,8 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75):
and if absent, falls back to ``'estimated'``.
"""

docdict["figure"] = "matplotlib.figure.Figure | " "MNEFigure | " "Figure3D | " "Brain"

docdict[
"fig_facecolor"
] = """\
Expand Down
1 change: 1 addition & 0 deletions mne/viz/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,4 @@
from ._brain import Brain
from ._figure import get_browser_backend, set_browser_backend, use_browser_backend
from ._proj import plot_projs_joint
from . import ui_events
64 changes: 42 additions & 22 deletions mne/viz/_brain/_brain.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
UpdateColorbarScale,
)

from .. import ui_events

from ..utils import (
_show_help_fig,
_get_color_list,
Expand Down Expand Up @@ -729,10 +731,7 @@ def reset(self):
self.reset_view()
max_time = len(self._data["time"]) - 1
if max_time > 0:
self.callbacks["time"](
self._data["initial_time_idx"],
update_widget=True,
)
ui_events.publish(self, ui_events.TimeChange(time=self.data["time"][0]))
self._renderer._update()

def set_playback_speed(self, speed):
Expand All @@ -759,15 +758,10 @@ def _advance(self):
delta = this_time - self._last_tick
self._last_tick = time.time()
time_data = self._data["time"]
times = np.arange(self._n_times)
time_shift = delta * self.playback_speed
max_time = np.max(time_data)
time_point = min(self._current_time + time_shift, max_time)
# always use linear here -- this does not determine the data
# interpolation mode, it just finds where we are (in time) in
# terms of the time indices
idx = np.interp(time_point, time_data, times)
self.callbacks["time"](idx, update_widget=True)
ui_events.publish(self, ui_events.TimeChange(time=time_point))
if time_point == max_time:
self.toggle_playback(value=False)

Expand Down Expand Up @@ -822,12 +816,20 @@ def _configure_dock_playback_widget(self, name):
brain=self,
callback=self.plot_time_line,
)

def publish_time_change_event(time_idx):
ui_events.publish(
self,
ui_events.TimeChange(time=self._time_interp_inv(time_idx)),
)

ui_events.subscribe(self, "time_change", self.callbacks["time"])
self.widgets["time"] = self._renderer._dock_add_slider(
name="Time (s)",
value=self._data["time_idx"],
rng=[0, len_time],
double=True,
callback=self.callbacks["time"],
callback=publish_time_change_event,
compact=False,
layout=layout,
)
Expand Down Expand Up @@ -1270,10 +1272,8 @@ def save_movie(filename, weakself=weakself):
)

def _shift_time(self, op):
wmvanvliet marked this conversation as resolved.
Show resolved Hide resolved
self.callbacks["time"](
value=(op(self._current_time, self.playback_speed)),
time_as_index=False,
update_widget=True,
ui_events.publish(
self, ui_events.TimeChange(time=op(self._current_time, self.playback_speed))
)

def _rotate_azimuth(self, value):
Expand Down Expand Up @@ -3149,6 +3149,7 @@ def add_annotation(

def close(self):
"""Close all figures and cleanup data structure."""
print("Closing abstract brain")
self._closed = True
self._renderer.close()

Expand Down Expand Up @@ -3490,7 +3491,7 @@ def set_data_smoothing(self, n_steps):
warn=False,
)
self._data[hemi]["smooth_mat"] = smooth_mat
self.set_time_point(self._data["time_idx"])
self._update_current_time_idx(self._data["time_idx"])
self._data["smoothing_steps"] = n_steps

@property
Expand Down Expand Up @@ -3532,8 +3533,8 @@ def set_time_interpolation(self, interpolation):
)
self._time_interp_inv = _safe_interp1d(idx, self._times)

def set_time_point(self, time_idx):
"""Set the time point shown (can be a float to interpolate).
def _update_current_time_idx(self, time_idx):
"""Update all widgets in the figure to reflect a new time point.

Parameters
----------
Expand Down Expand Up @@ -3604,6 +3605,27 @@ def set_time_point(self, time_idx):
self._data["time_idx"] = time_idx
self._renderer._update()

def set_time_point(self, time_idx):
"""Set the time point to display (can be a float to interpolate).

Parameters
----------
time_idx : int | float
The time index to use. Can be a float to use interpolation
between indices.
"""
if self._times is None:
raise ValueError("Cannot set time when brain has no defined times.")
elif 0 <= time_idx <= len(self._times):
ui_events.publish(
self, ui_events.TimeChange(time=self._time_interp_inv(time_idx))
)
else:
raise ValueError(
f"Requested time point ({time_idx}) is outside the range of "
f"available time points (0-{len(self._times)})."
)

def set_time(self, time):
"""Set the time to display (in seconds).

Expand All @@ -3615,9 +3637,7 @@ def set_time(self, time):
if self._times is None:
raise ValueError("Cannot set time when brain has no defined times.")
elif min(self._times) <= time <= max(self._times):
self.set_time_point(
np.interp(float(time), self._times, np.arange(self._n_times))
)
ui_events.publish(self, ui_events.TimeChange(time=time))
else:
raise ValueError(
f"Requested time ({time} s) is outside the range of "
Expand Down Expand Up @@ -3987,7 +4007,7 @@ def _iter_time(self, time_idx, callback):
Used by movie and image sequence saving functions.
"""
if self.time_viewer:
func = partial(self.callbacks["time"], update_widget=True)
func = partial(self.callbacks["time"])
else:
func = self.set_time_point
current_time_idx = self._data["time_idx"]
Expand Down
2 changes: 1 addition & 1 deletion mne/viz/_brain/_linkviewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ def set_fmax(self, value):

def set_time_point(self, value):
for brain in self.brains:
brain.callbacks["time"](value, update_widget=True)
brain.callbacks["time"](value)

def set_playback_speed(self, value):
for brain in self.brains:
Expand Down
14 changes: 8 additions & 6 deletions mne/viz/_brain/callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,20 @@ def __init__(self, brain=None, callback=None):
else:
self.time_label = None

def __call__(self, value, update_widget=False, time_as_index=True):
def __call__(self, event):
"""Update the time slider."""
if not time_as_index:
value = self.brain._to_time_index(value)
self.brain.set_time_point(value)
if event.time == self.brain._current_time:
return

time_idx = self.brain._to_time_index(event.time)
self.brain._update_current_time_idx(time_idx)
if self.label is not None:
current_time = self.brain._current_time
self.label.set_value(f"{current_time: .3f}")
if self.callback is not None:
self.callback()
if self.widget is not None and update_widget:
self.widget.set_value(int(value))
if self.widget is not None:
self.widget.set_value(int(time_idx))


class UpdateColorbarScale:
Expand Down
5 changes: 3 additions & 2 deletions mne/viz/backends/_abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from contextlib import nullcontext
import warnings

from .. import ui_events
from ..utils import tight_layout


Expand Down Expand Up @@ -1415,7 +1416,7 @@ def __init__(self, brain, width, height, dpi):
"""Initialize the MplCanvas."""
super().__init__(width, height, dpi)
self.brain = brain
self.time_func = brain.callbacks["time"]
# self.time_func = brain.callbacks["time"]

def update_plot(self):
"""Update the plot."""
Expand All @@ -1434,7 +1435,7 @@ def on_button_press(self, event):
# left click (and maybe drag) in progress in axes
if event.inaxes != self.axes or event.button != 1:
return
self.time_func(event.xdata, update_widget=True, time_as_index=False)
ui_events.publish(self.brain, ui_events.TimeChange(time=event.xdata))

on_motion_notify = on_button_press # for now they can be the same

Expand Down
1 change: 1 addition & 0 deletions mne/viz/backends/_pyvista.py
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,7 @@ def show(self):
self.plotter.show()

def close(self):
print("PyvistaQT close")
_close_3d_figure(figure=self.figure)

def get_camera(self):
Expand Down
Loading