ARTICLE DETAIL

资讯详情

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

OpenCvSharp+ONNX Runtime部署L2CS-Net实现头部姿态估计

OpenCvSharp+ONNX Runtime部署L2CS-Net实现头部姿态估计 简介基于C#与OpenCvSharp这份代码工程借助L2CS-Net神经网络实现人脸朝向估计与眼睛注视方向判断适合有一定C#基础、希望在桌面应用中集成视觉识别能力的开发者。项目基于VS2019/.NET Framework 4.7.2构建整合OpenCvSharp 4.8.0与Microsoft.ML.OnnxRuntime 1.16.3可直接打开sln编译运行。压缩包共41个文件、约162.66MB包含9个cs源码、11个dll依赖库、2个onnx模型、7个xml配置/注释及mp4演示视频等结构完整。核心代码中FaceDetector负责检测人脸L2CSManager与FaceManager分别完成模型加载和朝向推理层次清晰便于理解DNN调用逻辑并迁移到其他姿态估计任务。目前已有179人学习下载适合作为人脸视线估计入门与二次开发的参考。1. OpenCvSharp DNN 里跑 L2CS-Net为什么用分类代替回归做上位机注意力检测我早期习惯用关键点检测加 PnP 解算头部姿态一旦遇到侧脸、低头和眼镜遮挡五个关键点的轻微抖动就会放大成角度上的大跳变。L2CS-Net 换了个思路把眼睛注视方向和人脸朝向统一成两个分类任务偏航角一个头、俯仰角一个头每个头输出几十个角度区间的概率而不是直接回归一个连续值。这样模型对标注噪声和姿态分布不均衡更耐受部署时用 ONNX Runtime 推理OpenCvSharp 负责检测和可视化整条链路在 WinForms 上位机里可以稳定跑满摄像头帧率。本文按这个项目的实际拆解顺序讲先确认模型输入输出再做人脸裁剪随后封装推理类最后给出平滑角度与验证方法。2. L2CS-Net 模型结构与 ONNX 张量入口确认2.1 两个分类头与角度步长L2CS-Net 的骨干网络大多是 ResNet-50也有轻量化变体骨干之后不直接接全连接回归而是分出 yaw 和 pitch 两个分支。每个分支最后一层输出的维度就是 bin 数比如 90 维意味着把角度范围按 2° 或 4° 分格。推理时先对每个类别做 softmax再取最大概率对应的格子角度编码相当于把连续值离散化规避了回归网络在尾部样本上 loss 被大误差主导的问题。给你一个直观感受头部姿态训练集里正脸样本占绝大多数回归模型为了压低整体 loss会把预测值往 0° 附近拉极端角度反而不容易学出来分类模型每个 bin 是独立监督信号样本少的极端角不会被多数类“平均”掉。后面接注意力和驾驶行为分析时低头和侧头恰恰是最需要被稳定捕捉的这一点直接决定了选型。提示如果拿到的 ONNX 是别人从 PyTorch 导出的bin 数量、角度范围、步长这三个值都可能被改过。代码里按可配置常量写换模型时先查导出脚本不要只盯着网络结构猜。2.2 用代码确认输入输出元数据拿到一个 L2CS-Net 的 onnx 文件第一件事不是直接写推理而是把输入输出打印出来。先建一个控制台项目引用 Microsoft.ML.OnnxRuntime 1.16.3运行下面代码using System; using Microsoft.ML.OnnxRuntime; const string modelPath models\l2cs.onnx; using (var session new InferenceSession(modelPath)) { Console.WriteLine( inputs ); foreach (var key in session.InputMetadata.Keys) { var meta session.InputMetadata[key]; Console.WriteLine({0} type{1} dims{2}, key, meta.ElementType, string.Join(x, meta.Dimensions)); } Console.WriteLine( outputs ); foreach (var key in session.OutputMetadata.Keys) { var meta session.OutputMetadata[key]; Console.WriteLine({0} type{1} dims{2}, key, meta.ElementType, string.Join(x, meta.Dimensions)); } }这段代码遍历了会话的输入输出元数据并把张量名、元素类型、维度打出来。运行后你会看到类似下表的信息不过不要背名字实际模型可能叫 images、data 或者 out0、out1以后处理代码里按名字取就行。方向常见名称张量形状C# 侧类型inputinput / data / images1 x 3 x 224 x 224DenseTensorfloatoutput1yaw / head_yaw1 x 90Tensorfloatoutput2pitch / head_pitch1 x 90Tensorfloat特别注意输入的维度顺序是 NCHW不是 HWC。C# 里构造DenseTensorfloat时如果用new[] { 1, 3, 224, 224 }对应 NCHW一旦写成 224x224x3通道排列全部错位后面就算角度方向对了数值也会乱跳。2.3 常见导出模型的三个坑第一个坑是输入归一化。PyTorch 训练代码通常只做x / 255不带 ImageNet 的 mean/std但也有导出者把归一化直接折叠进 ONNX 模型节点。区分方法很简单给模型喂一张纯黑图看 yaw 和 pitch 的概率分布是不是集中在中间 bin。如果集中在中间说明模型内部已经处理了归一化后续代码直接除 255 即可如果持续偏向某一端再看导出脚本里的 transform。第二个坑是张量类型。OnnxRuntime 的 C# API 对 float 和 double 区分很严格DenseTensorfloat的底层存储必须是 float[]。session.Run不会因为你传了 double 就报错但会触发隐性转换性能掉得厉害这在实时视觉任务里不能接受。第三个坑是多个输出的顺序。ONNX 模型的输出顺序没有强约束results[0]不一定是 yaw。后处理代码里应该通过输出名去取而不是按下标硬取这一点在第 4 章的推理类里会体现。3. FaceDetector 裁剪与 224×224 输入预处理3.1 FaceDetectorYN 代替 Haar 级联Haar 级联对正脸有效对侧脸和低头基本无能为力而且滑动窗口遍历 CPU 时间也不短。OpenCV 4.8 里提供的 FaceDetectorYN 是 YuNet 人脸检测器单模型同时输出人脸框、五个关键点和置信度对侧脸的容忍度明显更高和 L2CS 这种需要把头转过去再判断方向的场景是匹配的。C# 侧创建只需要一个静态方法using OpenCvSharp; using OpenCvSharp.Face; private readonly FaceDetectorYN detector; // 构造函数里创建一次不要每帧 new detector FaceDetectorYN.Create( face_detection_yunet_2023mar.onnx, , new Size(320, 320), 0.9f, 0.3f, 5000);参数的含义分别是模型路径、配置文件路径这里填空字符串、内部检测分辨率、置信度阈值、NMS 阈值、最多保留人脸数。scoreThreshold在 720p 视频里 0.9 可能偏严人脸有遮挡时漏检明显我一般会降到 0.7nmsThreshold在密集人群中从 0.3 调到 0.4减少相邻人脸被合并的概率。检测调用和结果解析using (var faces new Mat()) { int detectStatus detector.Detect(frame, faces); if (detectStatus -1 || faces.Rows 0) return; float x faces.Atfloat(0, 0); float y faces.Atfloat(0, 1); float w faces.Atfloat(0, 2); float h faces.Atfloat(0, 3); float score faces.Atfloat(0, 14); }注意Detect返回的是状态码或人脸数不是布尔值别写成if (!detector.Detect(...))。OpenCV 内部把人脸结果存成 CV_32F 矩阵读取必须用Atfloat用Atint读出来是废数据。3.2 裁剪人脸框并外扩从上面拿到的是人脸框但它通常只包住眉毛到下巴L2CS-Net 训练时用的是包含完整头部轮廓的图像。直接裁剪会导致头部信息不足侧头时特别明显。常见做法是把框外扩 20% 到 30%。float pad 0.25f; float cx x w / 2f; float cy y h / 2f; float side Math.Max(w, h) * (1 pad); Rect roi new Rect( (int)(cx - side / 2), (int)(cy - side / 2), (int)side, (int)side); roi new Rect(0, 0, frame.Width, frame.Height); Mat faceCrop new Mat(frame, roi);外扩时用Math.Max(w, h)而不是直接乘 w是为了把裁剪区凑成正方形避免后续 resize 时脑袋被横向或纵向压扁。roi new Rect(0, 0, frame.Width, frame.Height)这行是让矩形与图像边界求交集自动截掉负坐标和超出边界的部分。如果外扩后人脸很小、被边缘切掉太多建议把该帧丢弃否则送入模型的是一张残缺图角度输出没有意义。更稳的做法是补齐边界而不是丢弃用Cv2.CopyMakeBorder给 ROI 补一圈灰色像素保持正方形。调试阶段我习惯先保存几张裁剪图确认人脸框没有把额头切掉再进行下一步。3.3 通道布局与数据填充OpenCvSharp 摄像头帧默认是 BGR模型要求 RGB同时要把 Mat 转成 NCHW 布局的 float 数组。using System.Runtime.InteropServices; public static float[] PrepareInput(Mat faceBgr, int size) { using (Mat resized new Mat()) { Cv2.Resize(faceBgr, resized, new Size(size, size)); using (Mat rgb new Mat()) { Cv2.CvtColor(resized, rgb, ColorConversionCodes.BGR2RGB); float[] data new float[1 * 3 * size * size]; byte[] bytes new byte[size * size * 3]; Marshal.Copy(rgb.Data, bytes, 0, bytes.Length); for (int i 0; i size * size; i) { data[i] bytes[i * 3] / 255f; // R 通道平面 data[size * size i] bytes[i * 3 1] / 255f; // G 通道平面 data[2 * size * size i] bytes[i * 3 2] / 255f; // B 通道平面 } return data; } } }Marshal.Copy依赖 Mat 内存连续Resize的输出满足这个条件所以可以直接拷贝。索引布局上前size*size个元素是 R 平面中间是 G 平面最后是 B 平面正好对应 ONNX Runtime 的 NCHW 约定。除 255 就够不需要自己减均值除非你的模型导出脚本里明确写了 mean/std。预处理的参数整理如下参数值说明目标尺寸224 x 224与 ONNX 元数据保持一致通道顺序RGBOpenCvSharp 默认 BGR必须转换数值范围0.0 ~ 1.0除以 255fmean/std无若模型内部已折叠不要二次归一化4. L2CSManager 推理与 WinForms 实时刷新4.1 封装一个推理类把推理封装成单独的L2CSManager不要直接在 Form1 里写 session否则模型加载、输出名称维护、后处理换算会全部耦合在 UI 代码里。using System; using System.Linq; using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public class L2CSManager : IDisposable { private readonly InferenceSession _session; private readonly string _inputName; private readonly int _binCount 90; private readonly float _binStep 4f; // 按导出脚本调整2f 或 4f 都常见 public L2CSManager(string modelPath) { _session new InferenceSession(modelPath); _inputName _session.InputMetadata.Keys.First(); } public (float Yaw, float Pitch) Predict(float[] inputData) { var tensor new DenseTensorfloat(inputData, new[] { 1, 3, 224, 224 }); using (var results _session.Run(new[] { NamedOnnxValue.CreateFromTensor(_inputName, tensor) })) { float[] yawProbs results.First(r r.Name yaw).AsTensorfloat().ToArray(); float[] pitchProbs results.First(r r.Name pitch).AsTensorfloat().ToArray(); int yawIdx ArgMax(yawProbs); int pitchIdx ArgMax(pitchProbs); float yaw (yawIdx - _binCount / 2f) * _binStep; float pitch (pitchIdx - _binCount / 2f) * _binStep; return (yaw, pitch); } } private static int ArgMax(float[] arr) { int idx 0; for (int i 1; i arr.Length; i) { if (arr[i] arr[idx]) idx i; } return idx; } public void Dispose() { _session?.Dispose(); } }代码里有几个关键点。_inputName在构造函数里从InputMetadata的第一个 key 取不硬编码。输出用First(r r.Name yaw)按名字取不按下标因为前面说过 ONNX 输出顺序不保证。最后的角度换算(idx - binCount / 2f) * binStep只跟训练脚本有关90 类、步长 2° 时下标 0 对应 -90°90 类、步长 4° 时下标 0 对应 -180°模型不对就改_binStep一个常量别的都不用动。ArgMax是朴素实现输出的 90 维数组长度固定不需要过度优化。如果你发现角度帧间抖动明显可以把ArgMax换成 soft-argmax即按概率加权求期望这样相邻 bin 的响应也能参与计算代价是低置信度帧反而更容易偏离是否使用取决于你的模型训练时有没有做 label smoothing。4.2 图像采集与推理的循环结构Form1 里建议用一个Timer驱动采集但Tick事件里不能直接做VideoCapture.Read和 ONNX 推理否则上位机界面全卡住拖拽窗口都没反应。把耗时操作扔到Task.Run里只把结果通过BeginInvoke回投 UI 线程。private bool _busy; private void timer1_Tick(object sender, EventArgs e) { if (_busy) return; _busy true; Task.Run(() { using (var frame new Mat()) { if (!_capture.Read(frame) || frame.Empty()) return; Mat faceCrop; Rect box; float score; bool ok _faceManager.CropFace(frame, out faceCrop, out box, out score); if (!ok || score 0.55f) return; float[] input Preprocess.PrepareInput(faceCrop, 224); faceCrop.Dispose(); var result _l2cs.Predict(input); frame.GetArray(out byte[] frameBytes); BeginInvoke((Action)(() Render(frameBytes, frame.Width, frame.Height, box, result.Yaw, result.Pitch))); } }).ContinueWith(_ _busy false); }_busy防止上一帧还没处理完、下一帧又进来这是摄像头循环卡顿最常见的来源。score 0.55f时直接丢弃避免把低置信度的人脸硬塞给角度模型。注意frame.GetArray会把一帧图像复制成 byte[]而不是把 Mat 传给 UI 线程这是因为BeginInvoke是异步的等 UI 线程真正执行Render时本地 Mat 已经释放直接传 Mat 会得到一张空图。4.3 在 UI 线程绘制结果Render方法在 UI 线程运行负责把人脸框和方向线画到 PictureBox 上。private void Render(byte[] frameBytes, int width, int height, Rect box, float yaw, float pitch) { var handle GCHandle.Alloc(frameBytes, GCHandleType.Pinned); try { using (var mat new Mat(height, width, MatType.CV_8UC3, handle.AddrOfPinnedObject())) using (var clone mat.Clone()) { if (pictureBox1.Image ! null) { pictureBox1.Image.Dispose(); pictureBox1.Image null; } Cv2.Rectangle(clone, box, Scalar.Red, 2); Point center new Point( box.X box.Width / 2, box.Y box.Height / 2); double yawRad yaw * Math.PI / 180.0; double pitchRad pitch * Math.PI / 180.0; int len 80; Point end new Point( (int)(center.X len * Math.Sin(yawRad) * Math.Cos(pitchRad)), (int)(center.Y - len * Math.Sin(pitchRad))); Cv2.Line(clone, center, end, Scalar.Green, 2); pictureBox1.Image clone.ToBitmap(); } } finally { handle.Free(); } }这里把 byte[] 用GCHandle固定再交给Mat构造是为了让托管数组和非托管 Mat 之间共享内存避免额外拷贝。绘制方向线的公式是一种屏幕近似yaw 控制水平偏移pitch 控制垂直偏移没有做相机内参标定所以它适合观察不适合作为严格的空间角度测量。如果你的业务只需要判断“看向左/右”或者“是否低头”这个近似完全够用。另外BeginInvoke在窗口关闭瞬间可能抛出异常放在Task.Run里要注意捕获或者在窗体关闭时先把Timer停掉、把_busy置位。5. 角度的平滑与后处理纯函数验证5.1 用带角度回绕的 EMA 平滑L2CS-Net 的单帧输出在低光照或快速转头时会有几度的抖动直接画线在界面上看起来就像在“抖”。简单指数移动平均就能压掉大部分高频噪声private float _smoothYaw; private float _smoothPitch; private const float SmoothFactor 0.35f; public (float Yaw, float Pitch) Smooth(float rawYaw, float rawPitch) { float deltaYaw rawYaw - _smoothYaw; if (deltaYaw 180f) deltaYaw - 360f; if (deltaYaw -180f) deltaYaw 360f; float deltaPitch rawPitch - _smoothPitch; if (deltaPitch 180f) deltaPitch - 360f; if (deltaPitch -180f) deltaPitch 360f; _smoothYaw SmoothFactor * deltaYaw; _smoothPitch SmoothFactor * deltaPitch; return (_smoothYaw, _smoothPitch); }SmoothFactor为 0.35 表示新帧只占 35% 权重30fps 下大约有 50 毫秒级别的延迟对头部姿态跟踪刚好。如果摄像头只有 15fps建议调到 0.5否则角度会明显“追不上”动作。回绕处理是专门为 yaw 跨 ±180° 边界准备的不做这一步从 179° 转向 -179° 时平滑后的角度会被错误地拉向 0°反而产生一道横跨画面的假轨迹。在实际项目中我会把平滑放在盘算中与 reducer“置信度低时保留上一帧”结合使用人脸丢帧超过 3 帧就清空平滑值避免重新检测到人脸的一瞬间角度从旧值跳过来。5.2 固定输出向量解码成角度的回归测试角度换算、符号、bin 步长这类后处理逻辑每改一次就要重启摄像头看一眼效果效率太低。我习惯把这部分抽成纯函数用断言直接验证public static float DecodeAngle(int binIndex, int binCount 90, float binStep 4f) { return (binIndex - binCount / 2f) * binStep; } Debug.Assert(Math.Abs(DecodeAngle(45) - 0f) 1e-6); Debug.Assert(Math.Abs(DecodeAngle(55) - 40f) 1e-6); Debug.Assert(Math.Abs(DecodeAngle(35) - (-40f)) 1e-6);这三个断言分别验证了中间索引对应 0°、正向偏移 10 个 bin 对应 40°、负向偏移 10 个 bin 对应 -40°。若把输出名写错、把 pitch 和 yaw 拿反、或者把 bin 步长写错这类断言会先于摄像头测试暴露问题。你把DecodeAngle的参数改成 90 类、2° 步长断言也跟着改模型换版本后回归成本就很低。如果模型输出的是 1x1 的连续值而不是分类向量这套验证会直接暴露出来因为角度计算结果和真实值的对应完全对不上。本文还有配套的精品资源点击获取
返回列表