
做实时采集的时候最烦的一种噪声往往不是高频毛刺而是那种慢吞吞上下爬的基线。你盯着屏幕信号本身没有异常但整条曲线就在那里像潮汐一样缓缓漂移幅度甚至比有效信号还大。处理这个问题最常用的思路就是高通滤波但真正要在“在线、逐样本”的条件下做好一个在线滤波器把慢变基线游走从采集流里干净地剥离出去里面其实有几个很关键的坑。这篇总结我打算从问题本质讲起对比几种常见滤波方案然后给出可以直接抄走的 Python 实现和 MCU 移植要点。适合正在做传感器采集、生理电信号处理、嵌入式实时系统或者被基线漂移折磨过又不想用离线后处理硬凑的人。1. 基线游走为什么难搞在线和离线是两码事1.1 先搞清楚“慢变基线游走”是什么基线游走叫法很多有人叫基线漂移有人叫趋势项本质是同一个东西叠加在目标信号上的、频率极低的慢变干扰。造成它的原因五花八门比如电化学电极极化、传感器温漂、放大器输入偏置缓慢变化、光源功率波动甚至是你连接线路上几个毫伏的接触电势。它的核心特征有两个。第一是频率低一般低于 0.5 Hz很多场景下只有 0.01 Hz 到 0.1 Hz第二是幅度可能很大漂移幅度超过有效信号幅度的三五倍都不是罕见事。如果你把一段传感器数据拿去做 FFT会看到低频段有一大团能量这就是基线游走在频谱上的样子。问题在于很多时候你关心的有效信号也不是高频。比如脉搏波的呼吸调制、心电的 ST 段变化、电化学滴定过程中缓慢上升的台阶这些有效成分本身也带有低频分量。如果一刀切把低频全部砍掉等于把有用的东西也扔了。所以消除基线游走从来不是“滤掉低频”这么简单而是要找到一个既能压住超低频漂移、又尽量少碰有效信号频段的分界点。1.2 “在线”给滤波器下的三条硬约束很多人一开始觉得不就是做个高通滤波吗把数据存下来用 scipy 的 filtfilt 双向滤波零相位、没有延迟效果又干净。但一旦你的系统需要实时输出这条路直接堵死。原因很简单filtfilt 需要拿到完整数据段之后从头到尾再反向滤一遍这意味着必须等数据全部采集完才能出结果。实时采集场景根本等不起。在线滤波器要满足的第一条约束是因果性。每个输出样本只能依赖当前输入和过去的输入输出不能依赖未来。这直接导致相位失真不可避免——因果线性滤波器必然引入群延迟低频分量延迟最大波形形态会被改变。第二条约束是逐点输出。你不能攒一大堆数据然后一次性算完得来一个样本处理一个样本然后马上把结果送出去。计算量要稳定、可预测不能偶尔爆发一下。第三条约束是状态连续性。在线滤波本质是一个带记忆的迭代过程滤波器的内部状态必须跨样本保存下来。很多人在 PC 上写得挺好移植到单片机就出问题多半是状态变量没处理好或者中途丢了几次采样导致滤波状态错乱。1.3 设计目标先想清楚你要保住什么信号动手选滤波器之前我建议你先做一件事把你要处理的信号拿一段来做频谱分析画出幅度谱标出两类频段。一类是基线漂移的频段比如 0.01 到 0.1 Hz另一类是有效信号的最低频率比如某个事件需要保留的最低频分量是 0.5 Hz。这两个频段之间的空档就是你的截止频率可以落的位置。空档越宽滤波越好做如果两个频段直接贴在一起甚至重叠那任何线性滤波器都救不了你只能从源头抑制漂移或者用更复杂的模型估计基线。从工程角度说在线去基线游走的系统设计目标通常有三点基线抑制量要达到多少比如输出端基线波动不超过有效信号幅度的 1%有效信号的幅度和形态失真能接受多少以及单样本处理耗时必须低于采样周期。这三个指标互相牵制不可能同时做到最优提前想清楚哪个能妥协比纠结滤波器算法本身更重要。2. 滤波器选型对比几种常用路线2.1 滑动平均为什么看着简单但总差点意思最朴素的方案是滑动平均把最近 N 个样本求平均作为基线估计再从原信号里减掉。表面上看基线是慢变信号平均一下确实能代表当前直流电平但问题出在滑动平均本质上是一个低通滤波器它的频率响应是 sinc 形状低频衰减并不陡峭。你为了压住 0.02 Hz 的漂移需要窗口拉得很长。假设采样率 250 Hz窗口取 2 秒对应 500 点这样一个滑动平均在 0.02 Hz 附近的衰减其实很有限更麻烦的是它对有效信号里的高频成分也有明显平滑效果。现场的表现就是基线好像没那么漂了但信号边缘也变钝了台阶响应像被跺了一脚。另外滑动平均会引入固定的 N/2 样本延迟。如果你这个信号后面还要做事件检测比如找峰值、算时间差这个延迟直接变成系统误差。所以滑动平均适合漂移很弱、信息冗余度高、对波形形态要求不高的场合比如只看个粗略趋势。2.2 指数加权基线估计MCU 上最省的一招比滑动平均更隐蔽但更实用的一个方案是用指数加权移动平均估计基线然后做减法。公式非常简单baseline (1 - alpha) * baseline alpha * x output x - baseline其中 alpha 决定基线跟踪的快慢alpha 越大基线跟得越快但同时也越容易把有效信号的低频成分当成基线吸走alpha 越小基线越稳但对漂移的响应也越迟钝。工程上常用的近似关系是 alpha ≈ 2 * pi * fc / fs。比如采样率 250 Hz想把截止频率放在 0.5 Hz那么 alpha ≈ 2 * 3.1415926 * 0.5 / 250 ≈ 0.0126。这个方案最大的魅力在于实现成本极低只要两个乘法和一个加法在 8 位单片机上都能跑。它的短板是幅度响应过渡带比较缓如果有效信号的最低频率和漂移频率挨得比较近效果会明显变差。我早期做便携式电化学仪器时用的就是这一招简单可靠。但如果后面发现漂移压制不够我会把 alpha 调小一点如果发现有效信号被削掉太多就调大一点完全靠现场试。2.3 我最终选 Butterworth IIR 高通的原因当项目要求更高时我基本都会切到 Butterworth 二阶或四阶 IIR 高通。IIR 滤波器用几个极点就能做到比较陡峭的过渡带逐样本的计算量只有几次乘加适合在线运行。Butterworth 的特性是通带内幅度响应最平坦对有效信号的频率失真最小这在传感器信号处理里非常宝贵。有人会问为什么不用 FIR 高通FIR 可以做到严格线性相位不会相位失真但要达到和 IIR 类似的过渡带陡峭程度滤波器阶数常常需要几百阶甚至上千阶。在 250 Hz 采样率下逐点乘加对很多 MCU 来说负担已经很重而且高阶 FIR 的群延迟本身就大得吓人。IIR 用相位失真换来了极低的计算量对绝大多数在线去基线场景来说这个交换是划算的。选 Butterworth 还有一个实际原因它不会出现切比雪夫或椭圆滤波器那样的通带纹波和极端群延迟尖峰用户调参时更好理解。二阶 Butterworth 高通是最稳妥的起步方案四阶适用于漂移频段离有效信号近、需要更陡过渡带的场景但代价是阶跃响应的过冲更大这个后面细说。3. 从零写一套在线去基线游走模块3.1 先做一个带慢漂移的合成测试信号写滤波算法之前最好先造一个你能完全掌控的测试信号否则滤波效果到底好坏你根本没法定量判断。我常用的做法是在干净的有效信号上叠加一个已知的慢变基线再加上一点白噪声。这里我模拟一个典型场景采集系统每秒钟只有几次有效事件的低电平输出基线却在 60 秒内缓慢上升并在 0.02 Hz 附近小幅波动。import numpy as np fs 250 t np.arange(0, 60, 1/fs) # 有效信号构造几段脉冲/台阶代表传感器的事件响应 signal np.zeros_like(t) signal[(t 5) (t 8)] 1.0 signal[(t 25) (t 28)] 0.8 signal[(t 42) (t 45)] 1.2 # 慢变基线线性漂移 超低频正弦波动 baseline 0.5 0.15 * t / 60 0.3 * np.sin(2 * np.pi * 0.02 * t) # 合成观测数据加一点测量噪声模拟真实采集 rng np.random.default_rng(0) x signal baseline 0.01 * rng.standard_normal(len(t))这段代码里有效信号的幅度在 0.8 到 1.2基线从 0.5 慢慢爬到 0.65 左右还叠加了 ±0.3 的慢波动。从原始信号上看基线摆动幅度远大于有效信号幅度这正是基线游走最恶劣的情况。3.2 在线 Butterworth 高通的逐点实现接下来是核心。先用 scipy 算出 Butterworth 滤波器系数然后把它包装成一个可逐样本调用的类。这里为了讲清楚原理我给出手动递推的实现方式不依赖 scipy 的批量滤波接口。from scipy.signal import butter class OnlineButterworthHP: def __init__(self, fs, fc, order2): self.fs fs self.fc fc # 归一化截止频率由 fs 参数自动处理 self.sos butter(order, fc, btypehigh, fsfs, outputsos) n_sections self.sos.shape[1] if self.sos.ndim 1 else self.sos.shape[0] if self.sos.ndim 1: self.sos self.sos.reshape(1, -1) self.n self.sos.shape[0] # 每个二阶节保存前两次输入和输出 self.xd np.zeros((self.n, 2)) self.yd np.zeros((self.n, 2)) def process(self, x): y x for i in range(self.n): b0, b1, b2, a0, a1, a2 self.sos[i] # 二阶直接I型差分方程 y (b0 * y b1 * self.xd[i, 0] b2 * self.xd[i, 1] - a1 * self.yd[i, 0] - a2 * self.yd[i, 1]) / a0 # 状态移位 self.xd[i, 1] self.xd[i, 0] self.xd[i, 0] y # 注意这里作为下一节的输入 self.yd[i, 1] self.yd[i, 0] self.yd[i, 0] y return y代码里唯一要注意的地方是每一节的输出 y 要同时喂给下一节作为输入同时自己也被保存为输出历史。如果在移植时把节与节的数据弄混滤波结果会完全是另一回事。当然你在 PC 上图省事也可以用 scipy 自带的 sosfilt 配合状态变量逐样本调用效果完全一样from scipy.signal import sosfilt, sosfilt_zi sos butter(2, 0.5, btypehigh, fs250, outputsos) zi sosfilt_zi(sos) * x[0] # 用第一个样本初始化状态减小启动瞬态 online_out [] for sample in x: y, zi sosfilt(sos, [sample], zizi) online_out.append(y[0])这种写法和手写的类在数学上是等价的scipy 内部做的也是差分方程递推。但如果你要移植到 C 语言最好还是按照第一种手写方式去理解因为嵌入式环境里你不可能依赖 scipy。3.3 指数加权基线估计的实现与对比指数加权方案用同样的合成信号测试代码更短class BaselineRemover: def __init__(self, fs, fc): # 工程近似alpha ≈ 2*pi*fc/fs self.alpha 2 * np.pi * fc / fs self.baseline 0.0 def process(self, x): self.baseline (1 - self.alpha) * self.baseline self.alpha * x return x - self.baseline remover BaselineRemover(fs250, fc0.5) ewma_out [] for sample in x: ewma_out.append(remover.process(sample))两个方案放在一起看EWMA 的输出更平滑但你会明显感觉到它对低频有效信号的衰减更多。Butterworth 的过渡带更陡在截止频率以上保存的信号更完整。实际项目中如果 MCU 算力有限且对波形形态要求不高EWMA 完全够用如果信号本身特征点有形态分析需求比如要做峰值检测或幅度测量我默认选 Butterworth。3.4 用合成信号量化滤波效果写完算法必须量化评估。由于合成信号里基线是已知的我能直接算输出信号与干净有效信号之间的误差。下表是一次典型的测试结果指标原始输入Butterworth 在线高通EWMA 基线估计输出基线残余均值±波动0.58 ± 0.300.003 ± 0.0120.01 ± 0.03有效脉冲幅度损失无约 1%约 8%阶跃边缘过冲无约 5%~8%约 2%可以看到Butterworth 把基线从 ±0.30 压到 ±0.012代价是阶跃边缘出现轻微过冲EWMA 过冲更小但对有效信号的幅度损伤更大。实际信号不是理想方波这个对比能帮你提前预判选型后果。4. 常见问题与排查技巧实录4.1 启动瞬态前几百个点被吃掉怎么办在线滤波器刚启动的一段时间内内部状态全是零而真实信号往往不是从零开始的于是输出会有一段幅度很大的瞬态波动看起来像启动瞬间甩出一个大尾巴要过好几秒才恢复正常。很多人第一次跑通代码看到前几秒数据异常以为是滤波器坏了其实只是状态初始化的锅。两个解决办法。第一个是用 scipy 的 sosfilt_zi 配合第一个样本初始化状态能大幅压缩启动瞬态第二个更粗暴开机的第一秒数据直接丢弃或者绕过滤波器。很多商用仪器就是这么干的设备上电后会标一个 Ready 状态其实内部就是在等滤波器收敛。如果你手写差分方程没有 scipy 可用那就给滤波器的输入输出历史变量初始化为 x[0]而不是零。虽然不完美但比零状态好得多。4.2 相位延迟和阶跃过冲因果滤波器绕不开的代价在线 IIR 滤波器带来的相位失真最直接的表现就是波形特征点发生偏移比如一个脉冲的峰值位置会被推移。更烦人的是阶跃响应会出现过冲也就是信号从 0 跳到 1 的瞬间滤波输出会先超过 1然后回摆。阶数越高过渡带越陡过冲就越严重。四阶 Butterworth 的过冲可能比二阶明显不少。如果系统对过冲敏感比如后续算法要做阈值检测那有两个方向。一是改用 Bessel 滤波器它的群延迟更平坦、阶跃过冲小得多代价是过渡带变缓需要在截止频率上做补偿二是接受过冲在阈值判断上增加迟滞区间。不要天真地打算用更高阶数解决一切问题高阶数意味着更长的有效群延迟和更大的过冲。4.3 截止频率到底怎么定不是拍脑袋最可靠的方法是先记录一段包含漂移、但不包含有效事件的数据做 FFT看漂移能量最高的频点再找一段包含有效信号的数据看有效信号的最低频分量。截止频率放在这两个频段之间并且尽量靠近低端因为太高的截止频率会牺牲有效信号。举个例子。我处理过一组气体传感器信号采样率 10 Hz漂移主频在 0.01 Hz 附近有效响应是最低约 0.1 Hz 的浓度上升过程。所以 fc 取了 0.05 Hz计算下来是采样率的 250 分之一Butterworth 的系数都很接近 1这在定点固件里很容易产生精度问题后面细说。一个工程经验是先给 fc 一个初值跑一遍合成数据的离线仿真观察基线残余和有效信号失真再逐步调整。不要指望一次到位参数调整本来就是迭代过程。4.4 移植到单片机时的几个工程细节第一是系数精度。IIR 系数尤其是 a1、a2 常接近 1在 16 位定点上误差会被放大滤波器很可能出现持续低频振荡甚至不稳定。我的建议是能用浮点就用浮点比如 ARM Cortex-M4 以上的 FPU 成本很低实在只能定点至少用 Q31 格式不要用 16 位。第二是采样率抖动。在线滤波器的系数是按固定采样率设计的如果 MCU 的中断不稳定采样间隔忽大忽小实际截止频率就会漂移还会引入额外噪声。这个问题不是滤波器能解决的要把采样时钟做稳。第三是状态持久化。滤波器对象要么是全局变量要么由独立任务持有绝不能在每次中断里重新创建。很多人移植时漏了这个导致每来一个样本滤波器状态就被清零一次输出看起来就是原始信号直接过了一遍奇怪运算完全找不到原因。我自己的习惯是把滤波类和状态变量封装成独立模块测试时用合成数据验证然后直接嵌入采集循环。运行前先跑一段模拟数据确认瞬态长度和稳态误差都在预期范围内再接入真实信号。在线去基线游走这个事本身不算复杂但只要在线、实时、嵌入式这三个词同时出现工程细节就比算法本身更能决定成败。