《HarmonyOS技术精讲-Core Vision Kit》第4篇:人脸检测与关键点定位

前言
人脸检测是计算机视觉里最基础也最常用的功能之一。在HarmonyOS NEXT开发中,很多开发者第一次接触Core Vision Kit时,会按照官方示例跑通人脸检测,但一旦涉及到从相机预览流中实时处理、同时绘制人脸框和关键点,就会遇到各种问题。
最常见的问题有:
- 相机预览和检测结果不同步------检测框比实际人脸位置偏移,或者延迟很大。
- 关键点坐标转换出错------模型返回的关键点坐标和画布坐标对不上,画出来的点歪到屏幕外面。
- 多人脸场景性能问题------同时检测多张人脸时,UI线程卡顿或者内存泄漏。
这篇文章会从实际的开发角度出发,把这些问题一个一个讲清楚,同时提供一个可直接运行的示例代码。
人脸检测解决什么问题
人脸检测的核心目标是从图像中找到人脸,并返回每个人脸的位置、大小、姿态(偏转角度)以及眼睛、鼻子、嘴巴等关键点的坐标。
适用场景
| 场景 | 说明 |
|---|---|
| 人脸解锁 | 检测人脸位置,作为活体检测的前置步骤 |
| 美颜/滤镜 | 根据关键点坐标定位五官,做瘦脸、大眼特效 |
| 人脸跟踪 | 视频通话中持续跟踪人脸位置 |
| 考勤打卡 | 检测人脸区域,截取后送入比对模型 |
不适合场景
- 极端光照条件(逆光、全黑环境)下检测率会明显下降
- 大角度侧脸(超过90度)无法检测到
- 遮挡严重(口罩遮住口鼻、眼镜反光过强)会影响关键点定位精度
为什么选择Core Vision Kit
HarmonyOS提供了多个视觉能力框架,常见的有:
| 框架 | 说明 |
|---|---|
| 系统Camera API + 自己实现检测 | 需要自己处理图像格式转换,稳定性差 |
| Core Vision Kit | 集成度最高,APIs设计合理,支持多种检测模式 |
| ML Kit(旧版) | 已不建议在新项目中使用 |
Core Vision Kit的优势在于和相机预览流直接衔接,不需要开发者手动处理NV12到RGB的转换,减少了大量边界情况。
环境说明
text
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机(支持相机设备即可)
核心实现
1. FaceDetector初始化与参数配置
FaceDetector的初始化是整个流程的第一步。很多人直接使用默认配置,结果发现关键点为空------这是因为默认模式不开启关键点检测。
typescript
import { faceDetector } from '@kit.CoreVisionKit';
import { image } from '@kit.ImageKit';
// 创建配置对象
let config: faceDetector.FaceDetectorConfig = {
// 检测模式:DETECT_ONLY只检测位置,不分析属性
// 这里选择DETECT_MODE_DEFAULT,表示同时检测位置和姿态
detectMode: faceDetector.FaceDetectMode.DETECT_MODE_DEFAULT,
// 关键点类型:0表示不检测关键点,1表示检测5个关键点(眼睛、鼻尖、嘴角)
// 2表示检测68个关键点,但精度和性能需要权衡
landmarkType: faceDetector.FaceLandmarkType.LANDMARK_TYPE_5,
// 置信度阈值,低于此值的检测结果会被过滤
confidenceThreshold: 0.5,
// 是否支持多人脸检测
maxFaceCount: 10
};
// 初始化FaceDetector实例
let detector: faceDetector.FaceDetector;
try {
detector = faceDetector.FaceDetector.create(config);
console.info('FaceDetector created successfully');
} catch (error) {
console.error(`Failed to create FaceDetector: ${error.message}`);
}
注意点:
landmarkType设置很重要。很多人设了LANDMARK_TYPE_5结果发现关键点还是空的,原因是必须同时将detectMode设为DETECT_MODE_DEFAULT,否则关键点不会计算。maxFaceCount设置过高会明显增加计算耗时。普通手机建议设为3-5,如果只检测单个人脸,设为1即可。
2. 从相机预览中获取图像数据
这里有一个常见的误解:开发者以为可以直接把Camera预览的Surface传给检测器。实际需要从预览流中获取PixelMap,然后才能送入检测器。
typescript
import { camera } from '@kit.CameraKit';
import { image } from '@kit.ImageKit';
// 假设已经初始化了CameraManager和CameraInput
async function getPreviewPixelMap(imageObj: image.Image): Promise<image.PixelMap> {
// 从camera预览接收到的Image对象中获取PixelMap
let receiver = image.createImageReceiver({
width: 640, // 检测不需要太高的分辨率,640x480足够
height: 480,
format: 0 // ImageFormat.NV21
});
// 简化处理,实际项目中需要绑定Camera的previewOutput
let pixelMap = await imageObj.createPixelMap();
return pixelMap;
}
为什么不用原始分辨率: 检测算法的输入分辨率越高,计算耗时越长。使用640x480是一个平衡点,既保证检测精度,又不会导致卡顿。
3. 执行人脸检测
拿到PixelMap后,调用detect方法即可。
typescript
async function detectFaces(pixelMap: image.PixelMap): Promise<faceDetector.FaceInfo[]> {
if (!detector) {
console.error('FaceDetector not initialized');
return [];
}
try {
let faces: faceDetector.FaceInfo[] = await detector.detect(pixelMap);
return faces;
} catch (error) {
console.error(`Detection failed: ${error.message}`);
return [];
}
}
FaceInfo的结构:
typescript
interface FaceInfo {
// 人脸在图像中的矩形区域
faceRect: faceDetector.FaceRect;
// 人脸姿态角度(偏航角yaw、俯仰角pitch、翻滚角roll)
facePose: faceDetector.FacePose;
// 关键点列表
landmarks: faceDetector.FaceLandmark[];
// 置信度 0~1
confidence: number;
// 人脸标识id(多人脸场景用于跟踪)
faceId: number;
}
4. 在Canvas上绘制人脸框和关键点
这一步是很多开发者翻车的地方。坐标系的转换是核心问题:检测返回的坐标是相对于输入图像(640x480)的,而Canvas的坐标系可能不同。
typescript
@State canvasWidth: number = 0;
@State canvasHeight: number = 0;
private canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D();
build() {
Column() {
Canvas(this.canvasContext)
.width('100%')
.height('100%')
.onAreaChange((oldValue, newValue) => {
this.canvasWidth = newValue.width as number;
this.canvasHeight = newValue.height as number;
})
}
}
// 绘制人脸框和关键点
drawFaceInfo(faces: faceDetector.FaceInfo[], imageWidth: number, imageHeight: number) {
let ctx = this.canvasContext;
ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
// 计算缩放比例
let scaleX = this.canvasWidth / imageWidth;
let scaleY = this.canvasHeight / imageHeight;
let scale = Math.min(scaleX, scaleY);
// 计算偏移(保持居中)
let offsetX = (this.canvasWidth - imageWidth * scale) / 2;
let offsetY = (this.canvasHeight - imageHeight * scale) / 2;
ctx.strokeStyle = '#00FF00';
ctx.lineWidth = 3;
for (let face of faces) {
// 绘制人脸矩形框
let rect = face.faceRect;
let x = rect.left * scale + offsetX;
let y = rect.top * scale + offsetY;
let w = (rect.right - rect.left) * scale;
let h = (rect.bottom - rect.top) * scale;
ctx.strokeRect(x, y, w, h);
// 绘制关键点
ctx.fillStyle = '#FF0000';
for (let landmark of face.landmarks) {
let lx = landmark.x * scale + offsetX;
let ly = landmark.y * scale + offsetY;
ctx.beginPath();
ctx.arc(lx, ly, 4, 0, 2 * Math.PI);
ctx.fill();
}
}
}
关键点说明:
- 这里的缩放计算假设Canvas和图像都是左上角原点,且没有旋转。
- 如果相机预览是倒置的(常见于前置摄像头),需要额外做旋转处理。
- 建议在
onAreaChange中获取Canvas实际尺寸,而不是在build中直接使用固定值。
5. 完整的实时检测流程
把前面的步骤组合起来,形成一个从相机取帧到绘制结果的完整循环。
typescript
// 假设每帧回调会触发此函数
async function onFrameArrived(imageObj: image.Image) {
try {
let pixelMap = await imageObj.createPixelMap();
// 记录检测到的分辨率
let imgWidth = pixelMap.width;
let imgHeight = pixelMap.height;
// 执行检测
let faces = await detectFaces(pixelMap);
// 更新UI状态
this.faces = faces;
this.imgWidth = imgWidth;
this.imgHeight = imgHeight;
// 触发canvas绘制(通过状态驱动)
this.isDirty = !this.isDirty;
} catch (error) {
console.error(`Frame processing error: ${error.message}`);
}
}
这个实现的潜在问题:
- 每帧都创建PixelMap,GC压力大。
- 异步回调无法保证帧序,可能出现后一帧先检测完成导致绘制乱序。
- 频繁调用
detect方法,如果设备性能不足,可能堆积请求。
踩坑记录
坑1:人脸框滞后严重
现象: 在相机预览中,人脸框明显晚于实际人脸移动,延迟在200ms以上。
原因分析: 这个问题有两个来源。第一,相机预览帧率(30fps)和检测帧率(可能只有5-10fps)不匹配,导致每一帧都送入检测器,但处理不过来,形成队列堆积。第二,绘制逻辑用的是异步回调,主线程被频繁的setState打断,绘制被延迟。
解决方案:
typescript
// 限制检测帧率,每100ms只处理一帧
private lastDetectTime: number = 0;
private minInterval: number = 100; // ms
async function onFrameArrived(imageObj: image.Image) {
let now = Date.now();
if (now - this.lastDetectTime < this.minInterval) {
return; // 跳帧
}
this.lastDetectTime = now;
// 后续处理同上
}
效果:牺牲了检测频率(从10fps降到5-8fps),但延迟稳定性大幅提升。
坑2:关键点坐标不对,画到了屏幕外面
现象: 检测结果中的关键点坐标画出来偏移很大,有的在屏幕边缘,有的完全超出画布。
原因分析: 这个问题通常出现在相机预览尺寸和Canvas尺寸不一致的情况下。例如相机提供的是1920x1080,检测时缩放到640x480,但Canvas是屏幕全屏(1080x2400)。如果缩放比例计算时只用宽度或者高度,就会导致坐标错位。
解决方案:
typescript
// 强制统一处理宽高比不一致的情况
function mapCoordinates(
point: { x: number, y: number },
srcWidth: number, srcHeight: number,
dstWidth: number, dstHeight: number
): { x: number, y: number } {
// 保持宽高比,计算实际绘制区域
let scaleX = dstWidth / srcWidth;
let scaleY = dstHeight / srcHeight;
let scale = Math.min(scaleX, scaleY);
let actualWidth = srcWidth * scale;
let actualHeight = srcHeight * scale;
let offsetX = (dstWidth - actualWidth) / 2;
let offsetY = (dstHeight - actualHeight) / 2;
return {
x: point.x * scale + offsetX,
y: point.y * scale + offsetY
};
}
注意: 这个方法假设图像内容没有被裁剪。如果相机预览填满了Canvas但进行了裁剪(比如填充模式是ImageFit.Fill),就需要用不同的计算方式。
最佳实践
-
检测频率不要超过10fps。人脸检测对实时性要求不高,10fps已经足够跟踪人脸移动。更高的检测频率只会浪费CPU。
-
使用适度分辨率作为检测输入。640x480是一个较好的选择。更高分辨率(如1920x1080)会明显增加检测耗时,但精度提升有限。
-
PixelMap的重用。频繁创建PixelMap会导致内存抖动和GC卡顿。可以考虑使用固定的Buffer池,或者在相机回调中直接处理NV12格式的字节数据而非PixelMap。
-
多人脸场景逐帧处理 。如果单帧检测到多张人脸,建议分配独立的跟踪ID。Core Vision Kit返回的
faceId在连续帧中可能变化,需要自己维护跟踪逻辑。 -
不要在主线程中执行检测 。
detect方法是异步的,但内部的图像处理仍可能阻塞调用线程。建议使用独立的任务队列。
Demo入口文件
typescript
// src/main/ets/pages/Index.ets
@Entry
@Component
struct FaceDetectDemo {
@State faces: faceDetector.FaceInfo[] = [];
@State imgWidth: number = 0;
@State imgHeight: number = 0;
@State isDirty: boolean = false;
private detector: faceDetector.FaceDetector | null = null;
private canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D();
aboutToAppear() {
this.initDetector();
}
aboutToDisappear() {
this.detector?.release();
}
initDetector() {
let config: faceDetector.FaceDetectorConfig = {
detectMode: faceDetector.FaceDetectMode.DETECT_MODE_DEFAULT,
landmarkType: faceDetector.FaceLandmarkType.LANDMARK_TYPE_5,
confidenceThreshold: 0.5,
maxFaceCount: 5
};
try {
this.detector = faceDetector.FaceDetector.create(config);
} catch (error) {
console.error('Detector init failed:', error.message);
}
}
build() {
Stack() {
// 相机预览(略,需要Camera相关配置)
Canvas(this.canvasContext)
.width('100%')
.height('100%')
.onAreaChange((_, newValue) => {
// Canvas尺寸变化时重绘
})
}
.width('100%')
.height('100%')
}
// 绘制检测结果,放在 @Watch 回调或者 computed中
@Watch('isDirty')
redraw() {
if (this.faces.length === 0) return;
let ctx = this.canvasContext;
ctx.clearRect(0, 0, ctx.width, ctx.height);
this.drawFaceInfo(this.faces, this.imgWidth, this.imgHeight);
}
}
FAQ
Q:为什么真机上检测正常,模拟器上返回结果为空?
A:模拟器的相机设备是虚拟的,返回的图像数据可能有格式问题。Core Vision Kit在模拟器上的行为不稳定,建议始终在真机上验证。这是一个已知的限制,官方文档也有说明。
Q:页面返回后第二次进入,检测卡住不返回结果?
A:检查aboutToDisappear中是否调用了detector.release()。如果页面返回后检测器被释放,但后续的异步回调中又试图使用它,就会卡住。建议使用一个标志位控制,例如this.isActive = true/false,在回调中先检查标志位。
Q:为什么有时候多人脸场景只检测到一个人?
A:检查maxFaceCount是否设置足够大,同时注意confidenceThreshold阈值。如果人脸在画面中较小或侧脸,置信度会低于阈值被过滤。建议先调低阈值(如0.3)测试,确认算法能力后逐步提高。
示例代码
示例代码地址:项目地址
这个项目包含了完整的相机预览、人脸检测、Canvas绘制功能,适配了常见的屏幕尺寸和旋转角度。如果你在实践过程中遇到其他问题,可以重点检查生命周期和坐标转换的部分。
如果你也遇到类似的坐标偏移或者性能问题,可以重点检查帧率限制和缩放计算逻辑。不同设备上的行为可能会有差异,建议在真机上多做几次测试。