【开源】在 STM32H7 上实时推理 SSD 目标检测网络

📁 项目已开源: Flora233333/Deploy-SSD-ON-STM32
📺 B 站演示视频: (开源+教程)在STM32上实时推理SSD目标检测网络🧐
硬件: STM32H743 + OV5640 摄像头 + SPI LCD
关键词:X -CUBE-AI、SSD、MobileNetV1、uint8 量化、DMA2D、stm32ai-modelzoo、后处理


写在前面:

这是上一篇 MobileNetV2 分类文章的姐妹篇

上次尝试了 MCU 上跑"图像分类"是可行的,这次我想 在一块单片机上跑一个轻量的 SSD 目标检测网络 ,而且要接上摄像头做实时推理

这个项目是我 2025 年初折腾的,做完在 B 站发了视频之后打算写一篇简单的介绍文,拖到现在才动笔 🤪

烦请多多担待啦!

如果帮助到您的话,可以给我点一个 Star⭐吗!

经常能看到这样的问题:

  • “MCU 真的能跑目标检测吗?分类都嫌费劲了。”
  • “后处理那么多浮点,MCU 处理速度如何?”

作为一个当时天天在各种 MCU 上打比赛折腾的研究生大学生,我想尝试一下
于是就有了这个项目——在一块 STM32H750 上,跑一个 SSD 目标检测网络,并且接上摄像头做实时推理。

最终的效果是这样的:摄像头对着检测目标,屏幕上实时框出目标位置并打印单次推理延迟。测试集 mAP 31.8%,单帧端到端 ≈ 240 ms(约 4-5 FPS),其中推理本体约 235 ms。


一、硬件选型

这次项目用的 MCU 是 STM32H750XBH6

参数 数值
内核 Cortex-M7 + FPU + DSP
主频 480 MHz
Flash (ROM) 2 MB
RAM 1 MB(含 DTCM / AXI SRAM)
图形加速 Chrom-ART(DMA2D)✅
AI 加速 无专用 NPU

选它的理由相比上一次更明确:

1. 目标检测的权重 + 算子代码比分类大得多,必须要有 2 MB Flash

2. DMA2D 这次不是可选项,是必选项(后面会说为啥)

3. 手头只有这个😋

H750XBH6 片上虽然只标 128 KB Flash,但实际它和 H743XI 是同一片硅,有完整的 2 MB Flash,ST 只是为了产品分级好像是没对多出来的Flash进行检测,但性价比很高。

外设搭配(和上一篇几乎一致):

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

相比上一篇分类项目,这次外设清单里多了一个 DMA2D——因为目标检测的预处理要三通道并行搬运,LUT 查表不再是最优解,后面会详细讲。


二、模型选型:为什么选 SSD,不选 YOLO?

做 MCU 端目标检测,绕不开的第一个问题是:选什么网络

我相信 99% 的人第一反应都是 YOLO。但如果你真的去翻 YOLOv3 以后的后处理代码——grid cell、多尺度 anchor、Sigmoid / Exp 解码、再加 NMS,全是浮点和分支。X-CUBE-AI 的算子支持又有限,很多东西得自己在 C 里重写一遍,光后处理就能把人写到怀疑人生

SSD(Single Shot MultiBox Detector)结构要"钝"得多,也正因为钝,它对 MCU 极度友好。

我用的是 ST 官方的 st_ssd_mobilenet_v1(就在 stm32ai-modelzoo 里),它的三个输出头排布非常规整:

输出 Shape 含义
cls (1, 3830, 2) 每个 anchor 的两类置信度(背景 + 目标)
boxes (1, 3830, 4) 每个 anchor 的回归偏移 ((\Delta x, \Delta y, \Delta w, \Delta h))
anchors (1, 3830, 4) 每个 anchor 的先验框 ((x_{min}, y_{min}, x_{max}, y_{max}))

SSD 的解码公式也简单到可以一行写完:

x p r e d = x a n c h o r , m i n + Δ x ⋅ ( x a n c h o r , m a x − x a n c h o r , m i n ) x_{pred} = x_{anchor,min} + \Delta x \cdot (x_{anchor,max} - x_{anchor,min}) xpred=xanchor,min+Δx(xanchor,maxxanchor,min)

