Sleep spindles (OpenSpindleNet)#

Sleep-spindle detection with OpenSpindleNet (scalp EEG and intracranial EEG models).

from brainmaze_eeg_models.spindles import SpindleDetector

det = SpindleDetector(model='eeg')            # 'ieeg' for intracranial EEG
res = det.detect(x, fs)                       # x: (n_samples,) or (n_channels, n_samples)
res.channel_intervals(0)                      # [start_s, end_s, confidence] per spindle
res.not_evaluated[0]                          # [start_s, stop_s) with no detection possible

See SpindleDetector for the pipeline (gaps, resampling to 250 Hz, sliding windows) and the differences from the openspindlenet package. The models and the decoding are ported from OpenSpindleNet (CaptainTrojan/openspindlenet, MIT License, Copyright (c) 2025 CaptainTrojan; full notice in NOTICE.md). Please cite Seják et al., Computers in Biology and Medicine 197 (2025) 110854.

Interpreting the output#

  • Times are seconds from the first sample of the input. Each spindle has a start, an end (at most 2 s apart: the model’s maximum duration; no minimum duration is enforced, and intervals clipped at a window or recording edge can be very short, so filter short events yourself if your definition needs one) and a confidence (the model’s sigmoid output, >= confidence_threshold).

  • not_evaluated[ch] lists, per channel, the time where no spindle can be reported: gaps (NaN/inf and flat runs) widened by gap_margin_s, and windows skipped for too little valid data. No spindle there means “not evaluated”, not “no spindle”. Densities (spindles per minute) must be computed over the evaluated time only (evaluated_fraction()).

  • The models detect spindle-like activity in any state; in wake many detections are alpha bursts. Restrict analyses to NREM sleep with a hypnogram.

Sampling rate and preprocessing#

Any rate >= 50 Hz is accepted, but the spindle counts depend on the band above ~25-60 Hz, not only on the spindle band (11-16 Hz): the network sees the whole z-scored signal. On the same hour of a scalp night (demo night, ‘eeg’ model, defaults; the 250 Hz signal presented at other rates with an anti-aliased resampler):

  • 250 Hz: 231 spindles; 512-4096 Hz: the same 231; 256 Hz: 232 (1 extra);

  • 200 Hz: 234 (recall 0.996, precision 0.983 vs 250 Hz);

  • 128 Hz: 247 (+7 %); 100 Hz: 291 (+26 %); 50 Hz: 313 (+35 %);

  • 250 Hz after a 60 Hz notch filter alone: 273 (+18 %).

Low-pass filtering the 250 Hz signal at 25 / 50 Hz gives 315 / 290 spindles, matching the 50 / 100 Hz runs at 99 %, so the resampler is consistent and the difference is the missing band (in this recording, line noise at 58-62 Hz holds 2.9 % of the power, the spindle band 3.5 %). detect() warns when fs < 200 Hz; it cannot tell whether a higher-rate recording was notch or low-pass filtered before. Use the same sampling rate and the same preprocessing for every recording of a study (or compare only recordings processed alike).

These measurements show only that the counts differ. Which rate or preprocessing agrees best with expert scoring was not measured (the counts are relative to the full-band 250 Hz run, not to a ground truth); that the full band at >= 200 Hz is closest to the training data is a plausible but untested hypothesis (whether the training recordings were notch filtered is not documented).

