☰
STM32N6实战:BlazeFace从单类别扩展为多类别人脸检测的部署经验
2026/10/13 13:07:37 网站建设 项目流程

最近在 STM32N6 上做一个人脸关键点 pipeline,遇到一个特别典型的改造需求:现有流程里 BlazeFace 只负责检测人脸框和做关键点定位,但业务方又提了新玩法——要能区分“戴了口罩的人脸”和“没戴口罩的人脸”,甚至后面可能还要加“戴眼镜”“戴帽子”这类属性类别。这实际上就是要把 BlazeFace 从单类别检测器扩展成多类别检测器,同时保住原有的 Face Landmark 回归分支,最终还得跑进 STM32N6 的板子里,在整个 pipeline 里不拖帧率。

这篇文章不聊虚的,就围绕这条改造路线,把我在数据准备、网络修改、量化部署、板端集成时实际踩过的坑和验证过的做法整理出来。适合正在做嵌入式人脸识别、MCU+NPU 端侧视觉的工程师看,也适合想把手头单类别检测模型扩成多类别但不确定从哪下手的同学。文章里的思路不绑定特定框架,PyTorch、Keras 都能用。

1. 先想清楚:这个改造的本质是把“检测器”变成“检测+分类器”

1.1 先复盘一下现有 pipeline 的组成

我手里这套 STM32N6 Face Landmark Pipeline 的核心链路很标准:

  1. 图像从摄像头进来,做预处理(缩放、归一化、通道调整)。
  2. 送入 BlazeFace 检测人脸框,同时回归一组人脸关键点。
  3. 根据人脸框做裁剪或对齐,再进后续关键点精修或识别模块。

BlazeFace 本质上是一个 SSD 思路的单阶段检测器,骨干网络用 Depthwise Separable Convolution 和 BlazeBlock 做轻量化设计,输出头同时出三样东西:anchor 对应的目标类别得分、bounding box 回归量、关键点坐标回归量。

在默认实现里,类别得分那一路基本上只有一个目标类别“face”,所以分类头的输出维度是 1(或者背景+人脸=2)。而扩展类别的本质,就是把分类头的输出维度从 1 改成 N,然后告诉网络“你现在要认出的不只是人脸,而是戴了口罩的人脸、没戴口罩的人脸、戴眼镜的人脸……”这些类别之间可以有交集,但在当前项目里我们按互斥类别来处理。

这里有个容易搞混的点:所谓“扩展额外类别”,不是让 BlazeFace 去识别“人脸框之外的其他物体”,而是在已有的 人脸框 基础上细化“这到底是一张什么状态的人脸”。所以 anchor 生成方式、box 回归分支、landmark 分支完全不用动,真正动的是 class head。

1.2 为什么这一类改造放在 STM32N6 上做有意义

STM32N6 和传统跑 Linux 的边缘盒子不同,它是一颗 MCU,M55 内核,自带 NPU 加速器。好处是启动快、成本低、功耗低、没有操作系统调度开销,适合做闸机、门禁、低功耗视觉传感器之类产品。

但坏处也明显:内存和 Flash 有限,NPU 对算子的支持有限,模型转换过程中稍不注意某个算子不支持,就会被拉到 CPU 上跑,帧率立刻崩。所以模型扩展类别后,能不能在 NPU 上高效推理,是比“精度涨一点”更优先要考虑的问题。

也正是因为 STM32N6 的 NPU 算力相对有限,BlazeFace 这种参数只有几十万到一两百万的轻量网络才有机会跑实时。如果你拿 ResNet50 之类去做多人脸检测,数据量还没吃进去,内存就先报警了。

1.3 改造前先评估的三件事

动手改网络之前,建议先回答三个问题,否则后面会陷入反复返工:

  1. 类别之间是互斥还是可有交集?比如“戴口罩”和“戴眼镜”完全可以同时存在,但 SSD 的 softmax 分类头默认是互斥的,如果你要支持多属性并存,得改成 multi-label 训练,或者为每个属性单独接一组 sigmoid 输出。
  2. 新增类别是否需要对应的关键点语义?比如戴口罩的人脸,下巴区域的关键点其实是不准确的,那么 landmark 分支对这部分样本需要特殊处理,要么在训练时降低这部分关键点的 loss 权重,要么标注时就把不可见点忽略掉。
  3. 板端是否需要实时切换类别白名单?如果只是做固定场景下的口罩检测,类别固定就行;如果需要动态加载不同任务的模型,就要考虑 Flash 布局和 NPU 上下文切换。

