> ## Documentation Index
> Fetch the complete documentation index at: https://docs.wuji.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# 离线数据处理

## 概述

本篇实践介绍如何使用离线管线将电磁位姿和原始 IMU 数据转换为手部追踪结果。应用场景包括重新处理录制数据、在无设备环境中调试和比较不同手部模型的求解结果。

对应的 Python 示例为 [3.offline\_pipeline.py](https://github.com/wuji-technology/wuji-sdk/blob/main/examples/python/wuji_glove/3.offline_pipeline.py)。 接口与参数说明见[原始数据转换](/docs/wuji-glove/offline-pipeline)，本页说明集成流程和运行时行为。

<Info>
  C SDK 没有对应的离线管线接口，本页介绍 Python 用法。
</Info>

## 前置条件

### 硬件

无。这是本系列唯一一篇不需要接设备的文章。

### 软件

| 项目 | 要求 |
| - | - |
| 操作系统 | Ubuntu 22.04 LTS 及以上版本 |
| Wuji SDK | v2026.8.31 或更高版本 |
| Python | 3.10 及以上版本 |

```bash theme={null}
pip install wuji-sdk
# 想读 MCAP 录制文件再装
pip install mcap
```

### 前序步骤

想重放真实数据先看[数据录制](/docs/wuji-glove/sdk-practice/data-recording)。只跑示例里的合成帧则无需任何准备。

## 数据流

```mermaid theme={null}
flowchart LR
    subgraph INPUT[输入]
        EMF["EmfPoseArray<br/>合成帧或 MCAP 回放"]
        IMU["ImuData.raw<br/>原始 IMU 帧"]
    end
    CFG["WujiGlove.offline_pipeline(sn, hand_side)<br/>可选 urdf_path"]
    TRACK["hand_joint_angles().compute(frame)<br/>tip_poses().compute(frame)<br/>hand_skeleton().compute(frame)"]
    FUSION["imu_data_palm().compute(frame)<br/>imu_data_*().compute(frame)"]
    OUT1["手部追踪结果"]
    OUT2["融合后的姿态"]

    EMF --> CFG
    CFG --> TRACK
    TRACK --> OUT1
    IMU --> CFG
    CFG --> FUSION
    FUSION --> OUT2
```

## 管线提供的通道

实测 `offline_pipeline()` 返回对象暴露这些方法：

| 方法 | 输入 | 输出 |
| - | - | - |
| `hand_joint_angles()` | `EmfPoseArray` | 5 指关节角，每指 `angles`（长 5）与 `confidence` |
| `tip_poses()` | `EmfPoseArray` | 5 个指端位姿 |
| `hand_skeleton()` | `EmfPoseArray` | 21 关节骨骼 |
| `imu_data_palm()` | `ImuData.raw(...)` | 融合后的掌心姿态 |
| `imu_data_thumb()` 等 5 个 | `ImuData.raw(...)` | 对应手指的融合姿态 |

另有三个只读属性：`sn`、`hand_side`、`urdf_source` 与 `urdf_source_path`。

**手部求解依赖 URDF，IMU 融合不依赖。** 换手模只影响前三个方法的输出。

## URDF 来源

构造时传的 `sn` 决定 SDK 去哪里找手部模型。实测未标定的序列号：

```text theme={null}
urdf: source=builtin_default, path=None
```

`urdf_source` 有两种取值。`builtin_default` 表示用的是内置通用手模，`path` 为 `None`。标定过的设备会指向 `~/.wuji/sdk/models/<序列号>_hand.urdf`。

想强制指定某份手模，传 `urdf_path`：

```python theme={null}
pipeline = WujiGlove.offline_pipeline(
    sn="WG1KA00XXXXXXXXX",
    hand_side="right",
    urdf_path="~/.wuji/sdk/models/WG1KA00XXXXXXXXX_hand.urdf",
)
```

路径不存在会立刻抛错，不会静默回落到默认手模：

```text theme={null}
WujiException: Internal error: failed to read explicit urdf_path (/nonexistent.urdf): No such file or directory
```

`hand_side` 只接受 `"left"` 和 `"right"`，其他值抛 `ValueError: hand_side must be 'left' or 'right'`。

<Warning>
  重放数据时 `sn` 要填**采集那只手套**的序列号，不是本机手上这只。填错会加载到另一份手模，求解结果整体偏移，而且不报错。
</Warning>

## 与在线结果的一致性实测

### 方法

1. 连真实手套，用 `TopicRecorder` 同时录 `emf_poses`、`hand_joint_angles`、`hand_skeleton` 三条流，4 秒
2. 从 MCAP 里按 `header.seq` 把三条流对齐，得到 525 个共同帧
3. 把录到的 `emf_poses` 重建成 `EmfPoseArray`，喂给离线管线
4. 拿离线算的结果与录到的在线结果逐元素相减

### 结果

对比前 200 帧：

| 对比项 | 最大逐元素偏差 |
| - | - |
| `hand_joint_angles` 的 25 个角度 | 0.000000000 rad |
| `hand_skeleton` 的 21 个关节位置 | 0.000000007 m |

关节角**完全相同**。骨骼位置的 7 纳米差异来自 MCAP 的 JSON 序列化把 float 往返了一遍，不是算法差异。

结论是离线重放能无损复现在线求解。拿录好的数据调算法、对比不同手模、复现线上问题，结果都可信。

### 重放代码

```python theme={null}
import json
from mcap.reader import make_reader
from wuji_sdk import WujiGlove, EmfPose, EmfPoseArray, FrameHeader, Pose, Quaternion

# 1. 从 MCAP 读出 emf_poses，按 seq 索引
frames = {}
with open("./data/<date>_<time>.mcap", "rb") as fp:
    for schema, channel, message in make_reader(fp).iter_messages():
        if not channel.topic.endswith("/emf_poses"):
            continue
        d = json.loads(message.data)
        frames[d["header"]["seq"]] = d

# 2. 建管线。sn 填采集那只手套的序列号
pipeline = WujiGlove.offline_pipeline(sn="WG1KA00XXXXXXXXX", hand_side="right")

# 3. 把 JSON 重建成 EmfPoseArray
def rebuild(d: dict) -> EmfPoseArray:
    h = d["header"]
    return EmfPoseArray(
        header=FrameHeader(seq=h["seq"], timestamp_us=h["timestamp_us"],
                           frame_id=h["frame_id"]),
        poses=[
            EmfPose(
                pose=Pose(
                    position=p["pose"]["position"],
                    orientation=Quaternion(*p["pose"]["orientation"]),
                ),
                confidence=p["confidence"],
            )
            for p in d["poses"]
        ],
    )

# 4. 逐帧重算
for seq in sorted(frames):
    angles = pipeline.hand_joint_angles().compute(rebuild(frames[seq]))
    skeleton = pipeline.hand_skeleton().compute(rebuild(frames[seq]))
    print(f"seq={seq} thumb={[round(a, 3) for a in angles.fingers[0].angles]} "
          f"joints={len(skeleton.joints)}")
```

`Quaternion` 的构造顺序是 `(x, y, z, w)`。MCAP 里存的方向如果是对象形式，按字段名取，别按顺序解包。

## IMU 融合实测

融合器有状态，逐帧推进。示例只喂了一帧，看不出行为，实测连续喂帧的演化。

### 静止

持续喂 `angular_velocity=(0,0,0)`、`linear_acceleration=(0,0,9.8)`，共 399 帧：

| 帧号 | 四元数 `(x, y, z, w)` | `orientation_covariance[0]` |
| - | - | - |
| 1 | `(0.0000, -0.0000, 0.0000, 1.0000)` | 0.000 |
| 10 | `(0.0000, 0.0000, 0.0000, 1.0000)` | 0.000 |
| 399 | `(0.0000, 0.0000, 0.0000, 1.0000)` | 0.000 |

姿态锁在单位四元数，没有漂移。重力方向与理想 Z 轴一致时融合器不产生修正量。

### 转动

改喂 `angular_velocity=(0, 0, 1.0)` rad/s，每帧时间戳递增 5 ms，共 200 帧（1.0 秒）：

| 帧号 | 四元数 `(x, y, z, w)` | 等效偏航角 |
| - | - | - |
| 1 | `(0.0000, -0.0000, 0.0000, 1.0000)` | +0.0° |
| 50 | `(0.0000, 0.0000, 0.1222, 0.9925)` | +14.0° |
| 100 | `(0.0000, 0.0000, 0.2450, 0.9695)` | +28.4° |
| 200 | `(0.0000, 0.0000, 0.4772, 0.8788)` | +57.0° |

1 rad/s 转 1 秒理论上是 1 rad = 57.30°，实测 57.0°。差的那 0.3° 是因为**首帧没有前一帧可以算时间差，不参与积分**，实际只积分了 199 帧：199/200 × 57.30° = 57.01°，与实测吻合。

这决定了两件事：时间戳必须真实递增，融合器完全靠它算积分步长。以及第一帧的输出永远是初始姿态，不要拿它做判断。

## 集成步骤

完整示例见 [Python 示例](https://github.com/wuji-technology/wuji-sdk/blob/main/examples/python/wuji_glove/3.offline_pipeline.py)。以下片段按接入顺序展示关键操作，参数解析、信号处理和完整错误分支见示例。

### 1. 创建离线管线

序列号使用采集数据的手套序列号，手别与采集设备一致。整段回放复用同一个管线，无需连接设备。

```python theme={null}
from wuji_sdk import WujiGlove
pipeline = WujiGlove.offline_pipeline(sn=serial_number, hand_side="right")
print(pipeline.urdf_source, pipeline.urdf_source_path)
```

### 2. 准备电磁位姿帧

[Python 示例](https://github.com/wuji-technology/wuji-sdk/blob/main/examples/python/wuji_glove/3.offline_pipeline.py) 用 `make_emf_frame()` 构造包含 5 指的合成帧。接入录制数据时，将同一时刻的位姿、置信度和帧头还原为 `EmfPoseArray`，保留采集时间顺序。

```python theme={null}
# make_emf_frame 的完整实现见 [Python 示例](https://github.com/wuji-technology/wuji-sdk/blob/main/examples/python/wuji_glove/3.offline_pipeline.py)
frames = [
    make_emf_frame(seq=1, timestamp_us=0, curl=0.00),
    make_emf_frame(seq=2, timestamp_us=8333, curl=0.35),
]
```

### 3. 逐帧计算所需输出

将一帧传入相应通道。关节角、指尖位姿和骨架可按应用需要选择，无需全部计算。

```python theme={null}
for frame in frames:
    angles = pipeline.hand_joint_angles().compute(frame)
    tips = pipeline.tip_poses().compute(frame)
    skeleton = pipeline.hand_skeleton().compute(frame)
    print(frame.header.seq, len(angles.fingers), len(skeleton.joints))
```

### 4. 按需计算 IMU 融合结果

IMU 融合使用独立输入。[Python 示例](https://github.com/wuji-technology/wuji-sdk/blob/main/examples/python/wuji_glove/3.offline_pipeline.py) 的 `make_raw_imu()` 构造原始 IMU 帧，真实回放按采集顺序持续传入。管线无需显式关闭，对象回收即可。

```python theme={null}
fused = pipeline.imu_data_palm().compute(
    make_raw_imu(seq=4, timestamp_us=25000)
)
print(fused.orientation)
```

## 运行与输出

```bash theme={null}
python examples/python/wuji_glove/3.offline_pipeline.py
```

```text theme={null}
pipeline: sn=WujiGlove-DEMO-OFFLINE, hand_side=right
urdf: source=builtin_default, path=None
frame=1 fingers=5 tips=5 joints=21 thumb_conf=0.475 thumb_angles=[-0.565, -0.6, -0.6, -0.22, 0.629]
frame=2 fingers=5 tips=5 joints=21 thumb_conf=0.475 thumb_angles=[-0.699, -0.611, -0.714, -0.203, 0.934]
frame=3 fingers=5 tips=5 joints=21 thumb_conf=0.475 thumb_angles=[-0.731, -0.611, -0.734, -0.161, 1.087]
imu_palm seq=4 orientation=(0.000, -0.000, 0.000, 1.000) cov0=0.0
```

以上为实机运行结果，未连接任何设备。示例用的是一个不存在的演示序列号，所以 `urdf_source` 是 `builtin_default`。

三帧的 `curl` 参数从 0.00 递增到 0.70，模拟手指逐渐弯曲。拇指第 5 个角度随之从 0.629 增到 1.087，方向正确。`thumb_conf` 恒为 0.475，合成数据的置信度不随弯曲程度变化。

## 注意事项

| 分支点 | 表现 | 处理 |
| - | - | - |
| `sn` 未标定过 | `urdf_source` 为 `builtin_default` | 用内置通用手模，结果可用但不贴合个人手型 |
| `urdf_path` 文件不存在 | 抛 `WujiException`，不回落默认 | 检查路径，注意 `~` 需要自己展开 |
| `hand_side` 传了别的值 | 抛 `ValueError` | 只能是 `"left"` 或 `"right"` |
| 时间戳不递增 | 积分步长异常 | 重放时保留录制的原始 `timestamp_us` |
| 只喂一帧 IMU | 输出恒为初始姿态 | 融合器有状态，需要连续帧 |
| 换手模重算 | 关节角与骨骼变化，IMU 融合不变 | 手部求解依赖 URDF，IMU 不依赖 |

## 扩展用法

### 对比两套手模

同一批数据跑两条管线，一条用默认手模、一条用标定手模，逐帧比较关节角，就能量化标定带来的差异：

```python theme={null}
default_pl = WujiGlove.offline_pipeline(sn="WG1KA00XXXXXXXXX", hand_side="right")
custom_pl = WujiGlove.offline_pipeline(
    sn="WG1KA00XXXXXXXXX", hand_side="right",
    urdf_path="/path/to/custom_hand.urdf",
)
```

### 批量重放不要每帧新建管线

`hand_joint_angles()` 每次调用返回一个通道对象。循环里反复调没有问题，但 IMU 融合的状态挂在管线上，**换管线等于重置融合器**。整批数据用同一个 `pipeline` 实例跑完。

### 合成数据用于回归测试

无需连接设备即可运行，适合用于 CI。示例里手写三帧的做法可扩展为固定输入和固定期望输出的断言。

## 相关内容

* [数据录制](/docs/wuji-glove/sdk-practice/data-recording)：产出可供本篇重放的 MCAP 文件
* [手部模型标定](/docs/wuji-glove/sdk-practice/hand-model-calibration)：生成贴合个人手型的 URDF
* [设置自定义手部模型](/docs/wuji-glove/sdk-practice/custom-hand-model)：在线场景下切换手模
* [通用数据类型](/docs/wuji-sdk/data-reference/)：`EmfPoseArray`、`ImuData`、`Pose` 等结构


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.