From 7266c23d98461132c9dab08dab8f97be9d27fd89 Mon Sep 17 00:00:00 2001 From: NoahMarkowitz Date: Tue, 27 Aug 2024 10:02:51 -0400 Subject: [PATCH 01/15] add event_channel hook to qt --- mne/viz/ui_events.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mne/viz/ui_events.py b/mne/viz/ui_events.py index 256d5741ad3..f018851a44b 100644 --- a/mne/viz/ui_events.py +++ b/mne/viz/ui_events.py @@ -233,6 +233,7 @@ def _get_event_channel(fig): import matplotlib from ._brain import Brain + from ._figure import BrowserBase from .evoked_field import EvokedField # Create the event channel if it doesn't exist yet @@ -263,9 +264,13 @@ def delete_event_channel(event=None, *, weakfig=weakfig): # Hook up the above callback function to the close event of the figure # window. How this is done exactly depends on the various figure types # MNE-Python has. - _validate_type(fig, (matplotlib.figure.Figure, Brain, EvokedField), "fig") + _validate_type( + fig, (matplotlib.figure.Figure, Brain, EvokedField, BrowserBase), "fig" + ) if isinstance(fig, matplotlib.figure.Figure): fig.canvas.mpl_connect("close_event", delete_event_channel) + elif isinstance(fig, BrowserBase): + fig.mne.viewbox.destroyed.connect(delete_event_channel) else: assert hasattr(fig, "_renderer") # figures like Brain, EvokedField, etc. fig._renderer._window_close_connect(delete_event_channel, after=False) From c9e19f1d0bf9ee22020deb41d34d7c8383450a66 Mon Sep 17 00:00:00 2001 From: NoahMarkowitz <34498671+nmarkowitz@users.noreply.github.com> Date: Wed, 28 Aug 2024 09:45:56 -0400 Subject: [PATCH 02/15] Update mne/viz/ui_events.py Co-authored-by: Marijn van Vliet --- mne/viz/ui_events.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mne/viz/ui_events.py b/mne/viz/ui_events.py index f018851a44b..8e76440dca5 100644 --- a/mne/viz/ui_events.py +++ b/mne/viz/ui_events.py @@ -270,7 +270,11 @@ def delete_event_channel(event=None, *, weakfig=weakfig): if isinstance(fig, matplotlib.figure.Figure): fig.canvas.mpl_connect("close_event", delete_event_channel) elif isinstance(fig, BrowserBase): - fig.mne.viewbox.destroyed.connect(delete_event_channel) + # The matplotlib-based browser case is a matplotlib.figure.Figure so is already + # handled. Therefore we can assume this is the QT-based browser. + from mne_qt_browser._pg_figure import MNEQtBrowser + assert isinstance(fig, MNEQtBrowser) + fig.mne.viewbox.destroyed.connect(delete_event_channel) else: assert hasattr(fig, "_renderer") # figures like Brain, EvokedField, etc. fig._renderer._window_close_connect(delete_event_channel, after=False) From 52e446f42cb52ae227c1f0dcd86c47ab88d08975 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 28 Aug 2024 13:46:12 +0000 Subject: [PATCH 03/15] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mne/viz/ui_events.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mne/viz/ui_events.py b/mne/viz/ui_events.py index 8e76440dca5..edeb2be4256 100644 --- a/mne/viz/ui_events.py +++ b/mne/viz/ui_events.py @@ -273,8 +273,9 @@ def delete_event_channel(event=None, *, weakfig=weakfig): # The matplotlib-based browser case is a matplotlib.figure.Figure so is already # handled. Therefore we can assume this is the QT-based browser. from mne_qt_browser._pg_figure import MNEQtBrowser + assert isinstance(fig, MNEQtBrowser) - fig.mne.viewbox.destroyed.connect(delete_event_channel) + fig.mne.viewbox.destroyed.connect(delete_event_channel) else: assert hasattr(fig, "_renderer") # figures like Brain, EvokedField, etc. fig._renderer._window_close_connect(delete_event_channel, after=False) From f83401eca6be02c82726c786e696ec5f08214188 Mon Sep 17 00:00:00 2001 From: Marijn van Vliet Date: Wed, 28 Aug 2024 16:50:11 +0300 Subject: [PATCH 04/15] Add TimeChange event to matplotlib-based browser. --- mne/viz/_mpl_figure.py | 19 +++ mne/viz/ui_events.py | 344 ++++++++++++++++++++--------------------- 2 files changed, 191 insertions(+), 172 deletions(-) diff --git a/mne/viz/_mpl_figure.py b/mne/viz/_mpl_figure.py index 2e552bd4012..e1999bf7c8b 100644 --- a/mne/viz/_mpl_figure.py +++ b/mne/viz/_mpl_figure.py @@ -58,6 +58,7 @@ from ..fixes import _close_event from ..utils import Bunch, _click_ch_name, check_version, logger from ._figure import BrowserBase +from .ui_events import TimeChange, disable_ui_events, publish, subscribe from .utils import ( DraggableLine, _events_off, @@ -566,6 +567,9 @@ def __init__(self, inst, figsize, ica=None, xlabel="Time (s)", **kwargs): vline_text=vline_text, ) + # Start listening to incoming UI events + subscribe(self, "time_change", self._on_time_change_event) + def _get_size(self): return self.get_size_inches() @@ -1738,6 +1742,7 @@ def _update_hscroll(self): """Update the horizontal scrollbar (time) selection indicator.""" self.mne.hsel_patch.set_xy((self.mne.t_start, 0)) self.mne.hsel_patch.set_width(self.mne.duration) + publish(self, TimeChange(time=self.mne.t_start)) def _check_update_hscroll_clicked(self, event): """Handle clicks on horizontal scrollbar.""" @@ -2318,6 +2323,20 @@ def _get_scale_bar_texts(self): return texts + def _on_time_change_event(self, event): + """Respond to the TimeChange UI event.""" + if self.mne.is_epochs: + last_time = self.mne.n_times / self.mne.info["sfreq"] + else: + last_time = self.mne.inst.times[-1] + t_max = last_time - self.mne.duration + old_t_start = self.mne.t_start + self.mne.t_start = np.clip(event.time, self.mne.first_time, t_max) + if self.mne.t_start != old_t_start: + with disable_ui_events(self): + self._update_hscroll() + self._redraw(annotations=True) + class MNELineFigure(MNEFigure): """Interactive figure for non-scrolling line plots.""" diff --git a/mne/viz/ui_events.py b/mne/viz/ui_events.py index edeb2be4256..22dc06e0446 100644 --- a/mne/viz/ui_events.py +++ b/mne/viz/ui_events.py @@ -42,176 +42,6 @@ _camel_to_snake = re.compile(r"(? Date: Fri, 30 Aug 2024 14:03:22 +0300 Subject: [PATCH 05/15] Change behavior of TimeChange event --- mne/viz/_mpl_figure.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/mne/viz/_mpl_figure.py b/mne/viz/_mpl_figure.py index e1999bf7c8b..6e736f3bd3a 100644 --- a/mne/viz/_mpl_figure.py +++ b/mne/viz/_mpl_figure.py @@ -1742,7 +1742,6 @@ def _update_hscroll(self): """Update the horizontal scrollbar (time) selection indicator.""" self.mne.hsel_patch.set_xy((self.mne.t_start, 0)) self.mne.hsel_patch.set_width(self.mne.duration) - publish(self, TimeChange(time=self.mne.t_start)) def _check_update_hscroll_clicked(self, event): """Handle clicks on horizontal scrollbar.""" @@ -2232,6 +2231,7 @@ def _show_vline(self, xdata): text = self._xtick_formatter(xdata, ax_type="vline")[:12] self.mne.vline_text.set_text(text) self._toggle_vline(True) + publish(self, TimeChange(time=xdata)) def _toggle_vline(self, visible): """Show or hide the vertical line(s).""" @@ -2326,16 +2326,12 @@ def _get_scale_bar_texts(self): def _on_time_change_event(self, event): """Respond to the TimeChange UI event.""" if self.mne.is_epochs: - last_time = self.mne.n_times / self.mne.info["sfreq"] + # last_time = self.mne.n_times / self.mne.info["sfreq"] + time = event.time else: - last_time = self.mne.inst.times[-1] - t_max = last_time - self.mne.duration - old_t_start = self.mne.t_start - self.mne.t_start = np.clip(event.time, self.mne.first_time, t_max) - if self.mne.t_start != old_t_start: - with disable_ui_events(self): - self._update_hscroll() - self._redraw(annotations=True) + time = np.clip(event.time, self.mne.inst.times[0], self.mne.inst.times[-1]) + with disable_ui_events(self): + self._show_vline(time) class MNELineFigure(MNEFigure): From 1e479168b963f079abcef9c705709960bb2661e2 Mon Sep 17 00:00:00 2001 From: NoahMarkowitz Date: Sat, 31 Aug 2024 17:51:36 -0400 Subject: [PATCH 06/15] add ui events --- mne/viz/ui_events.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/mne/viz/ui_events.py b/mne/viz/ui_events.py index 22dc06e0446..b798211c96e 100644 --- a/mne/viz/ui_events.py +++ b/mne/viz/ui_events.py @@ -370,6 +370,46 @@ class TimeChange(UIEvent): time: float +@dataclass +@fill_doc +class TimeBrowse(UIEvent): + """Indicates that the user has selected a range of time. + + Parameters + ---------- + time : tuple of float + The new time range in seconds. + + Attributes + ---------- + %(ui_event_name_source)s + time : tuple of float + The new time range in seconds. + """ + + time: tuple(float, float) + + +@dataclass +@fill_doc +class ChannelBrowse(UIEvent): + """Indicates that the user has selected a channel. + + Parameters + ---------- + channel : str + The new channel name. + + Attributes + ---------- + %(ui_event_name_source)s + channel : str + The new channel name. + """ + + channel: str + + @dataclass @fill_doc class PlaybackSpeed(UIEvent): From 09ae6c6cc7a3ed1aa5e3271c92935dda2ac41ebb Mon Sep 17 00:00:00 2001 From: NoahMarkowitz <34498671+nmarkowitz@users.noreply.github.com> Date: Thu, 5 Sep 2024 14:26:42 -0400 Subject: [PATCH 07/15] Update mne/viz/ui_events.py Co-authored-by: Marijn van Vliet --- mne/viz/ui_events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/viz/ui_events.py b/mne/viz/ui_events.py index b798211c96e..d9d8ff7f8b8 100644 --- a/mne/viz/ui_events.py +++ b/mne/viz/ui_events.py @@ -373,7 +373,7 @@ class TimeChange(UIEvent): @dataclass @fill_doc class TimeBrowse(UIEvent): - """Indicates that the user has selected a range of time. + """Indicates that the user has browsed to a new time range. Parameters ---------- From 50f4a74ff665793e24c3a59653caf5cc968d0f00 Mon Sep 17 00:00:00 2001 From: NoahMarkowitz <34498671+nmarkowitz@users.noreply.github.com> Date: Thu, 5 Sep 2024 14:26:47 -0400 Subject: [PATCH 08/15] Update mne/viz/ui_events.py Co-authored-by: Marijn van Vliet --- mne/viz/ui_events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/viz/ui_events.py b/mne/viz/ui_events.py index d9d8ff7f8b8..78a7259efec 100644 --- a/mne/viz/ui_events.py +++ b/mne/viz/ui_events.py @@ -393,7 +393,7 @@ class TimeBrowse(UIEvent): @dataclass @fill_doc class ChannelBrowse(UIEvent): - """Indicates that the user has selected a channel. + """Indicates that the user has browsed to a new range of channels. Parameters ---------- From 23119d1ddb6bd521b811ee5d6b5ece43a8613dc9 Mon Sep 17 00:00:00 2001 From: NoahMarkowitz <34498671+nmarkowitz@users.noreply.github.com> Date: Thu, 5 Sep 2024 14:26:54 -0400 Subject: [PATCH 09/15] Update mne/viz/ui_events.py Co-authored-by: Marijn van Vliet --- mne/viz/ui_events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/viz/ui_events.py b/mne/viz/ui_events.py index 78a7259efec..6edd8de3fd1 100644 --- a/mne/viz/ui_events.py +++ b/mne/viz/ui_events.py @@ -407,7 +407,7 @@ class ChannelBrowse(UIEvent): The new channel name. """ - channel: str + channels: list of str @dataclass From 53ce0e82797c94a9c6cb48b1ff540dbcddf38700 Mon Sep 17 00:00:00 2001 From: NoahMarkowitz <34498671+nmarkowitz@users.noreply.github.com> Date: Thu, 5 Sep 2024 14:28:42 -0400 Subject: [PATCH 10/15] Update mne/viz/ui_events.py Co-authored-by: Marijn van Vliet --- mne/viz/ui_events.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mne/viz/ui_events.py b/mne/viz/ui_events.py index 6edd8de3fd1..9bbb5e53245 100644 --- a/mne/viz/ui_events.py +++ b/mne/viz/ui_events.py @@ -387,7 +387,8 @@ class TimeBrowse(UIEvent): The new time range in seconds. """ - time: tuple(float, float) + time_start: float + time_end: float @dataclass From f65f2cfe41f4053f4be54cbcb405751bddc49dd8 Mon Sep 17 00:00:00 2001 From: NoahMarkowitz Date: Thu, 5 Sep 2024 16:33:57 -0400 Subject: [PATCH 11/15] implement TimeBrowse --- mne/viz/_mpl_figure.py | 7 ++++++- mne/viz/ui_events.py | 22 +++++++++++++--------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/mne/viz/_mpl_figure.py b/mne/viz/_mpl_figure.py index 6e736f3bd3a..770787f5bf7 100644 --- a/mne/viz/_mpl_figure.py +++ b/mne/viz/_mpl_figure.py @@ -58,7 +58,12 @@ from ..fixes import _close_event from ..utils import Bunch, _click_ch_name, check_version, logger from ._figure import BrowserBase -from .ui_events import TimeChange, disable_ui_events, publish, subscribe +from .ui_events import ( + TimeChange, + disable_ui_events, + publish, + subscribe, +) from .utils import ( DraggableLine, _events_off, diff --git a/mne/viz/ui_events.py b/mne/viz/ui_events.py index 9bbb5e53245..c3d0636cca8 100644 --- a/mne/viz/ui_events.py +++ b/mne/viz/ui_events.py @@ -377,14 +377,18 @@ class TimeBrowse(UIEvent): Parameters ---------- - time : tuple of float - The new time range in seconds. + time_start : float + The new start time in seconds. + time_end : float + The new end time in seconds. Attributes ---------- %(ui_event_name_source)s - time : tuple of float - The new time range in seconds. + time_start : float + The new start time in seconds. + time_end : float + The new end time in seconds. """ time_start: float @@ -398,17 +402,17 @@ class ChannelBrowse(UIEvent): Parameters ---------- - channel : str - The new channel name. + channels : list of str + The new channel names. Attributes ---------- %(ui_event_name_source)s - channel : str - The new channel name. + channels : list of str + The new channel names. """ - channels: list of str + channels: list[str] @dataclass From 587775ee62dcd91b2cbaa36d03b28809d3b89aed Mon Sep 17 00:00:00 2001 From: NoahMarkowitz Date: Fri, 6 Sep 2024 13:25:29 -0400 Subject: [PATCH 12/15] ChannelBrowse and TimeBrowse and mpl browser --- mne/viz/_mpl_figure.py | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/mne/viz/_mpl_figure.py b/mne/viz/_mpl_figure.py index 770787f5bf7..9a876ee8df9 100644 --- a/mne/viz/_mpl_figure.py +++ b/mne/viz/_mpl_figure.py @@ -59,6 +59,8 @@ from ..utils import Bunch, _click_ch_name, check_version, logger from ._figure import BrowserBase from .ui_events import ( + ChannelBrowse, + TimeBrowse, TimeChange, disable_ui_events, publish, @@ -572,9 +574,15 @@ def __init__(self, inst, figsize, ica=None, xlabel="Time (s)", **kwargs): vline_text=vline_text, ) - # Start listening to incoming UI events + # Start listening to incoming TimeChange UI events subscribe(self, "time_change", self._on_time_change_event) + # Start listening to incoming TimeBrowse UI events + subscribe(self, "time_browse", self._on_time_browse_event) + + # Start listening to incoming ChannelBrowse UI events + subscribe(self, "channel_browse", self._on_channel_browse_event) + def _get_size(self): return self.get_size_inches() @@ -1743,10 +1751,20 @@ def _update_vscroll(self): self.mne.vsel_patch.set_height(self.mne.n_channels) self._update_yaxis_labels() + # Publish to event system + publish(self, ChannelBrowse(channels=self.mne.ch_names[self.mne.picks])) + def _update_hscroll(self): """Update the horizontal scrollbar (time) selection indicator.""" self.mne.hsel_patch.set_xy((self.mne.t_start, 0)) self.mne.hsel_patch.set_width(self.mne.duration) + publish( + self, + TimeBrowse( + time_start=self.mne.t_start, + time_end=self.mne.t_start + self.mne.duration, + ), + ) def _check_update_hscroll_clicked(self, event): """Handle clicks on horizontal scrollbar.""" @@ -2338,6 +2356,27 @@ def _on_time_change_event(self, event): with disable_ui_events(self): self._show_vline(time) + def _on_time_browse_event(self, event): + """Respond to the TimeBrowse UI event.""" + time_start = event.time_start + time_end = event.time_end + self.mne.t_start = time_start + self.mne.duration = time_end - time_start + with disable_ui_events(self): + self._update_hscroll() + self._redraw(annotations=True) + + def _on_channel_browse_event(self, event): + """Respond to the ChannelBrowse UI event.""" + all_channels = self.mne.ch_names[self.mne.ch_order] + ch_indices = [np.where(all_channels == ch)[0][0] for ch in event.channels] + with disable_ui_events(self): + self.mne.ch_start = ch_indices[0] + self.mne.n_channels = len(ch_indices) + self._update_picks() + self._update_vscroll() + self._redraw(annotations=True) + class MNELineFigure(MNEFigure): """Interactive figure for non-scrolling line plots.""" From f4366c5d14dd030fb4dd7439db0636a8e252a61f Mon Sep 17 00:00:00 2001 From: Marijn van Vliet Date: Tue, 10 Sep 2024 12:10:46 +0300 Subject: [PATCH 13/15] Refactor TimeChange, TimeBrowse, ChannelBrowse events. Mouse and keypresses no longer directly update the plot, but instead generate the appropriate UI events. Figures always listen and respond to their own UI events, which is where the updating of the figure happens. This way, there is a single place in the code where checking of corner cases, etc. happens. In addition, the matplotlib xdata timeline is different from actual times in the Raw object. UI events should always be meaningful outside of the figure. Hence, TimeChange and TimeBrowse should give the selected times in terms of the original Raw object. Various translations therefore need to happen. --- mne/viz/_figure.py | 4 +- mne/viz/_mpl_figure.py | 208 +++++++++++++++++++++++++++-------------- 2 files changed, 140 insertions(+), 72 deletions(-) diff --git a/mne/viz/_figure.py b/mne/viz/_figure.py index ffb1c57dafd..42e6bb2292d 100644 --- a/mne/viz/_figure.py +++ b/mne/viz/_figure.py @@ -412,7 +412,8 @@ def _update_data(self): def _get_epoch_num_from_time(self, time): epoch_nums = self.mne.inst.selection - return epoch_nums[np.searchsorted(self.mne.boundary_times[1:], time)] + epoch_ix = np.searchsorted(self.mne.boundary_times[1:-1], time) + return epoch_nums[epoch_ix] def _redraw(self, update_data=True, annotations=False): """Redraws backend if necessary.""" @@ -543,7 +544,6 @@ def _create_ica_properties_fig(self, idx): def _create_epoch_image_fig(self, pick): """Show epochs image for the selected channel.""" from matplotlib.gridspec import GridSpec - from mne.viz import plot_epochs_image ch_name = self.mne.ch_names[pick] diff --git a/mne/viz/_mpl_figure.py b/mne/viz/_mpl_figure.py index 9a876ee8df9..aa0f69a817c 100644 --- a/mne/viz/_mpl_figure.py +++ b/mne/viz/_mpl_figure.py @@ -658,7 +658,7 @@ def _keypress(self, event): key = event.key n_channels = self.mne.n_channels if self.mne.is_epochs: - last_time = self.mne.n_times / self.mne.info["sfreq"] + last_time = self.mne.boundary_times[-2] else: last_time = self.mne.inst.times[-1] # scroll up/down @@ -691,24 +691,21 @@ def _keypress(self, event): else: ceiling = len(self.mne.ch_order) - n_channels ch_start = self.mne.ch_start + direction * n_channels - self.mne.ch_start = np.clip(ch_start, 0, ceiling) - self._update_picks() - self._update_vscroll() - self._redraw() + ch_start = np.clip(ch_start, 0, ceiling) + channels = self.mne.ch_names[ + self.mne.ch_order[ch_start : ch_start + n_channels] + ] + publish(self, ChannelBrowse(channels=channels)) # scroll left/right elif key in ("right", "left", "shift+right", "shift+left"): - old_t_start = self.mne.t_start direction = 1 if key.endswith("right") else -1 if self.mne.is_epochs: denom = 1 if key.startswith("shift") else self.mne.n_epochs else: denom = 1 if key.startswith("shift") else 4 - t_max = last_time - self.mne.duration t_start = self.mne.t_start + direction * self.mne.duration / denom - self.mne.t_start = np.clip(t_start, self.mne.first_time, t_max) - if self.mne.t_start != old_t_start: - self._update_hscroll() - self._redraw(annotations=True) + t_start = np.clip(t_start, 0, last_time - self.mne.duration) + self._publish_time_browse_event(t_start) # scale traces elif key in ("=", "+", "-"): scaler = 1 / 1.1 if key == "-" else 1.1 @@ -721,41 +718,31 @@ def _keypress(self, event): and not self.mne.butterfly ): new_n_ch = n_channels + (1 if key == "pageup" else -1) - self.mne.n_channels = np.clip(new_n_ch, 1, len(self.mne.ch_order)) + n_channels = np.clip(new_n_ch, 1, len(self.mne.ch_order)) + ch_start = self.mne.ch_start # add new chs from above if we're at the bottom of the scrollbar - ch_end = self.mne.ch_start + self.mne.n_channels - if ch_end > len(self.mne.ch_order) and self.mne.ch_start > 0: - self.mne.ch_start -= 1 - self._update_vscroll() - # redraw only if changed - if self.mne.n_channels != n_channels: - self._update_picks() - self._update_trace_offsets() - self._redraw(annotations=True) + ch_end = ch_start + n_channels + if ch_end > len(self.mne.ch_order) and ch_start > 0: + ch_start -= 1 + channels = self.mne.ch_names[ + self.mne.ch_order[ch_start : ch_start + n_channels] + ] + publish(self, ChannelBrowse(channels=channels)) + # change duration elif key in ("home", "end"): - old_dur = self.mne.duration dur_delta = 1 if key == "end" else -1 if self.mne.is_epochs: - # prevent from showing zero epochs, or more epochs than we have - self.mne.n_epochs = np.clip( - self.mne.n_epochs + dur_delta, 1, len(self.mne.inst) - ) # use the length of one epoch as duration change min_dur = len(self.mne.inst.times) / self.mne.info["sfreq"] new_dur = self.mne.duration + dur_delta * min_dur else: - # never show fewer than 3 samples - min_dur = 3 * np.diff(self.mne.inst.times[:2])[0] # use multiplicative dur_delta dur_delta = 5 / 4 if dur_delta > 0 else 4 / 5 new_dur = self.mne.duration * dur_delta - self.mne.duration = np.clip(new_dur, min_dur, last_time) - if self.mne.duration != old_dur: - if self.mne.t_start + self.mne.duration > last_time: - self.mne.t_start = last_time - self.mne.duration - self._update_hscroll() - self._redraw(annotations=True) + self._publish_time_browse_event( + self.mne.t_start, self.mne.t_start + new_dur + ) elif key == "?": # help window self._toggle_help_fig(event) elif key == "a": # annotation mode @@ -814,7 +801,13 @@ def _buttonpress(self, event): idx = self.mne.traces.index(line) self._toggle_bad_channel(idx) return - self._show_vline(event.xdata) # butterfly / not on data trace + time = event.xdata + if self.mne.is_epochs: + width = self.mne.boundary_times[1] - self.mne.boundary_times[0] + time = (time % width) + self.mne.inst.tmin + else: + time += self.mne.inst.first_time + publish(self, TimeChange(time=time)) self._redraw(update_data=False, annotations=False) return # click in vertical scrollbar @@ -1750,21 +1743,12 @@ def _update_vscroll(self): self.mne.vsel_patch.set_xy((0, self.mne.ch_start)) self.mne.vsel_patch.set_height(self.mne.n_channels) self._update_yaxis_labels() - - # Publish to event system - publish(self, ChannelBrowse(channels=self.mne.ch_names[self.mne.picks])) + # publish(self, ChannelBrowse(channels=self.mne.ch_names[self.mne.picks])) def _update_hscroll(self): """Update the horizontal scrollbar (time) selection indicator.""" self.mne.hsel_patch.set_xy((self.mne.t_start, 0)) self.mne.hsel_patch.set_width(self.mne.duration) - publish( - self, - TimeBrowse( - time_start=self.mne.t_start, - time_end=self.mne.t_start + self.mne.duration, - ), - ) def _check_update_hscroll_clicked(self, event): """Handle clicks on horizontal scrollbar.""" @@ -1779,8 +1763,7 @@ def _check_update_hscroll_clicked(self, event): ix = np.searchsorted(self.mne.boundary_times[1:], time) time = self.mne.boundary_times[ix] if self.mne.t_start != time: - self.mne.t_start = time - self._update_hscroll() + self._publish_time_browse_event(time) return True return False @@ -1792,9 +1775,10 @@ def _check_update_vscroll_clicked(self, event): len(self.mne.ch_order) - self.mne.n_channels, ) if self.mne.ch_start != new_ch_start: - self.mne.ch_start = new_ch_start - self._update_picks() - self._update_vscroll() + channels = self.mne.ch_names[ + self.mne.ch_order[new_ch_start : new_ch_start + self.mne.n_channels] + ] + publish(self, ChannelBrowse(channels=channels)) return True return False @@ -2254,7 +2238,6 @@ def _show_vline(self, xdata): text = self._xtick_formatter(xdata, ax_type="vline")[:12] self.mne.vline_text.set_text(text) self._toggle_vline(True) - publish(self, TimeChange(time=xdata)) def _toggle_vline(self, visible): """Show or hide the vertical line(s).""" @@ -2346,36 +2329,121 @@ def _get_scale_bar_texts(self): return texts - def _on_time_change_event(self, event): - """Respond to the TimeChange UI event.""" + def _publish_time_browse_event(self, t_start=None, t_end=None): + """Publish a TimeBrowse event with meaningful time_start and time_end values.""" + # Figure out proper t_start and t_end that doesn't exceed the data boundaries. + if t_start is None: + t_start = self.mne.t_start + else: + if self.mne.is_epochs: + last_time = self.mne.n_times / self.mne.info["sfreq"] + else: + last_time = self.mne.inst.times[-1] + t_max = last_time - self.mne.duration + t_start = np.clip(t_start, self.mne.first_time, t_max) + + if t_end is None: + t_end = t_start + self.mne.duration + else: + t_end = min(t_end, self.mne.inst.times[-1]) + + # Don't publish an event if nothing changed. + if ( + self.mne.t_start == t_start + and self.mne.t_start + self.mne.duration == t_end + ): + return + if self.mne.is_epochs: - # last_time = self.mne.n_times / self.mne.info["sfreq"] - time = event.time + # Translate the time-coordinate in the browser window to the actual + # start/end times of the epochs in the raw file. + epoch_num_start = self._get_epoch_num_from_time( + t_start + self.mne.sampling_period + ) + epoch_num_end = self._get_epoch_num_from_time( + t_end + self.mne.sampling_period + ) + onsets = self.mne.inst.events[:, 0] / self.mne.info["sfreq"] + t_start = onsets[epoch_num_start] + self.mne.inst.tmin + t_end = onsets[epoch_num_end - 1] + self.mne.inst.tmax else: - time = np.clip(event.time, self.mne.inst.times[0], self.mne.inst.times[-1]) - with disable_ui_events(self): - self._show_vline(time) + # For raw data, we need to take `first_time` into account. + t_start += self.mne.inst.first_time + t_end += self.mne.inst.first_time + + publish(self, TimeBrowse(time_start=t_start, time_end=t_end)) def _on_time_browse_event(self, event): - """Respond to the TimeBrowse UI event.""" + """Respond to the TimeBrowse UI event, update horizontal scrolling.""" time_start = event.time_start time_end = event.time_end + + if self.mne.is_epochs: + # Translate the start/end times from the original raw to the indices of the + # epochs being shown, and then to the appropriate start/end times in the + # browser. + events = self.mne.inst.events[self.mne.inst.selection] + onsets = events[:, 0] / self.mne.info["sfreq"] + # Subtract/add one sample to make sure we end on the right side. This is + # needed because of small floating point errors. + epoch_ix_start = np.searchsorted( + onsets, time_start - self.mne.inst.tmin - self.mne.sampling_period + ) + epoch_ix_end = np.searchsorted( + onsets, time_end - self.mne.inst.tmax + self.mne.sampling_period + ) + # Always show at least one epoch. + epoch_ix_start = min(epoch_ix_start, len(self.mne.inst) - 1) + epoch_ix_end = max(epoch_ix_start + 1, epoch_ix_end) + self.mne.n_epochs = epoch_ix_end - epoch_ix_start + + # Compute the browser time period to match the selected epochs. + time_start = self.mne.boundary_times[epoch_ix_start] + width = self.mne.boundary_times[1] - self.mne.boundary_times[0] + time_end = self.mne.boundary_times[epoch_ix_end - 1] + width + else: + # For raw data, we need to take `first_time` into account. + time_start -= self.mne.inst.first_time + time_end -= self.mne.inst.first_time + + # Never show fewer than 3 samples. + min_dur = 3 * np.diff(self.mne.inst.times[:2])[0] + time_end = np.clip(time_end, time_start + min_dur, self.mne.inst.times[-1]) + + # Update browser window. self.mne.t_start = time_start self.mne.duration = time_end - time_start - with disable_ui_events(self): - self._update_hscroll() - self._redraw(annotations=True) + self._update_hscroll() + self._redraw(annotations=True) def _on_channel_browse_event(self, event): """Respond to the ChannelBrowse UI event.""" - all_channels = self.mne.ch_names[self.mne.ch_order] - ch_indices = [np.where(all_channels == ch)[0][0] for ch in event.channels] - with disable_ui_events(self): - self.mne.ch_start = ch_indices[0] - self.mne.n_channels = len(ch_indices) - self._update_picks() - self._update_vscroll() - self._redraw(annotations=True) + old_n_channels = self.mne.n_channels + picks = np.flatnonzero( + np.in1d(self.mne.ch_names[self.mne.ch_order], event.channels) + ) + if len(picks) == 0: + return # can't handle the event + if picks.min() == self.mne.ch_start and len(picks) == self.mne.n_channels: + return # no change + + self.mne.ch_start = picks.min() + self.mne.n_channels = len(picks) + self._update_vscroll() + self._update_picks() + if self.mne.n_channels != old_n_channels: + self._update_trace_offsets() + self._redraw(annotations=True) + + def _on_time_change_event(self, event): + """Respond to the TimeChange UI event.""" + if self.mne.is_epochs: + time = np.clip(event.time, self.mne.inst.tmin, self.mne.inst.tmax) + time -= self.mne.inst.tmin + else: + time = event.time - self.mne.inst.first_time + time = np.clip(time, self.mne.inst.times[0], self.mne.inst.times[-1]) + self._show_vline(time) class MNELineFigure(MNEFigure): From 0a0f402a2641ced6a279b8c85c2d39868e372673 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 10 Sep 2024 09:14:12 +0000 Subject: [PATCH 14/15] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mne/viz/_figure.py | 1 + mne/viz/_mpl_figure.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mne/viz/_figure.py b/mne/viz/_figure.py index 42e6bb2292d..a2d83bd607e 100644 --- a/mne/viz/_figure.py +++ b/mne/viz/_figure.py @@ -544,6 +544,7 @@ def _create_ica_properties_fig(self, idx): def _create_epoch_image_fig(self, pick): """Show epochs image for the selected channel.""" from matplotlib.gridspec import GridSpec + from mne.viz import plot_epochs_image ch_name = self.mne.ch_names[pick] diff --git a/mne/viz/_mpl_figure.py b/mne/viz/_mpl_figure.py index aa0f69a817c..7b5a3ad0c30 100644 --- a/mne/viz/_mpl_figure.py +++ b/mne/viz/_mpl_figure.py @@ -62,7 +62,6 @@ ChannelBrowse, TimeBrowse, TimeChange, - disable_ui_events, publish, subscribe, ) @@ -2420,7 +2419,7 @@ def _on_channel_browse_event(self, event): """Respond to the ChannelBrowse UI event.""" old_n_channels = self.mne.n_channels picks = np.flatnonzero( - np.in1d(self.mne.ch_names[self.mne.ch_order], event.channels) + np.isin(self.mne.ch_names[self.mne.ch_order], event.channels) ) if len(picks) == 0: return # can't handle the event From 04b8b7cc26d752be8e53b932b8bd920dd36e3f09 Mon Sep 17 00:00:00 2001 From: Marijn van Vliet Date: Tue, 10 Sep 2024 12:24:42 +0300 Subject: [PATCH 15/15] some bugfixes and cleanup --- mne/viz/_mpl_figure.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/mne/viz/_mpl_figure.py b/mne/viz/_mpl_figure.py index aa0f69a817c..d0bc8bab94b 100644 --- a/mne/viz/_mpl_figure.py +++ b/mne/viz/_mpl_figure.py @@ -58,14 +58,7 @@ from ..fixes import _close_event from ..utils import Bunch, _click_ch_name, check_version, logger from ._figure import BrowserBase -from .ui_events import ( - ChannelBrowse, - TimeBrowse, - TimeChange, - disable_ui_events, - publish, - subscribe, -) +from .ui_events import ChannelBrowse, TimeBrowse, TimeChange, publish, subscribe from .utils import ( DraggableLine, _events_off, @@ -1743,7 +1736,6 @@ def _update_vscroll(self): self.mne.vsel_patch.set_xy((0, self.mne.ch_start)) self.mne.vsel_patch.set_height(self.mne.n_channels) self._update_yaxis_labels() - # publish(self, ChannelBrowse(channels=self.mne.ch_names[self.mne.picks])) def _update_hscroll(self): """Update the horizontal scrollbar (time) selection indicator.""" @@ -2345,7 +2337,7 @@ def _publish_time_browse_event(self, t_start=None, t_end=None): if t_end is None: t_end = t_start + self.mne.duration else: - t_end = min(t_end, self.mne.inst.times[-1]) + t_end = min(t_end, self.mne.n_times / self.mne.info["sfreq"]) # Don't publish an event if nothing changed. if (