【共创稿事节】 HarmonyOS7 拍照识人搜相册:faceDetector + faceComparator
前言
“拍一张照,然后从相册里找出所有包含这个人的照片”——这个需求听起来复杂,但 HarmonyOS 的 CoreVisionKit 已经把核心能力封装好了:faceDetector 负责检测人脸,faceComparator 负责比对人脸相似度。你不需要训练模型,不需要对接云端,两个 API 就够了。
这篇文章围绕一个完整的"拍照识人搜相册"案例,把从相机拍照、人脸检测、相册遍历到人脸比对的整条链路拆开讲。每个环节的代码我都会说明"为什么这样写",文末有完整源码,可以直接取用。
效果预览

主要流程
整个流程可以压缩成 5 步:
- 初始化
faceDetector和faceComparator,页面出现时一次性完成。 - 启动前置相机预览,用
MetadataOutput实时检测人脸,检测到人脸后才允许拍照。 - 拍照后用
faceDetector.detect()验证照片中确实有人脸,通过后保存为参考人脸。 - 遍历相册所有照片,对每张先用
faceDetector.detect()检测人脸,再对有人脸的照片用faceComparator.compareFaces()和参考人脸比对。 - 相似度 ≥ 0.5 且
isSamePerson = true的照片加入结果列表。
后面所有代码,都是围绕这条链路展开的。
开始之前:权限和导入
这个案例需要两个权限:相机权限用于拍照,相册读取权限用于遍历照片。在 module.json5 的 requestPermissions 里声明:
{
"name": "ohos.permission.CAMERA",
"reason": "$string:camera_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
},
{
"name": "ohos.permission.READ_IMAGEVIDEO",
"reason": "$string:read_imagevideo_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
代码里的导入:
import { faceDetector, faceComparator } from '@kit.CoreVisionKit';
import { camera } from '@kit.CameraKit';
import { image } from '@kit.ImageKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fileIo } from '@kit.CoreFileKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { dataSharePredicates } from '@kit.ArkData';
import { abilityAccessCtrl, bundleManager, common, Permissions } from '@kit.AbilityKit';
import { display } from '@kit.ArkUI';
faceDetector 和 faceComparator 来自 @kit.CoreVisionKit,是人脸检测和比对的核心模块。photoAccessHelper 用于遍历相册,dataSharePredicates 用于构建查询条件,fileIo 用于打开文件拿 fd。
初始化 faceDetector 和 faceComparator
和 textRecognition 一样,CoreVisionKit 的能力在 API 12 之后必须先初始化才能使用。
async aboutToAppear(): Promise<void> {
let detResult: boolean = await faceDetector.init();
this.faceDetInited = detResult;
hilog.info(DOMAIN, TAG, `faceDetector init: ${detResult}`);
let cmpResult: boolean = await faceComparator.init();
this.faceCmpInited = cmpResult;
hilog.info(DOMAIN, TAG, `faceComparator init: ${cmpResult}`);
}
两个引擎各自独立初始化,返回 boolean 表示是否成功。如果设备不支持人脸检测或比对能力,init() 返回 false,后续调用 detect() 或 compareFaces() 会抛异常。
页面销毁时必须释放:
async aboutToDisappear(): Promise<void> {
await this.releaseCamera();
if (this.faceDetInited) {
await faceDetector.release();
}
if (this.faceCmpInited) {
await faceComparator.release();
}
// 释放 PixelMap 资源...
}
faceDetector.release() 和 faceComparator.release() 各自独立释放,不互相影响。用 faceDetInited / faceCmpInited 标志位做保护,避免对未初始化的引擎调用 release 导致异常。
相机预览 + MetadataOutput 人脸检测
相机初始化的完整流程(CameraManager → CameraInput → PreviewOutput + MetadataOutput + PhotoOutput → PhotoSession)在上一篇人脸贴纸的文章里已经详细讲过,这里重点讲和本案例相关的两个差异点。
差异一:photoAvailable 回调拿拍照结果
本案例需要拍照拿到 PixelMap,所以 PhotoOutput 要注册 photoAvailable 回调:
if (this.photoOutput) {
this.photoOutput.on('photoAvailable', (err: BusinessError, photo: camera.Photo): void => {
if (err && err.code !== 0) {
return;
}
if (!photo || !photo.main) {
return;
}
let imageObj: image.Image = photo.main;
imageObj.getComponent(image.ComponentType.JPEG, (errCode: BusinessError,
component: image.Component): void => {
if (errCode && errCode.code !== 0) {
imageObj.release();
return;
}
if (!component || !component.byteBuffer) {
imageObj.release();
return;
}
let buffer: ArrayBuffer = component.byteBuffer;
let imgSource: image.ImageSource = image.createImageSource(buffer);
imgSource.createPixelMap().then((pm: image.PixelMap) => {
imgSource.release();
imageObj.release();
this.onPhotoCaptured(pm);
}).catch((_e: BusinessError) => {
imgSource.release();
imageObj.release();
});
});
});
}
这条数据提取链路比较长,拆开看:
photo.main:拍照返回的Photo对象,main是主图(JPEG 格式的image.Image)imageObj.getComponent(JPEG):从 Image 中提取 JPEG 组件,拿到byteBufferimage.createImageSource(buffer):用 JPEG buffer 创建 ImageSourceimgSource.createPixelMap():从 ImageSource 创建 PixelMap
每一步都要释放对应的资源——imageObj、imgSource 用完就 release()。不释放的话,连续拍照会内存泄漏。
差异二:MetadataOutput 检测到人脸才允许拍照
mOutput.on('metadataObjectsAvailable', (_err: BusinessError,
metadataObjectArr: Array<camera.MetadataObject>) => {
this.hasFace = metadataObjectArr.length > 0;
if (this.cameraStarted) {
if (this.hasFace) {
this.statusText = '已检测到人脸,点击拍照';
} else {
this.statusText = '未检测到人脸,请正对摄像头';
}
}
});
拍照按钮的 enabled 绑定 hasFace:
Button('拍照识人')
.enabled(this.hasFace && this.cameraStarted && !this.cameraStarting)
.backgroundColor(this.hasFace ? '#4CAF50' : '#555555')
没检测到人脸时按钮灰色不可点,检测到人脸后变绿可点。这样能避免拍出无人脸的照片,减少后续的无效处理。
拍照触发
private async captureAndSearch(): Promise<void> {
if (!this.photoOutput || !this.cameraStarted) {
return;
}
let capSettings: camera.PhotoCaptureSetting = {
quality: camera.QualityLevel.QUALITY_LEVEL_MEDIUM,
rotation: camera.ImageRotation.ROTATION_0
};
try {
await this.photoOutput.capture(capSettings);
} catch (error) {
this.statusText = '拍照失败';
}
}
capture() 是异步方法,调用后 photoAvailable 回调才会触发。quality 设为 MEDIUM 而不是 HIGH,因为人脸比对不需要超高清照片,中等质量就够了,还能减少内存占用和 PixelMap 创建时间。
拍照后验证人脸——onPhotoCaptured
拍照回调拿到 PixelMap 后,不是直接当参考人脸用,而是先用 faceDetector.detect() 验证照片中确实有人脸。
private async onPhotoCaptured(pm: image.PixelMap): Promise<void> {
await this.releaseCamera();
if (this.referencePixelMap) {
this.referencePixelMap.release();
this.referencePixelMap = undefined;
}
let detVisionInfo: faceDetector.VisionInfo = {
pixelMap: pm
};
try {
let faces: faceDetector.Face[] = await faceDetector.detect(detVisionInfo);
if (faces.length === 0) {
this.statusText = '拍照中未检测到人脸,请重试';
pm.release();
return;
}
} catch (e) {
this.statusText = '人脸检测失败,请重试';
pm.release();
return;
}
this.referencePixelMap = pm;
this.statusText = '人脸识别成功!点击"搜索相册"查找该人物照片';
}
这一步做了三件事:
- 释放相机资源:拍完照后立即释放相机,回到非拍照状态。相机是独占资源,不释放的话其他功能可能受限。
- 释放旧参考人脸:如果之前已有参考人脸(重复拍照场景),先释放旧的 PixelMap。
- 验证人脸:
faceDetector.detect()返回Face[]数组,空数组表示无人脸。验证通过后才把 PixelMap 存为referencePixelMap。
为什么不能跳过验证直接存参考人脸? 因为 MetadataOutput 的人脸检测和实际拍出来的照片有时间差——预览帧显示有人脸,但按下快门的那一瞬间人可能已经移开了。多一次 faceDetector.detect() 验证,确保参考人脸是有效的。
faceDetector.VisionInfo 的结构
let detVisionInfo: faceDetector.VisionInfo = {
pixelMap: pm
};
VisionInfo 只有一个必填字段 pixelMap。faceDetector.detect() 会对 PixelMap 做人脸检测,返回检测到的人脸信息数组。返回的 Face 对象包含人脸位置、关键点坐标等信息,本案例中只关心有没有人脸(faces.length > 0),不需要用具体的位置数据。
遍历相册——scanAlbumForPerson
这是案例中最复杂的部分,涉及相册查询、图片加载、人脸检测、人脸比对四个步骤的串行执行。
查询相册所有照片
let context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
let phAccessHelper: photoAccessHelper.PhotoAccessHelper =
photoAccessHelper.getPhotoAccessHelper(context);
let predicates: dataSharePredicates.DataSharePredicates =
new dataSharePredicates.DataSharePredicates();
predicates.orderByDesc(photoAccessHelper.PhotoKeys.DATE_ADDED);
let fetchOptions: photoAccessHelper.FetchOptions = {
fetchColumns: [photoAccessHelper.PhotoKeys.URI, photoAccessHelper.PhotoKeys.DISPLAY_NAME],
predicates: predicates
};
let fetchResult: photoAccessHelper.FetchResult<photoAccessHelper.PhotoAsset> | null =
await phAccessHelper.getAssets(fetchOptions);
this.totalCount = fetchResult.getCount();
dataSharePredicates 是查询条件构建器,orderByDesc(DATE_ADDED) 按添加时间倒序排列——最近的照片排在前面。fetchColumns 指定只获取 URI 和文件名,不拉取其他元数据,减少查询开销。
getAssets() 返回 FetchResult<PhotoAsset>,这是相册照片的游标。getCount() 拿总数,getAllObjects() 拿全部 PhotoAsset 对象。
逐张比对的核心循环
let allAssets: Array<photoAccessHelper.PhotoAsset> = await fetchResult.getAllObjects();
let refVisionInfo: faceComparator.VisionInfo = {
pixelMap: this.referencePixelMap
};
for (let i = 0; i < allAssets.length; i++) {
let asset: photoAccessHelper.PhotoAsset = allAssets[i];
this.scannedCount = i + 1;
this.statusText = `比对中 ${this.scannedCount}/${this.totalCount}`;
try {
let albumPm: image.PixelMap | undefined = await this.loadPixelMapFromAsset(asset);
if (!albumPm) {
continue;
}
let detVisionInfo: faceDetector.VisionInfo = {
pixelMap: albumPm
};
let faces: faceDetector.Face[] = await faceDetector.detect(detVisionInfo);
if (faces.length === 0) {
albumPm.release();
continue;
}
let albumVisionInfo: faceComparator.VisionInfo = {
pixelMap: albumPm
};
let compareResult: faceComparator.FaceCompareResult =
await faceComparator.compareFaces(refVisionInfo, albumVisionInfo);
albumPm.release();
if (compareResult.isSamePerson && compareResult.similarity >= SIMILARITY_THRESHOLD) {
let thumb: image.PixelMap | undefined = undefined;
try {
let size: image.Size = { width: 200, height: 200 };
thumb = await asset.getThumbnail(size);
} catch (e) {
hilog.warn(DOMAIN, TAG, `getThumbnail error`);
}
let item: PhotoItem = { uri: asset.uri, pixelMap: thumb };
this.matchedPhotos = [...this.matchedPhotos, item];
}
} catch (e) {
hilog.warn(DOMAIN, TAG, `compare error: ${(e as BusinessError).code}`);
}
}
每张照片的处理分四步:
- 加载 PixelMap:
loadPixelMapFromAsset()从 URI 加载图片到 PixelMap - 检测人脸:
faceDetector.detect()判断照片是否有人脸,无人脸直接跳过 - 比对相似度:
faceComparator.compareFaces()和参考人脸比对 - 收集结果:相似度达标时,获取缩略图加入结果列表
为什么必须先检测再比对
faceComparator.compareFaces() 不会自动做人脸检测——它假设传入的图片中已经有人脸。如果直接把无人脸的照片传给 compareFaces(),要么抛异常,要么返回无效结果。
所以流程是:先 detect() 过滤掉无人脸的照片,再对有人脸的照片 compareFaces() 比对。这个两步设计不是冗余的——检测和比对是两个独立的操作,比对更耗时,先过滤能大幅减少比对次数。
SIMILARITY_THRESHOLD = 0.5 的选择
const SIMILARITY_THRESHOLD = 0.5;
if (compareResult.isSamePerson && compareResult.similarity >= SIMILARITY_THRESHOLD) {
比对结果 FaceCompareResult 有两个关键字段:
| 字段 | 类型 | 说明 |
|---|---|---|
isSamePerson | boolean | 引擎判定是否为同一人 |
similarity | number | 相似度分数,0~1 |
为什么两个条件都要满足?isSamePerson 是引擎的布尔判定,similarity 是量化分数。单用 isSamePerson 可能误判(引擎在边界情况下的布尔判定不够稳),单用 similarity 阈值不好定。两者结合更可靠:引擎先判断"像",分数再确认"够像"。
0.5 的阈值偏保守,宁可漏一些也不误收。如果你的场景更看重召回率,可以降到 0.4;如果更看重准确率,可以升到 0.6。
loadPixelMapFromAsset:从 URI 到 PixelMap
private async loadPixelMapFromAsset(asset: photoAccessHelper.PhotoAsset): Promise<image.PixelMap | undefined> {
let fileSource = await fileIo.open(asset.uri, fileIo.OpenMode.READ_ONLY);
let imageSource: image.ImageSource = image.createImageSource(fileSource.fd);
let pm: image.PixelMap | undefined = undefined;
try {
pm = await imageSource.createPixelMap();
await fileIo.close(fileSource);
await imageSource.release();
} catch (e) {
try {
await fileIo.close(fileSource);
} catch (closeErr) {
// ignore
}
try {
await imageSource.release();
} catch (releaseErr) {
// ignore
}
return undefined;
}
return pm;
}
和 textRecognition 一样的流程:URI 不能直接给 createImageSource(),必须通过 fileIo.open() 拿到 fd,再用 fd 创建 ImageSource。
异常处理要补全:正常路径 fileIo.close() 和 imageSource.release() 在 try 块里;异常路径也要在 catch 里补上,否则 fd 泄漏会累积到系统上限,导致后续文件打不开。
用缩略图展示而非原图
let size: image.Size = { width: 200, height: 200 };
thumb = await asset.getThumbnail(size);
let item: PhotoItem = { uri: asset.uri, pixelMap: thumb };
this.matchedPhotos = [...this.matchedPhotos, item];
结果列表里用 200x200 的缩略图展示,不用原图。原图可能 4K 分辨率,如果每张都加载 PixelMap,几十张匹配结果就能把内存吃满。缩略图只做展示用,点击查看时再用 URI 加载原图。
匹配结果用整数组赋值触发刷新
this.matchedPhotos = [...this.matchedPhotos, item];
不是 this.matchedPhotos.push(item)——ArkUI 对 @State 数组的 push 操作在部分版本不触发 UI 刷新。用展开运算符 [...arr, newItem] 创建新数组,整个替换,确保框架感知到变化。
finally 块关闭 FetchResult
finally {
if (fetchResult !== null) {
fetchResult.close();
}
this.isScanning = false;
}
FetchResult 是系统资源,不用时必须 close()。放在 finally 里确保异常时也能关闭,否则相册查询的游标会残留。
UI 层:三种状态的切换
页面的 UI 根据状态分为三种视图:
状态一:拍照模式
if (this.isCapturing) {
Stack() {
XComponent({...})
.width('100%')
.height('100%')
if (this.cameraStarting) {
Column() {
LoadingProgress().width(40).height(40).color('#FFFFFF')
Text('正在启动相机...').fontSize(14).fontColor('#FFFFFF').margin({ top: 8 })
}
.backgroundColor('#1A1A2ECC')
} else if (this.hasFace) {
Row() {
Text('✓').fontSize(12).fontColor('#FFFFFF').margin({ right: 4 })
Text('人脸已识别').fontSize(13).fontColor('#FFFFFF')
}
.backgroundColor('#4CAF50CC')
.borderRadius(16)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.position({ x: 12, y: 12 })
}
}
}
拍照模式下显示相机预览。相机启动中叠加 LoadingProgress,人脸检测到后在左上角显示绿色提示。
状态二:参考人脸展示
else if (this.referencePixelMap) {
Row() {
Image(this.referencePixelMap)
.width(52)
.height(52)
.objectFit(ImageFit.Cover)
.borderRadius(26)
.border({ width: 3, color: '#4CAF50' })
Column() {
Text('已识别人脸').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Medium)
Text('点击搜索相册中该人物的照片').fontSize(12).fontColor('#888888').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
}
拍完照后,顶部展示参考人脸的圆形头像和提示文字。此时底部显示"拍照识人"和"搜索相册"两个按钮。
状态三:搜索结果展示
if (this.matchedPhotos.length > 0) {
Grid() {
ForEach(this.matchedPhotos, (item: PhotoItem, index: number) => {
GridItem() {
Stack() {
if (item.pixelMap) {
Image(item.pixelMap).width('100%').height('100%').objectFit(ImageFit.Cover)
} else {
Image(item.uri).width('100%').height('100%').objectFit(ImageFit.Cover)
}
}
.borderRadius(12)
.clip(true)
}
.aspectRatio(1)
.onClick(() => {
this.previewIndex = index;
this.showPreview = true;
})
}, (_item: PhotoItem, index: number) => `${index}`)
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.cachedCount(12)
}
4 列 Grid 展示匹配照片缩略图,cachedCount(12) 预缓存 12 项(3 行的量),滚动时更流畅。点击缩略图进入全屏预览。
搜索进度条
if (this.isScanning) {
Column() {
Row() {
LoadingProgress().width(18).height(18).color('#4A90D9')
Text(this.statusText).fontSize(14).fontColor('#DDDDDD').margin({ left: 8 })
}
Progress({ value: this.scannedCount, total: Math.max(this.totalCount, 1), type: ProgressType.Linear })
.width('100%')
.color('#4A90D9')
.margin({ top: 8 })
}
}
Progress 组件实时显示扫描进度,total 用 Math.max(this.totalCount, 1) 防止除零。
全屏预览叠层
@Builder
previewOverlay() {
if (this.showPreview && this.matchedPhotos.length > this.previewIndex) {
Stack() {
Column() {
Row() {
Text('×').onClick(() => { this.showPreview = false; })
Text(`${this.previewIndex + 1} / ${this.matchedPhotos.length}`)
Row().width(40).height(40)
}
Image(this.matchedPhotos[this.previewIndex].uri)
.width('100%')
.layoutWeight(1)
.objectFit(ImageFit.Contain)
Row() {
Button('‹').enabled(this.previewIndex > 0)
.onClick(() => { if (this.previewIndex > 0) this.previewIndex--; })
Row().layoutWeight(1)
Button('›').enabled(this.previewIndex < this.matchedPhotos.length - 1)
.onClick(() => { if (this.previewIndex < this.matchedPhotos.length - 1) this.previewIndex++; })
}
}
}
.backgroundColor('#0A0A0AF0')
}
}
预览层用半透明深色背景叠在整个页面上方,用 Image(uri) 加载原图而非缩略图。左右箭头切换,previewIndex 控制当前查看的照片。
完整源码
import { faceDetector, faceComparator } from '@kit.CoreVisionKit';
import { camera } from '@kit.CameraKit';
import { image } from '@kit.ImageKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fileIo } from '@kit.CoreFileKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { dataSharePredicates } from '@kit.ArkData';
import { abilityAccessCtrl, bundleManager, common, Permissions } from '@kit.AbilityKit';
import { display } from '@kit.ArkUI';
const DOMAIN = 0x0000;
const TAG = 'FacePicSearch';
const SIMILARITY_THRESHOLD = 0.5;
interface PhotoItem {
uri: string;
pixelMap: image.PixelMap | undefined;
}
@Entry
@Component
struct FacePicSearch {
@State referencePixelMap: image.PixelMap | undefined = undefined;
@State matchedPhotos: PhotoItem[] = [];
@State statusText: string = '点击"拍照识人"开始';
@State isScanning: boolean = false;
@State scannedCount: number = 0;
@State totalCount: number = 0;
@State showPreview: boolean = false;
@State previewIndex: number = 0;
@State faceDetInited: boolean = false;
@State faceCmpInited: boolean = false;
@State isCapturing: boolean = false;
@State surfaceReady: boolean = false;
@State hasFace: boolean = false;
@State cameraStarted: boolean = false;
@State cameraStarting: boolean = false;
private mXComponentController: XComponentController = new XComponentController();
private cameraManager: camera.CameraManager | undefined = undefined;
private cameraSession: camera.PhotoSession | undefined = undefined;
private cameraInput: camera.CameraInput | undefined = undefined;
private previewOutput: camera.PreviewOutput | undefined = undefined;
private photoOutput: camera.PhotoOutput | undefined = undefined;
private metadataOutput: camera.MetadataOutput | undefined = undefined;
private xComponentSurfaceId: string = '';
private previewAreaWidth: number = 0;
private previewAreaHeight: number = 0;
async aboutToAppear(): Promise<void> {
let detResult: boolean = await faceDetector.init();
this.faceDetInited = detResult;
hilog.info(DOMAIN, TAG, `faceDetector init: ${detResult}`);
let cmpResult: boolean = await faceComparator.init();
this.faceCmpInited = cmpResult;
hilog.info(DOMAIN, TAG, `faceComparator init: ${cmpResult}`);
}
async aboutToDisappear(): Promise<void> {
await this.releaseCamera();
if (this.faceDetInited) {
await faceDetector.release();
}
if (this.faceCmpInited) {
await faceComparator.release();
}
for (let i = 0; i < this.matchedPhotos.length; i++) {
if (this.matchedPhotos[i].pixelMap) {
this.matchedPhotos[i].pixelMap?.release();
}
}
if (this.referencePixelMap) {
this.referencePixelMap.release();
}
}
private checkPermission(perm: Permissions): boolean {
let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
let bundleInfo: bundleManager.BundleInfo =
bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION);
let tokenID: number = bundleInfo.appInfo.accessTokenId;
let grantStatus: number = atManager.checkAccessTokenSync(tokenID, perm);
return grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;
}
private async requestPermissions(): Promise<boolean> {
let perms: Array<Permissions> = ['ohos.permission.CAMERA', 'ohos.permission.READ_IMAGEVIDEO'];
let allGranted: boolean = true;
let context: Context = this.getUIContext().getHostContext() as Context;
let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
for (let i = 0; i < perms.length; i++) {
if (!this.checkPermission(perms[i])) {
try {
let result = await atManager.requestPermissionsFromUser(context, [perms[i]]);
if (result.authResults[0] !== 0) {
allGranted = false;
}
} catch (error) {
allGranted = false;
}
}
}
return allGranted;
}
private async startCamera(): Promise<void> {
let hasPerm: boolean = await this.requestPermissions();
if (!hasPerm) {
this.statusText = '需要相机和相册权限';
return;
}
this.isCapturing = true;
this.cameraStarting = true;
this.statusText = '正在准备相机预览...';
let waitCount: number = 0;
while (!this.surfaceReady && waitCount < 50) {
await new Promise<void>((resolve) => setTimeout(resolve, 100));
waitCount++;
}
if (!this.surfaceReady || this.xComponentSurfaceId === '') {
this.statusText = '预览组件初始化失败';
this.isCapturing = false;
this.cameraStarting = false;
return;
}
this.statusText = '正在启动相机...';
let context: Context = this.getUIContext().getHostContext() as Context;
let mgr: camera.CameraManager | undefined = undefined;
try {
mgr = camera.getCameraManager(context);
} catch (error) {
this.statusText = '相机管理器创建失败';
this.isCapturing = false;
this.cameraStarting = false;
return;
}
if (!mgr) {
this.statusText = '相机管理器创建失败';
this.isCapturing = false;
this.cameraStarting = false;
return;
}
this.cameraManager = mgr;
let cameraDevices: Array<camera.CameraDevice> = [];
try {
cameraDevices = mgr.getSupportedCameras();
} catch (error) {
this.statusText = '获取相机设备失败';
this.isCapturing = false;
this.cameraStarting = false;
return;
}
if (cameraDevices.length === 0) {
this.statusText = '未找到可用相机';
this.isCapturing = false;
this.cameraStarting = false;
return;
}
let frontDevice: camera.CameraDevice = cameraDevices[0];
for (let i = 0; i < cameraDevices.length; i++) {
if (cameraDevices[i].cameraPosition === camera.CameraPosition.CAMERA_POSITION_FRONT) {
frontDevice = cameraDevices[i];
break;
}
}
let cInput: camera.CameraInput | undefined = undefined;
try {
cInput = mgr.createCameraInput(frontDevice);
} catch (error) {
this.statusText = '创建相机输入失败';
this.isCapturing = false;
this.cameraStarting = false;
return;
}
this.cameraInput = cInput;
try {
await cInput.open();
} catch (error) {
this.statusText = '打开相机失败';
this.isCapturing = false;
this.cameraStarting = false;
return;
}
let cameraOutputCapability: camera.CameraOutputCapability =
mgr.getSupportedOutputCapability(frontDevice, camera.SceneMode.NORMAL_PHOTO);
if (!cameraOutputCapability) {
this.statusText = '获取相机能力失败';
this.isCapturing = false;
this.cameraStarting = false;
return;
}
let metadataObjectTypes: Array<camera.MetadataObjectType> = cameraOutputCapability.supportedMetadataObjectTypes;
let mOutput: camera.MetadataOutput | undefined = undefined;
try {
mOutput = mgr.createMetadataOutput(metadataObjectTypes);
} catch (error) {
hilog.warn(DOMAIN, TAG, 'createMetadataOutput error');
}
this.metadataOutput = mOutput;
if (mOutput) {
mOutput.on('metadataObjectsAvailable', (_err: BusinessError,
metadataObjectArr: Array<camera.MetadataObject>) => {
this.hasFace = metadataObjectArr.length > 0;
if (this.cameraStarted) {
if (this.hasFace) {
this.statusText = '已检测到人脸,点击拍照';
} else {
this.statusText = '未检测到人脸,请正对摄像头';
}
}
});
mOutput.on('error', (err: BusinessError) => {
hilog.error(DOMAIN, TAG, `metadata error: ${err.code}`);
});
}
if (cameraOutputCapability.previewProfiles.length === 0) {
this.statusText = '无可用预览配置';
this.isCapturing = false;
this.cameraStarting = false;
return;
}
let previewProfile: camera.Profile = cameraOutputCapability.previewProfiles[0];
for (let i = 0; i < cameraOutputCapability.previewProfiles.length; i++) {
let p: camera.Profile = cameraOutputCapability.previewProfiles[i];
if (p.size.width <= 1920 && p.size.width >= 640) {
previewProfile = p;
break;
}
}
let pOutput: camera.PreviewOutput | undefined = undefined;
try {
pOutput = mgr.createPreviewOutput(previewProfile, this.xComponentSurfaceId);
} catch (error) {
this.statusText = '创建预览输出失败';
this.isCapturing = false;
this.cameraStarting = false;
return;
}
this.previewOutput = pOutput;
if (cameraOutputCapability.photoProfiles.length > 0) {
let photoProfile: camera.Profile = cameraOutputCapability.photoProfiles[0];
try {
this.photoOutput = mgr.createPhotoOutput(photoProfile);
} catch (error) {
hilog.error(DOMAIN, TAG, 'createPhotoOutput error');
}
}
if (this.photoOutput) {
this.photoOutput.on('photoAvailable', (err: BusinessError, photo: camera.Photo): void => {
if (err && err.code !== 0) {
hilog.error(DOMAIN, TAG, `photoAvailable error: ${err.code}`);
return;
}
if (!photo || !photo.main) {
return;
}
let imageObj: image.Image = photo.main;
imageObj.getComponent(image.ComponentType.JPEG, (errCode: BusinessError,
component: image.Component): void => {
if (errCode && errCode.code !== 0) {
imageObj.release();
return;
}
if (!component || !component.byteBuffer) {
imageObj.release();
return;
}
let buffer: ArrayBuffer = component.byteBuffer;
let imgSource: image.ImageSource = image.createImageSource(buffer);
imgSource.createPixelMap().then((pm: image.PixelMap) => {
imgSource.release();
imageObj.release();
this.onPhotoCaptured(pm);
}).catch((_e: BusinessError) => {
imgSource.release();
imageObj.release();
});
});
});
}
let session: camera.PhotoSession | undefined = undefined;
try {
session = mgr.createSession<camera.PhotoSession>(camera.SceneMode.NORMAL_PHOTO);
} catch (error) {
this.statusText = '创建相机会话失败';
this.isCapturing = false;
this.cameraStarting = false;
return;
}
this.cameraSession = session;
try {
session.beginConfig();
session.addInput(cInput);
if (pOutput) {
session.addOutput(pOutput);
}
if (mOutput) {
session.addOutput(mOutput);
}
if (this.photoOutput) {
session.addOutput(this.photoOutput);
}
await session.commitConfig();
await session.start();
if (mOutput) {
mOutput.start().then(() => {
hilog.info(DOMAIN, TAG, 'metadataOutput started');
}).catch((err: BusinessError) => {
hilog.error(DOMAIN, TAG, `metadataOutput start error: ${err.code}`);
});
}
this.cameraStarted = true;
this.cameraStarting = false;
this.statusText = '正在检测人脸...';
} catch (error) {
let err: BusinessError = error as BusinessError;
hilog.error(DOMAIN, TAG, `session error: ${err.code} ${err.message}`);
this.statusText = `相机启动失败: ${err.code}`;
this.isCapturing = false;
this.cameraStarting = false;
}
}
private async onPhotoCaptured(pm: image.PixelMap): Promise<void> {
await this.releaseCamera();
if (this.referencePixelMap) {
this.referencePixelMap.release();
this.referencePixelMap = undefined;
}
let detVisionInfo: faceDetector.VisionInfo = {
pixelMap: pm
};
try {
let faces: faceDetector.Face[] = await faceDetector.detect(detVisionInfo);
if (faces.length === 0) {
this.statusText = '拍照中未检测到人脸,请重试';
pm.release();
return;
}
} catch (e) {
this.statusText = '人脸检测失败,请重试';
pm.release();
return;
}
this.referencePixelMap = pm;
this.statusText = '人脸识别成功!点击"搜索相册"查找该人物照片';
}
private async captureAndSearch(): Promise<void> {
if (!this.photoOutput || !this.cameraStarted) {
this.statusText = '相机未就绪';
return;
}
this.statusText = '正在拍照...';
let capSettings: camera.PhotoCaptureSetting = {
quality: camera.QualityLevel.QUALITY_LEVEL_MEDIUM,
rotation: camera.ImageRotation.ROTATION_0
};
try {
await this.photoOutput.capture(capSettings);
} catch (error) {
this.statusText = '拍照失败';
}
}
private async releaseCamera(): Promise<void> {
try {
if (this.metadataOutput) {
this.metadataOutput.release();
this.metadataOutput = undefined;
}
if (this.cameraSession) {
this.cameraSession.stop();
this.cameraSession.release();
this.cameraSession = undefined;
}
if (this.previewOutput) {
this.previewOutput.release();
this.previewOutput = undefined;
}
if (this.photoOutput) {
this.photoOutput.release();
this.photoOutput = undefined;
}
if (this.cameraInput) {
this.cameraInput.close();
this.cameraInput = undefined;
}
} catch (error) {
let err: BusinessError = error as BusinessError;
hilog.error(DOMAIN, TAG, `releaseCamera error: ${err.code}`);
}
this.cameraStarted = false;
this.isCapturing = false;
this.cameraStarting = false;
this.hasFace = false;
}
private async scanAlbumForPerson(): Promise<void> {
if (!this.referencePixelMap) {
this.statusText = '未获取到参考人脸';
return;
}
this.isScanning = true;
this.scannedCount = 0;
this.statusText = '正在扫描相册...';
this.matchedPhotos = [];
let context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
let phAccessHelper: photoAccessHelper.PhotoAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
let predicates: dataSharePredicates.DataSharePredicates = new dataSharePredicates.DataSharePredicates();
predicates.orderByDesc(photoAccessHelper.PhotoKeys.DATE_ADDED);
let fetchOptions: photoAccessHelper.FetchOptions = {
fetchColumns: [photoAccessHelper.PhotoKeys.URI, photoAccessHelper.PhotoKeys.DISPLAY_NAME],
predicates: predicates
};
let fetchResult: photoAccessHelper.FetchResult<photoAccessHelper.PhotoAsset> | null = null;
try {
fetchResult = await phAccessHelper.getAssets(fetchOptions);
this.totalCount = fetchResult.getCount();
hilog.info(DOMAIN, TAG, `total photos: ${this.totalCount}`);
let allAssets: Array<photoAccessHelper.PhotoAsset> = await fetchResult.getAllObjects();
let refVisionInfo: faceComparator.VisionInfo = {
pixelMap: this.referencePixelMap
};
for (let i = 0; i < allAssets.length; i++) {
let asset: photoAccessHelper.PhotoAsset = allAssets[i];
this.scannedCount = i + 1;
this.statusText = `比对中 ${this.scannedCount}/${this.totalCount}`;
try {
let albumPm: image.PixelMap | undefined = await this.loadPixelMapFromAsset(asset);
if (!albumPm) {
continue;
}
let detVisionInfo: faceDetector.VisionInfo = {
pixelMap: albumPm
};
let faces: faceDetector.Face[] = await faceDetector.detect(detVisionInfo);
if (faces.length === 0) {
albumPm.release();
continue;
}
let albumVisionInfo: faceComparator.VisionInfo = {
pixelMap: albumPm
};
let compareResult: faceComparator.FaceCompareResult =
await faceComparator.compareFaces(refVisionInfo, albumVisionInfo);
albumPm.release();
if (compareResult.isSamePerson && compareResult.similarity >= SIMILARITY_THRESHOLD) {
let thumb: image.PixelMap | undefined = undefined;
try {
let size: image.Size = { width: 200, height: 200 };
thumb = await asset.getThumbnail(size);
} catch (e) {
hilog.warn(DOMAIN, TAG, `getThumbnail error`);
}
let item: PhotoItem = { uri: asset.uri, pixelMap: thumb };
this.matchedPhotos = [...this.matchedPhotos, item];
}
} catch (e) {
hilog.warn(DOMAIN, TAG, `compare error: ${(e as BusinessError).code}`);
}
}
this.statusText = `搜索完成,找到 ${this.matchedPhotos.length} 张含该人物的图片`;
} catch (error) {
let err: BusinessError = error as BusinessError;
hilog.error(DOMAIN, TAG, `scanAlbum error: ${err.code} ${err.message}`);
this.statusText = '扫描相册失败';
} finally {
if (fetchResult !== null) {
fetchResult.close();
}
this.isScanning = false;
}
}
private async loadPixelMapFromAsset(asset: photoAccessHelper.PhotoAsset): Promise<image.PixelMap | undefined> {
let fileSource = await fileIo.open(asset.uri, fileIo.OpenMode.READ_ONLY);
let imageSource: image.ImageSource = image.createImageSource(fileSource.fd);
let pm: image.PixelMap | undefined = undefined;
try {
pm = await imageSource.createPixelMap();
await fileIo.close(fileSource);
await imageSource.release();
} catch (e) {
try {
await fileIo.close(fileSource);
} catch (closeErr) {
// ignore
}
try {
await imageSource.release();
} catch (releaseErr) {
// ignore
}
return undefined;
}
return pm;
}
@Builder
previewOverlay() {
if (this.showPreview && this.matchedPhotos.length > this.previewIndex) {
Stack() {
Column() {
Row() {
Text('×')
.fontSize(24)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Bold)
.width(40)
.height(40)
.textAlign(TextAlign.Center)
.borderRadius(20)
.backgroundColor('#33333380')
.onClick(() => {
this.showPreview = false;
})
Text(`${this.previewIndex + 1} / ${this.matchedPhotos.length}`)
.fontSize(16)
.fontColor('#FFFFFF')
.layoutWeight(1)
.textAlign(TextAlign.Center)
Row()
.width(40)
.height(40)
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 8 })
Image(this.matchedPhotos[this.previewIndex].uri)
.width('100%')
.layoutWeight(1)
.objectFit(ImageFit.Contain)
.borderRadius(8)
Row() {
Button() { Text('‹').fontSize(22).fontColor('#FFFFFF') }
.width(56)
.height(56)
.borderRadius(28)
.backgroundColor('#33333380')
.enabled(this.previewIndex > 0)
.onClick(() => {
if (this.previewIndex > 0) {
this.previewIndex--;
}
})
Row()
.layoutWeight(1)
Button() { Text('›').fontSize(22).fontColor('#FFFFFF') }
.width(56)
.height(56)
.borderRadius(28)
.backgroundColor('#33333380')
.enabled(this.previewIndex < this.matchedPhotos.length - 1)
.onClick(() => {
if (this.previewIndex < this.matchedPhotos.length - 1) {
this.previewIndex++;
}
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 24, right: 24, top: 16, bottom: 32 })
}
.width('100%')
.height('100%')
}
.width('100%')
.height('100%')
.backgroundColor('#0A0A0AF0')
}
}
build() {
Stack() {
Column() {
if (this.isCapturing) {
Stack() {
XComponent({
type: XComponentType.SURFACE,
controller: this.mXComponentController
})
.backgroundColor('#1A1A2E')
.onLoad(() => {
let dw: number = display.getDefaultDisplaySync().width;
let dh: number = Math.floor(dw * 4 / 3);
this.mXComponentController.setXComponentSurfaceRect({
surfaceWidth: dw,
surfaceHeight: dh
});
this.xComponentSurfaceId = this.mXComponentController.getXComponentSurfaceId();
this.surfaceReady = true;
})
.width('100%')
.height('100%')
if (this.cameraStarting) {
Column() {
LoadingProgress()
.width(40)
.height(40)
.color('#FFFFFF')
Text('正在启动相机...')
.fontSize(14)
.fontColor('#FFFFFF')
.margin({ top: 8 })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#1A1A2ECC')
} else if (this.hasFace) {
Row() {
Text('✓')
.fontSize(12)
.fontColor('#FFFFFF')
.margin({ right: 4 })
Text('人脸已识别')
.fontSize(13)
.fontColor('#FFFFFF')
}
.backgroundColor('#4CAF50CC')
.borderRadius(16)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.position({ x: 12, y: 12 })
}
}
.width('100%')
.layoutWeight(1)
.clip(true)
.onAreaChange((_old: Area, newArea: Area) => {
this.previewAreaWidth = Number(newArea.width);
this.previewAreaHeight = Number(newArea.height);
})
} else if (this.referencePixelMap) {
Column() {
Row() {
Image(this.referencePixelMap)
.width(52)
.height(52)
.objectFit(ImageFit.Cover)
.borderRadius(26)
.border({ width: 3, color: '#4CAF50' })
Column() {
Text('已识别人脸')
.fontSize(15)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Medium)
Text('点击搜索相册中该人物的照片')
.fontSize(12)
.fontColor('#888888')
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 12 })
.alignItems(VerticalAlign.Center)
}
.width('100%')
}
if (this.isScanning) {
Column() {
Row() {
LoadingProgress()
.width(18)
.height(18)
.color('#4A90D9')
Text(this.statusText)
.fontSize(14)
.fontColor('#DDDDDD')
.margin({ left: 8 })
}
Progress({ value: this.scannedCount, total: Math.max(this.totalCount, 1), type: ProgressType.Linear })
.width('100%')
.color('#4A90D9')
.margin({ top: 8 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.backgroundColor('#16162A')
} else if (this.matchedPhotos.length > 0) {
Row() {
Text('搜索结果')
.fontSize(15)
.fontColor('#AAAAAA')
.fontWeight(FontWeight.Medium)
Text(`${this.matchedPhotos.length}`)
.fontSize(13)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Bold)
.backgroundColor('#FF6B35')
.borderRadius(10)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.margin({ left: 8 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
} else if (!this.isCapturing) {
Text(this.statusText)
.fontSize(14)
.fontColor('#888888')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 4, bottom: 4 })
}
if (this.matchedPhotos.length > 0) {
Grid() {
ForEach(this.matchedPhotos, (item: PhotoItem, index: number) => {
GridItem() {
Stack() {
if (item.pixelMap) {
Image(item.pixelMap)
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
} else {
Image(item.uri)
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
}
Column()
.width('100%')
.height('100%')
.borderRadius(12)
.border({ width: 1, color: '#FFFFFF15' })
}
.width('100%')
.height('100%')
.borderRadius(12)
.clip(true)
}
.aspectRatio(1)
.onClick(() => {
this.previewIndex = index;
this.showPreview = true;
})
}, (_item: PhotoItem, index: number) => `${index}`)
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.rowsGap(6)
.columnsGap(6)
.width('100%')
.layoutWeight(1)
.padding({ left: 16, right: 16, top: 4, bottom: 4 })
.cachedCount(12)
} else if (!this.isScanning && !this.isCapturing) {
Column() {
Text('📷')
.fontSize(56)
.margin({ bottom: 12 })
Text('点击拍照识别你的人脸')
.fontSize(16)
.fontColor('#AAAAAA')
.fontWeight(FontWeight.Medium)
Text('将在相册中搜索包含该人物的照片')
.fontSize(13)
.fontColor('#666666')
.margin({ top: 6 })
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
}
Column() {
if (this.isCapturing) {
Row() {
Button('拍照识人')
.fontSize(15)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Medium)
.backgroundColor(this.hasFace ? '#4CAF50' : '#555555')
.borderRadius(24)
.height(48)
.layoutWeight(1)
.enabled(this.hasFace && this.cameraStarted && !this.cameraStarting)
.onClick(() => {
this.captureAndSearch();
})
Button('取消')
.fontSize(15)
.fontColor('#CCCCCC')
.backgroundColor('#2A2A3A')
.borderRadius(24)
.height(48)
.margin({ left: 12 })
.onClick(() => {
this.releaseCamera();
this.statusText = '已取消';
})
}
} else {
Row() {
Button('拍照识人')
.fontSize(15)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Medium)
.backgroundColor('#4A90D9')
.borderRadius(24)
.height(48)
.layoutWeight(1)
.enabled(!this.isScanning)
.onClick(() => {
this.startCamera();
})
if (this.referencePixelMap && !this.isScanning) {
Button('搜索相册')
.fontSize(15)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Medium)
.backgroundColor('#FF6B35')
.borderRadius(24)
.height(48)
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => {
this.scanAlbumForPerson();
})
}
}
}
}
.width('100%')
.padding({ left: 20, right: 20, top: 14, bottom: 20 })
.backgroundColor('#16162A')
}
.width('100%')
.height('100%')
.backgroundColor('#0D0D1A')
this.previewOverlay()
}
.width('100%')
.height('100%')
}
}
总结
"拍照识人搜相册"的核心不是某个单一能力,而是 faceDetector 和 faceComparator 两个 API 的配合使用。
faceDetector 负责两件事:一是验证拍照结果中是否有人脸(参考人脸验证),二是过滤相册中无人脸的照片(减少无效比对)。faceComparator 只做一件事:拿两张有人脸的照片比相似度。
相册遍历的性能瓶颈在 loadPixelMapFromAsset——每张照片都要从 URI 加载到 PixelMap 才能做人脸检测。如果相册有几千张照片,全量遍历会很慢。实际项目中可以考虑:先按时间范围缩小查询、用缩略图做粗筛、或做分批加载。但核心的"检测 → 比对"两步流程是不变的。
整条链路就是:faceDetector.init() + faceComparator.init() 初始化 → 相机拍照 + MetadataOutput 人脸检测 → faceDetector.detect() 验证参考人脸 → 遍历相册 detect() + compareFaces() 比对 → 相似度达标入结果。两个 API,一条链路,拍照搜人就搞定了。
更多推荐

所有评论(0)