1. 为什么人脸检测模型要做到1MB:边缘设备的现实约束
1.1 模型体积不只是存储问题,更是运行效率问题
很多人第一次看到Ultra-Light-Fast-Generic-Face-Detector这个模型名时,会盯着1MB这个体积指标反复确认:这么小的网络,真的能做人脸检测吗?我一开始也有同样的疑问,等到真正在边缘设备上跑过一轮之后,才理解“小”本身就是这类模型最大的竞争力。
移动端和嵌入式端的人脸检测场景,和服务器上的场景完全是两码事。服务器上你可以随意叠加ResNet、FPN、Transformer这些重组件,反正有GPU扛着;但到了手机、IPC摄像头、门禁面板、儿童手表这类设备上,你面对的往往是几百MHz到一两GHz的CPU、几十到几百MB可用内存、以及一个非常有限的电池容量。模型超过10MB,光是加载和常驻内存这一项,就可能让整个App的启动时间多出一两百毫秒,后台还被系统频繁回收。
模型体积还会直接影响推理延迟。哪怕是结构完全相同的两个网络,参数量翻倍,前向计算中访存开销也会跟着翻倍。而在CPU上做推理时,访存往往比乘加运算更贵。1MB模型最直接的好处是:权重能整体装入CPU缓存,甚至可以在部分场景下做到“一次读入、全程使用”,这比让权重反复在主内存和缓存之间搬运要快得多。所以1MB不是一个噱头,而是边缘推理场景里实打实的性能需求。
1.2 Ultra-Light、Fast、Generic分别指向哪些约束
拆开这个模型名,三个关键词其实对应了三条不同的设计主线。
Ultra-Light指的是参数量和模型体积被压到极致。整个网络不会出现超过1024通道的特征层,也不会堆叠几十个重复模块。它追求的是在“能跑”和“能检测”之间找到最小可用规模,而不是在精度排行榜上争名次。
Fast强调的是检测一张图所花的时间。除了层数少、特征图小之外,结构上尽量采用容易并行、缓存友好的算子,比如把3x3标准卷积替换成深度可分离卷积,把大量1x1卷积放到前面提前降维,减少后续计算量。
Generic则意味着不挑人脸角度、不挑光照、不要求正脸。它要覆盖门禁、安防、拍照辅助、视频通话等通用场景。这要求网络在训练时能适应多尺度人脸,在结构上采用多尺度特征图而不是单层输出。
如果你正准备在树莓派、手机端或某个SoC上做人脸检测,这个网络的结构非常值得拆开来看。它的每一层设计几乎都是围绕“小、快、稳”三个字做的取舍,理解了这些取舍,你以后设计其他轻量级模型也能直接复用这套思路。
2. 总体设计:单阶段SSD思路与轻量主干的搭配
2.1 从输入到输出,数据是如何流转的
整个网络走的是一条非常典型的单阶段检测流水线。输入一张图片,经过预处理统一缩放到固定尺寸,送入主干网络提取特征。主干网络在多个下采样阶段引出几组不同分辨率的特征图,这些特征图分别接到检测头上。检测头不做什么区域建议、不做两阶段筛选,直接在特征图的每个网格位置上预测“有没有人脸”以及“人脸框偏移量”。最后把多个特征图上的预测结果汇总,经过NMS去掉重叠框,得到最终的人脸位置。
整个结构里没有ROI Pooling,没有RPN,没有复杂的动态路由。所有路径都是固定写死的静态计算图,这对模型转换和部署非常友好。我见过不少工程团队因为模型里有动态shape或者自定义算子,卡在模型转换环节好几天,而这个网络的设计初衷就是尽量避开这些坑,每一层都能映射到移动端框架里最基础的算子。
2.2 为什么选择SSD式架构而不是YOLO或两阶段方案
两阶段方案先提候选框再逐个分类回归,精度确实有优势,但对轻量级模型来说,区域提议网络带来的额外计算量是致命的。而且两阶段方案后处理复杂,部署到嵌入式环境时要额外写不少C++代码,调试起来也麻烦。
YOLO系列虽然也是单阶段,但它为了保证检测精度,默认输入分辨率往往比较高,主干也偏重。对小体积模型而言,YOLO那种直接划分网格的做法在特征融合上不够灵活,针对小而密集的人脸场景反而不如SSD的多层特征图策略顺手。
SSD的思路更契合轻量脸检。它的核心是在不同stride的特征图上分别放置不同尺寸的先验框,这样小脸可以交给高分辨率低层特征图,大脸交给低分辨率高层特征图,天然覆盖了“通用”场景对尺度变化的要求。整个检测头只由少量卷积组成,输出张量形状固定,不管是从PyTorch转ONNX还是再转到各种端侧推理框架,都非常省心。
2.3 主干网络选型:以深度可分离卷积为核心的定制变体
主干网络没有直接照搬完整版MobileNet,而是做一个大幅裁剪和重构的定制变体。完整MobileNetV2有十几个瓶颈层,宽度系数1.0时大约几百MB计算量,模型体积也有几MB到十几MB,用在人脸这种只需要二分类加框回归的任务上属于杀鸡用牛刀。
人脸的类别粒度很粗——就“人脸”和“非人脸”两类,而且结构特征相对固定:有眼睛、鼻子、嘴巴的分布规律,不需要像ImageNet分类那样区分一千个细粒度类别。因此主干网络可以做得非常窄、非常浅。我复现过的常见配置里,整个主干只有五六个瓶颈阶段,最大通道数控制在64到128之间,分类分支输出的是单个logit而不是1000维向量,这才能把总模型压到1MB附近。
2.4 一个快速理解体积的数学直觉
用一个具体数字直观感受一下。普通3x3卷积在输入输出都是64通道时的参数量是 3×3×64×64 = 36864,约36K。而深度可分离卷积拆成深度卷积和逐点卷积两步,参数量是 3×3×64 + 1×1×64×64,也就是576+4096=4672,只有原来的八分之一左右。
如果整个网络中大量使用深度可分离卷积,1MB的体积摊下来其实就是“几层窄通道特征图 + 少量连接权重”的必然结果。后面拆解每一层时,你会反复看到这种“省参数量”的思路贯穿始终。
3. 主干网络逐层拆解:深度可分离卷积与瓶颈结构的实际组成
3.1 入口卷积:快速降低分辨率并为后续铺路
网络的第一层通常是一个普通的3x3标准卷积,步长为2,输出通道数从3扩到16或24左右。这里没有立刻使用深度可分离卷积是有原因的:输入图像只有RGB三个通道,深度可分离卷积在这种极低通道数的场景下无法体现省参数量的优势,反而会因为两个步骤拆开增加额外开销。
入口卷积之后,特征图分辨率直接减半。比如输入是320x240,经过这一层就变成160x120。这一步非常关键,因为整个模型的计算量近似等于“通道数 × 分辨率”,在小分辨率下跑后续瓶颈层的成本会低很多。很多初学者以为轻量模型就是把每层通道调小,其实第一步是否有策略地降分辨率,对FLOPs的影响远超调窄通道。
3.2 瓶颈块:深度卷积与逐点卷积的分工逻辑
进入主干中段后,网络由若干瓶颈块堆叠而成。每个瓶颈块的基本结构是:先做1x1卷积扩张通道,再做3x3深度卷积,最后用1x1卷积压缩回较小的通道数,同时在输入输出之间加上残差连接。
1x1卷积在这里扮演了两个角色。第一是改变通道数,让深度卷积在更高维空间里提取特征,增加表达能力;第二是充当一个廉价的全连接层,在不同通道之间做信息融合。而3x3深度卷积则是真正在空间维度上滑动提取局部纹理的步骤,它只在单通道内部做卷积,不跨通道,所以参数量极小。
为什么不直接用3x3标准卷积堆叠?前面算过体积账,深度可分离卷积的省参能力在这里被放大得最明显。假设瓶颈块内部通道是64,输入输出通道都是32,标准卷积的参数量是9×32×64=18432,换成深度可分离结构就变成9×64+1×1×32×64=2624,体积差出7倍。这个省下来的空间就是模型能做到1MB的关键。
3.3 通道数与下采样策略:一张表看清各阶段
不同开源实现里,主干各阶段的通道数会有一些微调。下面是我在某次复现实验中使用过的常见配置,按输入尺寸320x240、stride为2逐层下采样来看:
| 阶段 | 操作类型 | 输出尺寸 | 输出通道 | 步长 |
|---|---|---|---|---|
| 入口 | 3x3标准卷积 | 160x120 | 24 | 2 |
| Block1 | 瓶颈块 | 80x60 | 32 | 2 |
| Block2 | 瓶颈块 | 80x60 | 32 | 1 |
| Block3 | 瓶颈块 | 40x30 | 64 | 2 |
| Block4 | 瓶颈块 | 40x30 | 64 | 1 |
| Block5 | 瓶颈块 | 20x15 | 128 | 2 |
这套配置里,深层通道并没有像大模型那样一路翻到256、512,最大只到128。这是刻意为之的:人脸检测本质上是个粗粒度定位任务,不需要很高的语义抽象能力,反而需要保留足够的空间位置信息,通道数过大会让边缘设备上的访存压力急剧上升。
3.4 一种简化的结构示意
如果用伪代码来表示主干网络的核心结构,大致长这样:
def bottleneck(x, expand_ratio=3, out_channels=32, stride=1): hidden = round(out_channels * expand_ratio) identity = x # 1x1 卷积扩张通道 x = conv1x1_bn_relu(x, hidden) # 3x3 深度卷积,可降分辨率 x = depthwise_conv3x3_bn_relu(x, stride) # 1x1 卷积压缩通道 x = conv1x1_bn(x, out_channels) if stride == 1 and identity.shape == x.shape: x = x + identity # 残差连接 return x这段代码里最容易被忽视的是残差连接的判断条件。只有stride=1且输入输出通道一致时才启用残差,否则直接跳过。原因是stride=2时特征图尺寸变化了,无法直接相加;而通道不一致时如果硬加,要么补零要么加额外卷积,都会引入不必要的计算成本。这个细节在实际移植到端侧框架时建议保持原样,别为了省事强行统一结构。
4. 检测头与多尺度特征图:把特征图变成人脸框
4.1 为什么要从不同stride引出多组特征
主干网络在Block3、Block4、Block5之后分别输出stride=8、stride=16、stride=32的三组特征图。以输入320x240为例,这三组特征图的尺寸是40x30、20x15、10x8。
低层高分辨率特征图保留了更多边缘、纹理、五官细节,对检测小尺寸人脸至关重要。如果只用最后一层stride=32的特征图,一张20x20像素的小脸在特征图上可能只剩1到2个像素点,根本没法定位。反过来,高层低分辨率特征图语义信息更丰富,能应对大脸和被遮挡的人脸。多尺度结构让检测器在同一个模型里同时照顾小脸和大脸,这就是Generic一词在结构上的具体体现。
4.2 先验框设计:每层放多少框、什么比例
每层特征图的每个网格位置放置若干先验框。设stride=s的特征图大小为H×W,则网格位置数是H×W。每个位置通常放2个先验框,比例取1:1和1:1.5左右的长方形框。先验框的实际尺寸与该层的stride对应:stride=8层负责16到32像素的小脸,stride=16层负责32到64像素的中脸,stride=32层负责64像素以上的大脸。
计算一下总先验框数量。stride=8层:40×30×2=2400个;stride=16层:20×15×2=600个;stride=32层:10×8×2=160个。合计3160个先验框。这个数量比一般目标检测模型动辄几万到几十万个anchor要少得多,这也是速度快的来源之一——回归和分类只需要在3160个框上进行。
4.3 输出张量的形状推算
检测头输出的内容分三块:人脸置信度、边框偏移量、关键点坐标。以当前设计为例,每个先验框输出1个人脸logit、4个边框偏移量、10个关键点坐标,总共15个数值。如果把它拆成三个独立分支,输出张量分别是:
| 分支 | 作用于每组特征图的输出shape | 含义 |
|---|---|---|
| 分类 | [batch, 3160, 1] | 人脸置信度logit |
| 框回归 | [batch, 3160, 4] | 中心点偏移和宽高缩放 |
| 关键点 | [batch, 3160, 10] | 5个点各自的x/y |
其中分类分支只输出1个logit,这是因为人脸检测是二分类,没必要用softmax输出两个维度。把输出压缩成1维还能减少最后的decode逻辑,降低端侧实现难度。如果你拿到某个预训练模型的输出层维度略有不同,大概率是把二分类做成了2维softmax,原理一样但转换到端侧时要多写一行softmax,问题不大。
4.4 坐标解码:从偏移量还原成真实框
网络预测的不是绝对框坐标,而是相对先验框的偏移量。解码公式如下:
cx = anchor_cx + dx * anchor_w cy = anchor_cy + dy * anchor_h w = anchor_w * exp(dw) h = anchor_h * exp(dh)dx、dy是中心点偏移的归一化值,dw、dh是宽高缩放的对数值。训练时网络会更稳定地回归这些相对量,而不是直接预测绝对像素值。解码完成后,把三组特征图的结果合并在一起,按置信度分数从高到低排序,再做NMS过滤重叠框。
我建议在真正部署时,把解码和NMS写成一体化的后处理函数,不要分成多步中间张量,否则在边缘设备上每多一个临时张量,就多一次内存分配和拷贝,延迟会明显增加。
5. 关键点回归分支与损失函数:多任务如何平衡
5.1 五个人脸关键点检测头
除了框住人脸,这个网络还设计了一个输出五个关键点(左眼、右眼、鼻尖、左嘴角、右嘴角)的分支。关键点的输出单元仍然是1x1卷积,通道数是关键点数量乘2。
关键点分支的意义不只是画几个点好看。它能在框检测的基础上提供人脸对齐能力,输出稳定的人脸五点后,后续做人脸比对、活体检测时会省掉很多麻烦。比如门禁场景里,如果模型不能稳定输出五个点,你还得另接一个关键点模型,体积又上去了。一个网络把框和点一起输出,部署成本和维护成本都会低很多。
5.2 多任务损失如何分配权重
训练时多任务损失由三部分组成:分类损失用交叉熵,框回归和关键点回归用Smooth L1。我见过一份比较实用的权重配置:
| 损失项 | 权重 | 备注 |
|---|---|---|
| 分类 | 1.0 | 正负样本均衡后使用 |
| 框回归 | 1.0 | 只统计正样本 |
| 关键点 | 0.5 | 初期可降到0.1 |
关键点权重一开始不建议设高。刚训练时网络还没有形成基本的面部空间概念,如果强逼关键点分支快速收敛,很容易干扰框回归的学习。我习惯先让框和分类跑稳定几十个epoch,再逐步把关键点权重拉起来,收敛效果更平滑。
5.3 正负样本不均衡与先验框匹配规则
人脸检测里最麻烦的问题是正负样本极度不均衡,绝大多数先验框落不到人脸内部。常见的做法是使用IoU匹配:先验框与人脸框的IoU大于0.5算正样本,小于0.3算负样本,中间区域在训练时直接忽略。
训练时为了让负样本不淹没正样本,还要做困难样本挖掘或给分类分支加focal loss。没有这一步,你会发现模型训练完出来对“脸”非常迟钝,因为网络学到的最优策略就是全部输出背景,准确率还奇高。这个坑在轻量模型上尤其严重,因为模型容量小,更倾向于走捷径。
6. 复现、调参与部署时的实战经验
6.1 输入尺寸与resize方式:最隐蔽的精度杀手
很多人在复现时只关注网络结构,忽略了输入预处理。这个模型的设计输入尺寸通常是接近4:3或3:4的矩形,比如320x240或240x320。你如果懒省事直接resize成正方形,人脸会发生非等比拉伸,先验框的比例也会跟着失真,最终检测框会整体偏移,尤其是在画面边缘的位置。
我建议使用letterbox方式填充灰度边,保持原始宽高比不变,再送入网络。如果你用的端侧框架支持动态尺寸输入,直接按原始宽高比等比缩放后短边对齐固定值也是可以的,检测效果会好不少。
6.2 置信度阈值与NMS阈值的调节顺序
后处理里最容易出现的问题是:小脸漏检,于是把置信度阈值从0.7一路降到0.3,结果框反而变多了,但一堆误检也涌进来。正确的做法是先调输入分辨率或先验框的匹配逻辑,再动置信度阈值。具体地说,如果小脸漏检,优先检查是不是输入分辨率太低,导致小脸在特征图上太小;只有当输入分辨率已经到目标设备的算力上限时,才考虑适当调低置信度阈值。
NMS的IoU阈值则负责控制重叠框的合并力度。阈值设太低,两个人脸挨得近会被合并成一个框;设太高,同一个脸可能出现多个重复框。针对密集人群场景,我会把NMS IoU阈值放到0.35到0.4之间,适配单人脸的门禁场景则适当提一点。
6.3 模型转换时的算子兼容问题
从训练框架导出到端侧推理框架时,这个模型整体还算温和,但有几个细节仍然容易踩坑。第一个是PReLU这一类带可学习参数的激活函数,很多端侧框架对它的支持不算好,导出时要么被替换成ReLU,要么报算子不支持。如果预训练模型里使用了PReLU,优先在结构定义里手动改成ReLU6并重新训练几个epoch,比在转换时临时处理要稳得多。
第二个是resize或者padding方式。某些框架里的letterbox操作放在模型外边比放在模型里边更安全。如果在模型内部加了Resize节点,导出计算图时一旦目标框架不支持这种动态shape,整个转换直接失败。我通常把预处理放在模型外部的C++或Java层,模型内部只接受已经resize好的固定张量。
6.4 量化后的体积与精度取舍
把这套模型做int8量化后,体积能进一步压缩到250KB左右,推理速度也会再快一截。代价是精度会有一些损失,实测在WIDER FACE的困难子集上,检测率可能下降0.5到2个百分点。
要给量化留出余地,建议做训练时模拟量化,让模型在训练阶段就适应低精度表示的噪声。如果已经拿到了float模型又想快速量化,校准集一定要尽量贴近实际使用场景。用纯高清人脸照片做校准集,模型到了夜间低照度的人脸场景,量化误差会明显放大。我个人的习惯是每个典型场景抽100到200张图组成校准集,宁可校准耗时多一点,也不让精度损失失控。
还有一个折中方案是混合量化:把第一个入口卷积保留为float,其余卷积全部转int8。第一个卷积处理的是RGB输入,对颜色细节非常敏感,保留float能让整体精度回升不少,代价只是多占十几KB空间,这个账非常划算。
最后再分享一个小技巧
我在实际使用中发现,这个模型对侧脸和低头这类角度的检测效果,受输入宽高比影响很大。如果你把输入固定成接近3:4的竖屏比例,先验框比例也对应调整为1:1和1:2,对门禁这类竖直安装的摄像头场景,漏检率会有一个肉眼可见的下降。很多人拿到模型第一件事就是调网络结构,其实先根据实际安装角度和常见人脸的成像比例去调整先验框分布,往往是性价比最高的优化手段。
踩过几次坑之后,我的体会是轻量模型的设计思路和重模型确实不太一样:重模型可以靠规模和算力硬吃,轻模型每一步都必须精打细算。这个1MB的检测网络恰恰提供了一个非常好的样板,让你看到在极端资源约束下,一个人脸检测器应该如何分配有限的参数预算。把这套结构吃透,以后再面对各种轻量级检测任务,你会很清楚哪些地方能省、哪些地方必须花,这才是拆解这个网络最大的收获。