我这次的项目里,业务方一开始只说要区分“face”和“masked face”,我按互斥类别做;后来又要加“sunglasses face”,好在分类头维度是一次性扩好的,改起来不费劲。这就是为什么我建议你哪怕现在只扩一个类别,也直接按未来 3~5 类的容量来设计 head,省得后面反复重新训练。

2. 数据准备:类别扩展最花时间的往往不是代码,而是标注

2.1 类别标签怎么定,直接决定数据采集成本

别急着找训练脚本,先把类别规范定清楚。以口罩为例,我最终定为三类:

类别ID类别名语义
0background背景,非目标
1face无遮挡常规人脸,关键点完整可见
2masked_face面部下半部分被口罩遮挡,关键点最多只能看到眼部区域

这里没有加“全家福”“半遮挡”之类模糊类别,因为模糊类别会让数据标注人员崩溃,也让模型学得稀里糊涂。遮挡程度如果不一致,宁可归为 face 然后靠关键点分支自己处理,也不要单独学一个不伦不类的类。

另外,如果你确实要做多属性共存,比如“戴眼镜的口罩脸”,个人建议别急着加联合标签,第一版先把“口罩”和“眼镜”拆成两条任务,或者直接让分类头走多个二分类分支,否则数据量和标注成本会成倍增长。

2.2 数据来源与标注规范

数据来源我的优先级排序是:

  1. 自有业务场景拍摄。最贴合最终部署环境,尤其是摄像头安装角度、人员动线、光照特点。
  2. 公开人脸数据集做迁移。比如一些带口罩标注的人脸检测数据集,或者通用人脸识别数据集里筛一份出来。
  3. 爬虫或众包补充。适合做长尾场景,但要注意隐私合规,人脸数据不是闹着玩的。

标注格式我建议直接用 Pascal VOC 或者 COCO 的格式存下来,因为你后面转 TFRecord 或 YOLO 格式都方便,网上工具也多。每个目标框需要标:class_id、xmin、ymin、xmax、ymax。如果你还想顺带训练 landmark,那就得把 6 个关键点坐标也标出来(左眼、右眼、鼻子、嘴左角、嘴右角、下颌或者你自己的业务点位)。

这块工作量很大,我实际经验是:一个新类别最少要准备 5000 到 10000 张带目标样本,其中“正样本(目标框)”数量最好不低于 2 万,否则后面类别不均衡问题会很麻烦。

2.3 数据增强:别让小样本类别的增强策略太粗暴

BlazeFace 是轻量模型,数据增强很重要,尤其是新增类别样本少的时候。我这里用过并且有效的增强组合:

  • 几何增强:随机旋转 ±20°、随机缩放 0.8~1.2、随机平移、水平翻转。注意翻转后如果有文字类属性要处理,人脸场景基本不受影响。
  • 光照和颜色增强:亮度随机抖动、对比度调整、HSV 扰动、高斯噪声。因为很多嵌入式摄像头画质一般,光照增强必须给足。
  • 遮挡模拟:对 face 类样本,随机画一些黑色块来模拟局部遮挡,可以帮助模型减少对局部特征的过度依赖。
  • Mosaic 增强:把多张图拼接成一张,对提升小目标检测效果很明显。不过 BlazeFace 输入分辨率只有 128x128 或 256x256,拼接后目标更小,要小心别把目标拼得看不清。

一个特别容易踩的坑:新增类别的样本往往拍摄场景单一,比如只在室内光源下,模型一拿到户外逆光的画面就露馅。所以我建议对新增类别做半离线增强,比如把一部分样本背景换掉、加了随机色块、做了强光照变换,相当于用离线方式模拟场景多样性。

2.4 类别不均衡怎么处理

新增类别刚起步时,样本量肯定远少于已有的 face 类。我试过两种有效做法:

第一,在线采样时按类别做 loss 加权或者 hard negative mining。具体来说,背景框数量极大,如果全部参与反向传播,模型会被“全是背景”带偏。BlazeFace 本身有 anchor matching 策略,默认会生成很多负样本,所以我在训练时专门做了 hard negative mining,只取分类 loss 最高的那部分负样本参与训练,负正比控制在 3:1 左右。

