在 STM32H7 上跑 MobileNetV2 推理?12 FPS 实时推理图像分类实录【开源】

📦 项目已开源:https://github.com/Flora233333/STM32-Infer-MobileNetV2

🎬 B 站演示视频:https://www.bilibili.com/video/BV1MA4m1V72W

硬件:STM32H723ZGT6 + OV5640 摄像头 + SPI LCD

关键词:X-CUBE-AI、MobileNetV2_0.35、uint8 量化、DCMI、DMA、CMSIS-NN


写在前面:

这个项目,其实是本人本科时2024年初在家闲着无聊,进行的一次尝试😛
现在读研闲着无聊打算写一写分享一下这个项目的内容🤓
当时可能并没有像现在一样这么多在STM32上跑模型的样例(当时AI写MCU也是一坨)
所以烦请多多担待啦😇!(就假设现在是2024年初)
如果可以的话,可以给我点一个 Star⭐吗!

刷社区的时候,我经常能看到这样的问题:

  • “MCU 跑深度学习是不是只能做个 MNIST 手写数字识别?”
  • “STM32 上能不能跑 MobileNet 这种真正的 backbone?”
  • “X-CUBE-AI 到底靠不靠谱?延迟能压到多少?”

作为一个天天在各种 Cortex-M 上折腾的嵌入式工程师,我一直想亲自回答这几个问题。于是就有了这个项目——在一块成本不到百元的 STM32H723ZGT6 上,跑一个真正的 MobileNetV2 分类网络,并且接上摄像头做到实时推理

最终的效果是这样的:摄像头对着图片,屏幕上实时框出目标并打印类别名称和置信度。测试集准确率 94.65%,帧率 9~12 FPS,也许能达到"能用"的程度?


一、硬件选型:为什么是 STM32H723?

很多人第一反应是上 STM32H747/H750 这类带 DMA2D、Chrom-ART 图像加速器的型号,但我这次刻意选了一颗相对"朴素"的 STM32H723ZGT6

参数 数值
内核 Cortex-M7 + FPU + DSP
主频 550 MHz
Flash (ROM) 1 MB
RAM 564 KB(含 DTCM / AXI SRAM)
AI 加速 无专用 NPU

选它的理由很简单:

1. 因为它是2024年能随便买到的最高主频的ST的MCU
2. 我觉得 Cortex-M7 + CMSIS-NN 软件推理,也能把 MobileNetV2 跑到可用帧率
3. 当时我手头正好只有它😋

如果直接上带 NPU 的 STM32N6 或者走 Chrom-ART,那就没什么挑战性了。

外设搭配:

  • OV5640 摄像头:500 万像素,带硬件自动对焦,DCMI 接口
  • SPI LCD:2.0 寸 TFT,作为结果显示
  • 用到的 MCU 外设:DCMI、DMA、SPI、CRC、USART、GPIO

刚好把一条完整的视觉推理链路上常用的外设都串起来了。


二、模型选型:不是所有 MobileNetV2 都能塞进 MCU

MobileNetV2 有一个常被忽略的超参叫 width multiplier(记作 α \alpha α),它会把每一层的通道数缩放为原来的 α \alpha α 倍。这对 MCU 部署几乎是决定性的:

参数量大致按 α 2 \alpha^2 α2 缩放:

Params ( α ) ≈ α 2 ⋅ Params ( 1.0 ) \text{Params}(\alpha) \approx \alpha^2 \cdot \text{Params}(1.0) Params(α)α2Params(1.0)

原版 MobileNetV2( α = 1.0 \alpha = 1.0 α=1.0)参数量约 3.5M,即便 INT8 量化也要 3.5 MB 以上,根本塞不进 1 MB Flash

我最终选的是 MobileNetV2_0.35 α = 0.35 \alpha=0.35 α=0.35),输入 ((128, 128, 3))、输出 ((1, 15)) 的 15 分类模型。其中:

  • 输入端uint8(这决定了预处理查表怎么建,下文详说)
  • 输出端float(方便直接 argmax + 打印百分比置信度)

