YOLOv12与STM32嵌入式实战:在STM32F103C8T6上部署轻量级目标检测

最近几年,AI模型越来越“小”,开始往各种小设备里钻。以前觉得在单片机上跑目标检测是天方夜谭,现在随着模型压缩技术和硬件性能的提升,这事儿已经变得可行了。今天咱们就来聊聊,怎么把最新的YOLOv12模型,塞进一块经典的STM32F103C8T6最小系统板里,让它能实时“看”东西。

STM32F103C8T6这块板子,玩嵌入式的朋友应该都很熟,资源就那么点:72MHz主频,20KB RAM,64KB Flash。要在这种条件下跑一个目标检测模型,听起来像是让一辆小轿车去拉集装箱。但别急,通过一系列“瘦身”和“优化”,我们还真能让它跑起来。这背后的价值很大,比如智能门锁可以识别人脸开门,工业流水线上的小设备能自己检测产品瑕疵,不用再把图像数据传到云端,省电、省流量还保护隐私。

这篇文章,我就带你走一遍完整的流程,从拿到一个“大胖子”YOLOv12模型,到把它变成适合STM32的“小瘦子”,再到在板子上跑起来看到检测框。整个过程我会尽量讲得明白,就算你之前没怎么接触过模型部署,也能跟着一步步做出来。

1. 为什么要在STM32上跑YOLO?

你可能要问,有那么多性能更强的开发板,比如树莓派、Jetson Nano,为什么非要跟STM32F103C8T6这块“老古董”较劲?这其实是由实际应用场景决定的。

很多物联网设备,对成本、功耗和体积极其敏感。一个智能门锁,可能就需要7x24小时待机,用树莓派那功耗和成本根本扛不住。一个工业传感器,安装空间可能就指甲盖大小,也塞不进大板子。这时候,STM32这类超低功耗、极致廉价的MCU就成了唯一选择。

在STM32F103C8T6上成功部署YOLO,意味着我们可以把“智能”以极低的成本,部署到海量的终端设备上,实现真正的“边缘智能”。数据不用上传,响应速度更快,也没有网络延迟和隐私泄露的风险。虽然它的检测精度和速度没法跟服务器显卡比,但对于很多特定场景(比如只检测“人”或“某种零件”),已经足够用了。

当然,挑战是巨大的。核心矛盾就是模型庞大的计算量和内存需求,与MCU极其有限的资源之间的矛盾。解决这个矛盾,就是我们接下来要做的全部工作。

2. 模型瘦身第一步:选择与剪枝YOLOv12

YOLO系列模型一直在速度和精度之间寻找平衡。YOLOv12在保持高精度的同时,模型结构也做了优化,但直接拿来用对STM32来说还是太“胖”了。我们的第一步,就是给它“抽脂”。

2.1 选择合适的YOLOv12变体

YOLOv12通常提供不同大小的预训练模型,比如Nano、Small、Medium、Large。对于STM32F103C8T6,我们的起跑线只能是 YOLOv12-Nano 甚至更小的自定义版本。Nano版本已经为嵌入式环境做了初步优化,参数量和计算量相对较小,是我们改造的良好基础。

如果你从官方仓库下载模型,会得到一个.pt的PyTorch模型文件。这个模型是在COCO这种包含80类物体的数据集上训练的,但对于嵌入式场景,我们往往只需要识别少数几类东西。比如智能门锁只认人,工业质检只认某几种缺陷。识别类别越多,模型最后的输出层就越复杂,计算量也越大。

2.2 动手剪枝:移除冗余结构

剪枝,顾名思义,就是像修剪树枝一样,把模型里不重要的连接(权重)去掉。哪些是“不重要”的呢?简单说,就是那些权重值接近零的通道或神经元,它们对最终输出结果贡献微乎其微。

我们通常使用通道剪枝。你可以想象模型每一层都有很多个“过滤器”(通道),有的过滤器勤勤恳恳,提取关键特征;有的则整天“摸鱼”,输出总是零。剪枝就是把这些“摸鱼”的过滤器整个删掉。

这里给一个概念性的代码示例,展示如何使用一些剪枝工具(如Torch-Pruning)来操作:

import torch
import torch_pruning as tp

# 1. 加载预训练的YOLOv12-Nano模型
model = torch.load('yolov12n.pt')['model'].float()

# 2. 构建一个依赖图,分析层与层之间的连接关系
example_inputs = torch.randn(1, 3, 160, 160) # 输入图片尺寸先缩小
DG = tp.DependencyGraph().build_dependency(model, example_inputs=example_inputs)

# 3. 指定要剪枝的层(例如,某些卷积层)
pruning_layers = []
for module in model.modules():
    if isinstance(module, torch.nn.Conv2d):
        pruning_layers.append(module)

# 4. 定义一个剪枝策略:比如按权重的L1范数排序,剪掉最小的30%
pruning_strategy = tp.strategy.L1Strategy()
pruning_plan = DG.get_pruning_plan(pruning_layers[0], tp.prune_conv, idxs=pruning_strategy(pruning_layers[0].weight, amount=0.3))

