本篇文章的重点不在于详解每一步的细节,而是总结模型部署的方法论和工作流程。

那么我们现在开始。

  • 本项目使用的模型是 MobileNetV2,输入在板端压缩到 96×96,一个二分类。
  • 训练数据约 139 张,模型量化后 540 KB,塞进一块 8MB Flash / 8MB PSRAM 的 ESP32-S3。
  • 板端整个固件跑在 ESP-IDF 构建系统上,推理引擎用乐鑫官方的 ESP-DL

1、获取和整理数据集

本次项目使用的数据集是 Open Images,并只截取其中的耳机类。

由于本次项目主要为了验证工作流,所以在数据集的处理上是比较随便的,不过如果日后个人开发时,在处理数据集上一点注意以下几点:

  1. 尽量使用部署时的相机进行采集,逼近真实的部署场景
  2. 不同距离、角度、光线最好都要采集到
  3. 数据集的图像尺寸不要和训练时的尺寸偏差太大

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     = r"E:\Project\ROS2\esp32s3\model_out\mobilev2_35_96.h5"
OUTINT = r"E:\Project\ROS2\esp32s3\model_out\model_int8.tflite"
DATA   = os.environ.get("CLS_DATA", r"E:\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), channels=3)
        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_content=tflite)
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"], dtype=np.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, alpha=0.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", r"E:\Project\ROS2\esp32s3\data\headphone")
IMG     = 96
BATCH   = 4
ALPHA   = 0.35          # width multiplier
LR      = 1e-3
QAT_LR  = 1e-4
OUTDIR  = r"E:\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_ok=True)
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(
    rescale=1.0 / 255.0,
    rotation_range=30,
    width_shift_range=0.15,
    height_shift_range=0.15,
    shear_range=0.15,
    zoom_range=0.2,
    horizontal_flip=True,
    brightness_range=[0.7, 1.3],
    validation_split=0.2,
)
train_it = aug.flow_from_directory(DATA, target_size=(IMG, IMG), batch_size=BATCH,
                                   class_mode="categorical", subset="training",
                                   shuffle=True)
val_it   = aug.flow_from_directory(DATA, target_size=(IMG, IMG), batch_size=BATCH,
                                   class_mode="categorical", subset="validation",
                                   shuffle=False)

# ---------- 迁移学习: 扩展 base 图(避免 QAT 报 nested model 错误) ----------
base = MobileNetV2(input_shape=(IMG, IMG, 3), include_top=False,
                   weights="imagenet", alpha=ALPHA)
base.trainable = False                 # 冻结预训练主干

x = base.output
x = layers.GlobalAveragePooling2D()(x)
x = layers.Dropout(0.2)(x)
outputs = layers.Dense(len(classes), activation="softmax")(x)
model = models.Model(inputs=base.input, outputs=outputs)

# ---------- 校准集代表性数据 (仅量化用, 不喂训练) ----------
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), channels=3)
        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(optimizer=tf.keras.optimizers.Adam(LR),
                  loss="categorical_crossentropy", metrics=["accuracy"])
    model.summary()
    print("\n[float] 开始训练 %d epochs..." % EPOCHS)
    history = model.fit(train_it, validation_data=val_it, epochs=EPOCHS)

    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-optimization==0.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(optimizer=tf.keras.optimizers.Adam(QAT_LR),
                      loss="categorical_crossentropy", metrics=["accuracy"])
    print("\n[qat] QAT 对齐微调 %d epochs (小学习率)..." % QAT_EPOCHS)
    history = qat_model.fit(train_it, validation_data=val_it, epochs=QAT_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 .espdl

export_tflite.py(h5 → float tflite

# -*- coding: utf-8 -*-
"""用多种策略导出 TFLite 浮点模型, 绕过 from_keras_model 的 LLVM 崩溃"""
import os
import tensorflow as tf

H5  = r"E:\Project\ROS2\esp32s3\model_out\mobilev2_35_96.h5"
OUT = r"E:\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(TF+tf2onnx+onnx1.16) 运行."""
import os
import tensorflow as tf
import tf2onnx

tf.get_logger().setLevel("ERROR")
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"

H5  = r"E:\Project\ROS2\esp32s3\model_out\mobilev2_35_96.h5"
OUT = r"E:\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, name="input_1")]
onnx_model, _ = tf2onnx.convert.from_keras(m, input_signature=spec, opset=13)

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 → .espdl(esp-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", r"E:\Project\ROS2\esp32s3\data\headphone")
OUTDIR     = r"E:\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, dtype=np.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_size=1, shuffle=False)

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_file=ONNX,
    espdl_export_file=ESPDL,
    calib_dataloader=calib,
    calib_steps=len(cal_imgs),
    input_shape=[1, IMG, IMG, 3],      # 批次为 1, NHWC(由 Keras 布局决定)
    inputs=None,
    target=TARGET,
    num_of_bits=NUM_OF_BITS,
    collate_fn=collate_fn,
    dispatching_override=None,
    device=DEVICE,
    error_report=True,
    skip_export=False,
    export_test_values=True,
    verbose=1,
)
print("导出完成:", ESPDL,
      " 大小:", round(os.path.getsize(ESPDL) / 1024, 1), "KB")
print("类别顺序(部署时 labels 需一致):", classes)

!!!!!转换产物里包含了关键量化信息,部署时全都要看:

# model_espdl.info 片段
graph tf2onnx (
  %input_1[INT8, 1x96x96x3], exponents: [-7]   ← 输入 int8,exponent=-7 => scale=2^-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-cameraled_stripesp_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了。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