01Spatial Documentation

Machine-readable: manifest · index · 简体中文 Markdown

AR 应用定位指南

这篇教程介绍如何把一张相机图片,或一组来自同一段连续 AR 跟踪过程的 Rig.v2 相机帧(下文简称“帧”)发送给 01Spatial。系统会返回相机位姿,也就是相机在地图中的位置和朝向;应用再用这个结果把 AR 内容对齐到真实世界。

本文范围: 使用你自己的 API Key 和 Map Key(或 Project Key)完成单帧或 Rig.v2 多帧定位,并把返回位姿用于 AR 对齐。响应中除 AR 对齐必需的位姿矩阵和来源帧外,本文只说明 confidence;其他字段不属于客户端集成契约。

1. 先理解定位结果

AR 框架和 01Spatial 负责不同的工作:

  1. AR 框架在当前 AR Session 中平滑地跟踪设备。
  2. 01Spatial 找到拍摄这张图片时,相机在持久地图中的位置和朝向。
  3. 应用把这两个相机位姿组合起来,让地图和 AR Session 对齐。
  4. 保存在地图坐标系中的 AR 内容就能出现在正确位置。
拍照时的 AR 相机位姿 ─┐
                       ├─► 地图到 AR 的对齐变换 ─► AR 内容
01Spatial 地图相机位姿 ─┘

01Spatial 返回的是相机位姿,不会决定 AR 内容应该放在哪里。每个物体都需要提前在地图坐标系中保存自己的位置和朝向。

2. 开始前需要准备什么

你需要:

  • Portal 个人资料中的 API Key
  • Portal 中一张已就绪地图的 Map Key,或多地图定位使用的 Project Key
  • 一张相机图片。
  • 这张图片对应的相机内参,也就是描述焦距和图像中心的 fxfycxcy
  • 与图片同一时刻的 AR 相机变换。

API_HOST 设置为保存定位目标并签发对应 Key 的 Portal 来源地址(origin)。Origin 只包含协议和主机名,不包含路径、查询参数和结尾的斜杠。

请把 API_HOST 当作运行时配置。不要根据设备语言猜测服务器,也不要在公共应用代码中写死某个区域的主机名。这样以后增加新区域时,同一份应用代码仍然可以使用。

请妥善保管两个 Key。把它们放在请求体中,不要放进 URL,不要提交到公开代码仓库,也不要写入日志。

3. 获取同一帧的图片、内参和 AR 位姿

请使用目标设备对应的 AR 框架。图片、内参和 AR 相机变换必须来自同一个相机帧。不要使用屏幕截图或另外调用普通相机拍照,因为这些图片无法和 AR 相机位姿可靠对应。

平台获取相机图片保存同一帧的数据
iOS 或 iPadOSARKit ARFrame.capturedImageARFrame.camera.intrinsicsARFrame.camera.transform
AndroidARCore Frame.acquireCameraImage()frame.getCamera().getImageIntrinsics()frame.getCamera().getPose()
微信小程序XR-FRAME scene.ar.getARRawData() 中的相机数据同一次结果中的 intrinsicsviewMatrix,并转换、保存对应的相机到 Session 矩阵 trackerSpace
UnityAR Foundation ARCameraManager.TryAcquireLatestCpuImage()同一次相机更新中的 TryGetIntrinsics() 结果和 AR Camera 变换

如果图片编码或格式转换是异步的,请在开始异步任务前复制这一帧的内参和相机变换。完成像素复制或编码后,要及时释放 ARCore 的 Image 和 AR Foundation 的 XRCpuImage

四个内参都是像素值,而且必须对应最终上传的那张图片:

字段含义
fx水平方向焦距
fy垂直方向焦距
cx主点的水平位置
cy主点的垂直位置

如果图片缩放了,内参也要按相同比例缩放。例如,把 1920 × 1080 缩小为 960 × 540

scale_x = 960 / 1920 = 0.5
scale_y = 540 / 1080 = 0.5

fx' = fx × scale_x
fy' = fy × scale_y
cx' = cx × scale_x
cy' = cy × scale_y

如果裁剪了图片,要从 cx 中减去裁剪区域左边的偏移量,从 cy 中减去顶部偏移量。

