Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
dfb2585
Add standalone Manus glove calibration worker and browser example
gareth-morgan-nv Nov 2, 2026
11b65e9
Format the calibration worker with clang-format 14 and address review…
gareth-morgan-nv Nov 2, 2026
292d02f
Make a discarded await explicit in a worker test
gareth-morgan-nv Nov 2, 2026
7303cb2
Address review findings in the calibration worker
gareth-morgan-nv Nov 2, 2026
043f229
Report whether the SDK was released in the worker result
gareth-morgan-nv Nov 2, 2026
b9f3a08
manus calibration: write MCAL files into a plain folder
gareth-morgan-nv Nov 3, 2026
4a283b6
Address review findings in the Manus calibration worker
gareth-morgan-nv Nov 5, 2026
8573854
Address further review findings in the Manus calibration worker
gareth-morgan-nv Nov 5, 2026
31cfb3d
tests/manus_calibration: apply ruff format to the fake worker
gareth-morgan-nv Nov 5, 2026
88bb34b
manus: build the calibration core and its tests without the Manus SDK
gareth-morgan-nv Nov 5, 2026
1941038
manus calibration: handle SIGHUP, accept an empty step instruction, r…
gareth-morgan-nv Nov 5, 2026
e96689e
tests/manus_calibration: compare step durations as the double the wir…
gareth-morgan-nv Nov 5, 2026
c838d2f
manus calibration: add an opt-in window pacing policy for continuous …
gareth-morgan-nv Nov 5, 2026
6b9a291
manus calibration: move the reusable Python supervisor out of the exa…
gareth-morgan-nv Nov 5, 2026
613c9d1
manus calibration: move the worker out of the Manus plugin directory
gareth-morgan-nv Nov 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions examples/manus_calibration/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<!--
SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
SPDX-License-Identifier: Apache-2.0
-->

# Manus glove calibration

Calibrate Manus gloves from a browser and save the MCAL files on the machine the gloves are
plugged into. The page guides the operator through the steps the Manus SDK provides; the SDK work
happens in the `manus_glove_calibrator` executable (see
[`src/plugins/manus_calibration/README.md`](../../src/plugins/manus_calibration/README.md)).
No capture session, OpenXR runtime, or CaptureServer is involved.

This is vendor glove calibration only. Camera calibration, spatial alignment, and flex-sensor
offsets are separate operations.

## Prerequisites

- The Manus SDK, with the Manus plugin built against it (`MANUS_SDK_ROOT`, see
`src/plugins/manus/install_manus.sh`), producing `manus_glove_calibrator`.
- A Manus license for the SDK's `Integrated` feature, as Manus requires. Glove discovery and the step
list worked without one in testing, but what the SDK does for a step start, finish and export without a
license has not been established (see Status).
- Gloves and dongle attached to this host, with `src/plugins/manus/70-manus-hid.rules` installed.
- **Every other Manus consumer stopped**: the tracking plugin, capture sessions, other SDK
clients. The dongles are not shared.

## Run

The page needs only Python 3.11 or newer and the standard library. The worker supervisor and file storage
come from the `isaaccapture-manus-calibration` library in `src/plugins/manus_calibration/python`; install it
(`pip install ../../src/plugins/manus_calibration/python`) or put it on `PYTHONPATH` as below:

```bash
cd examples/manus_calibration
PYTHONPATH=python:../../src/plugins/manus_calibration/python python3 -m isaaccapture_examples.manus_calibration \
--worker /path/to/manus_glove_calibrator \
--out-dir ~/manus-calibration
```

Open `http://127.0.0.1:8080`. The page has no authentication and can drive hardware, so it binds to
loopback and refuses requests whose Host or Origin is not its own; to use it from another machine,
forward the port (`ssh -L 8080:127.0.0.1:8080 host`).

