第四节:心电数据处理任务设计

好,咱们接着聊。这一节要讲的是心电数据处理的核心任务——数字滤波、基线漂移去除,还有QRS波检测。说实话,这部分是我个人觉得整个监护系统里最有意思的地方。你想想看,原始心电信号进了ADC之后,那波形简直没法看,各种噪声混在里面。怎么把这些噪声去掉,把有用的心电特征提取出来,这就是我们要解决的问题。

4.1 数字滤波设计

先说说滤波。心电信号里最常见的干扰就是50Hz工频噪声。我在项目中遇到过好几次,明明硬件上已经做了屏蔽和滤波,但采集到的信号里还是有明显的50Hz分量。后来我总结了一下,这玩意儿很难完全靠硬件滤干净,数字滤波是必须的。

4.1.1 50Hz工频陷波器

工频陷波,说白了就是把50Hz这个频率点上的信号干掉。我习惯用IIR陷波器,阶数低,计算量小,适合在RTOS环境里跑。

这里给一个二阶IIR陷波器的系数计算方法:

/* 50Hz陷波器设计参数 */
#define SAMPLE_RATE    500     /* 采样率500Hz */
#define NOTCH_FREQ     50.0f   /* 陷波频率 */
#define Q_FACTOR       30.0f   /* 品质因数 */

/* 计算系数 */
float omega = 2.0f * PI * NOTCH_FREQ / SAMPLE_RATE;
float alpha = sin(omega) / (2.0f * Q_FACTOR);

float b0 = 1.0f;
float b1 = -2.0f * cos(omega);
float b2 = 1.0f;
float a0 = 1.0f + alpha;
float a1 = -2.0f * cos(omega);
float a2 = 1.0f - alpha;

/* 归一化系数 */
b0 /= a0;  b1 /= a0;  b2 /= a0;
a1 /= a0;  a2 /= a0;
我的经验:Q值选30左右比较合适。Q值太大,陷波带宽太窄,对频率偏移敏感;Q值太小,会损失太多有用信号。我曾经试过Q=50,结果电网频率稍微波动一下,陷波效果就大打折扣。

4.1.2 0.5-100Hz带通滤波器

带通滤波器的目的是保留心电信号的有效频段。0.5Hz以下的是基线漂移,100Hz以上的主要是肌电噪声。嗯,这里要注意,如果采样率是500Hz,那设计带通滤波器时要留够过渡带。

我推荐用FIR滤波器,虽然计算量大一点,但线性相位特性好,不会造成波形失真。对于QRS波检测来说,波形保真度很重要。

/* FIR带通滤波器系数示例(51阶,500Hz采样率) */
#define FIR_ORDER   50
float fir_coeff[FIR_ORDER + 1] = {
    /* 这里放实际计算出的系数 */
    /* 可以用Matlab的fdatool生成 */
};

/* 滤波函数 */
float fir_filter(float input) {
    static float buffer[FIR_ORDER + 1] = {0};
    static int index = 0;
    float output = 0.0f;
    int i, j;
    
    buffer[index] = input;
    j = index;
    
    for(i = 0; i <= FIR_ORDER; i++) {
        output += fir_coeff[i] * buffer[j];
        j = (j == 0) ? FIR_ORDER : j - 1;
    }
    
    index = (index == FIR_ORDER) ? 0 : index + 1;
    return output;
}
注意:FIR滤波器的群延迟是固定的,等于(N-1)/2个采样点。如果你的系统对实时性要求很高,记得把这个延迟考虑进去。我曾经在一个急诊监护项目里忽略了这个问题,结果报警延迟了100多毫秒,被临床医生投诉了。

4.2 基线漂移去除

基线漂移是低频干扰,主要来自呼吸运动、电极移动等。频率一般在0.5Hz以下。去除基线漂移,我常用的方法有两种:高通滤波法和中值滤波法。

4.2.1 高通滤波法

最简单的方法就是用0.5Hz的高通滤波器。但要注意,心电信号的ST段也含有低频成分,高通滤波可能会让ST段变形。所以,如果要做ST段分析,这个方法要慎用。

4.2.2 中值滤波法

我个人更推荐中值滤波法。它的原理很简单:用一个足够宽的窗口对信号做中值滤波,提取出基线,然后用原始信号减去基线。

/* 中值滤波去除基线漂移 */
#define BASELINE_WINDOW    200  /* 窗口宽度,200ms @500Hz */

