diff --git a/doc/changes/dev/14018.newfeature.rst b/doc/changes/dev/14018.newfeature.rst new file mode 100644 index 00000000000..eee30a1c02a --- /dev/null +++ b/doc/changes/dev/14018.newfeature.rst @@ -0,0 +1 @@ +Add a ``show_zero_line`` parameter (and :kbd:`0` keyboard shortcut) to :func:`mne.io.Raw.plot` and :func:`mne.Epochs.plot` to show a reference line at each channel's zero, by `Clemens Brunner`_. \ No newline at end of file diff --git a/mne/epochs.py b/mne/epochs.py index 442359f3d65..2f8342f7b37 100644 --- a/mne/epochs.py +++ b/mne/epochs.py @@ -1317,6 +1317,7 @@ def plot( butterfly=False, show_scrollbars=True, show_scalebars=True, + show_zero_line=False, epoch_colors=None, event_id=None, group_by="type", @@ -1345,6 +1346,7 @@ def plot( butterfly=butterfly, show_scrollbars=show_scrollbars, show_scalebars=show_scalebars, + show_zero_line=show_zero_line, epoch_colors=epoch_colors, event_id=event_id, group_by=group_by, diff --git a/mne/io/base.py b/mne/io/base.py index 597723d72b8..5d692871738 100644 --- a/mne/io/base.py +++ b/mne/io/base.py @@ -1993,6 +1993,7 @@ def plot( event_id=None, show_scrollbars=True, show_scalebars=True, + show_zero_line=False, time_format="float", precompute=None, use_opengl=None, @@ -2034,6 +2035,7 @@ def plot( event_id=event_id, show_scrollbars=show_scrollbars, show_scalebars=show_scalebars, + show_zero_line=show_zero_line, time_format=time_format, precompute=precompute, use_opengl=use_opengl, diff --git a/mne/utils/docs.py b/mne/utils/docs.py index 74b54e62b21..b47c0ce74cd 100644 --- a/mne/utils/docs.py +++ b/mne/utils/docs.py @@ -4271,6 +4271,17 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): .. versionadded:: 0.20.0 """ +docdict["show_zero_line"] = """ +show_zero_line : bool + Whether to show the zero line for each channel trace when the plot is + initialized. The zero line marks the true zero of each channel's own + displayed trace, independent of any DC removal or highpass filtering + already applied to the data. Can be toggled after initialization by + pressing :kbd:`0` while the plot window is focused. Default is ``False``. + + .. versionadded:: 1.13 +""" + docdict["size_topomap"] = """ size : float Side length of each subplot in inches. diff --git a/mne/viz/_figure.py b/mne/viz/_figure.py index cf49ce28ceb..954b90c9e95 100644 --- a/mne/viz/_figure.py +++ b/mne/viz/_figure.py @@ -105,6 +105,7 @@ def __init__(self, **kwargs): patch=0, grid=1, ann=2, + zero_line=3, events=10003, bads=10004, data=10005, @@ -140,6 +141,7 @@ def __init__(self, **kwargs): self.mne.scale_factor = 0.5 if self.mne.butterfly else 1.0 self.mne.scalebars = dict() self.mne.scalebar_texts = dict() + self.mne.zero_lines = list() # ancillary child figures self.mne.child_figs = list() self.mne.fig_help = None diff --git a/mne/viz/_mpl_figure.py b/mne/viz/_mpl_figure.py index 2a3b2385c31..daf5f0bc790 100644 --- a/mne/viz/_mpl_figure.py +++ b/mne/viz/_mpl_figure.py @@ -645,6 +645,13 @@ def __init__(self, inst, figsize, ica=None, xlabel="Time (s)", **kwargs): self.mne.traces = ax_main.plot( np.full((1, self.mne.n_channels), np.nan), **self.mne.trace_kwargs ) + self.mne.zero_line_kwargs = dict( + color=self.mne.fgcolor, + alpha=0.5, + linewidth=0.5, + linestyle="dashed", + zorder=self.mne.zorder["zero_line"], + ) # SAVE UI ELEMENT HANDLES vars(self.mne).update( @@ -874,6 +881,8 @@ def _keypress(self, event): checkbox.set_active(0) elif key == "s": # scalebars self._toggle_scalebars(event) + elif key == "0": # zero line + self._toggle_zero_line(event) elif key == "w": # toggle noise cov whitening self._toggle_whitening() elif key == "z": # zen mode: hide scrollbars and buttons @@ -1124,6 +1133,7 @@ def _get_help_text(self): ("shift+j", "Toggle all SSPs"), ("p", "Toggle draggable annotations" if is_raw else None), ("s", "Toggle scalebars" if not is_ica else None), + ("0", "Toggle zero line"), ("z", "Toggle scrollbars"), ("t", "Toggle time format" if not is_epo else None), ("F11", "Toggle fullscreen" if not is_mac else None), @@ -1993,6 +2003,11 @@ def _toggle_scalebars(self, event): self.mne.scalebars_visible = not self.mne.scalebars_visible self._redraw(update_data=False) + def _toggle_zero_line(self, event): + """Show/hide the zero line for each channel trace.""" + self.mne.zero_line_visible = not self.mne.zero_line_visible + self._redraw(update_data=False) + def _draw_one_scalebar(self, x, y, ch_type): """Draw a scalebar.""" from .utils import _simplify_float @@ -2213,6 +2228,23 @@ def _draw_traces(self): trace.remove() self.mne.traces = self.mne.traces[:n_picks] + # add/remove zero lines if needed + if self.mne.zero_line_visible: + if n_picks > len(self.mne.zero_lines): + n_new_chs = n_picks - len(self.mne.zero_lines) + new_zero_lines = self.mne.ax_main.plot( + np.full((1, n_new_chs), np.nan), **self.mne.zero_line_kwargs + ) + self.mne.zero_lines.extend(new_zero_lines) + extra_zero_lines = self.mne.zero_lines[n_picks:] + for zero_line in extra_zero_lines: + zero_line.remove() + self.mne.zero_lines = self.mne.zero_lines[:n_picks] + elif self.mne.zero_lines: + for zero_line in self.mne.zero_lines: + zero_line.remove() + self.mne.zero_lines = list() + # check for bad epochs time_range = (self.mne.times + self.mne.first_time)[[0, -1]] if self.mne.instance_type == "epochs": @@ -2248,6 +2280,9 @@ def _draw_traces(self): this_name = ch_names[ii] this_type = ch_types[ii] this_offset = offsets[ii] + if self.mne.zero_line_visible: + self.mne.zero_lines[ii].set_xdata(time_range) + self.mne.zero_lines[ii].set_ydata((this_offset, this_offset)) this_times = decim_times[decim[ii]] this_data = this_offset - self.mne.data[ii] * self.mne.scale_factor this_data = this_data[..., :: decim[ii]] diff --git a/mne/viz/epochs.py b/mne/viz/epochs.py index 0154f357588..fdd97d5605f 100644 --- a/mne/viz/epochs.py +++ b/mne/viz/epochs.py @@ -755,6 +755,7 @@ def plot_epochs( butterfly=False, show_scrollbars=True, show_scalebars=True, + show_zero_line=False, epoch_colors=None, event_id=None, group_by="type", @@ -845,6 +846,7 @@ def plot_epochs( %(show_scalebars)s .. versionadded:: 0.24.0 + %(show_zero_line)s epoch_colors : list of (n_epochs) list (of n_channels) | None Colors to use for individual epochs. If None, use default colors. event_id : bool | dict @@ -1081,6 +1083,7 @@ def plot_epochs( clipping=None, scrollbars_visible=show_scrollbars, scalebars_visible=show_scalebars, + zero_line_visible=show_zero_line, window_title=title, xlabel="Epoch number", # pyqtgraph-specific diff --git a/mne/viz/ica.py b/mne/viz/ica.py index ffb2f9fd422..3c2ae69cbdf 100644 --- a/mne/viz/ica.py +++ b/mne/viz/ica.py @@ -1442,6 +1442,7 @@ def _plot_sources( clipping=None, scrollbars_visible=show_scrollbars, scalebars_visible=False, + zero_line_visible=False, window_title=title, precompute=precompute, use_opengl=use_opengl, diff --git a/mne/viz/raw.py b/mne/viz/raw.py index 5e6febc550c..d5288bc9123 100644 --- a/mne/viz/raw.py +++ b/mne/viz/raw.py @@ -67,6 +67,7 @@ def plot_raw( event_id=None, show_scrollbars=True, show_scalebars=True, + show_zero_line=False, time_format="float", precompute=None, use_opengl=None, @@ -213,6 +214,7 @@ def plot_raw( %(show_scalebars)s .. versionadded:: 0.20.0 + %(show_zero_line)s %(time_format)s %(precompute)s %(use_opengl)s @@ -427,6 +429,7 @@ def plot_raw( clipping=clipping, scrollbars_visible=show_scrollbars, scalebars_visible=show_scalebars, + zero_line_visible=show_zero_line, window_title=title, bgcolor=bgcolor, # Qt-specific diff --git a/mne/viz/tests/test_raw.py b/mne/viz/tests/test_raw.py index dab3deb5165..14cc83e9eee 100644 --- a/mne/viz/tests/test_raw.py +++ b/mne/viz/tests/test_raw.py @@ -344,6 +344,22 @@ def test_scale_bar(browser_backend): browser_backend._close_all() +def test_zero_line(raw, mpl_backend): + """Test toggling the zero line for raw (matplotlib backend only).""" + fig = raw.plot() + assert not fig.mne.zero_line_visible + assert len(fig.mne.zero_lines) == 0 + fig._fake_keypress("0") + assert fig.mne.zero_line_visible + assert len(fig.mne.zero_lines) == len(fig.mne.picks) + for zero_line, offset in zip(fig.mne.zero_lines, fig.mne.trace_offsets): + assert_allclose(zero_line.get_ydata(), (offset, offset)) + assert_allclose(fig.mne.zero_lines[0].get_xdata(), fig.mne.ax_main.get_xlim()) + fig._fake_keypress("0") # toggle back off -> artists removed + assert not fig.mne.zero_line_visible + assert len(fig.mne.zero_lines) == 0 + + def test_plot_raw_selection(raw, browser_backend): """Test selection mode of plot_raw().""" ismpl = browser_backend.name == "matplotlib"