ESP32_S3分类模型部署工作流

📅 2026/8/22 17:45:53
ESP32_S3分类模型部署工作流
本篇文章的重点不在于详解每一步的细节而是总结模型部署的方法论和工作流程。那么我们现在开始。本项目使用的模型是MobileNetV2输入在板端压缩到96×96一个二分类。训练数据约 139 张模型量化后540 KB塞进一块8MB Flash / 8MB PSRAM的 ESP32-S3。板端整个固件跑在 ESP-IDF 构建系统上推理引擎用乐鑫官方的ESP-DL。1、获取和整理数据集本次项目使用的数据集是 Open Images并只截取其中的耳机类。由于本次项目主要为了验证工作流所以在数据集的处理上是比较随便的不过如果日后个人开发时在处理数据集上一点注意以下几点:尽量使用部署时的相机进行采集逼近真实的部署场景不同距离、角度、光线最好都要采集到数据集的图像尺寸不要和训练时的尺寸偏差太大2、训练、量化模型这是模型部署中最重要的一步决定着你部署的模型还有没有用。这里主要解决两个问题一个是模型的大小一个是保证模型的精度。即在降低模型大小的情况下尽可能的保证模型的精度这个过程叫做量化2.1量化为什么浮点不能被板子直接拿我们不管是使用tf训练还是使用pt训练一般都是得到一个float32类型的模型文件这个模型会很大在上位机跑还可以如果是放到边缘端的设备上跑就会占用大量的性能资源甚至都无法满足所以我们需要将其压成板子爱跑的 int8类型——内存砍 4 倍而且 8 位整数正好是 SIMD 指令集的强项。把模型变成 int8 的这个过程叫量化。损失的代价是精度浮点数换成整数等于把连续的值域切成一格一格总会有误差。量化要解决的就是让这个误差尽量小小到不影响结论。同样是模型变 int8落地方式有两条截然不同的路这就是本项目最核心的分岔口PTQ 和 QAT。2.2 PTQ QAT首先我们确定一个事实它们都在做同一件事得到 int8 模型但一个在训练完后补一个贯穿训练过程中。维度PTQ训练后量化QAT量化感知训练用一句话说训完的模型事后再压成 int8训练过程中就模拟 int8 的精度何时发生训练结束之后一次性转换训练阶段就介入微调需要什么一小部分校准数据统计各层激活的分布改训练脚本前向时插入伪量化节点误差怎么来转换时一次性引入无法在训练里弥补每步反传都会看到量化误差模型主动适应实现成本低一条命令高要重训一轮、多一套流水线典型精度大模型/简单任务下损失通常可忽略小模型/难任务下通常优于 PTQ最大收益场景模型大、数据分布简单时已够用模型小、参数敏感、任务难时更稳在这里我先给一个测试结果在小样本、接近可观测的分类任务上PTQ 已经足够而且更快、更简单。QAT 的成本高、流程重只有在模型小、任务变难、PTQ 压不住误差时才值得投入。量化部署讲究达标即可——先看 PTQ 能不能到达标线不行再上 QAT而不是反过来把复杂度先扛上。不过我的建议是建立好直接的QAT工作流会更接近真实的部署参数当然前提是处理好数据集。这里我贴出写的简单脚本quantize_tflite.py# -*- coding: utf-8 -*- 浮点 TFLite - int8 量化 (PTQ). 注意: 校准集与评估集分离, 抽样规则与 evaluate_quant.py 一致 (固定 seed 每类 CAL_PER_CLASS 张)。 若已用 train_classifier.py 的 QAT 训练并导出 model_int8_qat.tflite, 本脚本可跳过(纯 PTQ 参考)。 import os, glob, random import numpy as np import tensorflow as tf H5 rE:\Project\ROS2\esp32s3\model_out\mobilev2_35_96.h5 OUTINT rE:\Project\ROS2\esp32s3\model_out\model_int8.tflite DATA os.environ.get(CLS_DATA, rE:\Project\ROS2\esp32s3\data\headphone) IMG 96 CAL_PER_CLASS 3 # 每类取几张做校准, 其余留给评估 # ---------- 固定 seed 抽样: 校准子集(quant) vs 评估子集(eval) ---------- random.seed(20260820) cal_imgs, eval_imgs [], [] for sub in sorted(d for d in os.listdir(DATA) if os.path.isdir(os.path.join(DATA, sub))): fs (glob.glob(os.path.join(DATA, sub, *.jpg)) glob.glob(os.path.join(DATA, sub, *.jpeg))) random.shuffle(fs) cal_imgs fs[:CAL_PER_CLASS] eval_imgs fs[CAL_PER_CLASS:] print(校准集张数(用于量化):, len(cal_imgs), | 评估集张数(供 evaluate_quant 用):, len(eval_imgs)) def representative(): for p in cal_imgs: # 只用校准子集, 不污染评估 img tf.image.decode_jpeg(tf.io.read_file(p), channels3) img tf.image.resize(img, (IMG, IMG)) img tf.cast(img, tf.float32) / 255.0 yield [tf.expand_dims(img, 0)] model tf.keras.models.load_model(H5) converter tf.lite.TFLiteConverter.from_keras_model(model) converter.optimizations [tf.lite.Optimize.DEFAULT] converter.representative_dataset representative converter.target_spec.supported_ops [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] converter.inference_input_type tf.int8 converter.inference_output_type tf.int8 tflite converter.convert() with open(OUTINT, wb) as f: f.write(tflite) print(int8 tflite 导出:, OUTINT, round(os.path.getsize(OUTINT) / 1024, 1), KB) # 验证: 能加载 跑一次 dummy interp tf.lite.Interpreter(model_contenttflite) interp.allocate_tensors() i interp.get_input_details() o interp.get_output_details() print(input :, i) print(output:, o) x np.zeros(i[0][shape], dtypenp.int8) interp.set_tensor(i[0][index], x) interp.invoke() print(ok, 推理输出含, len(o), 个节点)train_classifier.py# -*- coding: utf-8 -*- 有耳机 / 无耳机 二分类训练脚本 (部署目标: ESP32-S3) 模型: MobileNetV2 (input 96x96 RGB, alpha0.35, ImageNet 预训练) 迁移学习 两阶段用法: python train_classifier.py float # 阶段1: 训练浮点模型, 导 model_float.tflite python train_classifier.py qat # 阶段2: 在 float 基线上做 QAT 对齐微调, 导 model_int8_qat.tflite 务必先 float 后 qat: qat 阶段会 load 阶段1产出的 h5 权重做初始化。 输出: E:/Project/ROS2/esp32s3/model_out/ - mobilev2_35_96.h5 (float) - model_float.tflite (float) - model_int8_qat.tflite (QAT int8) import os, sys, glob, random import tensorflow as tf from tensorflow.keras import layers, models from tensorflow.keras.preprocessing.image import ImageDataGenerator from tensorflow.keras.applications import MobileNetV2 DATA os.environ.get(CLS_DATA, rE:\Project\ROS2\esp32s3\data\headphone) IMG 96 BATCH 4 ALPHA 0.35 # width multiplier LR 1e-3 QAT_LR 1e-4 OUTDIR rE:\Project\ROS2\esp32s3\model_out FLOAT_H5 os.path.join(OUTDIR, mobilev2_35_96.h5) STAGE (sys.argv[1] if len(sys.argv) 1 else float).lower() EPOCHS 40 if STAGE float else 0 QAT_EPOCHS 5 if STAGE qat else 0 os.makedirs(OUTDIR, exist_okTrue) classes sorted(d for d in os.listdir(DATA) if os.path.isdir(os.path.join(DATA, d))) print(\n[%s] 检测到类别: % STAGE, classes, 类别数:, len(classes)) # ---------- 数据增强 (数据少, 靠增强扩充) ---------- aug ImageDataGenerator( rescale1.0 / 255.0, rotation_range30, width_shift_range0.15, height_shift_range0.15, shear_range0.15, zoom_range0.2, horizontal_flipTrue, brightness_range[0.7, 1.3], validation_split0.2, ) train_it aug.flow_from_directory(DATA, target_size(IMG, IMG), batch_sizeBATCH, class_modecategorical, subsettraining, shuffleTrue) val_it aug.flow_from_directory(DATA, target_size(IMG, IMG), batch_sizeBATCH, class_modecategorical, subsetvalidation, shuffleFalse) # ---------- 迁移学习: 扩展 base 图(避免 QAT 报 nested model 错误) ---------- base MobileNetV2(input_shape(IMG, IMG, 3), include_topFalse, weightsimagenet, alphaALPHA) base.trainable False # 冻结预训练主干 x base.output x layers.GlobalAveragePooling2D()(x) x layers.Dropout(0.2)(x) outputs layers.Dense(len(classes), activationsoftmax)(x) model models.Model(inputsbase.input, outputsoutputs) # ---------- 校准集代表性数据 (仅量化用, 不喂训练) ---------- random.seed(20260820) cal_imgs [] for sub in classes: fs (glob.glob(os.path.join(DATA, sub, *.jpg)) glob.glob(os.path.join(DATA, sub, *.jpeg))) random.shuffle(fs) cal_imgs fs[:3] def representative(): for p in cal_imgs: img tf.image.decode_jpeg(tf.io.read_file(p), channels3) img tf.image.resize(img, (IMG, IMG)) img tf.cast(img, tf.float32) / 255.0 yield [tf.expand_dims(img, 0)] # ---------- float 阶段: 正常训练 ---------- if STAGE float: model.compile(optimizertf.keras.optimizers.Adam(LR), losscategorical_crossentropy, metrics[accuracy]) model.summary() print(\n[float] 开始训练 %d epochs... % EPOCHS) history model.fit(train_it, validation_dataval_it, epochsEPOCHS) model.save(FLOAT_H5) print(\n[float] 已保存 float 权重:, FLOAT_H5) converter tf.lite.TFLiteConverter.from_keras_model(model) tflite converter.convert() fp os.path.join(OUTDIR, model_float.tflite) with open(fp, wb) as f: f.write(tflite) print([float] 已导出浮点 TFLite:, fp, 大小:, round(os.path.getsize(fp) / 1024, 1), KB) print([float] 训练 acc:, history.history[accuracy][-1], 验证 acc:, history.history[val_accuracy][-1]) # ---------- qat 阶段: 在 float 基线上量化感知微调 ---------- elif STAGE qat: if not os.path.exists(FLOAT_H5): print([qat] 未找到 %s, 请先运行: python train_classifier.py float % FLOAT_H5) raise SystemExit(1) try: import tensorflow_model_optimization as tfmot except ImportError: print([qat] 需要 tensorflow-model-optimization: pip install tensorflow-model-optimization0.7.5) raise SystemExit(1) model.load_weights(FLOAT_H5) # 从 float 权重初始化 print([qat] 已加载 float 权重, 套用量化感知训练) qat_model tfmot.quantization.keras.quantize_model(model) qat_model.compile(optimizertf.keras.optimizers.Adam(QAT_LR), losscategorical_crossentropy, metrics[accuracy]) print(\n[qat] QAT 对齐微调 %d epochs (小学习率)... % QAT_EPOCHS) history qat_model.fit(train_it, validation_dataval_it, epochsQAT_EPOCHS) converter tf.lite.TFLiteConverter.from_keras_model(qat_model) converter.optimizations [tf.lite.Optimize.DEFAULT] converter.representative_dataset representative converter.target_spec.supported_ops [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] converter.inference_input_type tf.int8 converter.inference_output_type tf.int8 tflite converter.convert() qp os.path.join(OUTDIR, model_int8_qat.tflite) with open(qp, wb) as f: f.write(tflite) print([qat] 已导出 QAT int8:, qp, 大小:, round(os.path.getsize(qp) / 1024, 1), KB) print([qat] 微调后 acc:, history.history[accuracy][-1], 验证 acc:, history.history[val_accuracy][-1]) else: print(未知阶段:, STAGE, 可选: float / qat) raise SystemExit(1) print(\n类别索引:, train_it.class_indices)PTQ 的关键是校准转的时候要用一小部分数据过一遍网络统计每一层激活值的实际范围据此确定每个量化刻度对应多少浮点数。这部分数据叫校准集必须和训练域同分布而且测试集里不能出现。QAT 则没有这个需要——它每一步训练都自带量化感知。3、格式转换板子没法直接读 .h5 或 .tflite需要转成板端推理引擎认的格式。本项目用的是乐鑫官方的ESP-DL库模型要转成.espdl格式由esp-ppq量化工具生成。转换链路是# float 模型训练产物 h5→ ONNX → int8 .espdlexport_tflite.pyh5 → float tflite# -*- coding: utf-8 -*- 用多种策略导出 TFLite 浮点模型, 绕过 from_keras_model 的 LLVM 崩溃 import os import tensorflow as tf H5 rE:\Project\ROS2\esp32s3\model_out\mobilev2_35_96.h5 OUT rE:\Project\ROS2\esp32s3\model_out\model_float.tflite model tf.keras.models.load_model(H5) print(已加载模型, 输出节点:, [o.name for o in model.outputs]) def write(tflite): with open(OUT, wb) as f: f.write(tflite) print(导出成功:, OUT, round(os.path.getsize(OUT) / 1024, 1), KB) # 方案1: concrete function (更贴近 graph, 常能绕过前端崩溃) try: tf.function(input_signature[tf.TensorSpec([1, 96, 96, 3], tf.float32)]) def infer(x): return model(x) c tf.lite.TFLiteConverter.from_concrete_functions([infer.get_concrete_function()], model) c.target_spec.supported_ops [tf.lite.OpsSet.TFLITE_BUILTINS] write(c.convert()) raise SystemExit(0) except Exception as e: print(方案1失败:, type(e).__name__, str(e)[:300]) print(改用方案2: from_keras_model 显式 input_signature) # 方案2: from_keras_model (重试, 有时为瞬时崩溃) try: c tf.lite.TFLiteConverter.from_keras_model(model) c.target_spec.supported_ops [tf.lite.OpsSet.TFLITE_BUILTINS] write(c.convert()) raise SystemExit(0) except Exception as e: print(方案2失败:, type(e).__name__, str(e)[:300]) raise SystemExit(1)convert_tf2onnx.py tflite → ONNX# -*- coding: utf-8 -*- Keras float 模型 - ONNX (tf2onnx). 需在 train_env2(TFtf2onnxonnx1.16) 运行. import os import tensorflow as tf import tf2onnx tf.get_logger().setLevel(ERROR) os.environ[TF_CPP_MIN_LOG_LEVEL] 3 H5 rE:\Project\ROS2\esp32s3\model_out\mobilev2_35_96.h5 OUT rE:\Project\ROS2\esp32s3\model_out\model_float.onnx m tf.keras.models.load_model(H5) # 输入张量 spec: 与训练一致的 NHWC (None,96,96,3) spec [tf.TensorSpec((None, 96, 96, 3), tf.float32, nameinput_1)] onnx_model, _ tf2onnx.convert.from_keras(m, input_signaturespec, opset13) with open(OUT, wb) as f: f.write(onnx_model.SerializeToString()) print(ONNX saved:, OUT, round(os.path.getsize(OUT) / 1024, 1), KB) print(inputs :, [i.name str(i.type.tensor_type.shape.dim) for i in onnx_model.graph.input]) print(outputs:, [o.name for o in onnx_model.graph.output]) print(op_set :, onnx_model.opset_import[0].version)convert_espdl.py ONNX → .espdlesp-ppq 量化# -*- coding: utf-8 -*- float ONNX - ESP32-S3 量化(.espdl) 转换脚本 (ESP-PPQ PTQ). 输入: float onnx (由 model_float.tflite 或 keras h5 转换而来) 输出: model_espdl.espdl / .info / .json (per-tensor 对称 int8, 面向 esp32s3) 校准数据: data/headphone/ 下与量化脚本一致的固定 seed 抽样(每类前 3 张), 与评估集分离. 用法: python convert_espdl.py float.onnx import os import sys import glob import random import numpy as np import torch from torch.utils.data import DataLoader, TensorDataset from esp_ppq.api import espdl_quantize_onnx # ---------- 配置 ---------- DATA os.environ.get(CLS_DATA, rE:\Project\ROS2\esp32s3\data\headphone) OUTDIR rE:\Project\ROS2\esp32s3\model_out ONNX sys.argv[1] if len(sys.argv) 1 else os.path.join(OUTDIR, model_float.onnx) ESPDL os.path.join(OUTDIR, model_espdl.espdl) IMG 96 CAL_PER_CLASS 3 # 与 quantize_tflite / evaluate_quant 保持一致 TARGET esp32s3 NUM_OF_BITS 8 DEVICE cpu # ---------- 固定 seed 抽样校准集 (float 0~1, 训练同款归一化) ---------- random.seed(20260820) cal_imgs, classes [], [] for sub in sorted(d for d in os.listdir(DATA) if os.path.isdir(os.path.join(DATA, d))): fs glob.glob(os.path.join(DATA, sub, *.jpg)) glob.glob(os.path.join(DATA, sub, *.jpeg)) random.shuffle(fs) cal_imgs fs[:CAL_PER_CLASS] classes.append(sub) print(校准集张数:, len(cal_imgs), 类别:, classes) xs, ys [], [] for p in cal_imgs: # 用 PIL 读 jpeg - RGB float 0~1 / 255, resize 96x96 (与 Keras 训练一致) from PIL import Image img Image.open(p).convert(RGB).resize((IMG, IMG)) arr np.asarray(img, dtypenp.float32) / 255.0 # [96,96,3] NHWC xs.append(arr) ys.append(np.float32(0.0)) x_t torch.from_numpy(np.stack(xs)).contiguous() # [N,96,96,3] y_t torch.from_numpy(np.stack(ys)).contiguous() def collate_fn(batch): return batch[0].to(DEVICE) calib DataLoader(TensorDataset(x_t, y_t), batch_size1, shuffleFalse) if not os.path.exists(ONNX): raise SystemExit(未找到 onnx: %s (先由 float tflite/h5 转 onnx) % ONNX) print(开始量化导出 -, ESPDL, target, TARGET, bits, NUM_OF_BITS) quant_ppq_graph espdl_quantize_onnx( onnx_import_fileONNX, espdl_export_fileESPDL, calib_dataloadercalib, calib_stepslen(cal_imgs), input_shape[1, IMG, IMG, 3], # 批次为 1, NHWC(由 Keras 布局决定) inputsNone, targetTARGET, num_of_bitsNUM_OF_BITS, collate_fncollate_fn, dispatching_overrideNone, deviceDEVICE, error_reportTrue, skip_exportFalse, export_test_valuesTrue, verbose1, ) print(导出完成:, ESPDL, 大小:, round(os.path.getsize(ESPDL) / 1024, 1), KB) print(类别顺序(部署时 labels 需一致):, classes)转换产物里包含了关键量化信息部署时全都要看# model_espdl.info 片段 graph tf2onnx ( %input_1[INT8, 1x96x96x3], exponents: [-7] ← 输入 int8exponent-7 scale2^-7 )这是最容易出错的一步类别顺序、输入张量的 NCHW/NHWC 布局、量化 exponent必须和板端 C 代码完全一致。错一个板子跑起来了但结果全是错的。本项目固件里输入张量按NHWC 1×96×96×3和 Keras 训练布局一致。4、部署前面好不容易把模型量化成了.espdl文件——但这只是躺在宿主机上的一个文件。板子上电后究竟怎么拿到它并让它跑起来这是部署真正要解决的问题。整个过程分两段先把它焊进固件再让 ESP-DL 在启动时把它拆出来。先说清楚本项目用的整套工具链是 乐鑫官方的 ESP-IDF 构建系统——代码用 C 写在main/下整个固件由idf.py build编译链接所有外设、WiFi、HTTP 服务器、以及 HAL都通过 ESP-IDF 提供的组件esp32-camera、led_strip、esp_http_server等来驱动。下面讲的嵌入模型自定义分区链接符号全是 ESP-IDF 这一套机制特有的产物——换其他芯片/框架命名和做法会变但把模型字节塞进固件的思路是通用的。4.1模型怎么进固件嵌入Embed最朴素、最可靠的做法是把模型字节直接作为固件的一部分打包进 flash。这靠的是 ESP-IDF 的二进制嵌入机制在CMakeLists.txt里一行指定# ESP32-S3 阶段一 · 图像分类 工程 cmake_minimum_required(VERSION 3.16) include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(esp32s3_ph1_image_class)这行的效果是编译链接时把models/model_espdl.espdl约540 KB当作一段只读数据并进固件 image落进自定义partitions_big.csv的 app 分区里。链接器会为它生成一对隐藏符号——_binary_model_espdl_start和_binary_model_espdl_end相当于这段数据在内存里的起点和终点。C 代码只需声明即可拿到它的地址// app_main.cpp —— extern 一个链接器符号, 指向 flash 里那段模型字节 extern const uint8_t model_espdl_start[] asm(_binary_model_espdl_start); extern const uint8_t index_html_html_start[] asm(_binary_index_html_html_start);这样操作后量产时只需要烧一个 bin不用单独管模型文件把视角拉高看这个模型在整个工程里的固件工程、训练脚本的关系esp32s3/ ├── scripts/ # 训练 转换脚本(宿主机 Python) │ ├── train_classifier.py # 训练 MobileNetV2 │ ├── quantize_tflite.py # PTQ 量化 │ ├── export_tflite.py # h5 - float tflite │ ├── convert_tf2onnx.py # tflite - onnx │ ├── convert_espdl.py # onnx - .espdl(esp-ppq) │ └── evaluate_quant.py # 量化后精度评估 │ ├── data/headphone/ # 二分类数据集 has_headphone/no_headphone ├── model_out/ # 转换产物(成品模型) │ ├── model_espdl.espdl (540 KB) # ← 真正被嵌入固件的那份 │ ├── model_espdl.info # 量化信息(exponent/layout) │ └── model_espdl.json # 图结构描述 │ └── ph1-image-class/ # ESP-IDF 固件工程 ├── main/ │ ├── app_main.cpp # 取帧/推理/HTTP 主程序 │ ├── index_html.html # 网页(一并嵌入固件) │ ├── CMakeLists.txt # 嵌入模型的那行在这里 │ └── models/model_espdl.espdl # 嵌入用的副本 ├── components/esp-dl/ # ESP-DL 推理引擎(local vendor) ├── partitions_big.csv # 4MB app 分区 └── sdkconfig.defaults / sdkconfig # PSRAM/Flash 配置模型文件出现了两份——model_out/里是转换链路的成品仓库ph1-image-class/main/models/里是真正嵌入固件的副本。转换/训练时你操作的是前者但板上真正跑的是后者那几行 CMake 指定的model_espdl.espdl。改模型后如果忘了同步这份副本就会训了新模型、板上还是旧的。4.2 ESP-DL 反序列化有了模型的裸字节还不够板子上的推理引擎必须读懂.espdl的二进制结构把它还原成可执行的算子图。这发生在板子上电初始化时// 用 Flash rodata 里的模型起始指针, 让 ESP-DL 就地反序列化 s_model new dl::Model((const char *)model_espdl_start, fbs::MODEL_LOCATION_IN_FLASH_RODATA); // 从还原出的图里取出输入/输出张量句柄, 拿到量化 exponent auto ins s_model-get_inputs(); auto outs s_model-get_outputs(); s_in_exp s_in-exponent; // 输入 int8 exponent-7这一步值得注意的参数是MODEL_LOCATION_IN_FLASH_RODATA它告诉 ESP-DL 模型数据是只读的、且存放在 flash 的 rodata 段。ESP-DL 据此决定如何分配内存、如何引用权重——这也是后续进行性能优化能生效的前提。get_outputs()返回的输出张量是 float32与量化工具导出的 输出 float[1,2] 一致于是部署端拿到的类别得分可直接比较大小。到这一步模式进正式嵌入到esp中了。固件后续需要怎么写识别逻辑就看你自己了。5、优化模型部署成功后往往需要针对性的进行性能优化——去提高它的推理速度和降低内存需求。针对esp32来说他有自带的ESP-DL引擎每次推理压到百毫秒级同时做了内存上的关键优化。最值得单独讲的PSRAM rodata。ESP32-S3 板载 8MB PSRAM把模型权重这些只读数据rodata搬到外置 PSRAM能腾出宝贵的内部 SRAM。而 ESP-DL 的加载器会自动检测 PSRAM 并做搬移基本不用改业务代码。6、总结以上的内容都是基于esp这个芯片进行模型部署如果后面大家遇到其他的开发板肯定有很多东西都不一样了但是工作的流程大差不差离不开量化操作而在很多的模型部署工作中会将更多的重点放在后期的优化上这就需要去看对于芯片的sdk了。