score i = σ ( cls_logit i ) \text{score}_i = \sigma(\text{cls\_logit}_i) scorei=σ(cls_logiti)

最终选的模型规格:

  • backbone:MobileNetV1(纯卷积,量化友好)
  • Input shape😦(192, 192, 3)),RGB888
  • 输入端:uint8(和上期分类模型一致,方便预处理)
  • 输出端:float(方便直接做解码 + NMS)
  • Total params:434,814
  • Total layers:174

经验之谈:MCU 上部署目标检测,模型有没有"量化友好"非常关键。YOLO 里的 Focus、SiLU 这些对 uint8 量化其实不太友好;SSD + MobileNetV1 量化到 uint8 后 mAP 掉得不算离谱,非常适合端侧。另外 ST 官方 modelzoo 里的 st_ssd_mobilenet_v1 已经给你做了不少端侧裁剪,直接用比从零改网络省事太多。

2.1 训练配置

训练基于 ST 官方的 stm32ai-modelzoo 仓库改的,object_detection/src/user_config.yaml 配好就能一键跑:

general:
  project_name: card_project
  model_type: st_ssd_mobilenet_v1
  gpu_memory_limit: 4
  global_seed: 42

operation_mode: chain_tqe        # training → quantization → evaluation

dataset:
  name: card
  class_names: [ card ]
  training_path: ./dataset/train
  validation_path: ./dataset/valid
  quantization_split: 0.2

preprocessing:
  rescaling: { scale: 1, offset: 0 }   # 这里为了部署方便不做归一化
  resizing:
    aspect_ratio: fit
    interpolation: nearest
  color_mode: rgb

data_augmentation:
  rotation: 30
  shearing: 15
  translation: 0.1
  vertical_flip: 0.5
  horizontal_flip: 0.2
  gaussian_blur: 3.0
  linear_contrast: [ 0.75, 1.5 ]

几个非常重要的点:

rescaling 保持 scale: 1, offset: 0。训练时如果你做了 / 127.5 - 1 这种归一化,部署时 MCU 端每一帧都得重复这个操作,白白多掉十几毫秒。直接让模型吃 uint8 原始像素,训练侧为部署让路——嵌入式场景里,这是铁律。

② 数据集必须是 YOLO 格式:一张图配一个 .txt,每行 class cx cy w h(全部归一化到 ([0, 1]))。

operation_mode: chain_tqe 是 modelzoo 的链式模式,一口气做完训练 → 量化 → 评估,量化后的 tflite 直接可以丢进 X-CUBE-AI,不用再手动转一遍。对新手非常友好。

其他超参:

  • 框架:Keras
  • 优化器:Adam,学习率 (1 \times 10^{-3})
  • 训练轮次:500 epochs
  • 数据集:自己采集的扑克牌图像,约两三千张

单卡 3060 跑两小时收敛,loss 从 110 一路降到个位数。

启动训练就一行:

cd object_detection/src
python stm32ai_main.py

2.2 模型转换链路

整条端到端链路:

.h5   (Keras) → modelzoo chain_tqe .tflite   (PTQ   uint8) → X-CUBE-AI C   source   +   weights \texttt{.h5 (Keras)} \xrightarrow{\text{modelzoo chain\_tqe}} \texttt{.tflite (PTQ uint8)} \xrightarrow{\text{X-CUBE-AI}} \texttt{C source + weights} .h5 (Keras)modelzoo chain_tqe .tflite (PTQ uint8)X-CUBE-AI C source + weights
这里有一个和上一篇分类项目共通的小决策——输入端 uint8 量化、但输出端保留 Float。原因很简单:SSD 的解码需要精确的浮点坐标,如果连输出都量化,边框坐标会掉得很惨。这个"输入量化 / 输出浮点"的搭配模式,基本可以套到 90% 的端侧视觉模型上。


三、工具链: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(const ai_handle *act_addr)
{
    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);

    /* 把分配好的 input / output buffer 指针挂给句柄 */
#if defined(AI_NETWORK_INPUTS_IN_ACTIVATIONS)
    for (int idx = 0; idx < AI_NETWORK_IN_NUM; idx++)
        data_ins[idx] = ai_input[idx].data;
#else
    for (int idx = 0; idx < AI_NETWORK_IN_NUM; idx++)
        ai_input[idx].data = data_ins[idx];
