HarmonyOS AR Engine高精几何重建实战——扫描纸盒并测量体积

HarmonyOS AR Engine高精几何重建实战——扫描纸盒并测量体积 HarmonyOS AR Engine高精几何重建实战——扫描纸盒并测量体积这次把一个真实小纸盒放进相机画面手机扫过正面和侧面后屏幕上出现了许多小点和一个青绿色立体框。小点表示系统找到的物体表面位置立体框表示系统估算出的长方体外框。第一次锁定得到长度20.8 cm宽度12.7 cm高度3.5 cm体积0.926 LCube 标签CUBE_VOLUME置信度原始值1.000。画面中的深色点、青绿色立体框、8 个白色角点和尺寸都来自同一轮真机扫描。页面没有提前放好一个盒子模型也没有用随机点把效果补出来。先看懂页面第一次打开这个实验不需要先理解三维数学。先看下面五个地方黑色小方点AR Engine 认为属于物体表面的空间点“稠密”只是表示点比较多青绿色立体框系统返回的 Cube也就是纸盒外面的长方体框不是应用提前画好的固定模型8 个白色角点长方体的 8 个角程序用它们计算三条边长、宽、高和体积直接由当前 Cube 的角点计算不是手工填写锁定按钮把当前点、立体框和数字冻结方便截图和核对。判断是否真正成功不能只看“检测到一个 Cube”。页面必须同时出现非零长宽高、体积和可见立体边锁定按钮才会启用。实验准备本次使用的实际环境如下项目实际环境设备HUAWEI Mate 60 Pro系统HarmonyOS 7.0SDKAPI 26工程ArkTS Phone 应用targetSdkVersion26必需权限CAMERA、ACCELEROMETER、GYROSCOPE必需能力SLAM、SEMANTIC_DENSEAR 会话类型WORLD高精重建模式CUBE_VOLUME扫描对象六面基本平整、边缘清楚的小型纸盒应用启动后先检查相机权限和八类 AR 能力。当前设备显示8/8 支持其中SEMANTIC_DENSE的说明为“高精几何重建”。实验工作台新增了“实验 10高精体积扫描仪”。这个入口只有在相机权限、SLAM 和SEMANTIC_DENSE同时满足时才会启动。能力门禁代码如下private canStartSemanticDense(): boolean { const semanticDense: FeatureItem | undefined this.features.find( (item: FeatureItem) item.type arEngine.ARFeatureType .ARENGINE_FEATURE_TYPE_SEMANTIC_DENSE ); return this.canStartAR() semanticDense ! undefined semanticDense.supported; } private startSemanticDense(): void { if (!this.cameraGranted) { this.latestMessage 拦截成功未获得相机权限不启动高精几何重建。; hilog.info(DOMAIN, TAG, DENSE_GUARD camerafalse); return; } if (!this.canStartAR()) { this.latestMessage 拦截成功当前设备不支持 SLAM不启动高精几何重建。; hilog.info(DOMAIN, TAG, DENSE_GUARD slamfalse); return; } if (!this.canStartSemanticDense()) { this.latestMessage 拦截成功当前设备不支持高精几何重建不创建 AR 会话。; hilog.info(DOMAIN, TAG, DENSE_GUARD semanticDensefalse); return; } hilog.info(DOMAIN, TAG, DENSE_GUARD cameratrue slamtrue semanticDensetrue); this.showSemanticDense true; }系统怎样把纸盒变成体积这张图只说明数据处理关系不是真机结果。真机识别是否成功仍以正文中的实际相机画面和日志为准。相机每更新一次画面就会产生一份ARFrame。可以把它理解为“这一刻的相机画面和 AR 数据”。页面依次做五件事从当前画面取得高精几何重建数据读出物体表面小点的位置和颜色读出长方体的 8 个角点用角点计算三条相邻边再把小点和立体框画回屏幕长、宽、高相乘得到立方米再换算成更直观的升。第一步让 AR Engine 寻找长方体页面底层用ARView显示真实相机上层用 Canvas 画小点和立体框。这样移动手机时标记会跟着真实纸盒的位置变化。下面这段配置的关键只有一项semanticDenseMode选择CUBE_VOLUME告诉 AR Engine 本次要寻找可以计算体积的长方体。自动对焦保持开启普通语义、深度和 Mesh 关闭。private async initARView(): Promisevoid { const startTime: number Date.now(); const scene: Scene await Scene.load(); const context new arViewController.ARViewContext(); this.callback new SemanticDenseCallback( (viewContext: arViewController.ARViewContext, timestamp: number) { this.handleFrameUpdate(viewContext, timestamp); } ); context.scene scene; context.callback this.callback; context.config { type: arEngine.ARType.WORLD, planeFindingMode: arEngine.ARPlaneFindingMode.HORIZONTAL_AND_VERTICAL, powerMode: arEngine.ARPowerMode.NORMAL, semanticMode: arEngine.ARSemanticMode.NONE, semanticDenseMode: arEngine.ARSemanticDenseMode.CUBE_VOLUME, poseMode: arEngine.ARPoseMode.GRAVITY, depthMode: arEngine.ARDepthMode.DISABLED, meshMode: arEngine.ARMeshMode.DISABLED, focusMode: arEngine.ARFocusMode.AUTO }; await context.init(); this.arContext context; this.sessionReadyTime Date.now(); hilog.info(DOMAIN, TAG, DENSE_SESSION_INIT success costMs%{public}d mode2 focusMode1 depthMode0 meshMode0, Date.now() - startTime); }最终真机初始化耗时 36 msDENSE_GUARD cameratrue slamtrue semanticDensetrue DENSE_SESSION_INIT success costMs36 mode2 focusMode1 depthMode0 meshMode0第二步每隔 250 ms 读取一次相机每秒会产生很多帧。如果每帧都读取并绘制几千个点页面容易卡顿。下面的判断把读取频率限制为每 250 ms 最多一次页面暂停、结果锁定、页面退出或上一轮还没结束时都不重复处理。private handleFrameUpdate( context: arViewController.ARViewContext, timestamp: number ): void { const now: number Date.now(); if (this.isDestroyed || this.isPaused || this.evidenceLocked || this.samplingInFlight || now - this.lastSampleTime SAMPLE_INTERVAL_MS) { return; } this.lastSampleTime now; this.samplingInFlight true; this.sampleSemanticDense(context).finally(() { this.samplingInFlight false; }); }每轮先确认相机正在稳定跟踪再读取高精重建数据。没有稳定跟踪时直接等待不显示旧数据冒充新结果frame session.getFrame(); const camera: arEngine.ARCamera frame.getCamera(); this.updateTrackingStatus(camera.state, camera.stateReason); if (camera.state ! arEngine.ARTrackingState.TRACKING) { return; } denseData frame.acquireSemanticDense(); this.sampleCount 1; this.pointCount denseData.pointDataSize; this.cubeCount denseData.cubeDataSize;刚进入页面时只有真实相机画面和青色扫描框尺寸、体积和点数都是空值。第三步读出物体表面的空间点每个小点至少需要知道三件事它是谁、它在三维空间的哪个位置、它是什么颜色。接口把这三组内容放在三个ArrayBuffer中ArkTS 需要先把它们转换成可以按下标读取的数组id转为Int32Arrayposition转为Float32Array每个点占 4 个值三维位置x、y、z和可信程度color转为Int32Array每个点占 4 个值r、g、b、a。let positions: Float32Array new Float32Array(0); let colors: Int32Array new Int32Array(0); if (denseData.pointDataSize 0) { const pointData: arEngine.ARSemanticDensePointData denseData.acquirePointData(); const pointIds: Int32Array new Int32Array(pointData.id); positions new Float32Array(pointData.position); colors new Int32Array(pointData.color); this.pointCount Math.min( denseData.pointDataSize, pointIds.length, Math.floor(positions.length / 4), Math.floor(colors.length / 4) ); }本轮首批点数据在会话建立后 18.242 秒出现共 24 个点。位置数组和颜色数组分别有 96 个值正好都是24 × 4说明数据布局能够对上。DENSE_FIRST_POINT latencyMs18242 points24 idValues24 positionValues96 colorValues96继续绕纸盒移动后点数增长到 553。Canvas 最多画 700 个点如果系统返回更多只隔几个取一个来画页面上的原始点数仍然照常显示。private drawPointCloud( positions: Float32Array, colors: Int32Array, viewMatrix: Arraynumber, projectionMatrix: Arraynumber, canvasWidth: number, canvasHeight: number ): number { const sourcePoints: number Math.min( this.pointCount, Math.floor(positions.length / 4) ); const stride: number Math.max( 1, Math.ceil(sourcePoints / MAX_DRAWN_POINTS) ); let drawn: number 0; for (let pointIndex: number 0; pointIndex sourcePoints; pointIndex stride) { const offset: number pointIndex * 4; const projected: number[] this.projectWorldPoint( positions[offset], positions[offset 1], positions[offset 2], viewMatrix, projectionMatrix, canvasWidth, canvasHeight ); if (projected.length ! 2) { continue; } const confidence: number positions[offset 3]; const alpha: number Math.max( 0.34, Math.min(0.92, 0.42 confidence * 0.45) ); this.overlayContext.fillStyle rgba(${colors[offset]}, ${colors[offset 1]}, ${colors[offset 2]}, ${alpha.toFixed(2)}); this.overlayContext.fillRect( projected[0] - 1.35, projected[1] - 1.35, 2.7, 2.7 ); drawn 1; } return drawn; }遇到的情况已经有 Cube尺寸还是 0这次扫描中出现了一个很容易误判的中间状态日志已经显示系统返回了 1 个 Cube但页面尺寸仍然是 0。第 62 次采样已经返回DENSE_SAMPLE sample62 points24 drawnPoints0 cubes1 cubeId0 vertexSize8 drawnEdges0 label10 confidence1.000 lengthM0.000 widthM0.000 heightM0.000 volumeL0.000这里的cubes1只表示“系统已经创建了一条外框记录”不代表 8 个角点已经张开成一个可测量的盒子。此时三条相邻边仍是 0所以页面继续等待。直到 37.692 秒后三条边都变成有效非零值才记录首个可用 CubeDENSE_FIRST_CUBE latencyMs37692 id0 vertexSize8 label10 confidence1.000因此页面把成功条件收紧为下面四项vertexSize 8vertexData至少包含 24 个数24 个值都是有限数三条相邻边都大于 1 mm且没有异常大于 10 m。private firstValidCube( cubes: ArrayarEngine.ARSemanticDenseCubeData ): arEngine.ARSemanticDenseCubeData | undefined { for (let index: number 0; index cubes.length; index 1) { const cube: arEngine.ARSemanticDenseCubeData cubes[index]; if (cube.vertexSize 8 cube.vertexData.length 24 this.hasFiniteVertices(cube.vertexData)) { return cube; } } return undefined; }第四步用 8 个角点算三条边一个长方体有 8 个角。按照接口返回的角点顺序v0-v1、v1-v2和v0-v4正好是从相邻角点连出的三条边。程序分别算出三条线的长度再把最长的显示为“长”、中间的显示为“宽”、最短的显示为“高”。private measureCube( vertices: Arraynumber, vertexSize: number ): CubeMeasurement { const invalid: CubeMeasurement { valid: false, vertices: [], lengthMeters: 0, widthMeters: 0, heightMeters: 0, volumeLiters: 0 }; if (vertexSize ! 8 || vertices.length 24 || !this.hasFiniteVertices(vertices)) { return invalid; } const edgeA: number this.vertexDistance(vertices, 0, 1); const edgeB: number this.vertexDistance(vertices, 1, 2); const edgeC: number this.vertexDistance(vertices, 0, 4); if (edgeA 0.001 || edgeB 0.001 || edgeC 0.001 || edgeA 10 || edgeB 10 || edgeC 10) { return invalid; } const dimensions: number[] [edgeA, edgeB, edgeC] .sort((first: number, second: number) second - first); return { valid: true, vertices: vertices.slice(0, 24), lengthMeters: dimensions[0], widthMeters: dimensions[1], heightMeters: dimensions[2], volumeLiters: edgeA * edgeB * edgeC * 1000 }; } private vertexDistance( vertices: Arraynumber, firstIndex: number, secondIndex: number ): number { const firstOffset: number firstIndex * 3; const secondOffset: number secondIndex * 3; const deltaX: number vertices[firstOffset] - vertices[secondOffset]; const deltaY: number vertices[firstOffset 1] - vertices[secondOffset 1]; const deltaZ: number vertices[firstOffset 2] - vertices[secondOffset 2]; return Math.sqrt( deltaX * deltaX deltaY * deltaY deltaZ * deltaZ ); }接口中的坐标单位是米所以三边相乘后得到立方米。为了让小纸盒的结果更好读再乘以 1000 换成升0.208 m × 0.127 m × 0.035 m 0.000926 m³ 0.000926 m³ × 1000 0.926 L第五步把空间点画回屏幕接口给出的点是三维空间坐标而 Canvas 需要的是屏幕上的横纵像素二者不能直接混用。页面使用相机的观察矩阵和投影矩阵把“空间中的点”换算成“屏幕上的位置”。第一次阅读时不必推导矩阵只要记住这一步负责让立体框跟着真实纸盒移动。private projectWorldPoint( worldX: number, worldY: number, worldZ: number, viewMatrix: Arraynumber, projectionMatrix: Arraynumber, canvasWidth: number, canvasHeight: number ): number[] { const viewX: number viewMatrix[0] * worldX viewMatrix[4] * worldY viewMatrix[8] * worldZ viewMatrix[12]; const viewY: number viewMatrix[1] * worldX viewMatrix[5] * worldY viewMatrix[9] * worldZ viewMatrix[13]; const viewZ: number viewMatrix[2] * worldX viewMatrix[6] * worldY viewMatrix[10] * worldZ viewMatrix[14]; const viewW: number viewMatrix[3] * worldX viewMatrix[7] * worldY viewMatrix[11] * worldZ viewMatrix[15]; const clipX: number projectionMatrix[0] * viewX projectionMatrix[4] * viewY projectionMatrix[8] * viewZ projectionMatrix[12] * viewW; const clipY: number projectionMatrix[1] * viewX projectionMatrix[5] * viewY projectionMatrix[9] * viewZ projectionMatrix[13] * viewW; const clipW: number projectionMatrix[3] * viewX projectionMatrix[7] * viewY projectionMatrix[11] * viewZ projectionMatrix[15] * viewW; if (!Number.isFinite(clipW) || clipW 0.001) { return []; } const normalizedX: number clipX / clipW; const normalizedY: number clipY / clipW; return [ (normalizedX * 0.5 0.5) * canvasWidth, (0.5 - normalizedY * 0.5) * canvasHeight ]; }换算完成后8 个角点可以连成 6 个半透明面和 12 条边。只有立体边确实进入当前画面后页面才允许锁定最终锁定时 12 条边全部可见。跟着真机扫一遍1. 让纸盒完整入镜把纸盒放在木纹桌面、地板等有纹理的地方。进入实验 10 后让纸盒正面、顶部和至少一个侧面同时进入青色扫描框。移动的是手机不是在屏幕上滑动。页面开始时会显示“等待纸箱体积”。本轮前 56 次采样仍然是points0 / cubes0页面继续等待没有拿默认数字填空。2. 缓慢经过正面和两个侧面拿着手机从纸盒正面缓慢移动到左侧或右侧尽量让纸盒始终完整入镜。18.242 秒后出现首批 24 个小点继续看到更多侧面后页面开始取得 Cube但尺寸还没有立刻可用。37.692 秒时三条边第一次都有非零长度页面开始显示青绿色立体框。之后点数从 21 增长到 375、426最后达到 553。看到立体框不再贴错位置、长宽高不为 0才进入下一步。3. 等立体框覆盖纸盒后锁定移动视角时可见 Cube 边数曾从 12 变为 5、4、9再回到 12。这不是 Cube 数据消失而是部分顶点暂时落到当前相机视野外。当纸盒完整入镜、小点覆盖盒子边缘、立体框能跟住纸盒时点击“锁定当前体积证据”。按钮变成黄色“继续扫描纸箱”说明当前结果已经冻结。第 115 次采样日志为DENSE_EVIDENCE_LOCK sample115 points553 cubeId0 vertexSize8 label10 confidence1.000 lengthM0.208 widthM0.127 heightM0.035 volumeL0.926 drawnEdges12这行日志翻译成页面数据就是553 个点、8 个角点、12 条可见边长宽高为 20.8、12.7、3.5 cm体积为 0.926 L。页面为了简洁把体积显示成一位小数0.9 L。4. 锁定后继续扫描为了验证锁定后能否重新工作又对同一只纸盒做了一轮扫描。第二轮在第 473 次采样锁定了 6627 个点尺寸为23.2 × 15.0 × 5.0 cm体积为1.741 L。DENSE_EVIDENCE_LOCK sample473 points6627 cubeId0 vertexSize8 label10 confidence1.000 lengthM0.232 widthM0.150 heightM0.050 volumeL1.741 drawnEdges12点击黄色“继续扫描纸箱”后按钮恢复为“锁定当前体积证据”状态从“证据已锁定”变成“已识别”。采样数从 473 继续增长到 480、488证明页面确实重新读取了 AR 帧。DENSE_EVIDENCE_CONTINUE sample473 DENSE_SAMPLE sample480 points6627 drawnPoints663 cubes1 vertexSize8 drawnEdges12 lengthM0.232 widthM0.150 heightM0.050 volumeL1.741 DENSE_SAMPLE sample488 points6627 drawnPoints663 cubes1 vertexSize8 drawnEdges12 lengthM0.232 widthM0.150 heightM0.050 volumeL1.741两轮对同一纸盒的结果并不完全相同第一轮为20.8 × 12.7 × 3.5 cm / 0.926 L第二轮为23.2 × 15.0 × 5.0 cm / 1.741 L。第二轮看到了更完整的顶部和侧面点数也从 553 增长到 6627。这说明取景范围和扫描完整度会影响结果。本文只验证接口、Cube、页面交互和体积计算链路可以运行没有用尺子做标定所以不能把这些数字当成计量仪器的精度证明。暂停、恢复和销毁锁定后点击“暂停重建”页面停在第 115 次采样按钮变成“恢复重建”。等待期间没有新的采样数。DENSE_SESSION_PAUSE sample115点击“恢复重建”后AR 会话恢复。因为当前证据仍处于锁定状态页面继续保留第 115 帧结果不会偷偷覆盖已锁定尺寸。DENSE_SESSION_RESUME sample115 lockedtrue最后点击“销毁退出”。页面返回实验工作台销毁汇总显示pending0 / errors0AR 相机页面关闭。DENSE_SESSION_DESTROY sample115 pending0 errors0 points553 cubes1 lockedtrue退出页面时释放相机数据每次读取都会占用一份高精数据和一帧相机数据。下面的finally无论成功还是报错都会执行先释放高精数据再释放相机帧避免连续扫描后资源越积越多。} finally { if (denseData) { try { await denseData.release(); } catch (error) { const err: BusinessError error as BusinessError; hilog.error(DOMAIN, TAG, DENSE_DATA_RELEASE error%{public}d, err.code); } } if (frame) { try { await frame.release(); } catch (error) { const err: BusinessError error as BusinessError; hilog.error(DOMAIN, TAG, DENSE_FRAME_RELEASE error%{public}d, err.code); } } }页面退出时销毁ARViewContext并清空 Canvasprivate async destroyARView(): Promisevoid { if (!this.arContext || this.isDestroyed) { return; } this.isDestroyed true; const context: arViewController.ARViewContext this.arContext; this.arContext undefined; await context.destroy(); if (this.canvasReady) { this.overlayContext.clearRect( 0, 0, this.overlayContext.width, this.overlayContext.height ); } }构建并确认数据来自真机静态回归覆盖了以下内容SEMANTIC_DENSE能力门禁CUBE_VOLUME会话配置点 ID、位置和颜色的数组转换Cube 8 顶点及 24 个坐标值检查三边距离和升数计算View/Projection 相机投影点云、6 个面、12 条边和 8 个角点绘制锁定、继续扫描、暂停、恢复和销毁ARSemanticDenseData与ARFrame释放随机或模拟点云实现为 0 命中。执行结果AR Engine 高精几何重建静态回归检查通过。随后执行 clean 签名构建hvigorw clean assembleHap--no-daemon最终结果CompileArkTS SUCCESS PackageHap SUCCESS SignHap SUCCESS BUILD SUCCESSFUL in 26 s 223 ms签名 HAP 大小为 2,923,106 bytes覆盖安装和 Ability 启动均成功。最后得到的结果验证项真机结果CAMERA、SLAM、SEMANTIC_DENSE 门禁通过会话初始化36 ms首批稠密点18.242 s24 个点首个有效 Cube37.692 s最终稠密点553Cube1 个vertexSize8标签label10CUBE_VOLUME置信度原始值1.000可见立体边12/12长 × 宽 × 高20.8 × 12.7 × 3.5 cm体积0.926 L第二轮锁定6627 点23.2 × 15.0 × 5.0 cm1.741 L继续扫描第 473 次解除锁定采样继续到 480、488暂停第 115 次采样保持不变恢复会话恢复锁定证据保持销毁pending0 / errors0返回工作台本次案例完成了从真实 AR 帧到高精重建结果的完整链路真机生成稠密点和 8 顶点 CubeArkTS 校验顶点并计算三条空间边Canvas 把世界坐标重新投影到相机画面最后锁定实际尺寸和体积并完成继续扫描、暂停、恢复、销毁与逐帧资源释放。两轮扫描的尺寸差异也作为真实结果保留没有用生成数据把它们改成一致。官方资料AR Engine Kit 指南高精几何重建ArkTS