ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Android原生实现离线人脸检测与圆形头像裁剪的完整实践

Android原生实现离线人脸检测与圆形头像裁剪的完整实践 简介面向Android开发者的FaceDetector人脸识别检测Demo围绕圆形相机预览框、人脸识别检测、自定义宽高截图、圆形图片显示四个核心功能展开。资源共1333个文件压缩包约10.66MB内部包含Java源码、XML布局、JSON配置与依赖JAR包同时保留了Android工程构建生成的class、dex、flat等编译文件适合导入Android Studio直接查看效果或作为人脸采集模块二次开发。已有4539人浏览学习。借助这份工程可以梳理FaceDetector的完整调用流程理解相机实时帧与自定义圆形预览控件的结合方式掌握人脸检测坐标的换算逻辑并学会从原始图像中按设定宽高截取人脸区域、以圆形图片形式输出。对于需要快速实现相机人脸识别与头像裁剪功能的应用开发者这份资源提供了可直接参考的完整示例。 最近在做一个头像采集的需求用户要在一个圆形相机取景框里对准人脸系统自动把脸部区域截取出来最后显示成圆形头像而且截图的宽高还要能自定义。这个需求拆开来看其实有四个技术点圆形相机预览框怎么做、Android 自带的 FaceDetector 怎么用、怎么按自定义宽高把人脸区域截出来、图片怎么显示成圆形。我最终没有引入 ML Kit 或者第三方相机库而是用系统原生 API 从头串了一遍整个过程踩了几个坑也理清了一些关键参数的计算逻辑。这篇文章就把完整链路写出来适合想快速实现离线人脸框检测 圆形头像裁剪又不想接重型 SDK 的 Android 开发者参考。1. 整体设计思路与技术选型1.1 圆形预览框其实是用遮罩“画”出来的先说一个很多人会理解错的点圆形相机预览框并不是真的把摄像头输出切成圆形而是相机预览本身还是全屏矩形只是在预览层的上面盖了一层半透明遮罩把圆形以外的区域压暗甚至完全盖住视觉上就形成了一个圆形取景区域。这样做的好处很明显不用动相机底层兼容性好而且遮罩层可以根据 UI 需求随时调整。实现方案有两种一种是在自定义 View 的 onDraw 里用 Paint 的 PorterDuff 模式“挖”一个圆洞另一种是用 Path 的 DIFFERENCE 运算生成一个圆环路径再绘制。我用的是前者原理上更直观先画一层半透明黑色覆盖全屏再通过 Xfermode 的 CLEAR 模式把圆形区域清空露出底下的相机预览。不过这里有个关键点使用 Xfermode 绘制挖洞效果时必须配合离屏缓冲或者 setLayerType 使用否则 CLEAR 模式会把外层布局的背景也一起擦掉。我用的是 View.setLayerType(View.LAYER_TYPE_HARDWARE, null) 配合离屏绘制这样只在遮罩层内部做像素操作不会污染上层其他控件。1.2 为什么还在用系统自带的 FaceDetector人脸检测在 Android 上其实有好几条路Google ML Kit 的 Face Detection、OpenCV、以及系统自带的 android.media.FaceDetector。ML Kit 是目前体验最好的方案但它需要引入 Google Play services 依赖部分定制 ROM 上还有兼容性问题而且包体积会增大不少。OpenCV 则是功能强但学习成本高集成也重。系统自带的 FaceDetector 从 API 1 就有了最大的优势是零依赖、完全离线、代码量少。它的检测能力没有 ML Kit 那么智能不支持活体检测不能返回人脸轮廓点但对我们这种“找脸、取框、裁剪”的场景足够用了。如果你需要识别人脸角度、多个复杂表情、或者强光弱光交替的环境建议上 ML Kit如果只是居中取一张人脸区域FaceDetector 完全够用而且性能表现稳定。选型时我给的对比结论如下维度系统 FaceDetectorML KitOpenCV依赖无系统 APIGoogle Play servicesOpenCV SDK包体积几乎不增加增加较大增加最明显检测能力基础人脸框丰富角度、表情等二次开发空间大离线可用是部分模型需要下载是集成成本低中高2. 核心原理与关键参数解析2.1 FaceDetector 的工作方式以及为什么图片必须转成 RGB565FaceDetector 的使用有三个硬性条件输入的 Bitmap 不能为空、必须是非安全模式创建的 Bitmap、必须使用 Bitmap.Config.RGB_565 格式。很多人第一次用都会在这里卡住明明图片没问题findFaces 却一直返回 0最后发现自己传的是 ARGB_8888。RGB565 意味着每个像素用 5 位红、6 位绿、5 位蓝表示相比 ARGB_8888 丢失了透明度通道和一部分颜色精度。FaceDetector 内部在做灰度和特征匹配时更吃低精度高性能的图RGB565 刚好满足这个需求。转换代码很简单Bitmap rgb565 source.copy(Bitmap.Config.RGB_565, false);FaceDetector 的检测结果封装成 Face 对象我们需要关注三个信息confidence 置信度0 到 1、eyesDistance 两眼间距、getMidPoint 返回的两眼中点坐标。其中 eyesDistance 和 midPoint 是计算人脸区域的关键参数。置信度低于 0.3 基本可以认为是误检。调用方式是这样的FaceDetector detector new FaceDetector(bitmap.getWidth(), bitmap.getHeight(), maxFaces); Face[] faces new Face[maxFaces]; int count detector.findFaces(bitmap, faces);注意构造函数里的 maxFaces 不是随便填的它影响内部缓冲区大小填太大内存占用上升填太小可能漏检。经验值是 5对这个场景足够。2.2 从两眼间距到人脸框宽高自定义的换算逻辑拿到 Face 对象后接下来最关键的问题就是怎么确定人脸裁剪区域。Face 并没有直接给出“人脸矩形框”只给了两眼中点和两眼间距。但是人类脸部的几何关系是相对固定的成年人双眼瞳孔间距大约占整个脸宽的 0.42 到 0.45 左右。所以可以通过这个比例反推人脸宽度float faceWidth eyesDistance * 2.8f;2.8 是我在实际项目里反复调出来的经验值它代表人脸宽度大约是瞳距的 2.8 倍。为什么不用更精确的 2.2 到 2.3因为那个计算结果太贴脸边了方形裁剪时会切到耳朵或者把下颌裁掉。2.8 会多出一些余量视觉上更自然。高度方向也是同理但需要注意人的脸长和脸宽并不是 1:1。亚洲人脸型平均宽高比大概在 1:1.1 到 1:1.2 之间所以我推荐这样计算float faceWidth eyesDistance * 2.8f; float faceHeight faceWidth * 1.15f;然后以两眼中点为基准把矩形框定出来。这里有个细节如果按中点上下等分脸框看起来会偏上因为上半部分有额头和头发下半部分有鼻子和嘴。按照视觉重心中点往上取 42%往下取 58% 比较舒服RectF faceRect new RectF(); faceRect.set(midPoint.x - faceWidth / 2f, midPoint.y - faceHeight * 0.42f, midPoint.x faceWidth / 2f, midPoint.y faceHeight * 0.58f);既然标题里强调宽高自定义那这两个比例就应该做成可配置参数用户在设置页里能调。我把参数抽成了 FaceCropConfig 类包含 widthRatio、heightRatio、topOffsetRatio、bottomOffsetRatio 四个字段后续改尺寸不需要动核心逻辑。3. 完整实现过程与核心代码3.1 圆形相机预览框的遮罩实现预览层我用的是 TextureView 加自定义遮罩 View 的方案。TextureView 的优势是可以在任何线程转化成 Bitmap方便后续取帧做检测缺点是需要手动处理生命周期但只要不是做多窗口这种复杂场景问题不大。遮罩层的核心是一个自定义 View继承自 View在 onDraw 里绘制挖洞效果Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); float cx getWidth() / 2f; float cy getHeight() / 2f; float radius Math.min(getWidth(), getHeight()) / 2f - edgeMargin; // 使用离屏缓冲避免影响其他图层 canvas.saveLayer(0, 0, getWidth(), getHeight(), null); // 1. 半透明黑色盖住整个预览区域 Paint coverPaint new Paint(Paint.ANTI_ALIAS_FLAG); coverPaint.setColor(Color.parseColor(#99000000)); canvas.drawRect(0, 0, getWidth(), getHeight(), coverPaint); // 2. 用 CLEAR 模式挖出圆形窗口 Paint clearPaint new Paint(Paint.ANTI_ALIAS_FLAG); clearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); canvas.drawCircle(cx, cy, radius, clearPaint); canvas.restore(); }这里除了画遮罩我一般还会顺手把外圈的白色描边一起画上让取景框更明显。可以再加一个圆形进度弧线来提示用户“正在检测人脸”的状态只要在同一个 onDraw 里追加绘制就行Canvas 的顺序是后来者居上。3.2 从相机帧到 Bitmap再交给 FaceDetector相机权限、Camera 初始化这些基础步骤不做赘述这里说一个最关键的衔接点怎么把当前 TextureView 的画面转成 Bitmap 给 FaceDetector 检测。TextureView 自带 getBitmap 方法可以从当前纹理提取 Bitmap。但有个坑必须在纹理可用之后调用否则返回 null。我一般是先监听 TextureView.SurfaceTextureListener在 onSurfaceTextureAvailable 之后再开启预览并在拍照或检测按钮点击时调用 getBitmap。还需要先判断 bitmap 尺寸如果太大先缩放到宽度 600 到 800 像素否则检测耗时明显上升而且系统 FaceDetector 对超大图片支持并不好。完整的检测流程代码private FaceCropResult detectFace(Bitmap source) { // 1. 缩放并转为 RGB565 Bitmap scaled scaleWidth(source, 640); Bitmap rgb565 scaled.copy(Bitmap.Config.RGB_565, false); // 2. 创建检测器并执行检测 FaceDetector detector new FaceDetector(rgb565.getWidth(), rgb565.getHeight(), 5); Face[] faces new Face[5]; int found detector.findFaces(rgb565, faces); if (found 0 || faces[0].confidence() 0.3f) { return null; } Face face faces[0]; PointF mid new PointF(); face.getMidPoint(mid); float eyesDistance face.eyesDistance(); // 3. 换算矩形框并把坐标映射回原始图片坐标系 FaceCropConfig config getCurrentConfig(); float faceWidth eyesDistance * config.widthRatio; float faceHeight faceWidth * config.heightRatio; RectF rect new RectF(); rect.set(mid.x - faceWidth / 2f, mid.y - faceHeight * config.topOffsetRatio, mid.x faceWidth / 2f, mid.y faceHeight * (1f - config.topOffsetRatio)); // 4. 映射回原始 Bitmap 坐标 float scaleX source.getWidth() / (float) scaled.getWidth(); float scaleY source.getHeight() / (float) scaled.getHeight(); rect.left * scaleX; rect.right * scaleX; rect.top * scaleY; rect.bottom * scaleY; return new FaceCropResult(rect, bitmapToFile(cropRect(source, rect))); }这里为什么先缩放再检测最后映射回原图坐标因为检测只在缩略图上做裁剪仍然以原图分辨率进行这样既保证了检测速度又不会损失裁剪清晰度。映射系数 scaleX、scaleY 不能混用很多人直接用一个比例导致脸部被拉伸或压缩。3.3 按自定义宽高截取人脸图片检测完成拿到 RectF 后下一步是真正的人脸裁剪。这里要注意几个边界问题第一RectF 的值可能超出 Bitmap 边界。如果人脸离画面边缘太近计算出来的矩形框会有负坐标直接 createBitmap 会崩溃。必须做一个 clamp 处理private Rect clampRect(RectF rect, int maxWidth, int maxHeight) { Rect result new Rect(); result.left Math.max(0, (int) rect.left); result.top Math.max(0, (int) rect.top); result.right Math.min(maxWidth, (int) rect.right); result.bottom Math.min(maxHeight, (int) rect.bottom); return result; }第二裁剪后想要自定义输出宽高直接 createBitmap 只能按原始像素裁所以我会再套一层 Matrix 做缩放。常见的做法是用 Bitmap.createScaledBitmap 统一输出成比如 512x512 的正方形Bitmap cropped Bitmap.createBitmap(source, rect.left, rect.top, rect.width(), rect.height()); Bitmap output Bitmap.createScaledBitmap(cropped, targetWidth, targetHeight, true);这样做的好处是不管人脸在原图中的占比是大是小最终输出的图片尺寸都是一致的方便后续上传到服务端或直接本地存储。3.4 圆形图片显示与保存拿到了正方形人脸图最后一步就是把它显示成圆形。如果是用 ImageView 显示最方便的是引入 Material 库的 ShapeableImageView直接设置 shapeAppearance 为圆形即可。但如果没有引入 Material 库或者希望完全自己控制推荐自定义 View 来画圆形头像。自定义圆形头像 View 的核心逻辑是在 onDraw 中用 Canvas 裁剪。我的做法是采用离屏缓冲加 SRC_IN 模式绘制Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (bitmap null || bitmap.isRecycled()) { return; } int size Math.min(getWidth(), getHeight()); int saved canvas.saveLayer(0, 0, size, size, null); Paint paint new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG); // 先画一个圆形蒙层 canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint); // 设置 SRC_IN只保留圆形区域内的像素 paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); RectF dst new RectF(0, 0, size, size); canvas.drawBitmap(bitmap, null, dst, paint); canvas.restoreToCount(saved); }注意这里 saveLayer 是必须的它创建了一个独立图层否则先画的圆形蒙层会被后续的 SRC_IN 操作错误地影响到其他图层。我最初省略了 saveLayer结果圆形边缘总是出现黑色背景框排查了半天。保存圆形头像到本地文件时逻辑是一样的只需要把 Canvas 换成 Bitmap 的 Canvas然后调用 compress 写出 PNG 文件。需要注意的是带透明背景的 PNG 保存没有任何问题如果非要白色背景最稳妥的方式是先画一层白色矩形再执行圆形裁剪否则用 JPEG 保存时透明区域会被自动填成黑色这是我踩过的另一个坑。4. 常见问题与排查技巧实录4.1 高频问题速查表现象可能原因解决方案findFaces 一直返回 0Bitmap 不是 RGB565调用前先 copy(Bitmap.Config.RGB_565, false)findFaces 返回 0 且图已转 RGB565人脸占比太小缩放后宽度 500px人脸宽度保证 100px 以上TextureView.getBitmap 返回 null纹理尚未available在 onSurfaceTextureAvailable 之后取帧取帧前判断 isAvailable截取时数组越界崩溃脸太靠边缘导致 Rect 越界对 RectF 做 clamp 再创建子图旋转屏幕后检测区域偏移Bitmap 坐标系没有跟随旋转先旋转 Bitmap 再检测或把检测坐标按角度映射回去前置摄像头图片左右反转镜像显示Matrix.preScale(-1, 1) 做水平翻转圆形头像边缘有黑色背景未使用 saveLayer 离屏缓冲在 drawCircle 与 drawBitmap 外部加 saveLayer/restore4.2 三个值得记住的调优细节第一个是检测性能问题。FaceDetector 在 640 宽的 RGB565 图片上检测耗时大概在 30ms 到 80ms 之间对人脸识别这种低频操作完全没压力。但如果你在 UI 主线程直接调用大图时还是能感觉到卡顿我习惯丢到子线程执行检测完成后再切回主线程更新 UI。不推荐用协程或线程池做太频繁的检测每两次检测之间最好隔 200ms 以上不然摄像头帧还没完全渲染完就又取了一次。第二个是光照和角度的限制。FaceDetector 对正脸、光线充足的情况检测率很高但侧脸、低头或者逆光环境下会直接漏检。这个限制不是代码能完全解决的所以我会在遮罩层加一行提示文案“请正对取景框保持光线明亮”提醒用户配合。如果实测还是不行可以考虑做一个简单的亮度判断太暗时提示用户。第三个是关于自定义宽高的细节。如果想要生成不同比例的头像比如 1:1、3:4、4:3最关键的是要在计算 topOffsetRatio 时同步调整上下留白比例否则切换裁剪比例后人脸位置会明显偏移。我建议把这三个参数放进一个配置类统一管理不要散落在各个方法里。最后再分享一个很多人忽略的小点转 RGB565 时用的是 copy 而不是 createScaledBitmap这两个方法组合时的顺序会影响效果。我的经验是先缩放、再转 RGB565如果先转格式再缩放缩放过程会产生额外的颜色插值误差弱光环境下检测率会下降。你现在可以直接按这个顺序写进自己的业务代码里能少踩不少坑。本文还有配套的精品资源点击获取
返回列表