量化后权重约 630 KB,整个工程烧进 Flash 占用约 567 KB,给摄像头驱动、LCD、字库都留了余量。

经验之谈:MCU 上部署 CNN,Flash 往往比 RAM 先告急。选模型时要先拿量化后的权重大小去比对 Flash 余量,而不是看论文里的 FLOPs。另外 (\alpha) 取 0.35 / 0.5 / 0.75 / 1.0 这几档都有官方预训练权重可以做迁移学习,省下不少训练时间。

2.1 训练配置

训练流程和配置(都写在仓库 README 里):

  • 框架:Keras
  • 优化器:Adam,学习率 1 × 10 − 4 1\times 10^{-4} 1×104
  • 训练轮次:200 epochs
  • 数据增强:multiple augmentation(水平翻转、随机亮度、随机旋转、随机裁剪等的组合,实际做下来对小样本数据集提升非常明显)

2.2 模型转换链路

整条端到端的链路是:
.h5   (Keras) → TFLite Converter .tflite   (PTQ   uint8) → STM32CubeAI C   source   +   weights \texttt{.h5 (Keras)} \xrightarrow{\text{TFLite Converter}} \texttt{.tflite (PTQ uint8)} \xrightarrow{\text{STM32CubeAI}} \texttt{C source + weights} .h5 (Keras)TFLite Converter .tflite (PTQ uint8)STM32CubeAI C source + weights
也就是先在 Keras 里训练并存成 .h5,用 TFLite 工具做后训练量化(Post-Training Quantization, PTQ) 得到 .tflite,最后丢进 X-CUBE-AI 生成 STM32 可以直接编译的 C 代码和权重数组。


三、工具链:X-CUBE-AI 到底做了什么

X-CUBE-AI 是 STM32CubeMX 的一个插件,它会自动:

  • 分析算子,生成 C 代码(network.c / network.h / network_data.c / network_data_params.c);
  • 打包权重成常量数组;
  • 给出 RAM/Flash 占用评估和逐层延迟报告。

生成的 API 也很清爽,核心就三步(这部分代码在 X-CUBE-AI/App/app_x-cube-ai.c 里):

/* 1. 句柄初始化与内存分配 */
static int ai_boostrap(ai_handle *act_addr)
{
    ai_error err;

    err = ai_network_create_and_init(&network, act_addr, NULL);
    if (err.type != AI_ERROR_NONE) {
        ai_log_err(err, "ai_network_create_and_init");
        return -1;
    }

    ai_input  = ai_network_inputs_get(network, NULL);
    ai_output = ai_network_outputs_get(network, NULL);

    /* 获取量化模型的反量化参数 */
    uint8_t qte_scheme = ai_get_input_quantization_scheme();
    printf("Quantization scheme: %d\r\n", qte_scheme);

    /* 预计算像素转换查找表 */
    Compute_pix_conv_tab();
    printf("Pixel conversion LUT Finished\r\n");

    return 0;
}

/* 2. 主推理流程:采集 → 预处理 → 推理 → 后处理 */
int MX_X_CUBE_AI_Process(void)
{
    int res = -1;
    int class = -1;

    if (network) {
        res = acquire_and_process_data();              /* RGB565 → RGB888 */
        if (res == 0)
            res = ai_run(input_data, output_data);     /* 真正跑 inference */
        if (res == 0)
            class = post_process();                    /* argmax → 类别名 */
    }
    return class;
}

注意 ai_run 的函数签名:

int ai_run(ai_u8 *data_in, ai_float *data_out);

输入是 ai_u8(uint8),输出是 ai_float——这正是 README 里 “Input: uint8 ; Output: Float” 的含义。模型是全整数量化的,但输出端被 X-CUBE-AI 自动反量化回 float,方便我们直接做 argmax。


四、踩过的坑:量化输入的预处理

这是整个项目里我花时间最长的部分。

训练时我们习惯把图像归一化成 float32(([0, 1]) 或 ([-1, 1])),但 uint8 量化模型的输入端要求整型。映射关系如下:

