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

# Tactile Residual Grid View

## Overview

`tactile_binary` has already decided whether something touched the glove. `tactile_residual` hands you the **continuous signal** from the step before that: a signed residual per taxel, where positive means pressed harder than the calibrated baseline, near zero means no contact, and negative means lighter than baseline.

That's the difference — **the threshold belongs to the consumer**. `tactile_binary` uses a threshold the SDK derives from sensitivity, while `tactile_residual` makes no judgment and leaves the line for you to draw.

This example lays the residual out as a 24x31 grid, shows intensity with a six-level character ramp, and lets you adjust the threshold live to watch the grid respond. It's an observation tool, not another calibration workflow.

Matching examples: `examples/python/wuji_glove/9.tactile_residual_view.py` and `examples/c/wuji_glove/7_tactile_residual_view.c`.

For tactile field definitions, see [Tactile](/en/docs/wuji-glove/sdk-data-reference/tactile). This page covers only how to run it and what the measurements show.

<Warning>
  The grid rendering, character ramp rules, threshold behavior, and uncalibrated appearance on this page are all measured. **The view under actual contact isn't** — producing a residual requires someone wearing the glove and touching something.
</Warning>

## Prerequisites

### Hardware

| Item | Requirement |
| - | - |
| Wuji Glove | 1, **tactile calibration completed** |
| Connection | Ethernet |
| Terminal | At least 31 columns wide and 26 rows tall |

### Software

| Item | Requirement |
| - | - |
| Operating system | Ubuntu 22.04 LTS or later |
| Wuji SDK | v2026.8.31 or later |
| Python | 3.10 or later |
| C toolchain | CMake 3.16 or later, plus the extracted SDK package |

<Tabs>
  <Tab title="Python">
    ```bash theme={null}
    pip install wuji-sdk
    ```
  </Tab>

  <Tab title="C">
    ```bash theme={null}
    cmake -S examples/c/wuji_glove -B build/glove \
      -DWUJI_SDK_INCLUDE_DIR="${SDK}/include" \
      -DWUJI_SDK_LIB="${SDK}/lib/libwuji_sdk_c.so"
    cmake --build build/glove --target 7_tactile_residual_view
    ```
  </Tab>
</Tabs>

### Before You Start

**Run tactile calibration first.** The residual is computed against a calibrated baseline, and without that baseline there's no residual — this stream measures exactly zero on an uncalibrated glove. See [Tactile Calibration](/en/docs/wuji-glove/sdk-practice/tactile-calibration).

## How the Residual Relates to the Binary Stream

```mermaid theme={null}
flowchart LR
    RAW["tactile<br/>raw z-score"]
    MODEL["contact model<br/>calibration output"]
    RES["tactile_residual<br/>signed continuous value"]
    BIN["tactile_binary<br/>decided 0/1"]
    YOU["your threshold"]
    APP["your application"]

    RAW --> MODEL
    MODEL --> RES
    RES -->|"SDK decides by sensitivity"| BIN
    RES -->|"passed through"| YOU
    YOU --> APP
    BIN --> APP
```

Both streams come from the same model, and `tactile_binary` is `tactile_residual` after one threshold. Take the former when you want a ready-made verdict, the latter when you want to set the criterion yourself.

## The Grid Matches the Binary View Exactly

`tactile_residual` also carries 744 values, with the same 218 invalid channels in the same places and the same row-major mapping of `index = row * 31 + col`. 24 x 31 = 744, so the grid is the sensor array's physical layout.

The hand outline is therefore identical between the two views, and switching between them never shifts anything. For where the channel counts come from, see [Subscribing With Callbacks](/en/docs/wuji-glove/sdk-practice/subscribe-callback).

## Six-Level Character Ramp

This is the main display difference from the binary view:

| Character | Test | Meaning |
| - | - | - |
| Space | `abs(v - (-1.0)) < 1e-6` | Invalid or masked taxel |
| `#` | `threshold > 0` and `v >= threshold` | At or above your threshold |
| `.` | `v <= 0` or `threshold <= 0` | No contact, or the fallback when the threshold is zero |
| `. : - = + *` | `0 < v < threshold` | Six intensity levels below the threshold, rising to the right |

