HarmonyOS趣味相机实战第16篇:实时识别采样、Busy锁与Generation并发治理

HarmonyOS趣味相机实战第16篇:实时识别采样、Busy锁与Generation并发治理

摘要

在相机预览上做人体识别,最直接的实现是不断从 Surface 创建 PixelMap,再调用 CoreVisionKit。这个方案能跑起来,却容易在真机上出现预览掉帧、多个识别任务重叠、切换镜头后旧结果回写、页面退出仍持有 PixelMap 等问题。

本文基于 D:/APP/1quweixiangji 趣味相机工程,拆解 CameraPreviewService.ets 中的实时识别调度器。当前实现使用 1200ms 周期、Busy 锁、Generation 代际和 finally 释放 PixelMap,并将相机人脸元数据、实时骨骼结果与拍照分析结果按新鲜度融合。我们进一步给出可观测性、超时、背压和测试方案,让识别增强功能不会反过来拖垮基础拍照链路。

工程背景与源码定位

文件 责任 本文关注点
entry/src/main/ets/service/CameraPreviewService.ets 相机会话、预览、拍照和目标通知 采样调度与目标融合
entry/src/main/ets/service/CoreVisionHumanService.ets 骨骼检测、分割和坐标标准化 单次分析任务
entry/src/main/ets/service/HumanFittingService.ets 将目标映射为装饰锚点 消费最新识别结果
entry/src/main/ets/model/DecorationModels.ets 目标、关键点和来源类型 融合后的统一协议
entry/src/main/ets/pages/Index.ets XComponent Surface 与页面生命周期 启停识别的入口
entry/src/main/module.json5 CAMERA 权限和 EntryAbility 运行边界

环境、常量与版本

项目 当前值 说明
应用版本 1.0.4 AppScope/app.json5
target SDK 6.0.2(22) 当前构建配置
预览逻辑尺寸 316 x 390 目标统一坐标
实时采样周期 1200ms LIVE_VISION_INTERVAL_MS
实时结果新鲜期 2200ms LIVE_VISION_FRESH_MS
人脸结果新鲜期 1800ms FACE_METADATA_FRESH_MS
首次采样延迟 500ms 预览稳定后再截帧
停止等待上限 1200ms 等待进行中的任务收尾

一、不要把识别绑在每一帧回调上

相机预览可能每秒输出 30 或 60 帧,而人体识别通常不需要相同频率。如果每帧都创建 PixelMap 并分析,会形成:

text 复制代码
Surface 帧到达速度
  > PixelMap 创建速度
  > CoreVision 分析速度
  > UI 消费速度

任务队列不断累积,最终表现为:

  • 预览掉帧。
  • 内存持续升高。
  • 识别结果明显滞后。
  • 切换镜头后仍收到旧画面结果。
  • 页面退出后异步任务继续运行。

实时装饰只需要"足够新"的目标,不需要处理所有帧。因此应主动采样,而不是被预览帧频率驱动。

二、状态字段表达调度器的真实状态

项目维护以下字段:

ts 复制代码
private static liveVisionActive: boolean = false;
private static liveVisionBusy: boolean = false;
private static liveVisionTimer: number = 0;
private static liveVisionGeneration: number = 0;
private static liveVisionSurfaceId: string = '';
private static lastLiveVisionAt: number = 0;
private static lastLiveVisionTargets: CameraDetectedTarget[] = [];

每个字段解决不同问题:

字段 含义 不能被谁替代
active 调度器是否允许继续运行 Timer 是否存在
busy 当前是否正在分析 active
timer 尚未触发的下一次任务 busy
generation 本轮调度身份 Surface ID
surfaceId 当前截帧来源 generation
lastAt 最近成功结果时间 任务开始时间
lastTargets 最近有效目标 UI 当前状态

布尔值看似较多,但它们分别描述许可、执行、等待和结果,不应压缩成一个含义模糊的 isRunning

三、启动时先取消旧调度

启动入口:

