简介:一套基于C# OpenCvSharp与L2CS-Net的人脸朝向/眼睛注视方向估计完整工程,面向需要实现视线估计或头部姿态判断的桌面应用开发者。利用ONNX Runtime加载深度学习模型,可在Windows环境下完成人脸检测、关键点提取与角度预测,适用于人机交互、驾驶员监控、注意力分析等场景。压缩包含41个文件,总大小约162.66MB,主要包含源码文件(cs)、项目配置(sln/csproj/config)、依赖库(dll)、模型文件(onnx)以及演示视频(mp4)和可执行文件(exe),结构清晰便于直接运行与二次开发。已有179人学习下载。通过该资源可掌握L2CS-Net在C#中的集成方式,获得人脸检测与朝向估计的完整调用流程、界面示例和调试经验,适合中高级C#开发者快速落地视线估计相关功能。
1. L2CS-Net 是什么:C# 做视线估计为什么绕不开它
做 C# 上位机的人,迟早会遇到一个需求:判断屏幕前的人是不是正在看屏幕,或者驾驶员有没有长时间偏头。这个需求在开源生态里大多是 Python 项目,但你的产线环境往往只有 Windows 工控机,装不了 Python 也拉不起 CUDA。L2CS-Net 是一个轻量级视线估计网络,输入一张裁剪好的人脸,输出眼睛注视方向的 yaw 和 pitch 角度;因为这两个角度和头部朝向强相关,工程里也常把它当“人脸朝向 / 人脸估计”的近似模型来用。配合 OpenCvSharp 的 DNN 模块,C# 可以直接加载 ONNX 模型做 CPU 推理,不依赖外部服务,也容易集成进 WinForms/WPF。适合谁:要写桌面视觉程序、想避开 Python 服务、又需要实时给方向的开发者。读完这篇,你能从模型转换一路做到摄像头实时管线,并避开我在实际项目里踩过的坑。
2. 先算明白 yaw/pitch:L2CS-Net 的 ONNX 推理与 C# DNN 骨架
2.1 视线角度的输出方式:90 个 bin 的 softmax 期望值为什么比直接回归稳
L2CS-Net 的输出不是一个连续值,而是把 yaw 和 pitch 各自离散成 90 个区间(bin),每个区间 4 度,覆盖 [-180, 180]。网络对每个 bin 输出一个得分,经过 softmax 变成概率,再对角度做加权期望,最终得到连续角度。这样做的好处是:直接回归角度很容易出现离群点,尤其是人眼图像本身有遮挡、LED 反光,回归损失会让网络为了压低一个极端样本而牺牲整体精度。分类式输出更钝,天然适合视线这种标注噪声很大的任务。
落地细节:yaw 和 pitch 各一个输出张量,形状是 [1, 90]。读取后先算 softmax,再把每个 bin 的中心角度加权求和。常见公式是angle = sum(p_i * (i * 4 - 180 + 2)),i 从 0 到 89。有些实现偷懒,直接用i * 4 - 180当角度,单点误差最多 2 度,对大部分业务可以接受,但做评价指标时最好还是用 bin 中心。pitch 虽然也输出 90 个 bin,真实人脸俯仰角只会落在 [-90, 90],计算后自己 clamp 一下。
还有一个容易忽略的点:L2CS-Net 对输入预处理是敏感的,但它不是普通回归网络,所以预处理错了不会完全跑飞,而是会出现“看起来合理、但整体偏移十几度”的问题。这就是为什么很多 C# 移植项目先珠玉在前,最后却卡在这个环节。
2.2 把 PyTorch 权重转成 ONNX:输出节点名、batch 维度和一张 224×224 人脸
在 C# 侧要用 ONNX,第一步是把 PyTorch 权重转出来。常见做法是加载官方预训练权重后封装一个 Wrapper,只返回 yaw 和 pitch,避免把其他输出(比如坐标点)也塞进 ONNX。下面这段脚本可以当作通用模板:
import torch class GazeWrapper(torch.nn.Module): def __init__(self, net): super().__init__() self.net = net def forward(self, face): # 有些 L2CS-Net 实现会返回 3 个值,我们只需要前两个角度 yaw, pitch, _ = self.net(face) return yaw, pitch model.eval() wrapper = GazeWrapper(model).eval() dummy = torch.randn(1, 3, 224, 224) torch.onnx.export( wrapper, dummy, "l2csnet.onnx", opset_version=11, input_names=["face"], output_names=["yaw", "pitch"], dynamic_axes={ "face": {0: "batch"}, "yaw": {0: "batch"}, "pitch": {0: "batch"}, }, ) print("exported")参数说明:dummy的 shape 必须是[1, 3, 224, 224],对应 batch、RGB 三通道、宽高 224。output_names要记好,C# 侧Forward("yaw")和Forward("pitch")要用到。dynamic_axes是让 batch 维度可变的,如果你在 C# 侧总是单张推理,不加也行。导出后用 Netron 打开看一眼输出节点的名字,避免代码里写错。
这里有个很容易翻车的点:如果你的模型是直接从训练代码 saved_model 拿的,导出时可能把归一化层也带进去。L2CS-Net 的原始 PyTorch 前处理是Resize + ToTensor + Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),导出 ONNX 时如果模型定义里没有包含这部分,C# 端必须自己做。后面第三节的代码就是按这条路径写的。
2.3 最小 C# 推理类:读模型、归一化、算角度
下面是一个可以直接用的 C# 类。它负责读 ONNX、预处理人脸图、前向推理,然后把 yaw/pitch 算出来。OpenCvSharp 的 DNN 接口在部分版本里是ReadNetFromOnnx,也有的版本叫ReadNetFromONNX,以你安装包的 intellisense 为准。
using System; using System.Linq; using OpenCvSharp; using OpenCvSharp.Dnn; public class L2CSInference : IDisposable { private readonly Net _net; public L2CSInference(string onnxPath) { _net = CvDnn.ReadNetFromOnnx(onnxPath); _net.SetPreferableBackend(Net.BackendType.OpenCV); _net.SetPreferableTarget(Net.TargetType.CPU); } public (float Yaw, float Pitch) Infer(Mat faceBgr) { using var inputBlob = NormalizeToBlob(faceBgr); _net.SetInput(inputBlob); using var yawMat = _net.Forward("yaw"); using var pitchMat = _net.Forward("pitch"); float[] yawScores = ReadScores(yawMat); float[] pitchScores = ReadScores(pitchMat); float yaw = SoftmaxExpectation(yawScores, -180f, 180f); float pitch = SoftmaxExpectation(pitchScores, -180f, 180f); pitch = Math.Clamp(pitch, -90f, 90f); return (yaw, pitch); } private static Mat NormalizeToBlob(Mat faceBgr) { using var rgb = new Mat(); Cv2.CvtColor(faceBgr, rgb, ColorConversionCodes.BGR2RGB); using var resized = new Mat(); Cv2.Resize(rgb, resized, new Size(224, 224)); resized.ConvertTo(resized, MatType.CV_32FC3, 1.0 / 255.0); Mat[] channels = Cv2.Split(resized); try { float[] mean = { 0.485f, 0.456f, 0.406f }; // RGB 顺序 float[] std = { 0.229f, 0.224f, 0.225f }; for (int c = 0; c < 3; c++) { // 这里把 (pixel - mean) / std 合并成一次缩放 channels[c].ConvertTo( channels[c], MatType.CV_32F, 1.0 / std[c], -mean[c] / std[c]); } using var merged = new Mat(); Cv2.Merge(channels, merged); // merged 已经是 RGB 顺序,swapRB 传 false return CvDnn.BlobFromImage( merged, 1.0, new Size(224, 224), new Scalar(), false, false); } finally { foreach (var ch in channels) ch.Dispose(); } } private static float[] ReadScores(Mat scoreMat) { int length = Math.Max(scoreMat.Rows, scoreMat.Cols); float[] result = new float[length]; for (int i = 0; i < length; i++) { result[i] = scoreMat.Rows == 1 ? scoreMat.At<float>(0, i) : scoreMat.At<float>(i, 0); } return result; } private static float SoftmaxExpectation(float[] scores, float min, float max) { float maxScore = scores.Max(); float sumExp = 0f, sumWeighted = 0f; float step = (max - min) / scores.Length; for (int i = 0; i < scores.Length; i++) { float e = (float)Math.Exp(scores[i] - maxScore); sumExp += e; float binCenter = min + step * (i + 0.5f); sumWeighted += e * binCenter; } return sumWeighted / sumExp; } public void Dispose() => _net?.Dispose(); }代码逻辑分三段。第一段是预处理:BGR 转 RGB、缩放 224、像素归一化到 [0,1],再按 ImageNet mean/std 逐通道处理。为什么不用BlobFromImage的 mean 参数?因为它只做减均值,不处理 std,最后会导致角度整体偏移。第二段是前向:SetInput后调用两次Forward,用导出时的输出节点名取张量。第三段是角度解码:对 90 个得分做 softmax 期望值,得到连续的 yaw/pitch。
ReadScores做了兼容处理,无论 ONNX 输出是1x90还是90x1都能读。SoftmaxExpectation里的step是 4 度,binCenter用min + step * (i + 0.5)得到区间中点。如果你发现正脸时 yaw 不是 0 而是 2 度左右,不用太在意,这是 bin 量化和预处理误差的叠加。
3. 从摄像头到方向向量:OpenCvSharp 实时人脸管线搭建
3.1 人脸检测选型:Haar 能用,但 L2CS 需要更稳的框
L2CS 的输入是一张裁剪出来的人脸,人脸框的质量直接影响最终角度。Haar 级联检测在正脸、光照均匀的场景没问题,但使用者一歪头就丢脸,而视线估计恰好要应对转头。OpenCV 4 之后有 YuNet 人脸检测模型,对侧脸、俯仰、小尺寸人脸的召回更好。如果你的工控机性能有限,可以先拿 Haar 跑通链路;但给客户演示时,建议换 YuNet,二者在 OpenCvSharp 里都能接。
Haar 的写法最简单,适合先验证整体流程:
using OpenCvSharp; var cascade = new CascadeClassifier("haarcascade_frontalface_default.xml"); using var frame = new Mat(); // frame 来自摄像头或图像源 using var gray = new Mat(); Cv2.CvtColor(frame, gray, ColorConversionCodes.BGR2GRAY); Cv2.EqualizeHist(gray, gray); var faces = cascade.DetectMultiScale(gray, 1.1, 5, HaarDetectionTypes.ScaleImage, new Size(60, 60), Size.Empty); foreach (Rect face in faces) { using var faceMat = new Mat(frame, face); var (yaw, pitch) = l2cs.Infer(faceMat); // 后续可视化 }参数说明:scaleFactor=1.1是每层金字塔缩放比,值越大检测越快但容易漏检;minNeighbors=5是保留候选框的最小邻近检测数,调大能压误检,调小能找回漏检的侧脸;minSize设为 60x60,过滤掉太小的误检。Haar 对竖放的人脸、戴眼镜场景表现还可以,但人脸一旦转到 30 度以上,框就开始漂。YuNet 模型是纯 DNN 推理,和 L2CS 共用同一个模型加载体系,推荐在正式版本里替换。
3.2 人脸框的裁剪与扩边:224×224 输入不能让脸占满整张图
把检测框直接裁出来送进 224x224,是新手最常见的翻车做法。L2CS-Net 训练时的人脸框是带背景的,网络要看到额头、下巴和外圈轮廓才能判断头部姿态和眼球位置。如果脸占满整图,角度会整体失真,尤其是低头、仰头时。
我习惯把检测框按 35%~40% 向外扩成方形,再缩放到 224x224。下面是裁剪函数:
Rect GetL2CSCrop(Rect face, double margin, int frameW, int frameH) { int baseSide = Math.Max(face.Width, face.Height); int side = (int)(baseSide * (1 + 2 * margin)); int x = face.X + face.Width / 2 - side / 2; int y = face.Y + face.Height / 2 - side / 2; x = Math.Clamp(x, 0, Math.Max(0, frameW - side)); y = Math.Clamp(y, 0, Math.Max(0, frameH - side)); return new Rect(x, y, Math.Min(side, frameW - x), Math.Min(side, frameH - y)); }margin=0.4表示人脸框外侧各加 40% 的背景边,最后整张图里人脸大概占 60% 左右,这接近 L2CS 训练正样本的分布。如果人脸靠近图像边缘,方形框会被截断,函数会返回一个非方形的矩形。项目里遇到这种边界,我会直接Cv2.Resize到 224x224 拉伸,实测比黑色填充更稳,因为填充会引入原图不存在的像素分布。
裁剪完之后,调L2CSInference.Infer(new Mat(frame, cropRect))。如果检测框本身抖动大,可以先对face的中心点做一阶低通滤波,比如center = 0.8 * lastCenter + 0.2 * currentCenter,再把平滑后的中心传进裁剪函数。
3.3 把 yaw/pitch 画成一条方向线:坐标符号与可视化
拿到角度后,最常见的可视化是一条从人脸中心射出去的线。视线向量在相机坐标系里可以近似为:x 方向由 yaw 决定,y 方向由 pitch 决定,z 方向指向相机前方。画在二维画面上只需要 x 和 y 分量。
Point DrawGazeLine(Mat image, Rect face, float yawDeg, float pitchDeg, int length = 120) { Point center = new Point(face.X + face.Width / 2, face.Y + face.Height / 2); double yaw = yawDeg * Math.PI / 180.0; double pitch = pitchDeg * Math.PI / 180.0; float vx = (float)(-Math.Sin(yaw) * Math.Cos(pitch)); float vy = (float)(-Math.Sin(pitch) * 0.6); // 0.6 是透视压扁系数 Point end = new Point( (int)(center.X + vx * length), (int)(center.Y + vy * length)); Cv2.Line(image, center, end, Scalar.Red, 2); Cv2.Circle(image, end, 4, Scalar.Red, -1); return end; }注意,视线向量的具体符号取决于你的相机坐标定义:图像上 x 朝右、y 朝下、z 朝前时,正 yaw 到底对应向左还是向右看,不同项目不一样。画出来发现方向反了,就把yawDeg取负,别在数学公式里纠结,以画面直觉为准。vy乘 0.6 是为了让俯仰角在画面上看起来别太夸张,实际使用时可以调。
这个可视化在联调时很有用。你让同事坐屏幕前,头左右转,屏幕上那条线应该跟着左右摆;头上下动,线跟着上下动。如果线的方向和头部动作反了,优先检查预处理里的swapRB,再看角度符号。
4. 避坑与排查:L2CS-Net 在 C# 落地最常见的 4 个问题
4.1 角度整体偏移十几度:预处理没按 ImageNet 归一化
现象:正对摄像头时 yaw 应该是 0,却输出 -19 或 23,pitch 也整体偏高。原因:用CvDnn.BlobFromImage只减 mean 没除 std,或者把 BGR 原图直接送给了期望 RGB 输入的模型。解决:按 2.3 的NormalizeToBlob写,先 BGR 转 RGB,再逐通道做(pixel - mean) / std。验证方式很简单:找一张公开人脸图,在 PyTorch 里跑一次,记录 yaw/pitch;再用 C# 跑同一张图,两个结果相差应该在 1 度以内。如果相差很大,就是预处理没对齐。
4.2 人脸一偏,角度跳来跳去:把 yaw 当成人脸朝向的误区
现象:头部转到 40 度,L2CS 输出的 yaw 一会儿 30 一会儿 45,甚至偶尔跳回 -20。原因:L2CS-Net 是视线估计模型,它看的是眼睛球体和眼睑,不只是头骨朝向。眼睛可以独立于头转动,当脸偏 40 度但眼睛盯着镜头时,视线方向可能还是接近 0。这是模型边界,不是 bug。解决:如果你的需求是“人脸朝向”,应该用专门的头部姿态估计模型(比如 6D-LNet、FSA-Net),或者用检测框的几何中心变化做粗估计;如果需求是“眼睛注视方向”,L2CS 是对的。如果你真的需要一个模型同时给两个量,可以把 L2CS 的 yaw 做一下低通滤波,只把它当“近似朝向”,而不是精确头姿。
4.3 CPU 延迟高:ResNet50 模型在 C# DNN 里跑不动
现象:一块 i5 工控机,1080p 摄像头,Haar 检测加 L2CS 推理,一帧 400 毫秒,完全没法实时。原因:ResNet50 主干的 FLOPs 太高,OpenCV DNN 的 CPU 后端又没有针对性算子优化。解决:换 ResNet18 权重,通常角度误差只多 1~2 度,但延迟能降一半以上。其次,不要把 1080p 整帧送检测,先把帧缩到 640x480 再跑人脸检测,检测到框后再裁 224x224 给 L2CS。还可以把后端换成 OpenVINO,_net.SetPreferableBackend(Net.BackendType.VINO),在部分 CPU 上能再快 20%~30%,但部署机需要装 OpenVINO runtime,集成复杂度会上升。
4.4 同一张脸,裁剪大小不同结果不同:人脸占比没有保持固定
现象:同一个视频帧,手动把框扩大一点 yaw 差 8 度,缩小一点又差 6 度。原因:L2CS 训练正样本的人脸占比相对固定,大约 60%~70%,剩下的是额头、下巴外侧的背景。你把脸填满整图,或者把整块肩部以上都裁进去,都会偏离训练分布。解决:使用固定 margin,不要用检测框紧贴脸;裁剪后 Resize 到 224 前,保证人脸占比尽量一致。我固定扩边 35%,比紧贴框稳定很多。检测框不稳定时,可以先在图像层做人脸框平滑,再送入裁剪函数。
5. 再往前走一步:标定、模型选型和验证方法
如果你的项目只是要一个“在看/没在看”的粗略判断,直接用 yaw/pitch 设阈值就行。但要做到“用户在看屏幕左上角还是右下角”,像素坐标不经过标定是算不出来的。L2CS 输出的角度是相机坐标系里的视线方向,屏幕是一个物理平面,两者之间需要建立映射。常见做法是让用户依次看屏幕四个角,记录每个角对应的 yaw/pitch,然后求单应矩阵。C# 里可以直接用 OpenCV 的FindHomography,保存到 JSON,下次启动时加载。做完这一步,L2CS 才真正从“角度花哨的 demo”变成“能给人用的功能”。
模型选型上,我给实时项目的建议是优先 ResNet18。视线估计任务里,ResNet50 带来的精度提升在小目标、远距离场景才明显;在电脑屏幕前这种近距离、光照可控的场景,ResNet18 足够。如果你要做离线分析,比如回放录像统计注意力,才值得用 ResNet50 慢慢跑。还有一个习惯:接到这类项目,我不会先写业务 UI,而是先写一个裸控制台窗口,把每帧的 yaw/pitch、人脸框中心、耗时写到调试台,录 5 分钟视频。等角度曲线逻辑清楚了再往界面上封装。这个习惯帮我挡掉了很多次“模型不行”的锅,其实问题都出在预处理或裁剪边界。
验证方法也很直接:准备一组带真实角度标注的人脸图,算 MAE。代码不需要复杂:
static float Mae(IEnumerable<float> pred, IEnumerable<float> gt) { return pred.Zip(gt).Select(p => Math.Abs(p.First - p.Second)).Average(); }yaw 和 pitch 分别算,只要 MAE 在 5 度以内,这个模型就能支撑大多数注视判断业务。最后说一句:L2CS-Net 在 C# 里跑通不难,难的是理解它输出的是视线方向而不是绝对的头部姿态,别把这个边界忘了。希望帮到你。
本文还有配套的精品资源,点击获取