HarmonyOS趣味相机实战第24篇:前摄镜像、旋转补偿与识别框坐标统一
摘要
前置摄像头最容易出现一种“每个模块单独看都对,叠在一起却错”的问题:预览像镜子,保存后的 JPEG 又可能按拍摄参数镜像,人物识别框还来自传感器或算法坐标。如果三个环节各自补一次方向,最终会出现左右颠倒、识别框跑到另一侧、水印文字反写等现象。
本文基于D:/APP/1quweixiangji的 HarmonyOS 趣味相机项目,围绕CameraPreviewService.ets中的前后摄状态、PreviewOutput旋转、PhotoCaptureSetting.mirror、元数据矩形映射以及 CoreVision 入参展开。目标不是写一个固定角度的补丁,而是建立可验证的坐标契约,让预览、检测框、成片和 ArkUI 水印各自只承担一次变换。
工程环境
| 项目 | 配置 |
|---|---|
| 开发语言 | ArkTS |
| UI 框架 | ArkUI Stage 模型 |
| 相机能力 | CameraKit |
| target SDK | HarmonyOS 6.0.2(22) |
| 预览逻辑尺寸 | 316 × 390 |
| 摄像头位置 | front/back |
| 输出链路 | PreviewOutput + PhotoOutput |
一、先把四套坐标系说清楚
相机页面至少同时存在四套坐标:
| 坐标空间 | 原点与范围 | 主要用途 |
|---|---|---|
| 传感器/元数据坐标 | 设备或接口定义 | 人脸、人体等元数据输出 |
| 图像像素坐标 | 0..width、0..height | PixelMap 与成片分析 |
| 归一化坐标 | 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。工程中应以摄像头位置为事实来源,再为不同输出计算策略:
typeCameraPosition='front'|'back';privatestaticactiveCameraPosition:CameraPosition='back';privatestaticshouldMirrorCapturedPhoto():boolean{returnCameraPreviewService.activeCameraPosition==='front';}这样做能避免切换摄像头后只更新按钮文案,却忘记更新拍照与算法映射。
三、PhotoCaptureSetting只控制成片
项目在拍照时构造如下参数:
constsetting:camera.PhotoCaptureSetting={quality:CameraPreviewService.captureQualityLevel(qualityPreference),mirror:CameraPreviewService.activeCameraPosition==='front'};awaitCameraPreviewService.photoOutput.capture(setting);这里的mirror属于 PhotoOutput 拍照请求,不能把它理解为“整个相机页面镜像”。它解决的是前摄成片是否符合用户对自拍的直觉,并不会自动修正 ArkUI 中自己绘制的识别框。
一条实用规则是:
PhotoCaptureSetting.mirror -> 只负责输出照片 元数据矩形变换 -> 只负责预览覆盖层 水印文字 -> 保持正常排版,不参与图像镜像如果水印是在 ArkUI 结果页叠加的文本,就不应再对水印容器做水平翻转,否则地点和时间会反写。
四、PreviewOutput旋转应优先询问系统
横竖屏和不同传感器朝向不能靠固定的 90 度猜测。项目先调用输出对象的旋转接口:
privatestaticresolvePreviewRotation(output:camera.PreviewOutput):camera.ImageRotation{try{constrotation:camera.ImageRotation=output.getPreviewRotation(0);output.setPreviewRotation(rotation,true);returnrotation;}catch(error){hilog.warn(DOMAIN,TAG,'resolve preview rotation failed: %{public}s',JSON.stringify(error));returnCameraPreviewService.previewCoordinateWidth>CameraPreviewService.previewCoordinateHeight?camera.ImageRotation.ROTATION_270:camera.ImageRotation.ROTATION_0;}}关键点有两个:优先使用系统返回值;兜底只在接口异常时生效。固定写死ROTATION_90在一台手机上可用,并不能证明它能覆盖另一种传感器安装方向。
工程兜底还可以考虑前后摄差异:
if(previewWidth>previewHeight){returnactiveCameraPosition==='front'?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};}每次计算后都应做边界收敛,防止浮点误差产生负数或超过 1:
privatestaticclamp(value:number,min:number,max:number):number{returnMath.max(min,Math.min(max,value));}六、前摄覆盖层只做一次水平镜像
项目对已经旋转到竖屏方向的矩形执行水平镜像:
privatestaticmapMetadataRectToPreview(rect:CameraNormalizedRect):CameraNormalizedRect{letmapped:CameraNormalizedRect=CameraPreviewService.rotateNormalizedRectForPortrait(rect);if(CameraPreviewService.activeCameraPosition==='front'){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,得到的是原矩形左边缘的镜像位置,整个框会向右偏移一个自身宽度。
例如矩形left=0.15, width=0.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.activeCameraPosition==='front');同一个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:number=Math.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.lastLiveVisionAt=0;CameraPreviewService.lastPhotoVisionAt=0;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.activeCameraPosition==='front',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};constactual=mirrorRect(source);expect(actual.left).assertEqual(0.65);expect(actual.top).assertEqual(0.20);});还应覆盖:
left=0与left+width=1的边界框。- 零宽高输入的容错。
- 旋转四次回到原矩形。
- 镜像两次回到原矩形。
- 旋转后所有值仍在
[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 的缩放偏移有明确责任方。
- 拍照请求冻结摄像头位置和旋转上下文。
- 切换摄像头会清理旧目标与旧回调。
- 水印文本不参与底图镜像。
- 纯函数测试覆盖边界、旋转和双重镜像。
- 真机使用不对称场景比对预览与成片。
总结
前摄适配的关键不是多加一个mirror=true,而是给每条链路划清责任:PreviewOutput 负责取景方向,PhotoCaptureSetting 负责成片镜像,算法服务负责把目标转换到统一归一化坐标,ArkUI 只负责按预览尺寸绘制覆盖层,水印文字保持正常阅读方向。
当摄像头位置、旋转角度和镜像策略在拍照开始时形成不可变上下文,再配合纯函数坐标测试与真机不对称场景验证,前摄左右颠倒、识别框漂移和切换摄像头串帧就能从偶发现象变成可重复定位的问题。