| Option | Meaning |
| --- | --- |
| `--worker PATH` | The calibration worker executable. |
| `--out-dir DIR` | Where MCAL files are saved, on this host. A launch setting, never browser input. |
| `--sides {left,right,both}` | Gloves preselected in the page. |
| `--host`, `--port` | Where to serve. |
| `--discovery-timeout SECONDS` | How long the worker waits for the requested gloves. |
| `--continuous-pacing operator\|window` | How a continuous step is paced (default `operator`). `operator`: move, then confirm. `window`: confirm, then move for the SDK's estimate while the worker holds the step. Experimental until compared on real gloves. |

## What the operator sees

Select the gloves and start. For each SDK step the page shows the SDK's title and instruction.
Every prompt needs its own confirmation, including the first prompt of the second glove, and the
confirmation starts that step in the SDK.

- A step with a positive SDK time starts a timed sampling window when confirmed, with a progress
bar showing elapsed time (not a quality score).
- A step with no time is confirmed once the operator is already in the pose.
- A step with a negative SDK time is a continuous step whose duration the SDK only estimates. The worker
has no completion signal for it, so a negative time does not prove the SDK finished sampling. With
`--continuous-pacing operator` the page asks you to get into the pose, then confirm, and the next
prompt follows at once. With `window` it asks you to confirm and move immediately; the worker holds
the step for the estimate and the bar shows elapsed time against it, not completion.

One browser owns an attempt, and it must have the page's event stream open to start one. Other
browsers see its progress but cannot confirm or cancel. If the owning tab disconnects, the attempt is
cancelled after a 3 second grace period, so a page reload does not cancel it; a reload in time keeps
ownership.

## Unresponsive worker

The supervisor bounds the answers only the worker can give: startup through the first prompt, the
acknowledgement of a confirmed step, and the time from the last step to the result. When the worker
stays silent past a bound the attempt ends with an uncertain outcome, nothing is saved, and the
worker is terminated. This contains an unresponsive SDK call; it does not show that a license is
missing. A silent worker restarts the bound on any event it sends, so these are inactivity bounds, not
absolute limits. The time the operator takes to confirm a step is never limited. If a worker cannot be
stopped at all, new attempts are refused until the program restarts, because it may still hold the gloves.

## Status

Exercised in tests: the worker, the supervisor, storage and the page behave as described against a scripted
stand-in for the worker, and the real SDK discovers both gloves and returns the step lists.

Not verified: a calibration run from the first step to saved files with real gloves. In testing, the SDK
call that starts a step did not return within 30 seconds, and the cause was not established. So an
exported file, its calibration quality, and loading it through the tracking plugin remain unverified.

## Saved files

`--out-dir` is a plain folder. The worker writes each file straight into it as `<attempt>_<side>.mcal`
(a half-written file carries a `.part` suffix and is never left behind), and a name that exists is never
overwritten. A cancelled, failed, or uncertain attempt removes whatever it wrote, so only a completed
calibration leaves files. When a calibration completes the page lists the files with a download link each,
so the operator can save them to the machine the browser runs on. Loading a file for tracking is done with
`manus_hand_plugin --left-calibration=FILE --right-calibration=FILE`.

## Tests

```bash
cd tests/python/examples/manus_calibration
uv run --extra dev pytest
```

CTest runs the same suite as `manus_calibration_*` (label `manus_calibration`).

The tests drive the adapter, controller and HTTP server against a scripted stand-in for the worker, so
they establish the supervision, storage, ownership and web-safety logic, not that the SDK produces a
valid MCAL file.
33 changes: 33 additions & 0 deletions examples/manus_calibration/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
# The dist name mirrors the import path, so an installed example claims no bare
# top-level name in site-packages.
name = "isaaccapture-examples-manus-calibration"
version = "0.0.0" # Internal example - not versioned
description = "Standalone Manus glove calibration with a browser page"
requires-python = ">=3.11,<3.14"
# The worker supervisor and MCAL storage live in a standard-library-only library beside the native worker;
# calibration needs no OpenXR, CUDA, capture session or UI toolkit.
dependencies = ["isaaccapture-manus-calibration"]

