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 并分析,会形成:
Surface 帧到达速度 > PixelMap 创建速度 > CoreVision 分析速度 > UI 消费速度任务队列不断累积,最终表现为:
- 预览掉帧。
- 内存持续升高。
- 识别结果明显滞后。
- 切换镜头后仍收到旧画面结果。
- 页面退出后异步任务继续运行。
实时装饰只需要“足够新”的目标,不需要处理所有帧。因此应主动采样,而不是被预览帧频率驱动。
二、状态字段表达调度器的真实状态
项目维护以下字段:
privatestaticliveVisionActive:boolean=false;privatestaticliveVisionBusy:boolean=false;privatestaticliveVisionTimer:number=0;privatestaticliveVisionGeneration:number=0;privatestaticliveVisionSurfaceId:string='';privatestaticlastLiveVisionAt:number=0;privatestaticlastLiveVisionTargets:CameraDetectedTarget[]=[];每个字段解决不同问题:
| 字段 | 含义 | 不能被谁替代 |
|---|---|---|
active | 调度器是否允许继续运行 | Timer 是否存在 |
busy | 当前是否正在分析 | active |
timer | 尚未触发的下一次任务 | busy |
generation | 本轮调度身份 | Surface ID |
surfaceId | 当前截帧来源 | generation |
lastAt | 最近成功结果时间 | 任务开始时间 |
lastTargets | 最近有效目标 | UI 当前状态 |
布尔值看似较多,但它们分别描述许可、执行、等待和结果,不应压缩成一个含义模糊的isRunning。
三、启动时先取消旧调度
启动入口:
privatestaticstartLiveVisionSampling(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 让旧任务自动失效
取消时递增代际:
privatestaticcancelLiveVisionSampling():void{CameraPreviewService.liveVisionActive=false;CameraPreviewService.liveVisionSurfaceId='';CameraPreviewService.liveVisionGeneration+=1;if(CameraPreviewService.liveVisionTimer>0){clearTimeout(CameraPreviewService.liveVisionTimer);CameraPreviewService.liveVisionTimer=0;}}调度和执行都验证代际:
if(!CameraPreviewService.liveVisionActive||generation!==CameraPreviewService.liveVisionGeneration){return;}为什么清除 Timer 还不够?因为已经进入await analyzePixelMap()的任务无法被clearTimeout撤回。Generation 是逻辑取消令牌,异步任务恢复后发现自己属于旧代际,就不能再影响新会话。
除了函数入口,提交结果前也应该再次验证:
consttargets=awaitanalyze(pixelMap);if(!liveVisionActive||generation!==liveVisionGeneration){return;}lastLiveVisionTargets=toCameraTargets(targets);lastLiveVisionAt=Date.now();这一步能防止旧分析结果覆盖新镜头结果。
五、Busy锁提供背压而不是排队
采样函数先检查:
if(CameraPreviewService.liveVisionBusy||CameraPreviewService.liveVisionSurfaceId.length===0){CameraPreviewService.scheduleLiveVisionSample(LIVE_VISION_INTERVAL_MS,generation);return;}如果上一次识别尚未完成,本轮直接跳过,不创建第二个任务。这叫丢帧背压:宁愿分析较新的下一帧,也不排队处理已经过时的旧帧。
对实时预览来说,“最新结果”比“每帧必达”更重要。日志或离线视频分析可能需要队列,但相机贴纸不适合无界排队。
六、使用递归setTimeout而不是setInterval
项目在每次任务结束后安排下一次:
privatestaticscheduleLiveVisionSample(delayMs:number,generation:number):void{if(!liveVisionActive||generation!==liveVisionGeneration){return;}liveVisionTimer=setTimeout(()=>{CameraPreviewService.sampleLiveVision(generation);},delayMs);}任务finally中再次调度:
finally{liveVisionBusy=false;scheduleLiveVisionSample(LIVE_VISION_INTERVAL_MS,generation);}与setInterval(1200)相比,这种方式的间隔从“上一次完成”开始计算。假设识别耗时 700ms:
setInterval: 0ms -> 1200ms -> 2400ms 递归Timeout: 0ms开始 -> 700ms完成 -> 1900ms下一次开始递归 Timeout 天然不会因为任务变慢而堆积触发信号。
七、一次采样必须形成完整资源闭环
核心流程:
letpixelMap:image.PixelMap|null=null;liveVisionBusy=true;try{pixelMap=awaitimage.createPixelMapFromSurface(liveVisionSurfaceId);consttargets:DetectedTarget[]=awaitCoreVisionHumanService.analyzePixelMap(pixelMap,previewRotation,activeCameraPosition==='front');constcameraTargets=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{awaitpixelMap.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 内存压力,也可能让后续识别一直消费旧图。
实时采样应遵循单任务所有权:
创建 PixelMap -> 分析 -> 转换为轻量目标数据 -> 释放 PixelMapUI 层只接收CameraDetectedTarget[],不持有采样 PixelMap。拍照结果预览如果需要 PixelMap,应使用另一套明确的所有权和释放流程。
九、停止调度要处理正在执行的任务
项目取消后最多等待 1200ms:
privatestaticasyncstopLiveVisionSampling():Promise<void>{CameraPreviewService.cancelLiveVisionSampling();constdeadline:number=Date.now()+1200;while(CameraPreviewService.liveVisionBusy&&Date.now()<deadline){awaitnewPromise<void>((resolve:()=>void)=>{setTimeout(()=>resolve(),40);});}}这个等待不是为了允许旧结果提交,而是尽量让 PixelMap 完成释放,再继续关闭 CameraInput、Session 和 Output。
生产增强可以把当前任务 Promise 保存下来:
privatestaticliveVisionTask:Promise<void>|null=null;staticasyncstop():Promise<void>{cancel();consttask=liveVisionTask;if(task!==null){awaitPromise.race([task,timeout(1200)]);}}这样比 40ms 轮询更清晰,也便于测试。
十、结果新鲜期防止无限使用旧目标
项目设置:
constLIVE_VISION_FRESH_MS=2200;constFACE_METADATA_FRESH_MS=1800;判断实时结果是否仍可用:
constliveVisionFresh=lastLiveVisionTargets.length>0&&Date.now()-lastLiveVisionAt<=LIVE_VISION_FRESH_MS;采样周期为 1200ms,新鲜期 2200ms 可以容忍一次采样失败,但不会在人物离开后长时间保留贴纸。人脸元数据频率通常更高,因此新鲜期设置为 1800ms。
“新鲜”只代表时间可接受,还要检查结果质量和来源。
十一、多来源目标不能简单拼接
系统存在三类数据:
| 来源 | 优势 | 局限 |
|---|---|---|
| 相机人脸 Metadata | 频率高、面部稳定 | 缺少全身关键点 |
| CoreVision 骨骼 | 肩、手、膝等点丰富 | 耗时较高 |
| 人体分割 | 有轮廓 | 不适合单独做人脸锚点 |
项目的优选顺序是:
当前回调是人脸元数据 -> 记录并返回 有可靠骨骼 -> 与新鲜人脸融合后返回 没有可靠骨骼 -> 尝试新鲜实时骨骼 只有新鲜人脸 -> 返回人脸 最后过滤不适合单独展示的分割目标这不是数组concat。同一个人同时出现在人脸和骨骼数组中,直接拼接会让页面认为有两个人。
十二、用空间匹配融合人脸与骨骼
项目按重叠、中心距离和包含关系打分:
constoverlap=rectOverlap(faceTarget,poseTarget);constdx=(poseTarget.centerX-faceX)/PREVIEW_FRAME_WIDTH;constdy=(poseTarget.centerY-faceY)/PREVIEW_FRAME_HEIGHT;constdistanceScore=1-Math.sqrt(dx*dx+dy*dy);constcontainsFace=pointInsideTarget(faceX,faceY,poseTarget);constscore=overlap+distanceScore*20000+(containsFace?30000:0);选出最高分后还要做拒绝条件:
if(!pointInsideTarget(faceCenterX,faceCenterY,bestTarget)&¢erDistance(faceTarget,bestTarget)>PREVIEW_FRAME_WIDTH*0.34){returnnull;}拒绝错误融合比强行得到结果更重要。多人相邻时,错误地把 A 的脸与 B 的身体合并,会让贴纸跳到另一个人身上。
十三、融合时按字段选择权威来源
融合结果使用人脸框作为主体,又保留骨骼身体点:
constkeypoints: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时通知,能减少短暂漏检导致的闪烁。但如果连续为空,页面需要最终清除旧目标。
可以增加空结果计数:
privatestaticemptySampleCount:number=0;if(cameraTargets.length>0){emptySampleCount=0;notifyTargets(cameraTargets);}else{emptySampleCount+=1;if(emptySampleCount>=2){notifyTargets([]);}}两次空结果约等于 2.4 秒,与 2200ms 新鲜期接近。更稳妥的方式是统一按lastLiveVisionAt判断,而不是同时维护两套清除标准。
十五、动态采样周期降低设备压力
固定 1200ms 是可用基线,但可以根据耗时调整:
functionnextDelay(analysisCostMs:number,appInForeground:boolean,hasFollowerLayers:boolean):number{if(!appInForeground||!hasFollowerLayers){return3000;}if(analysisCostMs>900){return1800;}if(analysisCostMs<350){return900;}return1200;}没有跟随型贴纸时,可以暂停骨骼采样,仅保留低成本人脸元数据;应用进入后台必须完全停止。
十六、加入可观测性但不记录图像
建议记录以下聚合字段:
interfaceVisionMetrics{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 后被判定过期。
- 人脸与骨骼距离过大时拒绝融合。
- 融合结果保留面部点和身体点且不重复。
- 空目标不会使用分割框冒充可靠骨骼。
真机集成测试:
启动预览并启用跟随贴纸 -> 观察采样周期和识别耗时 -> 连续切换前后镜头 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 单次分析变慢或暂时失败,基础预览、拍照和镜头切换仍能保持可用。这才是实时趣味相机在不同设备上稳定运行的工程前提。