SDK Data Reference

Tactile Data

# Tactile matrix
sub = glove.tactile().subscribe()
frame = await sub.recv_async()
print(f"Max pressure: {max(frame.data):.2f}")

# Zone data
sub = glove.tactile_zones().subscribe()
zones = await sub.recv_async()
print(f"Palm: {zones.palm}, Thumb: {zones.thumb}")

The contact detection and contact residual streams (TactileBinary and TactileResidual) depend on a trained contact model. Before first use, complete Tactile Contact Calibration in Wuji Studio. The model loads automatically per SDK user and glove serial number.

Tactile Matrix Layout

Tactile data comes from a 24×31 piezoresistive sensor matrix, 744 positions in total, of which 526 are active taxels. TactileFrame, TactileBinary, and TactileResidual all use this layout, in 24×31 row-major order. Access row r, column c: data[r * 31 + c] (r is 0–23, c is 0–30).

The figure below marks where each column index (left) and row index (right) sits on the physical glove, letting you map a matrix index (or a point's order in the exported point cloud) to a specific location on the glove.

Physical position of tactile matrix column indices (col, left) and row indices (row, right) on the glove

Layout Change

Starting with firmware v0.11.0, the tactile matrix changes from 24×32 (768 values) to 24×31 (744 values), removing invalid column 26 from the 24×32 layout. The remaining columns keep their relative order: columns 0–25 keep their indices, and columns 27–31 shift left to become columns 26–30.

Tactile matrix layout change: 24×32 (firmware v0.10.1 and earlier) on the left with invalid column 26 highlighted, 24×31 (firmware v0.11.0 and later) on the right

TactileFrame

Calibrated tactile matrix data. Frame rate: 120 FPS.

FieldTypeDescription
headerFrameHeaderFrame header
dataList[float]744 values: 526 active points (pressure 0.0~1.0), the rest are invalid taxels fixed at -1.0

data is an array of 744 values in 24×31 row-major order. Access row r, column c: data[r * 31 + c]. Invalid positions are fixed at -1.0, which distinguishes them from an active taxel reading zero pressure (0.0).

{
  "header": {
    "seq": 42,
    "timestamp_us": 1709876543210,
    "frame_id": ""
  },
  "data": [-1.0, 0.12, 0.45, 0.78, -1.0, 0.33, ...] // 744 values total, 526 active, invalid taxels are -1.0
}

TactileZones

Tactile data aggregated by finger region. Each region is a rectangle in row-major order. Positions inside the rectangle that belong to the region carry a pressure value, and the rest are fixed at -1.0.

FieldTypeArray LengthActive TaxelsDescription
headerFrameHeaderFrame header
palmList[float]308288Palm region, 14 × 22
thumbList[float]5041Thumb region, 10 × 5
indexList[float]4543Index finger region, 9 × 5
middleList[float]6058Middle finger region, 10 × 6
ringList[float]5452Ring finger region, 9 × 6
pinkyList[float]5544Pinky region, 11 × 5

Active taxels across the six regions total 526, the same mask as TactileFrame. Array length is the rectangle's cell count — index within it to stay in bounds.

{
  "header": {
    "seq": 42,
    "timestamp_us": 1709876543210,
    "frame_id": ""
  },
  "palm": [0.0, 0.12, 0.45, ...],  // 308 values, 288 of them active taxels
  // For each finger's array length and active taxel count, see the table above
  "thumb": [0.0, 0.85, 0.92, ...],
  "index": [0.0, 0.67, 0.23, ...],
  "middle": [0.0, 0.34, ...],
  "ring": [0.0, 0.11, ...],
  "pinky": [0.0, 0.05, ...]
}

PointField

Point cloud field descriptor.

FieldTypeDescription
namestrField name
offsetintByte offset
typeintData type encoding, fixed at 7 (float32)

PointCloud

Tactile point cloud data: each frame gives the 3D position and pressure of the 526 active taxels on the glove. Positions come from linear blend skinning over the hand skeleton, in the l_wrist or r_wrist frame. Beyond x / y / z, each point also carries pressure (normalized to 0.0–1.0), so you can color the 3D view by force.

The spatial positions in glove.tactile_point_cloud() come from the EMF-derived hand pose, so its output rate follows the EMF divider.

The byte layout is self-describing, in the style of ROS sensor_msgs/PointCloud2: data is a flat byte stream, point_stride is the byte count per point, and fields describes the per-point fields (name, byte offset, type). The current implementation has 4 fields and 16 bytes per point, so data is point_count × 16 bytes packed point by point. Adding a new per-point field only means appending one entry to fields — the PointCloud message definition itself stays unchanged.

       ┌─────── point 0 (16 bytes) ────┐┌─────── point 1 (16 bytes) ────┐
data = │ x0 (4B) y0 (4B) z0 (4B) p0 4B ││ x1 (4B) y1 (4B) z1 (4B) p1 4B │ ...
       └───────────────────────────────┘└───────────────────────────────┘
       offset 0                          offset 16                       offset 32

Slice back to per-point values:

start of point i      = i × point_stride
point i's x           = float32(data[start + 0  : start + 4])
point i's y           = float32(data[start + 4  : start + 8])
point i's z           = float32(data[start + 8  : start + 12])
point i's pressure    = float32(data[start + 12 : start + 16])
FieldTypeDescription
headerFrameHeaderFrame header
frame_idstrCoordinate frame
point_strideintBytes per point
fieldsList[PointField]Per-point field descriptors
dataList[int]Flat byte stream, length = point_count × point_stride
MethodReturnsDescription
point_count()intGet the number of points
{
  "header": {
    "seq": 42,
    "timestamp_us": 1709876543210,
    "frame_id": "l_wrist"
  },
  "frame_id": "l_wrist",
  "point_stride": 16,
  "fields": [
    { "name": "x",        "offset": 0,  "type": 7 },
    { "name": "y",        "offset": 4,  "type": 7 },
    { "name": "z",        "offset": 8,  "type": 7 },
    { "name": "pressure", "offset": 12, "type": 7 }
  ],
  "data": [0, 0, 128, 63, ...] // raw byte stream, 16 bytes per point (4 fields × 4 bytes)
}

Unpack the point cloud by field layout:

import struct

# Current layout: x, y, z, pressure are all float32, 16 bytes per point
raw = bytes(cloud.data)
for i in range(cloud.point_count()):
    x, y, z, pressure = struct.unpack_from('<ffff', raw, i * cloud.point_stride)

TactileBinary

Binary contact detection inferred from tactile data. Frame rate: 120 FPS, published at the same rate as glove.tactile().

FieldTypeDescription
headerFrameHeaderFrame header
dataList[float]744 contact-state values: 1.0 contact, 0.0 no contact, -1.0 invalid taxel

data is laid out in 24×31 row-major order, with the same shape and mask as TactileFrame (526 active points). Access row r, column c: data[r * 31 + c]. Existing tactile-grid visualizers work unchanged.

Contact detection relies on a contact model trained through Wuji Studio tactile contact calibration or the SDK calibration API glove.calibrate_tactile(), and loaded automatically from the current SDK user and glove serial number. When no model is loaded, the data stream is still published at 120 FPS, but every active taxel is 0.0 (no contact) and invalid taxels remain -1.0. 1.0 never appears.

Run calibration again after upgrading from an earlier version: a model trained back then doesn't move to the new model directory, the SDK no longer loads it, and the stream behaves as if you never calibrated.

Train a Contact Model

Train once, and the model is saved per SDK user and glove serial number (see tactile data paths) and loaded automatically when you subscribe—no path to configure. There are two training entries:

  • Wuji Studio: Follow the guided flow in Wuji Studio tactile contact calibration.
  • SDK calibration API: await glove.calibrate_tactile() (or the blocking glove.calibrate_tactile_blocking()) records a few prompted hand motions, then trains and installs the model automatically. After each recording you can keep it, re-record it, or stop—pass an on_pose_prompt callback for interactive control, or omit it for a hands-off run. For complete usage, see the examples 7.tactile_calibration.py (Python) and 4_tactile_calibration.c (C—API details in C API Reference).

Move a Model to Another Computer

The recommended way is Export and Import Data. For a manual move, two requirements must be met:

  • Copy all three model files: contact.safetensors, contact.npz, and contact.json (see Calibration Output Location). contact.json is the model's commit marker — without it the model doesn't load.
  • Put them into the paths["dir"] directory that manager.tactile_model_paths(user_id, sn) returns.

The SDK hot-reloads when you switch SDK users or retrain the model.

from wuji_sdk import SdkManager

# Look up the model directory for this user and glove (Wuji Studio writes it for you)
manager = SdkManager.instance()
user_id = manager.current_user()["user_id"]
paths = manager.tactile_model_paths(user_id, "WujiGlove-12345")
print(paths["dir"])  # place contact.safetensors, contact.npz, and contact.json here

sub = glove.tactile_binary().subscribe()
frame = await sub.recv_async()
contacts = sum(1 for v in frame.data if v == 1.0)
print(f"Active contacts: {contacts}")
{
  "header": {
    "seq": 42,
    "timestamp_us": 1709876543210,
    "frame_id": ""
  },
  "data": [-1.0, 0.0, 1.0, 1.0, -1.0, 0.0, ...] // 744 values total, 526 active
}

TactileResidual

Per-taxel signed contact residual inferred from tactile data. Frame rate: 120 FPS, published at the same rate as glove.tactile().

FieldTypeDescription
headerFrameHeaderFrame header
dataList[float]744 residual values. Positive = pressed harder than the baseline (contact), ~0 = no contact, negative = lighter than the baseline, -1.0 marks an invalid taxel

data is laid out in 24×31 row-major order, with the same shape and mask as TactileFrame (526 active points). Access row r, column c: data[r * 31 + c].

The residual signal is not smoothed, normalized, or binarized — pick a threshold and post-process it yourself. It shares the same contact model as TactileBinary. Subscribing to both runs model inference only once.

Residual computation relies on a contact model trained through Wuji Studio tactile contact calibration or the SDK calibration API glove.calibrate_tactile(), and loaded automatically from the current SDK user and glove serial number. When no model is loaded, the data stream is still published at 120 FPS, but every active taxel stays close to the static zero point and cannot reflect true pressure changes.

Run calibration again after upgrading from an earlier version: a model trained back then doesn't move to the new model directory, the SDK no longer loads it, and the stream behaves as if you never calibrated.

tactile_residual and tactile_binary share one model directory, and it loads automatically from the current SDK user and glove serial number. For the training entries and how to move models across computers, see TactileBinary. The SDK hot-reloads when you switch SDK users or retrain the model.

from wuji_sdk import SdkManager

# Look up the model directory for this user and glove (Wuji Studio writes it for you)
manager = SdkManager.instance()
user_id = manager.current_user()["user_id"]
paths = manager.tactile_model_paths(user_id, "WujiGlove-12345")
print(paths["dir"])  # place contact.safetensors, contact.npz, and contact.json here

sub = glove.tactile_residual().subscribe()
frame = await sub.recv_async()
valid = [v for v in frame.data if v != -1.0]
print(f"Residual range: [{min(valid):.2f}, {max(valid):.2f}]")
{
  "header": {
    "seq": 42,
    "timestamp_us": 1709876543210,
    "frame_id": ""
  },
  "data": [-1.0, 0.05, 1.23, 0.87, -1.0, -0.02, ...] // 744 values total, 526 active
}