4、时域特征提取:峰值检测、过零率、短时能量、波形因子计算

好,咱们进入第四章。时域特征提取。

说实话,在消防报警这个领域,时域特征是最直观的。你想想看,一个火灾探测器采集到的信号,本质上就是一条随时间变化的曲线。峰值有多高?波动有多快?能量有多大?这些信息,全藏在时域里。

我个人习惯,做嵌入式移植时,先把时域特征算准了,再谈频域。为什么?因为时域计算简单、实时性好,对MCU的算力要求低。你总不能指望一个Cortex-M0内核的探测器去跑FFT吧?

4.1 峰值检测

峰值检测,说白了就是找信号的最大值。但这里有个坑——你要找的是真实峰值,不是噪声尖峰。

我在项目中遇到过这种情况:一个烟雾探测器,采样率1kHz,环境中有空调吹风造成的扰动。直接取最大值,结果误报率飙升。后来怎么解决的?加了一个滞回比较最小间隔约束。

核心思路:

  • 设定一个阈值,只有超过阈值的点才参与峰值判定
  • 记录峰值后,设定一个“死区时间”,期间不检测新峰值
  • 用滑动窗口内的局部最大值代替全局最大值

代码实现上,我建议用状态机。嗯,这样效率最高。

// 峰值检测 - 状态机实现
typedef enum {
    PEAK_IDLE,
    PEAK_RISING,
    PEAK_FALLING
} peak_state_t;

float peak_detect(float sample, float threshold, uint32_t *dead_ticks) {
    static peak_state_t state = PEAK_IDLE;
    static float current_peak = 0.0f;
    
    if (*dead_ticks > 0) {
        (*dead_ticks)--;
        return 0.0f;  // 死区中,不输出峰值
    }
    
    switch (state) {
        case PEAK_IDLE:
            if (sample > threshold) {
                current_peak = sample;
                state = PEAK_RISING;
            }
            break;
        case PEAK_RISING:
            if (sample > current_peak) {
                current_peak = sample;  // 继续上升,更新峰值
            } else if (sample < current_peak * 0.9f) {
                // 下降超过10%,认为峰值已过
                state = PEAK_FALLING;
            }
            break;
        case PEAK_FALLING:
            if (sample < threshold) {
                state = PEAK_IDLE;
                *dead_ticks = 50;  // 50个采样点的死区
                return current_peak;  // 输出峰值
            }
            break;
    }
    return 0.0f;
}

小技巧:死区时间要根据采样率和信号特性来调。我一般先设50个采样点,然后看效果微调。消防报警信号变化慢,死区可以设大一点。

4.2 过零率

过零率,就是信号穿过零点的次数。这个特征在消防报警里特别有用——你想想,火灾初期的阴燃阶段,烟雾浓度缓慢上升,信号波动很小,过零率很低。但一旦出现明火,气流扰动加剧,过零率会突然升高。

计算过零率,公式很简单:

ZCR = (1 / N) * Σ |sign(x[n]) - sign(x[n-1])|

其中sign是符号函数,N是窗口长度。

但实际嵌入式实现时,有个细节要注意——直流偏置。如果信号有直流分量,零点就不在0了。我曾经吃过这个亏,一个传感器输出有1.2V的偏置,直接算过零率,结果全是0。

避坑指南:计算过零率前,一定要先做直流滤波。可以用一个简单的IIR高通滤波器,截止频率设0.5Hz就够了。

我常用的实现方式:

// 过零率计算 - 带直流滤波
typedef struct {
    float prev_sample;
    float dc_offset;
    float alpha;  // 滤波系数,通常0.99
} zcr_ctx_t;

float calc_zcr(zcr_ctx_t *ctx, float sample, int window_size) {
    static int count = 0;
    static float zcr_sum = 0.0f;
    
    // 直流滤波
    ctx->dc_offset = ctx->alpha * ctx->dc_offset + (1 - ctx->alpha) * sample;
    float ac_signal = sample - ctx->dc_offset;
    
    // 过零检测
    if (ctx->prev_sample * ac_signal < 0) {
        zcr_sum += 1.0f;
    }
    ctx->prev_sample = ac_signal;
    
    count++;
    if (count >= window_size) {
        float zcr = zcr_sum / window_size;
        count = 0;
        zcr_sum = 0.0f;
        return zcr;
    }
    return -1.0f;  // 窗口未满,返回无效值
}