ts 复制代码
private static startLiveVisionSampling(surfaceId: string): void {
  CameraPreviewService.cancelLiveVisionSampling();
  CameraPreviewService.liveVisionActive = true;
  CameraPreviewService.liveVisionSurfaceId = surfaceId;
  CameraPreviewService.liveVisionGeneration += 1;
  CameraPreviewService.scheduleLiveVisionSample(
    500,
    CameraPreviewService.liveVisionGeneration
  );
}

先取消旧调度很重要。预览重建、前后镜头切换或 Surface 重新创建都可能再次调用启动函数。如果只覆盖 surfaceId,旧 Timer 到点后会读取新 Surface,任务归属变得不清晰。

首次延迟 500ms 的目的不是固定等待 UI,而是给相机会话和 Surface 一个稳定窗口。更完善的实现可等待 PreviewSession 明确进入 running,再安排首次采样。

四、Generation 让旧任务自动失效

取消时递增代际:

ts 复制代码
private static cancelLiveVisionSampling(): void {
  CameraPreviewService.liveVisionActive = false;
  CameraPreviewService.liveVisionSurfaceId = '';
  CameraPreviewService.liveVisionGeneration += 1;
  if (CameraPreviewService.liveVisionTimer > 0) {
    clearTimeout(CameraPreviewService.liveVisionTimer);
    CameraPreviewService.liveVisionTimer = 0;
  }
}

调度和执行都验证代际:

ts 复制代码
if (!CameraPreviewService.liveVisionActive ||
    generation !== CameraPreviewService.liveVisionGeneration) {
  return;
}

为什么清除 Timer 还不够?因为已经进入 await analyzePixelMap() 的任务无法被 clearTimeout 撤回。Generation 是逻辑取消令牌,异步任务恢复后发现自己属于旧代际,就不能再影响新会话。

除了函数入口,提交结果前也应该再次验证:

ts 复制代码
const targets = await analyze(pixelMap);
if (!liveVisionActive || generation !== liveVisionGeneration) {
  return;
}
lastLiveVisionTargets = toCameraTargets(targets);
lastLiveVisionAt = Date.now();

这一步能防止旧分析结果覆盖新镜头结果。

五、Busy锁提供背压而不是排队

采样函数先检查:

ts 复制代码
if (CameraPreviewService.liveVisionBusy ||
    CameraPreviewService.liveVisionSurfaceId.length === 0) {
  CameraPreviewService.scheduleLiveVisionSample(
    LIVE_VISION_INTERVAL_MS,
    generation
  );
  return;
}

如果上一次识别尚未完成,本轮直接跳过,不创建第二个任务。这叫丢帧背压:宁愿分析较新的下一帧,也不排队处理已经过时的旧帧。

对实时预览来说,"最新结果"比"每帧必达"更重要。日志或离线视频分析可能需要队列,但相机贴纸不适合无界排队。

六、使用递归setTimeout而不是setInterval

项目在每次任务结束后安排下一次:

ts 复制代码
private static scheduleLiveVisionSample(
  delayMs: number,
  generation: number
): void {
  if (!liveVisionActive || generation !== liveVisionGeneration) {
    return;
  }
  liveVisionTimer = setTimeout(() => {
    CameraPreviewService.sampleLiveVision(generation);
  }, delayMs);
}

任务 finally 中再次调度:

ts 复制代码
finally {
  liveVisionBusy = false;
  scheduleLiveVisionSample(LIVE_VISION_INTERVAL_MS, generation);
}

setInterval(1200) 相比,这种方式的间隔从"上一次完成"开始计算。假设识别耗时 700ms:

text 复制代码
setInterval: 0ms -> 1200ms -> 2400ms
递归Timeout: 0ms开始 -> 700ms完成 -> 1900ms下一次开始

递归 Timeout 天然不会因为任务变慢而堆积触发信号。

七、一次采样必须形成完整资源闭环

核心流程:

