AR Engine稳定性诊断实战封面

这次做了一个可以直接复现问题的 AR 稳定性诊断台。镜头正常扫描木地板时,页面显示 TRACKING、平面数量和边界顶点;遮住摄像头后,状态变成 PAUSED,原因是 INSUFFICIENT_FEATURES;快速移动手机时,又真实触发了 EXCESSIVE_MOTION

镜头重新看到原来的环境后,页面会记录暂停了多久,以及恢复前后的位移和转角差。如果恢复结果不可信,还可以直接销毁旧会话并创建一个全新的 WORLD 会话。

正常扫描时,真机取得了 1 个跟踪平面和 11 个边界顶点。识别到平面之前,平面列表连续为空 4 次,页面只增加“空保护”计数,没有读取不存在的对象:

正常平面、真实顶点与空列表保护

完整遮挡镜头后,真机暂停了 10162 ms。重新对准木地板时,会话恢复为 TRACKING,恢复前后的位移差为 0.692 m,转角差为 50.1°:

遮挡恢复后的位姿差

本次结果

验证项目 真机结果
设备 HUAWEI Mate 60 Pro
系统 HarmonyOS 7.0
SDK API 26
CAMERA 权限 已授予
SLAM 能力 支持
正常平面 1 个
初次有效边界 11 个顶点
空平面保护 4 次,无异常
镜头遮挡原因 INSUFFICIENT_FEATURES
遮挡恢复 10162 ms / 0.692 m / 50.1°
快速移动原因 EXCESSIVE_MOTION
快速移动恢复 1003 ms / 0.188 m / 34.3°
一键重建 旧上下文销毁成功,新会话 22 ms 初始化
重建后结果 重新进入 TRACKING,取得 1 个平面
最终释放 暂停、恢复、销毁均成功

实验准备

本次需要:

  • 一台支持 ARENGINE_FEATURE_TYPE_SLAM 的 HarmonyOS 真机;
  • CAMERA 权限;
  • 一块纹理明显、光线正常的地面;
  • 一面纯色墙或可以完整遮挡摄像头的手掌;
  • 手机周围留出安全活动空间,用于短距离快速移动测试。

磁铁、长时间高负载和故意让手机发烫都不在实验范围内。官方提到这些因素可能造成漂移或位姿跳变,但没有必要为了文章主动制造不安全条件。

诊断台要看哪些数据

这次没有把所有 AR 数据都铺在页面上,只保留能判断问题的五段链路:

状态、原因、空对象保护、位姿差与会话重建

  1. 先读相机的跟踪状态;
  2. 暂停时再看真实原因;
  3. 平面列表为空就安全跳过;
  4. 恢复后计算前后 Pose 差值;
  5. 需要时销毁旧会话并重新初始化。

页面顶部显示相机状态、平面数、Polygon 顶点、空列表保护次数和恢复次数。底部保存最近一次暂停时长、恢复位移和恢复转角。

进入前检查 CAMERA 和 SLAM

诊断页使用 WORLD 会话。入口先检查相机权限和 SLAM 能力,任一条件不满足都不创建 ARViewContext

private startARStabilityDiagnostic(): void {
  if (!this.cameraGranted) {
    this.latestMessage =
      '未获得相机权限,不启动诊断';
    return;
  }

  const supported: boolean =
    arViewController.isARTypeSupported(
      arEngine.ARFeatureType
        .ARENGINE_FEATURE_TYPE_SLAM);

  if (!supported) {
    this.latestMessage =
      '当前设备不支持 SLAM';
    return;
  }

  this.showARStabilityDiagnostic = true;
}

本次真机门禁通过:

STABILITY_GUARD camera=true slam=true

创建只包含必要能力的 WORLD 会话

平面诊断需要运动跟踪和水平/垂直平面。语义、深度和 Mesh 在本次实验中关闭,避免其他模型干扰结果:

const scene: Scene = await Scene.load();
const context =
  new arViewController.ARViewContext();

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,
  poseMode: arEngine.ARPoseMode.GRAVITY,
  depthMode: arEngine.ARDepthMode.DISABLED,
  meshMode: arEngine.ARMeshMode.DISABLED,
  focusMode: arEngine.ARFocusMode.AUTO
};

await context.init();
this.arContext = context;

首轮初始化用了 31 ms:

STABILITY_SESSION_INIT trigger=INIT
costMs=31 restart=0

先看 state,再解释 stateReason

每一帧从相机对象读取 statestateReason

frame = session.getFrame();
const camera: arEngine.ARCamera =
  frame.getCamera();

const state = camera.state;
const reason = camera.stateReason;

if (state ===
  arEngine.ARTrackingState.TRACKING) {
  // 读取真实 Pose 和平面
} else if (state ===
  arEngine.ARTrackingState.PAUSED) {
  // 显示暂停原因,等待恢复
}

原因只负责解释当前状态:

