简介:这是一份面向C#开发者的完整示例工程,基于OpenCvSharp与L2CS-Net算法实现人脸检测、眼睛注视方向和头部朝向估计,适合想将ONNX模型部署到Windows桌面应用的视觉开发者参考。工程按功能拆分为人脸检测、L2CS推理管理、人脸管理、主窗体交互等模块,源码结构清晰;同时将OpenCvSharp 4.8.0、Microsoft.ML.OnnxRuntime 1.16.3等运行库和ONNX模型一并打包,在VS2019与.NET Framework 4.7.2环境下可直接打开项目编译运行。压缩包共41个文件,大小约162.66MB,主要包含C#源码、动态链接库、模型文件、配置与资源文件,以及操作演示视频;其中源码对应核心算法与界面逻辑,DLL与ONNX文件用于运行推理,配置和资源文件支撑项目编译,视频则展示实际效果,方便对照学习。目前已有179人学习下载,配套的演示视频和博客说明可帮助理解模型推理流程、参数配置和界面展示逻辑,节省环境搭建及调试时间,是人脸朝向估计入门与快速落地的实用资料。
1. 为什么把 L2CS-Net 接到 C#:人脸朝向与注视判断的工业落地路径
在 C# 上位机项目里做“注意力监测”或者“视线互动”时,最尴尬的不是算法选型,而是模型跑通了却搬不进工程。常见做法人脸检测用 OpenCvSharp 已经非常顺手,但 L2CS-Net 这种输出 90 分类 bin 的模型,官方生态基本是 PyTorch,C# 侧没有现成封装。真正落地的路径其实是把 PyTorch 权重导出成 ONNX,然后让 OpenCvSharp 负责采集、裁剪、绘制,ONNX Runtime 负责推理。整个过程不需要 Python 服务,不需要跨进程通信,延迟完全可控。适合的场景包括司机疲劳监控、坐姿偏移提醒、屏幕前注视区域判断,以及任何想要在本地实时拿到头部欧拉角或视线方向的 C# 桌面程序。
2. 理解 L2CS-Net 的输出结构和 OpenCvSharp 预处理:从人脸框到 224×224 张量
2.1 模型输出不是角度,而是 90 个 bin 的分类结果
很多第一次接触 L2CS-Net 的人会以为模型输出的是一个浮点数角度,实际不是。L2CS-Net 的思路是把角度范围离散成 90 个区间,网络最后是一个分类头,每个头输出长度 90 的 logits,经过 Softmax 得到概率分布。这样做比直接回归角度更稳,尤其是视线角度在大范围变化时,回归头容易在边界处出现抖动,而分类头天然对噪声更鲁棒。
常见导出的 ONNX 模型里,输入是 1×3×224×224 的 RGB 浮点张量;输出取决于你导出的是 gaze 分支还是 head pose 分支。gaze 分支通常有 yaw 和 pitch 两个 1×90 输出,head pose 分支除了 yaw、pitch,往往还会带 roll。角度范围和 bin 步长我按经验整理如下:
| 输出 | 覆盖范围 | bin 数量 | 单格步长 | argmax 换算 |
|---|---|---|---|---|
| yaw | -180° 到 180° | 90 | 4° | index × 4 - 180 |
| pitch | -90° 到 90° | 90 | 2° | index × 2 - 90 |
| roll(head pose 分支) | -90° 到 90° | 90 | 2° | index × 2 - 90 |
工程上最容易犯的错是把这 1×90 的张量当成回归值直接取第一个元素。正确做法是 Softmax 之后再做角度换算。至于用 argmax 还是加权平均,后面第 6 章会专门讲,视频场景里两者差别非常大。
2.2 用 OpenCvSharp 做预处理:别直接用 BlobFromImage
我见过不少人在 OpenCvSharp 里直接用Cv2.Dnn.BlobFromImage把 Mat 转成模型输入,结果角度偏得离谱。原因有两个:一是BlobFromImage的 mean/std 参数顺序和 PyTorch 的(x / 255 - mean) / std并不总能对齐;二是它返回的是 OpenCV 自己的 Mat 布局,喂给 ONNX Runtime 前还得再做一次张量转换,中间环节一多就容易在通道顺序上翻车。
我的习惯是:图像处理全部走 OpenCvSharp,但最终自己构造 DenseTensor,通道、归一化、缩放全部显式控制。下面这个函数把一个已经检测到的人脸框转成 L2CS-Net 需要的输入张量:
using System; using System.Runtime.InteropServices; using Microsoft.ML.OnnxRuntime.Tensors; using OpenCvSharp; namespace L2CSSharp { public static class L2CSPreprocess { private static readonly float[] Mean = { 0.485f, 0.456f, 0.406f }; private static readonly float[] Std = { 0.229f, 0.224f, 0.225f }; public static DenseTensor<float> ToTensor(Mat bgrFrame, Rect face, int inputSize = 224) { int margin = (int)(face.Width * 0.25); Rect roi = face; roi.Inflate(margin, margin); roi &= new Rect(0, 0, bgrFrame.Width, bgrFrame.Height); using (Mat cropped = new Mat(bgrFrame, roi)) using (Mat rgb = new Mat()) using (Mat resized = new Mat()) { Cv2.CvtColor(cropped, rgb, ColorConversionCodes.BGR2RGB); Cv2.Resize(rgb, resized, new Size(inputSize, inputSize)); var tensor = new DenseTensor<float>(new[] { 1, 3, inputSize, inputSize }); Mat[] channels = Cv2.Split(resized); float[] channelBuf = new float[inputSize * inputSize]; for (int c = 0; c < 3; c++) { using (Mat ch = channels[c]) { ch.ConvertTo(ch, MatType.CV_32FC1, 1.0 / 255.0); Cv2.Subtract(ch, Scalar.All(Mean[c]), ch); Cv2.Divide(ch, Scalar.All(Std[c]), ch); Marshal.Copy(ch.Data, channelBuf, 0, channelBuf.Length); int offset = c * inputSize * inputSize; for (int i = 0; i < channelBuf.Length; i++) { tensor.Buffer.Span[offset + i] = channelBuf[i]; } } } return tensor; } } } }这个函数有几个点值得单独说明。margin把检测框向外扩了 25%,这是 L2CS-Net 推理的一个关键参数:人脸检测器给的框往往紧贴面部轮廓,如果直接裁剪,额头和下巴会被切掉,头部姿态角度会整体偏移。扩框后必须与图像边界做一次相交,防止roi超出图像范围导致new Mat(bgrFrame, roi)异常。
Cv2.Split把 RGB 图的三个通道拆开,逐通道做ConvertTo归一化到[0,1],再做减均值除方差。注意Cv2.Subtract和Cv2.Divide的 in-place 写法,OpenCvSharp 的Scalar.All在这里是作为标量参与运算。最后把每个通道的数据复制到 CHW 布局的张量对应偏移位置。channelBuf预先分配一次,循环里复用,避免每帧产生新数组。
人脸检测部分不展开讲,但最简单可行的方案是 OpenCV 自带的 Haar Cascade:
var cascade = new CascadeClassifier("haarcascade_frontalface_default.xml"); using (Mat gray = new Mat()) { Cv2.CvtColor(frame, gray, ColorConversionCodes.BGR2GRAY); Rect[] faces = cascade.DetectMultiScale(gray, 1.1, 5, minSize: new Size(80, 80)); }如果对检测精度要求高,可以换成 OpenCvSharp 4.5 以上自带的FaceDetectorYN,它基于 YuNet,在侧脸和小角度场景下比 Haar 可靠得多。但无论用哪个检测器,喂给 L2CS-Net 的框都必须经过上面的扩框处理,这一步省不掉。
3. 用 ONNX Runtime 跑推理:最小 C# 预测类和输出张量解析
3.1 为什么优先选 ONNX Runtime,而不是 Cv2.Dnn
OpenCvSharp 自带Cv2.Dnn,也能读 ONNX 并做推理,不少教程直接用它能跑通。但我在实际项目中很少拿它跑 L2CS-Net,主要原因是算子兼容性和张量解析两个环节太折腾。L2CS-Net 的 ONNX 里包含 Softmax、Reshape、Gemm 这类常见算子,OpenCV DNN 基本都支持,但一旦你手里的导出版本稍微老一点,加入了一些自定义节点,OpenCV DNN 就会直接拒绝加载。ONNX Runtime 对 PyTorch 导出的兼容性要稳得多,几乎只要是标准 ONNX 就能跑。
另一个理由在张量层面。Cv2.Dnn推理完之后拿到的还是 Mat,你想拿到那个 1×90 的输出概率分布,得自己算 step、查 shape、再绕一圈 Mat 的索引访问。ONNX Runtime 的Tensor<float>直接ToArray()就能变成 C# 数组,配合后面要做的 Softmax 加权,代码读起来清楚得多。两边对比看这张表:
| 对比点 | OpenCvSharp Dnn | ONNX Runtime |
|---|---|---|
| 算子兼容性 | 对非标准 ONNX 节点敏感 | 官方维护,导出模型兼容性更好 |
| 输入构造 | BlobFromImage 的 mean/std 顺序容易踩坑 | 自己构建 DenseTensor,行为透明 |
| 输出解析 | Mat 维度要手动核对 | Tensor 直接 ToArray |
| 加速后端 | OpenCL/CUDA 支持有限 | CPU、CUDA、DirectML 都有官方 EP |
所以我的建议是:OpenCvSharp 只做图像采集、裁剪和可视化,模型推理交给 ONNX Runtime。这样职责清晰,后续换 GPU 加速也不需要动图像处理代码。
3.2 预测类:从 Mat 到 yaw/pitch 角度的一次完整调用
下面这个类封装了 L2CS-Net 的完整推理。它对输入图片一个人脸框,返回 yaw 和 pitch 两个角度。注意这里假设导出的 ONNX 输出名是yaw和pitch,遇到命名不同的模型,先打印一次输出名称再改对应字段就行。
using System; using System.Collections.Generic; using System.Linq; using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using OpenCvSharp; namespace L2CSSharp { public class L2CSPredictor { private readonly InferenceSession _session; private readonly string _inputName; public L2CSPredictor(string onnxPath) { var opts = new SessionOptions(); opts.AppendExecutionEP_CPU(); _session = new InferenceSession(onnxPath, opts); _inputName = _session.InputMetadata.Keys.First(); } public void Predict(Mat bgrFrame, Rect face, out double yaw, out double pitch) { using (DenseTensor<float> tensor = L2CSPreprocess.ToTensor(bgrFrame, face)) { var feeds = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor<float>(_inputName, tensor) }; using (RunResult results = _session.Run(feeds)) { float[] yawDist = results.First(r => r.Name == "yaw").AsTensor<float>().ToArray(); float[] pitchDist = results.First(r => r.Name == "pitch").AsTensor<float>().ToArray(); yaw = SoftmaxToAngle(yawDist, -180.0, 180.0); pitch = SoftmaxToAngle(pitchDist, -90.0, 90.0); } } } private static double SoftmaxToAngle(float[] dist, double rangeMin, double rangeMax) { double max = dist.Max(); double sum = 0; double acc = 0; for (int i = 0; i < dist.Length; i++) { double p = Math.Exp(dist[i] - max); sum += p; acc += p * i; } double idx = acc / sum; return rangeMin + idx * (rangeMax - rangeMin) / (dist.Length - 1); } } }SoftmaxToAngle这段是整个推送逻辑里最值得花时间看的地方。我没有直接拿argmax的索引去计算角度,而是先做 Softmax,再按概率加权平均得到 bin 索引。这样算出来的角度是连续值,而不是一格一格跳的离散值。Math.Exp(dist[i] - max)是防止指数溢出,dist 里的 logits 可能到十几甚至二十几,直接Math.Exp容易变成Infinity,减去最大值后数值就安全了。如果你发现角度在边界处有轻微抖动,也可以用argmax先看整体是否合理,再切到加权方式。
SessionOptions里目前只挂了 CPU EP。如果你装了 NVIDIA 显卡驱动和 CUDA,可以把AppendExecutionEP_CPU()换成AppendExecutionEP_CUDA(),但要注意需要额外引用Microsoft.ML.OnnxRuntime.GPU包。如果不确定目标机器环境,CPU 推理 224×224 输入单次约 20 到 40 毫秒,对于大多数上位机场景已经够用。
另外注意session.Run的返回值RunResult是IDisposable,using包围能及时释放输出张量。这段代码在多人脸场景会被循环调用,每一帧创建 List 和 Dispose,开销不大,但如果你压到毫秒级优化,可以把这个 List 提到循环外复用。
4. 把角度可视化:注视箭头、头部姿态轴与多人脸循环
4.1 绘制注视方向箭头:符号方向是参数,不是玄学
拿到 yaw 和 pitch 之后,最直接的调试方式是在人脸框中心画一条带箭头的视线。第一次跑通时大概率会碰到方向反了的情况,这不是 bug,是不同 L2CS-Net 导出版本对 yaw 正负号的定义不一致。我的做法先画出来再确认符号,画反了就把正弦前面的负号去掉。
private static void DrawGaze(Mat frame, Rect face, double yaw, double pitch, Scalar color) { int cx = face.X + face.Width / 2; int cy = face.Y + face.Height / 2; double yawRad = yaw * Math.PI / 180.0; double pitchRad = pitch * Math.PI / 180.0; int dx = (int)(-Math.Sin(yawRad) * 80); int dy = (int)(Math.Sin(pitchRad) * 80); Cv2.ArrowedLine(frame, new Point(cx, cy), new Point(cx + dx, cy + dy), color, 2, LineTypes.AntiAlias); Cv2.Circle(frame, new Point(cx + dx, cy + dy), 4, color, -1); }这里把 yaw 映射到了水平方向的dx,pitch 映射到了垂直方向的dy,线段长度固定 80 像素。严格来说这不是真正的 3D 投影,但对调试已经足够。如果要做完整的头部姿态轴,需要把 yaw、pitch、roll 先转成旋转矩阵再做投影,代码会复杂一个量级,一般只有姿态估计精度验证时才需要。
箭头端点画一个小圆,方便观察视线指向的位置。如果你后续要做注视区域判断,比如判断司机在看左后视镜还是前方,这个端点在画面里的坐标就是判断依据。实际使用时建议把lineLength改成与脸部宽度成比例,否则靠近摄像头时箭头会显得很短。
4.2 多人脸场景:检测循环与结果合并
真实场景里画面中往往不止一个人。处理方式很朴素:用检测器拿到所有Rect,挨个调用Predict,再逐个绘图。但要注意不要每帧都重新创建 Cascade 和 Predictor,这两个对象初始化开销不小,应该在窗体或服务启动时创建一次。
var detector = new CascadeClassifier("haarcascade_frontalface_default.xml"); var predictor = new L2CSPredictor("l2csnet.onnx"); using (Mat frame = new Mat()) using (Mat gray = new Mat()) { while (capture.Read(frame)) { Cv2.CvtColor(frame, gray, ColorConversionCodes.BGR2GRAY); Rect[] faces = detector.DetectMultiScale(gray, 1.1, 5, minSize: new Size(80, 80)); foreach (Rect face in faces) { predictor.Predict(frame, face, out double yaw, out double pitch); DrawGaze(frame, face, yaw, pitch, Scalar.Red); } Cv2.ImShow("L2CS-Net", frame); if (Cv2.WaitKey(1) == 27) break; } }多人脸时另一个值得注意的问题是画面分辨率。检测器在大分辨率下能发现更远的人脸,但每张脸裁剪后都要缩放到 224×224,如果人脸本身只有二三十像素,缩放后细节丢失,角度误差会明显变大。我一般会同时给检测器加一个minSize限制,低于这个尺寸的人脸不参与姿态估计,减少无效计算和误报。
5. 避坑:C# 侧跑 L2CS-Net 的 5 个高频踩坑点
5.1 人脸检测框太紧,导致头部姿态角度整体偏移
现象:单独跑一张大脸照片,yaw 输出总是偏小,比如人明显转向左侧,输出只有十几度。原因很直接,Haar 或 YuNet 给出的人脸框通常紧贴眉毛、脸颊、下巴,直接裁剪会把前额和颈部裁掉,L2CS-Net 看到的不是一个完整的头部形状,角度自然偏。解决方式是在预处理里把人脸框向外扩。我在第 2 章代码里用的是face.Width * 0.25,这个系数在正脸和侧脸场景下都稳定。但注意如果你的检测器框本身已经包含较多背景,扩框系数要降到0.1左右,否则背景占比过高也会干扰姿态判断。
5.2 BGR 与 RGB 通道混用,模型精度无声下降
现象:模型在测试图集上跑得很好,换成自己摄像头画面后角度整体偏移且带有诡异的方向性。原因通常是 OpenCvSharp 读出来的是 BGR,而 L2CS-Net 训练时用的是 RGB。很多人会想当然认为“颜色不影响形状”,但实际上 ImageNet 预训练模型的通道 mean/std 是按 RGB 顺序设计的,你把 BGR 数据喂进去,等于把 R 和 B 通道的信息对调,对颜色敏感的特征图全部错位。解决:预处理里明确Cv2.CvtColor(bgr, rgb, ColorConversionCodes.BGR2RGB)。如果你坚持用BlobFromImage,也要小心它内部对 mean/std 的处理顺序,最稳的办法还是像我第 2 章那样自己拆通道构造张量。
5.3 argmax 输出导致视频里角度跳动剧烈
现象:静止摄像头前轻微晃动头部,yaw 的输出在几个固定值之间跳,比如 12°、16°、20° 反复横跳,视觉上非常不自然。原因是直接对 90 个分类结果取最大索引,每次只取一个 bin,而模型在相邻 bin 之间本身就有概率重叠,头部微小运动就会让最高概率的 bin 切换。解决:用 Softmax 后所有 bin 的加权平均作为输出,相当于在概率分布上做了期望值计算。你会发现输出角度平滑很多,而且不需要额外写滤波器。这个改动只影响角度解析,不影响推理性能。
5.4 逐像素填充张量导致帧率断崖式下跌
现象:在 Debug 模式下面推理单帧要 100 毫秒以上,Release 模式下也只有 20 FPS,远低于模型本身应有的速度。原因很可能在张量填充环节。我看到有人用三层 for 循环遍历Mat.At<float>逐像素,再嵌套通道循环,224×224×3 就是 15 万次调用,At<float>在 OpenCvSharp 里还有类型检查和步幅计算开销,性能完全浪费。解决:用Cv2.Split分通道,配合Marshal.Copy一次性把连续内存复制到byte[]或float[],再整体写入张量。把循环控制在每通道一次批量复制。如果你对性能还有更高要求,可以把张量创建也提到循环外复用,DenseTensor底层 Buffer 是可以重复填充的。
5.5 ONNX Runtime 加载报错:DllNotFound 或 AVX 指令集不支持
现象:程序在开发机跑得好好的,部署到工控机上报Failed to load library或者DllNotFound。常见原因有三个。一是 NuGet 只引用了Microsoft.ML.OnnxRuntime管理包,没有把对应运行时的原生 DLL 带到输出目录,检查runtimes/win-x64/native/onnxruntime.dll是否存在。二是目标机器 CPU 比较老,不支持 AVX 指令集,ONNX Runtime 的标准包在加载时会直接崩溃,解决办法是换用Microsoft.ML.OnnxRuntime的 ARM 或 CPU 兼容包,或者退回 ONNX Runtime 1.15 以下的某个旧版本。三是 OpenCvSharp 本身依赖 VC++ 运行库,工控机如果缺运行库会先报 OpenCvSharp 的 DllNotFound,混淆排查方向。先单独写一个new InferenceSession("l2csnet.onnx")的最小程序,能确认是 ONNX Runtime 还是 OpenCvSharp 的加载问题。
6. 用视频流验证与调优:让角度稳定、误差可评估、并发不打架
6.1 角度平滑:不能直接对角度做平均
拿到 yaw 后,很多人第一反应是做 EMA 平滑:prev + alpha * (curr - prev)。这个公式在角度接近 0° 时没问题,但在 ±180° 的边界处会翻车。比如上一帧 yaw 是 178°,这一帧是 -178°,实际只转了 4°,但直接平均会得到 0°,箭头瞬间从右侧甩到左侧。正确做法是先计算角度差,把差值折叠到 [-180°, 180°] 区间,再应用到上一帧:
private static double WrapAngleDiff(double delta) { delta = (delta + 180.0) % 360.0; if (delta < 0) delta += 360.0; return delta - 180.0; } private static double SmoothAngle(double prev, double curr, double alpha = 0.3) { return prev + alpha * WrapAngleDiff(curr - prev); }注意Math.IEEERemainder也可以实现角度差折叠,但%运算符在浮点运算里更快,处理 ±180 边界已经足够。alpha我一般取0.3,太小输出反应迟钝,太大又失去平滑意义。多目标场景里每个Rect需要单独维护一个prevAngle,如果你用字典按人脸框位置做追踪,记得在目标消失时删除对应条目。
6.2 用固定场景做定量验证,而不是凭感觉调参
接入摄像头后很容易陷入“感觉差不多”的状态。我的做法是准备一张打印的人脸照片,或者用一个 3D 人脸网格模型固定在旋转台上,分别记录 0°、±30°、±60° 的读数。这能快速判断三个问题:yaw 符号是否反了、pitch 比例是否合理、输出是否稳定。如果你只有一个普通摄像头,最简单的验证是让被测者正对镜头后向左转头,观察箭头是否缓慢向左移动,同时记录最大角度,如果超过 90°,那说明你模型的 bin 换算范围可能设错了。
6.3 多路视频或线程池场景:每个线程一个 Session
生产环境里摄像头往往不止一路。比如驾驶舱里一个广角镜头拍驾驶员,一个窄角镜头拍副驾,两路画面可能由两个采集线程处理。这时候不要把单个InferenceSession放到多个线程里并发调用,ONNX Runtime 的 Session 内部状态不是完全线程安全的,最稳妥的做法是每个线程创建自己的L2CSPredictor。Session 初始化开销比较大,但创建之后可以长期复用。线程内再做一次低通滤波和标题绘制,最后把结果通过回调抛给 UI 线程。如果你的视频流来源是 RTSP,注意用 OpenCvSharp 的VideoCapture时把RtspTransport设为 TCP,避免 UDP 丢包导致画面花帧,进而让人脸检测框在高频跳动,姿态估计也就跟着抖。
这套路径走到这里已经足够支撑一个实时姿态估计模块了。我自己在交付类似项目时,最后一步还会做一段 30 分钟的连续运行压测,观察InferenceSession.Run是否有内存增长,以及 GPU EP 切换后是否有显存泄漏。一边看画面里的箭头是否稳定,一边看任务管理器里的内存曲线,这种综合验证比单帧跑多少毫秒更能暴露问题。希望帮到你。
本文还有配套的精品资源,点击获取