
简介这是一份基于C#与Windows Forms开发的人脸识别考勤系统完整源码面向.NET桌面应用学习者、计算机视觉入门开发者及需要快速搭建打卡原型的技术人员。项目围绕人脸捕捉、特征匹配与考勤记录展开包含WinForms界面布局、图像预处理、数据库存储、多线程处理等关键实现环节适合用于课程设计或企业考勤模块二次开发。压缩包共69个文件约3.3MB涵盖16个dll运行库、11个cs源码文件、sqlite数据库文件、resx资源文件、exe可执行程序及sln工程文件等目录结构完整可直接在Visual Studio中打开并编译运行。已有468人学习下载说明了其在C#桌面开发场景中的参考价值。通过阅读源码可掌握WinForms控件事件驱动、人脸识别库调用、数据库读写以及Git版本管理等实践技巧是一份将计算机视觉与桌面业务逻辑相结合的实用案例。1. WinForms 做人脸识别打卡系统先解决的不是识别率WinForms 在人脸识别打卡系统里最常见的评价是“老”但真正部署到企业内网考勤机的时候它反而把问题想得最清楚摄像头取流、离线识别、打卡记录落库、断网也能跑。人脸识别的精度反而不是项目里最难的环节因为现代开源离线模型的检出率已经足够高难的是 WinForms 主线程不能被识别计算卡死以及过了识别这一关之后防重复打卡、界面实时刷新、摄像头兼容这些桌面端老问题。这篇内容面向两类人一类是想在 WinForms 项目里增加人脸识别能力但不确定选哪条技术路线的 C# 工程师另一类是拿到类似项目源码后需要二次开发或移植的维护者。后面讲的选型、代码和参数都是可以直接抄进工程里改的不是演示用的伪代码。2. 人脸识别打卡系统的选型与整体架构离线识别优先摄像头和存储其次人脸识别打卡系统本质上是一个桌面端识别程序加上一个带业务规则的数据库应用。识别引擎选型决定了整个项目的技术栈走向而摄像头取流和存储结构虽然看起来普通却决定了程序能不能稳定跑一个月不重启。这里先说清三件事识别引擎用离线方案、取流用 OpenCvSharp、打卡记录用唯一索引去重。2.1 人脸识别引擎选型为什么优先选离线方案打卡设备所在的企业内网摄像头画面不能随意往外传打卡操作要求秒级响应识别请求不能依赖公网。所以选型排序应该是本地识别 局域网服务器识别 云端识别。WinForms 里加载开源人脸模型做本地推理的常见做法有三条路方案识别耗时CPU依赖适合场景Haar Cascade LBP直方图20-50ms纯 OpenCvSharp人数少、正面固定机位OpenCvSharp.Dnn 轻量分类模型30-60msOpenCvSharp.Dnn快速原型ONNX Runtime MobileFaceNet20-40msMicrosoft.ML.OnnxRuntime我推荐的主力方案前面两种方案的问题在于Haar Cascade 对姿态和光照非常敏感戴个黑框眼镜就会把人脸切丢Dnn 模块加载 ONNX 的算子覆盖率不如官方 onnxruntime遇到新模型容易报不支持。所以我在 WinForms 人脸识别打卡系统里推荐 ONNX Runtime 加载 MobileFaceNet模型文件单份约 10MB 以内CPU 单帧推理对 720p 画面够用不需要 GPU。using OpenCvSharp.Dnn; using System.IO; string modelPath Path.Combine(AppDomain.CurrentDomain.BaseDirectory, face_embedding.onnx); CvNet CvDnn.ReadNetFromOnnx(modelPath); // 打印各层名称确认模型输入输出张量对应的键值 Layer[] layers CvNet.GetLayerNames(); for (int i 0; i layers.Length; i) { Console.WriteLine($L{i}: {layers[i]}); }这段代码只做模型加载和结构确认。输入规格要以上面的输出为准MobileFaceNet 通常是 112x112 的 BGR 图像特征输出 512 维或 128 维浮点向量不同变体有差异。代码里不要写死维度值从输出张量的 Shape 属性动态读取这样换模型时比对逻辑不用改。选型时还有一条容易忽略的约束开源人脸模型有各自的训练数据许可协议商用前要确认模型卡片的授权范围并非所有开源模型都允许做商业考勤产品。2.2 摄像头取流WinForms 里取视频流的三种方式与选型WinForms 里取摄像头视频流有三种常见方案AForge.NET、OpenCvSharp.VideoCapture、Windows.Media.Capture。AForge 停止维护多年新装机容易缺 VC 运行库MediaCapture 是 WinRT 组件WinForms 里调用要处理线程模型开发量不小。我一般直接用 OpenCvSharp 的 VideoCapture因为识别代码本身就是 OpenCvSharp 的 Mat省去 Bitmap 到 Mat 的反复转换。using OpenCvSharp; using System.Threading; private CancellationTokenSource _cts new CancellationTokenSource(); private Thread _captureThread; private void StartCapture(int cameraIndex 0) { _captureThread new Thread(() { using var capture new VideoCapture(cameraIndex); if (!capture.IsOpened()) { MessageBox.Show(摄像头打开失败请检查USB驱动, 提示); return; } capture.Set(VideoCaptureProperties.FrameWidth, 1280); capture.Set(VideoCaptureProperties.FrameHeight, 720); capture.Set(VideoCaptureProperties.Fps, 15); using var frame new Mat(); while (!_cts.IsCancellationRequested) { if (!capture.Read(frame)) break; // 其他线程取这个拷贝去做人脸识别画面显示和识别解耦 Thread.Sleep(33); } }) { IsBackground true }; _captureThread.Start(); }参数说明FrameWidth 和 FrameHeight 建议保持 1280x720再大只会增加 USB 带宽压力并不会提高识别率Fps 设 15 只是请求帧率USB 摄像头实际输出可能只有 10-20fps这没关系。Thread.Sleep(33) 让采集循环不至于空转到满核更重要的是提示一个原则摄像头采集线程只负责读帧和派发识别和 UI 刷新都不要在这个线程里做否则画面会明显掉帧。2.3 数据模型与防重复打卡的存储设计打卡系统的数据模型按三张表设计员工表、人脸特征表、打卡记录表。人脸特征单独放一张表的好处是员工换照片时只更新特征表历史打卡记录完全不受影响。CREATE TABLE employee ( emp_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, department TEXT, photo_path TEXT ); CREATE TABLE face_feature ( emp_id INTEGER NOT NULL REFERENCES employee(emp_id), version TEXT DEFAULT onnx, dim INTEGER, feature BLOB NOT NULL, updated_at TEXT DEFAULT (datetime(now,localtime)) ); CREATE TABLE attendance ( id INTEGER PRIMARY KEY AUTOINCREMENT, emp_id INTEGER NOT NULL REFERENCES employee(emp_id), punch_date TEXT NOT NULL, punch_time TEXT NOT NULL, status TEXT NOT NULL DEFAULT normal ); CREATE UNIQUE INDEX uk_attendance_emp_date ON attendance(emp_id, punch_date);打卡去重不推荐“先查再插”两次查询之间并发插入同样会出现重复记录。正确做法是让数据库唯一索引兜底业务层只管 Insert冲突时捕获约束异常再决定更新还是忽略。feature 字段用 BLOB 存浮点数组的二进制序列化不要用逗号拼字符串否则 500 人的特征库每次启动要解析几万个字符串浪费时间也容易引入浮点精度误差。3. 人脸识别打卡系统的核心实现摄像头采集、特征提取与打卡判定这一章是识别链路的主体。从摄像头拿到原始画面到最终在数据库插入一条打卡记录中间要经过采集、抽帧、检测、对齐、提取特征、比对、写入七个环节。这里的每一段代码都是可以直接放进 WinForms 工程的关键参数集中讲清楚。3.1 摄像头画面实时显示与识别帧抽取人脸识别不能每帧都做。720p 画面下检测加特征提取一次大约 60ms如果 30fps 全识别CPU 会持续满载UI 线程也会跟着卡。常见做法是显示画面用采集线程高频刷新识别用低频抽帧比如每 5 帧放一帧进识别队列。using System.Collections.Concurrent; private ConcurrentQueueMat _recoQueue new ConcurrentQueueMat(); private int _frameNumber; // 在采集线程的 while 循环里补充 if (_frameNumber % 5 0) { using var small frame.Resize(new Size(640, 360)); using var copy small.Clone(); _recoQueue.Enqueue(copy); // 队列里最多保留 2 帧待识别避免识别线程跟不上时无限积压 while (_recoQueue.Count 2 _recoQueue.TryDequeue(out _)) { } }降采样到 640x360 再进识别队列是因为这个分辨率已经能检出 2 米距离的成人人脸再低会出现漏检再高只会增加推理耗时。队列上限设 2 帧是给识别线程一个反压信号识别变慢时丢旧帧保实时性这样员工站在摄像头前晃动时系统看到的是最新的脸而不是两秒前的旧帧。3.2 人脸检测、对齐与特征提取检测这层我一般用 OpenCvSharp 自带 Haar Cascade 做首筛主要目的是快速拿到人脸框。实际使用时如果发现小脸漏检再把检测环节也换成 ONNX 格式的 SCRFD 或 RetinaFace代价是显存和 CPU 占用上升。先看代码using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using OpenCvSharp; string baseDir AppDomain.CurrentDomain.BaseDirectory; using var faceDetector new CascadeClassifier(Path.Combine(baseDir, haarcascade_frontalface_default.xml)); using var session new InferenceSession(Path.Combine(baseDir, face_embedding.onnx)); while (!_cts.IsCancellationRequested) { if (_recoQueue.TryDequeue(out var frame)) { using var gray new Mat(); Cv2.CvtColor(frame, gray, ColorConversionCodes.BGR2GRAY); // 1.1 表示缩放步长5 表示至少保留 5 个相邻窗口才算人脸区域 Rect[] faces faceDetector.DetectMultiScale(gray, 1.1, 5, minSize: new Size(80, 80)); if (faces.Length 0) continue; Rect face faces.OrderByDescending(f f.Width * f.Height).First(); using var faceImg new Mat(frame, face); using var aligned faceImg.Resize(new Size(112, 112)); // 用归一化参数构造模型输入均值 127.5、缩放因子 1/128 是常见人脸模型的标准 using var blob CvDnn.BlobFromImage(aligned, 1.0 / 128.0, new Size(112, 112), new Scalar(127.5, 127.5, 127.5), false); var inputTensor new DenseTensorfloat(blob.DataPointer, new[] { 1, 3, 112, 112 }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(session.InputNames[0], inputTensor) }; using var results session.Run(inputs); var output results.First().AsTensorfloat(); float[] embedding output.ToArray(); // L2 归一化后入库或比对后续计算余弦相似度 float norm (float)Math.Sqrt(embedding.Sum(v v * v)); for (int i 0; i embedding.Length; i) { embedding[i] / norm; } } Thread.Sleep(10); }说明几个必须调的参数。DetectMultiScale 的 scaleFactor 设 1.1意思是检测窗口每层缩小 10%值越小检测越慢但小脸检出率越高固定机位用 1.1 足够。minSize 设 80x80低于这个尺寸的人脸直接忽略防止远处路过的人被当成打卡对象。归一化参数换成其他模型时一定要确认训练代码里的 mean 和 std否则出来的 embedding 特征分布是歪的相似度阈值完全不可用。更严谨的对齐做法是先取双眼坐标做仿射变换用 GetAffineTransform 对齐到标准位置后再 resize。这样能显著降低低头仰头带来的特征偏移但代码量会翻倍。固定摄像头高度在 1.5 米左右的打卡机直接 resize 也能接受。3.3 特征比对与打卡记录写入特征比对是线性遍历内存里的特征库因为几百个人的特征量很小一次完整比对在普通 CPU 上不会超过几毫秒。完全没必要上向量数据库或者 Faiss反而增加部署复杂度。public class FaceIndex { public int EmpId { get; set; } public string Name { get; set; } public float[] Feature { get; set; } } private FaceIndex MatchFace(float[] queryFeature, ListFaceIndex library, double threshold) { FaceIndex best null; double bestScore 0; foreach (var item in library) { double score CosineSimilarity(queryFeature, item.Feature); if (score bestScore) { bestScore score; best item; } } return bestScore threshold ? best : null; } private double CosineSimilarity(float[] a, float[] b) { double dot 0, na 0, nb 0; for (int i 0; i a.Length; i) { dot a[i] * b[i]; na a[i] * a[i]; nb b[i] * b[i]; } return dot / (Math.Sqrt(na) * Math.Sqrt(nb)); }由于前面入库前已经做了 L2 归一化余弦相似度就是两个向量的点积这个写法在数学上是等价的。threshold 通常在 0.75 到 0.85 之间考勤场景误刷比漏刷严重阈值应该偏向保守宁愿要求员工重新站一次也不要出现 A 刷成 B 的记录。具体校准方法放在最后一章讲。写入打卡记录时还要把迟到状态一并算出来private void InsertAttendance(FaceIndex user, TimeSpan lateThreshold) { string today DateTime.Now.ToString(yyyy-MM-dd); string now DateTime.Now.ToString(HH:mm:ss); DateTime t DateTime.Now; string status t.TimeOfDay lateThreshold ? late : normal; string sql INSERT OR IGNORE INTO attendance(emp_id, punch_date, punch_time, status) VALUES(empId, date, time, status); // 使用 Dapper 或 SqliteCommand 参数化执行 }INSERT OR IGNORE 加上前面建的唯一索引天然实现了同一天同一个人只插一条记录的逻辑。如果希望当天第二次打卡只是更新打卡时间而不是新增记录就把 SQL 改成 ON CONFLICT(emp_id, punch_date) DO UPDATE SET punch_timeexcluded.punch_time这样员工可以下班再刷一次但数据库里当天只有一条记录。提示如果公司班次跨夜员工晚上十一点打卡到凌晨单纯按自然日去重会把凌晨这条记录算成第二天需要在项目里额外维护一个“班次开始时间”字段来定义考勤日而不是直接用 DateTime.Now 的日期。4. WinForms 界面美化与交互打磨让人脸识别打卡系统像真正的产品功能跑通只是一个开始。默认的 WinForms 标题栏配上灰底 Button界面看起来永远像调试工具不像打卡机。这一章解决“winform界面美化”和“c# winform主题实现的方法”这两个高频需求从无边框窗口、主题色配置、实时识别反馈三个方面把外壳换掉。4.1 去掉系统标题栏用自定义标题栏撑起产品感打卡机通常是固定机位的触摸屏系统窗口边框和任务栏都没有存在意义反而让人误操作。做法是设置 FormBorderStyle.None自己画一个顶部标题栏并把关闭按钮改成弹出确认提示。using System.Runtime.InteropServices; [DllImport(user32.dll)] private static extern bool ReleaseCapture(); [DllImport(user32.dll)] private static extern IntPtr SendMessage(IntPtr hWnd, int Msg, int wParam, int lParam); this.FormBorderStyle FormBorderStyle.None; this.BackColor Color.FromArgb(24, 24, 28); var titleBar new Panel { Dock DockStyle.Top, Height 40, BackColor Color.FromArgb(32, 32, 38) }; titleBar.MouseDown (s, e) { if (e.Button MouseButtons.Left) { ReleaseCapture(); // 0x0112 是系统命令消息0xF012 表示拖动窗口 SendMessage(this.Handle, 0x0112, 0xF012, 0); } }; this.Controls.Add(titleBar);SendMessage 的 0x0112 是 WM_SYSCOMMAND0xF012 是 SC_DRAGMOVE配合 ReleaseCapture 实现无边框窗口拖动。窗口关闭按钮用普通 Button 即可但 Click 事件里弹一个确认对话框防止管理员在触摸屏上误碰导致考勤服务退出。这个细节在 WinForms 触摸屏项目里属于必做的交互保护。4.2 主题色管理JSON 配置文件驱动全局换肤C# WinForms 主题实现最常见的方式是维护一个静态 Theme 类控件初始化时从主题类取颜色。一个更省事的方式是在启动时递归遍历整棵控件树按控件类型统一赋值。{ primary: #1668DC, danger: #DC4446, panelBg: #141414, text: #E0E0E0 }public static class ThemeManager { public static Dictionarystring, Color Colors { get; private set; } public static void Load(string jsonPath) { // 用 System.Text.Json 反序列化到字典这里省略具体解析 } public static void ApplyTo(Control root) { foreach (Control c in root.Controls) { if (c is Button btn) { btn.BackColor Colors[primary]; btn.ForeColor Color.White; } else if (c is Panel p) { p.BackColor Colors[panelBg]; } ApplyTo(c); } root.Invalidate(); root.PerformLayout(); } }递归必须在根控件上调用一次子控件会被逐个处理。做完颜色赋值后必须调 Invalidate 和 PerformLayout否则 WinForms 的 WM_PAINT 消息不会立即触发界面颜色要等下一次重绘才会变。需要注意运行时换肤在 WinForms 里体验很差GDI 重绘大量控件会明显闪烁实际项目里主题 JSON 只在启动时读一次改主题就重启程序。4.3 实时识别反馈识别框、姓名标签与语音播报识别过程中用户最关心三件事摄像头画面里自己是否被框到、识别是否成功、打卡是否写进记录。画面双缓冲重绘是 WinForms 里必须处理的直接在 PictureBox 的 Image 上画线会造成重影。protected override void OnPaint(PaintEventArgs e) { if (_currentFace.HasValue) { using var pen new Pen(_matchSucceeded ? Color.LimeGreen : Color.Orange, 2); e.Graphics.DrawRectangle(pen, _currentFace.Value); using var font new Font(微软雅黑, 10); using var brush new SolidBrush(Color.White); e.Graphics.DrawString(_currentName, font, brush, _currentFace.Value.Left, _currentFace.Value.Top - 24); } }覆盖一个置于 PictureBox 之上的透明 Panel 的 OnPaint用字段存当前识别状态UI 线程用 30fps 的 Timer 调 Invalidate识别线程只更新字段。这个方案不涉及跨线程操作控件稳定性和流畅度都远好于在识别线程里直接给 PictureBox.Image 赋值。识别成功后的语音播报用 System.Speech 的 SpeechSynthesizer在后台线程调 SpeakAsync不能让识别线程等语音播完再回来处理下一帧。5. 人脸识别打卡系统上线前的验证阈值校准、时间同步与模型预热最后一章讲三个上线前必做但最容易忽略的事。前两章把链路打通了界面也像产品了但阈值不校准、时间不同步、模型不预热上线第一周就会出幺蛾子。5.1 阈值校准用同机位真实采样代替拍脑袋阈值定 0.75 还是 0.85不能靠猜。换一个摄像头、换一个机位高度人脸在画面里的占比和清晰度都会变。常见做法是分两步采集数据先在目标机位采集同一人不同时间段、不同角度、戴不戴眼镜的 8 到 12 张照片两两比对算类内相似度分布再采集至少 20 个不同人的照片两两比对算类间相似度分布。阈值取两个分布的重叠区中点。采集情况相似度常见区间判断同人同角度、光线好0.92-0.99命中同人换角度、光线一般0.82-0.92一般命中不同人0.35-0.72不命中同人戴口罩0.55-0.78不稳定不建议采集上表是经验参考数据不是特定模型在特定测试集上的结果。机器装到现场后第一天不要急着正式考勤先以旁观模式记录识别相似度打分积累半天数据后再定最终阈值这个方法最可靠。5.2 打卡时间基准让 NTP 时间而不是本地时间定迟到员工电脑改了系统时间打卡记录就废了。企业内网常见做法是让所有考勤机同步 NTP 时间源同时系统里保留“当前机器时间”和“服务器时间”两个字段事后对账时能看出员工是否刻意改过时间。上线前先验证本机时间同步状态w32tm /query /status w32tm /stripchart /computer:time.windows.com /samples:3第一条查看当前时间源第二条对比本机时间与标准时间的偏差。如果偏差超过 30 秒WinForms 程序启动时就弹提示要求管理员处理。迟到判定统一用服务器返回时间不要用 DateTime.Now 直接落库。5.3 模型预热避免第一次打卡冷启动卡顿ONNX 模型加载后第一次推理往往比后续慢 1-2 秒因为要完成算子初始化、内存分配、线程池预热。如果第一单恰好是早高峰体验就很糟。解决方式是在程序启动画面消失后立即在后台 Task.Run 里跑一次完整链路打开摄像头、取一帧黑帧、跑检测、跑特征提取、和特征库做一次全量比对。推理 session 和特征库字典都是静态字段这一轮预热做完后续识别响应都保持在毫秒级。内部实现上只是把识别方法完整调用一次不需要特殊逻辑重点是时序用户看到主界面的同时预热已经在后台开始了。这样即便内网环境第一次打卡也能保持跟后续打卡同样的毫秒级响应。本文还有配套的精品资源点击获取