HarmonyOS趣味相机实战第24篇:前摄镜像、旋转补偿与识别框坐标统一
HarmonyOS趣味相机实战第24篇前摄镜像、旋转补偿与识别框坐标统一摘要前置摄像头最容易出现一种“每个模块单独看都对叠在一起却错”的问题预览像镜子保存后的 JPEG 又可能按拍摄参数镜像人物识别框还来自传感器或算法坐标。如果三个环节各自补一次方向最终会出现左右颠倒、识别框跑到另一侧、水印文字反写等现象。本文基于D:/APP/1quweixiangji的 HarmonyOS 趣味相机项目围绕CameraPreviewService.ets中的前后摄状态、PreviewOutput旋转、PhotoCaptureSetting.mirror、元数据矩形映射以及 CoreVision 入参展开。目标不是写一个固定角度的补丁而是建立可验证的坐标契约让预览、检测框、成片和 ArkUI 水印各自只承担一次变换。工程环境项目配置开发语言ArkTSUI 框架ArkUI Stage 模型相机能力CameraKittarget SDKHarmonyOS 6.0.2(22)预览逻辑尺寸316 × 390摄像头位置front/back输出链路PreviewOutput PhotoOutput一、先把四套坐标系说清楚相机页面至少同时存在四套坐标坐标空间原点与范围主要用途传感器/元数据坐标设备或接口定义人脸、人体等元数据输出图像像素坐标0..width、0..heightPixelMap 与成片分析归一化坐标0..1跨分辨率传递矩形ArkUI 预览坐标316 × 390 逻辑区域绘制识别框和交互覆盖层不要在 UI 中直接使用算法返回的像素值。服务层应先把目标转换成归一化矩形页面最后再乘以当前预览宽高interfaceCameraNormalizedRect{left:number;top:number;width:number;height:number;}functiontoPreviewRect(rect:CameraNormalizedRect):CameraNormalizedRect{return{left:rect.left*316,top:rect.top*390,width:rect.width*316,height:rect.height*390};}归一化的价值是隔离分辨率。后续即使预览从 316 × 390 改为全屏算法层也不需要跟着重写。二、镜像不是一个布尔值能够包办的事情前摄场景中有三种不同含义预览镜像用户看到的画面是否像照镜子。成片镜像保存的照片是否与预览左右一致。覆盖层镜像识别框、关键点和贴纸是否跟随画面翻转。它们不能混为一个全局isMirror。工程中应以摄像头位置为事实来源再为不同输出计算策略typeCameraPositionfront|back;privatestaticactiveCameraPosition:CameraPositionback;privatestaticshouldMirrorCapturedPhoto():boolean{returnCameraPreviewService.activeCameraPositionfront;}这样做能避免切换摄像头后只更新按钮文案却忘记更新拍照与算法映射。三、PhotoCaptureSetting只控制成片项目在拍照时构造如下参数constsetting:camera.PhotoCaptureSetting{quality:CameraPreviewService.captureQualityLevel(qualityPreference),mirror:CameraPreviewService.activeCameraPositionfront};awaitCameraPreviewService.photoOutput.capture(setting);这里的mirror属于 PhotoOutput 拍照请求不能把它理解为“整个相机页面镜像”。它解决的是前摄成片是否符合用户对自拍的直觉并不会自动修正 ArkUI 中自己绘制的识别框。一条实用规则是PhotoCaptureSetting.mirror - 只负责输出照片 元数据矩形变换 - 只负责预览覆盖层 水印文字 - 保持正常排版不参与图像镜像如果水印是在 ArkUI 结果页叠加的文本就不应再对水印容器做水平翻转否则地点和时间会反写。四、PreviewOutput旋转应优先询问系统横竖屏和不同传感器朝向不能靠固定的 90 度猜测。项目先调用输出对象的旋转接口privatestaticresolvePreviewRotation(output:camera.PreviewOutput):camera.ImageRotation{try{constrotation:camera.ImageRotationoutput.getPreviewRotation(0);output.setPreviewRotation(rotation,true);returnrotation;}catch(error){hilog.warn(DOMAIN,TAG,resolve preview rotation failed: %{public}s,JSON.stringify(error));returnCameraPreviewService.previewCoordinateWidthCameraPreviewService.previewCoordinateHeight?camera.ImageRotation.ROTATION_270:camera.ImageRotation.ROTATION_0;}}关键点有两个优先使用系统返回值兜底只在接口异常时生效。固定写死ROTATION_90在一台手机上可用并不能证明它能覆盖另一种传感器安装方向。工程兜底还可以考虑前后摄差异if(previewWidthpreviewHeight){returnactiveCameraPositionfront?camera.ImageRotation.ROTATION_90:camera.ImageRotation.ROTATION_270;}returncamera.ImageRotation.ROTATION_0;五、矩形旋转要变换四个角只交换width和height并不能完成矩形旋转因为left和top也会变化。对于归一化矩形可先列出 90 度旋转关系functionrotate90(rect:CameraNormalizedRect):CameraNormalizedRect{return{left:1-rect.top-rect.height,top:rect.left,width:rect.height,height:rect.width};}旋转 180 度functionrotate180(rect:CameraNormalizedRect):CameraNormalizedRect{return{left:1-rect.left-rect.width,top:1-rect.top-rect.height,width:rect.width,height:rect.height};}每次计算后都应做边界收敛防止浮点误差产生负数或超过 1privatestaticclamp(value:number,min:number,max:number):number{returnMath.max(min,Math.min(max,value));}六、前摄覆盖层只做一次水平镜像项目对已经旋转到竖屏方向的矩形执行水平镜像privatestaticmapMetadataRectToPreview(rect:CameraNormalizedRect):CameraNormalizedRect{letmapped:CameraNormalizedRectCameraPreviewService.rotateNormalizedRectForPortrait(rect);if(CameraPreviewService.activeCameraPositionfront){mapped{left:CameraPreviewService.clamp(1-mapped.left-mapped.width,0,1),top:mapped.top,width:mapped.width,height:mapped.height};}returnmapped;}公式1 - left - width很重要。若只写1 - left得到的是原矩形左边缘的镜像位置整个框会向右偏移一个自身宽度。例如矩形left0.15, width0.20正确结果是1 - 0.15 - 0.20 0.65原框覆盖[0.15, 0.35]镜像后覆盖[0.65, 0.85]两者关于 0.5 对称。七、CoreVision也必须接收同一方向语义项目不仅处理相机元数据还会从实时 Surface 或 PhotoAvailable 的 PixelMap 运行人物分析consttargets:DetectedTarget[]awaitCoreVisionHumanService.analyzePixelMap(pixelMap,CameraPreviewService.previewRotation,CameraPreviewService.activeCameraPositionfront);同一个previewRotation和前摄标记同时用于实时帧与成片分析能够减少两条识别链路的分叉。服务层返回给页面前必须明确结果是否已经旋转、是否已经镜像。推荐在接口注释中写成契约// 返回值已经映射到竖屏镜像预览的归一化坐标调用方不得再次翻转。interfaceDetectedTarget{rect:CameraNormalizedRect;confidence:number;}如果契约不清晰调用方常见的“保险处理”会造成二次镜像。八、objectFit会引入裁剪偏移即使旋转和镜像都正确ImageFit.Cover或相机 Surface 的裁剪也可能让框偏移。源图比例与预览比例不同Cover 会放大后裁掉两侧或上下区域。映射过程应包含缩放和偏移functioncoverTransform(sourceWidth:number,sourceHeight:number,viewWidth:number,viewHeight:number):{scale:number;offsetX:number;offsetY:number}{constscale:numberMath.max(viewWidth/sourceWidth,viewHeight/sourceHeight);return{scale,offsetX:(viewWidth-sourceWidth*scale)/2,offsetY:(viewHeight-sourceHeight*scale)/2};}最终 UI 坐标为pixel * scale offset。若识别层已经返回预览归一化坐标就不要再重复应用 Cover 偏移。九、切换摄像头必须清空旧目标前后摄切换不是只重建 CameraInput。旧识别框属于上一台摄像头的时间线继续显示会形成短暂错位。切换前应privatestaticresetVisionState():void{CameraPreviewService.lastLiveVisionTargets[];CameraPreviewService.lastPhotoVisionTargets[];CameraPreviewService.lastLiveVisionAt0;CameraPreviewService.lastPhotoVisionAt0;CameraPreviewService.notifyTargets([]);}推荐顺序禁止拍照按钮 - 停止当前 session - 清空旧目标与旧方向 - 更新 activeCameraPosition - 创建新的 input/output/session - 获取新 PreviewRotation - 启动 session - 恢复拍照按钮这样 UI 不会在切换间隙把后摄目标按前摄规则翻转。十、拍照期间冻结方向快照异步拍照会跨越多个事件调用capture()、等待photoAvailable、创建 PixelMap、运行识别、返回页面。如果期间用户切换摄像头读取全局activeCameraPosition可能得到新值。更稳妥的实现是在请求开始时冻结上下文interfaceCaptureTransformContext{position:CameraPosition;rotation:camera.ImageRotation;mirror:boolean;startedAt:number;}constcontext:CaptureTransformContext{position:CameraPreviewService.activeCameraPosition,rotation:CameraPreviewService.previewRotation,mirror:CameraPreviewService.activeCameraPositionfront,startedAt:Date.now()};后续回调使用这份快照而不是重新读取可变全局状态。工程中还可用captureStartedAt过滤旧事件避免上一轮 PhotoAvailable 被误认为本次结果。十一、水印层不要跟随图像镜像趣味相机的结果页通过 ArkUI 在 PixelMap 上叠加地点、时间与备注。水印属于界面语义层不是相机像素层。推荐结构Stack(){Image(this.previewPhotoPixelMap).objectFit(ImageFit.Cover)Column(){Blank()this.WatermarkOverlay(this.previewPhoto?.watermark)}}即使底图来自前摄镜像成片也不要给整个Stack设置负缩放。否则文字、图标和点击区域都会反转。镜像发生在 PhotoOutput 或图像处理阶段文字始终按正常阅读方向渲染。十二、用不对称场景验证左右关系只拍正脸很难判断是否重复镜像。推荐使用具有明确左右差异的测试场景左手举一张写有“L”的纸。人物站在画面左侧三分之一处。背景左右放不同颜色物体。水印备注填写正常可读文本。同时记录预览截图与最终成片。验证目标场景预览成片识别框水印后摄竖屏不镜像不镜像对齐正常前摄竖屏镜像预览与产品策略一致对齐预览正常前摄切后摄方向立即更新使用后摄策略无旧框残留正常拍照中切换禁止或隔离不串帧不串目标正常十三、纯函数单元测试比真机肉眼更快矩形变换适合抽成纯函数测试it(mirrors normalized rect horizontally,0,(){constsource:CameraNormalizedRect{left:0.15,top:0.20,width:0.20,height:0.30};constactualmirrorRect(source);expect(actual.left).assertEqual(0.65);expect(actual.top).assertEqual(0.20);});还应覆盖left0与leftwidth1的边界框。零宽高输入的容错。旋转四次回到原矩形。镜像两次回到原矩形。旋转后所有值仍在[0,1]。前后摄快速切换时旧请求被丢弃。十四、常见错误与定位方式现象高概率原因定位点前摄框跑到人物对侧缺少镜像或做了两次镜像mapMetadataRectToPreview框整体偏上或偏左忽略 Cover 裁剪预览尺寸与源图比例横屏后框宽高颠倒只交换宽高未更新 left/top旋转公式成片正确但文字反写对整个 Stack 做镜像ArkUI 水印容器切换摄像头闪现旧框未清空旧目标session 重建前后偶发识别错方向异步回调读取了新全局状态拍照上下文快照日志不要只打印“识别失败”建议记录可公开的诊断字段hilog.info(DOMAIN,TAG,transform position%{public}s rotation%{public}d targets%{public}d,context.position,context.rotation,targets.length);不要输出照片二进制、用户备注或精确地点。十五、发布前验收清单前后摄的 PreviewRotation 都来自实际输出对象。PhotoCaptureSetting.mirror只用于成片策略。元数据矩形在服务层完成旋转与前摄镜像。UI 不会再次镜像已经处理过的目标。Cover/Crop 的缩放偏移有明确责任方。拍照请求冻结摄像头位置和旋转上下文。切换摄像头会清理旧目标与旧回调。水印文本不参与底图镜像。纯函数测试覆盖边界、旋转和双重镜像。真机使用不对称场景比对预览与成片。总结前摄适配的关键不是多加一个mirrortrue而是给每条链路划清责任PreviewOutput 负责取景方向PhotoCaptureSetting 负责成片镜像算法服务负责把目标转换到统一归一化坐标ArkUI 只负责按预览尺寸绘制覆盖层水印文字保持正常阅读方向。当摄像头位置、旋转角度和镜像策略在拍照开始时形成不可变上下文再配合纯函数坐标测试与真机不对称场景验证前摄左右颠倒、识别框漂移和切换摄像头串帧就能从偶发现象变成可重复定位的问题。