Validation against the original package#

  • Single windows: with flat_s=None, demean=False a 30 s, 250 Hz input gives the same network outputs (within 1e-5) and the same intervals (within 0.01 samples) as openspindlenet 0.1.1 on its two sample windows (golden tests, also run live against the installed original). With the defaults, the eeg sample gives the same spindles (confidences within 0.01).

  • Multiple windows: on 6 min (12 windows) of a scalp night, step_s=30 with the other settings at their defaults gives the same intervals as openspindlenet run window by window (eeg: within 0.5 samples and 0.01 confidence, the difference being demeaning; ieeg: identical); with the default 20 s step, 94-96 % (eeg) / 89 % (ieeg) of the spindles agree. An independent 1 h check gave 204 vs 204 spindles (step_s=30, demean=False).

  • The one deliberate difference: openspindlenet drops a spindle whose interval starts exactly at the first sample of a window (the iEEG sample has one, 0-0.87 s, confidence 0.6; about 2 % of the detections on a long recording); it is kept here.

  • The batched wavelet transform equals pywt.cwt (float64, the training-time wavelet sampling) to a relative error below 1e-14. Training computed it on float32 data, which moves the model outputs by <= 0.006 (see scalogram()).

  • Training resampled with np.interp (no anti-aliasing), the port with scipy.signal.resample_poly: 854 vs 861 spindles on a 6.8 h night (93-94 % agreement), F1 0.570 vs 0.572 on DREAMS.

Performance#

Measured on a Xeon E5-1650 v2 (2013, 6 cores / 12 threads, all threads used; CPU only): the wavelet transform takes about 6 ms per 30 s window (18 ms with one thread; pywt: 1.2 s), the network 0.12-0.2 s per window. With the default 20 s step a channel runs at 90-96x real time (the 6.8 h demo night in 253-271 s; more on a loaded machine). step_s=30 (no overlap, as the original) is 1.5x faster but less stable at window edges (90.7 % of the spindles agree with the 20 s step; 10 s vs 20 s: 94.6 %). A GPU (device='cuda') should remove most of the network cost (not yet measured).

Memory: channels are processed one at a time, but each channel is held whole at its original rate in float64, in a few copies (e.g. 22 GB per copy for 24 h at 32 kHz). Downsample long high-rate recordings beforehand or process them in overlapping segments (see SpindleDetector, Memory).

API#

class brainmaze_eeg_models.spindles.SpindleDetector(model: str = 'eeg', *, device: str = 'auto', threads: int | None = None, batch_size: int = 32, confidence_threshold: float = 0.5, nms_iou_threshold: float = 0.3, step_s: float = 20.0, min_valid_fraction: float = 0.5, gap_margin_s: float = 0.5, flat_s: float | None = 0.5, demean: bool | str = 'auto')#

Sleep-spindle detector (OpenSpindleNet) for long, multichannel recordings with gaps.

The models were trained on 30 s windows at 250 Hz (Seják et al. 2025, see Credits). detect() takes a whole recording at any sampling rate and

  1. treats NaN/inf samples, and runs of identical values lasting >= flat_s (a disconnected, clipped or zero-padded channel), as gaps;

  2. fills the gaps (brainmaze_utils.gaps.fill_gaps(), spectral noise) so that the filters and the network see a plausible background;

  3. resamples to 250 Hz with scipy.signal.resample_poly() (its polyphase FIR is the anti-aliasing low-pass; cut-off at the lower Nyquist rate, 125 Hz when downsampling; exact ratio where one exists, e.g. 256 Hz -> 125/128). The training data were resampled with np.interp (linear interpolation, no anti-aliasing); the better filter does not change the results measurably: on a 6.8 h 500 Hz night 854 vs 861 spindles with 93-94 % mutual agreement (the same order as step 20 s vs 30 s), on DREAMS F1 0.570 vs 0.572;

  4. cuts sliding 30 s windows every step_s seconds (the last one aligned to the end), computes the model inputs exactly as OpenSpindleNet (z-scored signal + z-scored ‘shan6-13’ scalogram, see brainmaze_eeg_models.spindles.scalogram()) and runs the network in batches;

  5. decodes each window as OpenSpindleNet does (confidence threshold, per-window NMS); each window keeps only the spindles whose centre lies in the part of the recording it owns (the middle of the window; window boundaries are half-way between window centres), so the network’s poorer view at window edges is not used; overlapping detections of neighbouring windows are then merged by NMS;

  6. drops every spindle that overlaps a gap widened by gap_margin_s and reports the unevaluated time per channel (SpindleDetections.not_evaluated).