# 5. 执行剪枝计划
pruning_plan.exec()

# 6. 微调(Fine-tune)剪枝后的模型,恢复性能
# ... 这里需要你用裁剪后的数据集对模型进行少量轮次的重新训练

剪枝之后,模型会变小变快,但精度通常会下降一点。所以一定要用你的特定数据集(比如只包含“人”和“背景”的图片)对剪枝后的模型进行微调,让它重新适应新任务。

3. 模型瘦身第二步:量化与转换

剪枝是从结构上减少参数,量化则是从数值精度上压缩每一个参数。STM32F103通常只支持8位整数(int8)运算,而训练好的模型权重通常是32位浮点数(float32)。量化就是把float32转换成int8。

3.1 训练后量化

这是最常用的方法,对已经训练好的模型直接进行转换。过程包括:

  1. 校准:准备一些代表性数据输入模型,统计每一层激活值的分布范围(比如最大值、最小值)。
  2. 量化:根据统计的范围,将float32的权重和激活值,映射到int8的范围内(-128 到 127)。

量化后的模型,计算时使用整数乘加,速度更快,内存占用直接降为原来的约1/4(32位 -> 8位)。PyTorch和TensorFlow都提供了相应的量化工具。

import torch.quantization

# 假设model是已经剪枝并微调好的模型
model.eval()

# 指定量化配置
model.qconfig = torch.quantization.get_default_qconfig('fbgemm') # 对于ARM,后端可能是'qnnpack'

# 准备模型,插入观察节点,用于校准
torch.quantization.prepare(model, inplace=True)

# 用校准数据集运行模型,收集统计数据
with torch.no_grad():
    for data in calibration_dataloader:
        model(data)

# 执行量化转换
torch.quantization.convert(model, inplace=True)

# 保存量化后的模型
torch.jit.save(torch.jit.script(model), 'yolov12n_quantized.pt')

3.2 转换为STM32可用的格式

量化后的PyTorch模型还不能直接在C语言环境中运行。我们需要把它转换成MCU通用的格式。目前最流行的中间格式是ONNX,然后通过STM32Cube.AI工具链将其转换为STM32的库。

import torch.onnx

# 加载量化后的模型
quantized_model = torch.jit.load('yolov12n_quantized.pt')
quantized_model.eval()

# 定义输入尺寸(根据你的需求调整,越小越快)
dummy_input = torch.randn(1, 3, 160, 160)

# 导出为ONNX格式
torch.onnx.export(quantized_model,
                  dummy_input,
                  "yolov12n_quantized.onnx",
                  opset_version=11,
                  input_names=['input'],
                  output_names=['output'])

得到.onnx文件后,我们就可以打开STM32CubeMX的配套工具STM32Cube.AI,将这个模型导入,工具会自动分析网络层,并生成一系列优化后的C代码文件。这些文件包含了模型的所有权重(已被量化并转换成数组)和推理函数。

4. 硬件与外设配置:让STM32“看得见,显得出”

模型准备好了,接下来得让STM32F103C8T6最小系统板具备图像采集和显示的能力。这里我们用STM32CubeMX进行图形化配置,事半功倍。

4.1 工程创建与基础配置

打开STM32CubeMX,选择STM32F103C8T6型号。首先配置时钟树,将系统时钟(SYSCLK)拉到最高的72MHz,这是性能的基础。然后配置一个调试接口,比如Serial Wire(SWD),方便我们下载程序和调试。

4.2 摄像头接口配置(DCMI)

要接摄像头,我们需要用到STM32的数字摄像头接口。对于OV7670这类常用摄像头模块,配置如下:

  • 引脚分配:在Pinout视图里找到DCMI接口,激活它。软件会自动分配数据线(D0-D7)、像素时钟(PIXCLK)、行同步(HSYNC)、场同步(VSYNC)等引脚。
  • 模式选择:DCMI模式通常选“连续抓取模式”。
  • DMA配置:图像数据量很大,必须用DMA来搬运,不占用CPU。为DCMI数据流添加一个DMA通道,方向设为外设到内存,模式设为循环模式。

4.3 显示接口配置(SPI驱动LCD)

对于小尺寸的TFT LCD屏(比如1.44寸),常用SPI接口驱动。

  • SPI配置:激活一个SPI,模式为主机全双工,设置合适的波特率(别太高,STM32F103的SPI速度有限)。
  • 引脚分配:分配SPI的SCK、MOSI引脚,再手动分配两个GPIO作为LCD的复位(RESET)和命令/数据选择(DC)引脚。
  • FSMC(可选):如果你用的是并口屏,且屏比较大,可能需要配置FSMC接口,但这会占用大量IO引脚,STM32F103C8T6引脚紧张,需谨慎。

4.4 生成工程代码

配置完成后,在Project Manager里设置好工程名、路径和IDE(比如Keil MDK或STM32CubeIDE),然后点击“Generate Code”。CubeMX会生成一个完整的、包含所有外设初始化代码的工程。

5. 集成与推理:在板子上跑通全流程

现在,我们有了STM32的工程,也有了STM32Cube.AI生成的模型代码。接下来就是最关键的“组装”环节。

