6000 点加速度数据 → 100 维特征 → 5KB int8 模型,完整部署到 MCU

目标硬件是一块 Cortex-M 级别的 MCU,只有 64KB Flash 和 64KB RAM。要在上面跑一个程序:输入加速度传感器采集的 6000 个连续采样点,输出"这是震动A、震动B、还是震动C"。

这篇文章走一遍完整过程:怎么把原始震动数据变成 MCU 能理解的数字,怎么训练一个足够小的模型,怎么把它压缩到 int8 塞进 MCU,以及怎么用 C++ 写出和 Python 训练时精度一致的推理代码。

先看一眼整条链路在做什么:

完整管线

六个步骤,①②③ 在 Python 端完成(训练 + 导出),④⑤ 在 C++ 端完成(MCU 推理)。下面逐个展开。

特征提取:6000 点 → 100 维

MCU 拿到的是一串 6000 个 float[0.003, -0.012, 0.008, ...]。直接把这 6000 个数扔给神经网络行不通 — 输入太大了,MCU 算不动。

解决方案是做特征提取:把 6000 个原始采样点压缩成 100 个有意义的数字。这些数字要能描述震动信号的核心特性 — 低频有多强、波动大不大、有没有突然的尖峰。可以把它理解成一种有损压缩:扔掉不重要的细节,保留能区分三类震动的关键信息。

特征提取流程

FFT 频域特征(64 维)

FFT 做的事情:把一段随时间变化的波形,拆成"哪些频率成分组成了这段波"。不同震动的频率分布不一样,这是 FFT 能区分它们的原因。

做法是对整条 6000 点的信号跑一次 FFT,取 64 个频率 bin 的幅度,用 log1p(x) = ln(1 + x) 对幅度做对数压缩。对数压缩的目的很简单:低频分量的幅度可能是高频的几百倍,直接送给模型,大数会把小数淹掉。取个对数,差距从几百倍缩到几倍。

fft = np.abs(np.fft.rfft(sig))        # FFT → 取幅度
feats[:64] = np.log1p(fft[1:65])       # 跳过 DC(0Hz),取 64 个 bin + 对数压缩

时域分段特征(36 维)

频域告诉模型"哪个频率在震",时域告诉模型"震动有多剧烈、怎么变的"。

把 6000 个点切成 4 段,每段 1500 点。对每一段算 9 个统计量:

特征 含义 只用加减乘除?
均值 这段信号的平均值
标准差 波动幅度
最小值 / 最大值 振幅范围
RMS 有效值,衡量能量大小
峰值 绝对值的最大值
过零率 穿越零点的次数 ÷ 段长
能量 平方和 ÷ 段长
非零比 非零采样点占比

9 个特征 × 4 段 = 36 维。 加上前面 64 维 FFT,总共 100 维特征向量。

选这些特征的硬性条件只有一个:MCU 上只用加减乘除就能算。 没有 percentile()、没有 sort()、没有 log()

for seg in range(4):
    s = sig[seg*1500:(seg+1)*1500]
    b = 64 + seg * 9
    feats[b+0] = np.mean(s)                              # 均值
    feats[b+1] = np.std(s)                               # 标准差
    feats[b+2] = np.min(s)                               # 最小值
    feats[b+3] = np.max(s)                               # 最大值
    feats[b+4] = np.sqrt(np.mean(s**2))                  # RMS
    feats[b+5] = np.max(np.abs(s))                       # 峰值
    feats[b+6] = np.sum(np.abs(np.diff(np.signbit(s)))) / len(s)  # 过零率
    feats[b+7] = np.sum(s**2) / len(s)                   # 能量
    feats[b+8] = np.count_nonzero(s) / len(s)            # 非零比

特征标准化

100 维特征算出来了,但有一个问题:不同特征的数值范围差别很大。

比如"均值"可能一直在 ±0.01 之间晃,但"能量"可能是几千的量级。直接把这些数喂给模型,数值大的特征会主导计算结果,数值小的等于没参与。

解决方法是做一次标准化(StandardScaler):对每个特征维度,减掉均值、除以标准差。处理完后,每个维度都变成零均值、单位方差的分布。相当于把厘米、千克、秒全部统一成同一个尺度。

scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)

# 保存 mean 和 scale — C++ 端要用完全一样的值
s_mean = scaler.mean_
s_scale = scaler.scale_
s_scale[s_scale < 1e-8] = 1.0   # 防止除零

保存 s_means_scale 是关键动作。训练时用的 normalization 参数,推理时也必须用同一组。

模型结构

100 维输入 → Dense(16, ReLU) → Dense(8, ReLU) → Dense(3, Softmax)

三层逐级压缩:100 → 16 → 8 → 3,每层只保留最关键的信息。16 和 8 是"刚好够用且省空间"的平衡点 — 翻倍则 Flash 放不下,减半则表达力不够。

