# 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 内容就能出现在正确位置。

```text
拍照时的 AR 相机位姿 ─┐
                       ├─► 地图到 AR 的对齐变换 ─► AR 内容
01Spatial 地图相机位姿 ─┘
```

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

## 2. 开始前需要准备什么

你需要：

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

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

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

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

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

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

| 平台 | 获取相机图片 | 保存同一帧的数据 |
| --- | --- | --- |
| iOS 或 iPadOS | ARKit `ARFrame.capturedImage` | `ARFrame.camera.intrinsics` 和 `ARFrame.camera.transform` |
| Android | ARCore `Frame.acquireCameraImage()` | `frame.getCamera().getImageIntrinsics()` 和 `frame.getCamera().getPose()` |
| 微信小程序 | XR-FRAME `scene.ar.getARRawData()` 中的相机数据 | 同一次结果中的 `intrinsics` 和 `viewMatrix`，并转换、保存对应的相机到 Session 矩阵 `trackerSpace` |
| Unity | AR Foundation `ARCameraManager.TryAcquireLatestCpuImage()` | 同一次相机更新中的 `TryGetIntrinsics()` 结果和 AR Camera 变换 |

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

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

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

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

```text
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` 请求：

```bash
: "${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，不要手动设置这个请求头。

```ts
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 应用需要读取的字段：

```json
{
  "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` 是服务器对本次位姿可靠程度的评分，范围为 `0` 到 `1`，数值越高表示结果越可靠。

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

```text
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 变换。编码像素没有旋转时，计算：

```text
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` 必须一致。

```json
{
  "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。二进制布局为：

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

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

```ts
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` 是该位姿对应的上传帧：

```json
{
  "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 状态为 `200`、`poses[0]` 存在、`confidence` 满足客户端策略，且 AR Session 没有发生变化时应用结果。`poses[0].image` 必须属于本次上传的帧。

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

```text
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` 向前。需要做一次相机轴转换：

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

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

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

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

```text
T_A_M = T_A_C_graphics × D_cv_to_graphics × inverse(T_M_C)
```

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

```text
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。把它应用到包含全部地图内容的根节点：

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

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

```text
T_A_O = T_A_M × T_M_O
```

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

## 9. Three.js 和 WebXR 示例

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

```ts
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 `401` 或 `403` | API 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。

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