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 应用,targetSdkVersion 26 |
| 必需权限 | CAMERA、ACCELEROMETER、GYROSCOPE |
| 必需能力 | SLAM、DEPTH |
| AR 类型 | WORLD |
| 深度模式 | AUTOMATIC |
工程已经完成签名,真机连接正常。应用入口先检查相机权限、SLAM 和 DEPTH,三项全部满足后才显示可用的"实验 09:实时深度热力镜"。

权限声明沿用 AR Engine 工程的基础配置:
json5
"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"
}
}
]
入口页在创建会话前做能力门禁:
ArkTS/ets
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 都关闭,本次页面只处理距离数据。
ArkTS/ets
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 最多一次;页面暂停、结果锁定、页面退出或上一轮还没读完时,都不会重复读取。
ArkTS/ets
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() 返回同一位置的可信程度。两张图尺寸相同,下标也一一对应。
ArkTS/ets
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 时,页面把它显示成"正在准备",继续等待,而不是立即判定失败:
ArkTS/ets
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 小区域取代表值:
ArkTS/ets
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 区域的有效值,再取排在中间的数。这样某一个点突然为空或跳得很远时,页面数字不会立刻跟着乱跳。
ArkTS/ets
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 个格子。距离决定颜色,可信程度只决定颜色是更实还是更淡:
ArkTS/ets
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 每次清空旧画面,再按行列绘制全部色块:
ArkTS/ets
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 帧结果为:
text
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. 锁定远距离结果
点击"继续实时采样",把手机对准更远区域。等中心距离稳定在米级后再次锁定,本次记录为:
text
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 帧,页面重新显示"实时深度"。

text
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,页面返回实验工作台。

text
DEPTH_SESSION_DESTROY sample=326 pending=19 errors=0 locked=false
深度图、置信度图和 AR 帧都在 finally 中释放。即使读取深度时抛出异常,也不会跳过释放路径。
ArkTS/ets
} finally {
if (confidenceImage) {
await confidenceImage.release();
}
if (depthImage) {
await depthImage.release();
}
if (frame) {
await frame.release();
}
}
页面退出时销毁 ARViewContext,并清空 Canvas:
ArkTS/ets
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 命中。
执行结果:
text
AR Engine 深度估计静态回归检查通过。
随后执行 clean 签名构建:
powershell
hvigorw clean assembleHap --no-daemon
本次结果:
text
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 把结果画到真实相机画面上,最后用近、远两组数据和完整生命周期做了验证。