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

发布时间:2026/7/18 14:18:18

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.etsXComponent Surface 与页面生命周期启停识别的入口entry/src/main/module.json5CAMERA 权限和 EntryAbility运行边界环境、常量与版本项目当前值说明应用版本1.0.4AppScope/app.json5target SDK6.0.2(22)当前构建配置预览逻辑尺寸316 x 390目标统一坐标实时采样周期1200msLIVE_VISION_INTERVAL_MS实时结果新鲜期2200msLIVE_VISION_FRESH_MS人脸结果新鲜期1800msFACE_METADATA_FRESH_MS首次采样延迟500ms预览稳定后再截帧停止等待上限1200ms等待进行中的任务收尾一、不要把识别绑在每一帧回调上相机预览可能每秒输出 30 或 60 帧而人体识别通常不需要相同频率。如果每帧都创建 PixelMap 并分析会形成Surface 帧到达速度 PixelMap 创建速度 CoreVision 分析速度 UI 消费速度任务队列不断累积最终表现为预览掉帧。内存持续升高。识别结果明显滞后。切换镜头后仍收到旧画面结果。页面退出后异步任务继续运行。实时装饰只需要“足够新”的目标不需要处理所有帧。因此应主动采样而不是被预览帧频率驱动。二、状态字段表达调度器的真实状态项目维护以下字段privatestaticliveVisionActive:booleanfalse;privatestaticliveVisionBusy:booleanfalse;privatestaticliveVisionTimer:number0;privatestaticliveVisionGeneration:number0;privatestaticliveVisionSurfaceId:string;privatestaticlastLiveVisionAt:number0;privatestaticlastLiveVisionTargets:CameraDetectedTarget[][];每个字段解决不同问题字段含义不能被谁替代active调度器是否允许继续运行Timer 是否存在busy当前是否正在分析activetimer尚未触发的下一次任务busygeneration本轮调度身份Surface IDsurfaceId当前截帧来源generationlastAt最近成功结果时间任务开始时间lastTargets最近有效目标UI 当前状态布尔值看似较多但它们分别描述许可、执行、等待和结果不应压缩成一个含义模糊的isRunning。三、启动时先取消旧调度启动入口privatestaticstartLiveVisionSampling(surfaceId:string):void{CameraPreviewService.cancelLiveVisionSampling();CameraPreviewService.liveVisionActivetrue;CameraPreviewService.liveVisionSurfaceIdsurfaceId;CameraPreviewService.liveVisionGeneration1;CameraPreviewService.scheduleLiveVisionSample(500,CameraPreviewService.liveVisionGeneration);}先取消旧调度很重要。预览重建、前后镜头切换或 Surface 重新创建都可能再次调用启动函数。如果只覆盖surfaceId旧 Timer 到点后会读取新 Surface任务归属变得不清晰。首次延迟 500ms 的目的不是固定等待 UI而是给相机会话和 Surface 一个稳定窗口。更完善的实现可等待 PreviewSession 明确进入 running再安排首次采样。四、Generation 让旧任务自动失效取消时递增代际privatestaticcancelLiveVisionSampling():void{CameraPreviewService.liveVisionActivefalse;CameraPreviewService.liveVisionSurfaceId;CameraPreviewService.liveVisionGeneration1;if(CameraPreviewService.liveVisionTimer0){clearTimeout(CameraPreviewService.liveVisionTimer);CameraPreviewService.liveVisionTimer0;}}调度和执行都验证代际if(!CameraPreviewService.liveVisionActive||generation!CameraPreviewService.liveVisionGeneration){return;}为什么清除 Timer 还不够因为已经进入await analyzePixelMap()的任务无法被clearTimeout撤回。Generation 是逻辑取消令牌异步任务恢复后发现自己属于旧代际就不能再影响新会话。除了函数入口提交结果前也应该再次验证consttargetsawaitanalyze(pixelMap);if(!liveVisionActive||generation!liveVisionGeneration){return;}lastLiveVisionTargetstoCameraTargets(targets);lastLiveVisionAtDate.now();这一步能防止旧分析结果覆盖新镜头结果。五、Busy锁提供背压而不是排队采样函数先检查if(CameraPreviewService.liveVisionBusy||CameraPreviewService.liveVisionSurfaceId.length0){CameraPreviewService.scheduleLiveVisionSample(LIVE_VISION_INTERVAL_MS,generation);return;}如果上一次识别尚未完成本轮直接跳过不创建第二个任务。这叫丢帧背压宁愿分析较新的下一帧也不排队处理已经过时的旧帧。对实时预览来说“最新结果”比“每帧必达”更重要。日志或离线视频分析可能需要队列但相机贴纸不适合无界排队。六、使用递归setTimeout而不是setInterval项目在每次任务结束后安排下一次privatestaticscheduleLiveVisionSample(delayMs:number,generation:number):void{if(!liveVisionActive||generation!liveVisionGeneration){return;}liveVisionTimersetTimeout((){CameraPreviewService.sampleLiveVision(generation);},delayMs);}任务finally中再次调度finally{liveVisionBusyfalse;scheduleLiveVisionSample(LIVE_VISION_INTERVAL_MS,generation);}与setInterval(1200)相比这种方式的间隔从“上一次完成”开始计算。假设识别耗时 700mssetInterval: 0ms - 1200ms - 2400ms 递归Timeout: 0ms开始 - 700ms完成 - 1900ms下一次开始递归 Timeout 天然不会因为任务变慢而堆积触发信号。七、一次采样必须形成完整资源闭环核心流程letpixelMap:image.PixelMap|nullnull;liveVisionBusytrue;try{pixelMapawaitimage.createPixelMapFromSurface(liveVisionSurfaceId);consttargets:DetectedTarget[]awaitCoreVisionHumanService.analyzePixelMap(pixelMap,previewRotation,activeCameraPositionfront);constcameraTargetstoCameraTargets(targets);lastLiveVisionTargetscameraTargets;lastLiveVisionAtDate.now();if(cameraTargets.length0){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);}}liveVisionBusyfalse;scheduleLiveVisionSample(LIVE_VISION_INTERVAL_MS,generation);}关键规则busy在创建 PixelMap 前设置。PixelMap 只归本次任务所有。释放放在finally。释放异常不能阻断 Busy 复位。分析异常不能终止整个调度器。下一次调度也放在finally。八、为什么PixelMap不能缓存复用createPixelMapFromSurface()得到的是某个时间点的图像资源。长期持有会增加 native 内存压力也可能让后续识别一直消费旧图。实时采样应遵循单任务所有权创建 PixelMap - 分析 - 转换为轻量目标数据 - 释放 PixelMapUI 层只接收CameraDetectedTarget[]不持有采样 PixelMap。拍照结果预览如果需要 PixelMap应使用另一套明确的所有权和释放流程。九、停止调度要处理正在执行的任务项目取消后最多等待 1200msprivatestaticasyncstopLiveVisionSampling():Promisevoid{CameraPreviewService.cancelLiveVisionSampling();constdeadline:numberDate.now()1200;while(CameraPreviewService.liveVisionBusyDate.now()deadline){awaitnewPromisevoid((resolve:()void){setTimeout(()resolve(),40);});}}这个等待不是为了允许旧结果提交而是尽量让 PixelMap 完成释放再继续关闭 CameraInput、Session 和 Output。生产增强可以把当前任务 Promise 保存下来privatestaticliveVisionTask:Promisevoid|nullnull;staticasyncstop():Promisevoid{cancel();consttaskliveVisionTask;if(task!null){awaitPromise.race([task,timeout(1200)]);}}这样比 40ms 轮询更清晰也便于测试。十、结果新鲜期防止无限使用旧目标项目设置constLIVE_VISION_FRESH_MS2200;constFACE_METADATA_FRESH_MS1800;判断实时结果是否仍可用constliveVisionFreshlastLiveVisionTargets.length0Date.now()-lastLiveVisionAtLIVE_VISION_FRESH_MS;采样周期为 1200ms新鲜期 2200ms 可以容忍一次采样失败但不会在人物离开后长时间保留贴纸。人脸元数据频率通常更高因此新鲜期设置为 1800ms。“新鲜”只代表时间可接受还要检查结果质量和来源。十一、多来源目标不能简单拼接系统存在三类数据来源优势局限相机人脸 Metadata频率高、面部稳定缺少全身关键点CoreVision 骨骼肩、手、膝等点丰富耗时较高人体分割有轮廓不适合单独做人脸锚点项目的优选顺序是当前回调是人脸元数据 - 记录并返回 有可靠骨骼 - 与新鲜人脸融合后返回 没有可靠骨骼 - 尝试新鲜实时骨骼 只有新鲜人脸 - 返回人脸 最后过滤不适合单独展示的分割目标这不是数组concat。同一个人同时出现在人脸和骨骼数组中直接拼接会让页面认为有两个人。十二、用空间匹配融合人脸与骨骼项目按重叠、中心距离和包含关系打分constoverlaprectOverlap(faceTarget,poseTarget);constdx(poseTarget.centerX-faceX)/PREVIEW_FRAME_WIDTH;constdy(poseTarget.centerY-faceY)/PREVIEW_FRAME_HEIGHT;constdistanceScore1-Math.sqrt(dx*dxdy*dy);constcontainsFacepointInsideTarget(faceX,faceY,poseTarget);constscoreoverlapdistanceScore*20000(containsFace?30000:0);选出最高分后还要做拒绝条件if(!pointInsideTarget(faceCenterX,faceCenterY,bestTarget)centerDistance(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:number0;if(cameraTargets.length0){emptySampleCount0;notifyTargets(cameraTargets);}else{emptySampleCount1;if(emptySampleCount2){notifyTargets([]);}}两次空结果约等于 2.4 秒与 2200ms 新鲜期接近。更稳妥的方式是统一按lastLiveVisionAt判断而不是同时维护两套清除标准。十五、动态采样周期降低设备压力固定 1200ms 是可用基线但可以根据耗时调整functionnextDelay(analysisCostMs:number,appInForeground:boolean,hasFollowerLayers:boolean):number{if(!appInForeground||!hasFollowerLayers){return3000;}if(analysisCostMs900){return1800;}if(analysisCostMs350){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 单次分析变慢或暂时失败基础预览、拍照和镜头切换仍能保持可用。这才是实时趣味相机在不同设备上稳定运行的工程前提。

相关新闻