第二,对小众类别用 Focal Loss。如果你把分类头改成多类别输出,直接用 softmax cross-entropy,小样本类别容易学不动。Focal Loss 的 gamma 我一般取 1.5~2.0,alpha 按类别样本比例的倒数来配,实测对小类别召回率提升比较明显。

3. 网络结构修改与训练策略:不是所有层都需要重训

3.1 BlazeFace 的结构回顾,以及到底改哪里

BlazeFace 的网络结构大致可以分成三段:

  1. 输入层:接受 128x128x3 或 256x256x3 的输入。
  2. Backbone:由多个 BlazeBlock 堆叠,提取特征。
  3. 检测头:在多个尺度的特征图上做预测,每个 anchor 输出几类信息。

在标准实现里,检测头通常长这样:

  • box_heads:每个 anchor 输出 4 个 box 坐标偏移值。
  • class_heads:每个 anchor 输出类别得分。
  • landmark_heads:每个 anchor 输出 6~16 个关键点坐标,具体看你用的版本。

所以扩展类别的核心动作是:把 class_heads 的输出维度从 [num_anchors, 1] 改成 [num_anchors, num_classes]。其他 head 不动。

有一种更省事的做法是直接在 class_head 旁边加一个很小的属性分类分支,比如把原来的检测头先不碰,额外加一个“face_attributes”分支专门输出是否戴口罩、是否戴眼镜。这种设计的好处是不影响原始 BlazeFace 的检测能力,坏处是推理多了一次额外计算,而且 attribute 分支的特征可能没有和检测框对齐。

我最终选择的是直接扩展 class_head,理由很简单:部署在 STM32N6 上,模型输入输出越简单越好,多一个输出头就多一次后处理逻辑,板端代码也更复杂。

3.2 训练策略:全量微调还是冻结 backbone

改完网络结构后,第一个问题就是:到底要不要冻结 backbone 只训练新 head?

我的经验分阶段:

  • 第一阶段:冻结 backbone,只训练新初始化的 class head,跑 20~30 个 epoch。目的是让新分类头先学会用 backbone 提的特征做判断,避免 backbone 刚初始化时梯度抖动影响全局。
  • 第二阶段:解冻全部网络,用较低学习率微调全模型,跑 50~100 个 epoch。这个阶段模型会重新适配新增类别,同时微调检测框和关键点回归。

学习率方面,第一阶段用 1e-3 量级,第二阶段降为 1e-4 到 5e-5。优化器选择上,AdamW 比较稳,不要一上来就用很高学习率的 SGD,否则新增类别几乎学不动。

输入尺寸:我用的 128x128x3,和原版 BlazeFace 保持一致。如果你希望小目标检测更好,可以上 256x256,但推理时间会明显增加,STM32N6 帧率大概率吃不住,先想清楚指标再决定。

3.3 关键点分支在新类别样本上的处理

人脸关键点检测和新增类别之间的关系,应该是“互不拖累”。我处理的方式:

  • 对 masked_face 样本,把下半脸那几个关键点的 loss 权重直接置为 0,也就是让模型不关注这些不可见点。
  • 对无遮挡 face 样本,正常计算全部关键点 loss。

具体实现时,可以给每个关键点配置一个 visibility mask。标注数据里,每个关键点除了坐标外,再标一个 0/1 可见性标签。loss 计算时,只对可见点计算距离损失,不可见点跳过。

这样处理之后,模型在训练阶段不需要强行“猜”口罩下面的嘴在哪,关键点分支的精度不会再被新类别拖累。

3.4 量化感知训练:从第一天就要考虑

STM32N6 的 NPU 对高精度浮点支持的效率不如 INT8 量化,所以模型最终上板基本都要走 INT8 量化。很多人是先训练 FP32 模型,再一口气转量化,结果精度掉得惨不忍睹。我的建议是:训练一开始就考虑量化问题。

做法是导入量化感知训练(QAT)。具体操作是,在训练流程中模拟 8-bit 量化带来的精度损失,比如在 activation 和 weight 后插入 fake quantize 节点,这样模型在训练时就学会适应量化噪声,转换后精度损失能控制在 1%~3% 以内。