ts 复制代码
let pixelMap: image.PixelMap | null = null;
liveVisionBusy = true;
try {
  pixelMap = await image.createPixelMapFromSurface(liveVisionSurfaceId);
  const targets: DetectedTarget[] =
    await CoreVisionHumanService.analyzePixelMap(
      pixelMap,
      previewRotation,
      activeCameraPosition === 'front'
    );
  const cameraTargets = toCameraTargets(targets);
  lastLiveVisionTargets = cameraTargets;
  lastLiveVisionAt = Date.now();
  if (cameraTargets.length > 0) {
    notifyTargets(cameraTargets);
  }
} catch (error) {
  hilog.warn(DOMAIN, TAG, 'live preview vision analysis failed');
} finally {
  if (pixelMap !== null) {
    try {
      await pixelMap.release();
    } catch (releaseError) {
      hilog.warn(DOMAIN, TAG, 'release live preview pixelMap failed');
    }
  }
  liveVisionBusy = false;
  scheduleLiveVisionSample(LIVE_VISION_INTERVAL_MS, generation);
}

关键规则:

  • busy 在创建 PixelMap 前设置。
  • PixelMap 只归本次任务所有。
  • 释放放在 finally
  • 释放异常不能阻断 Busy 复位。
  • 分析异常不能终止整个调度器。
  • 下一次调度也放在 finally

八、为什么PixelMap不能缓存复用

createPixelMapFromSurface() 得到的是某个时间点的图像资源。长期持有会增加 native 内存压力,也可能让后续识别一直消费旧图。

实时采样应遵循单任务所有权:

text 复制代码
创建 PixelMap
  -> 分析
  -> 转换为轻量目标数据
  -> 释放 PixelMap

UI 层只接收 CameraDetectedTarget[],不持有采样 PixelMap。拍照结果预览如果需要 PixelMap,应使用另一套明确的所有权和释放流程。

九、停止调度要处理正在执行的任务

项目取消后最多等待 1200ms:

ts 复制代码
private static async stopLiveVisionSampling(): Promise<void> {
  CameraPreviewService.cancelLiveVisionSampling();
  const deadline: number = Date.now() + 1200;
  while (CameraPreviewService.liveVisionBusy &&
         Date.now() < deadline) {
    await new Promise<void>((resolve: () => void) => {
      setTimeout(() => resolve(), 40);
    });
  }
}

这个等待不是为了允许旧结果提交,而是尽量让 PixelMap 完成释放,再继续关闭 CameraInput、Session 和 Output。

生产增强可以把当前任务 Promise 保存下来:

ts 复制代码
private static liveVisionTask: Promise<void> | null = null;

static async stop(): Promise<void> {
  cancel();
  const task = liveVisionTask;
  if (task !== null) {
    await Promise.race([task, timeout(1200)]);
  }
}

这样比 40ms 轮询更清晰,也便于测试。

十、结果新鲜期防止无限使用旧目标

项目设置:

ts 复制代码
const LIVE_VISION_FRESH_MS = 2200;
const FACE_METADATA_FRESH_MS = 1800;

判断实时结果是否仍可用:

ts 复制代码
const liveVisionFresh =
  lastLiveVisionTargets.length > 0 &&
  Date.now() - lastLiveVisionAt <= LIVE_VISION_FRESH_MS;

采样周期为 1200ms,新鲜期 2200ms 可以容忍一次采样失败,但不会在人物离开后长时间保留贴纸。人脸元数据频率通常更高,因此新鲜期设置为 1800ms。

"新鲜"只代表时间可接受,还要检查结果质量和来源。

十一、多来源目标不能简单拼接

系统存在三类数据:

来源 优势 局限
相机人脸 Metadata 频率高、面部稳定 缺少全身关键点
CoreVision 骨骼 肩、手、膝等点丰富 耗时较高
人体分割 有轮廓 不适合单独做人脸锚点

项目的优选顺序是:

text 复制代码
当前回调是人脸元数据 -> 记录并返回
有可靠骨骼 -> 与新鲜人脸融合后返回
没有可靠骨骼 -> 尝试新鲜实时骨骼
只有新鲜人脸 -> 返回人脸
最后过滤不适合单独展示的分割目标

这不是数组 concat。同一个人同时出现在人脸和骨骼数组中,直接拼接会让页面认为有两个人。

十二、用空间匹配融合人脸与骨骼

项目按重叠、中心距离和包含关系打分:

