STM32N6实战:扩展BlazeFace实现多类别人脸属性识别
2026/8/30 7:46:33 网站建设 项目流程

把 Google MediaPipe 里的 BlazeFace 跑上 STM32N6,这件事本身不算难,Cube.AI 里拖一拖、模型转一转,demo 就能动起来。但如果你跟我一样,接到一个看似很合理的需求——“在人脸关键点流水线里再加几个类别”,比如同时输出“是否戴口罩”“是否闭眼”“人脸角度区间”,原来的快乐就会瞬间结束。因为 BlazeFace 原版根本不是一个多类别检测器,它从头到尾只认“人脸”这一类。你需要在训练侧扩展它的检测头,在数据侧重新组织样本,在部署侧处理 STM32N6 NPU 的算子与量化约束,最终把整条 ISP 到检测到关键点解码的 pipeline 串起来,保证新增类别不会拖垮帧率和内存。这篇文章就是我在这个方向上从零到一的完整记录,包含模型结构怎么改、训练怎么分段、ONNX 导出会遇到什么妖、以及最后落板子时那些文档里不会写的问题。适合正在用 STM32 系列做边缘视觉、尤其是想把手头 MediaPipe 模型扩展升级的嵌入式工程师参考。

1. 选型复盘:为什么在 STM32N6 上选择扩展 BlazeFace 而不是换一个网络

1.1 STM32N6 的 NPU 对轻量检测模型的硬约束

STM32N6 是意法半导体第一颗内置神经网络加速单元(他们叫 Neural-ART)的 MCU,标称算力在每秒几千亿次操作级别。听上去不错,但常跑嵌入式模型的人都知道,不能只看算力峰值。它背后有几个非常实际的约束:内部 SRAM 有限,外部 DDR 的带宽和延迟会影响 NPU 和 CPU 之间的数据搬运;NPU 对算子的支持是有列表的,不是所有 ONNX 算子都能加速;int8 量化是常态,而且校准数据选得不好,精度会打骨折。

所以在这个平台上选模型,第一原则不是“精度最高”,而是“吃完 NPU 支持列表里的算子”。BlazeFace 恰好属于这种类型:Google 当初设计它就是给移动端和嵌入式用的,主干里大量使用 3x3 和 5x5 的深度可分离卷积,输出层是 SSD 风格的检测头,没有复杂的 DCN 可变形卷积,没有 Transformer 那套 attention,整体结构非常规整。相比把 YOLOv5n 或者 NanoDet 往 STM32N6 上搬,BlazeFace 在原封不动转到 ONNX 后,能被 NPU 吸收的层比例高很多,后处理也只是简单的 decode + NMS,不会让 Cortex-M55 那边的 CPU 扛太多负担。

不过也要说清楚,BlazeFace 不是万能的。它是一款实时的轻量人脸检测器,如果你新增的类别是“水杯”“手机”“人”这种跟人脸差异很大的物体,完全靠改 BlazeFace 的检测输出去做,效果大概率不理想。因为主干特征是为“人脸”这个特定语义调优出来的,泛化到完全不同的物体时,前面几个 block 的特征表达力不够。所以我在扩展前先明确了一个边界:新类别必须与“人脸”强相关,比如人脸+口罩、人脸+眼镜、人脸+朝向、人脸+表情,这种场景下扩展 BlazeFace 是合理的;跨到通用物体检测,建议换网络,不要硬来。

1.2 在现有流水线上做扩展,成本远低于换模型

另一个让我坚持改 BlazeFace 的原因,是项目里已经存在一条完整的人脸关键点 pipeline:摄像头采集 → ISP 输出 RAW/RGB → ROI 裁剪与缩放 → 模型推理 → 关键点解码 → 后续业务逻辑。这条链路如果一旦换成新模型,意味着 ISP 后的预处理参数要重新调,模型输出的解码逻辑要重写,Cube.AI 工程要重新集成,大量的联调测试要重跑。而扩展类别,本质上是把“人脸检测”升级成“人脸及相关属性检测”,backbone 的特征还能继续用,模型的输入输出尺寸、anchor 分布、整体推理流程都不用推翻,属于在一条稳定的流水线上做局部升级。

