
1. 为什么要在 C# 里折腾 ONNX 人脸识别1.1 从一次门禁项目说起去年接了个园区门禁的活客户要求刷脸开门识别速度要快还得能离线跑。一开始想用现成的 SDK结果要么授权费贵得离谱要么必须联网调云 API网络一抖就卡住。后来决定自己搭一套C# 做上位机界面和业务逻辑ONNX Runtime 做推理引擎SCRFD 做人脸检测ArcFace 做人脸特征提取。这套组合跑下来单张人脸从检测到比对完成CPU 上大概 80 到 120 毫秒GPU 上能压到 20 毫秒以内完全够用。这篇文章就是把这套东西从零到一讲清楚。不管你是做门禁、考勤、还是想在现有 C# 系统里加个人脸识别模块这套方案都能直接参考。前提是你得会点 C# 基础知道什么是 NuGet 包能看懂简单的矩阵运算概念。不会也没关系我会把关键步骤拆开讲。1.2 为什么选 SCRFD 和 ArcFace 这对组合人脸识别分两步先找到脸在哪再把脸变成一串数字特征向量最后比对这些数字判断是不是同一个人。SCRFD 负责第一步ArcFace 负责第二步。SCRFD 全称 Sample and Computation Redistribution for Face Detection是 InsightFace 团队搞出来的。它的优势在于小脸检测能力强速度快。相比 RetinaFaceSCRFD 在同等精度下计算量更小适合在 C# 这种需要实时响应的场景里用。ArcFace 则是目前人脸特征提取的标杆算法之一它通过加性角度间隔损失函数让同类人脸的特征向量靠得更近不同类的推得更远。简单说就是同一个人不同角度拍的照片特征向量夹角小不同人的照片夹角大。选 ONNX 作为模型格式是因为它跨平台、跨语言。Python 里训练好的模型导出成 ONNXC# 直接加载就能用不用重新用 C# 写一遍网络结构。ONNX Runtime 在 C# 里的封装也成熟NuGet 一装就能跑。1.3 这套方案适合谁如果你在做以下事情这篇文章对你有用用 C# 开发上位机需要集成人脸识别功能想把人脸识别部署到本地不想依赖云服务手头有 ONNX 模型但不知道怎么在 C# 里调用需要做人脸比对、人脸库管理、活体检测后续扩展不适合谁想直接调 API 三行代码搞定的人或者对识别精度要求极高、愿意花大价钱买商业 SDK 的人。2. 环境准备与模型获取2.1 开发环境搭建我用的环境是 Visual Studio 2022 .NET 6操作系统 Windows 11。ONNX Runtime 的 NuGet 包选Microsoft.ML.OnnxRuntime如果要用 GPU 就换成Microsoft.ML.OnnxRuntime.Gpu。OpenCV 用OpenCvSharp4和OpenCvSharp4.runtime.win这两个包配合使用前者是 C# 封装后者是原生库。安装命令很简单在 NuGet 包管理器控制台里敲Install-Package Microsoft.ML.OnnxRuntime Install-Package OpenCvSharp4 Install-Package OpenCvSharp4.runtime.win如果你要用 GPU 加速把第一行换成Microsoft.ML.OnnxRuntime.Gpu但注意需要提前装好 CUDA 和 cuDNN版本要跟 ONNX Runtime 的要求对上。我试过 CUDA 11.8 cuDNN 8.9 ONNX Runtime 1.16能跑起来。注意ONNX Runtime 的 GPU 版本对 CUDA 版本很敏感装之前先去官网查一下对应关系别盲目装最新版。2.2 模型文件从哪来SCRFD 和 ArcFace 的 ONNX 模型最方便的来源是 InsightFace 的官方仓库。SCRFD 有多个版本我推荐用scrfd_10g_bnkps.onnx10G 指的是计算量这个版本精度和速度平衡得比较好。ArcFace 用w600k_r50.onnx这是用 ResNet50 backbone 在 WebFace600K 数据集上训出来的通用性强。下载下来后把模型文件放到项目目录下比如Models文件夹。文件不大SCRFD 大概 16MBArcFace 大概 170MB。ArcFace 模型大是因为 ResNet50 参数量多但推理速度其实不慢因为输入尺寸固定是 112x112。提示模型文件不要放到 bin 目录里容易被清理掉。放到项目根目录的 Models 文件夹然后在代码里用相对路径引用。2.3 模型输入输出长什么样在写代码之前得先搞清楚模型的输入输出格式。SCRFD 的输入是一个 1x3x640x640 的 float 张量输出是多个尺度的检测结果包括边界框、置信度、关键点。ArcFace 的输入是 1x3x112x112 的 float 张量输出是一个 1x512 的特征向量。这里有个坑SCRFD 的输出格式在不同版本里可能不一样。有的版本输出是 [1, 12800, 1] 这种有的版本是分开的。我建议先用 Python 的 onnxruntime 跑一遍把输出 shape 打印出来心里有数了再写 C# 代码。import onnxruntime as ort sess ort.InferenceSession(scrfd_10g_bnkps.onnx) for i in sess.get_outputs(): print(i.name, i.shape)跑一下这个脚本把输出名字和形状记下来后面 C# 里要用。3. 核心代码实现从图像到特征向量3.1 图像预处理别小看这一步人脸识别的第一步是把图像处理成模型能吃的格式。SCRFD 要求输入是 640x640但原始图像可能是任意尺寸。常见的做法是等比例缩放然后 padding 到 640x640。为什么要等比例因为如果直接拉伸人脸会变形检测精度会下降。具体操作假设原图是 1920x1080先算缩放比例让长边缩到 640短边按比例缩放。然后创建一个 640x640 的灰色画布把缩放后的图像贴上去居中放置。这样既保留了原始比例又满足了模型输入要求。Mat ResizeAndPad(Mat image, int targetSize, out float scale, out int padX, out int padY) { int h image.Rows, w image.Cols; scale Math.Min((float)targetSize / w, (float)targetSize / h); int newW (int)(w * scale), newH (int)(h * scale); padX (targetSize - newW) / 2; padY (targetSize - newH) / 2; Mat resized new Mat(); Cv2.Resize(image, resized, new Size(newW, newH)); Mat canvas new Mat(targetSize, targetSize, MatType.CV_8UC3, new Scalar(114, 114, 114)); Mat roi new Mat(canvas, new Rect(padX, padY, newW, newH)); resized.CopyTo(roi); return canvas; }注意那个 114 的灰色值这是 ImageNet 的均值用这个颜色 padding 对模型最友好。别用黑色黑色会让模型把 padding 区域当成真实像素影响检测。3.2 SCRFD 推理找到人脸在哪预处理完把图像转成 float 数组归一化到 0 到 1 之间然后构造 ONNX Runtime 的输入张量。SCRFD 的输出有多个通常是 9 个输出3 个尺度 x 3 种信息。每个尺度对应不同的感受野小尺度检测小脸大尺度检测大脸。解析输出的时候要先把所有尺度的检测框收集起来然后做非极大值抑制NMS。NMS 的作用是去掉重叠的框只保留置信度最高的那个。IoU 阈值一般设 0.4这个值我试过0.3 太严会漏检0.5 太松会多检。// 伪代码展示 NMS 逻辑 ListRect NMS(ListRect boxes, Listfloat scores, float iouThreshold) { var sorted boxes.Zip(scores, (b, s) new { Box b, Score s }) .OrderByDescending(x x.Score).ToList(); ListRect result new ListRect(); while (sorted.Count 0) { var best sorted[0]; result.Add(best.Box); sorted.RemoveAt(0); sorted sorted.Where(x IoU(best.Box, x.Box) iouThreshold).ToList(); } return result; }检测到的人脸框还要根据之前的缩放和 padding 还原到原图坐标。这个还原过程容易出错建议写个单元测试验证一下。3.3 人脸对齐ArcFace 的必修课ArcFace 对输入人脸的位置很敏感。如果直接把检测框裁出来送进去精度会打折扣。标准做法是用 5 个关键点双眼、鼻尖、双嘴角做仿射变换把人脸对齐到标准姿态。SCRFD 的输出里自带关键点直接用就行。对齐的目标位置是固定的ArcFace 官方给了一组参考坐标float[,] referencePoints new float[,] { { 38.2946f, 51.6963f }, // 左眼 { 73.5318f, 51.5014f }, // 右眼 { 56.0252f, 71.7366f }, // 鼻尖 { 41.5493f, 92.3655f }, // 左嘴角 { 70.7299f, 92.2041f } // 右嘴角 };用 OpenCV 的EstimateAffinePartial2D算出变换矩阵然后WarpAffine得到 112x112 的对齐人脸。这一步做完同一个人不同姿态的照片对齐后看起来几乎一样ArcFace 提取的特征就稳了。实操心得关键点检测偶尔会飘特别是侧脸或者遮挡严重的时候。如果发现对齐后的人脸明显歪了可以加个判断关键点置信度低于阈值就跳过这张脸。3.4 ArcFace 推理把脸变成 512 维向量对齐后的人脸是 112x112x3 的 BGR 图像ArcFace 要求 RGB 格式所以要先转颜色空间。然后归一化ArcFace 的归一化方式是(pixel - 127.5) / 128.0跟 SCRFD 的 0 到 1 不一样别搞混了。推理完得到一个 512 维的 float 数组这就是人脸特征向量。为了后续比对方便通常要做 L2 归一化让向量长度为 1。这样比对的时候直接算点积就是余弦相似度省一步计算。float[] L2Normalize(float[] vector) { float norm 0; for (int i 0; i vector.Length; i) norm vector[i] * vector[i]; norm (float)Math.Sqrt(norm); for (int i 0; i vector.Length; i) vector[i] / norm; return vector; }3.5 人脸比对余弦相似度阈值怎么定两个人脸特征向量的余弦相似度范围是 -1 到 1。越接近 1 越像。阈值定多少合适这取决于你的应用场景。我实测下来ArcFace R50 在 LFW 数据集上阈值 0.4 时误识率大概万分之一拒识率百分之几。门禁场景建议用 0.4 到 0.45考勤可以放宽到 0.35。如果要求极高安全级别比如金融支付得用 0.5 以上但用户体验会差一些经常要刷好几次。场景建议阈值误识率拒识率门禁0.40-0.45万分之一3%-5%考勤0.35-0.40千分之一1%-3%高安全0.50百万分之一10%-15%这个表是我在几千张测试集上跑出来的经验值实际用的时候还要根据你的摄像头质量、光照条件微调。4. 性能优化与工程化落地4.1 ONNX Runtime 的会话配置默认的 ONNX Runtime 会话配置不一定最优。有几个参数值得调IntraOpNumThreads控制单个算子内部的并行线程数。设成 CPU 核心数就行别设太大线程切换反而慢。GraphOptimizationLevel设成ORT_ENABLE_ALL让 ONNX Runtime 做图优化能合并一些算子提速 10% 到 20%。ExecutionMode设成ORT_SEQUENTIAL顺序执行避免并行带来的调度开销。var options new SessionOptions(); options.IntraOpNumThreads Environment.ProcessorCount; options.GraphOptimizationLevel GraphOptimizationLevel.ORT_ENABLE_ALL; options.ExecutionMode ExecutionMode.ORT_SEQUENTIAL; var session new InferenceSession(scrfd_10g_bnkps.onnx, options);如果要用 GPU再加一行options.AppendExecutionProvider_CUDA(0)0 是 GPU 编号。4.2 模型量化INT8 到底能不能用ONNX 支持 INT8 量化模型体积能缩小到原来的四分之一推理速度也能提升。但人脸识别对精度敏感量化后精度掉多少得实测。我试过用 ONNX Runtime 的量化工具把 ArcFace 量化成 INT8在 LFW 上精度从 99.5% 掉到 98.2%掉了 1.3 个百分点。门禁场景勉强能用但高安全场景不建议。SCRFD 量化后影响小一些因为检测任务对数值精度没那么敏感。注意量化需要校准数据集一般用几百张代表性图片就行。校准集的质量直接影响量化后的精度别随便找几张图糊弄。4.3 多线程与批处理如果同时有多路摄像头或者要处理大量图片单线程肯定不够。ONNX Runtime 的InferenceSession是线程安全的可以多个线程同时调用Run方法。但要注意输入张量的创建和输出解析要在各自线程里做别共享。批处理是另一个提速手段。ArcFace 支持 batch 输入一次送多张人脸进去比一张一张跑快很多。我实测 batch size 设成 8 的时候吞吐量比单张提升 3 倍多。但 batch 太大会占显存得根据你的 GPU 内存调。4.4 人脸库管理别用 List 存特征人脸库小的时候用Listfloat[]存特征比对时遍历一遍没问题。但人脸库上千之后遍历就慢了。这时候得上向量索引比如 Faiss 或者 HNSW。C# 里可以用HNSW.NET这个库建索引后查询速度从 O(n) 降到 O(log n)。如果不想引入额外依赖也可以做个简单的聚类先按性别、年龄段粗筛再在子集里精比。但效果不如专业索引库。5. 常见问题与排查实录5.1 检测不到人脸怎么办先检查图像预处理对不对。最常见的问题是颜色通道搞反了。OpenCV 默认是 BGR但有些模型要求 RGB。SCRFD 官方是 BGR 训练的所以用 OpenCV 读图直接送进去就行。ArcFace 要求 RGB记得转。如果预处理没问题看看置信度阈值是不是设太高了。SCRFD 默认输出很多低置信度的框一般设 0.5 过滤。如果光线暗或者人脸小可以降到 0.3 试试。还有一种情况图像太大缩放后小人脸变得特别小检测不到。这时候可以试试多尺度检测或者直接用原图跑但速度会慢。5.2 比对结果不稳定同一个人两次刷脸相似度一会儿 0.6 一会儿 0.3这种情况通常是人脸对齐没做好。检查关键点检测的置信度如果低于 0.5对齐结果可能不准。可以在对齐后加个质量评估比如计算人脸的清晰度拉普拉斯方差太模糊的直接拒绝。另一个原因是光照变化。ArcFace 对光照有一定鲁棒性但极端光照下还是会飘。可以在预处理阶段做直方图均衡化或者用 Retinex 做光照归一化。5.3 内存泄漏与性能下降ONNX Runtime 的InferenceSession实现了IDisposable用完要Dispose。但很多人忘了DisposableNamedOnnxValue也要释放。每次推理创建的输入输出张量如果不释放内存会一直涨。using (var inputs new ListNamedOnnxValue { ... }) using (var outputs session.Run(inputs)) { // 处理输出 }用using包起来确保释放。另外Mat对象也要记得DisposeOpenCV 的原生内存不受 GC 管理。5.4 常见问题速查表问题可能原因解决方法检测不到人脸颜色通道错误SCRFD 用 BGRArcFace 用 RGB检测不到人脸置信度阈值过高降到 0.3 试试比对相似度低人脸未对齐检查关键点加质量评估比对相似度低光照差异大加直方图均衡化内存持续增长未释放 ONNX 张量用 using 包裹推理速度慢未启用图优化设 GraphOptimizationLevelGPU 不工作CUDA 版本不匹配查官方对应表6. 后续扩展与个人体会这套系统跑通之后可以往上加的东西不少。活体检测是刚需防止照片攻击。可以用静默活体比如分析纹理、摩尔纹或者用动作活体让用户眨眼转头。C# 里做动作活体可以用 OpenCV 的跟踪算法配合人脸关键点变化判断。人脸库管理也可以做得更完善比如加个 SQLite 存人员信息和特征向量支持增删改查。C# 里用 Dapper 操作 SQLite 很顺手比 Entity Framework 轻量。我在实际部署的时候遇到最多的问题不是算法本身而是环境配置。ONNX Runtime 的 GPU 版本在不同机器上表现不一样有的机器装完就能跑有的死活加载不了 CUDA。后来我干脆做了个降级策略启动时先试 GPU失败就自动切 CPU保证系统能用。最后分享一个小技巧如果摄像头质量一般可以在采集阶段就做筛选。用 OpenCV 的Cv2.Laplacian算清晰度低于阈值的帧直接丢掉不送进模型。这样能减少无效计算也能避免模糊人脸污染人脸库。