ts 复制代码
const overlap = rectOverlap(faceTarget, poseTarget);
const dx = (poseTarget.centerX - faceX) / PREVIEW_FRAME_WIDTH;
const dy = (poseTarget.centerY - faceY) / PREVIEW_FRAME_HEIGHT;
const distanceScore = 1 - Math.sqrt(dx * dx + dy * dy);
const containsFace = pointInsideTarget(faceX, faceY, poseTarget);
const score = overlap +
  distanceScore * 20000 +
  (containsFace ? 30000 : 0);

选出最高分后还要做拒绝条件:

ts 复制代码
if (!pointInsideTarget(faceCenterX, faceCenterY, bestTarget) &&
    centerDistance(faceTarget, bestTarget) >
      PREVIEW_FRAME_WIDTH * 0.34) {
  return null;
}

拒绝错误融合比强行得到结果更重要。多人相邻时,错误地把 A 的脸与 B 的身体合并,会让贴纸跳到另一个人身上。

十三、融合时按字段选择权威来源

融合结果使用人脸框作为主体,又保留骨骼身体点:

ts 复制代码
const keypoints: BodyKeypoint[] = [];

faceKeypoints.forEach((point: BodyKeypoint) => {
  if (isFaceKeypoint(point.name)) {
    keypoints.push(point);
  }
});

poseKeypoints.forEach((point: BodyKeypoint) => {
  if (!isFaceKeypoint(point.name)) {
    keypoints.push(point);
  }
});

字段权威表:

字段 权威来源
人脸中心、宽高 Camera Metadata
眼、鼻、嘴 人脸目标
肩、肘、腕、髋、膝、踝 骨骼目标
目标 ID 人脸目标,便于高频稳定
置信度 两者最大值或分字段置信度
fitSource 融合后标记 poseKeypoints

十四、空结果也需要策略

当前采样只有 cameraTargets.length > 0 时通知,能减少短暂漏检导致的闪烁。但如果连续为空,页面需要最终清除旧目标。

可以增加空结果计数:

ts 复制代码
private static emptySampleCount: number = 0;

if (cameraTargets.length > 0) {
  emptySampleCount = 0;
  notifyTargets(cameraTargets);
} else {
  emptySampleCount += 1;
  if (emptySampleCount >= 2) {
    notifyTargets([]);
  }
}

两次空结果约等于 2.4 秒,与 2200ms 新鲜期接近。更稳妥的方式是统一按 lastLiveVisionAt 判断,而不是同时维护两套清除标准。

十五、动态采样周期降低设备压力

固定 1200ms 是可用基线,但可以根据耗时调整:

ts 复制代码
function nextDelay(
  analysisCostMs: number,
  appInForeground: boolean,
  hasFollowerLayers: boolean
): number {
  if (!appInForeground || !hasFollowerLayers) {
    return 3000;
  }
  if (analysisCostMs > 900) {
    return 1800;
  }
  if (analysisCostMs < 350) {
    return 900;
  }
  return 1200;
}

没有跟随型贴纸时,可以暂停骨骼采样,仅保留低成本人脸元数据;应用进入后台必须完全停止。

十六、加入可观测性但不记录图像

建议记录以下聚合字段:

ts 复制代码
interface VisionMetrics {
  generation: number;
  sampleStartedAt: number;
  sampleCostMs: number;
  targetCount: number;
  skippedByBusy: boolean;
  source: 'faceMetadata' | 'poseKeypoints' | 'merged' | 'none';
  errorCode?: number;
}

不要记录 PixelMap、用户人脸坐标明细、照片路径或水印地点。日志用于定位性能和错误,不应成为用户图像数据的旁路存储。

关键指标包括:

  • P50/P95 分析耗时。
  • Busy 跳过比例。
  • PixelMap 创建失败率。
  • 目标为空比例。
  • 旧代际结果丢弃次数。
  • 前置和后置镜头分别的错误率。

十七、调度器纯函数与集成测试

纯函数测试:

  • generation 不一致时不调度。
  • active 为 false 时不调度。
  • Busy 为 true 时跳过本轮。
  • 结果超过 2200ms 后被判定过期。
  • 人脸与骨骼距离过大时拒绝融合。
  • 融合结果保留面部点和身体点且不重复。
  • 空目标不会使用分割框冒充可靠骨骼。