4.3 短时能量

短时能量,反映的是信号在一小段时间内的强度。消防报警里,这个特征用来区分“有信号”和“没信号”。比如,一个感烟探测器,正常环境下短时能量很低,一旦有烟雾进入,散射光信号增强,能量值会跳变。

计算公式:

E = Σ x[n]²

嗯,就这么简单。但嵌入式实现时,平方运算对定点MCU来说有点费劲。我建议用绝对值求和代替平方求和,效果差不多,但速度快很多。

方法 计算量(每采样点) 精度损失 推荐场景
平方求和 1次乘法 + 1次加法 有FPU的MCU
绝对值求和 1次条件判断 + 1次加法 约5%~10% 无FPU的低端MCU
查表法 1次查表 + 1次加法 取决于表精度 RAM充足的场景

我的经验:在STM32F0这类Cortex-M0芯片上,用绝对值求和做短时能量,一个窗口(256点)的计算时间不到0.5ms,完全满足实时性要求。

// 短时能量 - 绝对值求和实现
float short_term_energy(int16_t *buffer, int len) {
    uint32_t energy = 0;
    for (int i = 0; i < len; i++) {
        // 绝对值求和,避免乘法
        if (buffer[i] < 0) {
            energy += (uint32_t)(-buffer[i]);
        } else {
            energy += (uint32_t)buffer[i];
        }
    }
    return (float)energy / len;  // 归一化
}

4.4 波形因子

波形因子,这个稍微有点抽象。它定义为有效值(RMS)与平均绝对值(MAV)的比值

波形因子 = RMS / MAV

有什么用呢?它反映了信号的“形状”。正弦波的波形因子是1.11,方波是1.0,白噪声大约是1.25。在消防报警里,不同火源产生的信号波形不同——阴燃火信号偏正弦波,明火信号偏随机噪声。通过波形因子,可以初步判断火源类型。

计算时要注意:RMS需要开平方,这在嵌入式里是个大开销。我建议用牛顿迭代法或者查表法来算平方根,别用标准库的sqrt,太慢了。

优化技巧:如果只是要波形因子,其实可以不用算真正的RMS。用“平方和”代替RMS,用“绝对值和”代替MAV,比值不变。这样省掉了开平方运算。

// 波形因子 - 优化版(避免开平方)
float waveform_factor(int16_t *buffer, int len) {
    uint32_t sum_sq = 0;  // 平方和
    uint32_t sum_abs = 0; // 绝对值和
    
    for (int i = 0; i < len; i++) {
        int32_t val = buffer[i];
        sum_sq += (uint32_t)(val * val);  // 平方
        if (val < 0) {
            sum_abs += (uint32_t)(-val);
        } else {
            sum_abs += (uint32_t)val;
        }
    }
    
    // 波形因子 = sqrt(sum_sq / N) / (sum_abs / N)
    // 简化后 = sqrt(sum_sq) / sum_abs * sqrt(N)
    // 但为了省掉sqrt,我们直接返回平方和与绝对值和的比值
    // 这个比值与真正的波形因子是单调对应的
    return (float)sum_sq / (float)(sum_abs * sum_abs / len);
}

注意:上面这个优化版返回的不是真正的波形因子,而是一个单调映射值。如果你需要精确的波形因子值(比如要和标准库对比),还是得老老实实算RMS。但在嵌入式报警算法里,我们只关心相对变化,单调映射完全够用。

4.5 综合应用:一个完整的时域特征提取流程

好了,四个特征都讲完了。在实际项目中,我通常把它们组合成一个特征向量,输入到分类器里。

举个例子,一个典型的消防报警算法流程:

  1. 每256个采样点(约256ms)计算一次特征
  2. 提取峰值、过零率、短时能量、波形因子
  3. 将四个特征归一化到0~1范围
  4. 输入到阈值判断或简单分类器
  5. 连续3个窗口都超过阈值,才触发报警

为什么要连续3个窗口?嗯,防止误报。我曾经遇到过,一个电焊工人在探测器旁边干活,单次峰值很高,但持续不到1秒。如果单窗口触发,就会误报。加上连续判断,误报率降低了90%。

最后提醒一句:时域特征虽然简单,但组合起来威力不小。别小看这四个参数,调好了,能解决80%的火灾探测问题。剩下的20%,才需要上频域和机器学习。

下一章,咱们聊聊频域特征提取。那个更有意思。