ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

libfacedetection源码阅读与扩展:从人脸框到角度输出

libfacedetection源码阅读与扩展:从人脸框到角度输出 写这个项目复盘的时候我手里还拿着当时给 libfacedetection 框架加的一叠标注笔记。说实话人脸检测的轮子很多OpenCV 自带的人脸检测器、YuNet、RetinaFace 都能跑但我还是花了两周时间把 libfacedetection 从 API 层一路读到了 ncnn 前向推理那一段并且在这个基础上做了一个小扩展从普通的人脸框扩展到带角度信息的检测输出。这篇文章就是把当初读源码、改代码、踩坑的过程重新整理一遍写给那些想拿现成框架做二次开发、又不想稀里糊涂调 API 的人。我会按照实际阅读顺序来组织内容先交代取舍思路再讲调用链和关键结构然后用一个真实的扩展需求串起改动点最后聊移植和性能优化时最容易被忽略的细节。1. 读源码之前我先搞清楚了三件事1.1 这个框架在众多人脸检测方案里的定位我一开始其实有点犹豫既然都在用深度学习做检测了为什么不直接上 RetinaFace 或者轻量一点的 SCRFD后来把需求一摆就清楚了。我需要的是一个能在嵌入式设备上跑、最好只有少量第三方依赖、还能方便我改输出的 C 检测器。libfacedetection 恰好符合这几个条件它是于仕琪老师开源的项目核心检测模型是 CNN代码仓库里同时包含训练与检测部分推理端从 2021 年左右的版本开始基于 ncnn 实现整个库对外暴露的接口非常克制本质就是facedetect_cnn这一组函数。和其它方案对比一下会更直观方案模型体积依赖扩展难度适合场景libfacedetection很小适合嵌入式ncnn OpenCV可选中等源码可读性较好边缘设备、快速人脸框检测YuNetOpenCV小OpenCV DNN较低但封装较深OpenCV 原生集成RetinaFace中大PyTorch / MNN 等较高训练与部署链路长追求精度、需要密集关键点SCRFD中各家推理框架高高精度、高算力场景读库之前一定要先确认它的定位。libfacedetection 的好处是“小而直接”坏处是模型能力上限就摆在那里想硬让它输出遮挡很严重的人脸关键点本身就是不合理的期待。我给自己定的目标是检测框必须沿用原模型关键点在原 5 点基础上扩展同时增加一个旋转角度的回归值。这个目标能不能实现完全取决于源码结构。1.2 版本与分支差异我该拿哪一份代码作为阅读基线去 GitHub 上看这个项目时会发现分支和 tag 不少早期版本和最新版本在推理后端上差别很大。早一点的版本自带一个轻量 CNN 推理实现不依赖 ncnn看起来更“纯净”但扩展空间不大因为底层算子都是写死的。而 2020 年底之后的版本直接拥抱 ncnn检测主程序里能看到典型的ncnn::Net加载模型、创建 extractor、执行 forward 的流程。ncnn 的好处是算子齐全、跨平台性好坏处是你需要额外了解一点 ncnn 的 API 语义。我最后选择以最新 release 版本为基线理由有三个。第一ncnn 后端意味着我可以在不重写神经网络算子的情况下修改模型结构第二项目里提供的模型文件是嵌入到 C 数组里的解包之后可以转出来再用第三社区对 ncnn 版本的讨论更多遇到问题容易查到资料。这里也给你一个建议不要一开始就在最新 master 上读先切到 release tag读稳定版代码至少能少踩一半因为工程结构调整带来的坑。1.3 扩展前先明确“改哪里”比“怎么改”更重要读源码容易陷入一个误区想把每行都看懂。我的经验是在通读之前先拿笔写出扩展需求对应的“改动点”如果要改输出维度需要动检测后处理里的结果解析逻辑如果要加关键点或者姿态角需要看网络最后输出的张量形状如果要跑在不同硬件上需要看 ncnn 的 target 设置和内存分配方式。我把 libfacedetection 的源码在本地建了一份带注释的副本然后沿着一个检测调用把关键路径标出来。等真正开始改的时候我基本只需要关注facedetectcnn.h、facedetectcnn.cpp和模型参数这三块。其它训练代码可以先放着因为框架阅读和框架扩展两个任务其实可以解耦。这个习惯帮我省下了大量时间后面所有改动都围绕一条主线进行没有出现“该改的地方没动不该动的地方改了半屏”的情况。2. 从 facedetect_cnn 入口到 CNN 前向推理一条完整的调用链2.1 检测结果缓冲区的设计意图facedetect_cnn的函数签名看起来很简单但里面暗藏了一个非常关键的设计。调用者要传入一个result_buffer这个缓冲区不能用完就丢因为函数返回的不是一个结构化数组而是一个int*本质上就是在这个缓冲区头部位偏移后记录“检测到的人脸数量”紧接着是一段固定长度的人脸信息。每个检测结果里先放置信度、人脸框坐标再放关键点坐标。这种设计在初看时会觉得不够“现代化”因为你要手动理解内存布局。但它的好处非常明显接口不依赖任何一个 STL 容器也不依赖动态分配不同语言绑定和跨 DLL 边界时都很方便。我后来在给这个库写 C# 调用时正是靠着这个纯内存布局才没有额外写一层序列化。值得注意的是缓冲区开得够不够大直接决定检测结果会不会被截断。项目头文件里已经定义了一个建议的 buffer size我在扩展时一开始把结果结构体尺寸增大了却忘了同步调大 buffer。线上直接表现就是检测到的人脸数量总是莫名少一些排查半天才发现是结果写越界把数量字段踩掉了。这里必须强调只要你动了输出结构体的大小先找缓冲区大小再找解析逻辑。2.2 前处理、推理、后处理分别在哪儿整套推理链路可以拆成三段。前处理在facedetect_cnn函数内部完成主要做的是图像缩放、通道转换、归一化。输入图像可能是 RGB 或 BGR函数通过参数区分。这里有一个容易忽略的点数据的step参数表示图像每行占用多少字节不是 width 本身。很多移植到嵌入式平台的人常常在 step 上栽跟头因为摄像头采集到的图像往往有行对齐宽和 step 不是一回事。推理阶段则由 ncnn 接管。facedetectcnn.cpp里会加载模型参数和模型权重然后为每个缩放尺度创建一个ncnn::Extractor。libfacedetection 本身走的是多尺度检测策略min_face_size参数配合输入的 scale 决定要检测多小的人脸。每张图会被缩放成几个不同的分辨率分别跑一次前向再把所有结果汇总。后处理阶段在检测函数主流程里完成。汇总到候选框之后先按置信度过滤再做 NMS非极大值抑制。NMS 的阈值在函数参数里是iou_threshold。读到这一层时你会明白为什么这个库的检测框有时候边缘偏“紧”因为它的 anchor 设定和后处理逻辑是为特定训练集服务的你想改变检测框表达习惯就得连后处理一起改不能只替换模型权重。2.3 几个值得标注的关键数据结构我读代码的时候在三个地方做了重点标注。第一个是 ncnn 的Net对象它在 C 里是一个类内部管理模型参数和权重数据。第二个是Extractor它负责单次前向推理的输入输出本质上是一个轻量级会话。第三个是内存结果区的前几个 float它们是后续所有坐标换算的基准。如果对这些数据结构没有概念你在扩展时会感觉很别扭。举个例子ncnn 的Extractor::input需要传入一个ncnn::Mat而ncnn::Mat的通道顺序是 CHW。如果直接从外部拿 HWC 数据塞进去检测结果会完全错乱。libfacedetection 源码里已经做了HWC - CHW的转换我建议读的时候直接把这段画出来因为它是连接图像采集和神经网络推理的桥梁也是移植到 SIMD 优化时最容易改出 bug 的位置。3. 我做的第一个扩展把检测结果从“框”升级到“带角度的人脸区域”3.1 为什么挑这个需求当练手我的实际业务场景是闸机上的动态人脸抓拍很多时候人脸是倾斜的普通正脸检测框虽然也能框住目标但后续对齐模块表现不稳定。因此我想让人脸检测器额外输出一个旋转角度框还是要原来的框但角度值可以用来把人脸区域摆正。为什么不直接换一个人脸姿态估计模型因为设备上算力有限能省一次前向推理就省一次。libfacedetection 的模型内部已经提取了丰富的人脸特征我在特征层后面加一个很小的全连接分支理论上增加的算力消耗几乎可以忽略。这也是“阅读框架后做增量扩展”和“另起炉灶”的关键差别尽量复用主干特征而不是在外部串联新模型。3.2 在输出层追加角度回归分支并同步修改接口真正动手后第一步不是写代码而是把模型的训练结构调出来。libfacedetection 的模型训练部分在独立的目录中我需要找到网络定义文件里最后一个卷积层或全连接层在哪然后在此之后添加一个输出维度为 1 的分支。这个分支使用全局平均池化把特征压成向量再接一个全连接层输出角度值。因为原始模型是在特定数据集上训练出来的我不可能大规模重新训练因此我把除新增分支外的权重全部冻结只对新分支做微调。数据集用的是我业务场景里采集的带角度标注人脸图大概一万张。训练过程不复杂重点是收敛速度非常快因为主干特征已经很强。训练完成后导出模型再转换成 ncnn 格式这一步是整个扩展里最繁琐的。你要保证网络结构里的层类型、参数名和 ncnn 解析器完全对得上。我当时遇到的一个典型问题是Flatten层的写法不兼容转换工具直接报错。解决方法是在原始网络里去掉显式 Flatten改用全局池化输出这样既减少参数又绕开算子兼容问题。有了新模型接下来就是改公开接口。facedetectcnn.h里的结果结构原本只定义了一个通用缓冲区现在我要在关键点之后追加一个 float 表示角度。这个改动同时涉及三个文件头文件里的注释和结构说明、cpp 里的结果填充逻辑、以及调用方解析逻辑。一定不要只改一处尤其是文档中声明的结果布局如果你改了实际布局却忘了文档后来的维护者会被坑得很惨。3.3 后处理里最容易写错的坐标换算角度分支输出的值在模型内部是按“输入图像分辨率”计算的而facedetect_cnn对外返回的坐标是基于原始图像分辨率。这里必须做一次反向映射。如果你只是简单地把返回框的坐标乘以缩放比却忘了角度值也需要用相同方式处理那角度就废了。我踩到的坑比这更隐蔽训练时我用的图像标注角度是逆时针为正但网络输出层用的激活函数是tanh范围在 -1 到 1 之间。我把它直接当成角度值返回结果在图像坐标体系里方向刚好反了。最后我在后处理里加了一个负号并且用一组带已知角度的图片做回归测试才确认。这段经历给我最大的启发是扩展框架的输出时不要只盯着模型训练精度还要盯着后处理和 API 的约定。用户的调用方可能根本不关心你中间怎么回归它只认你写进文档的“返回角度是弧度、顺时针为正”这种定义。你前后一致集成就顺畅你有一处符号反了整个业务逻辑就全反了。4. 移植与性能优化从 x86 到 ARM 端的真实遭遇4.1 替换 ncnn 后端时需要检查的三个地方把 x86 上验证通过的代码移植到 ARM 开发板时我原本以为只是重新编译一次这么简单结果折腾了两个晚上。第一个要检查的地方是 ncnn 的编译参数。x86 上通常会开启 OpenMP而 ARM 端如果交叉编译工具链没配好 OpenMP运行时就会静默退化到单线程性能直线下降。第二个要检查的地方是内存对齐。ARM 处理器对非对齐访问的容忍度远低于 x86我在结果缓冲区上分配的内存地址没有做 16 字节对齐运行到 ncnn 内部时就报段错误。第三个地方是模型输入的像素格式。ARM 端的摄像头采集接口很有可能是 NV21 或者 YUV 格式而不是 RGB。libfacedetection 的对外接口只接受 RGB 或 BGR因此我在接入摄像头前必须先做一次格式转换。这个转换本身很耗 CPU如果不做优化它会占据整个检测耗时的很大比例。我的方案是使用带 NEON 指令的查表法做 YUV 转 RGB整体耗时降了很多。这张表总结了我检查的主要项目检查项x86 场景ARM 场景典型问题OpenMP 支持默认开启交叉编译链不完整导致静默退化输入图像格式RGB/BGR 容易保证摄像头直接输出 NV21需额外转换内存对齐不敏感非对齐访问可能直接崩溃ncnn 版本自己编译系统自带的旧版本可能缺少算子4.2 多线程推理与 ncnn 实例的关系框架自带的 demo 是单线程跑检测但实际业务里我需要在同一个进程里同时处理多个视频通道。最初的想法很简单每个线程里各自调用facedetect_cnn互不影响。事实也的确如此但性能并没有随着线程数线性增长因为在多核设备上ncnn 内部本身就会利用 OpenMP 并行计算如果每个线程再各建一个ncnn::Net内存占用和线程调度开销都会翻倍。更好的做法是尝试池化复用。一个思路是所有线程共享一个ncnn::Net实例因为 ncnn 的模型权重可以共享但前向推理时的Extractor需要线程独立我在实际使用中给共享推理加了一把全局锁保证同时只有一个线程在执行前向。这个做法损失了一部分并行度换来了内存稳定。后来又试了另一种方案维护一个ncnn::Net对象池每个对象绑定到一个线程上这样既避免了模型重复加载又允许真正多路并行。这个方案的缺点是内存占用等于“线程数乘以单模型内存”在内存紧张的板卡上要谨慎。我个人建议在内存充足的设备上用对象池在内存紧张时直接退化回全局锁模式毕竟检测速度下降总比内存不足被杀进程好。4.3 实测数据与调参记录我在 ARM 平台上跑的模型输入分辨率是 320x240最小人脸检测尺寸设为 48 像素阈值score_threshold0.7、iou_threshold0.3。单线程情况下一帧平均耗时 12ms 左右开了 ncnn 的 OpenMP 之后稳定在 8ms 左右。如果我把最小人脸尺寸调到 80耗时会降到 5ms 左右但远处的小人脸会直接漏检。有一个调参经验非常值得分享libfacedetection 的min_face_size是像素单位但它同时会影响内部生成图像金字塔的层数。你把min_face_size调得越小金字塔层数越多前向次数越多耗时增长不是线性的而是近似指数增长。所以遇到性能瓶颈时先别急着换硬件可以尝试把min_face_size调大一点牺牲远距离小脸检测来换取速度提升。这个参数对实测体验的影响远大于score_threshold。另一个经验是关于scale参数。它控制的是检测时图像缩放的步长。步长越小检测越密耗时越高步长越大越容易漏掉中间尺度的人脸。对于 1080p 输入我建议scale1.1起步不要低于 1.05否则一帧耗时会非常离谱。这个参数和模型内部 anchor 设计强相关换了模型之后需要重新标定。5. 扩展 libfacedetection 之后我建议你也保留的几个习惯5.1 在源码里维护一份“扩展日志”写完扩展后我把仓库里不少原始文件改动过了。为了日后能跟进 upstream 更新我维护了一个EXTENSION.md里面记录了每一次改动的文件、改动原因和对应的 commit。这件事看起来繁琐但实际做下来非常值得。因为 ncnn 或者 OpenCV 一升级API 可能微小变化你有了修改记录就知道哪些地方是“本地补丁”哪些地方可以安全被 upstream 覆盖。如果你用的是 git我还会额外建一个分支专门跟踪 upstream同时在另一个分支上做自己的扩展。合并 upstream 更新时EXTENSION.md能让你快速识别冲突而不是面对一堆毫无上下文的 diff 发呆。5.2 用最小可复现样本做回归测试人脸检测扩展最容易出“这次调好了下次又崩了”的问题。我准备了一个非常小的回归测试图片集包含正脸、侧脸、不同光照、带旋转角度、完全没有人脸这几类情况。每次改动后处理或模型结构我都会先跑一遍这个图片集比对检测框和关键点的输出结果。不要小看这一步。我加了角度分支后一度导致某一张侧脸图的检测框偏移很大但常规视频流里根本看不出来。正是回归测试里的侧脸样本暴露了问题。这种测试集应该尽量固化下来放在仓库的testdata目录任何协作成员都可以一键运行。5.3 什么时候该停手直接换更重量级的框架最后说一点可能不太顺耳的话。libfacedetection 的源码结构虽然清晰但它的模型能力上限决定了你不可能通过简单的“加分支”获得与人脸识别算法同等级别的关键点定位精度。如果你的需求从“检测人脸框”变成了“做人脸精细对齐”、“做稠密关键点”、“做属性分析”我建议你认真评估换用更完整的方案。我自己的切分标准是当扩展改动开始覆盖模型的 backbone 结构、需要大规模重新训练、并且后处理里的逻辑越来越多时就说明你已经不是在“扩展框架”而是在“重写框架”。这时候最理性的做法是停下来看看有没有更合适的基线。libfacedetection 的价值在于提供了一条低门槛的“人脸检测从初始化到部署”的路径而不是一个什么都想往里装的万能容器。按照我个人经验框架阅读最有收获的时刻不是把所有代码注释完的时候而是你第一次带着一个真实扩展目标在庞大的源码里精准定位并完成改动的那一刻。libfacedetection 刚好很适合作为这样的练习对象因为它足够小能让你完整经历一个检测框架的输入、推理、输出全链路。如果你也有一个“想在现有框架里加点东西”的需求不妨先把这篇文章里的思路走一遍先定版本基线再标调用链最后带着目标去改你的效率会比我当时高很多。
返回列表