Source code for mne_denoise.dss.denoisers.periodic

"""Periodic bias functions for DSS."""

from __future__ import annotations

import numpy as np
from scipy import ndimage, signal

from .base import LinearDenoiser, NonlinearDenoiser


[docs] class PeakFilterBias(LinearDenoiser): """Second-order IIR peak-filter bias for DSS. Parameters ---------- freq : float Target frequency in Hz. sfreq : float Sampling frequency in Hz. q_factor : float, default=30.0 Quality factor passed to :func:`scipy.signal.iirpeak`; approximately, ``bandwidth = freq / q_factor``. order : int, default=2 Accepted and stored for API compatibility. The current implementation always designs the filter with :func:`scipy.signal.iirpeak` and this parameter does not change it. """ def __init__( self, freq: float, sfreq: float, *, q_factor: float = 30.0, order: int = 2, ) -> None: self.freq = freq self.sfreq = sfreq self.q_factor = q_factor self.order = order # Design peak filter self._sos = self._design_peak_filter() def _design_peak_filter(self) -> np.ndarray: """Design IIR peak filter using second-order sections.""" nyq = self.sfreq / 2 if self.freq >= nyq: raise ValueError( f"Target frequency ({self.freq} Hz) must be < Nyquist ({nyq} Hz)" ) # Normalized frequency w0 = self.freq / nyq # Bandwidth from Q factor w0 / self.q_factor # Design peak filter using iirpeak b, a = signal.iirpeak(w0, self.q_factor) sos = signal.tf2sos(b, a) return sos
[docs] def apply(self, data: np.ndarray) -> np.ndarray: """Apply the peak-filter bias. Parameters ---------- data : ndarray, shape (n_channels, n_times) or (n_channels, n_times, n_epochs) Channel-first data. Returns ------- ndarray Peak-filtered data with the input shape. """ if data.ndim not in (2, 3): raise ValueError(f"Data must be 2D or 3D, got {data.ndim}D") # sosfiltfilt filters along `axis` independently of the other axes, # so a 3D (n_channels, n_times, n_epochs) array needs no per-epoch loop. return signal.sosfiltfilt(self._sos, data, axis=1)
[docs] class CombFilterBias(LinearDenoiser): """Comb-filter bias for harmonic frequencies. Parameters ---------- fundamental_freq : float Fundamental frequency in Hz. sfreq : float Sampling frequency in Hz. n_harmonics : int, default=3 Number of harmonics to consider. q_factor : float, default=30.0 Quality factor passed to each peak filter. q_mode : {"fixed", "proportional"}, default="fixed" With ``"fixed"``, Q is constant and ``bandwidth = frequency / Q`` therefore increases in absolute width at higher harmonics. With ``"proportional"``, Q is multiplied by the harmonic number, giving approximately constant absolute bandwidth. weights : array-like or None, default=None Harmonic weights. ``None`` uses inverse harmonic-number weights. Notes ----- The implementation sums peak-filter outputs and excludes harmonics at or above 95 percent of Nyquist. """ def __init__( self, fundamental_freq: float, sfreq: float, *, n_harmonics: int = 3, q_factor: float = 30.0, q_mode: str = "fixed", weights: np.ndarray | None = None, ) -> None: self.fundamental_freq = fundamental_freq self.sfreq = sfreq self.n_harmonics = n_harmonics self.q_factor = q_factor # Validate q_mode allowed_q_modes = ("fixed", "proportional") if q_mode not in allowed_q_modes: raise ValueError(f"q_mode must be one of {allowed_q_modes}, got {q_mode!r}") self.q_mode = q_mode # Set up weights if weights is None: self.weights = np.array([1.0 / h for h in range(1, n_harmonics + 1)]) else: self.weights = np.asarray(weights) if len(self.weights) != n_harmonics: raise ValueError( f"weights length ({len(self.weights)}) must match " f"n_harmonics ({n_harmonics})" ) # Create peak filters for each valid harmonic self._peak_filters: list[tuple[np.ndarray, float]] = [] self._create_harmonic_filters() def _create_harmonic_filters(self) -> None: """Create peak filter for each harmonic within Nyquist.""" nyq = self.sfreq / 2 for h in range(1, self.n_harmonics + 1): freq = self.fundamental_freq * h if freq >= nyq * 0.95: continue # Skip harmonics too close to Nyquist w0 = freq / nyq weight = self.weights[h - 1] # Proportional Q scales linearly with harmonic number, # maintaining constant absolute bandwidth across harmonics q = self.q_factor * h if self.q_mode == "proportional" else self.q_factor b, a = signal.iirpeak(w0, q) sos = signal.tf2sos(b, a) self._peak_filters.append((sos, weight))
[docs] def apply(self, data: np.ndarray) -> np.ndarray: """Apply the summed harmonic peak-filter bias. Parameters ---------- data : ndarray, shape (n_channels, n_times) or (n_channels, n_times, n_epochs) Channel-first data. Returns ------- ndarray Weighted harmonic content with the input shape. """ if len(self._peak_filters) == 0: raise ValueError("No valid harmonics within Nyquist frequency") if data.ndim not in (2, 3): raise ValueError(f"Data must be 2D or 3D, got {data.ndim}D") # sosfiltfilt filters along `axis` independently of the other axes, # so a 3D (n_channels, n_times, n_epochs) array needs no per-epoch loop. biased = np.zeros_like(data) for sos, weight in self._peak_filters: biased += weight * signal.sosfiltfilt(sos, data, axis=1) return biased
@property def harmonic_frequencies(self) -> list[float]: """Return list of harmonic frequencies being filtered.""" nyq = self.sfreq / 2 return [ self.fundamental_freq * h for h in range(1, self.n_harmonics + 1) if self.fundamental_freq * h < nyq * 0.95 ]
[docs] class QuasiPeriodicDenoiser(NonlinearDenoiser): """Cycle-template denoiser for quasi-periodic source signals. Peaks are detected on the absolute source using the configured percentile and minimum-distance rule. The resulting cycles are rescaled to a common duration, averaged into a template, optionally smoothed, and mapped back to each cycle with its original length and amplitude scaling. The reconstructed cycle structure is returned; fewer than three detected peaks leave the source unchanged. Parameters ---------- peak_distance : int, default=100 Minimum peak distance in samples; values below 10 are set to 10. peak_height_percentile : float, default=75.0 Percentile used as the absolute-source peak threshold. warp_length : int or None, default=None Common cycle length. ``None`` uses the median detected cycle length. smooth_template : bool, default=True Smooth the averaged template with a uniform filter. """ def __init__( self, peak_distance: int = 100, peak_height_percentile: float = 75.0, *, warp_length: int | None = None, smooth_template: bool = True, ) -> None: self.peak_distance = max(10, peak_distance) self.peak_height_percentile = peak_height_percentile self.warp_length = warp_length self.smooth_template = smooth_template
[docs] def denoise(self, source: np.ndarray) -> np.ndarray: """Apply the cycle-template denoiser. Parameters ---------- source : ndarray, shape (n_times,) or (n_times, n_epochs) One source or sources arranged by columns. Returns ------- ndarray Reconstructed source with the input shape. """ if source.ndim == 1: return self._denoise_1d(source) elif source.ndim == 2: n_times, n_epochs = source.shape denoised = np.zeros_like(source) for ep in range(n_epochs): denoised[:, ep] = self._denoise_1d(source[:, ep]) return denoised else: raise ValueError(f"Source must be 1D or 2D, got {source.ndim}D")
def _denoise_1d(self, source: np.ndarray) -> np.ndarray: """Apply quasi-periodic denoising to 1D source.""" n_samples = len(source) # Step 1: Detect peaks height_threshold = np.percentile(np.abs(source), self.peak_height_percentile) peaks, _ = signal.find_peaks( np.abs(source), height=height_threshold, distance=self.peak_distance, ) if len(peaks) < 3: # Not enough cycles, return original return source # Step 2: Determine cycle boundaries (midpoints between peaks) boundaries = np.zeros(len(peaks) + 1, dtype=int) boundaries[0] = 0 boundaries[-1] = n_samples for i in range(1, len(peaks)): boundaries[i] = (peaks[i - 1] + peaks[i]) // 2 # Step 3: Extract cycles and determine warp length cycles = [] cycle_lengths = [] for i in range(len(peaks)): start = boundaries[i] end = boundaries[i + 1] if end > start: cycles.append(source[start:end]) cycle_lengths.append(end - start) if len(cycles) < 2: return source # Warp length: use provided or median if self.warp_length is not None: warp_len = self.warp_length else: warp_len = int(np.median(cycle_lengths)) warp_len = max(10, warp_len) # Step 4: Time-warp all cycles to common length and average warped_cycles = [] for cycle in cycles: if len(cycle) >= 3: # Resample to warp_len warped = np.interp( np.linspace(0, 1, warp_len), np.linspace(0, 1, len(cycle)), cycle ) warped_cycles.append(warped) if len(warped_cycles) < 2: return source # Average to create template template = np.mean(warped_cycles, axis=0) # Optional smoothing if self.smooth_template: smooth_window = max(3, warp_len // 20) template = ndimage.uniform_filter1d( template, size=smooth_window, mode="reflect" ) # Step 5: Replace each cycle with time-warped template denoised = np.zeros_like(source) for i, cycle in enumerate(cycles): start = boundaries[i] end = boundaries[i + 1] cycle_len = end - start if cycle_len >= 3: # Warp template back to original cycle length warped_template = np.interp( np.linspace(0, 1, cycle_len), np.linspace(0, 1, warp_len), template ) # Match amplitude to original cycle scale = np.std(cycle) / (np.std(warped_template) + 1e-15) offset = np.mean(cycle) - np.mean(warped_template) * scale denoised[start:end] = warped_template * scale + offset return denoised