其实这里有一个更根本的问题:既然后续只是做人脸关键点,为什么不直接跑一个分类模型来识别口罩/闭眼这些属性?我也这么纠结过。后来发现,单一分类头的方案要额外维护一套“框住人脸 → 丢给分类器”的两级流程,二级分类器在 STM32N6 上可能只占百分之几的算力,看似很轻,但工程上要处理框的时序稳定性问题——如果检测框在前后帧抖动,裁剪出来的人脸区域会忽大忽小,分类器很容易误判。反过来,让检测头本身输出“face”“face_with_mask”这样的多类别,框和属性天然绑定,后期业务拿到的就是一套对齐好的结果。这个思路也符合上游业务方“一条模型链路搞定检测+属性”的预期。

2. 动检测头之前,先理解 BlazeFace 里的“类别”到底存在哪里

2.1 BlazeFace 的结构与 Anchor 体系

BlazeFace 是一个单阶段、基于 anchor 的检测器,它不像 YOLO 那样把每个网格直接预测类别数,而是预先在特征图上铺好一堆固定大小的 anchor 框,然后让模型为每个 anchor 预测两类东西:一是相对 anchor 的坐标偏移(box regression),二是这个 anchor 属于某个类别的置信度(classification),同时它还多输出一组人脸关键点的偏移。原版模型为了极致性能,把类别数设成了 1,也就是只有“人脸”这一类,很多开源实现里甚至直接把这个维度省略掉,只输出一个物体性分数。

但严格来说,SSD 风格的检测头本来就该有“num_classes”这个维度。原版 BlazeFace 的锚点总数我记得是 896 个左右,分布在多个尺度的特征图上。每个 anchor 对应的类别输出,原版是 1 个值,也就是“我像不像人脸”;如果我们要扩展成 3 类,比如背景、人脸、带口罩人脸,那么每个 anchor 的类别输出就必须从 1 变成 3。放到张量维度上就是:原来类别分支的输出 shape 是[batch, 1, num_anchors]或者[batch, num_anchors, 1],扩展后就变成[batch, num_anchors, num_classes]

很多第一次改模型的人会在这一步入坑:为了省事,直接在当前模型最后加一个nn.Linear(1, num_classes)或者把最后一层卷积的输出通道从num_anchors改成num_anchors * num_classes,看似 shape 对上了,但训练时 loss 根本不收敛。原因很简单——你只在输出端改了维度,但 anchor 匹配、正负样本定义、loss 计算甚至后处理 decode 全都还停留在单类别的写法上。SSD 的检测头不只是“最后一层卷积”,而是一整套围绕类别数展开的数据流。

2.2 扩展类别时输出张量怎么改才算规范

以我手头基于 PyTorch 的 BlazeFace 实现为例,修改检测头结构其实只有几行代码,我把它放到一个num_classes参数里,而不是写死:

class BlazeFace(nn.Module): def __init__(self, num_classes=1, num_anchors=896): super().__init__() # backbone 与 box/keypoints 分支保持原样 self.num_classes = num_classes # 类别分支:输出通道数变为 num_anchors * num_classes self.classifier = nn.Conv2d( 96, num_anchors * num_classes, kernel_size=1, stride=1, padding=0 )

但真正麻烦的是损失函数那块。原来每个 anchor 的类别标签是 0/1(背景 or 人脸),现在要改成 one-hot 标签,而且维度要铺到num_anchors * num_classes。在计算 Softmax 或 Sigmoid 交叉熵之前,需要对预测张量做 reshape。这里我建议训练时把 tensors 整理成[batch, num_anchors, num_classes]的布局,这样锚点匹配逻辑、困难样本挖掘逻辑都能复用过去的代码,不用为了兼容两个版本而写两套索引。