q = c l i p ( r o u n d ( r s ) + z ,   0 ,   255 ) q = \mathrm{clip}\left(\mathrm{round}\left(\frac{r}{s}\right) + z,\ 0,\ 255\right) q=clip(round(sr)+z, 0, 255)

其中 (r) 是实数域的像素值,(s) 是 scale,(z) 是 zero_point。这两个量化参数可以通过 ai_get_input_scale() / ai_get_input_zero_point() 从模型元数据里拿到。

4.1 为什么要查表

如果对每个像素都跑一次浮点 round + 乘除,OV5640 吐出来 128×128 的图就要做上万次浮点运算,光预处理就能把 FPS 拖掉一半

我的优化方案:预计算一张 256 项的查找表pixel_conv_lut[256])。核心就是这个 Precompute_8IntU 函数:

static void Precompute_8IntU(float scale, int32_t zp,
                             float scale_prepro, int32_t zp_prepro)
{
    uint8_t *lut = pixel_conv_lut;

    for (int32_t i = 0; i < 256; i++)
    {
        float tmp = (i - zp_prepro) * scale_prepro;
        *(lut + i) = _CLAMP(zp + _ROUND(tmp * scale, int32_t), 0, 255, uint8_t);
    }
}

逻辑就是:先把原始 0~255 的像素值按 scale_prepro / zp_prepro 做归一化,再按模型需要的 scale / zp 反量化到 uint8 区间,最后 clamp[0, 255]。全程跟 Python 里 np.clip(np.round(...), 0, 255) 等价。

查表建好之后,预处理阶段就是一次 O(1) 的内存查表,每像素一条 LDRB 指令的事儿。这一步直接把推理管线的预处理开销从毫秒级压进了十微秒级。

4.2 量化方案要分情况

X-CUBE-AI 把量化方案分成了三种(在 ai_get_input_quantization_scheme() 返回值里):

switch (ai_get_input_quantization_scheme())
{
    case AI_FXP_Q:
        /* 定点量化(较少用)*/
        // Precompute_8FXP(lut, ai_get_input_quantized_format());
        break;

    case AI_UINT_Q:
        /* ✅ 模型输入量化为 UInt8 类型(本项目走这条) */
        Precompute_8IntU(ai_get_input_scale(), ai_get_input_zero_point(),
                         prepro_scale, prepro_zp);
        break;

    case AI_SINT_Q:
        /* Int8 量化 —— 可参照 ST 官方源码补全 */
        // Precompute_8IntS(lut, ai_get_input_scale(), ai_get_input_zero_point(), ...);
        break;

    default: break;
}

由于这个 MobileNetV2 的 .tflite PTQ 导出默认就是 uint8,我实际走的就是 AI_UINT_Q 分支,另外两个留了 TODO。如果你转的模型是 int8 对称量化,需要自行补全 Precompute_8IntS——可以参照 ST 官方的 FP-AI-VISION1 包里的实现。


五、数据流:RGB565 → 推理 → LCD 显示

摄像头到屏幕再到推理引擎的数据流我设计成这样:

OV5640 ──DCMI+DMA──► Camera_Buffer (RGB565)
                        │
                        ├──► SPI LCD 显示(原分辨率)
                        │
                        └──► ROI 裁剪中心 128×128
                               │
                               ├── RGB565 → RGB888 拆位
                               ├── pixel_conv_lut 查表量化
                               └──► ai_input ──► ai_run()
                                                    │
                                                    └──► post_process ──► LCD 打印结果

5.1 RGB565 到 RGB888 的拆位

这是 acquire_and_process_data() 里的关键片段:

