拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于虹软ArcFace的动态人脸识别Android实现:从Camera2到画框

简介一份面向移动与桌面开发者的虹软动态人脸识别工程资源基于 ArcSoft 技术实现了摄像头实时视频流中的人脸检测、追踪与画框识别尤其适合需要快速接入实时识别能力的应用开发者。压缩包共 137 个文件约 65.77MB核心包括 12 个 so 动态库、6 个 jar 依赖、9 个 java 源码以及 70 个 xml 配置/布局文件另有 png 图标、bin 缓存和 gradle 构建脚本等工程结构完整便于在 Android Studio 或 Gradle 环境中导入调试。资源涵盖从视频帧采集、图像处理、特征提取到人脸比对的完整调用链路开发者可参照 java 源码与配置文件快速理解虹软 SDK 的接入方式并复用动态画框、实时识别等核心模块缩短集成周期。这些库文件包含面向多平台的优化代码适合在复杂光照、表情变化等场景下验证识别效果。截至目前已有 1191 人学习/下载适合希望将摄像头动态人脸识别能力集成到真实项目中的移动端或桌面开发者参考。1. 项目整体思路与方案选型解析1.1 动态人脸识别到底是怎么跑起来的先把这个项目拆开看名字里的四样东西其实是四条技术链路虹软是识别引擎动态人脸识别是业务形态Camera是数据来源画框是结果呈现。合在一块的场景非常常见比如智能门禁、考勤机、安防闸机、智慧零售的客流分析还有各类需要“刷脸”的行业终端。这个项目的本质就是把摄像头采集到的视频流一帧一帧地送给虹软的人脸检测引擎拿到人脸位置信息之后再在预览画面上一层一层地把框画出来。很多人第一次接触“动态人脸识别”容易把它和“静态人脸识别”搞混。静态识别是拍一张照片然后在这张固定图上找人脸动态识别则是连续的视频帧流每一帧都在做人脸检测而且通常还要配合跟踪逻辑保证同一张脸在连续帧里的ID稳定。虹软的ArcFace SDK同时提供了人脸检测、人脸跟踪、人脸比对、活体检测这些能力而“动态”两个字核心靠的就是SDK里的FRFace Recognition和FTFace Tracking这两个能力组合。1.2 为什么选虹软而不是其他方案先说结论虹软是目前国内做离线人脸识别落地最省心的方案之一。对比过几种主流路线各有各的坑。纯开源方案比如OpenCV的Haar Cascade、Dlib的HOG人脸检测检测效果在正脸、光线好的场景下还可以但一旦出现侧脸、低头、逆光漏检率直接拉满而且没有配套的活体检测和人脸比对想做一个接近商用的效果工作量非常大。Google的ML Kit人脸检测精度不错但依赖Google Play服务国内终端设备上部署非常难受。至于云服务商的在线人脸识别API识别精度确实高但要联网、要按次计费而且人脸数据要传到云端很多客户在数据安全这一关就过不去。虹软的优势在于完全离线本地化运行、检测比对活体全套能力、免费授权额度对中小项目很友好。实测下来在RK3288、RK3399这类国产平台和主流高通平台上VGA分辨率640x480的人脸检测单帧耗时能控制在20ms以内完全满足实时性要求。不过也要客观说一句虹软的免费版SDK对设备有绑定限制激活码失效、换主板之后需要重新激活这个坑在项目交付阶段尤其容易踩到。1.3 整体模块怎么划分每一个看起来“很高端”的动态人脸识别项目落到代码层面其实就是四条线预览采集模块负责Camera打开、参数配置、帧数据回调。检测引擎模块负责SDK初始化、激活、把每一帧转成SDK能识别的格式再送检。坐标转换与画框模块负责把SDK返回的人脸坐标从图像坐标系映射到屏幕坐标系再绘制到界面上。业务逻辑模块负责把“检测到人脸”“跟踪到人脸”“人脸比对结果”这些信号转成业务动作比如亮绿灯、开门、语音播报。这篇文章后面的内容会重点讲第二和第三条线因为这两条线是网上资料最少、实际踩坑最多的部分。预览采集是Android开发的基础能力但要把Camera的输出格式和虹软SDK的输入格式对齐这里面的细节非常多。2. Camera预览流接入把每一帧画面变成SDK能懂的数据2.1 Camera2 采集参数与适配目前新项目建议直接用Camera2Camera1已经放弃维护了。Camera2的采集链路里和虹软SDK对接最关键的类是ImageReader它负责在每一帧图像数据到达时回调给上层。初始化ImageReader时有几个参数非常关键直接决定后面能不能顺利对接虹软。val imageReader ImageReader.newInstance( 640, 480, ImageFormat.YUV_420_888, 3 )这里的640x480是VGA分辨率虹软官方文档里明确推荐这个分辨率原因很简单人脸检测不需要太高的分辨率VGA在性能和检测率之间是最平衡的。有些项目为了画框更清晰非要用1920x1080结果检测帧耗时飙升而且因为画面范围变大人脸在画面里的相对尺寸变小检测率反而下降。imageReader的maxImages参数建议设成3因为虹软的detectFaces是耗时操作如果缓冲区太少会导致Camera生产帧的速度大于消费速度预览画面出现滞后。设备兼容性是一个很容易被低估的问题。Camera2的参数在不同平台上差异很大有些国产设备默认的预览尺寸列表里根本没有640x480需要先从CameraCharacteristics的SCALER_STREAM_CONFIGURATION_MAP里把所有支持的尺寸捞出来和640x480做匹配找不到就退而求其次选一个接近的。在项目初期就做好这层兼容可以避免后面换设备时整个预览黑屏。2.2 NV21 数据预处理Image到byte数组的转换ImageReader拿到的YUV_420_888是一组独立的Plane不能直接丢给虹软。虹软SDK的人脸检测接口需要的是NV21格式的byte数组这就涉及到从YUV_420_888到NV21的转换。private fun yuv420888ToNv21(image: Image): ByteArray { val planes image.planes val yPlane planes[0] val uPlane planes[1] val vPlane planes[2] val yBuffer yPlane.buffer val uBuffer uPlane.buffer val vBuffer vPlane.buffer val ySize yBuffer.remaining() val uvSize uBuffer.remaining() vBuffer.remaining() val nv21 ByteArray(ySize uvSize) yBuffer.get(nv21, 0, ySize) val yPixelStride yPlane.pixelStride val uPixelStride uPlane.pixelStride val vPixelStride vPlane.pixelStride val uRowStride uPlane.rowStride val vRowStride vPlane.rowStride val uRowPadding uRowStride - uPlane.width * uPixelStride val vRowPadding vRowStride - vPlane.width * vPixelStride var uvIndex ySize val columnCount uPlane.width val rowCount uPlane.height var uPos 0 var vPos 0 for (row in 0 until rowCount) { for (col in 0 until columnCount) { nv21[uvIndex] vBuffer.get(vPos) nv21[uvIndex] uBuffer.get(uPos) uPos uPixelStride vPos vPixelStride } uPos uRowPadding vPos vRowPadding } return nv21 }这段代码看起来简单但里面藏着两个非常隐蔽的坑。第一个是rowStride对齐问题。YUV_420_888在内存中的每一行数据末尾可能有padding不能直接用rowBytes乘以height来算数据大小必须用rowStride逐行拷贝。如果单纯用image.planes[0].buffer.remaining()来拿完整数据在多数字符串上是没问题的但在某些竖屏编码的配置下会多出一截无效数据导致虹软检测时图像发生“斜切”变形。第二个是V/U通道的顺序。Android的YUV_420_888里面planes的顺序不固定有些设备UV顺序就是反的如果不做检查检测结果倒是能出来但画框位置可能会有几个像素的偏差而且如果后面接RGB转换或者保存图片颜色就直接偏了。稳妥的做法是在初始化时读取planes顺序做标记然后按实际顺序填充NV21。还有一点务必注意image.close()一定要在数据用完以后调用。Camera2的ImageReader最多就3个可用的Image缓冲你不close后续帧就没办法进来了画面会卡死在最后一帧而且摄像头一直占着不放App退出之后再次进预览页面就会黑屏。2.3 把数据送进ArcFace引擎数据准备好之后就是虹软SDK的标准流程了。先激活再初始化。// 激活SDK val ret FaceEngine.active( context, APP_ID, SDK_KEY ) // 初始化引擎 val engine FaceEngine() val initCode engine.init( context, FaceEngine.ASF_DETECT_MODE_VIDEO, FaceEngine.ASF_OP_0_HIGHER_EXT, 16, 10, FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_FACE_TRACK )这里有几个参数值得展开说。激活是在Application或者MainActivity启动时做一次的操作需要把申请好的APP_ID和SDK_KEY填进去。这里踩过一个大坑SDK_KEY是和包名绑定的而且绑定的是applicationId不是module的packageName。很多人Debug包和Release包的applicationId不一致结果Debug跑得好好的一打Release包就报ASF_EX_APPID_SDK_DETECT_MISSING_PERMISSION查了很久才发现是包名对不上。ASF_DETECT_MODE_VIDEO是动态人脸识别的关键。这个模式会启动SDK内部的跟踪机制让同一张人脸在连续多帧里的faceId保持一致这样后面的业务逻辑才能基于“这个人一直站在镜头前”来做状态判断。如果误选了ASF_DETECT_MODE_IMAGE每一帧都是独立检测同一个人的faceId会不断变化后面做防重复识别、做签到逻辑的时候会非常痛苦。detectFaces的调用逻辑如下val detectionInfo FaceInfo() val retCode engine.detectFaces( nv21Data, width, height, FaceEngine.CP_PAF_NV21, detectionInfo )检测完成后detectionInfo.faceCount就是画面里检测到的人脸数量detectionInfo.getFaceRect(index)拿到的就是人脸框坐标。这里的坐标是相对坐标取值在0到32768之间对应的是图像宽高的比例不是像素坐标。想要画到屏幕上做映射必须先搞清楚这个坐标系的含义。这是后面踩坑最多的地方。3. 人脸框画框的最终实现从坐标到View的映射3.1 坐标系换算的逻辑虹软返回的人脸框坐标是这样的left、top、right、bottom四个值都落在0到32768之间表示相对图像宽高的比例。比如返回的left8192就表示人脸的左边距离图像左边是8192/3276825%的图像宽度。这个设计和很多引擎直接返回像素坐标不一样但逻辑上是严谨的因为这样就把引擎跟具体的图像分辨率解耦了。从虹软坐标到屏幕坐标的转换公式是这样// imageWidth/imageHeight是送入引擎的图像宽高 // viewWidth/viewHeight是预览View的实际宽高 val left rect.left / 32768f * imageWidth val top rect.top / 32768f * imageHeight val right rect.right / 32768f * imageWidth val bottom rect.bottom / 32768f * imageHeight // 实际绘制的时候通常Image和View的比例一致 // 所以直接用viewWidth/imageWidth作为缩放系数 val scaleX viewWidth / imageWidth.toFloat() val scaleY viewHeight / imageHeight.toFloat()但实际情况没有这么简单因为预览View的显示模式会影响坐标映射。很多项目用了TextureView的ScaleType.CENTER_CROP或者FrameLayout里的match_parent加固定比例这时候图像会被裁剪。以CENTER_CROP为例假设相机输出是4:3View是16:9那么图像会被等比放大然后裁掉左右两边的部分。如果只是简单按比例缩放画出来的框会整体往中间偏、左右偏移。解决CENTER_CROP下的坐标偏移需要计算实际显示区域val viewAspect viewWidth / viewHeight.toFloat() val imageAspect imageWidth / imageHeight.toFloat() var cropLeft 0f var cropRight imageWidth.toFloat() var cropTop 0f var cropBottom imageHeight.toFloat() if (viewAspect imageAspect) { // 画面比View窄上下裁剪 val scaledWidth viewWidth / viewHeight * imageHeight cropLeft (imageWidth - scaledWidth) / 2f cropRight cropLeft scaledWidth } else { // 画面比View宽左右裁剪 val scaledHeight viewHeight / viewWidth * imageWidth cropTop (imageHeight - scaledHeight) / 2f cropBottom cropTop scaledHeight } val scaleX viewWidth / (cropRight - cropLeft) val scaleY viewHeight / (cropBottom - cropTop) val realLeft (left - cropLeft) * scaleX val realTop (top - cropTop) * scaleY val realRight (right - cropLeft) * scaleX val realBottom (bottom - cropTop) * scaleY如果项目里用的是FitCenter等比缩放居中换算逻辑会简单一些但也会在View四周留出黑边坐标偏移同样要处理。还有一个绕不开的坑前置摄像头的镜像。Android的前置摄像头默认输出是镜像的也就是说画面的左边界对应人的右侧。如果不处理画框会出现在人脸的另一侧看起来就像一个原图一个镜像非常诡异。处理方式有两种如果用的是TextureView可以对View做scaleX-1的水平翻转同时注意坐标也要跟着翻转要么在数据处理时对NV21做水平翻转但NV21逐行翻转的性能开销不小不推荐在每一帧都做。更推荐的做法是View层镜像翻转同时把坐标的X轴方向反转即mirrorX viewWidth - originalX。3.2 自定义View画框画框用自定义View实现是最灵活的方式。核心逻辑就是onDraw里面根据最新的人脸框列表逐个绘制矩形。class FaceOverlayView JvmOverloads constructor( context: Context, attrs: AttributeSet? null ) : View(context, attrs) { private val faceRects mutableListOfRectF() private val paint Paint().apply { color Color.GREEN style Paint.Style.STROKE strokeWidth 6f isAntiAlias true } Synchronized fun updateFaces(rects: ListRectF) { faceRects.clear() faceRects.addAll(rects) invalidate() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) faceRects.forEach { rect - canvas.drawRect(rect, paint) } } }这个View需要叠加在预览层之上层级结构是FrameLayout根布局下面是TextureView/PreviewView上面是FaceOverlayView。这个View的大小要和预览View完全一致否则坐标映射又会产生误差。这里有一个很重要的性能细节updateFaces里的invalidate()会触发onDraw但onDraw里不能有任何耗时的计算所有坐标换算必须在子线程完成后再传进来。Camera的帧率是30fps如果每帧都在UI线程做坐标变换主线程会被拖垮。实测下来的做法是人脸检测在子线程做检测完成之后把坐标换算也放在子线程做换算好的屏幕坐标RectF列表通过一个线程安全的容器交到UI线程UI线程只负责画矩形。画框的画质也有讲究。默认的Paint.Style.STROKE画出来的矩形会随着View的大小变化看起来粗细不一致所以如果要保持视觉上框的粗细一致strokeWidth应该根据View的宽度动态计算比如viewWidth / 100f。另外一些人脸框不只是画一个矩形还会在框下面显示“姓名”“相似度”之类的文本作为项目扩展点可以加但要注意drawText的基线对齐不然文字会贴上框或者离得太远。3.3 画框之外的体验细节画框只是动态人脸识别项目中最直观的呈现但实际落地时框的颜色和状态往往承载着业务语义。比如默认状态框是绿色人脸比对通过后变成蓝色比对失败变成红色。这种状态切换的核心是检测引擎负责告诉你“哪里有人脸”比对的业务逻辑负责告诉你“这张脸认不认识”画框层的职责只是把结果可视化。千万不要试着在画框线程里穿插业务逻辑状态流转应该在更上层的业务模块中维护。另外很多项目在画面里同时出现多人时为了让业务聚焦到“主目标”会选择只画最大的人脸框或者只画距离画面中心最近的人脸框。虹软SDK的FaceInfo里会返回每个人脸的角度信息可以结合角度做过滤比如只处理俯仰角在±15度之内的正面脸减少误识别和无效计算。4. 性能调优与高频问题排查实录4.1 帧率控制与线程模型动态人脸识别对延迟的要求是“不能明显感觉到滞后”并不是每一帧都必须检测。实测下来人眼能接受的延迟在100ms以内也就是每秒10帧的检测频率已经足够流畅。如果把30fps的每一帧都送进SDKCPU占用率会非常难看设备发热严重甚至触发系统的温控降频结果帧率反而更不稳定。推荐的方案是做降采样// 检测线程中 while (isRunning) { val frame frameQueue.poll(200, TimeUnit.MILLISECONDS) ?: continue val currentTime System.currentTimeMillis() if (currentTime - lastDetectTime 66) { // 约15fps continue } lastDetectTime currentTime val result engine.detectFaces(frame.data, frame.width, frame.height, ...) postToUi(result) }线程模型上Camera的回调线程、人脸检测线程、UI线程必须分开。Camera的回调线程尽量只做一件事把Image数据拷贝成NV21然后扔进一个有界队列让检测线程去消费。如果直接在Camera回调线程里做检测系统摄像头会因为你消费过慢而自动丢帧预览画面会明显卡顿。有界队列的实现注意给个上限比如2~3帧就够。队列满的时候建议直接丢弃新帧而不是阻塞因为阻塞会导致ImageReader的buffer释放不了摄像头就“堵死”了。我一直用的策略是offer 元素数量超过上限就poll掉最老的一帧再offer保证队列永远是最新的画面。4.2 内存与功耗细节NV21转换会创建大量的byte数组在Java层每次new一个大数组GC压力不容小觑。如果检测线程每帧都新建数组在低端设备上很容易出现频繁的GC卡顿直接被用户感觉成画面掉帧。优化方式是用对象池复用预先分配一个固定大小的byte数组在Camera尺寸不变的情况下NV21数组大小是固定的完全可以复用同一个数组。不过注意因为异步队列里可能同时存在多个帧在排队所以实际上需要准备2~3个数组轮流用或者干脆让队列只保存一帧牺牲一点点实时性换内存稳定。另外一个容易被忽略的地方是Bitmap的使用。如果项目里后续要保存人脸截图、上传人脸比对尽量不要在检测线程里直接Bitmap.createBitmap。创建Bitmap的耗时和内存开销都很大建议走异步把NV21和检测到的人脸Rect一起放到另一个线程在那个线程里去创建和保存。功耗方面最有效的措施是动态降帧。当画面里完全没有人脸的时候可以主动把检测频率降到5fps一旦检测到人脸再恢复到15~20fps。这个逻辑对长时间运行的终端设备比如门口机、闸机能省不少电量也降低CPU发热设备更稳定。4.3 高频问题速查表我把实际开发中踩过的坑以及周边同行反馈过的典型问题整理成了一张表遇到问题可以先对着排查。问题现象可能原因排查方向画面卡顿帧率低ImageReader的maxImages太小或NV21转换在主线程执行确认maxImages3确认真人检测在子线程队列消费不能阻塞能检测人脸但框偏移严重View的ScaleType导致裁剪未处理或前后摄镜像未处理按上文CENTER_CROP逻辑裁剪图像坐标前置摄像头翻转X轴Release包SDK激活失败包名与申请Key时不一致检查applicationIdDebug和Release包需分别申请或使用同一包名人脸框在竖屏模式下跑到侧边图像旋转角度未处理确认送入engine的宽高是否经过旋转旋转90度时宽高要交换偶发报image.close异常Image使用后未及时关闭或关闭时序错误确认每一帧的Image在拷贝完数据后立即close千万不要放到队列里延时关闭检测不到人脸尤其侧脸或低头检测阈值过严或分辨率过大VGA分辨率下侧脸检测本身就会下降可以适当增加检测角度范围ASF_FACE_DETECT全角度模式还有一个很容易被忽略的兼容性问题某些设备在Camera2的ImageReader回调里拿到的ImageFormat.YUV_420_888其plane的pixelStride不是1。比如华为部分机型上Y通道的pixelStride2这表示每两个字节才是一个有效Y值。如果代码里没有处理pixelStrideNV21数组长度会算错检测时SDK直接崩溃或者返回ASF_EX_INVALID_IMAGE_DATA。这个问题的典型特征就是“这台设备上没问题换一台设备就崩”。所以YUV转NV21的代码里pixelStride和rowStride的处理一定不能省。关于SDK本身的初始化还有一个容易被忽视的点FaceEngine.init里的第三个参数是人脸检测的最小人脸尺寸单位是相对于图像宽度的比例乘以100。VGA分辨率下建议设为16代表最小检测人脸是图像宽度的16%。设得越小检测越灵敏但误检也会变多设得太大人脸离镜头稍远就检测不到。如果是做门禁机这种固定安装距离的设备建议根据实际安装高度和距离去调整这个参数往往能同时提升检测率和降低误报率。每次验证画框效果我都会先在电脑上放一段多角度人脸的测试视频然后让摄像头对着屏幕播放。这个土办法比真人测试效率高很多能快速覆盖正脸、侧脸、低头、多脸同时出现等场景而且可以重复回放非常适合做回归测试。最后再分享一个保命技巧正式提测或者交付之前一定要留一个debug总开关可以在不开新包的情况下直接切换“显示原始检测坐标”和“显示最终屏幕坐标”两种模式。这样现场定位问题的时候只需点两下就能判断是SDK检测结果的问题还是坐标映射的问题能省掉大把沟通时间。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门