第一次接入时,建议保留相机帧的原始方向,不要在上传前旋转图片。如果必须旋转,只更新图片尺寸和内参还不够:返回的位姿会使用旋转后的相机轴。你还需要在第 8 节对齐相机轴时加入同一个旋转补偿。

拍照时还要保存 AR 框架的相机到 Session 变换。不要等网络响应回来后再读取,因为这时设备可能已经移动了。

4. 发送定位请求

POST /localize 发送 multipart/form-data 请求:

: "${API_HOST:?请把 API_HOST 设置为保存该地图的 Portal origin}"

curl -X POST "${API_HOST%/}/localize" \
  -F "api_key=YOUR_API_KEY" \
  -F "map_key=YOUR_MAP_KEY" \
  -F "fx=1100" \
  -F "fy=1100" \
  -F "cx=960" \
  -F "cy=540" \
  -F "image_path=@frame.jpg"

在 JavaScript 中,让 FormData 自动生成 Content-Type 和 multipart boundary,不要手动设置这个请求头。

type Intrinsics = {
  fx: number;
  fy: number;
  cx: number;
  cy: number;
};

async function localizeFrame(
  apiHost: string,
  apiKey: string,
  mapKey: string,
  image: Blob,
  intrinsics: Intrinsics,
) {
  const localizationUrl = new URL('/localize', new URL(apiHost).origin);
  const body = new FormData();
  body.set('api_key', apiKey);
  body.set('map_key', mapKey);
  body.set('fx', String(intrinsics.fx));
  body.set('fy', String(intrinsics.fy));
  body.set('cx', String(intrinsics.cx));
  body.set('cy', String(intrinsics.cy));
  body.set('image_path', image, 'frame.jpg');

  const response = await fetch(localizationUrl, {
    method: 'POST',
    body,
  });

  if (response.status === 422) return null;
  if (!response.ok) {
    throw new Error(`Localization request failed (${response.status})`);
  }
  return response.json();
}

5. 读取位姿和置信度

定位成功时,poses[0] 中会有一个位姿。下面只保留 AR 应用需要读取的字段:

{
  "poses": [
    {
      "camera_to_world": {
        "matrix_column_major": [
          1, 0, 0, 0,
          0, 1, 0, 0,
          0, 0, 1, 0,
          1.2, 0.4, -3.1, 1
        ]
      },
      "metrics": {
        "confidence": 0.86
      }
    }
  ]
}
  • matrix_column_major 是完整的 4 × 4 相机到地图变换,AR 对齐时优先使用它。
  • confidence 是服务器对本次位姿可靠程度的评分,范围为 01,数值越高表示结果越可靠。

接入初期可以先采用下面这个简单规则,再根据真实场地的验收结果调整阈值:

confidence >= 0.7  → 使用新的位姿
confidence < 0.7   → 保留当前对齐,重新拍一张图片

confidence 不是距离误差,也不代表成功概率。

HTTP 422 表示这张图片没有得到可靠位姿。不要修改当前对齐。请换一张清晰、能看到更多已建图区域细节的图片再试。

如果单帧结果经常不稳定,请使用下一节的 Rig.v2,不要连续应用多个彼此独立的单帧结果。

6. 使用 Rig.v2 完成多帧定位

POST /localize_rig 接收同一个连续跟踪周期(tracking epoch)中的 2–6 个相邻相机帧。这个周期内 AR 跟踪不能中断或重置。接口会返回其中一个采集帧对应的定位位姿,适合单帧定位容易受视角、遮挡或画面细节不足影响的场景。

当单个视角中只有白墙、画面被遮挡、存在重复纹理或眩光,或者只能看到很少的已建图区域时,Rig.v2 很有帮助。手机或平板可以在单帧失败后,引导用户缓慢转过大约半圈并采集四个清晰视角。不要发送多张位置和方向几乎相同的图片;它们无法提供更多有效视角信息。

6.1 采集一组属于同一跟踪过程的帧

采集每一帧时,都必须同时保存以下数据:

  • 最终上传的 JPEG 像素。
  • 对应这些最终像素的相机内参。
  • 相机到 AR Session 的变换。
  • 按帧单调不减的采集时间戳。
  • 当前 AR 跟踪状态(tracking state)和连续跟踪周期(tracking epoch)。