float remove_baseline(float input, float *buffer, int *index) {
    float baseline;
    float temp[200];
    int i, j;
    
    /* 更新环形缓冲区 */
    buffer[*index] = input;
    *index = (*index + 1) % BASELINE_WINDOW;
    
    /* 复制并排序 */
    for(i = 0; i < BASELINE_WINDOW; i++) {
        temp[i] = buffer[i];
    }
    
    /* 冒泡排序取中值 */
    for(i = 0; i < BASELINE_WINDOW - 1; i++) {
        for(j = 0; j < BASELINE_WINDOW - i - 1; j++) {
            if(temp[j] > temp[j + 1]) {
                float t = temp[j];
                temp[j] = temp[j + 1];
                temp[j + 1] = t;
            }
        }
    }
    
    baseline = temp[BASELINE_WINDOW / 2];
    return input - baseline;
}
关键点:窗口宽度要大于一个心搏周期。正常人心率60-100次/分,对应周期600-1000ms。窗口选200个采样点(400ms)其实偏小了,我建议选500个采样点(1000ms)更稳妥。

4.3 QRS波检测算法

QRS波检测是心电分析的核心。说白了,就是要找到每个心跳的位置。我做过很多种算法,从简单的阈值法到复杂的小波变换,但在嵌入式系统里,我推荐用Pan-Tompkins算法。这个算法计算量适中,检测准确率高,非常适合在RTOS任务里跑。

4.3.1 算法流程

Pan-Tompkins算法的核心步骤:

  1. 带通滤波:5-15Hz,突出QRS波的能量
  2. 差分运算:突出斜率变化
  3. 平方运算:增强QRS波特征
  4. 移动窗口积分:平滑信号,便于阈值检测
  5. 自适应阈值:根据信号质量动态调整
/* Pan-Tompkins QRS检测核心代码 */
typedef struct {
    float signal;       /* 原始信号 */
    float filtered;     /* 带通滤波后 */
    float derivative;   /* 差分 */
    float squared;      /* 平方 */
    float integrated;   /* 积分 */
} qrs_channel_t;

/* 自适应阈值 */
typedef struct {
    float signal_peak;      /* 信号峰值 */
    float noise_peak;       /* 噪声峰值 */
    float threshold;        /* 检测阈值 */
    int refractory_period;  /* 不应期,200ms */
    int last_qrs_index;     /* 上次QRS位置 */
} qrs_detector_t;

int qrs_detect(float sample, qrs_channel_t *ch, qrs_detector_t *det) {
    /* 1. 带通滤波 */
    ch->filtered = bandpass_5_15hz(sample);
    
    /* 2. 差分 */
    ch->derivative = (2 * ch->filtered + ch->filtered_prev1 
                     - ch->filtered_prev3 - 2 * ch->filtered_prev4) / 8;
    
    /* 3. 平方 */
    ch->squared = ch->derivative * ch->derivative;
    
    /* 4. 移动窗口积分(150ms窗口) */
    ch->integrated = moving_window_integrate(ch->squared, 75);
    
    /* 5. 阈值检测 */
    if(ch->integrated > det->threshold) {
        /* 检查不应期 */
        if(current_index - det->last_qrs_index > det->refractory_period) {
            det->last_qrs_index = current_index;
            /* 更新信号峰值 */
            det->signal_peak = 0.125 * ch->integrated + 0.875 * det->signal_peak;
            return 1;  /* 检测到QRS */
        }
    } else {
        /* 更新噪声峰值 */
        det->noise_peak = 0.125 * ch->integrated + 0.875 * det->noise_peak;
    }
    
    /* 更新阈值 */
    det->threshold = det->noise_peak + 0.25 * (det->signal_peak - det->noise_peak);
    
    return 0;
}
避坑指南:我曾经在算法里忘了加不应期,结果T波被误检成QRS波,心率直接翻倍。后来加了200ms的不应期,问题就解决了。记住,不应期要大于正常心电的QT间期。

4.4 RTOS任务设计要点

把这些算法放到RTOS里,有几个要点要注意:

任务名称 优先级 周期 说明
数据采集任务 最高 2ms (500Hz) 从ADC读取数据,放入队列
滤波处理任务 2ms 执行陷波、带通滤波
基线去除任务 2ms 中值滤波去除基线
QRS检测任务 2ms Pan-Tompkins算法
心率计算任务 1000ms 统计RR间期,计算心率
性能考量:每个采样点都要在2ms内完成所有处理。以STM32F4 @168MHz为例,Pan-Tompkins算法一次迭代大约需要50μs,加上滤波和基线去除,总共不到200μs。嗯,这个余量是够的。但如果你的MCU主频较低,记得把滤波和检测分到不同任务里,利用RTOS的优先级抢占来保证实时性。

好了,这一节的内容就到这里。数字滤波、基线漂移去除、QRS波检测,这三块是心电数据处理的基础。下一节我们会讲怎么把这些任务整合到RTOS里,实现完整的调度方案。