Parameters:
  • model ({'eeg', 'ieeg'}) – 'eeg': scalp EEG model; 'ieeg': intracranial EEG model.

  • device ({'auto', 'cpu', 'cuda'}) – See brainmaze_eeg_models.runtime.OnnxModel.

  • threads (int, optional) – CPU threads for the network and the FFTs (None: all cores).

  • batch_size (int) – Windows per inference call.

  • confidence_threshold (float) – Minimum confidence of a detection (OpenSpindleNet default 0.5).

  • nms_iou_threshold (float) – Overlapping detections with IoU >= this are merged, keeping the most confident one (OpenSpindleNet default 0.3).

  • step_s (float) – Hop between windows, in (0, 30] s. Default 20 s: 10 s overlap, every window keeps the spindles centred in its middle ~20 s (>= 5 s away from its edges, except at the recording edges). Smaller steps cost proportionally more time.

  • min_valid_fraction (float) – A window with less than this fraction of valid (non-gap) samples is not evaluated.

  • gap_margin_s (float) – Spindles overlapping a gap widened by this many seconds on each side are dropped.

  • flat_s (float or None) – Runs of identical consecutive values at least this long (s) are treated as gaps. None disables the check.

  • demean ({'auto', True, False}) –

    Subtract each window’s mean before the scalogram. The scalogram kernels (up to 43 s) are longer than the window, so a DC offset leaks into every scalogram sample (the z-scored raw-signal input is not affected). 'auto' (default) does what matches each model’s training data: on for ‘eeg’, off for ‘ieeg’ (DEMEAN_AUTO). Measured effects:

    • ’eeg’ (scalp data have small offsets; DREAMS <= 0.02 SD): demeaning changes nothing measurable (DREAMS F1 identical, the 6.8 h demo night gives the same 854 spindles) and protects against offsets, which without it change the confidences on the bundled eeg sample by up to 0.01 at 2 SD and 0.13 at 10 SD, and suppress most detections at 100 SD.

    • ’ieeg’: the training data were raw MEF signals (no filtering, no demeaning), so DC offsets were part of the training distribution, and this model is much more sensitive to them: on the bundled iEEG sample (offset -1.89 SD) an added offset of 0.5 / 1 / 3 / 5 SD changes the confidences by up to 0.07 / 0.10 / 0.32 / 0.57, and demeaning that sample changes them by up to 0.055 (segmentation 0.25, interval edges by up to 9 samples). Nobody has validated either choice on labelled iEEG, so the default keeps the training behaviour.

    Limitation: with demeaning off, the outputs depend on the recording’s DC offset (amplifier offset, slow drift, reference choice). detect() records the largest |window mean| / window SD of the evaluated windows in params['max_abs_offset_sd'] and warns when it exceeds 5 (the bundled iEEG sample, from the training domain, has 1.9; 3.1 after its zero-filled dropout is filled, so a few SD are in-distribution; how far the training data went is not known). For such data, high-pass filter beforehand (e.g. 0.3-0.5 Hz) or decide explicitly with demean=True. False reproduces the original package exactly.

Notes

Differences from the openspindlenet package (0.1.1), all deliberate:

  • The wavelet is sampled as in PyWavelets <= 1.8 (precision=10), the version the models were trained with. openspindlenet with PyWavelets >= 1.9 computes a different scalogram (different detections) without any warning.

  • openspindlenet silently drops a spindle whose interval starts exactly at the first sample of the 30 s input; here it is kept.

  • NaN input is not z-scored to zeros silently: gaps are filled, then the spindles in and next to them are dropped and the time is reported as not evaluated. Flat runs (zero-filled dropouts, disconnected or saturated channels) are treated as gaps.

  • With the ‘eeg’ model each window is demeaned before the scalogram (demean='auto').

  • Any length >= 30 s and any sampling rate >= 50 Hz is accepted (the original: exactly 7500 samples, assumed to be at 250 Hz). Rates below 250 Hz are upsampled. The ‘eeg’ model’s training data included 50, 100 and 200 Hz DREAMS recordings upsampled to 250 Hz (by linear interpolation). See Sampling rate and preprocessing below: low rates are accepted, but they do change the results.