真机集成测试:

text 复制代码
启动预览并启用跟随贴纸
  -> 观察采样周期和识别耗时
  -> 连续切换前后镜头 10 次
  -> 确认旧代际结果未回写
  -> 快速切换 Tab 和返回页面
  -> 确认 Timer 与 PixelMap 均释放
  -> 人物离开画面
  -> 旧目标在新鲜期后清除

可在 Debug 构建中提供采样计数器,但正式 UI 不展示内部技术状态。

十八、常见问题排查

现象 可能原因 排查方式
预览明显掉帧 采样频率过高或任务重叠 查看 Busy 跳过率与分析耗时
切镜头后贴纸短暂反向 旧代际结果回写 提交结果前再次检查 generation
页面退出后内存不降 PixelMap 未在 finally 释放 检查所有异常分支
识别越来越滞后 使用 setInterval 并排队 改为完成后递归 setTimeout
两个人被识别成三个目标 人脸和骨骼数组直接拼接 执行空间匹配和字段融合
人物离开后贴纸仍保留 没有结果新鲜期 使用 lastAt 过期判断
暂时漏检时贴纸闪烁 空结果立即清空 短时保留最近有效结果
停止预览偶发卡住 无限等待识别任务 设置停止等待上限

十九、上线前验收清单

  • 识别采用主动采样,不绑定每一预览帧。
  • Busy 时直接跳过,不积压任务。
  • 使用递归 Timeout,任务完成后再计时。
  • 每轮启动都获得新的 generation。
  • 任务开始和提交结果前均检查 generation。
  • PixelMap 在 finally 中逐次释放。
  • 页面退出和 Surface 销毁会取消 Timer。
  • 停止流程有明确等待上限。
  • 实时结果与人脸结果都有新鲜期。
  • 人脸与骨骼按空间关系融合而非拼接。
  • 多人场景存在错误匹配拒绝阈值。
  • 应用后台或无跟随贴纸时降低采样压力。
  • 日志只记录耗时、数量和错误码,不记录图像。
  • 真机完成连续切镜头和页面进出压力测试。

总结

实时识别调度的核心不是"每隔多久调用一次模型",而是定义背压、取消、资源所有权和结果有效期。Busy 锁避免任务重叠,Generation 阻止旧会话回写,递归 Timeout 避免触发积压,finally 保证 PixelMap 释放,新鲜期和空间匹配则让人脸、骨骼与分割结果形成稳定输出。

把识别当作相机预览的可降级增强能力后,即使 CoreVision 单次分析变慢或暂时失败,基础预览、拍照和镜头切换仍能保持可用。这才是实时趣味相机在不同设备上稳定运行的工程前提。

相关推荐
xd18557855513 小时前
表情包配文-基于鸿蒙的AI表情包配文生成应用开发实践
人工智能·华为·harmonyos·鸿蒙
AD022714 小时前
39-设置开关退出就复原-用偏好写队列和回滚状态兜住
harmonyos·arkts·鸿蒙开发
不羁的木木14 小时前
HarmonyOS技术精讲-Connectivity Kit:蓝牙基础——经典蓝牙与BLE入门
华为·harmonyos
不羁的木木15 小时前
HarmonyOS技术精讲-Connectivity Kit:自动设备发现与一键配网
华为·harmonyos
雪芽蓝域zzs15 小时前
HarmonyOS开发 指纹验证和ESDSA加密
华为·harmonyos
念雨思15 小时前
HarmonyOS AI 应用开发实战:短视频选题灵感 —— AI 驱动的内容创作引擎
人工智能·学习·华为·harmonyos·鸿蒙
tyqtyq2219 小时前
HarmonyOS AI 应用开发实战:考研择校分析系统
人工智能·学习·考研·华为·生活·harmonyos
ldsweet19 小时前
《HarmonyOS技术精讲-Basic Services Kit》USB服务:设备发现与通信
华为·harmonyos
程序员黑豆19 小时前
从零开始:编写第一个鸿蒙(HarmonyOS)程序
前端·harmonyos