Source code for mne_denoise.dss.denoisers.masking

"""Local-variance masks for iterative DSS."""

from __future__ import annotations

import numpy as np
from scipy import ndimage

from .base import NonlinearDenoiser


[docs] class WienerMaskDenoiser(NonlinearDenoiser): """Local-variance Wiener mask for iterative DSS. The local variance is estimated from moving averages of the source and its square. A percentile of that variance, or ``noise_variance``, sets the noise floor; the soft gain is ``signal_variance / (signal_variance + noise_variance)`` and is bounded below by ``min_gain``. Parameters ---------- window_samples : int, default=50 Window length for local statistics; values below 3 are set to 3. noise_percentile : float, default=25.0 Percentile used for the estimated noise floor. min_gain : float, default=0.01 Lower bound on the mask gain. noise_variance : float or None, default=None Fixed noise variance. If ``None``, estimate it from the local-variance percentile. """ def __init__( self, window_samples: int = 50, noise_percentile: float = 25.0, *, min_gain: float = 0.01, noise_variance: float | None = None, ) -> None: self.window_samples = max(3, window_samples) self.noise_percentile = noise_percentile self.min_gain = min_gain self.noise_variance = noise_variance
[docs] def denoise(self, source: np.ndarray) -> np.ndarray: """Apply Wiener mask denoising. Parameters ---------- source : ndarray, shape (n_times,) or (n_times, n_epochs) Source time series. Returns ------- denoised : ndarray, same shape as input Wiener-masked source. """ if source.ndim == 1: return self._denoise_1d(source) elif source.ndim == 2: _, 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 Wiener mask to 1D source.""" n_samples = len(source) window = min(self.window_samples, n_samples // 2) # Estimate local signal variance: σ²(t) = E[s²] - E[s]² source_sq = source**2 local_mean_sq = ndimage.uniform_filter1d(source_sq, size=window, mode="reflect") local_mean = ndimage.uniform_filter1d(source, size=window, mode="reflect") local_var = np.maximum(local_mean_sq - local_mean**2, 0) # Estimate noise variance (from quiet periods) if self.noise_variance is not None: noise_var = self.noise_variance else: # Use percentile of local variance as noise floor estimate noise_var = np.percentile(local_var, self.noise_percentile) noise_var = max(noise_var, 1e-15) # Prevent division by zero # Wiener mask: m(t) = σ²_signal / (σ²_signal + σ²_noise) # where σ²_signal = max(0, local_var - noise_var) signal_var = np.maximum(local_var - noise_var, 0) mask = signal_var / (signal_var + noise_var + 1e-15) # Apply minimum gain mask = np.maximum(mask, self.min_gain) return source * mask
[docs] class VarianceMaskDenoiser(NonlinearDenoiser): """Local-variance mask for iterative DSS. The local variance is computed from moving means of the source and its square. A percentile threshold produces either a sigmoid soft mask or a binary mask. Parameters ---------- window_samples : int, default=100 Window length for local variance; values below 3 are set to 3. percentile : float, default=75.0 Local-variance percentile used as the mask threshold. soft : bool, default=True Use a sigmoid gain when true, otherwise use a binary threshold mask. """ def __init__( self, window_samples: int = 100, percentile: float = 75.0, *, soft: bool = True, ) -> None: self.window_samples = max(3, window_samples) self.percentile = percentile self.soft = soft
[docs] def denoise(self, source: np.ndarray) -> np.ndarray: """Apply variance-based masking to a source time series. Parameters ---------- source : ndarray, shape (n_times,) or (n_times, n_epochs) Source time series. Two-dimensional input is processed one epoch at a time. Returns ------- denoised : ndarray, same shape as ``source`` Source weighted by the local-variance mask. """ if source.ndim == 1: return self._denoise_1d(source) elif source.ndim == 2: _, 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: """Process single 1D source.""" n_samples = len(source) source_sq = source**2 window = min(self.window_samples, n_samples) local_mean_sq = ndimage.uniform_filter1d(source_sq, size=window, mode="reflect") local_mean = ndimage.uniform_filter1d(source, size=window, mode="reflect") local_var = np.maximum(local_mean_sq - local_mean**2, 0) if self.soft: threshold = np.percentile(local_var, self.percentile) if threshold < 1e-15: threshold = np.max(local_var) * 0.5 if threshold < 1e-15: return source weights = 1 / (1 + np.exp(-(local_var - threshold) / (threshold * 0.5))) denoised = source * weights else: threshold = np.percentile(local_var, self.percentile) mask = local_var >= threshold denoised = source * mask.astype(float) return denoised