With flat_s=None, demean=False a 250 Hz input gives the same intervals as openspindlenet run window by window with step_s=30 (pinned by golden tests on single windows and on a 6 min multi-window recording), apart from the spindle-at-sample-0 case. The ‘ieeg’ model’s defaults (demean='auto' = off) give the same; the ‘eeg’ defaults differ only through demeaning (tested: same spindles, confidence differences < 0.01).

Sampling rate and preprocessing

The network sees the z-scored raw signal as well as the scalogram, so its output depends on the whole band up to 125 Hz, not only on the spindle band (11-16 Hz). Measured on the same hour of a scalp night (Fz-Cz, demo night, ‘eeg’ model, defaults), presented at different rates with an anti-aliased resampler (resample_poly):

input rate / processing

spindles

vs the 250 Hz run (IoU >= 0.3)

250 Hz and >= 256 Hz

231-232

identical, except 1 extra at 256 Hz

200 Hz

234

recall 0.996, precision 0.983

128 Hz

247 (+7 %)

recall 0.991, precision 0.927

100 Hz

291 (+26 %)

recall 0.983, precision 0.780

50 Hz

313 (+35 %)

recall 0.970, precision 0.716

250 Hz, 60 Hz notch only

273 (+18 %)

The 250 Hz signal low-pass filtered at 25 / 50 Hz gives 315 / 290 spindles, matching the 50 / 100 Hz runs at 99 %: the difference comes from the missing band above the lower Nyquist rate (here line noise at 58-62 Hz is 2.9 % of the power, the spindle band 3.5 %), not from the resampler. The extra detections are mostly additional detections, not shifted copies of the 250 Hz ones (recall vs 250 Hz stays >= 0.97); whether they are spindles was not checked. The shared ones agree well (median edge difference 11-12 ms at 50-100 Hz).

Consequences:

  • detect() warns (RuntimeWarning) when fs < 200 Hz (FS_WARN). It cannot see whether a higher-rate recording was low-pass or notch filtered before (scalp EEG has little power above 40 Hz anyway, so a power test would not tell a filtered recording from a clean one), so check your own pipeline.

  • Within a study, use the same sampling rate and the same preprocessing (notch, low-pass, re-referencing) for every recording, or compare densities only between recordings processed alike. Absolute spindle densities from data below 200 Hz, or after notch / low-pass filtering below ~60 Hz, are not directly comparable with those from full-band data.

  • Which rate or preprocessing agrees best with expert scoring was not measured: the counts above are relative to the full-band 250 Hz run only, not to a ground truth. (It is a plausible but untested hypothesis that the full band at >= 200 Hz is closest to the training data; whether the training recordings were notch filtered is not documented.)

Memory

Channels are processed one at a time, but each whole channel is held at its original rate in float64, in a few copies (the input cast, flat-run masking, gap filling and the resampler’s working arrays): about 8 bytes x samples per copy, e.g. 22 GB per copy for 24 h at 32 kHz. For long high-rate recordings, downsample beforehand (with an anti-aliasing filter, to e.g. 250-1000 Hz) or call detect() on segments (overlap them by >= 30 s and keep each segment’s spindles away from its edges).

Credits

Models and the decoding are from OpenSpindleNet (CaptainTrojan/openspindlenet, MIT License, Copyright (c) 2025 CaptainTrojan; full text in NOTICE.md). If you use the detector, please cite: M. Seják, F. Mivalt, V. Sladký, V. Všianský, D. Z. Carvalho, E. K. St Louis, G. A. Worrell, V. Křemen, “OpenSpindleNet: An open-source deep learning network for reliable sleep spindle detection in scalp and intracranial EEG”, Computers in Biology and Medicine 197 (2025) 110854.

