HarmonyOS AR Engine深度估计实战——把毫米距离画成实时热力图
打开实验页面后,相机画面上会多出一层彩色网格。它不是滤镜:每个小格都保存着手机到画面中物体的大致距离。手机对准近处木纹地面时,中心距离是791 mm;对准约 3 米外的区域后,中心距离变成2.91 m。
两次锁定都取得了784/784个有效抽样点。热力镜里的暖色区域代表近处,绿色和蓝色逐步对应更远的区域,画面变化和毫米距离能够互相对上。
| 锁定场景 | 中心距离 | 抽样范围 | 有效抽样 | 高置信度 |
|---|---|---|---|---|
| 中近距离木纹地面 | 791 mm | 0.48–0.80 m | 784/784 | 11% |
| 远距离区域 | 2.91 m | 1.43–3.10 m | 784/784 | 9% |
这里的“深度图”可以先理解为一张距离表:普通照片的像素记录颜色,深度图的像素记录距离。页面再把距离换成颜色,才得到容易观察的“热力图”。锁定按钮只把当前画面停住,数字不会被重新计算或美化。
先看懂页面
第一次进入页面,先认下面五个信息:
- 中心距离:准星附近一小块区域到手机的距离,不是整张画面的平均值;
- 红色、黄色:距离较近;
- 绿色、蓝色:距离逐渐变远;
- 有效抽样:成功读到距离的格子数量,
784/784表示 784 个格子都有值; - 高置信度:系统认为“这次距离更可靠”的格子比例,它不是有效格子的比例。
实际操作时只看两个变化就够了:手机靠近物体,中心数字会变小,网格偏暖色;手机对准远处,中心数字会变大,网格逐渐变成绿色或蓝色。
实验准备
本次运行环境如下:
| 项目 | 实际环境 |
|---|---|
| 设备 | HUAWEI Mate 60 Pro |
| 系统 | HarmonyOS 7.0 |
| SDK | API 26 |
| 工程 | ArkTS Phone 应用,targetSdkVersion26 |
| 必需权限 | CAMERA、ACCELEROMETER、GYROSCOPE |
| 必需能力 | SLAM、DEPTH |
| AR 类型 | WORLD |
| 深度模式 | AUTOMATIC |
工程已经完成签名,真机连接正常。应用入口先检查相机权限、SLAM 和 DEPTH,三项全部满足后才显示可用的“实验 09:实时深度热力镜”。
权限声明沿用 AR Engine 工程的基础配置:
"requestPermissions": [ { "name": "ohos.permission.CAMERA", "reason": "$string:permission_reason_camera", "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } }, { "name": "ohos.permission.ACCELEROMETER", "reason": "$string:permission_reason_accelerometer", "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } }, { "name": "ohos.permission.GYROSCOPE", "reason": "$string:permission_reason_gyroscope", "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } } ]入口页在创建会话前做能力门禁:
private canStartDepth(): boolean { const depth: FeatureItem | undefined = this.features.find( (item: FeatureItem) => item.type === arEngine.ARFeatureType.ARENGINE_FEATURE_TYPE_DEPTH ); return this.canStartAR() && depth !== undefined && depth.supported; } private startDepthEstimation(): void { if (!this.cameraGranted) { this.latestMessage = '未获得相机权限,不启动深度估计。'; hilog.info(DOMAIN, TAG, 'DEPTH_GUARD camera=false'); return; } if (!this.canStartAR()) { this.latestMessage = '当前设备不支持 SLAM,不启动深度估计。'; hilog.info(DOMAIN, TAG, 'DEPTH_GUARD slam=false'); return; } if (!this.canStartDepth()) { this.latestMessage = '当前设备不支持深度估计,不创建 AR 会话。'; hilog.info(DOMAIN, TAG, 'DEPTH_GUARD depth=false'); return; } hilog.info(DOMAIN, TAG, 'DEPTH_GUARD camera=true slam=true depth=true'); this.showDepthEstimation = true; }这张彩色网格是怎么来的
这张原理图只说明数据处理过程,不是真机结果。正文中的距离、热力图和日志均来自后面的真实运行记录。
相机每更新一次画面,就会产生一份ARFrame。可以把它理解为“带有 AR 数据的一帧相机画面”。页面对每一帧做下面五件事:
- 收到新的相机帧;
- 取得一张“距离表”和一张“可信程度表”;
- 把底层缓冲区转成 ArkTS 可以按下标读取的整数数组;
- 从
256 × 256的原始数据中抽出28 × 28个格子,并计算准星附近的代表距离; - 距离决定颜色,可信程度决定透明度,最后用 Canvas 盖到相机画面上。
第一步:打开能返回距离的 AR 会话
页面底层使用ARView显示真实相机画面,上层使用 Canvas 画彩色网格和准星。两层叠在一起后,既能看到现实环境,也能看到距离变化。
下面这段配置只打开“自动返回深度”和“自动对焦”。平面、语义和 Mesh 都关闭,本次页面只处理距离数据。
const scene: Scene = await Scene.load(); const context = new arViewController.ARViewContext(); this.callback = new DepthEstimationCallback( (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.DISABLED, powerMode: arEngine.ARPowerMode.NORMAL, semanticMode: arEngine.ARSemanticMode.NONE, poseMode: arEngine.ARPoseMode.GRAVITY, depthMode: arEngine.ARDepthMode.AUTOMATIC, meshMode: arEngine.ARMeshMode.DISABLED, focusMode: arEngine.ARFocusMode.AUTO }; await context.init(); this.arContext = context;相机出帧速度很快,没有必要每一帧都重新画 784 个格子。下面的判断把读取频率限制为每 250 ms 最多一次;页面暂停、结果锁定、页面退出或上一轮还没读完时,都不会重复读取。
const DEPTH_SAMPLE_INTERVAL_MS: number = 250; 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 < DEPTH_SAMPLE_INTERVAL_MS) { return; } this.lastSampleTime = now; this.samplingInFlight = true; this.sampleCurrentDepth(context).finally(() => { this.samplingInFlight = false; }); }第二步:从当前画面读出距离和可信程度
只有页面显示相机正在稳定跟踪时才读取数据。acquireDepthImage16Bits()返回每个位置的毫米距离;acquireDepthConfidenceImage()返回同一位置的可信程度。两张图尺寸相同,下标也一一对应。
frame = session.getFrame(); const camera: arEngine.ARCamera = frame.getCamera(); this.updateTrackingStatus(camera.state, camera.stateReason); if (camera.state !== arEngine.ARTrackingState.TRACKING) { return; } depthImage = frame.acquireDepthImage16Bits(); confidenceImage = frame.acquireDepthConfidenceImage(); if (depthImage.planes.length === 0 || confidenceImage.planes.length === 0) { throw new Error('深度图或置信度图没有可读取的平面'); } const depthPlane: arEngine.ImageComponent = depthImage.planes[0]; const confidencePlane: arEngine.ImageComponent = confidenceImage.planes[0];深度功能刚启动时,普通相机画面往往已经出现,但距离数据还没准备好。真机返回1009200001时,页面把它显示成“正在准备”,继续等待,而不是立即判定失败:
const DEPTH_NOT_READY_CODE: number = 1009200001; if (err.code === DEPTH_NOT_READY_CODE) { this.pendingCount += 1; this.guideText = '深度数据正在准备,保持镜头稳定并扫过有纹理的物体边缘'; } else { this.errorCount += 1; this.guideText = `深度读取异常:${err.code}`; }最终真机返回的距离表大小为256 × 256。两轮测试分别等待约 7.4 秒和 7.7 秒才出现首份有效数据,所以进入页面后短时间只看到相机并不异常。
遇到的问题:数组读错后出现竖向空带
第一版页面虽然能画出颜色,却出现了规则的竖向空带,有效格子固定为448/784。问题不在相机,而在数组读取方式。
当时把ImageComponent.buffer当成一个个原始字节,又使用rowStride和pixelStride跳着取值。但 ArkTS 转换完成后,每个数组元素已经是一个完整数值,再按字节步长跳读,就会有一部分位置永远被跳过。
处理方式是先把ArrayBuffer转成Int32Array,然后用最普通的二维数组下标y * width + x读取。简单说:数组已经按数值展开,不能再按字节跳着读。
修订后的代码同时读取距离和可信程度,并从中心5 × 5小区域取代表值:
private sampleDepthGrid( width: number, height: number, depthPlane: arEngine.ImageComponent, confidencePlane: arEngine.ImageComponent ): DepthSampleResult { const depthValues: Int32Array = new Int32Array(depthPlane.buffer); const confidenceValues: Int32Array = new Int32Array(confidencePlane.buffer); const depths: number[] = []; const confidences: number[] = []; const centerValues: number[] = []; let validCount: number = 0; let highConfidenceCount: number = 0; let minDepth: number = 65535; let maxDepth: number = 0; const centerColumn: number = Math.floor(GRID_COLUMNS / 2); const centerRow: number = Math.floor(GRID_ROWS / 2); for (let row: number = 0; row < GRID_ROWS; row += 1) { const sourceY: number = Math.min( height - 1, Math.floor((row + 0.5) * height / GRID_ROWS) ); for (let column: number = 0; column < GRID_COLUMNS; column += 1) { const sourceX: number = Math.min( width - 1, Math.floor((column + 0.5) * width / GRID_COLUMNS) ); const planeIndex: number = sourceY * width + sourceX; const depth: number = depthValues[planeIndex]; const confidence: number = confidenceValues[planeIndex]; depths.push(depth); confidences.push(confidence); if (depth > 0) { validCount += 1; minDepth = Math.min(minDepth, depth); maxDepth = Math.max(maxDepth, depth); if (confidence >= 2) { highConfidenceCount += 1; } if (Math.abs(column - centerColumn) <= CENTER_RADIUS && Math.abs(row - centerRow) <= CENTER_RADIUS) { centerValues.push(depth); } } } } return { depths: depths, confidences: confidences, validCount: validCount, highConfidenceCount: highConfidenceCount, minDepth: validCount > 0 ? minDepth : 0, maxDepth: maxDepth, centerDepth: this.median(centerValues) }; }修订后有效抽样从448/784恢复到784/784,竖向空带消失。rowStride和pixelStride仍保留在日志中作为原始信息,但不再用来计算数组下标。
中心距离没有只取准星下的一个点,而是收集中心5 × 5区域的有效值,再取排在中间的数。这样某一个点突然为空或跳得很远时,页面数字不会立刻跟着乱跳。
private median(values: number[]): number { if (values.length === 0) { return 0; } const ordered: number[] = values.slice().sort( (first: number, second: number) => first - second ); const middle: number = Math.floor(ordered.length / 2); if (ordered.length % 2 === 1) { return ordered[middle]; } return Math.round( (ordered[middle - 1] + ordered[middle]) / 2 ); }第三步:把毫米距离换成颜色
页面固定绘制28 × 28,也就是 784 个格子。距离决定颜色,可信程度只决定颜色是更实还是更淡:
private depthColor(depth: number, confidence: number): string { if (depth <= 0) { return 'rgba(38, 49, 62, 0.20)'; } const alpha: string = confidence >= 2 ? '0.88' : (confidence === 1 ? '0.58' : '0.30'); if (depth < 700) { return `rgba(255, 117, 92, ${alpha})`; } if (depth < 1500) { return `rgba(255, 209, 90, ${alpha})`; } if (depth < 3000) { return `rgba(93, 225, 168, ${alpha})`; } return `rgba(85, 181, 255, ${alpha})`; }Canvas 每次清空旧画面,再按行列绘制全部色块:
for (let index: number = 0; index < result.depths.length; index += 1) { const row: number = Math.floor(index / GRID_COLUMNS); const column: number = index % GRID_COLUMNS; this.heatmapContext.fillStyle = this.depthColor( result.depths[index], result.confidences[index] ); this.heatmapContext.fillRect( lensX + column * cellWidth, lensY + row * cellHeight, Math.max(1, cellWidth - 0.35), Math.max(1, cellHeight - 0.35) ); }修订版的实时画面已经没有规则空带,中心距离为 1.52 m,范围为 0.91–2.44 m,784 个网格全部取得有效深度。
这里最容易看错的是“高置信度”。例如高置信度为 11%,不代表只有 11% 的格子有距离。本次仍然是784/784个格子有效,只是其中 11% 被系统标为更可信;其他格子照样显示,只是颜色更淡。
跟着真机做一遍
1. 启动深度实验
进入实验 09 后,先拿着手机缓慢扫过木纹、家具边缘或明暗变化明显的区域。需要移动的是手机,不是在屏幕上滑动。
页面开始时显示“等待深度”;出现彩色网格、中心距离和784/784后,说明距离数据已经可用。
2. 锁定中近距离结果
把准星对准 0.4–1 米内的木纹地面。等中心数字不再大幅跳动后,点击“锁定当前深度证据”。按钮变成“继续实时采样”,说明当前画面已经冻结。第 204 帧结果为:
DEPTH_EVIDENCE_LOCK sample=204 centerMm=791 minMm=483 maxMm=804 valid=784 highConfidenceRatio=11这行日志翻译成页面数据就是:中心 791 mm,画面范围约 0.48–0.80 m,784 个格子全部有效,其中 11% 为高置信度。锁定只停止页面采样,不会关闭相机。
3. 锁定远距离结果
点击“继续实时采样”,把手机对准更远区域。等中心距离稳定在米级后再次锁定,本次记录为:
DEPTH_EVIDENCE_LOCK sample=159 centerMm=2906 minMm=1431 maxMm=3104 valid=784 highConfidenceRatio=9这行日志表示中心 2906 mm,画面范围约 1.43–3.10 m,784 个格子仍全部有效。两次中心距离相差 2115 mm,页面也从暖色为主变成绿色、蓝色为主,数字和颜色变化能够互相验证。
暂停、恢复和销毁
远距离证据锁定后先恢复实时采样,再点击“暂停深度”。页面停在第 274 帧,等待 5 秒后没有出现新的DEPTH_SAMPLE。
点击“恢复深度”后,日志先记录从第 274 帧恢复,随后采样增长到第 280、288、296 帧,页面重新显示“实时深度”。
DEPTH_SESSION_PAUSE sample=274 DEPTH_SESSION_RESUME sample=274 locked=false DEPTH_SAMPLE sample=280 image=256x256 format=4 valid=784 highConfidenceRatio=9 centerMm=2906 minMm=1431 maxMm=3104点击“销毁退出”后,采样停在第 326 帧,错误计数为 0,页面返回实验工作台。
DEPTH_SESSION_DESTROY sample=326 pending=19 errors=0 locked=false深度图、置信度图和 AR 帧都在finally中释放。即使读取深度时抛出异常,也不会跳过释放路径。
} finally { if (confidenceImage) { await confidenceImage.release(); } if (depthImage) { await depthImage.release(); } if (frame) { await frame.release(); } }页面退出时销毁ARViewContext,并清空 Canvas:
private async destroyARView(): Promise<void> { 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.heatmapContext.clearRect( 0, 0, this.heatmapContext.width, this.heatmapContext.height ); } }构建并确认不是演示数据
静态回归检查覆盖以下内容:
- DEPTH 能力门禁;
- 自动深度和自动对焦配置;
- 深度图、置信度图的
Int32Array转换; y * width + x平面索引;- 中心区域中位数;
- Canvas 热力图;
- 锁定、暂停、恢复和销毁日志;
- 深度图、置信度图和 AR 帧释放;
- 随机或模拟深度数据为 0 命中。
执行结果:
AR Engine 深度估计静态回归检查通过。随后执行 clean 签名构建:
hvigorw clean assembleHap--no-daemon本次结果:
CompileArkTS SUCCESS PackageHap SUCCESS SignHap SUCCESS BUILD SUCCESSFUL in 17 s 410 ms最终签名 HAP 大小为 2,780,410 bytes,覆盖安装和 Ability 启动均成功。
最后得到的结果
| 验证项 | 实际结果 |
|---|---|
| CAMERA、SLAM、DEPTH 门禁 | 通过 |
| 深度图与置信度图 | 成功取得 |
| 深度图尺寸 | 256 × 256 |
| 热力图网格 | 28 × 28,784 个格子 |
| 修订版有效抽样 | 784/784 |
| 中近距离中心值 | 791 mm |
| 远距离中心值 | 2906 mm |
| 两组中心差值 | 2115 mm |
| 暂停 | 第 274 帧停止增长 |
| 恢复 | 从第 274 帧恢复并继续出帧 |
| 销毁 | 第 326 帧销毁,errors=0 |
这次案例完成的是一条完整的真实深度链路:AR 会话取得 16 位毫米深度和置信度,ArkTS 按官方方式转换数组,固定网格抽样并计算中心中位数,Canvas 把结果画到真实相机画面上,最后用近、远两组数据和完整生命周期做了验证。
官方资料
- AR Engine Kit 指南
- 获取深度估计信息(ArkTS)
- ArrayBuffer 数据类型转换说明