ESP32-S3 + TFLite Micro + int8 量化模型,从零搭建边缘 AI 图像分类项目
在 ESP32-S3 上运行 MobileNetV2 (alpha=0.5) int8 量化模型,实现猫狗图片分类。图片内嵌 Flash,推理结果通过串口打印,无需 Wi-Fi / SD 卡。
性能指标(ESP32-S3 @ 240MHz, 8MB PSRAM):
| 环节 | 耗时 |
|---|---|
| JPEG 解码 | 115~204ms |
| 缩放 + 量化 | ~7ms |
| 推理 | 205ms |
| 总计 | 330~420ms |
分类准确率:6/6 正确(3 猫 + 3 狗测试图片)
以下是从拿到 ESP32-S3 芯片到完成分类器的完整步骤。
git clone -b v6.0.1 --recursive https://github.com/espressif/esp-idf.git
cd esp-idf
./install.sh esp32s3
# 每次开终端都需要 source
. ./export.shmkdir esp32-catdog-classifier && cd esp32-catdog-classifier创建以下目录结构:
esp32-catdog-classifier/
├── CMakeLists.txt # 顶层 CMake
├── sdkconfig.defaults # SDK 默认配置
├── partitions.csv # 自定义分区表
├── main/
│ ├── CMakeLists.txt # 组件 CMake
│ ├── idf_component.yml # 组件依赖
│ ├── main.c # 主程序
│ ├── image_preprocess.h # 图像预处理头文件
│ ├── image_preprocess.c # 图像预处理实现
│ ├── tflite_inference.h # 推理引擎头文件
│ ├── tflite_inference.cpp # 推理引擎实现 (C++)
│ ├── model_data.cc # 模型数据 (C 数组)
│ └── test_images.h # 内嵌测试图片
├── model/
│ └── model_int8.tflite # 量化模型文件
└── train_catdog_model.py # 训练脚本 (Colab)
将 train_catdog_model.py 上传到 Colab 运行,或直接复制代码到 Colab 单元格执行。
训练脚本的核心流程:
- 下载 Kaggle 猫狗数据集
- 用 MobileNetV2 (alpha=0.5) 迁移学习,第一阶段冻结训练,第二阶段微调
- 构建推理模型(去掉数据增强层,直接复用训练好的层对象)
- 全整数量化 (int8) 导出
model_int8.tflite - 验证量化后精度
关键代码(推理模型构建,解决 TFLite 兼容性问题):
# 数据增强层 (RandomFlip/RandomRotation/RandomZoom) 用的算子
# TFLite Micro 不支持,所以构建推理模型时跳过这些层
inference_model = tf.keras.Sequential([
inputs, # InputLayer
rescaling, # Rescaling(1./255)
base_model_layers, # MobileNetV2 特征提取
global_pool, # GlobalAveragePooling2D
dropout, # Dropout (推理时无效果)
dense, # Dense(1, sigmoid)
])
# 关键:直接复用训练好的层对象,不是创建新层再 set_weightsint8 量化导出:
def representative_dataset():
for i in range(100):
img = val_ds.unbatch().batch(1).skip(i).take(1)
yield [next(iter(img))[0]]
converter = tf.lite.TFLiteConverter.from_keras_model(inference_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
model_int8 = converter.convert()训练完成后,从 Colab 下载 model_int8.tflite,放到 model/ 目录。
cmake_minimum_required(VERSION 3.16)
set(IDF_TARGET "esp32s3")
# 只编译需要的组件,排除 Wi-Fi/BLE 等大组件
set(COMPONENTS main esp_timer esp_psram espressif__esp-tflite-micro espressif__esp-nn espressif__esp_jpeg CACHE STRING "")
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(esp32-catdog-classifier)要点:
IDF_TARGET设为esp32s3(不是esp32s31,没有这个芯片型号)COMPONENTS显式列出,避免编译不需要的 Wi-Fi/BLE 组件
# 自定义分区表
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
# Flash 16MB
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
# CPU 240MHz (默认 160MHz,提速 50%)
CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240=y
CONFIG_ESP32S3_DEFAULT_CPU_FREQ_MHZ=240
# PSRAM (8MB OCT, 80MHz)
CONFIG_SPIRAM=y
CONFIG_SPIRAM_MODE_OCT=y
CONFIG_SPIRAM_SPEED_80M=y
CONFIG_SPIRAM_USE_MALLOC=y
# 将 rodata/instructions/BSS 搬到 PSRAM,释放 DRAM
CONFIG_SPIRAM_RODATA=y
CONFIG_SPIRAM_FETCH_INSTRUCTIONS=y
CONFIG_SPIRAM_XIP_FROM_PSRAM=y
CONFIG_SPIRAM_ALLOW_BSS_SEG_EXTERNAL_MEMORY=y
# 禁用 Wi-Fi / BLE(省 DRAM)
CONFIG_ESP_WIFI_ENABLED=n
CONFIG_BT_ENABLED=n
# 编译优化 -O2
CONFIG_COMPILER_OPTIMIZATION_PERF=y要点:
- PSRAM 配置让大数组(模型数据、tensor arena)可以放在 PSRAM
SPIRAM_RODATA+SPIRAM_FETCH_INSTRUCTIONS让 Flash 中的模型数据和代码映射到 PSRAM- CPU 频率从默认 160MHz 提到 240MHz,整体提速 50%
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 0x700000,要点:
- 16MB Flash 默认分区只有 1MB factory,模型 + 图片需要更多
- 0x700000 = 7MB,足够放下 ~966KB 模型 + 测试图片 + 代码
idf_component_register(
SRCS
"main.c"
"image_preprocess.c"
"tflite_inference.cpp"
"model_data.cc"
INCLUDE_DIRS
"."
REQUIRES
esp_timer
)要点:
tflite_inference.cpp是 C++ 文件,model_data.cc也是 C++ 编译image_preprocess.c是 C 文件,通过extern "C"头文件调用 C++ 函数- 不要把
.cc文件同时放入 SRCS 和 #include,会导致重复符号
dependencies:
espressif/esp-tflite-micro: "^1"
espressif/esp_jpeg: "^1"要点:
esp-tflite-micro提供 TFLite Micro 库esp_jpeg提供软件 JPEG 解码器(ESP32-S3 没有硬件 JPEG)esp-nn是 TFLite Micro 的依赖,会自动拉取
将 .tflite 文件转为 C 数组:
xxd -i model/model_int8.tflite | \
sed 's/model_model_int8_tflite/g_model_data/g' > main/model_data.cc然后在 model_data.cc 的数组声明前加 extern 和确认有 const:
// 生成的文件内容大致如下:
// DO NOT EDIT - regenerate with: xxd -i model_int8.tflite > model_data.cc
extern const unsigned char g_model_data[] = {
0x18, 0x00, 0x00, 0x00, ...
};
extern const unsigned int g_model_data_len = 966864;要点:
- 必须有
const:没有const的话,966KB 数据会被放在 DRAM(只有几百 KB),启动时崩溃 const让数据放在 Flash 的.rodata段,不占 DRAM- 在 C++ 中
const默认内部链接,需要extern const才能跨文件引用
将 JPEG 图片转为 C 数组:
xxd -i cat.4003.jpg | sed 's/cat_4003_jpg/cat_4003_jpg/g' > main/test_images.h对每张图片重复,最终 test_images.h 包含:
extern const uint8_t cat_4003_jpg[];
extern const uint32_t cat_4003_jpg_len;
// ... 其他图片#ifndef IMAGE_PREPROCESS_H
#define IMAGE_PREPROCESS_H
#include <stdint.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
// 模型输入尺寸 — 必须和训练时一致
#define MODEL_INPUT_WIDTH 96
#define MODEL_INPUT_HEIGHT 96
#define MODEL_INPUT_CHANNELS 3
// float32 路径(用于 float32 模型)
int image_preprocess_from_jpeg(const uint8_t *jpeg_buf, size_t jpeg_len,
float *out_buf);
// int8 路径(用于 int8 量化模型)
int image_preprocess_from_jpeg_int8(const uint8_t *jpeg_buf, size_t jpeg_len,
int8_t *out_buf);
#ifdef __cplusplus
}
#endif
#endif要点:
MODEL_INPUT_WIDTH/HEIGHT必须和训练时的输入尺寸一致(96x96)extern "C"让 C++ 文件可以 include 这个头文件调用 C 函数
核心流程:JPEG 解码 → 缩放 → int8 量化
int image_preprocess_from_jpeg_int8(const uint8_t *jpeg_buf, size_t jpeg_len,
int8_t *out_buf)
{
// 1. 获取 JPEG 尺寸
esp_jpeg_get_image_info(&jpeg_cfg, &jpeg_out);
// 2. 如果图片 > 192px,用 1/2 缩放解码(减少 4x 解码像素量)
if (src_w > 192 || src_h > 192)
jpeg_cfg.out_scale = JPEG_IMAGE_SCALE_1_2;
// 3. 解码为 RGB888
esp_jpeg_decode(&jpeg_cfg, &jpeg_out);
// 4. 双线性缩放 + int8 量化(合并为一步,省中间缓冲区)
// 定点运算,不用 float
for (dy, dx) {
// 双线性插值
val = (p00 * w00 + p10 * w10 + p01 * w01 + p11 * w11) >> 16;
// int8 量化: [0,255] → [-128,127]
out_buf[...] = (int8_t)(val - 128);
}
}要点:
- ESP32-S3 没有硬件 JPEG,软件解码是性能瓶颈(占预处理 95% 时间)
JPEG_IMAGE_SCALE_1_2让解码器在解 JPEG 时就做 1/2 缩放,像素量减少 4x- 缩放和量化合并为一步,避免分配额外的中间缓冲区
- int8 量化公式取决于模型的输入 quantization 参数:
scale=1.0, zero_point=-128(Rescaling(1./255) 模型):int8 = pixel - 128- 其他情况:
int8 = round(pixel / scale) + zero_point
- 千万不要对 [0,255] 像素先除以 255 再量化,这会把所有值压缩到 -128 附近,破坏信息
#ifndef TFLITE_INFERENCE_H
#define TFLITE_INFERENCE_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
float confidence; // sigmoid 输出 (0~1)
int is_dog; // >0.5 → 狗
float cat_prob;
float dog_prob;
int inference_ms;
} inference_result_t;
int tflite_inference_init(void);
int tflite_inference_run_int8(const int8_t *input_data, inference_result_t *result);
int tflite_inference_get_input_quant(float *scale, int *zero_point);
void tflite_inference_deinit(void);
#ifdef __cplusplus
}
#endif
#endif这是最关键的文件,要点如下:
为什么是 .cpp 而不是 .c?
TFLite Micro 的头文件(micro_interpreter.h 等)是 C++ 代码,不能在 .c 文件中 include。所以推理引擎必须用 C++ 编写,再通过 extern "C" 暴露 C 接口给 main.c 调用。
OpResolver — 注册算子
TFLite Micro 不会自动注册算子,必须手动注册模型用到的每一个:
typedef tflite::MicroMutableOpResolver<14> OpResolver;
static int register_ops(OpResolver *resolver)
{
resolver->AddConv2D(); // 卷积
resolver->AddDepthwiseConv2D(); // 深度可分离卷积 (MobileNet 核心)
resolver->AddAdd(); // 残差连接
resolver->AddMul(); // 逐元素乘法
resolver->AddRelu(); // 激活函数
resolver->AddAveragePool2D(); // 平均池化
resolver->AddFullyConnected(); // 全连接分类层
resolver->AddReshape(); // 张量形状变换
resolver->AddQuantize(); // float → int8 量化
resolver->AddDequantize(); // int8 → float 反量化
resolver->AddPad(); // 零填充
resolver->AddSoftmax(); // Softmax
resolver->AddMean(); // Reduce Mean (Global Average Pooling)
resolver->AddLogistic(); // Sigmoid
}要点:
MicroMutableOpResolver<14>的模板参数 14 是算子数量,必须和实际注册数一致- 如果报 "Didn't find op for builtin opcode 'XXX'",就是缺少算子,需要添加对应的
AddXxx() - 如何查看模型用了哪些算子:用 Netron (https://netron.app) 打开 .tflite 文件
Tensor Arena
#define TENSOR_ARENA_SIZE (1024 * 1024) // 1MB
g_tensor_arena = (uint8_t *)heap_caps_malloc(TENSOR_ARENA_SIZE, MALLOC_CAP_SPIRAM);要点:
- Arena 必须分配在 PSRAM(
MALLOC_CAP_SPIRAM),DRAM 不够 - 如果
AllocateTensors()失败,逐步增大TENSOR_ARENA_SIZE - 当前模型实际使用 ~305KB,1MB 预留了充足余量
g_tensor_arena类型是uint8_t*(不是float*),匹配新版 TFLite Micro API
int8 推理流程
int tflite_inference_run_int8(const int8_t *input_data, inference_result_t *result)
{
// 1. 填入 int8 输入
memcpy(g_input_tensor->data.int8, input_data, input_size);
// 2. 运行推理
g_interpreter->Invoke();
// 3. 反量化输出: float = (int8 - zero_point) * scale
float scale = g_output_tensor->params.scale;
int zero_point = g_output_tensor->params.zero_point;
float confidence = (float)(g_output_tensor->data.int8[0] - zero_point) * scale;
// 4. sigmoid: >0.5 = 狗, <0.5 = 猫
result->is_dog = (confidence > 0.5f) ? 1 : 0;
}static int8_t *g_input_buf = NULL; // PSRAM 分配
void app_main(void)
{
// 1. 分配 int8 输入缓冲区 (PSRAM)
g_input_buf = heap_caps_malloc(96*96*3, MALLOC_CAP_SPIRAM);
// 2. 初始化推理引擎
tflite_inference_init();
// 3. 逐张推理
classify_image("cat.jpg", cat_jpg, cat_jpg_len);
classify_image("dog.jpg", dog_jpg, dog_jpg_len);
// 4. 释放资源
tflite_inference_deinit();
heap_caps_free(g_input_buf);
}# 设置目标芯片
idf.py set-target esp32s3
# 编译
idf.py build
# 烧录 + 串口监视
idf.py -p /dev/cu.usbmodem1301 flash monitor以下是开发过程中遇到的所有坑,按严重程度排列:
现象:启动时崩溃,或 heap_caps_malloc 分配失败
原因:model_data.cc 中数组声明没有 const,966KB 数据被放在 DRAM(只有 ~400KB)
解决:extern const unsigned char g_model_data[],const 让数据放在 Flash 的 .rodata 段
现象:运行时报 "Hybrid models are not supported on TFLite Micro"
原因:TFLiteConverter 默认做混合量化(权重 int8,激活 float),TFLite Micro 只支持全整数量化
解决:转换时设置 converter.target_spec.supported_ops = [TFLITE_BUILTINS_INT8]
现象:转换时报 ERROR_NEEDS_FLEX_OPS
原因:RandomFlip/RandomRotation/RandomZoom 用了 TFLite 不支持的算子
解决:构建推理模型时跳过数据增强层,直接复用训练好的层对象(不是新建层再 set_weights)
现象:.c 文件 include TFLite 头文件报 cstddef 等错误
原因:TFLite Micro 是 C++ 库,不能在 C 文件中使用
解决:
- 推理引擎用
.cpp文件编写 - 头文件加
extern "C"包装 - C 文件(main.c, image_preprocess.c)通过
extern "C"声明调用 C++ 函数
现象:"Didn't find op for builtin opcode 'MEAN'" 或 'LOGISTIC'
解决:在 MicroMutableOpResolver 中注册对应的 AddMean() / AddLogistic()
现象:所有图片都分到同一类,置信度异常
原因:对 [0,255] 像素先除以 255 变成 [0,1],再用 scale=1.0 量化,所有值变成 -127~-128
解决:根据模型的输入 quantization 参数(scale, zero_point)做量化。Rescaling(1./255) 模型的 int8 输入参数是 scale=1.0, zero_point=-128,量化公式就是 int8 = pixel - 128
现象:编译报错 cannot convert float* to uint8_t*
原因:新版 TFLite Micro 的 MicroInterpreter 构造函数要求 uint8_t* 类型的 arena
解决:g_tensor_arena 声明为 uint8_t* 而非 float*
如果重新训练了模型,需要以下步骤:
- 替换
model/model_int8.tflite - 重新生成
main/model_data.cc:xxd -i model/model_int8.tflite | sed 's/model_model_int8_tflite/g_model_data/g' > main/model_data.cc
- 确认
model_data.cc中数组声明有extern const - 如果模型输入尺寸变了,修改
image_preprocess.h中的MODEL_INPUT_WIDTH/HEIGHT - 如果模型用了新算子,在
tflite_inference.cpp的register_ops()中添加 - 如果模型更大,可能需要增大
TENSOR_ARENA_SIZE - 重新编译烧录
| 文件 | 用途 |
|---|---|
CMakeLists.txt |
顶层 CMake,指定目标芯片和组件 |
sdkconfig.defaults |
PSRAM、CPU 频率、编译优化等配置 |
partitions.csv |
7MB factory 分区(16MB Flash) |
main/main.c |
入口,分配内存,调用预处理和推理 |
main/image_preprocess.c/h |
JPEG 解码 + 缩放 + int8 量化 |
main/tflite_inference.cpp/h |
TFLite Micro 推理引擎封装 |
main/model_data.cc |
int8 模型的 C 数组(Flash 中) |
main/test_images.h |
内嵌 JPEG 测试图片 |
main/idf_component.yml |
esp-tflite-micro、esp_jpeg 依赖 |
train_catdog_model.py |
Colab 训练脚本 |
model/model_int8.tflite |
int8 量化模型文件 |
sdcard_images/ |
测试图片(仅备份,不参与编译) |