The ramp maps proportionally:

```python theme={null}
RAMP = ".:-=+*"

idx = int(value / threshold * len(RAMP))
return RAMP[min(idx, len(RAMP) - 1)]
```

A value in the first sixth of the threshold shows `.`, the second sixth shows `:`, and so on, with `*` meaning nearly at the threshold. **The ramp is relative to the threshold**, so adjusting the threshold redistributes the whole scale. It isn't an absolute measure.

The invalid-channel test uses an approximate float comparison rather than `== -1.0`, avoiding an equality check:

```python theme={null}
MASKED_VALUE = -1.0
MASKED_EPS = 1e-6

if abs(value - MASKED_VALUE) < MASKED_EPS:
    return " "
```

## Threshold Behavior

| Input | Status line shows | Note |
| - | - | - |
| `0.5` | `0.5` | Default |
| `0.0` | `0.0` | Valid, and every valid channel renders as `.` |
| `-1.0` | `0.0` | Clamped to the lower bound |

The clamp has a floor only:

```python theme={null}
def clamp_threshold(value: float) -> float:
    return max(0.0, float(value))
```

**There's no ceiling.** That differs from sensitivity in the binary view, which the example clamps to `[0.5, 3.0]`. The residual threshold is your own criterion, and the example doesn't bound it for you.

`threshold = 0` is a special case: in `cell_for`, the `value <= 0.0 or threshold <= 0.0` branch renders every valid channel as `.` and `#` never appears. To "show everything," use a very small positive number rather than zero.

In an interactive terminal, `+` and `-` step by 0.1 (`THRESHOLD_STEP = 0.1`).

## Status Line

```text theme={null}
above=0   threshold=0.5 peak=+0.000 row=0 col=14 seq=657713 len=744  (+/- threshold, q quit)
```

| Field | Meaning |
| - | - |
| `above` | Taxels at or above the threshold |
| `threshold` | Current threshold |
| `peak` | Highest residual this frame, **signed** |
| `row` / `col` | Grid coordinates of the peak |
| `seq` | Frame sequence number, absent from the binary view |
| `len` | Frame length, normally 744 |

The sign on `peak` matters. A negative peak means a whole region reads lighter than the calibrated baseline, which usually points at a loose fit or a hand posture far from the one used during calibration.

`seq` confirms the stream is alive: a number that stops moving means no new frames.

## Render Throttling

Throttled to 10 Hz, same as the binary view:

```python theme={null}
RENDER_INTERVAL_S = 0.1
```

The stream runs at 130 Hz with callbacks firing per frame on an SDK worker thread. Without throttling, that thread spends itself on terminal output.

## Runtime Handling

| Branch point | Behavior | What to do |
| - | - | - |
| No tactile calibration | The whole grid is `.` with a constant `peak=+0.000` | Run tactile calibration first |
| Glove not worn | Same as above | Put the glove on and touch something |
| Negative `threshold` | Clamped to 0.0 | Use a positive number |
| `threshold` set to 0 | `#` never appears and everything is `.` | Use a very small positive number to show everything |
| `peak` stays negative | The whole glove reads lighter than baseline | Check the fit, or recalibrate |
| Non-interactive terminal | Keyboard control is unavailable | Preset a value with `--threshold` |
| `--seconds 0` | Runs until `q` or `Ctrl-C` | That's the default |

## Integration Steps

