Skip to content

Skip redrawing hscroll annotations when not needed#13972

Open
frankier wants to merge 2 commits into
mne-tools:mainfrom
frankier:skip-hscroll-annotations
Open

Skip redrawing hscroll annotations when not needed#13972
frankier wants to merge 2 commits into
mne-tools:mainfrom
frankier:skip-hscroll-annotations

Conversation

@frankier

Copy link
Copy Markdown

What does this implement/fix?

If you have a file with a lot of annotations, all annotations on the horizontal scrollbar are redrawn unneccesarily, making horizontal scrolling very slow. This PR changes things so the annotations are only redrawn when needed.

This PR is written by hand, but the script to generate the example data given below is written by Claude Opus.

"""
Generate a large synthetic continuous MNE Raw .fif file for stress-testing
annotation plotting.

- 64 EEG channels (biosemi64 montage -> real names + sensor positions)
- Long continuous recording (a few hundred MB)
- Signals are sums of low-frequency sine oscillations + light noise
- ~1/4 of channels are scaled up to ~3x so their traces overlap when plotted
- Thousands of annotations across several labels -> colorful, dense spans
  in the raw browser

Output: synthetic_raw.fif  (readable with mne.io.read_raw_fif)

View with colorful annotations:
    raw = mne.io.read_raw_fif("synthetic_raw.fif", preload=True)
    raw.plot(duration=20, n_channels=64, scalings=dict(eeg=20e-6))
"""

import numpy as np
import mne


def main():
    rng = np.random.default_rng(42)

    # ----------------------------- CONFIG -----------------------------
    sfreq = 500.0            # sampling rate (Hz)
    duration_s = 1600.0      # total length in seconds (~27 min)
    n_annotations = 5000     # "thousands" of annotations for stress testing
    out_fname = "synthetic_raw.fif"   # MNE raw files must end in raw.fif
    # ------------------------------------------------------------------

    n_total = int(round(sfreq * duration_s))
    t = np.arange(n_total) / sfreq

    # 64-channel montage gives valid names AND sensor positions, so topomaps
    # and sensor plots work too, not just the time-series browser.
    montage = mne.channels.make_standard_montage("biosemi64")
    ch_names = montage.ch_names
    n_channels = len(ch_names)  # 64
    info = mne.create_info(ch_names, sfreq, ch_types="eeg")

    # --------------------- synthetic signals --------------------------
    # EEG data in MNE is in Volts. ~12 uV per oscillatory component.
    base_amp = 12e-6
    data = np.empty((n_channels, n_total), dtype=np.float64)
    for ch in range(n_channels):
        sig = np.zeros(n_total)
        for _ in range(int(rng.integers(3, 6))):   # 3-5 components per channel
            f = rng.uniform(0.3, 5.0)               # low-frequency oscillations
            phase = rng.uniform(0.0, 2.0 * np.pi)
            amp = rng.uniform(0.5, 1.5)
            sig += amp * np.sin(2.0 * np.pi * f * t + phase)
        sig += rng.normal(0.0, 0.1, n_total)        # light broadband noise
        data[ch] = sig * base_amp

    # Scale ~1/4 of channels up to 3x so they exceed the normal display range
    # and overlap neighboring traces when plotted.
    big = rng.choice(n_channels, size=n_channels // 4, replace=False)
    data[big] *= rng.uniform(2.0, 3.0, size=(len(big), 1))

    raw = mne.io.RawArray(data, info, verbose="ERROR")
    raw.set_montage(montage)

    # --------------------- thousands of annotations -------------------
    # Several distinct descriptions -> MNE colors each label differently.
    onsets = np.sort(rng.uniform(0.0, duration_s - 1.0, n_annotations))
    durations = rng.uniform(0.1, 0.6, n_annotations)   # wide enough to see
    labels = ["blink", "muscle", "saccade", "spike", "artifact"]
    descriptions = rng.choice(labels, n_annotations)
    annotations = mne.Annotations(
        onset=onsets, duration=durations, description=descriptions
    )
    raw.set_annotations(annotations)

    print(raw)
    print(
        f"channels={len(raw.ch_names)} | duration={raw.times[-1]:.1f}s | "
        f"annotations={len(raw.annotations)} | "
        f"labels={sorted(set(raw.annotations.description))}"
    )

    raw.save(out_fname, overwrite=True, verbose="ERROR")
    print(f"saved -> {out_fname}")

    # --------------------------- verify -------------------------------
    reloaded = mne.io.read_raw_fif(out_fname, preload=False, verbose="ERROR")
    print(f"reloaded OK: {len(reloaded.annotations)} annotations")

    # To view the colorful annotations (uncomment):
    # reloaded.load_data()
    # reloaded.plot(duration=20, n_channels=64, scalings=dict(eeg=20e-6))


if __name__ == "__main__":
    main()

This can then be plotted like so:

from mne.io import read_raw_fif
from sys import argv
import matplotlib
matplotlib.use('tkagg')

raw = read_raw_fif("synthetic_raw.fif", preload=False)
raw.plot(block=True)

If you try before and after this PR, you will notice horitzontal scrolling is significantly faster afterwards.

@frankier frankier requested a review from drammock as a code owner June 22, 2026 08:53
@larsoner

Copy link
Copy Markdown
Member

Sounds reasonable to me and I will test shortly, but in the meantime can you add doc/changes/13972.newfeature.rst mentioning the speed-up (this counts as an enhancement/new feature)?

@larsoner

Copy link
Copy Markdown
Member

After removing the unnecessary load/save round-trip and reducing channel count to 32 by using biosemi32 in the example above, shift-right-arrow took ~18s on main and ~2s on this branch, which is great! Let me know when the changelog is updated and we should be able to merge

(Side note, both are quite a bit slower than the qt backend, where the delay is way less than 1s, in case you're interested in trying that backend!)

@larsoner larsoner self-requested a review as a code owner June 23, 2026 17:20
@larsoner larsoner force-pushed the skip-hscroll-annotations branch from 0c4b654 to 9880770 Compare June 23, 2026 17:20
Skip replotting of annotations on the horizontal scrollbar in the matplotlib
browser backend, resulting in a significant speedup for traces with a large
number of annotations by `Frankie Robertson`_.
@frankier frankier force-pushed the skip-hscroll-annotations branch from 9880770 to 609d3ac Compare June 24, 2026 09:19
@frankier frankier requested a review from agramfort as a code owner June 24, 2026 09:19
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