指标 数值
float32 权重 ~7.0 KB
int8 权重 ~1.8 KB
TFLite 模型 5,024 字节
model = tf.keras.Sequential([
    tf.keras.layers.InputLayer(shape=(100,)),
    tf.keras.layers.Dense(16, activation='relu'),
    tf.keras.layers.Dense(8, activation='relu'),
    tf.keras.layers.Dense(3, activation='softmax'),
])
model.compile(optimizer=tf.keras.optimizers.Adam(0.001),
              loss='sparse_categorical_crossentropy',
              metrics=['accuracy'])
model.fit(X_train_s, y_train, batch_size=16, epochs=60,
          validation_split=0.2,
          callbacks=[tf.keras.callbacks.EarlyStopping(
              monitor='val_loss', patience=12, restore_best_weights=True)])

量化:把 float32 压成 int8

训练用的是 float32 — 每个权重占 4 字节。要在 MCU 上跑推理,需要把 float32 换成 int8,减少存储空间和计算开销。

量化和 map() 是一个道理——把数值从一个范围线性映射到另一个范围:

map(value, fromLow, fromHigh, toLow, toHigh)

量化就是把 float32 的 [r_min, r_max] 映射到 int8 的 [-128, 127]

q = round((r - r_min) / (r_max - r_min) * 255) - 128

TFLite 的写法等价,用 scale(S)和 zero point(Z)来表达同一件事:

q = round(r / S) + Z

举个例子:某层激活值在 [-1.0, 1.0] 之间,则 S = 2.0/255 ≈ 0.0078Z = 0r = 0.5 映射后 q = 64

TFLite 转换器会自动算每层最优的 S 和 Z — 提供几十到几百个真实样本作为校准数据即可:

def representative_dataset():
    for i in range(0, min(len(X_train_s), 300)):
        yield [X_train_s[i:i+1]]

converter = tf.lite.TFLiteConverter.from_keras_model(model)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_dataset
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
converter.inference_input_type = tf.int8
converter.inference_output_type = tf.int8
tflite_model = converter.convert()

量化前后的准确率对比:

阶段 准确率
float32 训练模型 80.9%
int8 TFLite(同测试集) 79.3%
量化损失 −1.6%

损失不到 2%,但模型的存储空间从 7KB 降到了 1.8KB,计算从 float32 乘法变成了 int8 乘法(MCU 上快很多)。

用 C 实现特征提取

python 端的工作完成后,产出三个文件:

C 端的特征提取流水线:

CSV 一行 → 特征提取(DFT+时域) → StandardScaler → 量化到 int8 → 送入推理 runtime → 取最大类别

手写 DFT 的精度细节

python 端用的是 numpy 的高性能 FFT,MCU 上只能手写朴素 DFT。注意一个容易被跳过的细节 — 角度必须用 double 算

double omega = -2.0 * 3.141592653589793 * k / len;
for (int i = 0; i < len; i++) {
    double angle = omega * i;          // ← 关键:double,不是 float
    real += sig[i] * cos(angle);
    imag += sig[i] * sin(angle);
}
feats[k - 1] = (float)log1p(sqrt(real * real + imag * imag));

为什么?6000 点的 DFT,omega * i 循环到最后一轮时 i=5999。如果用 float32 计算角度,累加误差在 10⁻³ 量级,cos/sin 之后误差被放大,最终的幅度谱和 python 端对不上。用 double 算角度,结果再截回 float,两边就对齐了。

时域特征没这个问题 — 1500 点的段内累加,float32 精度绰绰有余。

标准化 + 量化 + 推理

这步在 C++ 端复现 python 训练时的 Scaler + 量化逻辑:

for (int i = 0; i < 100; i++) {
    float s = (feats[i] - kScalerMeanV2[i]) / kScalerScaleV2[i];  // Scaler
    float q = s / kInputScale + kInputZeroPoint;                   // 量化
    if (q > 127.0f) q = 127.0f;       // clip 到 int8 范围
    if (q < -128.0f) q = -128.0f;
    input->data.int8[i] = (int8_t)lroundf(q);
}
interpreter.Invoke();

TFLM 的初始化也很轻量 — 模型只有全连接和 Softmax,两个 Op 即可:

tflite::MicroMutableOpResolver<2> resolver;
resolver.AddFullyConnected();
resolver.AddSoftmax();

static uint8_t arena[16 * 1024];   // Tensor Arena
tflite::MicroInterpreter interpreter(model, resolver, arena, 16 * 1024);
interpreter.AllocateTensors();

Python 与 C 端结果对比

同一批测试文件、同一组 Scaler 参数、同一个 TFLite 模型:

类别 python int8 C++ int8 差异
震动A 94.3% 92.4% ±1.9%
震动B 92.0% 93.9% ±1.9%
震动C 53.4% 55.7% ±2.3%

1-3% 的差异来自 numpy 的优化 FFT 实现 vs 朴素 DFT,以及 float64 和 double 中间精度的微小区别。两端结果高度一致,整条链路验证通过。

震动C 准确率偏低(~54%),原因是该类样本和震动B 的时域特征高度重叠,仅靠 100 维线性特征难以完全分开。这个方向上的改进空间在特征工程,不在模型结构。

资源占用

资源 用量 占 64KB 总量
Flash(模型权重) 5,024 B 7.7%
Flash(含代码) ~10 KB 16%
RAM(Tensor Arena) ~1,800 B 2.7%
RAM(特征+工作缓冲) ~3,000 B 4.6%

Flash 用了 16%,RAM 用了不到 10%。在 64KB/64KB 的 MCU 上还有充裕空间跑传感器驱动和应用层代码。

适用范围:

如果需要更低延迟,可以对输入做降采样(如 1024 点)来减少 DFT 计算量。移植到实际 Cortex-M 开发板后,用 CMSIS-NN 的 int8 矩阵乘法内核替换 TFLM 的 Reference Fallback,可以进一步提升推理速度。

注意事项

Scaler 参数必须一字不差。 python 训练时的 meanscale 要原样导出到 C 头文件。一个特征维度对不上,量化后的 int8 值直接漂到 −128 或 127 饱和,整条推理报废。

DFT 角度用 double 算。 长序列下 float32 的角度累加误差会通过 cos/sin 放大,导致幅度谱和 python 端偏差不可忽略。用 double 算角度,结果截回 float。

校准数据覆盖所有类别。 量化校准时 representative_dataset 覆盖三类震动,否则某些层的激活范围估计不准,clip 损失大。

训练前洗数据。 传感器偶发野值(读数突然飙到天文数字)会污染 Scaler 的统计。过滤掉绝对值异常的样本(比如超过 1000 的读数),用 float64 做特征提取中间计算。

防除零。 Scaler 拟合后,接近 0 的 scale_ 替换为 1.0,避免 C 头文件出现正负无穷。

在 STM32L496 上用 X-CUBE-AI 部署

前面的 C++ 推理代码先在云端虚拟机上跑通了一遍,确认整条链路(特征提取 → Scaler → 量化 → 推理)和 Python 端结果一致。实际落到 STM32L496 工程时,没有手动移植 TFLM,而是使用 STM32CubeMX 集成的 X-CUBE-AI,把同一个 vibration_model_v2.tflite 转成 ST 官方的 MCU runtime 工程代码。

CubeMX 里选择 TFLite 模型,runtime 选择 STM32Cube.AI MCU runtime。模型输入仍然是 int8[100],输出是 int8[3],核心网络结构没有变化。

X-CUBE-AI 配置

X-CUBE-AI 生成后的计算图和训练时一致:

100 维输入 → Dense(16) → Dense(8) → Dense(3) → Softmax

X-CUBE-AI 网络结构

这条部署路径的职责划分比较清楚:

真正容易出错的位置在模型输入之前。最初 C 端特征提取把时域特征数组偏移加了两次,导致 feats[64..99] 没有写入正确数据,栈上还发生了越界写。X-CUBE-AI 拿到错误的 100 维输入,预测结果自然和 Python 参考对不上。

修正后,把三类样本分别烧到 STM32L496 上运行,串口里看到的预测类别和预期类别一致:

STM32 串口验证输出

最终验证结果:

测试样本 预期类别 MCU 预测
vib_A vib_A vib_A
vib_B vib_B vib_B
vib_C vib_C vib_C

这说明模型部署到 STM32 后,实际分类结果和桌面端验证结果一致。真正的部署检查重点是:固件端送进模型的 100 个 int8 特征必须和训练/验证端保持同一套特征工程和量化流程。

如果想看手动移植 TFLite Micro 的做法,可以看这篇更小的正弦模型示例:CH585M 上手动移植 TFLite Micro 跑 sin demo。这篇振动分类工程选择 X-CUBE-AI,是为了直接使用 ST 官方生成的 MCU runtime;手动 TFLM 和 X-CUBE-AI 两条路线的目标相同,都是把同一个量化模型稳定地放到 MCU 上运行。

如果手上有 STM32L496 开发板,也可以直接下载这份编译产物烧录验证:HEX 固件,或者用 BIN 固件0x08000000 写入。烧录后打开串口窗口看日志即可,参数是 115200 8N1

总结与改进方向

这条路线的优点是可解释、可复现,训练、特征工程、量化和 MCU 端输入都能逐项对齐;缺点也很明显,6000 点特征提取在 MCU 上跑得比较慢,最终分类效果也只能说够用。

如果想要更好的效果,可以先用 ST 的 NanoEdge AI 跑一轮基准测试。它会给出一个黑盒模型,也会把模型路线流水线暴露出来:中间用了哪些特征提取、有没有 FFT、有没有裁剪、最后用了什么模型。先让 NanoEdge AI 帮你把特征流水线试错一遍,再把这条流水线的核心思路移植成自己的代码,这样就能从黑盒工具里提炼出一个更可控的边缘 AI 模型。