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 单次分析变慢或暂时失败,基础预览、拍照和镜头切换仍能保持可用。这才是实时趣味相机在不同设备上稳定运行的工程前提。