同一次请求中的所有帧必须属于同一个 tracking epoch。如果 AR Session 结束、参考空间(reference space)被重置或跟踪中断,这一组帧就不再属于同一段连续跟踪过程,应立即取消旧请求。只使用跟踪状态(tracking state)为 normal 时采集的帧。

calibrated_rig 要求相机图片、内参和跟踪位姿(tracking pose)描述同一台物理相机。ARKit、ARCore 和 AR Foundation 在正确同步采样时可以提供这些数据。WebXR 只有在 Raw Camera Access 返回与同一个 XRView 关联的图片时才满足条件;独立的 getUserMedia 视频流不能用于 Rig 定位。

第一次接入时请保留编码像素的原始采集方向。把 EXIF orientation 实际应用到像素,并把 Orientation 写为 1(或移除该字段),然后把 encoded_image_rotation_cw_deg 设置为 0。这样图片、内参、相机轴和相对位姿(relative pose)的含义都是唯一、明确的。

6.2 计算帧间相对位姿

T_A_Ci_graphics 表示采集第 i 帧时保存的相机到 AR Session 变换。编码像素没有旋转时,计算:

T_frame0_from_frame_i = inverse(T_A_C0_graphics) × T_A_Ci_graphics

这个变换把第 i 帧的相机坐标转换到第 0 帧的相机坐标。把旋转编码为归一化的 quat_wxyz,把平移编码为 tvec

  • frames[0].relative_pose 必须是 null
  • calibrated_rig 中后续每一帧都必须携带 relative_pose
  • relative_pose_semantics 必须是 frame0_from_frame
  • relative_pose_coordinate_system 必须是 camera_gl_encoded

如果在 JPEG 编码前旋转了像素,还必须同步旋转内参,并把每一个相对位姿转换到编码图片对应的 OpenGL 风格相机轴(graphics-camera)。这个数学操作通常称为共轭变换。在这个转换通过测试之前,不要发送 calibrated 请求;服务端不会猜测图片旋转或坐标系含义。

6.3 构造 Rig.v2 请求头

必须设置 rig_schema_version: 2。下面的示例只包含 AR 客户端构造标定型(calibrated)Rig 请求所需的字段。

下面是两帧示例;生产客户端最多可以发送六帧。声明的宽高必须和解码后的 JPEG 完全一致,时间戳必须单调不减,frame_id 必须唯一,所有帧的 tracking_epoch 必须一致。

{
  "map_key": "YOUR_MAP_KEY",
  "api_key": "YOUR_API_KEY",
  "rig_schema_version": 2,
  "rig_capability": "calibrated_rig",
  "client_platform": "ios",
  "client_build": "1.0.0",
  "operation_id": "rig-7f4d2c",
  "relative_pose_semantics": "frame0_from_frame",
  "relative_pose_coordinate_system": "camera_gl_encoded",
  "intrinsics_coordinate_space": "encoded_pixels",
  "frames": [
    {
      "frame_id": "frame-0",
      "fx": 1100.0,
      "fy": 1100.0,
      "cx": 960.0,
      "cy": 540.0,
      "image_width": 1920,
      "image_height": 1080,
      "capture_timestamp_s": 1234.000,
      "tracking_epoch": "ar-session-42",
      "tracking_state": "normal",
      "encoded_image_rotation_cw_deg": 0,
      "capture_quality": {},
      "relative_pose": null
    },
    {
      "frame_id": "frame-1",
      "fx": 1100.0,
      "fy": 1100.0,
      "cx": 960.0,
      "cy": 540.0,
      "image_width": 1920,
      "image_height": 1080,
      "capture_timestamp_s": 1234.620,
      "tracking_epoch": "ar-session-42",
      "tracking_state": "normal",
      "encoded_image_rotation_cw_deg": 0,
      "capture_quality": {},
      "relative_pose": {
        "quat_wxyz": [0.9998, 0.0, 0.0175, 0.0],
        "tvec": [0.05, 0.0, -0.02]
      }
    }
  ]
}

使用 Project 进行多地图定位时,以 project_key 替代 map_key,不能同时发送两种目标。无论使用哪种模式,api_key 都必须有权访问目标地图或 Project。

6.4 发送带长度前缀的二进制请求体

请求使用 application/octet-stream,不是 multipart form data。二进制布局为:

[4 字节小端序 JSON 长度]
[UTF-8 JSON Header]
[4 字节小端序 JPEG 0 长度]
[JPEG 0 数据]
[4 字节小端序 JPEG 1 长度]
[JPEG 1 数据]
...

JPEG 的顺序必须和 frames 数组完全一致。下面的浏览器示例使用 Blob parts,避免为了组装请求再复制一份全部 JPEG 数据:

function uint32LE(value: number): Uint8Array {
  const bytes = new Uint8Array(4);
  new DataView(bytes.buffer).setUint32(0, value, true);
  return bytes;
}

function buildRigBody(header: object, jpegFrames: Blob[]): Blob {
  const headerBytes = new TextEncoder().encode(JSON.stringify(header));
  const parts: BlobPart[] = [uint32LE(headerBytes.byteLength), headerBytes];

  for (const jpeg of jpegFrames) {
    parts.push(uint32LE(jpeg.size), jpeg);
  }
  return new Blob(parts, { type: 'application/octet-stream' });
}

async function localizeRig(
  apiHost: string,
  header: object,
  jpegFrames: Blob[],
  signal: AbortSignal,
) {
  const response = await fetch(
    new URL('/localize_rig', new URL(apiHost).origin),
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/octet-stream' },
      body: buildRigBody(header, jpegFrames),
      signal,
    },
  );
  const result = await response.json();
  return { response, result };
}

一旦 AR Session 或 tracking epoch 失效,立即取消正在进行的请求,并丢弃随后才返回的响应。

6.5 应用返回位姿

Rig.v2 成功响应中,poses[0].image 是该位姿对应的上传帧:

{
  "poses": [
    {
      "image": "frame-1",
      "camera_to_world": {
        "matrix_column_major": [
          1, 0, 0, 0,
          0, 1, 0, 0,
          0, 0, 1, 0,
          1.2, 0.4, -3.1, 1
        ]
      },
      "metrics": {
        "confidence": 0.91
      }
    }
  ]
}

只在 HTTP 状态为 200poses[0] 存在、confidence 满足客户端策略,且 AR Session 没有发生变化时应用结果。poses[0].image 必须属于本次上传的帧。

返回的 camera_to_world 属于 poses[0].image,它不一定是第 0 帧。必须使用该帧采集时保存的相机到 Session 变换,代入第 8 节的对齐公式:

T_A_M = T_A_Cselected_graphics × D_cv_to_graphics × inverse(T_M_Cselected)

如果返回的是其他帧却仍使用第 0 帧的 AR 位姿,会产生对齐误差。HTTP 422 表示本次没有可靠位姿;保留当前对齐并重新采集,不要应用部分结果。

7. 理解坐标系

下面使用三个坐标系和一个变换符号:

  • M:地图坐标系。
  • A:当前 AR Session 坐标系。
  • C:相机坐标系。
  • T_M_C:从相机坐标系变换到地图坐标系,也就是 API 返回的 camera_to_world 矩阵。

camera_to_world 中的相机轴采用计算机视觉惯例:

方向
+X向右
+Y向下
+Z向前

WebXR 和 OpenGL 风格的 AR 相机使用 +X 向右、+Y 向上、-Z 向前。需要做一次相机轴转换:

D_cv_to_graphics = diag(1, -1, -1, 1)

位姿中的世界坐标使用建图时定义的地图坐标系。需要长期保存的 AR 内容也必须使用同一个坐标系。不要假设所有地图都有相同的向上轴。如果地图已经校准为真实大小,平移单位就是米。

8. 把地图内容对齐到 AR Session

拍摄定位图片时,保存 AR 相机到 Session 的变换 T_A_C_graphics。如果上传图片相对于这个相机帧没有旋转,定位成功后计算:

T_A_M = T_A_C_graphics × D_cv_to_graphics × inverse(T_M_C)

如果上传图片发生了旋转,用 R_upload_from_capture 表示“从原始计算机视觉相机轴变换到上传图片相机轴”的旋转。此时计算:

T_A_M = T_A_C_graphics × D_cv_to_graphics × inverse(R_upload_from_capture) × inverse(T_M_C)

图片没有旋转时,R_upload_from_capture 就是单位矩阵,两个公式完全相同。

T_A_M 可以把地图坐标变换到当前 AR Session。把它应用到包含全部地图内容的根节点:

AR Session
└── mapRoot             在这里应用 T_A_M
    ├── 信息标记        保存在地图坐标系中
    ├── 导航路线        保存在地图坐标系中
    └── 3D 物体         保存在地图坐标系中

如果某个物体在地图中的变换是 T_M_O,它最终在 AR Session 中的变换为:

T_A_O = T_A_M × T_M_O

不要修改 AR 框架的相机变换。让本地 AR 跟踪继续平滑地更新相机。如果需要修正跟踪漂移,可以在下一次可靠定位后更新 mapRoot

9. Three.js 和 WebXR 示例

THREE.Matrix4.fromArray() 可以直接读取 matrix_column_major,不要再转置一次。

import * as THREE from 'three';

function readTrustedPose(response: any) {
  const pose = response?.poses?.[0];
  const matrix = pose?.camera_to_world?.matrix_column_major;
  const confidence = pose?.metrics?.confidence;

  if (!Array.isArray(matrix) || matrix.length !== 16) return null;
  if (!Number.isFinite(confidence) || confidence < 0.7) return null;
  return pose.camera_to_world;
}

function alignMapRoot(
  mapRoot: THREE.Object3D,
  arCameraAtCapture: ArrayLike<number>,
  cameraToWorld: { matrix_column_major: number[] },
) {
  const arFromCamera = new THREE.Matrix4().fromArray(arCameraAtCapture);
  const mapFromCamera = new THREE.Matrix4().fromArray(
    cameraToWorld.matrix_column_major,
  );
  const cvToGraphics = new THREE.Matrix4().makeScale(1, -1, -1);

  const arFromMap = arFromCamera
    .clone()
    .multiply(cvToGraphics)
    .multiply(mapFromCamera.clone().invert());

  mapRoot.matrixAutoUpdate = false;
  mapRoot.matrix.copy(arFromMap);
  mapRoot.matrixWorldNeedsUpdate = true;
}

必须使用生成上传图片的同一帧相机变换:

  • WebXR: XRView.transform.matrix
  • ARKit、RealityKit 或 App Clip: ARFrame.camera.transform
  • 微信 XR-FRAME: 拍照时的 trackerSpace 矩阵。

上面的 Three.js 示例假设上传图片没有旋转。如果图片发生了旋转,请按前面的公式加入 inverse(R_upload_from_capture)。其他 AR 引擎也使用相同的矩阵乘法顺序。

10. 常见问题

结果需要检查什么
HTTP 400图片字段和四个内参是否完整、有效。
HTTP 401403API Key 和 Map Key 是否正确,是否有权访问地图。
HTTP 422换一张更清晰、包含更多已建图细节的图片。
HTTP 429等待一段时间后再发请求。
HTTP 5xx保留当前对齐,稍后重试。
有效的 Map Key 被拒绝确认 API_HOST 指向保存这张地图的同一个 Portal 来源地址(origin)。
内容被镜像或旋转确认相机轴转换只做了一次。
响应回来后内容发生移动必须使用拍照时保存的 AR 相机变换。
内容始终有固定偏移确认物体变换和定位位姿使用同一个地图坐标系。

11. 上线前检查

请确认你的应用:

  • 图片、内参和 AR 相机变换来自同一个 AR 帧。
  • 使用了最终上传图片对应的内参。
  • 在拍照时保存了 AR 相机变换。
  • 如果上传图片发生了旋转,已经补偿了相机轴。
  • 读取 matrix_column_major 后没有再次转置。
  • 相机轴转换只执行了一次。
  • 只使用满足 confidence 置信度规则的位姿。
  • 如果拍照后 AR Session 被重置,会丢弃过期响应。
  • 使用 Rig.v2 时,所有帧属于同一个 tracking epoch,并会取消过期请求。
  • 使用 Rig.v2 时,会使用 poses[0].image 对应帧保存的 AR 相机变换,而不是始终使用第 0 帧。
  • 持久内容和定位位姿使用同一个地图坐标系。
  • 把对齐变换应用到地图根节点,而不是覆盖 AR 相机位姿。
  • 从运行时配置读取 API_HOST,并使用保存该地图的 Portal。
  • 不在 URL、日志或公开代码中暴露 API Key 和 Map Key。

本文未说明的响应字段不应成为客户端逻辑的依赖。