#endif
    for (int idx = 0; idx < AI_NETWORK_OUT_NUM; idx++)
        data_outs[idx] = ai_output[idx].data;

    return 0;
}

/* 2. 主推理流程:采集 → 推理 → 后处理 → 画框 */
void MX_X_CUBE_AI_Process(void)
{
    int res = -1;
    if (network) {
        res = acquire_and_process_data(data_ins);   /* 摄像头 → 192×192 uint8 */
        if (res == 0) res = ai_run();                /* 真正跑 inference */
        if (res == 0) {
            res = post_process(data_outs);           /* 解码 + NMS */
            if (res > 0) {
                /* RGB888 → RGB565 硬件搬运,准备送显 */
                Dma2d_Memcpy_PFC((uint32_t *)data_ins[0],
                                 (uint32_t *)Camera_Buffer,
                                 0, 0, 192, 192,
                                 DMA2D_INPUT_RGB888,
                                 DMA2D_OUTPUT_RGB565);
                /* 画框 */
                for (int i = 0; i < res; i++) {
                    int x_min  = target[i].x_min;
                    int y_min  = target[i].y_min;
                    int width  = target[i].x_max - target[i].x_min;
                    int height = target[i].y_max - target[i].y_min;
                    _LCD_DrawRect(x_min, y_min, width, height,
                                  IMG_INPUT_WIDTH, (uint16_t *)Camera_Buffer);
                }
            }
        }
    }
}

相比上一篇分类模型,MX_X_CUBE_AI_Process 多做了两件事:
① 解码 3830 个 anchor 并做 NMS;② 用 DMA2D 把 RGB888 转 RGB565 再送显。这两件事就是这篇文章剩下要讲的全部内容。


四、踩过的坑:后处理

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

推理出来的三个 tensor 你不能直接拿来用,必须走一遍标准的 SSD 后处理:

  1. 遍历 3830 个 anchor,按置信度阈值过滤;
  2. 按解码公式把 ( Δ x , Δ y , Δ w , Δ h \Delta x, \Delta y, \Delta w, \Delta h Δx,Δy,Δw,Δh) 映射回 ([0, 1]) 区间的框;
  3. 乘回 IMG_INPUT_WIDTH = 192 得到像素坐标;
  4. NMS 去重。

关键代码:

int post_process(ai_i8 *data[])
{
    float *scores  = (float *)data[0];
    float *boxes   = (float *)data[1];
    float *anchors = (float *)data[2];

    int detection_count = 0;

    for (int i = 0; i < AI_OBJDETECT_SSD_ST_PP_TOTAL_DETECTIONS; i++) {
        float class1_conf = scores[i * 2 + 1];
        if (class1_conf < AI_OBJDETECT_SSD_ST_PP_CONF_THRESHOLD)
            continue;
        if (detection_count >= AI_OBJDETECT_SSD_ST_PP_MAX_PORCESS_LIMIT)
            break;

        float xmin = boxes[i * 4 + 0];
        float ymin = boxes[i * 4 + 1];
        float xmax = boxes[i * 4 + 2];
        float ymax = boxes[i * 4 + 3];

        float anchor_x_min = anchors[i * 4 + 0];
        float anchor_y_min = anchors[i * 4 + 1];
        float anchor_x_max = anchors[i * 4 + 2];
        float anchor_y_max = anchors[i * 4 + 3];

        int _xmin = (int)((anchor_x_min + xmin * (anchor_x_max - anchor_x_min)) * IMG_INPUT_WIDTH);
        int _ymin = (int)((anchor_y_min + ymin * (anchor_y_max - anchor_y_min)) * IMG_INPUT_HEIGHT);
        int _xmax = (int)((anchor_x_min + xmax * (anchor_x_max - anchor_x_min)) * IMG_INPUT_WIDTH);
        int _ymax = (int)((anchor_y_min + ymax * (anchor_y_max - anchor_y_min)) * IMG_INPUT_HEIGHT);

        /* push 到待 NMS 列表 */
        detections[detection_count].x_min = _xmin;
        /* ...存 ymin / xmax / ymax / conf... */
        detection_count++;
    }

    return nms(detections, detection_count);
}

这段代码看起来平平无奇,但里面埋了两个让我调了整整两天的坑

4.1 第一口坑:栈溢出

