简介:采用C#与OpenCvSharp实现的L2CS-Net本地推理方案,专为需要在桌面端完成眼睛注视方向或人脸朝向估计的开发者准备。基于WinForm界面与ONNX Runtime加载模型,可离线运行,适合人机交互、疲劳监测、视线追踪等应用场景的快速验证与集成。
资源包共41个文件,总体积162.66MB,涵盖C#工程源码、编译后DLL、两个ONNX模型、界面设计文件及配置项,并包含可直接运行的exe与演示录屏。源码分为人脸检测、视线估计管理、主窗体等清晰模块,便于定位与修改关键逻辑。
已有179人学习下载。通过这份完整工程,可了解从摄像头帧获取、人脸检测、L2CS-Net推理到结果绘制的完整流程,也可基于现有框架替换模型或调整输入参数,作为后续二次开发的基础。整体适合具备基础C#和深度学习概念的中级开发者。
1. 先用C# OpenCvSharp跑通L2CS-Net:眼睛注视与人脸朝向这事,没有想象中那么玄
最近把一个看上去很“学术”的模型——L2CS-Net——落到了一个C# WinForm工程里,用来同时判断眼睛注视方向和人脸朝向。做上位机或者视觉检测的朋友应该能get到点:很多时候我们不需要活体识别那种级别的东西,只想知道“摄像头前这个人大概在看哪、脸是不是正对着屏幕”,这就够了。这个工程把OpenCvSharp的DNN人脸检测和ONNX Runtime的L2CS推理串成了一条完整管线,VS2019直接打开就能跑,.NET Framework 4.7.2,依赖锁定在OpenCvSharp 4.8.0 + Microsoft.ML.OnnxRuntime 1.16.3。反直觉的一点是:模型推理本身只占一半工作量,另一半全花在张量布局、归一化顺序、Session生命周期这些看似不起眼的地方——而这些恰恰是新手最容易被绊倒的地方。
2. 工程先铺开:FIRC解决方案布局与OpenCvSharp 4.8.0环境搭法
2.1 一个能直接跑的VS2019工程,文件各自干什么
拿到这个压缩包,解压后是一个完整的VS2019解决方案,sln名字叫FIRC.sln。整个工程是WinForms,Form1是主窗体,核心逻辑被拆到了三个类里:FaceDetector、L2CSManager、FaceManager。这种拆法值得借鉴——检测、推理、调度三层分开,后面换模型或者调参都不用动UI代码。
| 文件/目录 | 职责 |
|---|---|
| Form1.cs | 主窗体逻辑,负责选图、启动摄像头、把结果画到界面上 |
| FaceDetector.cs | 封装OpenCvSharp DNN人脸检测器,输出人脸框和关键点 |
| L2CSManager.cs | 封装L2CS-Net的ONNX模型加载、预处理、推理、后处理 |
| FaceManager.cs | 调度层:拿人脸框 → 裁图 → 喂给L2CS → 取回角度 |
| bin/x64/Debug | 编译输出目录,模型文件一般放这里或单独建models目录 |
| Resources.resx | 窗体的图标、图片等资源 |
需要说明的是,FaceManager这个“调度层”是这工程里承上启下的角色。FaceDetector只负责回答“人脸在哪”,L2CSManager只负责回答“这张脸在看哪”,而FaceManager把两个答案拼起来。代码里它是被Form1在Load事件里初始化的,之后所有界面按钮都走它,不让UI直接碰模型对象。这样做的好处是:如果你以后想换一个人脸检测器,改FaceDetector内部就行,L2CSManager和Form1都不用动。
2.2 NuGet依赖三件套:OpenCvSharp 4.8.0与ONNX Runtime 1.16.3
环境要求很明确:VS2019、.NET Framework 4.7.2、OpenCvSharp 4.8.0、Microsoft.ML.OnnxRuntime 1.16.3。前两个好说,后两个是踩坑高发区,版本一定要对。OpenCvSharp 4.8.0对应的是OpenCV 4.8.0的封装,DNN模块支持FaceDetectorYN(YuNet)这一套。ONNX Runtime 1.16.3则是跑L2CS导出的onnx模型用的——注意它不是OpenCvSharp的一部分,是单独的一个NuGet包。
打开NuGet包管理器,按这个清单装:
Install-Package OpenCvSharp4.Windows -Version 4.8.0.20230708 Install-Package OpenCvSharp4.WinForms -Version 4.8.0.20230708 Install-Package OpenCvSharp4.runtime.win -Version 4.8.0.20230708 Install-Package Microsoft.ML.OnnxRuntime -Version 1.16.3装完以后,在解决方案里确认一下packages.config或packages目录下的程序集引用都指向了x64。这个工程是从x64 Debug编译的,如果改成x86,OpenCvSharp的原生dll加载会直接报“无法加载DLL”之类的错,这是OpenCvSharp一个老毛病——托管层是AnyCPU没用的,原生层必须跟着进程位数走。
<PropertyGroup> <PlatformTarget>x64</PlatformTarget> </PropertyGroup>2.3 初始化顺序:Form1 → FaceManager → L2CSManager
整个启动流程我梳理了一下,大概是这样的:Form1构造完后,在Load事件里new一个FaceManager,FaceManager内部再分别创建FaceDetector和L2CSManager。模型文件路径、置信度阈值、输入尺寸这些参数都在构造时传进去。我一般习惯把模型路径写在App.config里,方便换机器时改路径不用重新编译。
public partial class Form1 : Form { private FaceManager _faceManager; public Form1() { InitializeComponent(); } private void Form1_Load(object sender, EventArgs e) { // 模型路径按实际位置调整 string faceDetectModel = @"models\face_detection_yunet.onnx"; string l2csModel = @"models\l2cs_net.onnx"; _faceManager = new FaceManager(faceDetectModel, l2csModel); _faceManager.Initialize(); } }这段代码里需要注意Initialize()这一步——它不是简单new完就结束,内部要做两件事:一是确认两个模型文件存在,二是预创建ONNX Runtime的Session对象。把初始化单独拎出来而不是塞进构造函数,是为了以后加“模型加载失败提示”或者“启动进度条”时不用改构造函数签名。如果你拿到工程后发现运行时报错说找不到模型文件,优先检查bin目录下有没有models文件夹,或者App.config里的路径是不是绝对路径。
3. L2CS-Net推理主链路:ONNX Runtime 1.16.3下的张量处理与角度解算
3.1 L2CS-Net在预测什么:90个bin的分类输出,而不是直接回归角度
L2CS-Net全称是“Look at the Center of Screen”相关的注视估计网络,核心思路是把注视方向预测拆成两个独立的分支:yaw(左右偏航角)和pitch(上下俯仰角)。这里有个关键细节——它不是像很多姿态模型那样直接回归一个连续角度值,而是把角度范围离散成90个类别,输出90个bin的分类概率,最后用softmax后的概率加权求期望得到角度。这样做的好处是训练更稳定,坏处是推理时多一步后处理,很多第一次用的人在这里拿不到角度,就是因为漏了softmax和期望这一步。
人脸朝向(head pose)分支则是三个输出——yaw、pitch、roll(翻滚角),同样是90个bin。工程里如果选择“人脸朝向”模式,就取三个分支;选择“眼睛注视”模式,取两个分支。这也就是为什么L2CSManager里能看到对输出个数做分支判断的逻辑。
| 模式 | 输出张量个数 | 每个张量含义 |
|---|---|---|
| gaze(注视) | 2 | yaw 90bin、pitch 90bin |
| head pose(朝向) | 3 | yaw 90bin、pitch 90bin、roll 90bin |
3.2 预处理:从Mat人脸图到DenseTensor ,BGR转RGB这一步不能省
L2CS在PyTorch里训练时用的是RGB图像,而OpenCvSharp读出来的Mat默认是BGR通道顺序。如果直接把BGR的Mat数据塞进张量,模型不是完全不能用,但角度会明显偏移,而且你很难排查——因为人脸框检测是正常的,只有输出角度不对,这种“活见鬼”的现象最容易让人怀疑模型文件坏了。
我一般在L2CSManager里写一个独立的MatToTensor方法,把预处理固定下来:resize到224x224、BGR转RGB、除以255、再按ImageNet的均值和标准差做归一化。这套参数跟L2CS官方仓库保持一致。
public static Tensor<float> MatToTensor(Mat face) { // 统一尺寸:模型输入是224x224 Mat resized = new Mat(); Cv2.Resize(face, resized, new Size(224, 224)); // OpenCV默认BGR,模型训练用RGB,这里必须转换 Mat rgb = new Mat(); Cv2.CvtColor(resized, rgb, ColorConversionCodes.BGR2RGB); // 创建NCHW布局张量:1张图、3通道、224高、224宽 var tensor = new DenseTensor<float>(new[] { 1, 3, 224, 224 }); for (int c = 0; c < 3; c++) { for (int h = 0; h < 224; h++) { for (int w = 0; w < 224; w++) { float pixel = rgb.At<Vec3b>(h, w)[c]; // 归一化:除以255后按均值/方差缩放 if (c == 0) tensor[0, c, h, w] = (pixel / 255f - 0.485f) / 0.229f; else if (c == 1) tensor[0, c, h, w] = (pixel / 255f - 0.456f) / 0.224f; else tensor[0, c, h, w] = (pixel / 255f - 0.406f) / 0.225f; } } } return tensor; }这段代码里最容易被忽略的是At<Vec3b>(h, w)[c]的索引语义——在OpenCvSharp里,[c]取通道,但通道顺序是BGR。因为前面已经转了RGB,这里再按c=0,1,2取出来的就是R、G、B了。如果你把CvtColor这行去掉,那么c=0取到的是B通道的值,喂给模型后,角度会偏到让你怀疑人生。
3.3 推理与后处理:Session.Run拿输出,softmax+加权期望解出yaw/pitch
L2CSManager的核心推理代码不长,但信息密度高。创建Session时用SessionOptions把设备设为CPU,Run()方法传入输入张量名称和输出张量名称。输入的节点名一般是input,输出的节点名在gaze模式下是output_yaw和output_pitch之类的名字,具体以你手上的onnx为准——建议拿Python的onnxruntime看一眼session.get_outputs(),或者用Netron打开模型确认。
public (float yaw, float pitch) Infer(Mat face) { using var inputTensor = MatToTensor(face); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("input", inputTensor) }; // 输出节点名以实际onnx为准,常见是output_yaw/output_pitch var outputs = _session.Run(inputs, new[] { "output_yaw", "output_pitch" }); float[] yawLogits = outputs[0].AsTensor<float>().ToArray(); float[] pitchLogits = outputs[1].AsTensor<float>().ToArray(); float yaw = SoftmaxExpectation(yawLogits); float pitch = SoftmaxExpectation(pitchLogits); return (yaw, pitch); } private float SoftmaxExpectation(float[] logits) { // 先算softmax,再用bin中心值做加权期望 double sumExp = 0; double[] exp = new double[logits.Length]; for (int i = 0; i < logits.Length; i++) { exp[i] = Math.Exp(logits[i]); sumExp += exp[i]; } float angle = 0; for (int i = 0; i < logits.Length; i++) { float prob = (float)(exp[i] / sumExp); angle += prob * (i * 2.0f - 90.0f); // 90个bin,每个bin覆盖2度,中心从-90到88 } return angle; }参数说明:i * 2.0f - 90.0f这个映射不是拍脑袋写的。L2CS官方实现里,90个bin对应的角度范围是-90度到90度,每个bin宽度2度,第i个bin的中心值是i * 2 - 90。如果你的onnx模型是从别的分支导出的,bin数量可能是100,那这个公式要相应改成(i * 1.8f - 90.0f)之类的映射,务必先用Python验证一个已知角度的图片再集成到C#里。我见过有人在这个公式上栽跟头——模型输出看起来是0.3到0.5这样的小数,以为要乘个大系数,其实人家已经是角度了。
4. 人脸检测到注视方向的衔接:FaceDetector与L2CS的配合及避坑清单
4.1 FaceDetector先框住人脸:FaceDetectorYN/YuNet的输出是Nx15的Mat
L2CS模型只接受“已经裁好的人脸图”,所以第一步永远是人脸检测。工程里的FaceDetector.cs封装的是OpenCvSharp DNN模块自带的FaceDetectorYN,也就是YuNet。它是OpenCV 4.8里新整合的轻量人脸检测器,速度和精度平衡得不错,在CPU上单张图大概几毫秒到十几毫秒。
public class FaceDetector { private FaceDetectorYN _detector; public FaceDetector(string modelPath, int inputWidth = 320, int inputHeight = 320) { // 输入尺寸决定检测精度,320x320速度最快,640x640更准 _detector = FaceDetectorYN.Create( modelPath, "", new Size(inputWidth, inputHeight), scoreThreshold: 0.7f, nmsThreshold: 0.3f, topK: 10 ); } public List<Rect> Detect(Mat image) { _detector.SetInput(image); using Mat faces = new Mat(); _detector.Forward(faces); var results = new List<Rect>(); // YuNet输出Nx15:x, y, w, h, 右眼x,右眼y,左眼x,左眼y,鼻尖x,鼻尖y,右嘴角x,右嘴角y,左嘴角x,左嘴角y,置信度 for (int i = 0; i < faces.Rows; i++) { float x = faces.At<float>(i, 0); float y = faces.At<float>(i, 1); float w = faces.At<float>(i, 2); float h = faces.At<float>(i, 3); results.Add(new Rect((int)x, (int)y, (int)w, (int)h)); } return results; } }这里有一个值得注意的点:Forward输出的Mat行数就是检测到的人脸数,列数固定是15。很多第一次用的人以为要先解析DetectedFaces数据结构,其实OpenCvSharp里FaceDetectorYN.Forward直接给的就是原始浮点Mat,用At<float>(i, j)按列索引取值就行。阈值参数上,scoreThreshold我习惯设0.7,低于这个值容易把背景当脸;如果你摄像头角度偏、人脸小,可以放宽到0.5,代价是误检变多。
4.2 人脸框跟L2CS的衔接:坐标别直接用,先做边界收缩和越界保护
拿到人脸框以后,不能直接把原图按这个Rect裁出来喂给L2CS。原因有二:一是人脸检测框通常把人脸轮廓框得比较贴,而L2CS在训练时输入包含了一部分额头和下巴以外的背景,需要往外扩一点;二是当人脸在画面边缘时,裁框会超出图像边界,Mat的new Mat(src, roi)一旦roi越界会抛异常。
我在FaceManager里一般这样处理:
public List<FacePoseResult> ProcessFrame(Mat frame) { var faces = _faceDetector.Detect(frame); var results = new List<FacePoseResult>(); foreach (var face in faces) { // 外扩20%,避免裁掉额头和下巴 int expandW = (int)(face.Width * 0.2); int expandH = (int)(face.Height * 0.2); int x1 = Math.Max(0, face.X - expandW); int y1 = Math.Max(0, face.Y - expandH); int x2 = Math.Min(frame.Cols, face.X + face.Width + expandW); int y2 = Math.Min(frame.Rows, face.Y + face.Height + expandH); if (x2 - x1 <= 0 || y2 - y1 <= 0) continue; using Mat faceCrop = new Mat(frame, new Rect(x1, y1, x2 - x1, y2 - y1)); var (yaw, pitch) = _l2cs.Infer(faceCrop); results.Add(new FacePoseResult { Bbox = new Rect(x1, y1, x2 - x1, y2 - y1), Yaw = yaw, Pitch = pitch }); } return results; }这段代码的边界收缩逻辑是“保住不崩”的关键防线。尤其在做摄像头实时推理时,人脸从画面边缘走入的瞬间,检测框刚出现就有一半在画面外,如果不做Math.Max(0, ...)和Math.Min(...)的保护,程序会直接崩在new Mat(frame, roi)这行,而且是必现的,不是偶现。外扩20%是我试过多个模型后的经验值,L2CS对头部轻微偏转的容忍度跟裁图范围关系很大,扩太多会把旁边的人脸也裁进来,扩太少精度掉得厉害。
4.3 避坑清单:现象、原因、解决
这一节是拿真金白银换来的,每一条都对应一个真实翻车现场。
坑一:角度输出永远在一个小范围内抖动,且数值明显不对。现象是yaw输出长期在-3到+3之间,哪怕人明显转向侧面也是这个数。原因是MatToTensor里漏了BGR转RGB,模型拿到的通道顺序是反的。解决:回到MatToTensor加上Cv2.CvtColor(resized, rgb, ColorConversionCodes.BGR2RGB),然后重新验证一个已知角度的图片。这个坑最大的迷惑性在于——人脸检测正常、推理不报错、数值稳定,只有跟真值对比才发现全错了。
坑二:人脸在画面边缘时程序崩溃或花屏。现象是点击开始检测后,只要有人脸从侧边进入画面,“未处理异常”的窗口就弹出来。原因就是4.2里说的new Mat(frame, roi)越界。解决:裁图前对x1、y1、x2、y2做Math.Max(0, ...)和Math.Min(frame.Cols, ...)的钳位,并且判断裁出来的宽高是否大于0。从那以后我写所有跟roi相关的代码都会在裁图前加一道边界检查,这已经是肌肉记忆了。
坑三:界面卡死,按钮点了没反应。现象是点击“开始检测”后窗口标题栏出现“未响应”,等几秒又恢复。原因是ONNX推理是同步阻塞的,而且Session对象每次调用都走了一遍OnnxRuntime的资源分配。解决:把推理放到Task.Run里跑,同时把Session在L2CSManager.Initialize()里只创建一次,整个程序生命周期复用同一个实例。Session不是线程安全的,所以同一个时刻只能有一个推理请求,但摄像头场景下串行推理完全够用。
坑四:gaze模式和head pose模式的输出数量不同,统一接口时取错了索引。现象是切到人脸朝向模式后,角度输出变成三列,有的行显示正常有的行显示乱码。原因是两个模式的分支数量不一样——gaze是2个输出,head pose是3个输出,如果代码里写死了索引1是pitch,在head pose模式下索引1其实是roll。解决:在接口里显式区分模式,或者在解析输出时先判断outputs.Length再决定怎么映射。
坑五:ONNX Runtime报“DllNotFound”或者其他原生库异常。现象是编译通过、运行时报错,而且报错信息指向onnxruntime.dll。原因是OpenCvSharp的正则包和ONNX Runtime的原生dll都在x64目录下,但项目的PlatformTarget被设成了x86或AnyCPU。解决:项目属性 → 生成 → 平台目标改成x64,同时确认bin\x64\Debug目录下能看到onnxruntime.dll和OpenCvSharpExtern.dll。VS2022打开老工程时尤其容易碰到这个问题——默认平台目标继承可能被改掉了。
5. 让结果可验证:图片、摄像头、视频三种复现路径与延迟优化
5.1 三种复现路径,从静态到动态逐步推进
拿到工程后别急着上摄像头,先用静态图片验证管线是对的。打开图片用OpenFileDialog,选一张正面照,如果输出yaw在-10到+10之间、pitch也接近0,说明整条链路通了。然后再试摄像头——VideoCapture(0)打开默认摄像头,循环读帧、检测、推理、画框。最后接视频文件,把VideoCapture的构造参数从0换成视频文件路径就行,逻辑不用改。
using var capture = new VideoCapture(0); // 0=默认摄像头,或者传视频文件路径 using var window = new Window("result"); Mat frame = new Mat(); while (capture.Read(frame)) { var results = _faceManager.ProcessFrame(frame); foreach (var result in results) { Cv2.Rectangle(frame, result.Bbox, Scalar.Red, 2); string text = $"yaw:{result.Yaw:F1} pitch:{result.Pitch:F1}"; Cv2.PutText(frame, text, new Point(result.Bbox.X, result.Bbox.Y - 8), HersheyFonts.HersheySimplex, 0.6, Scalar.Green, 2); } window.ShowImage(frame); if (Cv2.WaitKey(30) == 'q') break; }在WinForms里把Mat显示到PictureBox上,用OpenCvSharp.WinForms的扩展方法BitmapConverter.ToBitmap(frame)就行。注意UI更新要跨线程——Task.Run里推理完成后,用pictureBox.BeginInvoke回到UI线程再刷新图像,否则控件会崩。
5.2 延迟优化:CPU推理、CUDA加速、关键帧策略
L2CS在CPU上224x224输入,单次推理大概20到40毫秒,配合FaceDetectorYN,一套下来大约50到60毫秒,也就是15到20帧每秒。如果这不够用,两个方向:一是把人脸检测的输入尺寸从640降到320,能省一半时间;二是ONNX Runtime跑CUDA执行提供程序,但1.16.3对应的CUDA版本要跟本机驱动匹配,配置起来稍微麻烦一点。
我最后提醒一个习惯:每次改动完预处理代码,先拿同一张已知角度的图片跑一遍,记录输出数值,再改下一处。做视觉算法的调试,最怕的是“改了A发现B也变了”,有了基线参照,排查起来才有据可依。这个项目本身不难,难的是把每个环节的假设都验证一遍。希望帮到你。
本文还有配套的精品资源,点击获取