前言

“拍一张照,然后从相册里找出所有包含这个人的照片”——这个需求听起来复杂,但 HarmonyOS 的 CoreVisionKit 已经把核心能力封装好了:faceDetector 负责检测人脸,faceComparator 负责比对人脸相似度。你不需要训练模型,不需要对接云端,两个 API 就够了。

这篇文章围绕一个完整的"拍照识人搜相册"案例,把从相机拍照、人脸检测、相册遍历到人脸比对的整条链路拆开讲。每个环节的代码我都会说明"为什么这样写",文末有完整源码,可以直接取用。

效果预览

主要流程

整个流程可以压缩成 5 步:

  1. 初始化 faceDetectorfaceComparator,页面出现时一次性完成。
  2. 启动前置相机预览,用 MetadataOutput 实时检测人脸,检测到人脸后才允许拍照。
  3. 拍照后用 faceDetector.detect() 验证照片中确实有人脸,通过后保存为参考人脸。
  4. 遍历相册所有照片,对每张先用 faceDetector.detect() 检测人脸,再对有人脸的照片用 faceComparator.compareFaces() 和参考人脸比对。
  5. 相似度 ≥ 0.5 且 isSamePerson = true 的照片加入结果列表。

后面所有代码,都是围绕这条链路展开的。

开始之前:权限和导入

这个案例需要两个权限:相机权限用于拍照,相册读取权限用于遍历照片。在 module.json5requestPermissions 里声明:

{
  "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';

faceDetectorfaceComparator 来自 @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();
      });
    });
  });
}

这条数据提取链路比较长,拆开看:

  1. photo.main:拍照返回的 Photo 对象,main 是主图(JPEG 格式的 image.Image
  2. imageObj.getComponent(JPEG):从 Image 中提取 JPEG 组件,拿到 byteBuffer
  3. image.createImageSource(buffer):用 JPEG buffer 创建 ImageSource
  4. imgSource.createPixelMap():从 ImageSource 创建 PixelMap

每一步都要释放对应的资源——imageObjimgSource 用完就 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 = '人脸识别成功!点击"搜索相册"查找该人物照片';
}

这一步做了三件事:

  1. 释放相机资源:拍完照后立即释放相机,回到非拍照状态。相机是独占资源,不释放的话其他功能可能受限。
  2. 释放旧参考人脸:如果之前已有参考人脸(重复拍照场景),先释放旧的 PixelMap。
  3. 验证人脸faceDetector.detect() 返回 Face[] 数组,空数组表示无人脸。验证通过后才把 PixelMap 存为 referencePixelMap

为什么不能跳过验证直接存参考人脸? 因为 MetadataOutput 的人脸检测和实际拍出来的照片有时间差——预览帧显示有人脸,但按下快门的那一瞬间人可能已经移开了。多一次 faceDetector.detect() 验证,确保参考人脸是有效的。

faceDetector.VisionInfo 的结构

let detVisionInfo: faceDetector.VisionInfo = {
  pixelMap: pm
};

VisionInfo 只有一个必填字段 pixelMapfaceDetector.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}`);
  }
}

每张照片的处理分四步:

  1. 加载 PixelMaploadPixelMapFromAsset() 从 URI 加载图片到 PixelMap
  2. 检测人脸faceDetector.detect() 判断照片是否有人脸,无人脸直接跳过
  3. 比对相似度faceComparator.compareFaces() 和参考人脸比对
  4. 收集结果:相似度达标时,获取缩略图加入结果列表

为什么必须先检测再比对

faceComparator.compareFaces() 不会自动做人脸检测——它假设传入的图片中已经有人脸。如果直接把无人脸的照片传给 compareFaces(),要么抛异常,要么返回无效结果。

所以流程是:先 detect() 过滤掉无人脸的照片,再对有人脸的照片 compareFaces() 比对。这个两步设计不是冗余的——检测和比对是两个独立的操作,比对更耗时,先过滤能大幅减少比对次数。

SIMILARITY_THRESHOLD = 0.5 的选择

const SIMILARITY_THRESHOLD = 0.5;

if (compareResult.isSamePerson && compareResult.similarity >= SIMILARITY_THRESHOLD) {

比对结果 FaceCompareResult 有两个关键字段:

字段类型说明
isSamePersonboolean引擎判定是否为同一人
similaritynumber相似度分数,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 组件实时显示扫描进度,totalMath.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%')
  }
}

总结

"拍照识人搜相册"的核心不是某个单一能力,而是 faceDetectorfaceComparator 两个 API 的配合使用。

faceDetector 负责两件事:一是验证拍照结果中是否有人脸(参考人脸验证),二是过滤相册中无人脸的照片(减少无效比对)。faceComparator 只做一件事:拿两张有人脸的照片比相似度。

相册遍历的性能瓶颈在 loadPixelMapFromAsset——每张照片都要从 URI 加载到 PixelMap 才能做人脸检测。如果相册有几千张照片,全量遍历会很慢。实际项目中可以考虑:先按时间范围缩小查询、用缩略图做粗筛、或做分批加载。但核心的"检测 → 比对"两步流程是不变的。

整条链路就是:faceDetector.init() + faceComparator.init() 初始化 → 相机拍照 + MetadataOutput 人脸检测 → faceDetector.detect() 验证参考人脸 → 遍历相册 detect() + compareFaces() 比对 → 相似度达标入结果。两个 API,一条链路,拍照搜人就搞定了。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