int acquire_and_process_data(void)
{
    /* detect_img_data 是从 Camera_Buffer 中心裁出的 128×128 RGB565 ROI */
    uint16_t *p = (uint16_t *)detect_img_data;

    for (int i = 0; i < img_size * img_size; i++)
    {
        uint16_t pixel = *p++;
        uint8_t r = (pixel >> 11) & 0x1F;   /* 高 5 位 */
        uint8_t g = (pixel >> 5)  & 0x3F;   /* 中 6 位 */
        uint8_t b =  pixel        & 0x1F;   /* 低 5 位 */

        /* 过 LUT 查表量化(对应 pixel_conv_lut[])
         * input_data[i*3    ] = pixel_conv_lut[r];
         * input_data[i*3 + 1] = pixel_conv_lut[g];
         * input_data[i*3 + 2] = pixel_conv_lut[b];
         * (训练时也要相应地在 [0,31] / [0,63] 上做归一化)
         */
        input_data[i * 3]     = r;
        input_data[i * 3 + 1] = g;
        input_data[i * 3 + 2] = b;
    }
    return 0;
}

注意:RGB565 的三个通道量程不一样(R/B 是 5 bit,G 是 6 bit)。如果直接把 r/g/b 当成 0~255 的 RGB888 喂进网络,和训练时的归一化会有偏差,最好是训练时也按这个量程做归一化,或者在 LUT 里把 r << 3 | r >> 2 做一次 5→8 位扩展(具体取舍看你对准确率的敏感度)。

5.2 DCMI + DMA + 自动对焦

几个关键点我在 Core/Src/main.c 里是这样处理的:

SPI_LCD_Init();         // SPI LCD 屏幕初始化
DCMI_OV5640_Init();     // DCMI 以及 OV5640 初始化

OV5640_AF_Download_Firmware();   // 写入自动对焦固件
OV5640_AF_Trigger_Constant();    // 自动对焦持续触发
// OV5640_AF_Trigger_Single();   // 自动对焦单次触发(用不上)

OV5640_DMA_Transmit_Continuous((uint32_t)Camera_Buffer, Display_BufferSize); // 启动 DMA 连续传输

MX_X_CUBE_AI_Init();    // AI 推理引擎初始化

while (1)
{
    if (OV5640_FrameState == 1)  // 采集到了一帧图像
    {
        OV5640_FrameState = 0;
        /* 1. LCD 显示整帧
         * 2. 在屏幕上画 ROI 红框
         * 3. 调 MX_X_CUBE_AI_Process 做推理
         * 4. sprintf 结果到屏幕
         */
    }
}

几个设计选择:

  1. DCMI + DMA 连续模式:让摄像头帧数据直接 DMA 进 SRAM,CPU 在这期间可以去做推理,硬件并行;
  2. ROI 抠图:只取画面中心一个 128×128 的正方形作为网络输入,屏幕上再用 LCD_DrawRect 画一个红框可视化 ROI;
  3. 自动对焦:没有用 AF_Trigger_Single,而是用 AF_Trigger_Constant 让摄像头持续对焦——识别时用户移动物体不会糊,非常实用。

六、后处理:简单的 argmax

网络输出是 15 个类别的 float 概率向量(因为输出层是 Float),后处理就是最朴素的 argmax:

int post_process(void)
{
    float max_conf = -1;
    float tmp = -1;

    for (uint8_t i = 0; i < AI_NETWORK_OUT_1_SIZE; i++)
    {
        if (max_conf < output_data[i])
        {
            tmp = i;
            max_conf = output_data[i];
        }
    }

    sprintf(display_str, "%s",    class_names[(int)tmp]);
    sprintf(conf_str,    "%.2f%%", max_conf * 100);
    return (int)tmp;
}

就是一个标准的"找最大值再查类别名表"的写法。纯粹是我自己采集、自己标的"just for fun"数据集,没啥技术含量,大家可以把 class_names[]AI_NETWORK_OUT_1_SIZE 换成自己的即可。


七、最终表现

  • 测试集准确率: 0.9465
  • 端到端帧率: 9 ~ 12 FPS
  • 单帧端到端延迟: ≈ 80~110 ms(含摄像头采集、预处理、推理、后处理、LCD 刷新)
  • Flash 占用: 567 KB / 1 MB
  • 权重大小: 630 KB(uint8 量化后)

