Skip to content

Add option to show a zero line in browser - #14018

Merged
larsoner merged 5 commits into
mne-tools:mainfrom
cbrnr:browser-horizontal-zero
Jul 10, 2026
Merged

Add option to show a zero line in browser#14018
larsoner merged 5 commits into
mne-tools:mainfrom
cbrnr:browser-horizontal-zero

Conversation

@cbrnr

@cbrnr cbrnr commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

This PR adds an option to show a zero line (toggleable with "0") to the Matplotlib backend. I've implemented this feature for the Qt backend in mne-tools/mne-qt-browser#423.

Short example:

import numpy as np

import mne

mne.viz.set_browser_backend("matplotlib")

sfreq = 200.0
n_channels = 20
t = np.arange(int(60 * sfreq)) / sfreq

rng = np.random.default_rng(0)
offsets = rng.uniform(-100e-6, 100e-6, size=n_channels)
data = rng.standard_normal((n_channels, t.size)) * 20e-6 + offsets[:, np.newaxis]

ch_names = [f"EEG {i + 1}" for i in range(n_channels)]
info = mne.create_info(ch_names, sfreq, "eeg")
raw = mne.io.RawArray(data, info)

raw.plot(show_zero_line=True, scalings=dict(eeg=100e-6), n_channels=5, block=True)

@cbrnr
cbrnr requested a review from larsoner as a code owner July 3, 2026 12:11
@cbrnr

cbrnr commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

The docs error (AssertionError: Duplicates in PYPI_PACKAGES and installer packages: ['mne-kit-gui']) is very likely unrelated.

The Azure error is also unrelated (hitting 403: rate limit exceeded).

Comment thread mne/utils/docs.py Outdated
Comment on lines +4277 to +4279
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm somehow it's hard for me to parse this. I expected at first that hitting "d" would change the position of the line in the plot. I think calling it "true zero" made me think of "where zero actually is for the signal". So maybe

Suggested change
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
initialized. The zero line marks the centering point of each channel's
**displayed trace**, independent of any DC removal or highpass filtering
already applied to the data. Can be toggled after initialization by

I wonder if there is value in having a third mode where toggling DC removal changes where the "zero line" is displayed so that it's actually at where the zero level is for that channel (in other words, treating "DC removal" as more of a visual vertical translation rather than a subtraction of any values from the trace)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I thought about that as well. Can you think of a good way to visually distinguish the two modes that show the line? Otherwise it might be confusing if the line doesn't simple toggle when pressing "0".

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe dashed for one (fixed location at the ch_name) and dotted for the other (where true zero is for the signal)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this might work. Or maybe even clearer, dashed for the fixed location and solid for the true zero? True and solid is intuitive I think.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure that works! That way the fixed looks more like an axis line anyway, which is what is basically is at that point

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But does it make sense to show a zero line at the channel midpoint when DC removal is off? Could we couple the zero line to the currently set DC removal?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I now set the style of the zero line depending on whether DC removal is on or off. If it's off, we show the true zero line as a solid line. When it's on, we show a "virtual" zero line as a dashed line. Yes, if the signal has an actual mean of zero, the virtual mean will be equal to the true mean, but let's not overcomplicate this. Also, the difference between solid and dashed is pretty hard to spot, but I think it looks OK.

@larsoner

larsoner commented Jul 9, 2026

Copy link
Copy Markdown
Member

Hmm okay I had something slightly different in mind. With DC removal off, this is what we see:

image