5.1 整合模型推理库

将STM32Cube.AI生成的network.cnetwork.h以及权重数组文件添加到你的MDK或CubeIDE工程中。在main.c里,你需要:

  1. 包含AI库的头文件。
  2. 初始化AI推理引擎(通常调用一个ai_init()函数)。
  3. 准备一个输入数据缓冲区(对应你模型输入的尺寸,比如160x160x3)。
  4. 准备一个输出数据缓冲区(用于存放检测结果)。

5.2 图像预处理流水线

摄像头采集到的原始图像(比如QVGA: 320x240)需要经过处理才能喂给模型:

  1. 裁剪/缩放:将图像缩放到模型输入尺寸(160x160)。可以在DMA搬运后,用软件算法(如最近邻插值)在内存中完成,但这比较耗时。更高效的方法是在图像传感器端配置输出分辨率,或者使用硬件缩放(如果MCU支持)。
  2. 色彩空间转换:摄像头通常是YUV或RGB格式,模型需要RGB。需要编写转换代码。
  3. 归一化:将像素值从[0, 255]归一化到模型训练时使用的范围,比如[0, 1]或[-1, 1]。由于我们做了量化,这个步骤可能被融合到量化参数中。
  4. 数据排布:将处理好的图像数据,按照模型输入要求的格式(例如RGBRGBRGB...)填充到输入缓冲区。

这部分代码的效率直接影响帧率,是优化的重点。

5.3 执行推理与解析结果

预处理完成后,调用AI引擎的推理函数(如ai_run())。推理结束后,输出缓冲区里是一堆数据。YOLO的输出不是直观的框,而是张量

你需要根据YOLOv12的输出层结构来解析这个张量。通常,它是一个多维数组,包含了所有预设锚框(anchor)的信息:每个框的中心坐标(x, y)、宽高(w, h)、置信度(confidence)以及各个类别的概率。

解析步骤包括:

  1. 过滤低置信度框:设定一个阈值(如0.5),去掉置信度低的预测。
  2. 非极大值抑制:同一个目标可能被多个锚框预测,NMS算法会去掉那些重叠度(IoU)很高的冗余框,只保留最好的一个。
  3. 映射回原图坐标:将模型输入尺寸(160x160)上的框坐标,换算回原始摄像头图像尺寸(320x240)上的坐标。

5.4 绘制与显示

最后,将解析得到的边框坐标和类别标签,通过LCD驱动函数画到屏幕上。你可以画矩形框,也可以把类别文字显示在旁边。至此,一个完整的“采集-处理-推理-显示”的闭环就完成了。

6. 优化技巧与实战经验

在STM32F103C8T6上追求实时性(比如每秒5帧以上),需要榨干每一分性能。这里分享几个关键优化点:

  • 输入尺寸是王道:把模型输入从224x224降到160x160甚至128x128,计算量呈平方级下降,这是提升速度最有效的方法。精度损失在可接受范围内。
  • 利用硬件加速:STM32F103没有AI专用加速器,但可以用DSP指令集。在CubeMX中开启CMSIS-DSP库,用优化后的函数(如arm_math.h中的函数)进行矩阵乘加、激活函数计算,比纯C代码快不少。
  • 内存管理艺术:20KB的RAM寸土寸金。使用静态分配,避免动态内存申请。精心设计缓冲区,让摄像头DMA缓冲区、预处理缓冲区、AI输入输出缓冲区复用同一块内存。
  • 定点数优化:STM32Cube.AI生成的代码已经使用了int8量化。但在自己写的预处理和后处理代码中,也要尽量使用整数运算,避免浮点数。
  • 功耗平衡:在不需要检测的时候,让MCU和摄像头进入低功耗模式,由外部中断唤醒。这对于电池供电的设备至关重要。

实际做下来,在STM32F103C8T6上,运行一个输入为128x128的极度轻量化YOLO模型,处理一帧图像(包含采集、预处理、推理、后处理)大概需要150-200毫秒,能达到5帧左右的“准实时”效果。对于智能门锁这种不需要高速连续检测的场景,已经足够用了。

7. 总结

把YOLOv12部署到STM32F103C8T6上,就像完成一次精密的微雕。整个过程充满了挑战,从模型的剪枝量化“瘦身”,到外设驱动的配置,再到端侧推理流水线的每一毫秒优化,都需要对AI模型和嵌入式系统有双重的理解。

虽然最终的性能无法与高端设备媲美,但它打开了一扇门:让最廉价、最普及的微控制器也拥有了“视觉智能”。这种能力可以赋能无数的低功耗、低成本场景,从安防到农业,从工业到家居。当你看到检测框终于在自己亲手焊接的最小系统板上亮起时,那种成就感是无可替代的。

这条路走通了,你可以尝试更强大的STM32系列(如F4、H7),它们有更多的RAM和更强的算力,能跑更复杂的模型。你也可以探索其他轻量级网络,如MobileNet-SSD、NanoDet等。嵌入式AI的世界才刚刚开始,还有无数可能性等待我们去实现。


获取更多AI镜像

想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。

Logo

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

更多推荐