HarmonyOS趣味相机实战第16篇:实时识别采样、Busy锁与Generation并发治理
2026/7/18 14:18:11 网站建设 项目流程

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.json5
target 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: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 -> 分析 -> 转换为轻量目标数据 -> 释放 PixelMap

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询