另外一个容易被忽略的地方是:新增类别后,NMS 后处理里的类别过滤索引也要动。多数 BlazeFace 的推理脚本里,解码后的检测结果会按“得分大于阈值”去筛框,再按类别 id 做抑制。如果类别数从 1 变成 3,那么后处理必须按类别分别做 NMS,尤其要小心那些跨类别的重复框。比如同一张脸上,模型同时输出了“人脸”和“带口罩人脸”两个高置信度的框,它们高度重叠,如果你做的是全局 NMS,就会把其中一类全干掉。实际项目里我们的做法是:先按类别分组,组内做 NMS,最后合并输出,同时允许业务方指定某些类别组之间可以共存。

2.3 一个容易在需求层面就埋雷的问题:关键点要不要区分类别

BlazeFace 的价值不光是检测框,还在于它能回归 6 个人脸关键点(两只眼睛、鼻子、两个嘴角、下巴,具体布局视版本而定)。扩展类别时,就必须先想清楚:新类别对应的关键点,和原来的“人脸”关键点是不是同一个语义?如果是“带口罩人脸”,模型还必须输出鼻尖吗?这时候鼻尖可能被遮挡,硬让它回归鼻尖坐标,训练时就会产生一个“不可能完成的任务”,模型只能靠瞎猜去拟合,最终把整个关键点头部带坏。

这就要在数据标注和 loss 设计上做选择。比较实用的做法是分两套输出:检测框和类别走一组预测头,6 个关键点走另一组预测头,保持关键点不跟类别绑定,所有类别共用同一套关键点解码方式。如果业务方确实需要“带口罩时不要鼻尖关键点”,那也不应该修改网络结构,而是在后处理层根据类别 id 丢弃对应关键点。这样模型训练的稳定性会高很多,因为你没有强迫网络去为一个语义不稳定的目标做回归。我强烈建议在立项阶段就跟需求方对齐这一点,否则后面训练、量化、部署全跑完了,突然说关键点输出不对,返工成本极高。

3. 数据与标注:新类别的训练素材不能靠“简单补充”

3.1 数据集来源与标注格式的适配

改动检测头之后,最优先要解决的就是训练数据。BlazeFace 官方是用 WIDER FACE 这类大规模人脸数据集训练的,我们做扩展时,原始人脸数据量是够的,关键是新增类别的样本怎么凑。以“带口罩人脸”为例,有公开的 MAFA 数据集可以用,但这些公开数据大多来自互联网图片,摄像头角度、光照、分辨率跟我们的实际场景差距很大。所以我在项目里的策略是:公开数据集打底,自采数据做真实场景校准。自采时把摄像头装在目标场景的典型位置,录制不同时间段、不同光照、不同人脸的样本,然后按帧切图。切出来的图片要覆盖侧脸、低头、抬头、远近变化,不要只采正脸,否则部署后稍微偏一个角度模型就“瞎了”。

标注格式这一步,强烈建议直接转成 COCO 或 VOC 这类通用格式,不要一开始就写脚本生成 BlazeFace 特有的 SSD 二进制格式。因为标注工具、数据清洗脚本、模型训练代码都要在同一个数据集上反复迭代,通用格式生态最成熟,后期加类别、加标注只需要改 JSON 文件。标注字段里除了 bounding box,还要有 landmarks 和 category_id。如果你打算共享关键点,所有类别的 landmark 位置定义必须严格一致,一个类别的关键点标在眼睛上,另一个类别标在额头上,训练时就会打架。

3.2 数据增强:关键点必须跟着框一起变换

检测+关键点任务的数据增强比纯检测要麻烦,因为翻转、旋转、随机裁剪时,关键点坐标必须同步变换。很多人第一次跑通但精度不行的原因就出在这里:图像做了水平翻转,关键点没做镜像,模型等于看了一半的“左右眼坐标反了”的标注,不崩才怪。我代码里翻转部分的逻辑大概是这个样子:

