> ## 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.

# Replaying Data Offline

## Overview

`WujiGlove.offline_pipeline()` builds a solver pipeline that needs no physical device. Feed it EMF pose frames and it computes finger joint angles, fingertip poses, and a 21-joint skeleton. Feed it raw IMU frames and it runs orientation fusion.

Three uses: rerun algorithms over a finished recording, develop and debug on a machine with no hardware, and re-solve the same data against a different hand model to see what changes.

The most important measurement on this page: **the offline pipeline and the online solver produce bit-identical output**. Given the same EMF pose frames, offline joint angles differ from the device's live output by exactly zero.

Matching example: `examples/python/wuji_glove/3.offline_pipeline.py`.

For the API and parameter semantics, see [Offline Pipeline](/en/docs/wuji-glove/offline-pipeline). This page covers only how to run it and what the measurements show.

<Info>
  The C SDK has no matching offline pipeline API. This page covers Python.
</Info>

## Prerequisites

### Hardware

None. This is the only page in the series that needs no device.

### Software

| Item | Requirement |
| - | - |
| Operating system | Ubuntu 22.04 LTS or later |
| Wuji SDK | v2026.8.31 or later |
| Python | 3.10 or later |

```bash theme={null}
pip install wuji-sdk
# add this to read MCAP recordings
pip install mcap
```

### Before You Start

To replay real data, read [Recording Data](/en/docs/wuji-glove/sdk-practice/data-recording) first. Running the example's synthetic frames needs no preparation.

## Data Flow

```mermaid theme={null}
flowchart TD
    SRC1["synthetic frames<br/>hand-written EmfPoseArray"]
    SRC2["MCAP recording<br/>captured data"]
    PL["WujiGlove.offline_pipeline(sn, hand_side)"]
    URDF["hand model URDF<br/>built-in default or calibration output"]
    A["hand_joint_angles().compute()"]
    T["tip_poses().compute()"]
    S["hand_skeleton().compute()"]
    I["imu_data_palm().compute()"]
    OUT["solver output<br/>matches online"]

    SRC1 --> PL
    SRC2 --> PL
    URDF --> PL
    PL --> A
    PL --> T
    PL --> S
    PL --> I
    A --> OUT
    T --> OUT
    S --> OUT
    I --> OUT
```

## Channels the Pipeline Offers

The object returned by `offline_pipeline()` exposes these methods:

| Method | Input | Output |
| - | - | - |
| `hand_joint_angles()` | `EmfPoseArray` | Joint angles for 5 fingers, each with `angles` (length 5) and `confidence` |
| `tip_poses()` | `EmfPoseArray` | 5 fingertip poses |
| `hand_skeleton()` | `EmfPoseArray` | 21-joint skeleton |
| `imu_data_palm()` | `ImuData.raw(...)` | Fused palm orientation |
| `imu_data_thumb()` and 4 more | `ImuData.raw(...)` | Fused orientation for that finger |

Three read-only properties round it out: `sn`, `hand_side`, `urdf_source`, and `urdf_source_path`.

**Hand solving depends on the URDF. IMU fusion doesn't.** Swapping hand models changes only the first three methods.

## Where the URDF Comes From

The `sn` you pass at construction decides where the SDK looks for a hand model. For a serial number that was never calibrated:

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

`urdf_source` takes two values here. `builtin_default` means the built-in generic hand model, with `path` as `None`. A calibrated device points at `~/.wuji/sdk/models/<serial number>_hand.urdf`.

To force a specific model, pass `urdf_path`:

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

A missing path raises immediately rather than falling back to the default model:

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

`hand_side` accepts only `"left"` and `"right"`. Anything else raises `ValueError: hand_side must be 'left' or 'right'`.

<Warning>
  When replaying, `sn` must name **the glove that captured the data**, not the one on your desk. The wrong serial number loads a different hand model, shifts every solver result, and reports no error.
</Warning>

## Consistency With Online Results

This is the section worth reading.

### Method

1. Connect a real glove and use `TopicRecorder` to capture `emf_poses`, `hand_joint_angles`, and `hand_skeleton` together for 4 seconds
2. Align the three streams by `header.seq` in the MCAP file, yielding 525 common frames
3. Rebuild the recorded `emf_poses` into `EmfPoseArray` and feed them to the offline pipeline
4. Subtract the offline results from the recorded online results, element by element

### Results

Across the first 200 frames:

| Comparison | Peak element-wise difference |
| - | - |
| The 25 angles in `hand_joint_angles` | 0.000000000 rad |
| The 21 joint positions in `hand_skeleton` | 0.000000007 m |

Joint angles are **exactly identical**. The 7-nanometer difference in skeleton positions comes from MCAP's JSON serialization round-tripping a float, not from the algorithm.

Offline replay therefore reproduces online solving without loss. Tuning algorithms on recorded data, comparing hand models, and reproducing production issues all give trustworthy results.

### Replay Code

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

# 1. Read emf_poses out of the MCAP file, indexed by 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. Build the pipeline. sn names the glove that captured the data
pipeline = WujiGlove.offline_pipeline(sn="WG1KA00XXXXXXXXX", hand_side="right")