So for example, "true zero" for EEG 1 is generally below the rest of the data (so it is biased positively), and true zero for EEG 2 is generally above the rest of the data (so the signal is actually biased negatively (which is true since offsets[:2] is [ 2.73923375e-05 -4.60426572e-05]). I expected when hitting "d" to reenable DC removal that the line, this relationship between the "zero point" and the data would stay the same. But it doesn't, it stays at the "plotting baseline" / offset, i.e., always at the same y location as the channel label:

image

So in both of these cases, these line show the plotting baseline, which sometimes corresponds to the true signal zero level (when DC removal is off), or not (when DC removal is on).

What I was thinking of would add a bit of different information -- thinking of "DC removal" as just a visual plotting thing, the "zero line" would keep its position relative to the data. So in this last view, that would be like this:

image

i.e., you can tell where true signal zero is relative to the data (that is what the line represents in this mode, rather than the plotting baseline). Like EEG2 you can immediately tell is actually (almost) all negative, because the "zero line" is above it -- but it displays locally centered relative to the plotting baseline/offset. Does that make sense?

Diff to produce above
larsoner:~/python/mne-python$ git diff
diff --git a/mne/viz/_figure.py b/mne/viz/_figure.py
index 954b90c9e9..b14a194d75 100644
--- a/mne/viz/_figure.py
+++ b/mne/viz/_figure.py
@@ -401,10 +401,11 @@ class BrowserBase(ABC):
         # get only the channels we're displaying
         data = data[picks]
         # remove DC
+        self.mne.dc_values = np.nanmean(data[..., time_slice], axis=1)
         if self.mne.remove_dc:
             if thread:
                 thread.processText.emit("Removing DC...")
-            data -= np.nanmean(data[..., time_slice], axis=1, keepdims=True)
+            data -= self.mne.dc_values[:, np.newaxis]
         # apply filter
         if self.mne.filter_coefs is not None:
             if thread:
@@ -426,6 +427,7 @@ class BrowserBase(ABC):
         norms[white] = self.mne.scalings["whitened"]
         norms[norms == 0] = 1
         data /= 2 * norms[:, np.newaxis]
+        self.mne.dc_values /= 2 * norms
 
         return data
 
diff --git a/mne/viz/_mpl_figure.py b/mne/viz/_mpl_figure.py
index 2e546d4bc6..74cea3f0a6 100644
--- a/mne/viz/_mpl_figure.py
+++ b/mne/viz/_mpl_figure.py
@@ -2280,8 +2280,11 @@ class MNEBrowseFigure(BrowserBase, MNEFigure):
             this_type = ch_types[ii]
             this_offset = offsets[ii]
             if self.mne.zero_line_visible:
+                dc_y = this_offset
+                if self.mne.remove_dc:
+                    dc_y = dc_y + self.mne.dc_values[ii] * self.mne.scale_factor
                 self.mne.zero_lines[ii].set_xdata(time_range)
-                self.mne.zero_lines[ii].set_ydata((this_offset, this_offset))
+                self.mne.zero_lines[ii].set_ydata((dc_y, dc_y))
                 self.mne.zero_lines[ii].set_linestyle(
                     "dashed" if self.mne.remove_dc else "solid"
                 )

If you agree this is also a useful mode, then we have to figure out how to enable it. One way would be that "toggle zero line" could actually be a three-state switch: off (main), on-showing-plotting-baseline (currently added), on-showing-signal-zero (new mode I demonstrated)?

Comment thread mne/viz/_mpl_figure.py Outdated
@cbrnr

cbrnr commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Yes, I agree that this is better and it was what I initially wanted. However, I think three toggle states are confusing, so I left it at on/off, but on now always shows the true zero even when DC removal is true.

@larsoner

Copy link
Copy Markdown
Member

Failures are unrelated so in it goes, thanks @cbrnr !

@larsoner
larsoner merged commit 9dc7ba8 into mne-tools:main Jul 10, 2026
29 of 32 checks passed
@cbrnr
cbrnr deleted the browser-horizontal-zero branch July 11, 2026 05:57
larsoner added a commit to larsoner/mne-python that referenced this pull request Jul 14, 2026
* upstream/main:
  Make scrollbar handlers draggable (mne-tools#14040)
  [pre-commit.ci] pre-commit autoupdate (mne-tools#14052)
  Warn when Epochs events fall outside the raw data range (mne-tools#12989) (mne-tools#14004)
  Ensure epochs being concatenated have compatible event ids (mne-tools#14051)
  Widen main content area (mne-tools#14015)
  MAINT: remove dead gain/bits/value_range fields from _read_header in … (mne-tools#14047)
  ENH: replace `_get_blocks` binary reader with mffpy Reader API (mne-tools#14043)
  MAINT: Update dependency specifiers (mne-tools#14048)
  [dependabot]: Bump the actions group with 2 updates (mne-tools#14049)
  Simplify doc building with more refleak (mne-tools#14045)
  ENH: add overlay Brain GUI (mne-tools#14031)
  ENH: support multiple simultaneous overlays in Brain.add_data (mne-tools#13995)
  Add option to show a zero line in browser (mne-tools#14018)
  FIX: pass cmap name string not tuple to interactive topomap slider kwargs (mne-tools#14039)
  Doc/add ai policy pointer (mne-tools#14037)
  Allow subclasses of FigureClass to be passed to plot_raw/plot_epochs (mne-tools#13979)
  MAINT: Replace manual PNS binary block reader in `_read_segment_file` with `mffpy` (mne-tools#14030)
larsoner added a commit to sharifhsn/mne-python that referenced this pull request Jul 14, 2026
* upstream/main: (206 commits)
  Improve type checks (mne-tools#14036)
  Make scrollbar handlers draggable (mne-tools#14040)
  [pre-commit.ci] pre-commit autoupdate (mne-tools#14052)
  Warn when Epochs events fall outside the raw data range (mne-tools#12989) (mne-tools#14004)
  Ensure epochs being concatenated have compatible event ids (mne-tools#14051)
  Widen main content area (mne-tools#14015)
  MAINT: remove dead gain/bits/value_range fields from _read_header in … (mne-tools#14047)
  ENH: replace `_get_blocks` binary reader with mffpy Reader API (mne-tools#14043)
  MAINT: Update dependency specifiers (mne-tools#14048)
  [dependabot]: Bump the actions group with 2 updates (mne-tools#14049)
  Simplify doc building with more refleak (mne-tools#14045)
  ENH: add overlay Brain GUI (mne-tools#14031)
  ENH: support multiple simultaneous overlays in Brain.add_data (mne-tools#13995)
  Add option to show a zero line in browser (mne-tools#14018)
  FIX: pass cmap name string not tuple to interactive topomap slider kwargs (mne-tools#14039)
  Doc/add ai policy pointer (mne-tools#14037)
  Allow subclasses of FigureClass to be passed to plot_raw/plot_epochs (mne-tools#13979)
  MAINT: Replace manual PNS binary block reader in `_read_segment_file` with `mffpy` (mne-tools#14030)
  Fix transition bandwidth reported in 'filter too short' error (mne-tools#11406) (mne-tools#14005)
  ENH: Show the current time as a vertical line in plot_evoked_topo (mne-tools#14032)
  ...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants