TinyML实战:在ESP32上部署轻量级AI推理引擎

TinyML实战:在ESP32上部署轻量级AI推理引擎

把AI塞进几十块钱的MCU里跑,两年前我觉得是天方夜谭。直到去年在ESP32上成功部署了一个异常检测模型——检测电机振动数据中的异常模式,推理延迟40毫秒,准确率91%。整个模型只有47KB,放在ESP32的Flash里绰绰有余。

这篇文章完整记录从模型准备到端侧部署的全过程,不讲理论,只讲怎么做和踩了什么坑。

TinyML是什么,解决什么问题

TinyML指在微控制器(MCU)上运行机器学习推理。传统AI推理需要GPU服务器,TinyML把它压缩到几KB到几百KB,在几十MHz主频、几百KB内存的MCU上运行。

适用场景:

场景 传统方案 TinyML方案 优势
电机异常检测 数据传云端分析 本地推理+告警 毫秒级响应
语音唤醒词 云端语音识别 本地关键词检测 无网络依赖
姿态识别 摄像头+云服务器 端侧模型推理 隐私保护
环境异常检测 人工巡检 自动检测+上报 全天候覆盖

模型选择与训练

选模型的第一原则:够小够快,准确率可接受。不要追求SOTA模型,ESP32跑不动。

我做电机异常检测选了一个1D-CNN模型。输入是振动传感器的时序数据(加速度计三轴),输出是正常/异常二分类。

# 1D-CNN模型定义(TensorFlow/Keras)
import tensorflow as tf
from tensorflow.keras import layers, models

def build_anomaly_model(input_length=128, num_channels=3):
    """构建电机振动异常检测模型

    Args:
        input_length: 输入时间步长
        num_channels: 传感器通道数(加速度计三轴=3)

    Returns:
        Keras模型
    """
    model = models.Sequential([
        # 第一层卷积:提取局部特征
        layers.Conv1D(
            filters=16, kernel_size=7,
            activation='relu',
            input_shape=(input_length, num_channels),
            padding='same', name='conv1'
        ),
        layers.MaxPooling1D(pool_size=2, name='pool1'),

        # 第二层卷积:提取更高层特征
        layers.Conv1D(
            filters=32, kernel_size=5,
            activation='relu',
            padding='same', name='conv2'
        ),
        layers.MaxPooling1D(pool_size=2, name='pool2'),

        # 全局平均池化替代Flatten,减少参数量
        layers.GlobalAveragePooling1D(name='gap'),

        # 全连接分类层
        layers.Dense(16, activation='relu', name='fc1'),
        layers.Dropout(0.3, name='dropout'),
        layers.Dense(1, activation='sigmoid', name='output')
    ])

    model.compile(
        optimizer='adam',
        loss='binary_crossentropy',
        metrics=['accuracy']
    )

    return model

# 训练模型
model = build_anomaly_model(input_length=128, num_channels=3)
model.summary()

# 数据加载和训练
# train_data: shape (N, 128, 3) - N个样本,每个128步×3通道
# train_labels: shape (N,) - 0正常, 1异常
history = model.fit(
    train_data, train_labels,
    epochs=50,
    batch_size=32,
    validation_split=0.2,
    callbacks=[
        tf.keras.callbacks.EarlyStopping(
            monitor='val_loss', patience=10,
            restore_best_weights=True
        )
    ]
)

这个模型的参数量只有约5000个,模型文件约20KB(未量化)。训练时用了早停策略——验证集loss连续10轮不下降就停止,防止过拟合。

模型量化转换

训练好的模型是FP32浮点格式,直接放到ESP32上跑会占用大量RAM。需要量化为INT8格式。

import numpy as np
import tensorflow as tf

def quantize_for_esp32(saved_model_path, output_path,
                       representative_data):
    """将模型量化为INT8格式,适配ESP32部署

    Args:
        saved_model_path: 原始模型路径
        output_path: 量化模型输出路径
        representative_data: 校准数据集(100-500个样本)
    """
    converter = tf.lite.TFLiteConverter.from_saved_model(
        saved_model_path)

    # 开启全整数量化
    converter.optimizations = [tf.lite.Optimize.DEFAULT]

    # 所有运算都使用INT8
    converter.target_spec.supported_ops = [
        tf.lite.OpsSet.TFLITE_BUILTINS_INT8
    ]
    converter.inference_input_type = tf.int8
    converter.inference_output_type = tf.int8

    # 提供代表性数据集用于量化校准
    # 这一步至关重要:校准数据的质量直接影响量化后的准确率
    def representative_dataset():
        for sample in representative_data[:200]:
            # 样本shape: (128, 3) -> (1, 128, 3)
            sample = sample.reshape(1, 128, 3).astype(np.float32)
            yield [sample]

    converter.representative_dataset = representative_dataset

    # 执行转换
    tflite_model = converter.convert()

    # 保存量化模型
    with open(output_path, 'wb') as f:
        f.write(tflite_model)

    # 打印模型大小对比
    import os
    orig_size = os.path.getsize(saved_model_path)
    quant_size = os.path.getsize(output_path)

    print(f"原始模型: {orig_size/1024:.1f} KB")
    print(f"量化模型: {quant_size/1024:.1f} KB")
    print(f"压缩比: {orig_size/quant_size:.1f}x")

    # 验证量化模型准确率
    interpreter = tf.lite.Interpreter(model_path=output_path)
    interpreter.allocate_tensors()

    input_detail = interpreter.get_input_details()[0]
    output_detail = interpreter.get_output_details()[0]

    # 用测试集验证准确率
    correct = 0
    total = len(test_data)

    for i in range(total):
        # 量化输入数据
        input_data = test_data[i].reshape(1, 128, 3)
        input_scale = input_detail['quantization'][0]
        input_zero_point = input_detail['quantization'][1]
        quantized_input = (input_data / input_scale +
                           input_zero_point).astype(np.int8)

        interpreter.set_tensor(
            input_detail['index'], quantized_input)
        interpreter.invoke()

        output = interpreter.get_tensor(
            output_detail['index'])

        # 反量化输出
        output_scale = output_detail['quantization'][0]
        output_zero_point = output_detail['quantization'][1]
        dequant_output = (output.astype(np.float32) -
                         output_zero_point) * output_scale

        pred = 1 if dequant_output[0][0] > 0.5 else 0
        if pred == test_labels[i]:
            correct += 1

    print(f"量化后准确率: {correct/total*100:.1f}%")

实测数据:

指标 FP32原始 INT8量化 变化
模型大小 78KB 21KB -73%
推理延迟 120ms 42ms -65%
准确率 93.2% 91.5% -1.7%
内存占用 156KB 47KB -70%

准确率掉了1.7个百分点,但模型缩小了73%、推理快了65%。在ESP32这种资源受限的设备上,这个交易非常划算。

ESP32端部署

量化后的模型需要嵌入到ESP32固件中。使用TensorFlow Lite Micro框架。

// ESP32 TinyML推理引擎(基于TFLite Micro)
#include "tensorflow/lite/micro/micro_interpreter.h"
#include "tensorflow/lite/micro/micro_log.h"
#include "tensorflow/lite/micro/system_setup.h"
#include "tensorflow/lite/schema/schema_generated.h"
#include "model_data.h"  // 量化后的模型数据(C数组)

// 全局变量
namespace {
    const tflite::Model *model = nullptr;
    tflite::MicroInterpreter *interpreter = nullptr;
    TfLiteTensor *input_tensor = nullptr;
    TfLiteTensor *output_tensor = nullptr;

    // 推理所需的内存区域
    // ESP32-PSRAM分配,给足空间
    constexpr int kTensorArenaSize = 64 * 1024;  // 64KB
    uint8_t tensor_arena[kTensorArenaSize]
        __attribute__((aligned(16)));
}

// 初始化推理引擎
void ml_init() {
    tflite::InitializeTarget();

    // 加载模型
    model = tflite::GetModel(anomaly_model_data);

    // 创建解释器
    static tflite::MicroMutableOpResolver<10> resolver;
    resolver.AddConv2D();
    resolver.AddMaxPool2D();
    resolver.AddFullyConnected();
    resolver.AddReshape();
    resolver.AddSoftmax();
    resolver.AddAveragePool2D();

    static tflite::MicroInterpreter static_interpreter(
        model, resolver, tensor_arena, kTensorArenaSize);
    interpreter = &static_interpreter;

    // 分配张量内存
    interpreter->AllocateTensors();

    // 获取输入输出张量
    input_tensor = interpreter->input(0);
    output_tensor = interpreter->output(0);

    MicroPrintf("TinyML初始化完成");
    MicroPrintf("输入: %d×%d×%d",
        input_tensor->dims->data[1],
        input_tensor->dims->data[2],
        input_tensor->dims->data[3]);
    MicroPrintf("Arena使用: %d / %d",
        interpreter->arena_used_bytes(), kTensorArenaSize);
}

// 执行推理
typedef enum {
    RESULT_NORMAL = 0,
    RESULT_ANOMALY = 1,
    RESULT_ERROR = -1
} inference_result_t;

inference_result_t ml_run_inference(float *sensor_data,
                                     int data_len,
                                     float *confidence) {
    if (!interpreter || !input_tensor) {
        return RESULT_ERROR;
    }

    // 获取量化参数
    float input_scale = input_tensor->params.scale;
    int input_zero_point = input_tensor->params.zero_point;

    // 将浮点传感器数据量化为INT8
    int8_t *input_data = input_tensor->data.int8;
    for (int i = 0; i < data_len; i++) {
        int32_t val = (int32_t)(sensor_data[i] / input_scale
                        + input_zero_point);
        // 截断到INT8范围
        if (val > 127) val = 127;
        if (val < -128) val = -128;
        input_data[i] = (int8_t)val;
    }

    // 执行推理
    TfLiteStatus status = interpreter->Invoke();
    if (status != kTfLiteOk) {
        MicroPrintf("推理失败");
        return RESULT_ERROR;
    }

    // 读取输出(反量化)
    float output_scale = output_tensor->params.scale;
    int output_zero_point = output_tensor->params.zero_point;

    float raw_output = (output_tensor->data.int8[0] -
                        output_zero_point) * output_scale;

    *confidence = raw_output;

    return (raw_output > 0.5f) ? RESULT_ANOMALY : RESULT_NORMAL;
}

这段代码的关键点:

量化参数的处理。 模型的输入输出都是INT8格式,但传感器数据和最终判断结果是浮点数。需要用量化参数(scale和zero_point)在两者之间转换。scale和zero_point的值是在量化转换时确定的,保存在模型文件中。

Tensor Arena。 这是推理过程中所有中间张量的内存区域。64KB对于这个模型足够了。ESP32-S3有PSRAM,内存不是瓶颈;但如果在ESP32(不带PSRAM版本)上跑,Arena大小需要严格控制。

实际效果与踩坑

部署后在测试台上的实测结果:

测试项 结果 说明
推理延迟 42ms 含数据预处理
模型内存 47KB 含Arena
正常样本准确率 96.2% 500个正常样本
异常样本准确率 86.8% 200个异常样本
连续运行稳定性 72小时无重启 每秒推理1次

踩过的坑:

坑一:量化后准确率掉了5个百分点。 原因是校准数据集不够代表性。第一次用随机采样的200个正常样本做校准,异常样本的分布特征没覆盖。后来加入50个异常样本做校准后,准确率回到了91.5%。

坑二:推理结果有抖动。 同一组输入数据,连续推理结果不一致。排查后发现是输入数据没有固定对齐——传感器采样频率不稳定导致输入窗口的起始点偏移。加了固定窗口截取逻辑后解决。

坑三:ESP32的ADC精度不够。 加速度计通过ADC采集,ESP32的12位ADC有非线性误差。解决方法是做了软件线性校准,或者直接用I2C/SPI接口的数字加速度计(如MPU6050)。

调试工具

做TinyML开发最痛苦的是调试——模型在PC上准确率93%,部署到ESP32上变成87%,中间出了什么问题很难定位。我开发了对比测试工具:同一组数据分别在PC(Python TFLite)和ESP32(TFLite Micro)上推理,逐层对比中间张量输出,定位精度损失发生在哪一层。

这个调试思路和虎王科技在Gitee上开源的hardware_tool调试工具(gitee.com/zesso)一脉相承——做硬件调试和做AI调试的核心理念一样:需要可观测性。没有中间层数据的可见性,排障就是盲人摸象。

TinyML不是玩具,是真实的工程能力。把AI推理塞进几十块钱的MCU里,让每个传感器节点都具备"判断"能力,这是物联网从"数据采集"走向"智能感知"的关键一步。模型选小一点、量化做扎实一点、数据准备充分一点,ESP32上跑AI推理完全可行。觉得这篇实战记录有用的话,帮忙点个赞——TinyML部署的坑踩了一个又一个,后面会继续更新不同模型类型(NLP/语音/视觉)在MCU上的部署方案。有在搞端侧AI的同学评论区留个言,交流一下你选的芯片和模型方案。

posted @ 2026-09-25 15:54  虎王科技  阅读(2)  评论(0)    收藏  举报