private reasonText(
  reason: arEngine.ARTrackingStateReason
): string {
  if (reason ===
    arEngine.ARTrackingStateReason
      .EXCESSIVE_MOTION) {
    return '设备移动过快';
  }

  if (reason ===
    arEngine.ARTrackingStateReason
      .INSUFFICIENT_FEATURES) {
    return '纹理、光线或视觉特征不足';
  }

  return '系统正在建立或恢复跟踪';
}

真机中还出现了一个值得保留的细节:状态短暂保持 TRACKING 时,stateReason 已经先变成 1;约 0.5 秒后,状态才正式进入 PAUSED

因此本案例只在 state === PAUSED 时记为跟踪中断。不能看到 reason=1 就直接把页面改成暂停,否则会把过渡帧当成失败。

平面为空时不要继续读取

会话刚进入 TRACKING 时,平面列表通常还是空的。本次首轮连续出现 4 次空列表:

STABILITY_PLANE_GUARD_EMPTY
sample=19 guardCount=1
action=skip_index_and_polygon

STABILITY_PLANE_GUARD_EMPTY
sample=24 guardCount=2
action=skip_index_and_polygon

处理方式是先检查数组长度。为空就更新页面并返回,不读取 trackables[0],也不调用 Polygon 接口:

trackables = session.getAllTrackables(
  arEngine.ARTrackableType.PLANE);

if (trackables.length === 0) {
  this.trackedPlanes = 0;
  this.polygonVertexCount = 0;
  this.emptyPlaneGuardCount += 1;
  return;
}

官方 FAQ 中的 plane is nullptr / 401 是 C API 场景。ArkTS 侧不需要强行制造相同错误码,但根因相同:还没有取得有效平面,就继续读取具体对象。

把 Polygon 的 ArrayBuffer 转成坐标

只有类型为 PLANE、状态为 TRACKING 的对象才读取边界:

const plane: arEngine.ARPlane =
  trackable as arEngine.ARPlane;

const polygonBuffer: ArrayBuffer =
  plane.getPolygonXZ();
const values: Float32Array =
  new Float32Array(polygonBuffer);

边界按 X, Z, X, Z... 排列,所以两个 Float32 数值组成一个顶点。正式计数前继续检查长度和有限值:

private isValidPolygon(
  values: Float32Array
): boolean {
  if (values.length < 6 ||
    values.length % 2 !== 0) {
    return false;
  }

  for (let index: number = 0;
    index < values.length;
    index += 1) {
    if (!Number.isFinite(values[index])) {
      return false;
    }
  }
  return true;
}

const vertexCount: number =
  Math.floor(values.length / 2);

真机首次取得的有效结果是:

STABILITY_PLANE_VALID sample=39
total=1 tracking=1 vertices=11 invalid=0

记录暂停前的最后一组 Pose

TRACKING 进入非跟踪状态时,保存当前时间和最后一组稳定 Pose:

if (previousState ===
    arEngine.ARTrackingState.TRACKING &&
  state !==
    arEngine.ARTrackingState.TRACKING) {
  this.pauseStartedAt = Date.now();
  this.pauseReason = reason;
  this.pauseReferencePose =
    this.latestStablePose;
}

这里同时保存了“最初导致暂停的原因”。实测遮挡期间,原因先是 INSUFFICIENT_FEATURES,随后又变成 NONE。如果每帧都覆盖原因,恢复时就只剩下 NONE,真正的触发原因反而丢了。

恢复时计算位移和转角差

状态重新回到 TRACKING 后,取恢复后的第一组 Pose,与暂停前的 Pose 比较:

const dx = current.x - reference.x;
const dy = current.y - reference.y;
const dz = current.z - reference.z;
const distance = Math.sqrt(
  dx * dx + dy * dy + dz * dz);

const dot = Math.abs(
  currentRotation.x * referenceRotation.x +
  currentRotation.y * referenceRotation.y +
  currentRotation.z * referenceRotation.z +
  currentRotation.w * referenceRotation.w);

const normalizedDot =
  Math.min(1, Math.max(0, dot));
const rotation =
  2 * Math.acos(normalizedDot) *
  180 / Math.PI;

第一次遮挡实验的完整状态变化如下:

STABILITY_PAUSE_START sample=97
state=1 reason=2
reasonText=INSUFFICIENT_FEATURES

STABILITY_TRACKING sample=185
state=0 stateText=TRACKING reason=0

STABILITY_RECOVERY count=1
durationMs=10162 reason=2
hasReference=true
distance=0.692 rotation=50.1

这组数字不是手机真实移动距离的计量结果,而是 AR 会话在暂停前后返回的坐标差。它的用途是判断恢复后坐标是否发生了明显跳变。

快速移动测试

保持镜头对准木地板,短距离快速左右移动手机,随后停住等待恢复。本次真实触发了 EXCESSIVE_MOTION

STABILITY_PAUSE_START sample=173
state=1 reason=1
reasonText=EXCESSIVE_MOTION

STABILITY_RECOVERY count=1
durationMs=1003 reason=1
distance=0.188 rotation=34.3