<Tabs>
  <Tab title="Python">
    ```python theme={null}
    import sys
    import time
    from wuji_sdk import SdkManager, DeviceType, TactileResidual

    TACTILE_ROWS, TACTILE_COLS = 24, 31
    MASKED_VALUE, MASKED_EPS = -1.0, 1e-6
    RAMP = ".:-=+*"                  # six intensity levels below the threshold
    RENDER_INTERVAL_S = 0.1          # stream at 130 Hz, rendering throttled to 10 Hz

    threshold = 0.5                  # your criterion, not the SDK's
    last_render = 0.0

    def cell_for(value: float, threshold: float) -> str:
        # Approximate comparison for invalid channels. Never write == -1.0
        if abs(value - MASKED_VALUE) < MASKED_EPS:
            return " "
        if threshold > 0.0 and value >= threshold:
            return "#"
        if value <= 0.0 or threshold <= 0.0:
            return "."
        # The ramp is cut relative to the threshold, so adjusting it rescales everything
        idx = int(value / threshold * len(RAMP))
        return RAMP[min(idx, len(RAMP) - 1)]

    def on_residual(frame: TactileResidual) -> None:
        global last_render
        now = time.monotonic()
        if now - last_render < RENDER_INTERVAL_S:
            return
        last_render = now

        data = frame.data
        limit = min(len(data), TACTILE_ROWS * TACTILE_COLS)
        above = sum(1 for i in range(limit)
                    if threshold > 0.0 and data[i] >= threshold)
        peak = max(data[:limit])     # signed. A negative peak means lighter than baseline

        lines = ["\x1b[H\x1b[2J",
                 f"above={above} threshold={threshold:.1f} peak={peak:+.3f} len={len(data)}"]
        for row in range(TACTILE_ROWS):
            # Row-major: index = row * 31 + col, same as the binary view
            lines.append("".join(
                cell_for(data[row * TACTILE_COLS + col] if row * TACTILE_COLS + col < limit
                         else MASKED_VALUE, threshold)
                for col in range(TACTILE_COLS)
            ))
        sys.stdout.write("\n".join(lines) + "\n")

    manager = SdkManager.instance()

    # 1. Connect
    devices = [d for d in manager.scan() if d.device_type == DeviceType.WujiGlove]
    glove = manager.connect(sn=devices[0].sn, device_name="my_glove")

    # 2. Subscribe. The residual needs no device parameter, since the threshold is yours
    sub = glove.tactile_residual().subscribe_with_callback(on_residual)

    time.sleep(30)

    sub.close()
    manager.disconnect_all()
    ```
  </Tab>

  <Tab title="C">
    ```c theme={null}
    #include "wuji_sdk.h"

    #define TACTILE_ROWS 24
    #define TACTILE_COLS 31
    #define MASKED_VALUE (-1.0f)
    #define MASKED_EPS   1e-6f

    static const char *RAMP = ".:-=+*";   /* six intensity levels below the threshold */
    static float g_threshold = 0.5f;      /* your criterion, not the SDK's */

    static char cell_for(float v, float threshold) {
        if (fabsf(v - MASKED_VALUE) < MASKED_EPS) return ' ';
        if (threshold > 0.0f && v >= threshold)   return '#';
        if (v <= 0.0f || threshold <= 0.0f)       return '.';
        int n = (int)strlen(RAMP);
        int idx = (int)(v / threshold * n);
        return RAMP[idx < n ? idx : n - 1];
    }

    /* Callbacks fire on an SDK worker thread, so throttle rendering */
    static void on_residual(WujiFrameKind kind, const WujiTactileResidual *f, void *ud) {
        if (kind != WUJI_FRAME_KIND_OK || !f) return;

        size_t limit = f->data_len < TACTILE_ROWS * TACTILE_COLS
                     ? f->data_len : TACTILE_ROWS * TACTILE_COLS;
        int above = 0;
        float peak = -1.0f;
        for (size_t i = 0; i < limit; i++) {
            if (g_threshold > 0.0f && f->data[i] >= g_threshold) above++;
            if (f->data[i] > peak) peak = f->data[i];
        }

        printf("\x1b[H\x1b[2J" "above=%d threshold=%.1f peak=%+.3f len=%zu\n",
               above, g_threshold, peak, f->data_len);
        for (int row = 0; row < TACTILE_ROWS; row++) {
            for (int col = 0; col < TACTILE_COLS; col++) {
                size_t idx = (size_t)row * TACTILE_COLS + col;
                putchar(cell_for(idx < limit ? f->data[idx] : MASKED_VALUE, g_threshold));
            }
            putchar('\n');
        }
        fflush(stdout);
    }

    /* 1. Initialize, discover, connect. See the callback subscription page */

    /* 2. Subscribe to tactile_residual */
    struct WujiSub *sub = NULL;
    wuji_glove_subscribe_tactile_residual(dev, on_residual, NULL, &sub);

    /* 3. Close the subscription first, then release the device */
    wuji_sub_close(sub);
    wuji_dev_disconnect(dev);
    wuji_dev_release(dev);
    wuji_shutdown();
    ```
  </Tab>
