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

这次把一个真实小纸盒放进相机画面,手机扫过正面和侧面后,屏幕上出现了许多小点和一个青绿色立体框。小点表示系统找到的物体表面位置,立体框表示系统估算出的长方体外框。第一次锁定得到:
- 长度:20.8 cm;
- 宽度:12.7 cm;
- 高度:3.5 cm;
- 体积:0.926 L;
- Cube 标签:
CUBE_VOLUME; - 置信度原始值:
1.000。

画面中的深色点、青绿色立体框、8 个白色角点和尺寸都来自同一轮真机扫描。页面没有提前放好一个盒子模型,也没有用随机点把效果补出来。
先看懂页面
第一次打开这个实验,不需要先理解三维数学。先看下面五个地方:
- 黑色小方点:AR Engine 认为属于物体表面的空间点;"稠密"只是表示点比较多;
- 青绿色立体框:系统返回的 Cube,也就是纸盒外面的长方体框,不是应用提前画好的固定模型;
- 8 个白色角点:长方体的 8 个角,程序用它们计算三条边;
- 长、宽、高和体积:直接由当前 Cube 的角点计算,不是手工填写;
- 锁定按钮:把当前点、立体框和数字冻结,方便截图和核对。
判断是否真正成功,不能只看"检测到一个 Cube"。页面必须同时出现非零长宽高、体积和可见立体边,锁定按钮才会启用。
实验准备
本次使用的实际环境如下:
| 项目 | 实际环境 |
|---|---|
| 设备 | HUAWEI Mate 60 Pro |
| 系统 | HarmonyOS 7.0 |
| SDK | API 26 |
| 工程 | ArkTS Phone 应用,targetSdkVersion 26 |
| 必需权限 | CAMERA、ACCELEROMETER、GYROSCOPE |
| 必需能力 | SLAM、SEMANTIC_DENSE |
| AR 会话类型 | WORLD |
| 高精重建模式 | CUBE_VOLUME |
| 扫描对象 | 六面基本平整、边缘清楚的小型纸盒 |
应用启动后先检查相机权限和八类 AR 能力。当前设备显示 8/8 支持,其中 SEMANTIC_DENSE 的说明为"高精几何重建"。

实验工作台新增了"实验 10:高精体积扫描仪"。这个入口只有在相机权限、SLAM 和 SEMANTIC_DENSE 同时满足时才会启动。

能力门禁代码如下:
ArkTS/ets
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 camera=false');
return;
}
if (!this.canStartAR()) {
this.latestMessage =
'拦截成功:当前设备不支持 SLAM,不启动高精几何重建。';
hilog.info(DOMAIN, TAG, 'DENSE_GUARD slam=false');
return;
}
if (!this.canStartSemanticDense()) {
this.latestMessage =
'拦截成功:当前设备不支持高精几何重建,不创建 AR 会话。';
hilog.info(DOMAIN, TAG,
'DENSE_GUARD semanticDense=false');
return;
}
hilog.info(DOMAIN, TAG,
'DENSE_GUARD camera=true slam=true semanticDense=true');
this.showSemanticDense = true;
}
系统怎样把纸盒变成体积
这张图只说明数据处理关系,不是真机结果。真机识别是否成功,仍以正文中的实际相机画面和日志为准。