考虑到这是一颗没有 NPU 的通用 MCU,纯靠 CMSIS-NN 的 SIMD 指令把 MobileNetV2 主干跑到两位数 FPS,我个人是满意的。作为对比,STM32 官方 Model Zoo 在 H747(400 MHz)上跑同结构模型差不多是 9 FPS / 110 ms,我这边 H723 主频高一些(550 MHz)所以帧率略好。


八、一点 TinyML 的思考

做完这个项目我最大的感受是:MCU 上跑 CNN 的瓶颈早就不只是推理本身了

一个实时推理系统里,真正吃性能的其实是:

  1. 内存带宽:特征图反复读写,H7 的 AXI SRAM / DTCM 要用好,权重能放 Flash 就放 Flash(MCU Flash 是 0 等待访问);
  2. 预处理开销:量化模型的输入格式转换很容易被忽略,上面讲的 LUT 就是一个典型优化;
  3. 外设 I/O:DCMI、SPI LCD 抢 AXI 总线时要错开推理时机,否则会互相阻塞;
  4. Cache 配置:ICache / DCache 一定要开,别忘了给 DMA 缓冲区做 cache invalidate;
  5. Flash 布局:权重能不能放 DTCM?能不能开 ART Accelerator?链接脚本要不要改?

这些在 GPU / 服务器上根本不是问题的事情,到了 MCU 上每一项都可能让你帧率腰斩。所以 TinyML 本质上还是一门嵌入式系统工程,不是把一个模型转一下就完事。


九、如何自己跑起来

仓库的实际目录结构(main 分支):

STM32-Infer-MobileNetV2/
├── Core/                   # STM32CubeMX 生成的 HAL 代码(主函数、中断等)
├── Drivers/                # STM32H7xx HAL + CMSIS
├── MDK-ARM/                # Keil 工程(.uvprojx 在这里)
├── Middlewares/
│   └── ST/
│       └── AI/             # X-CUBE-AI 运行时库
├── X-CUBE-AI/
│   └── App/
│       ├── app_x-cube-ai.c # 本文重点讲的量化/推理代码
│       └── network*.c/h    # 自动生成的算子与权重
├── STM32_mobilenet.ioc     # STM32CubeMX 工程配置文件
├── .gitignore
└── README.md

复现步骤:

  1. 克隆仓库

    git clone https://github.com/Flora233333/STM32-Infer-MobileNetV2
    
  2. Keil 打开 MDK-ARM/ 下的 .uvprojx 工程

  3. (可选)换你自己训练的模型

    • 用 STM32CubeMX 打开根目录的 STM32_mobilenet.ioc
    • 在 X-CUBE-AI 插件里 Load 你的 .tflite(MobileNetV2)
    • Generate Code,覆盖 X-CUBE-AI/App/network* 这几个文件
    • 修改 app_x-cube-ai.c 里的 class_names[]AI_NETWORK_OUT_1_SIZE 以及预处理参数(prepro_scale / prepro_zp
  4. 编译下载到 H723 开发板,接上 OV5640 + SPI LCD,上电即用。

如果你的板子 Flash 不够 1 MB,可以把 (\alpha) 再调小到 0.25,或者把输入尺寸从 128 降到 96。


写在最后

项目完整代码、Keil 工程、STM32CubeMX .ioc、X-CUBE-AI 生成的算子代码都已经开源到 GitHub:

👉 https://github.com/Flora233333/STM32-Infer-MobileNetV2

也欢迎看 B 站上我发的演示视频(BV1MA4m1V72W),从最终效果一路讲到 Code Review

如果你也在折腾 MCU 上的深度学习部署,欢迎去 Star / Issue / PR,有任何疑问也可以在评论区交流。下一步我打算把检测头(类似YOLO-Nano 那种量级的)也塞进来,做个端上实时目标检测——事实上我已经开源了这个姐妹项目 Deploy-SSD-ON-STM32 (25年初的项目,同样也做了视频)

谢谢大家了! 👋

Logo

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

更多推荐