Skip to content

Commit 11605a7

Browse files
Gnefilwmvanvliet
andauthored
ENH: Implement TimeChange behaviour for evoked_topo and recursive option in link (#13968)
Co-authored-by: Marijn van Vliet <w.m.vanvliet@gmail.com>
1 parent 89ece45 commit 11605a7

9 files changed

Lines changed: 126 additions & 15 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
In :func:`mne.viz.iter_topography`, the ``on_pick`` callable for sub-figures now has a new parameter ``orig_fig`` that refers to the main figure. To preserve backward compatibility, it is made optional. By `Lifeng Qiu Lin`_.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add ``TimeChange`` behaviour for :func:`mne.viz.plot_evoked_topo`, where time is marked with solid vertical line after clicking on subfigure axis.
2+
Add ``recursive`` parameter to :func:`mne.viz.ui_events.link` that enables linking to a target figure, as well as all figures that the target figure links to, by `Lifeng Qiu Lin`_.

mne/evoked.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -614,12 +614,6 @@ def plot_topo(
614614
select=False,
615615
show=True,
616616
):
617-
""".
618-
619-
Notes
620-
-----
621-
.. versionadded:: 0.10.0
622-
"""
623617
return plot_evoked_topo(
624618
self,
625619
layout=layout,

mne/viz/evoked.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,6 +1243,14 @@ def plot_evoked_topo(
12431243
-------
12441244
fig : instance of matplotlib.figure.Figure
12451245
Images of evoked responses at sensor locations.
1246+
1247+
Notes
1248+
-----
1249+
The figure will publish and subscribe to the following UI events:
1250+
1251+
* :class:`~mne.viz.ui_events.TimeChange`
1252+
1253+
.. versionadded:: 1.13.0
12461254
"""
12471255
if type(evoked) not in (tuple, list):
12481256
evoked = [evoked]

mne/viz/tests/test_topo.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
mne_analyze_colormap,
2626
plot_evoked_topo,
2727
plot_topo_image_epochs,
28+
ui_events,
2829
)
2930
from mne.viz.evoked import _line_plot_onselect
3031
from mne.viz.topo import _imshow_tfr, _plot_update_evoked_topo_proj, iter_topography
@@ -274,6 +275,16 @@ def test_plot_topo_single_ch():
274275
assert isinstance(ax._cursorline, matplotlib.lines.Line2D)
275276
_fake_click(fig, ax, (1.5, 1.5), kind="motion") # cursor should disappear
276277
assert ax._cursorline is None
278+
# test select bar
279+
_fake_click(fig, ax, (1.5, 1.5), kind="press")
280+
assert ax._selectline is None
281+
_fake_click(fig, ax, (0.5, 0.5), kind="press")
282+
assert isinstance(ax._selectline, matplotlib.lines.Line2D)
283+
init_time = ax._selectline.get_xdata()[0]
284+
assert init_time == 0.0
285+
_fake_click(fig, ax, (0.6, 0.5), kind="press")
286+
changed_time = ax._selectline.get_xdata()[0]
287+
assert changed_time != init_time
277288
plt.close("all")
278289

279290

@@ -334,6 +345,32 @@ def test_plot_topo_select():
334345
assert fig.lasso.selection == ["MEG 0111", "MEG 0132", "MEG 0133", "MEG 0131"]
335346

336347

348+
def test_plot_topo_timechange():
349+
"""Test that time change events are properly handled in plot_evoked_topo."""
350+
evoked = _get_epochs().average()
351+
fig = plot_evoked_topo(evoked)
352+
_fake_click(fig, fig.axes[0], (0.08, 0.65)) # open single channel
353+
subfig = plt.gcf()
354+
ax = plt.gca()
355+
_fake_click(subfig, ax, (0.5, 0.5), kind="press")
356+
init_time = ax._selectline.get_xdata()[0]
357+
assert init_time == 0.0
358+
359+
# test existence of _current_time in main figure
360+
assert hasattr(fig, "_current_time")
361+
assert fig._current_time == 0.0
362+
363+
# test time change event from subfig and main fig
364+
ui_events.publish(subfig, ui_events.TimeChange(time=0.1))
365+
assert ax._selectline.get_xdata()[0] == 0.1
366+
assert fig._current_time == 0.1
367+
ui_events.publish(subfig, ui_events.TimeChange(time=0.2))
368+
assert ax._selectline.get_xdata()[0] == 0.2
369+
assert fig._current_time == 0.2
370+
371+
plt.close("all")
372+
373+
337374
def test_plot_tfr_topo():
338375
"""Test plotting of TFR data."""
339376
epochs = _get_epochs()

mne/viz/tests/test_ui_events.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,9 +234,19 @@ def callback(event):
234234
ui_events.publish(fig2, ui_events.TimeChange(time=10.2))
235235
assert len(callback_calls) == 2 # Only called for both figures once
236236

237+
# Test recursive linking.
238+
fig3 = plt.figure()
239+
ui_events.subscribe(fig3, "time_change", callback)
240+
ui_events.link(fig2, fig3, recursive=True) # fig1 and fig2 are already linked
241+
callback_calls.clear()
242+
ui_events.publish(fig3, ui_events.TimeChange(time=10.2))
243+
ui_events.publish(fig1, ui_events.TimeChange(time=10.2))
244+
assert len(callback_calls) == 6 # Called for all three figures twice
245+
237246
# Test cleanup
238247
fig1.canvas.callbacks.process("close_event", None)
239248
fig2.canvas.callbacks.process("close_event", None)
249+
fig3.canvas.callbacks.process("close_event", None)
240250
assert len(event_channels) == 0
241251
assert len(event_channel_links) == 0
242252

mne/viz/topo.py

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,21 @@
66

77
from copy import deepcopy
88
from functools import partial
9+
from inspect import signature
910

1011
import numpy as np
1112
from scipy import ndimage
1213

1314
from .._fiff.pick import _FNIRS_CH_TYPES_SPLIT, _picks_to_idx, channel_type, pick_types
1415
from ..defaults import _handle_default
1516
from ..utils import Bunch, _check_option, _clean_names, _is_numeric, _to_rgb, fill_doc
16-
from .ui_events import ChannelsSelect, publish, subscribe
17+
from .ui_events import (
18+
ChannelsSelect,
19+
TimeChange,
20+
link,
21+
publish,
22+
subscribe,
23+
)
1724
from .utils import (
1825
DraggableColorbar,
1926
SelectFromCollection,
@@ -57,7 +64,7 @@ def iter_topography(
5764
on_pick : callable | None
5865
The callback function to be invoked on clicking one
5966
of the axes. Is supposed to instantiate the following
60-
API: ``function(axis, channel_index)``.
67+
API: ``function(axis, channel_index, orig_fig)``.
6168
fig : matplotlib.figure.Figure | None
6269
The figure object to be considered. If None, a new
6370
figure will be created.
@@ -413,14 +420,18 @@ def _plot_topo_onpick(event, show_func):
413420
return
414421
ch_idx = orig_ax._mne_ch_idx
415422
face_color = orig_ax._mne_ax_face_color
416-
fig, ax = plt.subplots(1)
423+
subfig, ax = plt.subplots(1)
417424

418425
plt.title(orig_ax._mne_ch_name)
419426
ax.set_facecolor(face_color)
420427

421428
# allow custom function to override parameters
422-
show_func(ax, ch_idx)
423-
plt_show(fig=fig)
429+
if "orig_fig" in signature(show_func).parameters:
430+
show_func(ax, ch_idx, orig_fig=fig)
431+
plt_show(fig=subfig)
432+
else:
433+
show_func(ax, ch_idx)
434+
plt_show(fig=fig)
424435

425436
except Exception as err:
426437
# matplotlib silently ignores exceptions in event handlers,
@@ -568,6 +579,7 @@ def _plot_timeseries(
568579
hline=None,
569580
hvline_color="w",
570581
labels=None,
582+
orig_fig=None,
571583
):
572584
"""Show time series on topo split across multiple axes."""
573585
import matplotlib.pyplot as plt
@@ -627,7 +639,7 @@ def _cursor_vline(event):
627639
return
628640
if ax._cursorline is not None:
629641
ax._cursorline.remove()
630-
ax._cursorline = ax.axvline(event.xdata, color=ax._cursorcolor)
642+
ax._cursorline = ax.axvline(event.xdata, color=ax._cursorcolor, alpha=0.2)
631643
ax.figure.canvas.draw()
632644

633645
def _rm_cursor(event):
@@ -637,14 +649,35 @@ def _rm_cursor(event):
637649
ax._cursorline = None
638650
ax.figure.canvas.draw()
639651

652+
def _on_click(event):
653+
if event.inaxes == ax:
654+
publish(ax.figure, TimeChange(time=event.xdata))
655+
656+
def _on_time_change_sub(event):
657+
_update_selectline(event.time)
658+
659+
def _update_selectline(time):
660+
if ax._selectline is not None:
661+
ax._selectline.remove()
662+
ax._selectline = ax.axvline(time, color=ax._selectcolor, alpha=1)
663+
ax.figure.canvas.draw()
664+
640665
ax._cursorline = None
641666
# choose cursor color based on perceived brightness of background
642667
facecol = _to_rgb(ax.get_facecolor())
643668
face_brightness = np.dot(facecol, [299, 587, 114])
644669
ax._cursorcolor = "white" if face_brightness < 150 else "black"
645670

671+
ax._selectline = None
672+
ax._selectcolor = "white" if face_brightness < 150 else "black"
673+
646674
plt.connect("motion_notify_event", _cursor_vline)
647675
plt.connect("axes_leave_event", _rm_cursor)
676+
plt.connect("button_press_event", _on_click)
677+
678+
subscribe(ax.figure, "time_change", _on_time_change_sub)
679+
680+
link(orig_fig, ax.figure, recursive=True)
648681

649682
ymin, ymax = ax.get_ylim()
650683
# don't pass vline or hline here (this fxn doesn't do hvline_color):
@@ -1139,6 +1172,14 @@ def _plot_evoked_topo(
11391172
select=select,
11401173
)
11411174

1175+
setattr(fig, "_current_time", None)
1176+
1177+
def on_time_change(event, fig):
1178+
"""Respond to a time change UI event."""
1179+
fig._current_time = event.time
1180+
1181+
subscribe(fig, "time_change", partial(on_time_change, fig=fig))
1182+
11421183
add_background_image(fig, fig_background)
11431184

11441185
if legend is not False:

mne/viz/ui_events.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,9 @@ def unsubscribe(fig, event_names, callback=None, *, verbose=None):
411411

412412

413413
@verbose
414-
def link(*figs, include_events=None, exclude_events=None, verbose=None):
414+
def link(
415+
*figs, include_events=None, exclude_events=None, recursive=False, verbose=None
416+
):
415417
"""Link the event channels of two figures together.
416418
417419
When event channels are linked, any events that are published on one
@@ -430,6 +432,9 @@ def link(*figs, include_events=None, exclude_events=None, verbose=None):
430432
exclude_events : list of str | None
431433
Select which events not to publish across figures. By default (``None``),
432434
no events are excluded.
435+
recursive : bool
436+
If ``True``, also link the existing link-groups that figs already belong
437+
to, so all members are mutually linked.
433438
%(verbose)s
434439
"""
435440
if include_events is not None:
@@ -443,7 +448,17 @@ def link(*figs, include_events=None, exclude_events=None, verbose=None):
443448
if fig not in _event_channel_links:
444449
_event_channel_links[fig] = weakref.WeakKeyDictionary()
445450

446-
# Link the event channels
451+
if recursive:
452+
# Also link to anything that is linked to any of the figures in `figs`.
453+
figs_set = weakref.WeakSet()
454+
for fig in figs:
455+
figs_set.add(fig)
456+
for linked_fig in _event_channel_links[fig].keys():
457+
figs_set.add(linked_fig)
458+
# Deliberately let current include and exclude events dominate over past ones.
459+
figs = figs_set
460+
461+
# Do the actual linking.
447462
for fig1 in figs:
448463
for fig2 in figs:
449464
if fig1 is not fig2:

tutorials/evoked/20_visualize_evoked.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,10 @@ def custom_func(x):
269269
#
270270
# In interactive sessions, both approaches to topographical plotting allow
271271
# you to click one of the sensor subplots to open a larger version of the
272-
# evoked plot at that sensor.
272+
# evoked plot at that sensor. In this view, you can select a time by clicking
273+
# somewhere in the timecourse. The selected time is marked with a vertical line on
274+
# all channels, also in the original figure and any other single-channel figures
275+
# that are open.
273276
#
274277
#
275278
# 3D Field Maps

0 commit comments

Comments
 (0)