相机每更新一次画面,就会产生一份 ARFrame。可以把它理解为"这一刻的相机画面和 AR 数据"。页面依次做五件事:
- 从当前画面取得高精几何重建数据;
- 读出物体表面小点的位置和颜色;
- 读出长方体的 8 个角点;
- 用角点计算三条相邻边,再把小点和立体框画回屏幕;
- 长、宽、高相乘得到立方米,再换算成更直观的升。
第一步:让 AR Engine 寻找长方体
页面底层用 ARView 显示真实相机,上层用 Canvas 画小点和立体框。这样移动手机时,标记会跟着真实纸盒的位置变化。
下面这段配置的关键只有一项:semanticDenseMode 选择 CUBE_VOLUME,告诉 AR Engine 本次要寻找可以计算体积的长方体。自动对焦保持开启,普通语义、深度和 Mesh 关闭。
ArkTS/ets
private async initARView(): Promise<void> {
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 ' +
'mode=2 focusMode=1 depthMode=0 meshMode=0',
Date.now() - startTime);
}
最终真机初始化耗时 36 ms:
text
DENSE_GUARD camera=true slam=true semanticDense=true
DENSE_SESSION_INIT success costMs=36 mode=2 focusMode=1 depthMode=0 meshMode=0
第二步:每隔 250 ms 读取一次
相机每秒会产生很多帧。如果每帧都读取并绘制几千个点,页面容易卡顿。下面的判断把读取频率限制为每 250 ms 最多一次;页面暂停、结果锁定、页面退出或上一轮还没结束时,都不重复处理。
ArkTS/ets
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;
});
}
每轮先确认相机正在稳定跟踪,再读取高精重建数据。没有稳定跟踪时直接等待,不显示旧数据冒充新结果:
ArkTS/ets
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转为Int32Array;position转为Float32Array,每个点占 4 个值:三维位置x、y、z和可信程度;color转为Int32Array,每个点占 4 个值:r、g、b、a。
ArkTS/ets
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,说明数据布局能够对上。
text
DENSE_FIRST_POINT latencyMs=18242 points=24 idValues=24 positionValues=96 colorValues=96
继续绕纸盒移动后,点数增长到 553。Canvas 最多画 700 个点;如果系统返回更多,只隔几个取一个来画,页面上的原始点数仍然照常显示。
ArkTS/ets
private drawPointCloud(
positions: Float32Array,
colors: Int32Array,
viewMatrix: Array<number>,
projectionMatrix: Array<number>,
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 次采样已经返回:
text
DENSE_SAMPLE sample=62 points=24 drawnPoints=0 cubes=1 cubeId=0 vertexSize=8 drawnEdges=0 label=10 confidence=1.000 lengthM=0.000 widthM=0.000 heightM=0.000 volumeL=0.000
这里的 cubes=1 只表示"系统已经创建了一条外框记录",不代表 8 个角点已经张开成一个可测量的盒子。此时三条相邻边仍是 0,所以页面继续等待。直到 37.692 秒后,三条边都变成有效非零值,才记录首个可用 Cube:
text
DENSE_FIRST_CUBE latencyMs=37692 id=0 vertexSize=8 label=10 confidence=1.000
因此,页面把成功条件收紧为下面四项:
vertexSize === 8;vertexData至少包含 24 个数;- 24 个值都是有限数;
- 三条相邻边都大于 1 mm,且没有异常大于 10 m。
ArkTS/ets
private firstValidCube(
cubes: Array<arEngine.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 正好是从相邻角点连出的三条边。程序分别算出三条线的长度,再把最长的显示为"长"、中间的显示为"宽"、最短的显示为"高"。
ArkTS/ets
private measureCube(
vertices: Array<number>,
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: Array<number>,
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 换成升:
text
0.208 m × 0.127 m × 0.035 m = 0.000926 m³
0.000926 m³ × 1000 = 0.926 L
第五步:把空间点画回屏幕
接口给出的点是三维空间坐标,而 Canvas 需要的是屏幕上的横纵像素,二者不能直接混用。页面使用相机的观察矩阵和投影矩阵,把"空间中的点"换算成"屏幕上的位置"。第一次阅读时不必推导矩阵,只要记住这一步负责让立体框跟着真实纸盒移动。
ArkTS/ets
private projectWorldPoint(
worldX: number,
worldY: number,
worldZ: number,
viewMatrix: Array<number>,
projectionMatrix: Array<number>,
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 次采样仍然是 points=0 / cubes=0,页面继续等待,没有拿默认数字填空。
2. 缓慢经过正面和两个侧面
拿着手机从纸盒正面缓慢移动到左侧或右侧,尽量让纸盒始终完整入镜。18.242 秒后出现首批 24 个小点;继续看到更多侧面后,页面开始取得 Cube,但尺寸还没有立刻可用。
37.692 秒时三条边第一次都有非零长度,页面开始显示青绿色立体框。之后点数从 21 增长到 375、426,最后达到 553。看到立体框不再贴错位置、长宽高不为 0,才进入下一步。
3. 等立体框覆盖纸盒后锁定
移动视角时,可见 Cube 边数曾从 12 变为 5、4、9,再回到 12。这不是 Cube 数据消失,而是部分顶点暂时落到当前相机视野外。
当纸盒完整入镜、小点覆盖盒子边缘、立体框能跟住纸盒时,点击"锁定当前体积证据"。按钮变成黄色"继续扫描纸箱",说明当前结果已经冻结。第 115 次采样日志为:
text
DENSE_EVIDENCE_LOCK sample=115 points=553 cubeId=0 vertexSize=8 label=10 confidence=1.000 lengthM=0.208 widthM=0.127 heightM=0.035 volumeL=0.926 drawnEdges=12
这行日志翻译成页面数据就是: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。
text
DENSE_EVIDENCE_LOCK sample=473 points=6627 cubeId=0 vertexSize=8 label=10 confidence=1.000 lengthM=0.232 widthM=0.150 heightM=0.050 volumeL=1.741 drawnEdges=12
点击黄色"继续扫描纸箱"后,按钮恢复为"锁定当前体积证据",状态从"证据已锁定"变成"已识别"。采样数从 473 继续增长到 480、488,证明页面确实重新读取了 AR 帧。

text
DENSE_EVIDENCE_CONTINUE sample=473
DENSE_SAMPLE sample=480 points=6627 drawnPoints=663 cubes=1 vertexSize=8 drawnEdges=12 lengthM=0.232 widthM=0.150 heightM=0.050 volumeL=1.741
DENSE_SAMPLE sample=488 points=6627 drawnPoints=663 cubes=1 vertexSize=8 drawnEdges=12 lengthM=0.232 widthM=0.150 heightM=0.050 volumeL=1.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 次采样,按钮变成"恢复重建"。等待期间没有新的采样数。

text
DENSE_SESSION_PAUSE sample=115
点击"恢复重建"后,AR 会话恢复。因为当前证据仍处于锁定状态,页面继续保留第 115 帧结果,不会偷偷覆盖已锁定尺寸。

text
DENSE_SESSION_RESUME sample=115 locked=true
最后点击"销毁退出"。页面返回实验工作台,销毁汇总显示 pending=0 / errors=0,AR 相机页面关闭。

text
DENSE_SESSION_DESTROY sample=115 pending=0 errors=0 points=553 cubes=1 locked=true
退出页面时释放相机数据
每次读取都会占用一份高精数据和一帧相机数据。下面的 finally 无论成功还是报错都会执行,先释放高精数据,再释放相机帧,避免连续扫描后资源越积越多。
ArkTS/ets
} 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 并清空 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.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 命中。
执行结果:
text
AR Engine 高精几何重建静态回归检查通过。
随后执行 clean 签名构建:
powershell
hvigorw clean assembleHap --no-daemon
最终结果:
text
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 s,24 个点 |
| 首个有效 Cube | 37.692 s |
| 最终稠密点 | 553 |
| Cube | 1 个,vertexSize=8 |
| 标签 | label=10,CUBE_VOLUME |
| 置信度原始值 | 1.000 |
| 可见立体边 | 12/12 |
| 长 × 宽 × 高 | 20.8 × 12.7 × 3.5 cm |
| 体积 | 0.926 L |
| 第二轮锁定 | 6627 点,23.2 × 15.0 × 5.0 cm,1.741 L |
| 继续扫描 | 第 473 次解除锁定,采样继续到 480、488 |
| 暂停 | 第 115 次采样保持不变 |
| 恢复 | 会话恢复,锁定证据保持 |
| 销毁 | pending=0 / errors=0,返回工作台 |
本次案例完成了从真实 AR 帧到高精重建结果的完整链路:真机生成稠密点和 8 顶点 Cube,ArkTS 校验顶点并计算三条空间边,Canvas 把世界坐标重新投影到相机画面,最后锁定实际尺寸和体积,并完成继续扫描、暂停、恢复、销毁与逐帧资源释放。两轮扫描的尺寸差异也作为真实结果保留,没有用生成数据把它们改成一致。