property device: str#

Device the network runs on (‘cpu’ or ‘cuda’).

predict_windows(windows: ndarray) → dict[str, ndarray]#

Run the network on 30 s windows at 250 Hz, preprocessed as OpenSpindleNet does.

Parameters:

windows (array_like, shape (n_windows, 7500) or (7500,)) – Finite signal windows sampled at 250 Hz (any units; each window is z-scored, and demeaned before the scalogram if demean).

Returns:

'detection': (n_windows, 30, 3) sigmoid outputs (confidence, centre offset, duration / 2 s) per 1 s segment; 'segmentation': (n_windows, 7500) per-sample spindle probability.

Return type:

dict

detect(x, fs: float) → SpindleDetections#

Detect spindles in a recording.

Parameters:
  • x (array_like, shape (n_samples,) or (n_channels, n_samples)) – Signal (any units, e.g. uV); NaN/inf mark missing data. At least 30 s long. Channels are processed independently (time on the last axis).

  • fs (float) – Sampling rate (Hz), >= 50. Below 200 Hz a RuntimeWarning is issued: the results depend on the band above ~25-60 Hz (see Sampling rate and preprocessing in the class notes).

Return type:

SpindleDetections

class brainmaze_eeg_models.spindles.SpindleDetections(start: ndarray, end: ndarray, confidence: ndarray, channel: ndarray, not_evaluated: tuple, n_channels: int, duration_s: float, model: str, params: dict = <factory>)#

Spindles found in a recording, one row per spindle, times in seconds from the first sample.

start, end

Interval of each spindle (s). Sorted by channel, then start.

Type:

np.ndarray, float64

confidence#

Detection confidence in [0, 1] (the model’s sigmoid output).

Type:

np.ndarray, float64

channel#

Channel index (row of the input; 0 for 1-D input).

Type:

np.ndarray, int64

not_evaluated#

Per channel, (k, 2) [start, stop) intervals (s) where no detection is possible: NaN/inf gaps and flat segments widened by gap_margin_s, plus the time owned by windows that were skipped (too little valid data, or flat). An empty result there means “not evaluated”, never “no spindles”.

Type:

tuple of np.ndarray

n_channels#
Type:

int

duration_s#

Recording duration n_samples / fs.

Type:

float

model#
Type:

str

to_array() → ndarray#

(n, 4) float64 array [start, end, confidence, channel].

channel_intervals(channel: int = 0) → ndarray#

(n, 3) [start, end, confidence] of one channel, sorted by start.

evaluated_fraction(channel: int = 0) → float#

Fraction of the recording of channel that was evaluated.

to_dataframe()#

pandas DataFrame with columns start, end, duration, confidence, channel.

brainmaze_eeg_models.spindles.scalogram(x: ndarray, *, scales=array([135., 141.85214622, 149.05208435, 156.61746713, 164.56684332, 172.91970312, 181.69652601, 190.91883092, 200.60922903, 210.79147918, 221.49054612, 232.73266174, 244.54538935, 256.95769131, 270.]), precision: int = 10, workers: int | None = None) → ndarray#

Magnitude of the ‘shan6-13’ CWT of each window, as OpenSpindleNet computes it.

Parameters:
  • x (array_like, shape (..., n)) – Real windows (time on the last axis), finite. Each window is transformed independently with zero padding at its edges (as pywt.cwt does).

  • scales (array_like) – Scales in samples (default: OpenSpindleNet’s 15 scales, 135-270).

  • precision (int) – Wavelet sampling, 2**precision points (default 10 = what the models were trained on).

  • workers (int, optional) – Threads for scipy.fft (default: scipy’s default, 1).

Returns:

|coef|.

Return type:

np.ndarray, shape (…, n_scales, n), float64