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, anend(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 aconfidence(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 bygap_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=Falsea 30 s, 250 Hz input gives the same network outputs (within 1e-5) and the same intervals (within 0.01 samples) asopenspindlenet0.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=30with the other settings at their defaults gives the same intervals asopenspindlenetrun 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:
openspindlenetdrops 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 (seescalogram()).Training resampled with
np.interp(no anti-aliasing), the port withscipy.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 andtreats NaN/inf samples, and runs of identical values lasting >=
flat_s(a disconnected, clipped or zero-padded channel), as gaps;fills the gaps (
brainmaze_utils.gaps.fill_gaps(), spectral noise) so that the filters and the network see a plausible background;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 withnp.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;cuts sliding 30 s windows every
step_sseconds (the last one aligned to the end), computes the model inputs exactly as OpenSpindleNet (z-scored signal + z-scored ‘shan6-13’ scalogram, seebrainmaze_eeg_models.spindles.scalogram()) and runs the network in batches;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;
drops every spindle that overlaps a gap widened by
gap_margin_sand 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.
Nonedisables 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 SDof the evaluated windows inparams['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 withdemean=True.Falsereproduces the original package exactly.
Notes
Differences from the
openspindlenetpackage (0.1.1), all deliberate:The wavelet is sampled as in PyWavelets <= 1.8 (
precision=10), the version the models were trained with.openspindlenetwith PyWavelets >= 1.9 computes a different scalogram (different detections) without any warning.openspindlenetsilently 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=Falsea 250 Hz input gives the same intervals asopenspindlenetrun window by window withstep_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) whenfs< 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
RuntimeWarningis issued: the results depend on the band above ~25-60 Hz (see Sampling rate and preprocessing in the class notes).
- Return type:
- 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 bygap_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
channelthat 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.cwtdoes).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