我一开始把候选框数组开成 int selected[3830],直接扔在函数栈上。

STM32 默认栈才 4 KB,3830 × 4 = 15 KB,板子直接 HardFault

改法很简单:静态全局 + 限定 MAX_PORCESS_LIMIT = 10。这也是为什么你在代码里会看到 AI_OBJDETECT_SSD_ST_PP_MAX_PORCESS_LIMIT 这个宏——砍候选框本质上不是为了 NMS 效率,是为了让内存活下来

经验之谈:MCU 上写代码,任何 > 1 KB 的数组都要仔细想一下放哪里。优先级大致是:DTCM(最快但最小)> AXI SRAM(大) > 外部 SDRAM。一个很容易踩的误区是"我把它声明成 static 就万事大吉"——static 也会吃 BSS 段的 RAM,链接脚本没配好照样爆。

4.2 第二口坑:坐标系

SSD anchor 坐标是 ([0, 1]) 归一化的,乘回 IMG_INPUT_WIDTH = 192 才能画到 LCD 上。

但我的 LCD 是 240×320 的,直接画 192×192 的框就会定位到屏幕一角,看起来像是模型坏了。我当时排查了半天推理结果,最后才发现是显示坐标系没对齐😅 后来加了一次居中偏移才解决。

经验之谈:只要不是 LCD 分辨率和模型输入 1:1 的场景,先在屏幕上画个十字线标定坐标系,再去调框。能省你一晚上的排查时间。别问我怎么知道的。


五、预处理这次换个思路:DMA2D 直接搬

上一篇分类模型里,我用了一个 256 项的 LUT(pixel_conv_lut[256])来做 RGB565 → RGB888 + 量化,那套方案在单通道上非常优雅。

这次目标检测 SSD 要保留三通道原始结构,LUT 做起来就很别扭——你得分别给 R / G / B 建三张表,还得考虑 RGB565 三通道位宽不一致的问题(R/B 是 5 bit,G 是 6 bit)。

这时候就该 DMA2D 登场了。

整个图像通路大致长这样:

OV5640 ──DCMI+DMA──► Camera_Buffer (RGB565)
                          │
                          ├─► SPI LCD 原分辨率显示
                          │
                          └─► DMA2D_Memcpy_PFC (RGB565 ⇄ RGB888)
                                    │
                                    ├─► ai_input[0] (uint8 × 192 × 192 × 3)
                                    └─► ai_run()
                                             │
                                             └─► post_process ─► _LCD_DrawRect

5.1 DMA2D 加速图像处理

STM32H7 自带的 DMA2D 是一个专门做 2D 图像搬运 + 格式转换的硬件加速器,完全不走 CPU。一次调用就能把一整块 RGB565 转成 RGB888:

Dma2d_Memcpy_PFC(
    (uint32_t *)data_ins[0],     // 目标:ai_input (uint8 RGB888 排布)
    (uint32_t *)Camera_Buffer,   // 源:  Camera_Buffer (RGB565)
    0, 0, 192, 192,              // 裁剪区域 + 输出尺寸
    DMA2D_INPUT_RGB888,
    DMA2D_OUTPUT_RGB565
);

PFC = Pixel Format Conversion,一次调用搞定裁剪 + 格式转换 + 搬运三件事,并且 CPU 在这期间可以去做别的(比如画框或者刷屏)。省下来的时间直接加到帧率上。

这是 H7 相对于其他 MCU 最大的甜点,强烈建议所有做 MCU 视觉的人都把 DMA2D 用起来。

5.2 主循环长这样

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

OV5640_AF_Download_Firmware();
OV5640_AF_Trigger_Constant(); // 让摄像头持续自动对焦
MX_X_CUBE_AI_Init();          // AI 推理引擎初始化

while (1)
{
    if (OV5640_FrameState == 1)  // 采集到了新一帧图像
    {
        OV5640_FrameState = 0;
        MX_X_CUBE_AI_Process();
        /* 内部依次做:
         * 1. 摄像头帧 → 192×192 uint8
         * 2. ai_run() 推理
         * 3. 解码 + NMS
         * 4. DMA2D 转 RGB565
         * 5. LCD 刷新 + 画框
         * 6. 屏幕角落打印 "235 ms"
         */
    }
}