if random.random() < 0.5: img = img[:, ::-1, :] # 水平翻转 bbox[:, [0, 2]] = width - bbox[:, [2, 0]] # 框左右互换 landmarks[:, :, 0] = width - landmarks[:, :, 0] # 所有x坐标镜像 if category in ["face_with_mask", "face"] and has_symmetric_landmarks: # 左右眼、左右嘴角等关键点索引互换 landmarks = landmarks[:, symmetric_index_map, :]

这个映射表必须在项目一开始就定义好,比如symmetric_index_map把左眼球索引和右眼球索引互换。否则后加的类别越多,漏掉对称映射的坑就越深。

其它增强手段我这边按重要性排序大致是:随机裁剪(配合缩放)、亮度/对比度抖动、模糊、HSV 扰动、随机仿射。随机裁剪很关键,它等价于给模型提供更多尺度变化的人脸;亮度抖动在 ISP 管线里等价于模拟不同曝光;模糊则模拟运动场景。但也要注意,在 MCU 上用 int8 量化部署后,如果训练时的颜色空间和数据增强跟部署时的 ISP 输出差异太大,精度会进一步下降。所以增强强度要克制,尤其颜色扰动不要拉得太极端,否则模型学到的“鲁棒性”在板子上根本不存在。

3.3 新类别样本量级怎么估算

具体多少样本算够,没有绝对数字,但可以给一个经验参考。新增一个与“人脸”强相关的类别,我建议至少准备 5000 到 10000 张带标注的图片,其中要保证不同人、不同角度、不同光照的分布。如果连 1000 张都没有,那别急着训练,先解决数据问题。另外一个非常有效的办法是:把新增类别做“合成数据”,把公开的人脸 mask 素材叠加到已有的人脸数据集上,一次性生成几万张“带口罩人脸”。合成数据虽然跟真实场景有 gap,但可以在训练初期让检测头先学会“口罩人脸”的基本特征,然后再加入真实数据微调,收敛速度快很多,最终精度也会比直接硬训好不少。

4. 训练策略:冻结、微调与全量训练的取舍

4.1 不要让模型从零开始理解什么是人脸

扩展训练最大的优势是你手里通常有一份官方或社区的 BlazeFace 预训练权重。这份权重里的 backone 已经学会了非常鲁棒的人脸纹理、轮廓、局部特征表达,这是从零开始训几百个 epoch 都未必能学到的。所以我的第一步是:加载预训练权重,但把类别分支的权重去掉,因为它的输出通道数变了,直接加载会报形状不匹配;接着冻结 backbone 和关键点分支,只训练新类别分支。

这一步的目标很明确:让新类别分支在旧特征空间里先建立“哪些 anchor 是人脸”“哪些 anchor 是口罩人脸”的映射关系。训练个 10 到 20 个 epoch,观察 loss 降下来后,再决定要不要解冻。很多开源代码里在加载权重时会遇到strict=True报错,原因就是类别分支的权重键对不上。处理方式很简单,参考下面的写法:

state_dict = torch.load("blazeface.pth", map_location="cpu") for k in list(state_dict.keys()): if "classifier" in k or "cls_head" in k: del state_dict[k] # 丢弃旧的类别分支 model.load_state_dict(state_dict, strict=False)

这里注意,strict=False会容忍缺失的键,但如果你还有其它写错名字的层,它也会静默通过。建议打印一下missing_keysunexpected_keys,确认只是类别分支被丢弃,而不是整个网络都随机初始化了。

4.2 分阶段训练的具体做法

我的训练节奏大概是这样的:

  • 阶段一(新头热身):冻结 backbone 和关键点分支,只更新类别分支。学习率给 1e-3 左右,batch size 在显存允许范围内尽量大。因为新头输出通道数变多,初始权重又是随机的,梯度会比较大,lr 太高容易把整个检测头带偏。
  • 阶段二(解冻深层):解冻 backbone 的最后两个 block,与类别分支一起微调,学习率降到 1e-4。这一阶段让高层语义特征针对新增类别做适配。关键点分支我通常保持冻结,因为 6 个关键点的特征表达是人脸通用信息,没必要因为新增类别而重新学。
  • 阶段三(可选全局微调):如果阶段二结束后,某个类别的 AP 还是偏低,我会解冻更多层,但学习率继续降一个量级,同时加大正则强度。全局微调不是越多越好的,它对数据量的要求非常严苛,数据不够时容易把预训练学到的好特征冲掉。