如果训练框架不支持 QAT,退而求其次的做法是:先正常训练一个高精度 FP32 模型,转换时准备一个覆盖新增类别各种光照、角度、遮挡场景的校准数据集,大概几百到一千张图,统计每层激活值范围,再用 PTQ 做 INT8 量化。

我实际跑了多个模型,结论是:QAT 模型转 INT8 之后,分类 mAP 平均损失大概在 1% 左右;PTQ 模型平均损失可能在 3%~5%,而且小类别更容易翻车。如果你要上产线,强烈建议上 QAT。

4. STM32N6 部署与 pipeline 集成细节:模型导入只是刚开始

4.1 模型导出格式与转换链

从我常用的路线给你一个可复现的转换链:

  1. PyTorch 或 Keras 训练好模型后,先导出 ONNX。
  2. 用 ONNX 转 TFLite,或者直接用 ST Edge AI Core 导入 ONNX。
  3. 做 INT8 量化,生成量化模型。
  4. 在 STM32CubeMX 里集成 ST Edge AI Core(也就是以前的 X-CUBE-AI),生成为 STM32N6 优化的 C 代码工程。

这里有个很容易踩的坑:ONNX 里有很多算子默认不支持 NPU 加速,比如某些自定义的 Resize 模式、动态 shape、一些高级激活函数。如果 NPU 不支持的算子太多,ST Edge AI Core 会自动把这些层放到 CPU 上执行,结果就是模型占用内存不高,但帧率根本提不上来。

我的做法是:导出 ONNX 之前,先手动检查网络里有没有这些“高危算子”:

  • Resize / Upsample:能不用就不用,或者改成 nearest 模式。
  • 复杂的激活函数,比如 SiLU / GELU,STM32N6 的 NPU 不一定支持,最好换成 ReLU / ReLU6。
  • Split / Concat 如果 tensor 维度变化太多,可能被拆到 CPU,能合并的层尽量合并。
  • 动态 shape 必须全部去掉,模型输入输出必须是静态尺寸。

你可以在 PC 端先用 ST Edge AI Core 的基准测试工具跑一遍,看看每层被分配到 NPU 还是 CPU。哪层落到 CPU 了,优先把那层优化掉,这会比整天调训练参数更影响最终效果。

4.2 板端 pipeline 的内存与预处理设计

STM32N6 这类 MCU,内存管理不是“够用就行”,而是“要显式规划”。我的经验是分四块:

  1. 输入图像缓冲区:建议放在 AXI SRAM,方便 NPU 和摄像头 DMA 直接访问。
  2. 模型权重区:通常放在外部 Flash,加载时按需换入内部 RAM,或者直接内存映射。
  3. 中间激活值区:NPU 推理时需要一块很大的临时 buffer,这块要预留充足,否则编译时可能会因为内存不足直接报错。
  4. 后处理结果区:存放检测框、关键点、类别得分。

预处理也要格外注意。BlazeFace 在训练时一般做了归一化,比如值域缩放到 [-1, 1] 或 [0, 1]。到了板端,摄像头输出的通常是 RGB888 或者 YUV422,你需要先做格式转换再缩放,同时把像素值缩放到和训练一致的范围内。

我之前犯过一个低级错误:PC 端用 ImageNet 的 mean/std 做了归一化,板端却忘了做同样的归一化,导致模型在板端准确率崩掉。后来我把归一化操作融合到输入图像的量化参数里,也就是在转换时把 scale/zero_point 调整好,让板端只要做简单的像素格式转换和缩放,归一化由 NPU 的输入算子自己搞定,避免在 MCU 上做浮点除法。

4.3 输出后处理:NMS、类别过滤、关键点坐标换算

模型跑完,板端拿到的是一堆 anchor 的原始输出,需要做后处理。我在板端实现的后处理流程是:

  1. 读取分类得分,按 score_threshold(一般取 0.5~0.6)过滤低分 anchor。
  2. 对保留下来的 box 做解码,转换为图像坐标。
  3. 做类别维度的 NMS。注意 NMS 要按类别分别做,否则不同类别之间可能互相误抑制。
  4. 输出最终的人脸框、类别 ID、类别得分、关键点坐标。

之后才进入你原有的 Face Landmark 精修流程。也就是说,pipeline 的“前一半”是目标检测和分类,“后一半”才是针对检测框的 landmark 定位。

