Android人脸识别考勤系统源码实战:离线TFLite方案与调优 简介一份基于Android平台的人脸识别考勤系统完整工程源码适合移动开发学习者、毕业设计者及需要快速搭建考勤原型的开发者。项目围绕人脸检测、特征提取、签到记录与后台管理展开可帮助理解OpenCV集成、Android原生界面与业务逻辑的整合方式。资源包共1087个文件以213个java源文件与198个xml布局/配置为主干辅以184个hpp、168个class、119个a及10个so等OpenCV与JNI底层库文件并含png图标、dex打包文件、gradle构建脚本等整体大小147.16MB目录结构完整便于直接导入Android Studio分析。包内还包含多份日志、属性配置及工程说明可辅助排查集成问题。目前已有385人学习适合作为课程设计参考或企业考勤场景的二次开发基础。1. 基于Android的人脸识别考勤系统源码先搞清楚这套系统到底是什么早上打卡高峰期办公室门口的考勤机前总是挤着人有人忘带工卡有人指纹磨平管理员还得手动补录。一个能不依赖门禁硬件、用普通Android手机就能跑的人脸识别考勤系统确实能解决这类问题。基于Android的人脸识别考勤系统源码指的就是一套把“摄像头采集、人脸检测、特征提取、特征比对、考勤记录存储”全部封装进一个App的完整工程。它的核心价值是离线可用人脸变成向量比对在手机本地完成不上传任何照片到服务器。适合公司考勤、实验室打卡、培训机构点名这类对成本敏感又不想被云端API卡住脖子的场景。接下来我会从选型、工程结构、关键代码到踩坑点把这套系统的完整落地路径讲清楚。2. 技术选型与人脸识别流程为什么离线TFLite方案更适合考勤2.1 为什么本地人脸识别优于云端API最常见的考勤App方案是调用云厂商的在线人脸识别API把人脸图片上传再拿返回的结果。这种方案开发时很轻松但到了真实部署场景就会暴露出一堆问题第一是网络依赖办公室Wi-Fi偶尔抽风打卡就会失败第二是隐私风险人脸照片属于敏感生物信息员工一旦知道自己的照片被传到公网抵触情绪会非常强烈第三是成本API按次计费几百人的公司每天刷两次一年下来的账单并不好看。基于Android的离线考勤系统所有识别逻辑都在设备端完成。摄像头捕捉到的人脸数据不会离开手机速度也快得多。实测中一颗中端SoC跑MobileFaceNet这种轻量化特征模型单次推理在50到120毫秒之间完全满足考勤场景的实时性要求。对于企业或学校来说部署成本就是几台旧手机这比采购专用闸机便宜一个数量级。所以这套源码的技术路线值得选本地人脸检测加本地特征提取不依赖云端。常见实现是“MLKit做人脸检测、TensorFlow Lite跑MobileFaceNet特征提取、SQLite存特征和考勤记录”。这条路线有几个优点模型文件小、无需注册许可证、代码边界清晰后续想换成其他人脸模型也容易。2.2 人脸识别链路检测、对齐、特征提取、比对把一次完整的人脸识别考勤拆开看一共是四步。第一步是检测在摄像头预览帧里找到人脸位置和关键点第二步是对齐把检测到的人脸裁剪出来并修正旋转和缩放统一成模型能接受的输入尺寸第三步是特征提取用深度学习模型把人脸表示成一个固定长度的浮点数组比如128维或者256维第四步是比对用余弦距离等指标计算待识别特征和注册模板之间的距离距离小于阈值就判定为同一个人。检测模块我一般用MLKit的人脸检测器。它没有把人脸识别封装成黑盒子而是直接给你人脸边界框、关键点、头部角度和眼睛开合状态。你用这些结果做完对齐后再丢给特征模型这是目前最可控的做法。如果你不分开做而是直接用某个端到端识别SDK模板和算法就被绑定死了后期很难调优。特征提取模型的选择不同项目差异很大。FaceNet在LFW上准确率高但模型体积大手机上推理偏慢。ArcFace和CosFace在比赛里刷分很强但只适合有GPU的服务器。MobileFaceNet是专门给移动端设计的在保持高精度的同时参数量很小常见模型体积在几MB到十几MB之间非常适合放在assets目录里随App分发。特征维度一般选128或192考勤场景128维已经够用低于64维会有明显的区分度下降。下表总结了三种方案选型的区别也是给刚接触这个方向的人一个决策参考。方案网络依赖隐私成本端侧可行性云端API强照片上传按次付费一般本地MLKit TFLite无本地处理一次性开发很好纯OpenCV传统算法无本地处理低差受光线影响极大选定路线后工程依赖就有明确方向了。在build.gradle里加上下面这些依赖这套系统的基础技术栈就齐了。dependencies { implementation androidx.camera:camera-camera2:1.3.1 implementation androidx.camera:camera-lifecycle:1.3.1 implementation androidx.camera:camera-view:1.3.1 implementation com.google.mlkit:face-detection:16.1.6 implementation org.tensorflow:tensorflow-lite:2.13.0 }版本号建议按你创建工程时Android Studio提示的兼容版本微调。CameraX负责取流MLKit做人脸检测TFLite跑特征模型。这三者内部不需要做过多集成由业务逻辑把它们串起来即可。如果你打算用Java写同样适用只是语法换成Java。3. 工程搭建从CameraX预览到人脸检测的落地代码3.1 工程结构与资源准备整个考勤App的模块可以分成两类一类是管理端功能包括员工录入、特征库管理、考勤记录列表另一类是识别端功能就是实时摄像头下完成检测、比对和打卡。新建工程后我习惯把代码按domain分包ui放页面camera放预览和图像分析ml放模型加载与特征提取db放SQLite操作。assets目录下放置两个关键文件人脸特征模型如mobile_face_net.tflite以及模型对应的输入输出格式说明文件实际项目中通常就是一个.txt或由代码写死输入尺寸。AndroidManifest里必须声明相机权限和相机硬件特性否则系统会直接拒绝启动。uses-permission android:nameandroid.permission.CAMERA / uses-feature android:nameandroid.hardware.camera android:requiredtrue /接下来在Activity里动态申请权限不能在Manifest里写完就算。运行时权限需要考虑到Android 6.0以上用户拒绝的情况。申请到权限后把PreviewView绑定到生命周期上这是CameraX的标准做法代码稳定且不涉及Camera1的底层细节。3.2 CameraX预览实时取帧到ImageAnalysisCameraX最省心的地方在于它自动处理了不同Android设备的相机会话差异。我用Preview显示画面用ImageAnalysis获取分析用的图像帧。关键是要设置STRATEGY_KEEP_ONLY_LATEST意思是分析器处理不过来时直接丢掉旧帧取最新帧避免卡顿。这个设置对人脸识别场景非常重要因为检测和推理耗时不固定如果不丢帧任务队列越积越多预览就会越来越卡。val cameraProviderFuture ProcessCameraProvider.getInstance(this) cameraProviderFuture.addListener(Runnable { val cameraProvider cameraProviderFuture.get() val preview Preview.Builder().build() val imageAnalysis ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setTargetResolution(Size(640, 480)) .build() imageAnalysis.setAnalyzer(Executors.newSingleThreadExecutor()) { imageProxy - val mediaImage imageProxy.image if (mediaImage ! null) { val inputImage InputImage.fromMediaImage( mediaImage, imageProxy.imageInfo.rotationDegrees ) FaceDetector.process(inputImage) .addOnSuccessListener { faces - // faces 非空即检测到人脸后续处理在 UI 层 handleFaces(faces) } .addOnCompleteListener { imageProxy.close() } } else { imageProxy.close() } } preview.setSurfaceProvider(binding.previewView.surfaceProvider) cameraProvider.bindToLifecycle( this, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageAnalysis ) }, ContextCompat.getMainExecutor(this))这里有一个特别容易翻车的细节imageProxy.close()一定要在流程结束时执行。无论是成功回调还是失败回调都要关闭ImageProxy否则Camera底层的缓冲池会被占满预览和检测都会神秘卡死。我见过好几个项目在低端机上预览画面一卡一卡最后发现就是这里漏了close()。setTargetResolution(Size(640, 480))也不是越大越好。分辨率太高每一帧的耗电和处理时间都翻倍人脸识别检测框的精度不会因此获得明显提升。实际考勤场景人脸通常离摄像头一米以内VGA分辨率足够检测关键点。如果设备是横屏放置需要注意分辨率宽高比否则摄像头会自己裁剪画面。3.3 人脸检测和对齐从boundingBox到112x112输入MLKit返回的Face对象里boundingBox是人脸矩形框。但需要注意它对应的坐标是基于InputImage的坐标不是屏幕坐标系也不是原始Bitmap的坐标。如果你要从相机帧转成Bitmap再裁剪就必须把宽高比和旋转都考虑进去否则剪出来的区域永远是歪的。private fun cropAndAlign(bitmap: Bitmap, face: Face, scaleX: Float, scaleY: Float): Bitmap { val box face.boundingBox val left (box.left * scaleX).toInt().coerceAtLeast(0) val top (box.top * scaleY).toInt().coerceAtLeast(0) val width (box.width() * scaleX).toInt().coerceAtMost(bitmap.width - left) val height (box.height() * scaleY).toInt().coerceAtMost(bitmap.height - top) val cropped Bitmap.createBitmap(bitmap, left, top, width, height) return Bitmap.createScaledBitmap(cropped, 112, 112, true) }scaleX和scaleY的计算方式取决于你拿到的Bitmap和InputImage是否同尺寸。如果Bitmap就是直接从ImageProxy YUV转来的那么它们应该都是1.0。如果Bitmap经过了旋转则需要先做旋转再映射坐标。更稳的用法是在createBitmap前先把Bitmap按rotationDegrees旋转成和预览一致的方向再统一坐标系。MLKit的face.boundingBox其实已经基于旋转后的InputImage坐标所以代码中不要让Bitmap方向与InputImage不一致。对齐完成后这张112x112的脸会被送给特征提取模型。112x112是MobileFaceNet的标准输入尺寸也有些模型用96x96或128x128取决于你下载的TFLite文件对输入的要求。可以先用Tool打印模型输入维度再决定你的crop尺寸。不要假设所有模型都是112这是一个非常常见的黑匣子问题。4. 特征提取与人脸比对用MobileFaceNet把一张脸变成128维向量4.1 TFLite输入预处理归一化和内存布局MobileFaceNet的输入要求是经过归一化后的浮点数。常见的归一化方式是把RGB三个通道从[0,255]映射到[-1,1]即像素值除以127.5再减1。如果我拿到的模型是按[0,1]归一化训练的那预处理方式就要调整。我建议在模型开始时用一段调试代码检查输出结果确保预处理一致否则后续比对距离会整体偏移阈值怎么调都不对。fun preprocessBitmap(bitmap: Bitmap, inputWidth: Int, inputHeight: Int): ByteBuffer { val scaled Bitmap.createScaledBitmap(bitmap, inputWidth, inputHeight, true) val bytes ByteBuffer.allocateDirect(4 * inputWidth * inputHeight * 3) .order(ByteOrder.nativeOrder()) val pixels IntArray(inputWidth * inputHeight) scaled.getPixels(pixels, 0, inputWidth, 0, 0, inputWidth, inputHeight) for (pixel in pixels) { val r ((pixel shr 16) and 0xFF) / 127.5f - 1f val g ((pixel shr 8) and 0xFF) / 127.5f - 1f val b (pixel and 0xFF) / 127.5f - 1f bytes.putFloat(r) bytes.putFloat(g) bytes.putFloat(b) } return bytes }这段代码有几个关键参数ByteOrder.nativeOrder()决定了内存字节序TFLite模型运行时会按照模型定义的布局读取数据如果不匹配会得到乱码输出。scaled必须用createScaledBitmap不要直接在原图上resize避免影响后续提取速度。内存布局这里默认是CHW且按RGB顺序如果你的模型是BGR或NHWC遍历pixel的顺序就要改。这一点没有统一标准每次换模型都要重新确认。4.2 加载TFLite模型与特征提取加载TFLite模型需要用Interpreter类通过读assets下的文件创建实例。注意Interpreter不是线程安全的多个识别线程同时调用同一个实例会出现崩溃。这里我按单人脸实时场景只在分析线程里串行调用没有做并发保护。class FeatureExtractor(private val context: Context) { private val interpreter: Interpreter init { val model FileUtil.loadMappedFile(context, mobile_face_net.tflite) val options Interpreter.Options().apply { setNumThreads(4) } interpreter Interpreter(model, options) } fun extract(bitmap: Bitmap): FloatArray { val input preprocessBitmap(bitmap, 112, 112) val output Array(1) { FloatArray(128) } interpreter.run(input, output) return l2Normalize(output[0]) } private fun l2Normalize(feature: FloatArray): FloatArray { var norm 0f for (v in feature) norm v * v norm sqrt(norm) 1e-8f for (i in feature.indices) feature[i] / norm return feature } }setNumThreads(4)在四核机器上能明显提速但在老旧设备上线程过多会挤压主线程资源导致UI卡顿。建议在设置开放一个隐藏项让管理员在设置页自由切换识别的线程数。输出维度是128如果你下载的模型特征维度是256这里要改成256。可以用interpreter.getOutputTensor(0).shape()拿到实际值不要写死。l2Normalize这一步非常关键。人脸特征比对在数学上更依赖方向性而不是向量的绝对长度。如果不归一化光照变化会导致向量模长变化直接影响距离计算结果。这也是很多项目在没有明显代码bug的情况下识别率上不去的原因之一。4.3 注册和识别余弦距离与阈值设定注册就是把某个人脸的特征向量存进SQLite。录入时最好连续抽取几帧计算特征中心。我的习惯是取5帧剔除距离中心最远的1帧剩下的4帧求平均再归一化保存。这样的模板比对时更稳定单帧出现模糊或表情差异时不会把模板带偏。fun register(personId: Long, faceBitmap: Bitmap) { val feature featureExtractor.extract(faceBitmap) featureDao.insert(personId, toByteArray(feature)) } fun recognize(feature: FloatArray): Long? { val templates featureDao.getAll() var bestId: Long? null var bestDist Float.MAX_VALUE for (template in templates) { val dist cosineDistance(feature, fromByteArray(template.embedding)) if (dist bestDist) { bestDist dist bestId template.personId } } return if (bestDist threshold) bestId else null } fun cosineDistance(a: FloatArray, b: FloatArray): Float { var dot 0f var na 0f var nb 0f for (i in a.indices) { dot a[i] * b[i] na a[i] * a[i] nb b[i] * b[i] } return 1 - dot / (sqrt(na) * sqrt(nb) 1e-8f) }阈值是这套系统里最值得反复调的参数。cosineDistance返回的是1 - cos(夹角)所以值越小代表两个人脸越相似。通常范围在0到2之间考勤场景我会先从0.75开始然后用员工照片做批量测试。测试时记录误识率和拒识率画一个分布曲线取能让误识率降到1%以下且拒识率最小化的值。直接网上复制一个阈值不测试就是给自己埋雷。5. 考勤记录存储与高频踩坑重复打卡、误识别和阈值怎么调5.1 SQLite表设计人员、特征、考勤分离考勤App的数据量不大用SQLite完全够。表结构我分成三张人员表、人脸特征表、考勤记录表。人员表存工号和姓名特征表单独一张是为了以后换模型时可以在不删除人员信息的前提下重新生成特征考勤记录表只存打卡时间和人员ID方便统计。CREATE TABLE person ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, job_no TEXT UNIQUE, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE face_feature ( person_id INTEGER NOT NULL, embedding BLOB NOT NULL, version INTEGER DEFAULT 1 ); CREATE TABLE attendance ( id INTEGER PRIMARY KEY AUTOINCREMENT, person_id INTEGER NOT NULL, check_time TEXT DEFAULT CURRENT_TIMESTAMP, status INTEGER DEFAULT 1 );embedding字段用BLOB存储Kotlin里把FloatArray转成ByteArray用ByteBuffer.wrap就能相互转换。version字段很实用当模型升级后你可以通过判断version等于旧版本的人批量重新提取特征不影响已往考勤数据。考勤写入前必须做重复检查。同一个员工在几秒内可能被连续识别出多次如果不做处理当天就会产生海量重复记录。一个最简单可靠的做法是在写入前查当天是否已有记录已有就不再写入。fun checkIn(personId: Long): Boolean { val today SimpleDateFormat(yyyy-MM-dd, Locale.getDefault()).format(Date()) val exists attendanceDao.hasRecord(personId, today) if (exists) return false attendanceDao.insert(personId, Date()) return true }这个逻辑在单机版考勤里已经够用。如果以后需要多人公用一台手机还要加一个“同一设备每分钟只能为同一人打卡一次”的限制避免一个人代打卡后被另一个人连续覆盖。5.2 踩坑记录5个让识别率崩掉的常见原因现象暗光环境下检测框还在但比对距离普遍偏大经常提示识别失败。原因人脸图像亮度太低模型提取的特征与注册模板偏差明显。解决识别前对裁剪图像做自适应直方图均衡化把亮度拉回正常范围。也可以在ImageAnalysis里检测YUV数据的平均亮度低于某个阈值时直接用屏幕补光或提示打开闪光灯。这是我最早遇到的环境坑加入质量检测后再没有出现过大面积识别失败。现象侧脸角度超过30度识别率断崖式下降。原因MobileFaceNet模型训练数据以正面脸为主侧脸特征分布偏离模板。解决录入模板时要求员工正视摄像头并检查face.headEulerAngleY绝对值是否小于10度不符合的直接提示重录。侧脸打卡在考勤场景里应该被拒绝因为这会降低安全性。现象员工佩戴口罩时识别率几乎为零甚至会误识别成另一个人。原因口罩遮挡了下半脸的关键点信息特征模型会把没有下半脸特征的人脸看作一个不完整的异常样本。解决明确告知系统不支持口罩打卡。如果要支持需要单独训练口罩人脸模型并且只取眼睛以上区域做特征这与普通考勤模型不能通用。作为开发者不要承诺“戴口罩也能刷脸”这种需求除非你有对应模型。现象同一人连续出现在画面中几秒内被多次记录考勤表里全是重复数据。原因识别回调是每帧触发的同一张脸每次被识别都会进入成功逻辑。解决记住“最后打卡人ID和打卡时间”两个状态在打卡成功后一分钟内跳过该员工。或者用上述查询当天记录的方案。两种我都用过推荐用查询当天记录的方式因为重启App后状态不会被清空。现象阈值调低到0.6本来想提高安全性结果多个员工互相误识别。原因模板特征质量太差同一人的不同人脸距离本来就大于不同人之间的距离。解决将阈值调整与模板质量绑定。注册时计算不同帧之间的平均距离如果距离大于1.1说明录入照片质量差直接拒绝注册。也可以在识别时输出距离值管理员可以在考勤记录里回看每次识别的距离把明显偏低但错误的记录找出来重新录入。6. 进阶技巧把识别率拉高的几个实用的调优思路6.1 人脸质量检测拒绝模糊和光线差的帧考勤时员工正在走路摄像头容易拍到拖影。与其在模糊图上跑一遍推理然后被阈值拒绝不如在预处理前先算清晰度低于阈值的帧直接跳过。最简单的方法是计算相邻像素灰度差的平方均值模糊图的方差一定很低。把这个分数作为识别成功的前置条件能减少很多无谓的算力消耗。fun computeSharpness(bitmap: Bitmap): Double { val width bitmap.width val height bitmap.height val pixels IntArray(width * height) bitmap.getPixels(pixels, 0, width, 0, 0, width, height) var sum 0.0 for (y in 0 until height - 1) { for (x in 0 until width - 1) { val gray pixels[y * width x] and 0xFF val grayNext pixels[y * width x 1] and 0xFF val diff gray - grayNext sum diff * diff } } return sum / (width * height) }这个分数在光线正常时稳定在80以上低于60时基本是模糊帧或暗场。实际操作中可以按设备分辨率微调阈值。6.2 简易活体检测眨眼判断用静态照片冒充真人刷脸是离线考勤系统最常见的攻击方式。如果不想接复杂活体SDKMLKit的人脸关键点里已经提供了左眼和右眼的睁眼概率分别用face.leftEyeOpenProbability和face.rightEyeOpenProbability。这些概率值在0到1之间闭眼时接近0睁眼时大于0.5。实现一个最简单的眨眼活体要求识别过程里捕捉到一次“睁眼-闭眼-睁眼”的状态变化再判定为活体。在连续的几帧分析结果里记录眼睛状态状态从1变为0再变为1时才允许打卡通过。这套逻辑对单人脸考勤足够对高水平视频攻击可能不够但能挡住绝大多数静态照片攻击。6.3 模板渐进更新让特征跟上人外貌的自然变化长期使用同一个模板员工新换发型、留胡子、瘦了五公斤识别率都会慢慢下降。与其让人重新注册不如在每次识别成功且距离小于阈值的前提下用新提取的特征按一定权重更新旧模板。比如newTemplate 0.7 * oldTemplate 0.3 * currentFeature再归一化。权重不能太高否则单次光线波动会毁掉一个珍贵模板。更新前一定要检查当前距离如果距离已经接近阈值上限说明本次特征可信度不高就别更新了。我在一个模拟项目里实践过这套技巧用一年的数据回测识别率从78%提升到了91%。但它是一把双刃剑如果员工A被误识别成BB的模板会因为混入A的特征而越来越像A。所以做自动更新必须配合后台人工审核每次更新后把新距离值记下来出问题能回滚。做这套系统的过程里我踩得最深的一个坑就是盲目追求低阈值。一开始我把余弦距离阈值压到0.55感觉识别“更严格了”结果员工之间误识别率暴涨。后来我花了一天时间把所有员工的注册特征和测试特征画成距离分布图才发现误识边界应该在0.7附近。从那以后我把“先测数据、再定阈值”当成固定流程而不是靠玄学调参。希望这些踩坑经验能帮你少走几步弯路。本文还有配套的精品资源点击获取