整个过程我用的是 AdamW 优化器,weight decay 设到 5e-4。损失函数上,类别分支用 Focal Loss,因为新增类别样本占比通常远低于背景和普通人脸,Focal Loss 的alpha参数可以抑制大量 easy negative 的梯度贡献;框回归分支和关键点分支继续沿用 Smooth L1,关键点分支如果发现大误差点很多,可以换成 Wing Loss,它对小中误差区域的梯度形态更友好,人脸关键点这种任务实测提升明显。

4.3 评估时不要只看整体 mAP

嵌入式模型训练完,我们最关心的往往是部署后的漏检和误检。所以我评估时会单独统计每个类别的 AP、召回率,以及关键点的平均归一化误差。尤其要对比“普通人脸”这个老类别在扩展前后的 AP 变化。如果老类别 AP 掉了,说明新类别样本把原有特征空间挤占了,这时候优先回退到“只训练新头、冻结 backbone”的配置,而不是盲目增加训练轮数。一个很常见的操作是把关键点分支的 loss 权重调小一点,比如从 1.0 调到 0.5,让检测头有更多精力去区分不同类别。

如果你在训练时发现验证集 loss 降不下去,先检查数据标注有没有冲突。我曾在数据清洗时发现一批“带口罩人脸”的标注框居然把整张脸和口罩一起框进去了,而另一批只框了口罩覆盖区域,模型看到同一类目标有两种尺度的标注,置信度始终上不去。

5. 部署链路:从 PyTorch 到 STM32N6 NPU 的关键一跃

5.1 ONNX 导出:留哪些算子给 CPU,留哪些给 NPU

训练完成后的第一件事是把 PyTorch 模型导出为 ONNX。导出本身不难,但“能导出”和“能被 STM32N6 的 NPU 加速”是两码事。我的经验是:导出时固定输入分辨率,不要把 dynamic_axes 打开。STM32N6 这种嵌入式目标,输入尺寸固定是常态,动态 shape 会引入一堆非定值算子,Cube.AI 工具链解析起来非常痛苦,稍不留神就把推理速度拖下来。

