简介:这份C#人脸识别Demo基于虹软免费SDK构建,面向需要在WinForms应用中快速集成人脸检测、人脸对比与人脸检索功能的.NET开发者,也适合作为入门离线SDK调用的参考工程。资源共25个文件,以17个C#源码文件为主,辅以2个功能DLL(人脸检测、人脸识别)、工程配置与界面资源文件,压缩包约20.77MB,代码模块按人脸检测(AFD)、人脸识别(AFR)等目录清晰划分,便于对照学习。工程完整包含人脸框选、特征提取、1:1比对与1:N检索的实现示例,并对虹软SDK的底层结构体、图像格式转换与封装函数做了整理,可在Visual Studio中直接打开调试,观察完整识别流程。已有8474人学习下载,适合需要快速搭建原型或理解离线人脸识别流程的.NET开发者,有助于缩短二次开发周期。
1. 一个不用联网就能跑的人脸识别C# Demo:选型、免费与落地
人脸识别最常见的翻车现场,不是算法不够准,而是你把一个本该离线完成的事做成了在线请求:图片传云端、等待响应、再拿回一个相似度分数。延迟、带宽、隐私、离线环境,随便一个都能让项目死得很难看。这次拆的这份C#人脸识别Demo,走的是另一条路——基于虹软免费SDK的本地识别方案,整个检测、特征提取、比对过程全部在内存中完成,不向任何服务器发请求。免费版SDK的限制是每日调用次数(常见为200次/日),但单机离线场景基本够用,做原型、做课程设计、做内部考勤工具都合适。
项目本身是个完整的WinForms工程,不是那种只有一个DLL和两行说明的半成品。它把摄像头取帧、人脸检测、特征提取、实时比对、结果展示串联成了一条完整链路,跑起来就能看到自己名字出现在识别框上。适合谁?想快速落地一个离线人脸识别原型、但又不想从零啃OpenCV和Dlib的C#开发者;也适合需要给老板演示“人脸识别能做”的交付场景。读完你会清楚它怎么跑、SDK每个核心接口在干什么、以及最容易被免费版SDK坑到的两三个地方。
2. 技术栈与工程结构:一次看懂SDK封装与项目全貌
2.1 选型逻辑:为什么用SDK而不是OpenCV
拿到“人脸识别Demo”需求时,摆在前面的有三条路:直接用OpenCV的Haar级联检测人脸,但OpenCV本身不提供特征提取与人脸比对,你得再找一肚子模型;自己拿深度模型训练,识别率可以但训练数据、标注、调参工作量直接劝退;用虹软ArcFace这类商用SDK,检测+特征提取+比对全套给齐,C#封装完善,免费版对个人学习和Demo完全够用。
这个Demo选了第三条路,也是在实际交付中最稳的路。虹软免费SDK的免费额度对商用有限制,但Demo和原型阶段不会触发,而且它最大的价值在于:人脸检测、特征提取、特征比对三个核心步骤都是黑盒接口,你只需要把图像数据喂进去,拿回结果。对一个要快速跑通流程的项目来说,把时间花在业务逻辑上,而不是去调分类器参数,明显更划算。工程里能看到SDK封装层和业务层是分开的,换SDK牌子时不用大面积改UI代码。
2.2 目录结构:一个让人舒服的WinForms工程长什么样
解压后建议先看目录,整个工程的模块边界比较清晰。核心结构大致如下:
FaceRecognitionDemo/ ├── FaceRecognitionDemo.sln ├── FaceRecognitionDemo/ │ ├── App.config # SDK激活配置、数据库连接串 │ ├── Program.cs # 启动入口 │ ├── MainForm.cs # 主窗体:摄像头预览、识别结果展示 │ ├── MainForm.Designer.cs # 窗体设计器生成代码 │ ├── Core/ │ │ ├── FaceEngineFactory.cs # SDK引擎初始化与激活封装 │ │ ├── FaceDetector.cs # 人脸检测封装 │ │ ├── FaceRecognizer.cs # 特征提取与比对封装 │ │ └── FaceFeatureDatabase.cs # 本地特征库管理(序列化存储) │ ├── Models/ │ │ ├── FaceInfo.cs # 检测结果模型 │ │ └── RegisteredUser.cs # 注册用户模型 │ ├── Utils/ │ │ ├── ImageConverter.cs # 摄像头帧转Bitmap、转ImageInfo │ │ └── IniConfigHelper.cs # 读取配置文件工具 │ ├── libs/ │ │ ├── arcsoft_face.dll # SDK原生DLL(x64) │ │ └── arcsoft_face_c.dll # C接口DLL │ └── Resources/ │ └── face_images/ # 注册照片样本注意libs目录里的DLL要设置“复制到输出目录”,否则一运行就报DLL找不到。App.config里通常放着SDK的AppId、SdkKey、激活码,以及特征库文件的保存路径。Core目录是整个项目的重点,它把SDK的C接口包装成了C#类,UI层只跟FaceDetector和FaceRecognizer打交道。
2.3 SDK能力边界:免费版能做哪些事
免费SDK的能力限制必须在设计阶段就清楚,否则做着做着发现某个功能用不了,返工成本很高。以这套Demo用到的能力为例:
| 能力项 | 使用方式 | 限制说明 |
|---|---|---|
| 人脸检测 | ASFFaceEngine.DetectFace | 免费版有人脸检测次数上限,超出后返回空结果 |
| 特征提取 | ASFFaceEngine.ExtractFeature | 单次调用耗时约几十毫秒,与图片大小相关 |
| 1:1比对 | ASFFaceEngine.CompareFace | 返回相似度分数,阈值需根据场景自调 |
| 1:N检索 | 自写循环比对 + 排序 | SDK不直接提供1:N接口,Demo用遍历特征库实现 |
| 活体检测 | RGB活体 | 部分版本开放,免费版可能不可用 |
这决定了Demo的架构:特征库是本地文件,比对是线性遍历。人脸数量在几十人的规模下完全够用,超过几百人时比对耗时线性增长,那就要考虑特征向量聚类或换付费版了。
3. 核心模块拆解:一个请求从摄像头到识别结果的完整链路
3.1 引擎初始化:激活码与引擎实例的创建
SDK使用有一个规矩:先激活,再初始化引擎,最后才能调用人脸接口。激活只需要一次,但AppId、SdkKey和激活码必须匹配。激活码通常是一个压缩包里的独立文件,用机器码绑定,换电脑就要重新激活,这是新手第一个拦路虎。FaceEngineFactory把激活和引擎创建封装在同一个入口里:
public class FaceEngineFactory { private static IntPtr _engineHandle = IntPtr.Zero; public static bool InitEngine(string appId, string sdkKey, string activeKey) { int result = ASFFunctions.ASFActivation(appId, sdkKey, activeKey); if (result != 0) { // 返回值含义:0成功,其他值对应错误码,比如81921表示激活码错误 return false; } ASF_DETECT_MODE detectMode = ASF_DETECT_MODE.ASF_DETECT_MODE_IMAGE; int detectFaceOrientPriority = 1; // 优先检测正脸 int detectFaceScaleModel = 16; // 检测模型,16适合近距离人脸 int detectFaceMaxNum = 5; // 一帧最多检测5张人脸 result = ASFFunctions.ASFInitEngine( detectMode, detectFaceOrientPriority, detectFaceScaleModel, detectFaceMaxNum, out _engineHandle); return result == 0; } public static IntPtr GetEngine() { return _engineHandle; } }初始化参数里两个值最需要关注:detectFaceOrientPriority设为1表示只检测正脸,识别速度更快但侧脸漏检率上升;detectFaceScaleModel设为16表示用于近距离场景,如果摄像头在3米外抓人,这个值要改成32。引擎初始化失败时,返回值是负数,常见的-86004表示内存分配失败,通常是传入图像格式不对。
3.2 摄像头上帧与图像格式转换
WinForms里取摄像头画面,常见做法是用DirectShow库封装一个视频源类,或者直接调SDK的ArcFace摄像头组件。这个Demo里用的是AForge框架的VideoCaptureDevice,帧回调里拿到的是一张Bitmap。SDK要求图像以灰度图或NV21格式传入,所以需要把Bitmap转成SDK能识别的ASF_ImageData:
public static ASF_ImageData BitmapToImageData(Bitmap bitmap) { // 先把原图转成24位RGB,保证像素格式统一 Bitmap rgb24 = new Bitmap(bitmap.Width, bitmap.Height, System.Drawing.Imaging.PixelFormat.Format24bppRgb); using (Graphics g = Graphics.FromImage(rgb24)) { g.DrawImage(bitmap, 0, 0, bitmap.Width, bitmap.Height); } // 锁定像素内存区域,避免逐像素SetPixel的性能灾难 Rectangle rect = new Rectangle(0, 0, rgb24.Width, rgb24.Height); BitmapData bmpData = rgb24.LockBits(rect, ImageLockMode.ReadOnly, rgb24.PixelFormat); ASF_ImageData imageData = new ASF_ImageData(); imageData.format = ASF_ImageFormat.ASF_CL_IMAGE_PL_RGB24; imageData.width = rgb24.Width; imageData.height = rgb24.Height; imageData.imgData = bmpData.Scan0; imageData.pixelFormatFormat = 0; imageData.pitch = bmpData.Stride; ASF_MultiFaceInfo faceInfo = new ASF_MultiFaceInfo(); int ret = ASFFunctions.ASFDetectFaces(_engineHandle, imageData, out faceInfo); rgb24.UnlockBits(bmpData); return imageData; }这里最容易踩的坑是pitch(每行字节数)不赋值。Bitmap的Stride不等于Width乘以3,因为GDI+会按4字节对齐,如果直接把Stride填进pitch,SDK拿到的图像数据就是错位的,检测结果会出现框位置偏移。另外,使用LockBits后UnlockBits必须放在所有SDK调用之后,SDK内部会同步读取图像数据,但如果是在异步回调里,确保Bitmap存活到SDK返回即可。
3.3 人脸检测:拿到人脸框与关键点
引擎初始化完成后,FaceDetector类里就是对SDK检测接口的简单封装。ASFDetectFaces一次调用返回所有检测到的人脸框坐标、角度和每张脸的置信度。这个Demo在主窗体里用一个定时器驱动:每100毫秒从当前帧检测一次,确保画面流畅又不会耗尽免费额度:
private void Timer_Tick(object sender, EventArgs e) { Bitmap frame = _camera.GetCurrentFrame(); if (frame == null) return; ASF_MultiFaceInfo faceInfo = _faceDetector.Detect(frame); // 在原始帧上用Graphics画框 using (Graphics g = Graphics.FromImage(frame)) { Pen pen = new Pen(Color.Red, 3); for (int i = 0; i < faceInfo.faceNum; i++) { Rectangle rect = new Rectangle( faceInfo.faceRects[i].left, faceInfo.faceRects[i].top, faceInfo.faceRects[i].right - faceInfo.faceRects[i].left, faceInfo.faceRects[i].bottom - faceInfo.faceRects[i].top); g.DrawRectangle(pen, rect); // 如果已注册,在框上方写名字;未注册则写“Unknown” string name = _recognizer.Recognize(frame, faceInfo.faceRects[i]); g.DrawString(name, new Font("Microsoft YaHei", 12), Brushes.Yellow, rect.X, rect.Y - 20); } } pictureBox.Image?.Dispose(); pictureBox.Image = frame; }画框这步有个血泪经验:不要把pictureBox.Image = frame和g.DrawRectangle写在同一个Bitmap上之后直接赋值。因为GDI+对象在渲染完成后才被释放,下一次绘制时GetCurrentFrame返回的可能是同一块内存,直接Dispose旧Image会抛出“对象当前正在使用中”。稳妥的做法是先画完,再赋给PictureBox,等下一帧来的时候先Dispose旧引用,但这个Dispose必须放在新帧赋值之前。
3.4 特征提取与本地特征库管理
检测到人脸只是第一步,识别需要先把人脸转成特征向量。FaceRecognizer里ExtractFeature方法把检测到的人脸区域传入SDK的特征提取接口,返回一个byte[]。这个Demo的特征库用二进制序列化存成本地文件,目录是Resources/face_images/下的同名.dat文件。注册用户的流程是:摄像头对准人脸,点“注册”,录入姓名,系统截取当前帧提取特征,保存到文件:
public bool RegisterFace(Bitmap frame, string name) { ASF_SingleFaceInfo singleFace = new ASF_SingleFaceInfo(); ASF_FaceData faceData = new ASF_FaceData(); // 第一步:从帧里取第一张脸的区域 ASF_MultiFaceInfo multiFace = _faceDetector.Detect(frame); if (multiFace.faceNum == 0) return false; singleFace.faceRect = multiFace.faceRects[0]; singleFace.faceOrient = multiFace.faceOrients[0]; // 第二步:提取特征 int ret = ASFFunctions.ASFFaceFeatureExtract( _engineHandle, frame, singleFace, out faceData); if (ret != 0) { return false; } // 第三步:转成字节数组并保存 byte[] feature = new byte[faceData.featureSize]; Marshal.Copy(faceData.feature, feature, 0, faceData.featureSize); RegisteredUser user = new RegisteredUser { Name = name, Feature = feature, RegisterTime = DateTime.Now }; FaceFeatureDatabase.Save(user); return true; }特征大小由SDK决定,不同版本特征维度不同,常见的是512维float数组,实际占用的字节数要看featureSize。保存特征时不要只存裸特征,建议连版本号一起存。SDK升级后特征维度如果变了,旧特征文件全废,有版本号就能在加载时给出明确提示,而不是比对结果全是负数。
3.5 比对逻辑:阈值怎么选才不会六亲不认
特征比对是1:1接口,输入两个特征,返回相似度。SDK返回的相似度范围受算法影响,常见取值在0到1之间,越接近1越像。这个Demo把阈值默认设在0.8,高于则认为同一个人:
public string Recognize(Bitmap frame, ASF_RECT faceRect) { byte[] targetFeature = ExtractFeature(frame, faceRect); if (targetFeature == null) return "Unknown"; double maxScore = 0; string maxName = "Unknown"; List<RegisteredUser> users = FaceFeatureDatabase.LoadAll(); foreach (RegisteredUser user in users) { ASF_FaceData faceData = new ASF_FaceData(); IntPtr unmanagedPtr = Marshal.AllocHGlobal(user.Feature.Length); Marshal.Copy(user.Feature, 0, unmanagedPtr, user.Feature.Length); faceData.feature = unmanagedPtr; faceData.featureSize = user.Feature.Length; float score = 0; ASFFunctions.ASFFaceCompare( _engineHandle, targetFeature, faceData, out score); Marshal.FreeHGlobal(unmanagedPtr); if (score > maxScore) { maxScore = score; maxName = user.Name; } } return maxScore > 0.8 ? maxName : "Unknown"; }这个循环里有两个性能关键点。第一,Marshal.AllocHGlobal每次比对都要做,特征数量一多就很伤;改进方案是加载特征库时统一把字节数组转成连续的内存块,比对时直接用指针偏移。第二,LoadAll()如果每次识别都从磁盘读文件,摄像头实时模式下IO会成为瓶颈,至少要加一层内存缓存。如果识别结果出问题,先在阈值上做文章:0.8太严格就调到0.75,两个不同的人也会被认成同一个就往上调。
4. 常见问题排查:激活失败、白屏、识别误差的实战复盘
4.1 激活码报错81921
现象:初始化引擎时ASFActivation返回非0,值大概是81921,程序直接退出,网上搜代码发现别人也用同样的AppId。
原因:免费SDK的激活码是和电脑硬件指纹绑定的。同一套AppId、SdkKey和激活码,在其他机器上能用,换到这台就报错,因为你拿到的激活码不是给这台机器生成的。另一种常见情况是,解压SDK时激活码文件被杀了毒软件删了,但程序还在尝试读取它。
解决:去SDK官网重新生成一个激活码,生成时会绑定机器码(通常在SDK包里有个工具能查看本机机器码)。把新的激活码填到App.config里重新跑。注意激活码有有效期,过期同样报错,需要重新生成。从那以后我每次解压SDK都会先确认activekey.txt在不在、内容是不是空,再谈后面的事。
4.2 摄像头画面黑屏,但程序没报错
现象:窗体弹出来了,PictureBox区域一片黑,定时器也在走,拿到的Bitmap是null或者全是黑色像素。
原因:摄像头被其他程序占用是头号嫌疑,比如微信、Teams还在用摄像头;其次是AForge的VideoCaptureDevice在Start()的时候失败了,但异常被吞掉,只在输出窗口留一行日志;还有可能是视频格式选了一个设备不支持的解析度,AForge默认会选第一个匹配的尺寸,某些老摄像头只支持640x480而不支持1920x1080。
解决:在摄像头启动前先用VideoCaptureDevice.VideoCapabilities遍历一遍,打印所有分辨率,手动指定一个宽高。同时把NewFrame回调里的代码包在try-catch里,任何异常都弹日志。启动失败时检查Windows设置里的“摄像头隐私权限”,Windows 10以上版本默认会阻止桌面应用访问摄像头,控制面板里把“允许桌面应用访问相机”打开就好了。
4.3 识别框位置和实际人脸位置对不上
现象:框画的是一张脸,但框出来的区域是背景墙;人脸在左边,框却在右边。
原因:这个问题的根源在Bitmap到SDK图像数据的转换,pitch字段填错了,或者LockBits时锁定的区域不是整个Bitmap。另外一个隐蔽原因是WinForms的PictureBox在缩放模式下显示,PictureBox的SizeMode如果是Zoom或Stretch,绘制框时用的是原始Bitmap坐标,但显示时被拉伸了,看起来就有位置偏差。
解决:把pictureBox.SizeMode固定为PictureBoxSizeMode.Zoom,然后在绘制时计算缩放比例,把原始坐标映射到显示坐标。代码里保存一个_scaleX = (float)pictureBox.Width / frame.Width,画框时全部乘这个系数。还有一个玄学情况:摄像头采集的旋转角度不同,比如竖屏摄像头采集的Bitmap自带EXIF旋转,检测框坐标系和显示坐标系差了90度,需要手动翻转坐标。
4.4 识别结果一直Unknown,注册的人永远认不出来
现象:注册成功了,特征也保存了,但摄像头里的自己始终显示Unknown。
原因:最常见的是阈值卡太死,0.82的阈值在特定光线环境下,同一个人的相似度经常在0.78到0.85之间波动。另一个原因是注册时用的是摄像头当前帧,本身带有运动模糊,提出来的是一个残缺脸的特征,本身质量就差,比对得分当然低。还有可能是特征字节序问题:SDK把特征导出成byte数组,有些版本的C#封装需要转成float数组再算余弦相似度,直接拿byte数组送进Compare接口得到的结果是乱码水平。
解决:先用SDK自带的Demo工具测一下你们的相似度分数大概在什么区间。如果同一个人两次拍摄得分总是0.75到0.85,就把阈值调到0.7。注册时多做一次质量检查:检测当前帧的人脸置信度,低于某个值就提示“请靠近/正对摄像头”。特征要么统一走SDK的比对接口,要么统一走自定义余弦相似度计算,不要一次用SDK一次用自定义算法去交叉比,那等于两个坐标系在对比。
4.5 启动报DLL不存在或初始化返回其他负数错误码
现象:F5跑起来,还没看到窗体就弹出DllNotFoundException。
原因:SDK的原生DLL没有复制到输出目录。注意虹软SDK有x86和x64两套DLL,如果你的项目编译成AnyCPU,它会按当前系统位数加载,但没拷贝到bin目录就找不到。还有一种情况是DLL在,但依赖的VC++运行库没装,初始化时报的是-87002这类底层错误。
解决:把libs下对应的DLL右键属性设置为“如果较新则复制”,或者直接在构建事件里写一行copy命令。项目平台目标明确设为x64(或x86,取决于你拿到的SDK版本),别用AnyCPU。装一下VC++ 2015-2022运行库,初始化失败的错误码记下来,对照SDK文档给的错误码表逐一查。
5. 性能优化与可信度:从“能跑”到“跑得稳”
5.1 识别延迟与CPU占比测试
Demo直接跑起来,普通笔记本上人脸检测大概50到80毫秒,特征提取加比对的耗时和人脸数量、特征库大小正相关。单人脸、100人特征库时,单帧总耗时约100到150毫秒,能跑出15帧左右的实时效果。如果这个数字在你的机器上翻倍,先确认是不是Debug模式跑的,JIT优化没开全性能差距很大;再确认摄像头采集分辨率,AForge默认可能给你拉到1920x1080,检测时间翻几倍,把分辨率降到640x480立即见效。
代码里还藏着两处隐藏的性能问题。第一,Timer_Tick里每次LoadAll()读磁盘,这个一定要改:启动时把特征库load进内存字典,后续注册新用户时同步更新内存和磁盘。第二,Recognize循环里每次都Marshal.AllocHGlobal,100人就是100次分配释放,改成预处理后比对耗时能砍掉一半。改完可以用Stopwatch在定时器首尾打点,把每帧耗时显示在窗体标题栏上,肉眼可见从“卡顿”变成“顺滑”。
5.2 多人脸识别与ID分配策略
SDK的检测接口一帧最多返回5张人脸(初始化参数决定),所以Demo支持多人同屏识别。但多人场景会暴露一个新问题:人脸框是每帧重新检测的,同一张脸在不同帧里没有稳定ID——如果画面里两个人交叉走过,上一帧的“A”和这一帧的“A”是不是同一个人,需要额外做跟踪。
这套Demo没有做帧间跟踪,用的是比较取巧的做法:每次检测到的人脸都做一次1:N比对,把名字显示在框上。画面不动的时候识别稳定,一旦人物移动频繁,偶尔会出现框上的名字闪变。想做得更稳,可以用IoU(交并比)判断相邻帧人脸框是否重叠:上一帧A框和当前帧B框的IoU超过0.5,就认为是同一张脸,直接用上一帧的识别结果,不再重复比对。这样既省特征提取次数,又让名字显示更稳定。
5.3 光线与环境适配:调参的真实逻辑
人脸识别SDK对光线极度敏感。逆光环境下整张脸是黑的,检测阶段就可能直接漏掉;侧面强光导致半张脸过曝,特征提取质量大幅下降。这个Demo没有做图像增强预处理,所以实际部署时要注意摄像头位置:避免正对窗户、避免顶光直射、尽量用顺光环境。如果环境条件改不了,可以加一个简单的直方图均衡化预处理:
public static Bitmap Preprocess(Bitmap src) { Bitmap gray = new Bitmap(src.Width, src.Height, PixelFormat.Format24bppRgb); using (Graphics g = Graphics.FromImage(gray)) { g.DrawImage(src, 0, 0, src.Width, src.Height); } // 转成灰度数组做直方图均衡化 // 这里省略具体循环,核心是让低亮度区域的对比度提升 // 注意:预处理会增加每帧耗时,约10ms,低端设备慎用 return gray; }预处理加在摄像头回调里,先增强再送检测。效果是逆光环境的人脸检测率从三成提升到七八成,但代价是图像细节损失,特征提取的稳定性可能受影响。我更推荐的做法是放弃预处理,直接把阈值调低、同时让用户稍微走近摄像头。光线问题永远先改环境,再改代码。
6. 二次开发实战:从Demo改成考勤机,你需要动哪些代码
6.1 第一步:把特征库从序列化文件换成SQLite
Demo自带的特征存储是文件往桌面一扔,对演示够用,但做成考勤机就意味着要支撑历史记录查询、考勤统计、人员管理,文件存储撑不住。换成SQLite是成本最低的方案。表结构大致长这样:
CREATE TABLE person ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, feature BLOB NOT NULL, feature_version INTEGER, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE attendance ( id INTEGER PRIMARY KEY AUTOINCREMENT, person_id INTEGER NOT NULL, recognize_time DATETIME DEFAULT CURRENT_TIMESTAMP, similarity REAL, FOREIGN KEY(person_id) REFERENCES person(id) );替换时不要直接把FaceFeatureDatabase里的File.ReadAllBytes换成SQLiteCommand就完事,最好把接口先抽象出来:定义IFaceFeatureStore,包含AddUser、GetAllUsers、GetUserByName三个方法,然后分别实现FileStore和SqliteStore两个版本。这样切换存储只需要改一行注入代码,后续接MySQL也顺手。考勤记录写入走异步队列,别在摄像头线程里同步写数据库,掉帧是小事,卡顿导致人脸检测超时才是大事。
6.2 第二步:从“识别成功”到“记录考勤”的流程改造
Demo的逻辑是每帧识别,识别到就画名字。考勤场景不能这么写:否则一个人站在摄像头前5秒,就打了5次卡。常见做法是加一个状态机:未打卡状态 -> 识别成功 -> 标记已打卡 -> 冷却时间内不再重复记录。伪代码如下:
private Dictionary<string, DateTime> _lastRecordTime = new Dictionary<string, DateTime>(); private string CheckAttendance(Bitmap frame) { string name = _recognizer.Recognize(frame); if (name == "Unknown") return string.Empty; // 冷却时间:10分钟内同一人不重复打卡 if (_lastRecordTime.TryGetValue(name, out DateTime lastTime)) { if ((DateTime.Now - lastTime).TotalMinutes < 10) { return string.Empty; } } _lastRecordTime[name] = DateTime.Now; AttendanceService.Record(name); return name; }冷却时间这个参数要暴露到配置文件里,给甲方留调节余地。另外注意,识别成功和打卡成功之间如果有消息弹窗、声音提示,要考虑UI线程阻塞问题:识别是后台线程,弹窗是UI线程,跨线程访问控件要用Invoke。
6.3 第三步:接入更高质量的摄像头硬件
Demo代码里摄像头用的AForge,它适合USB摄像头,但考勤机场景一般用海康、大华的RTSP流。从Demo改过去的核心工作有两块:一是把视频源抽象出接口,二是处理RTSP流的解码格式。RAForge对RTSP的支持比较弱,常见做法是改用FFmpeg的C#封装(如FFMpegCore或OpenCvSharp的VideoCapture),拉流解码后转成Bitmap,再送到现有的人脸检测链路。
好消息是FaceDetector和FaceRecognizer完全不用动,只要替换视频源层。给你一个改造建议:先写一个IVideoSource接口,包含Start()、Stop()、event Action<Bitmap> OnFrameReceived,然后把现在的AForge摄像头封装成一个UsbCameraSource,再写一个RtspCameraSource做拉流。这样以后换任意摄像头,只需要新增一个类。这个过程走一遍,你会发现SDK封装层一开始就没有和UI层耦合是这套代码里最有价值的设计决策,改硬件、改存储都不影响识别核心。
我自己做考勤机改造时翻过一次车:以为RTSP拉流的帧可以直接喂给SDK,结果发现FFmpeg解码出来的是BGR24格式,而SDK要求RGB24或NV21,颜色通道顺序反了,检测率断崖式下跌。排查了半天才想起查像素格式,从那以后每条视频链路我都会打一个像素格式校验日志,宁可慢也不能带病上线。
希望这篇拆解能帮你把Demo跑起来,也能让你在改造它的时候少踩两个坑。全部源码和依赖都在资源包里,环境配对后Run一次就够了。
本文还有配套的精品资源,点击获取