# 3. Rebuild each JSON record into an 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. Recompute frame by frame
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` takes `(x, y, z, w)` in that order. When the MCAP stores orientation as an object, read it by field name rather than unpacking positionally.

## IMU Fusion

The fuser carries state and advances frame by frame. The example feeds a single frame, which shows nothing, so here is how it evolves over a continuous run.

### At Rest

Feeding `angular_velocity=(0,0,0)` and `linear_acceleration=(0,0,9.8)` for 399 frames:

| Frame | Quaternion `(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 |

Orientation locks to the identity quaternion with no drift. When gravity aligns with the ideal Z axis, the fuser produces no correction.

### Rotating

Switching to `angular_velocity=(0, 0, 1.0)` rad/s with timestamps 5 ms apart, 200 frames (1.0 second):

| Frame | Quaternion `(x, y, z, w)` | Equivalent yaw |
| - | - | - |
| 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° |

One rad/s for one second should give 1 rad, or 57.30°, and the measurement reads 57.0°. That 0.3° gap exists because **the first frame has no predecessor to compute a time delta against, so it doesn't integrate**. Only 199 frames integrate: 199/200 x 57.30° = 57.01°, matching the measurement.

Two consequences. Timestamps must increase for real, because the fuser derives its integration step entirely from them. And the first frame always returns the initial orientation, so never judge anything by it.

## Runtime Handling

| Branch point | Behavior | What to do |
| - | - | - |
| `sn` was never calibrated | `urdf_source` is `builtin_default` | The built-in generic model works, though it won't match an individual hand |
| `urdf_path` doesn't exist | Raises `WujiException` with no fallback | Check the path, and expand `~` yourself |
| `hand_side` gets another value | Raises `ValueError` | Only `"left"` or `"right"` |
| Timestamps don't increase | The integration step goes wrong | Keep the recorded `timestamp_us` when replaying |
| Only one IMU frame | Output stays at the initial orientation | The fuser is stateful and needs continuous frames |
| Re-solving with another hand model | Joint angles and skeleton change, IMU fusion doesn't | Hand solving depends on the URDF, IMU fusion doesn't |

## Integration Steps

```python theme={null}
from wuji_sdk import (
    EmfPose, EmfPoseArray, FrameHeader, ImuData,
    Pose, Quaternion, Vector3F64, WujiGlove,
)

# 1. Build the pipeline. sn names the glove that captured the data
pipeline = WujiGlove.offline_pipeline(sn="WG1KA00XXXXXXXXX", hand_side="right")
print(f"urdf: source={pipeline.urdf_source}, path={pipeline.urdf_source_path}")

# 2. Prepare one EMF pose frame. In practice you read these from a recording
frame = EmfPoseArray(
    header=FrameHeader(seq=1, timestamp_us=0, frame_id="r_hand_emf_tx"),
    poses=[
        EmfPose(
            pose=Pose(position=[0.028, -0.030, -0.010],
                      orientation=Quaternion(0.0, 0.0, 0.0, 1.0)),
            confidence=0.95,
        )
        # …four more fingers
    ],
)

# 3. Solve. The three channels are independent, so use only what you need
angles = pipeline.hand_joint_angles().compute(frame)
tips = pipeline.tip_poses().compute(frame)
skeleton = pipeline.hand_skeleton().compute(frame)

# 4. IMU fusion is independent of hand solving and needs continuous frames
fused = pipeline.imu_data_palm().compute(
    ImuData.raw(
        header=FrameHeader(seq=2, timestamp_us=5_000, frame_id="imu_raw/palm"),
        angular_velocity=Vector3F64(0.0, 0.0, 0.0),
        linear_acceleration=Vector3F64(0.0, 0.0, 9.8),
    )
)
```

The pipeline needs no explicit shutdown. Let the object go out of scope.

## Running It

```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
```

Measured with no device connected. The example uses a demo serial number that doesn't exist, so `urdf_source` reads `builtin_default`.

The three frames raise `curl` from 0.00 to 0.70, simulating fingers closing. The thumb's fifth angle rises from 0.629 to 1.087 accordingly, in the right direction. `thumb_conf` stays at 0.475 because confidence on synthetic data doesn't vary with curl.

## Adapting It

### Compare Two Hand Models

Run the same data through two pipelines, one on the default model and one on a calibrated model, then compare joint angles frame by frame to quantify what calibration buys:

```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",
)
```

### Don't Build a New Pipeline Per Frame

`hand_joint_angles()` returns a channel object on each call, and calling it repeatedly in a loop is fine. IMU fusion state, however, lives on the pipeline, so **a new pipeline resets the fuser**. Run a whole batch through one `pipeline` instance.

### Use Synthetic Data for Regression Tests

It runs without hardware, which suits CI. The example's hand-written three frames extend naturally into fixed-input, fixed-expected-output assertions.

## Related

* [Recording Data](/en/docs/wuji-glove/sdk-practice/data-recording): produce the MCAP files this page replays
* [Hand Model Calibration](/en/docs/wuji-glove/sdk-practice/hand-model-calibration): generate a URDF that matches your hand
* [Setting a Custom Hand Model](/en/docs/wuji-glove/sdk-practice/custom-hand-model): switch models in an online session
* [Common Data Types](/en/docs/wuji-sdk/data-reference/): `EmfPoseArray`, `ImuData`, `Pose`, and related structures


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