
一、二维码解出来了红色命中框却落在票据外CodeAxis 的离线票据扫描页上线前只剩一个看似不影响结果的问题二维码内容INV-20261002-0736能稳定识别校验也通过但旋转拍摄或前摄镜像图片上的红色四边形总会偏到票据另一侧。测试同学问得很直接“既然框都不准怎么证明扫到的是这张票据上的码而不是缓存里的旧结果”我从 HiLog 反推这次异常。原图是3024×4032EXIF orientation 为 6需要顺时针旋转 90°前摄预览又做了水平镜像。zxing-cpp 返回的是裁剪图局部坐标而页面直接把它乘预览缩放比漏掉了裁剪偏移、旋转和镜像的逆变换。识别没错空间证据链断了。这次验收编号为ZX-0736。规范化画布为4032×3024裁剪区域x896, y512, w2240, h1680解码局部四点为(602,318) (1574,300) (1590,1276) (618,1294)。最终映射到1080×810预览的命中区约为(401,222) (662,218) (666,479) (406,484)24 组旋转/镜像样本全部通过最大回映误差1.8px单次耗时46ms。二、先统一像素空间再谈坐标公式工程结构和普通扫码 Demo 不同pages/ScanGeometryPage.ets负责选图与呈现service/FrameNormalizer.ets处理 Image Kit 资源geometry/CoordinateMapper.ets维护变换参数native/BarcodeBridge.cpp只接收已经规范化的裁剪像素。这样 zxing-cpp 不需要猜 EXIF也不需要知道 UI 是否镜像。第一段代码解决输入像素空间不一致。创建 PixelMap 后先读取尺寸再按 orientation 旋转、按预览语义镜像最后裁剪。顺序不能交换如果先裁剪再旋转cropRect就必须在原图空间重新推导调试字段也会失去统一含义。// service/FrameNormalizer.etsimport{image}fromkit.ImageKitexportinterfaceNormalizedFrame{pixelMap:image.PixelMap sourceWidth:numbersourceHeight:numbernormalizedWidth:numbernormalizedHeight:number}exportasyncfunctionnormalizeFrame(buffer:ArrayBuffer,mirrorX:boolean,crop:image.Region):PromiseNormalizedFrame{constsourceimage.createImageSource(buffer)letpixelMap:image.PixelMap|undefinedtry{pixelMapawaitsource.createPixelMap({editable:true})constsourceInfoawaitpixelMap.getImageInfo()awaitpixelMap.rotate(90)// orientation6if(mirrorX)awaitpixelMap.flip(true,false)constnormalizedInfoawaitpixelMap.getImageInfo()awaitpixelMap.crop(crop)return{pixelMap,sourceWidth:sourceInfo.size.width,sourceHeight:sourceInfo.size.height,normalizedWidth:normalizedInfo.size.width,normalizedHeight:normalizedInfo.size.height}}catch(error){pixelMap?.release()throwerror}finally{source.release()}}这里有两个容易忽略的生命周期点。ImageSource在createPixelMap()完成后即可释放但返回的 PixelMap 仍由调用方持有成功路径不能在函数内 release否则 Native 层拿到的是已失效对象。失败路径则必须释放已经创建的 PixelMap。调用方在解码与 UI 绘制都结束后再统一 release并把引用置空避免同一对象重复释放。rotate/flip/crop都是异步变换不能并行触发。重复调用normalizeFrame()需要先取消上一任务或比较 taskId本 Demo 只接受当前任务ZX-0736的结果晚到回调立即释放自己的 PixelMap不覆盖新页面状态。三、解码库只报告裁剪局部坐标不替 UI 猜方向第二段是 Native 桥接。输入在 ArkTS 侧已经旋转、镜像并裁剪所以关闭 zxing-cpp 的自动旋转限定 QRCode直接返回四边形与库报告的镜像状态。这样同一张图不会同时被 Image Kit 和 zxing-cpp 各旋转一次。// native/BarcodeBridge.cppScanResultDecodeQr(constuint8_t*gray,intwidth,intheight,introwStride){ZXing::ImageViewview(gray,width,height,ZXing::ImageFormat::Lum,rowStride);ZXing::ReaderOptions options;options.setFormats(ZXing::BarcodeFormat::QRCode);options.setTryRotate(false);options.setTryInvert(true);options.setTryHarder(false);autobarcodesZXing::ReadBarcodes(view,options);if(barcodes.size()!1||!barcodes.front().isValid()){throwScanError(QR_COUNT_NOT_ONE);}constautocodebarcodes.front();constautopcode.position();returnScanResult{ZXing::TextUtfEncoding::ToUtf8(code.text()),Quad{p.topLeft(),p.topRight(),p.bottomRight(),p.bottomLeft()},code.rotation(),code.isMirrored()};}限定格式把 46ms 延迟维持在可接受范围也降低票据条形码被误当候选的概率。tryInvert保留是为了兼容深色票据模板tryHarder默认关闭只有普通路径失败时才允许进入一次慢速补偿且必须沿用同一 taskId。rowStride不能偷懒写成 width因为 PixelMap 行对齐后每行字节数可能更大。Native 访问像素时还要保证锁定/解锁配对任何异常返回都不能跳过解锁。这一层返回局部四点不直接返回一个轴对齐矩形。旋转后的二维码仍是四边形过早取包围盒会放大命中范围在票据密集区域形成“框住了两个码”的假象。四、把变换链写成可逆函数而不是散落的加减乘除第三段代码解决真正的空间回映。当前场景的正向变换为原图顺时针 90°再水平镜像然后裁剪。对于原图宽3024、高4032两步组合后恰好有displayXsourceY、displayYsourceX但代码仍保存明确参数避免以后去掉镜像时公式悄悄失效。// geometry/CoordinateMapper.etsexportinterfacePoint{x:number;y:number}exportinterfaceRect{x:number;y:number;width:number;height:number}exportclassCoordinateMapper{constructor(privatecrop:Rect,privatenormalizedWidth:number,privatepreviewWidth:number,privatepreviewHeight:number){}cropToNormalized(p:Point):Point{return{x:p.xthis.crop.x,y:p.ythis.crop.y}}normalizedToSource(p:Point):Point{// orientation6 后再 mirrorX逆变换 sourceXdisplayY, sourceYdisplayXreturn{x:p.y,y:p.x}}normalizedToPreview(p:Point):Point{constscaleMath.min(this.previewWidth/4032,this.previewHeight/3024)return{x:Math.round(p.x*scale),y:Math.round(p.y*scale)}}}局部点先加裁剪偏移得到规范化四点(1498,830) (2470,812) (2486,1788) (1514,1806)需要回到原始文件做审计时再得到(830,1498) (812,2470) (1788,2486) (1806,1514)。UI 使用的是规范化预览因此直接乘1080/4032得到红色命中区四点。若页面用了 centerCrop 而不是 fit必须把额外平移量也纳入 mapper不能只乘缩放比。我为这个函数做了双向性质测试任一点经过 source→display→source 后误差不得超过 0.5px四点必须保持顺时针顺序所有点都要落在画布内。24 个样本覆盖 0°/90°/180°/270°、镜像开关和三种裁剪位置。最终最大 UI 回映误差 1.8px来自整数像素取整低于 2px 门槛。图中的 DevEco Studio 正在编辑CoordinateMapper.ets左侧还能看到FrameNormalizer.ets与BarcodeBridge.cpp右侧模拟器显示票据与红色四边形底部日志完整记录ZX-0736、orientation 6、mirrorX true、crop 896/512/2240/1680、decode 46ms 和 24/24 通过。代码、画面与日志共用同一组坐标避免了“截图是一组数测试报告是另一组数”。五、页面状态只接受当前任务旧回调负责自我清理识别服务在任务开始时从IDLE进入NORMALIZING随后是DECODING、MAPPING校验通过后到VERIFIED。任何阶段切换新图片都会生成新 taskId旧任务即使成功也只能释放资源并记录STALE_RESULT_DROPPED。如果 Native 解码失败页面保留原图但清空命中框绝不能沿用上一次四边形。最终运行页把证据链直接展示出来任务ZX-0736内容INV-20261002-0736状态 VERIFIEDorientation6 / 90° CW镜像“是”裁剪区域与四点坐标可展开查看资源计数在绘制完成后回到 0。这张 9:16 页面不是营销结果页而是测试人员能复核的运行状态。红线只圈命中二维码不遮挡票据状态栏、耗时、误差、样本数与 HiLog 一致。点击“重新验收”会创建新 ID不会复用ZX-0736从而避免重复调用把旧结果误认成新扫描。六、最后留下的是一条可审计的空间证据链修复后扫码是否成功不再只看字符串。原始尺寸、orientation、镜像、裁剪区域、解码四点、规范化四点、预览四点和 taskId 共同组成证据链任一环节变更都能在日志中定位。24/24 样本通过最大误差 1.8px耗时 46ms校验结果 PASS任务结束后活动 PixelMap 与 Native 锁均为 0。这类问题最危险的地方是业务结果“看起来对”会掩盖几何链路的错误。把输入先规范化、让解码库只做解码、把坐标变换集中成可逆函数再用 taskId 和成对释放守住生命周期才让红色框从装饰变成可信的工程证据。参考资料华为 Image Kit PixelMap 图像变换指南https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/image-transformation、Image_NativeModule 位图操作与资源释放说明https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/pixelmap-c、zxing-cpp 官方仓库https://github.com/zxing-cpp/zxing-cpp。