[project.optional-dependencies]
dev = [
"pytest>=8",
]

# `only-include` + `sources`, not `packages`, and no __init__.py above
# manus_calibration -- see the packaging rules in examples/README.md.
[tool.hatch.build]
only-include = ["python/isaaccapture_examples/manus_calibration"]

[tool.hatch.build.targets.wheel]
sources = ["python"]

[tool.uv.sources]
isaaccapture-manus-calibration = { path = "../../src/plugins/manus_calibration/python" }
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Standalone Manus glove calibration: worker supervision, MCAL storage and a browser page."""
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Command line for the standalone Manus calibration page."""

from __future__ import annotations

import argparse
import asyncio
import logging
import os
import signal
import sys
from pathlib import Path

from .server import PageServer
from isaaccapture_manus_calibration.worker import CalibrationWorker

logger = logging.getLogger("isaaccapture.examples.manus_calibration")


def build_parser() -> argparse.ArgumentParser:
"""Arguments for the page; the output directory is a launch setting, never browser input."""
parser = argparse.ArgumentParser(
prog="python -m isaaccapture_examples.manus_calibration",
description="Calibrate Manus gloves from a browser and save the MCAL files on this host.",
)
parser.add_argument(
"--worker",
required=True,
type=Path,
help="path to the manus_glove_calibrator executable",
)
parser.add_argument(
"--out-dir", required=True, type=Path, help="calibration directory on this host"
)
parser.add_argument(
"--sides",
choices=("left", "right", "both"),
default="both",
help="gloves preselected in the page (default: both)",
)
parser.add_argument(
"--host",
default="127.0.0.1",
help="interface to serve on; the page has no authentication, so keep loopback and use SSH port forwarding",
)
parser.add_argument(
"--port", type=int, default=8080, help="port to serve on (default: 8080)"
)
parser.add_argument(
"--discovery-timeout",
type=float,
default=15.0,
help="seconds the worker waits for the requested gloves (default: 15)",
)
parser.add_argument(
"--continuous-pacing",
choices=("operator", "window"),
default="operator",
help="how a continuous step is paced: 'operator' (move, then confirm; the step ends at once) or "
"'window' (confirm, then move for the SDK's estimate); default: operator",
)
return parser


def _validate(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
"""Fail early, with a usage error, on a worker path that cannot run."""
if not args.worker.is_file() or not os.access(args.worker, os.X_OK):
parser.error(f"--worker {args.worker} is not an executable file")
if not 0 <= args.port < 65536:
parser.error("--port must be between 0 and 65535")
if args.discovery_timeout <= 0:
parser.error("--discovery-timeout must be positive")


async def _serve(args: argparse.Namespace) -> int:
"""Serve until interrupted, then cancel any active attempt and wait for the worker to settle."""
out_dir = args.out_dir.resolve()
worker = CalibrationWorker(
args.worker,
out_dir,
discovery_timeout_s=args.discovery_timeout,
continuous_pacing=args.continuous_pacing,
)
server = PageServer(worker, out_dir, args.host, args.port, args.sides)
await server.start()
logger.info(
"serving on http://%s:%d, calibration files go to %s",
args.host,
server.port,
out_dir,
)

stop = asyncio.Event()
loop = asyncio.get_running_loop()
# SIGHUP: a dropped terminal session must cancel an active calibration rather than kill the page mid-write.
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
loop.add_signal_handler(sig, stop.set)
await stop.wait()
logger.info("shutting down; cancelling any active calibration")
await server.aclose()
return 0


def main(argv: list[str] | None = None) -> int:
"""Entry point."""
parser = build_parser()
args = parser.parse_args(argv)
_validate(args, parser)
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
)
return asyncio.run(_serve(args))


if __name__ == "__main__":
sys.exit(main())
Loading
Loading