如果帧率有压力,我建议把 NMS 的 IoU 阈值适当调大一点,默认 0.5 可以调到 0.6,减少候选框数量,能省一点 CPU 时间。另外,NMS 在 MCU 上建议用平方距离代替开方 IoU 计算,能降低不少浮点开销。

4.4 实际部署性能数据参考

这里说一个我在板上的实测感受:以 128x128 输入、单类别扩展成 3 类(背景、人脸、口罩人脸),STM32N6 上用 NPU 跑一次推理耗时大约在十几到几十毫秒量级,具体数值和模型算子优化程度强相关。如果后处理没有做额外的手写优化,整条 pipeline 大致能跑 10~30 FPS。如果你把输入提升到 256x256,推理耗时可能会翻倍,这个账要提前算好。

项目上一般建议先把“检测+分类”跑通一个闭环,再考虑上关键点精修,因为关键点精修往往还包含一个小的 CNN,它同样要吃 NPU/CPU 时间。

5. 实操中最容易翻车的五个问题

5.1 新增类别总是被误判为原有类别

这个几乎必然出现,尤其是新增类别样本不够的时候。解决办法除了加数据,我建议把类别阈值单独调。做法是:训练完成后,在验证集上统计每个类别的 PR 曲线,给“容易混”的类别单独设一个更高的置信度阈值。比如 face 类用 0.5,masked_face 类用 0.65,这样可以减少误报。

5.2 量化后某一类精度断崖下降

这是 PTQ 的经典问题。我的排查路径是:

  1. 先确认是不是校准数据集没有覆盖这一类。校准集里要有大量该类别的样本,否则量化统计的激活范围就不对。
  2. 检查网络里有没有对数值敏感的层,比如大通道数的 depthwise conv,这类层量化误差容易被放大,可以考虑在转换工具中对该层做“per-channel 量化”或“保持浮点”。
  3. 如果无法通过调校准集解决,就得回炉用 QAT 重训。

5.3 板端推理时间突然拉长

先用 ST Edge AI Core 的 profiling 工具看各层耗时。如果某些算子落到了 CPU,推理时间一定会暴涨。常见触发点是 Resize 和 Concat 结构堆得太多。另一个隐藏坑是内存带宽限制,如果中间 buffer 放在内存带宽不够的区域,NPU 会频繁等待数据搬移。我最后把 buffer 尽量放在 AXISRAM,同时注意对齐,带宽改善比较明显。

5.4 关键点在口罩人脸样本上飘

前面说过,靠 visibility mask 解决。但如果你的模型是从公开预训练权重迁移过来的,而预训练模型从来没见过口罩样本,那么即便设了 mask,backbone 的特征也未必能适应遮挡区域。建议在数据增强阶段给 face 样本的嘴巴区域叠加一个黑块模拟口罩,然后这部分的 landmark loss 同样做 mask,效果很好。

5.5 模型在 STM32N6 上能跑,但一接摄像头就花屏或者掉帧

这种情况多半是 DMA 中断和 NPU 推理的并行处理没做好。建议摄像头采集用双 buffer 切换,当前帧在 NPU 推理时,下一帧通过 DMA 持续写入另一块 buffer,这样帧率才能上去。不要等整帧推理完再开始采集下一帧。

6. 部署后性能调优的一点心得

最后给准备动手的同学一个建议:扩类别的训练工作,不要想着“一次训完直接上板”。我通常先训练一个小模型,只验证分类头能不能在 NPU 上跑到低延迟,再回来扩数据、调参。这种“板端验证先行”的策略,能帮你节省大量时间。

另外,在板端调试阶段,建议打印每一帧的检测结果,包括类别、得分、box 坐标、关键点坐标,并和上位机跑同一张图的结果做对比。如果两边数值差异较大,优先排查预处理归一化是否一致,其次是量化精度损失。

这个项目后续还可以继续扩展的方向,一是给不同类别定制不同的关键点返回策略,比如口罩人脸只返回眼睛区域关键点,直接减少无效计算;二是在 STM32N6 上叠加一个轻量分类器对检测框区域做二次确认,来进一步提升属性类别的准确率。总之,BlazeFace 这套网络结构本身很适合做嵌入式多类别人脸检测,关键是每个环节的数据和部署细节都要抠到位。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询