dummy_input = torch.randn(1, 3, 128, 128) torch.onnx.export( model, dummy_input, "blazeface_extended.onnx", opset_version=16, input_names=["input"], output_names=["bbox", "landmark", "cls"], dynamic_axes=None # 固定shape,不要开动态 )

导出后我一般会先用 onnxruntime 跑一遍,对比 PyTorch 输出,误差在 1e-4 量级就算合格。之后扔进 STM32Cube.AI 的转换工具,它会报告哪些算子被分配到 NPU,哪些算子落到了 CPU。像 anchor 解码、NMS 这类后处理,我根本不会放进 ONNX 模型里,它们涉及大量动态 shape、循环和排序操作,NPU 不支持,硬塞进去只会被丢到 CPU 上跑,还不如直接在 STM32 工程里手写。简单说:模型只负责“从图像到原始预测张量”,其余 decode 和 NMS 全在 MCU 端用 C 实现。

5.2 INT8 量化:校准集必须包含新类别样本

STM32N6 的 NPU 主要跑 int8,所以量化这一环绕不开。STM32Cube.AI 工具链支持训练后量化(PTQ)和量化感知训练(QAT)。我在这个项目里优先尝试 PTQ,因为它不需要重新训练,流程最短。但 PTQ 的校准集选择直接影响精度,很多人在这一步吃了大亏:校准集全用普通人脸,量化出来的模型对新增的“口罩人脸”类别预测结果严重漂移。因为量化缩放因子是根据激活值的统计分布确定的,如果校准数据里没出现过口罩人脸这一类样本,某个中间层的激活范围就可能被估小了,int8 表示时数值溢出或截断,类别分支直接输出一堆乱码。

我的做法是把校准集按类别分布均匀采样,每类至少 200 张,总共 1000 张左右,覆盖不同光照和角度。校准过程同时观察每一层输出的 min/max,如果发现某个层的动态范围异常大,比如因为背景杂乱或高光人脸导致极端激活值,可以考虑对输入做归一化限制,或者在训练时加强数据增强让中间层输出更稳定。还有一个偷懒但有效的办法:如果 PTQ 后某些类别 AP 掉得厉害,直接上 QAT。QAT 会模拟量化噪声训练几个 epoch,虽然工程上多了几步,但综合成本往往比反复调校准集低。

5.3 部署到 STM32N6:内存、DMA 与安全区那些事

模型转换完成后,最终要嵌进 STM32N6 的工程里。这一步我个人最大的体感是:模型代码只是整个工程的一小部分,真正花时间去调的是内存布局和数据搬运。STM32N6 上存在“应用安全区”和“应用非安全区”的概念,这是硬件层面的 TrustZone 隔离。NPU 推理用的输入输出缓冲、DMA 搬运的内存、以及供 Cortex-M55 做后处理的临时 buffer,分配在哪个区、能否被 NPU 直接访问、会不会触发总线错误,这些都要在工程初始化阶段就理顺。我之前犯过的一个错是把模型输出的显存缓冲区放在了安全区,结果非安全区的后处理代码一访问就触发 fault,排查了大半天才定位到是内存归属的问题。所以部署时第一件事就是画清楚整条链路上每一块 buffer 的归属、大小、生命周期。

输入侧还要关注 ISP pipeline 的配合。STM32N6 的 ISP 输出格式、分辨率、对齐方式跟模型要求的输入不完全一致,需要在 CPU 端或硬件加速器上做一次格式转换和缩放。这里有一个常见优化:如果 ISP 能直接输出模型输入分辨率,比如 128x128 或 192x192 的 RGB 图,就不需要额外做一次完整图像的缩放,省掉的 CPU 周期非常可观。如果 ISP 输出分辨率很高,那至少要在裁剪 ROI 之后再缩放,不要全图先缩一遍再做检测。

后处理侧的速度优化也很关键。在 STM32N6 上,我建议把 anchor 解码、边界框反算、关键点反算全部用定点运算,不要用浮点,否则 Cortex-M55 跑起来占用太高。anchor 相关的 stride、中心点坐标、先验宽高都可以预先算成定点查表,甚至直接在工程里生成常量数组。NMS 里大量用到的排序,可以用插入排序或计数排序这类简单算法,数据集类别少(比如 3 类)、anchor 候选也不多,排序开销完全可控。

6. 实测数据与优化技巧:扩展后的帧率、内存与稳定性

6.1 一组有参考价值的性能数字

先给一组我在类似硬件配置下跑出来的实测数据,配置不同会有差异,但量级可以帮你心里有个底。模型输入分辨率是 128x128,anchor 总数保持原版 896,类别数从 1 增加到 3,关键点仍然是 6 个。模型 INT8 量化后大小大概在 600 到 800 KB 之间。STM32N6 跑到 30 FPS 左右是没问题的;如果类别变成 5 个,输出通道数继续增加,但推理时间增加有限,瓶颈反而容易卡在 CPU 端后处理上。RAM 峰值占用一般在 1.5 MB 到 3 MB 之间,主要取决于 NPU 中间 buffer 的大小和外部 DDR 的分配策略。

配置输入分辨率类别数模型大小推理耗时峰值RAM
原版 BlazeFace128x1281~450 KB~15 ms~1.2 MB
扩展 3 类128x1283~600 KB~17 ms~1.5 MB
扩展 5 类128x1285~750 KB~19 ms~2 MB

类别扩展后推理耗时增加并不多,因为计算量主要来自 backbone 的卷积层,检测头的参数量在整个模型里占比很小。真正需要警惕的是内存——每多一个类别,类别分支输出的张量就多num_anchors * 4个字节(int8),看起来不多,但如果后处理把类别得分先转成 float,再存一个副本,内存就会悄悄膨胀。我的建议是解码时直接用 int8 存分数,只在比较阈值时临时转 float,不要把所有输出一次性转成浮点数组。

6.2 后处理里最容易出错的两个细节

第一个坑是 anchor 解码时用错变量类型。ONNX 导出的模型输出的是“相对 anchor 的偏移量”,不是最终坐标。偏移量要跟 anchor 的中心点、宽高一起反算,反算公式我之前写错过一次,把exp(w)写成exp(h),结果框全部变成竖长条,压测时才发现。强烈建议在 PC 端先用 Python 把解码逻辑写成脚本,跑通后把同样的定点逻辑搬到 STM32C。

第二个坑是多类别 NMS 的阈值设置。原版人脸检测的置信度阈值通常可以设到 0.5 甚至 0.6,因为“人脸”类别的训练样本非常充足,置信度分布很尖锐。但新增类别样本少,置信度普遍偏低,如果沿用同一个阈值,新类别的召回率会很难看。我最后的做法是给每个类别单独配置阈值,比如普通人脸 0.5、带口罩人脸 0.4,并在模型部署配置里做成可调参数。后期现场调优时,不需要重新编译模型,只改阈值配置就能快速平衡误检和漏检。

6.3 如果帧率仍然不够,从这三个方向动手

扩展完类别后如果发现帧率掉了,普遍原因不是模型变大了,而是 pipeline 的其它环节被拖垮。按我排障的经验,优先级是:先查 CPU 端的图像预处理,再查后处理实现,最后才怀疑 NPU 推理时间。一个很典型的现象是模型只用了 15 ms,但 ISP 出来的 1080p 图在 CPU 上缩放、颜色转换花掉 40 ms,帧率直接崩到 15 FPS。优化手段包括:

  • 让 ISP 直接输出带 ROI 的裁剪图,减少缩放面积;
  • 格式转换尽量做“两步缓存”,不要让 CPU 一边读 DDR 一边写 DDR,能走 DMA 就走 DMA;
  • 颜色空间转换用查表法或 Ne10 这类 DSP 加速库;
  • 后处理解码用定点运算,NMS 用简化排序,避免在 C 代码里写std::sort这种重函数。

另外一个方向是减小输入分辨率。128x128 降到 96x96,NPU 推理时间能省 30% 以上,代价是检测小目标能力下降。如果实际场景中人脸距离摄像头不远、尺寸比较大,这个取舍很划算。用 96x96 输入配合 ISP 的 ROI 裁剪,很多场景下帧率能直接从 20 FPS 提到 30 FPS 以上。

6.4 不要迷信“调快阈值”能解决现场问题

最后说一个经验教训。模型部署完,现场测试发现误检偏多,我们一开始的方法是调高阈值,效果立竿见影,当时还觉得挺聪明。结果到第二天阴天环境,光照一变,大量“带口罩人脸”直接漏检,业务方拿着对照视频找过来,才发现把阈值调高只是在“掩盖特征区分度不足”的问题。真正有效的做法是回到训练侧,补充阴天、逆光、暗光场景的数据,再微调几个 epoch;阈值只在最后做微调,不能把宝全押在阈值上。这也是嵌入式和纯 PC 视觉一个很大的不同:现场环境变化会让训练时没见过的分布直接暴露出来,能提前做的场景模拟一定要提前做,省得后面擦屁股。

如果你也要做类似的扩展,我最大的建议是:别把“增加类别”当成一次简单的输出层改动,从数据、训练、量化到后处理,每一步都要围绕新增类别重新验证。把老类别的水位稳住,把新类别的数据做大做杂,把部署后的性能预算提前算清楚,这条路就能走通。尤其是数据这关,很多人觉得检测头改改就行,实际上最后决定成败的,大概率不是网络或量化,而是你手里的标注数据到底覆盖了多宽的真实场景。

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

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

立即咨询