和上一篇一样,AF_Trigger_Constant 的持续对焦让识别时用户随便晃动物体也不会糊,非常实用。


六、最终表现

  • 测试集 mAP:31.8%
  • 单次推理(ai_run 本体):≈ 235 ms
  • 单帧端到端延迟:≈ 240 ms(含采集、预处理、推理、NMS、LCD 刷新)
  • 端到端帧率:≈ 4 FPS
  • Flash 占用:约 1.2 MB / 2 MB(得益于 H750 的"隐藏 Flash")

上一篇分类模型我做到了 12 FPS,这次检测掉到 4 FPS——看起来慢了三倍,但做的事情完全不是一个级别的:3830 个 anchor 的后处理 + 两次 DMA2D 搬运 + 画框显示,已经是 MCU 能做到的相当可观的边界了。

做完项目有人说我:“240 ms 一帧,这也叫实时?”

我觉得关键在于这是 没有任何 NPU 加速的纯 Cortex-M7 软件推理

对 ADAS、无人机自主避障,这个延迟当然不够;但对于大部分"识别到目标就触发一次动作"的场景——电表上的数字识别、循迹小车的靶标检测、电赛题目里的卡片识别、农业传感器里的害虫检测、玩具的物体识别——大概也许还是能用下吧…


七、一点 TinyML 的思考(再进一步)

如果说分类模型的瓶颈分布是 [70% 推理, 20% 预处理, 10% 其他],那目标检测的瓶颈分布会直接摊平:

  1. 内存带宽:三个输出 head 合起来几十 KB 浮点,必须分配到 AXI SRAM;
  2. 像素格式转换:RGB565 → RGB888,CPU 查表和 DMA2D 硬件搬运差 10 ms 起步;
  3. 后处理:3830 个 anchor 遍历 + NMS,这块就能吃掉 5 ~ 10 ms;
  4. Cache 配置:ICache / DCache 一定要开,但 DMA 缓冲区要做 SCB_CleanInvalidateDCache_by_Addr,否则画面就花了(这个坑我也踩过);
  5. Flash 布局:权重放内部 Flash 还是 QSPI 外接 Flash?后者可能让推理慢 30%。

这些在 GPU / 服务器上根本不是问题的事情,到了 MCU 上每一项都可能让你帧率腰斩。

所以还是那句话——TinyML 本质上是一门嵌入式系统工程,不是把一个模型转一下就完事。真正能让你帧率翻一倍的,往往不是换一个更小的模型,而是把 DMA2D 用起来、把 Cache 配对、把 buffer 放对位置


八、如何自己跑起来

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

Deploy-SSD-ON-STM32/
├── model/                         # .tflite 模型文件
│   ├── st_ssd_mbv_for_card.tflite
│   └── uint8-float_mobilenetV2-0.35_acc0.972.tflite  # 姐妹项目的分类模型
├── deployment on mcu/             # Keil 工程,X-CUBE-AI 已集成
│   └── STM32H743/                 # H743 / H750 均可烧录
│       ├── MDK-ARM/               # Keil 工程 (.uvprojx 在这里)
│       ├── Core/                  # STM32CubeMX 生成的 HAL 代码
│       ├── Drivers/               # STM32H7xx HAL + CMSIS
│       └── X-CUBE-AI/
│           └── App/
│               ├── app_x-cube-ai.c    # 本文重点讲的推理 + 后处理代码
│               └── network*.c/h      # 自动生成的算子与权重
├── train/                         # 基于 stm32ai-modelzoo 的训练配置
│   └── object_detection/
│       └── src/
│           ├── user_config.yaml
│           └── stm32ai_main.py
└── README.md

复现步骤:

  1. Clone 仓库;
  2. Keil 打开 deployment on mcu/STM32H743/MDK-ARM/ 下的 .uvprojx 工程,直接烧录可以看到检测效果;

硬件门槛:一块 H750 / H743 核心板 + OV5640 摄像头 + 2.4 寸 SPI 屏


写在最后

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

希望能给我点一个 Star⭐!

也欢迎看 B 站上我发的演示视频,从最终效果一路讲到训练脚本的 Code Review:

如果你也在折腾 MCU 上的深度学习部署,欢迎来提 Issue / PR,也欢迎在评论区交流!

谢谢大家了! 🙌

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