STABILITY_RECOVERY count=2
durationMs=1704 reason=1
distance=0.118 rotation=20.3

STABILITY_RECOVERY count=3
durationMs=8279 reason=1
distance=0.117 rotation=18.2

本轮没有把快速移动本身的普通 Pose 变化写成故障。只有状态真正进入 PAUSED,并且随后再次回到 TRACKING,才增加一次恢复计数。

一键销毁并重建会话

如果遮挡恢复后叠加物明显错位,或位姿差超出业务可以接受的范围,可以重新初始化会话。

页面的“重建会话”先销毁旧上下文,再清空旧 Pose、平面和暂停状态,最后创建新会话:

private async restartARView(): Promise<void> {
  if (!this.arContext ||
    this.isRestarting) {
    return;
  }

  this.isRestarting = true;
  this.restartCount += 1;

  const oldContext = this.arContext;
  this.arContext = undefined;
  await oldContext.destroy();

  this.resetRuntimeState();
  await this.initARView('RESTART');
  this.isRestarting = false;
}

真机执行结果:

STABILITY_SESSION_RESTART_BEGIN
restart=1 sample=166 recoveries=0

STABILITY_CONTEXT_DESTROY
action=RESTART success=true

STABILITY_SESSION_INIT
trigger=RESTART costMs=22 restart=1

STABILITY_SESSION_RESTART_END
restart=1 success=true

重建后新会话重新进入 TRACKING。识别平面之前,空列表保护再次生效;随后取得 1 个平面和 8 个边界顶点:

会话重建后重新取得平面

实操步骤

1. 建立正常基线

进入诊断页后,把镜头缓慢扫向木地板。页面先显示空保护计数,随后出现“平面链路正常”。本次基线为 1 个平面、11 个顶点。

2. 遮挡镜头

用手完整遮住后置摄像头约 3 秒,然后移开手并重新对准木地板。真机先返回 INSUFFICIENT_FEATURES,恢复时记录 10162 ms、0.692 m 和 50.1°。

3. 快速移动

在安全范围内短距离快速左右移动手机 3~5 次,随后停住。真机返回 EXCESSIVE_MOTION,并完成多次暂停—恢复。

4. 重建会话

点击“重建会话”。旧相机上下文销毁后,页面回到建立环境坐标状态;继续扫描地面,新会话再次进入 TRACKING 并得到平面。

5. 验证生命周期

先点击“暂停”,等待数秒,再点击“恢复”,最后退出页面。本轮暂停和恢复时采样数都为 100,证明暂停期间没有继续采样:

STABILITY_SESSION_PAUSE
sample=100 state=0 planes=1

STABILITY_SESSION_RESUME
sample=100 state=0 planes=1

STABILITY_CONTEXT_DESTROY
action=EXIT success=true

STABILITY_SESSION_DESTROY
samples=100 recoveries=1
emptyGuards=9 maxPlanes=1 restarts=1

实验中遇到的情况

stateReason 会先于状态变化

快速移动时,个别帧仍是 TRACKING,但原因已经变成 EXCESSIVE_MOTION。约半秒后状态才进入 PAUSED。最终逻辑以 state 判断是否暂停,以 stateReason 解释原因。

暂停原因会从具体原因变回 NONE

遮挡后先返回 INSUFFICIENT_FEATURES,恢复过程中又返回 NONE。页面在第一次离开 TRACKING 时保存触发原因,避免后续帧覆盖。

入场阶段没有平面是正常状态

新会话进入 TRACKING 不代表平面已经生成。空数组需要跳过,而不是直接读取第一个元素。首轮和重建后的真机日志都验证了这条保护。

重建必须先销毁旧上下文

只清空页面数值并不能重置 AR 坐标系。真正的重建需要等待旧 ARViewContext.destroy() 完成,再创建新的 Scene、Context 和会话配置。

资源释放

每轮帧读取结束后释放 Pose、全部 Trackable 和 Frame:

if (pose) {
  await pose.release();
}

for (const trackable of trackables) {
  await trackable.release();
}

if (frame) {
  await frame.release();
}

页面退出前销毁 ARViewContext,防止相机继续占用。暂停、恢复、重建和退出都带重复调用保护。

最终结果

这次实操把 AR Engine FAQ 中几类容易混在一起的问题拆成了可以观察的数据:

  • 没有平面时,空数组保护可以避免继续读取无效对象;
  • 纯色、遮挡或视觉信息不足时,真机返回 INSUFFICIENT_FEATURES
  • 快速移动时,真机返回 EXCESSIVE_MOTION
  • 遮挡恢复后,前后 Pose 差可以直接显示坐标跳变量;
  • 对恢复结果不放心时,可以销毁旧上下文并创建新会话;
  • 暂停、恢复和退出后,采样与相机资源都按预期收口。

磁场干扰、设备发热和重复纹理造成的漂移没有在本次实验中主动制造,因此不写成已经复现。诊断台验证的是状态读取、防空、恢复差值和会话重建这条完整链路。

参考资料

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