</Tabs>

The residual stream needs no device parameter at all, which is another difference from the binary stream, where sensitivity has to be `set()` on the device.

## Running It

<Tabs>
  <Tab title="Python">
    ```bash theme={null}
    python examples/python/wuji_glove/9.tactile_residual_view.py --sn <serial number>
    python examples/python/wuji_glove/9.tactile_residual_view.py --sn <serial number> \
      --seconds 60 --threshold 0.8
    ```
  </Tab>

  <Tab title="C">
    ```bash theme={null}
    ./build/glove/7_tactile_residual_view
    ```
  </Tab>
</Tabs>

Measured output, uncalibrated and unworn:

```text theme={null}
above=0   threshold=0.5 peak=+0.000 row=0 col=14 seq=557357 len=744  (+/- threshold, q quit)
              ....
      ....   ......   ...
     ......  ......  .....
     ......  ......  .....
     ......  ......  .....
 ... ......  ......  .....
...........  ......  .....
...........  ......  .....
...........  ......  .....
...........  ......  ..... ...
...................  ..........
...............................
...............................
.... .................... .....
     .................... .....
.    .................... .....
.    ..........................
.    ..................... ...
.    .....................
.    .....................
.    .....................
.    ...................
.    ..................
.    ..................
```

Measured on a right-hand Wuji Glove resting on a desk, unworn and uncalibrated.

The outline matches the binary view exactly, because invalid channels sit in the same places. `peak=+0.000` and `above=0` are what an uncalibrated glove looks like, while `seq` keeps incrementing to show the stream itself is alive — **a stream carrying frames isn't the same as a stream carrying content**, which is what the `seq` field is for.

Once calibrated and worn, contact regions render as the `. : - = + *` ramp by intensity, places at or above the threshold turn into `#`, and `peak` gives the strongest current residual.

## Adapting It

### Find Your Threshold From peak

Use the example as follows: hold the lightest contact your application cares about, watch where `peak` settles, and set the threshold just below that. It beats guessing, and it beats tuning sensitivity by trial and error.

### Read the Ramp for Contact Edges

`#` tells you only where the threshold was crossed, while the ramp shows how contact falls off toward the edges. For grasp-posture analysis or contact-area estimation, that gradient carries more than a binary verdict.

### A Negative Residual Is a Signal

A persistently negative `peak` means the whole glove reads lighter than its calibrated baseline, most often because it's worn looser than during calibration. Refit the glove rather than lowering the threshold to compensate.

### For a Verdict, Use the Binary Stream

Writing your own threshold logic costs real work: noise handling, temporal filtering, hysteresis. If all you need is whether something touched the glove, use `tactile_binary`, where the SDK has already done that. The residual earns its place when your criterion differs from the default.

## Related

* [Tactile Calibration](/en/docs/wuji-glove/sdk-practice/tactile-calibration): the hard requirement here, since the residual is computed against a calibrated baseline
* [Tactile Contact Grid View](/en/docs/wuji-glove/sdk-practice/tactile-contact-view): the same grid after the SDK's own decision
* [Subscribing With Callbacks](/en/docs/wuji-glove/sdk-practice/subscribe-callback): where 744, 526, and 218 come from
* [Common Data Types](/en/docs/wuji-sdk/data-reference/): the `TactileResidual` structure


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