Skip to content

Detect the headset automatically and correct the skeleton to match - #2

Open
jsepulveda-nvidia wants to merge 24 commits into
mainfrom
jsepulveda/skeleton_source_auto
Open

Detect the headset automatically and correct the skeleton to match#2
jsepulveda-nvidia wants to merge 24 commits into
mainfrom
jsepulveda/skeleton_source_auto

Conversation

@jsepulveda-nvidia

@jsepulveda-nvidia jsepulveda-nvidia commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Before conducting a code review, its best to learn how the big picture of headset detection and remapping works form this doc: https://docs.google.com/document/d/1dOX4aN6cnj9VV8iaMVsjhS7loJGrN_lSWixJVMpJelY/edit?usp=sharing

Supersedes and replaces PR #1, which should be closed. This targets main and carries the whole of the Quest work: capture tooling, the wrist bias, automatic headset detection, and taking the skeleton correction from isaacteleop.

The per-joint skeleton correction itself is not here — it moved upstream to NVIDIA/IsaacTeleop#921, which this depends on. Until that merges and .venv_teleop is upgraded to an isaacteleop containing it, --skeleton-source quest and the quest branch of auto require that build.

Why

An operator who forgets --skeleton-source quest on a Quest drives the robot with permuted joint axes, and nothing says so. The failure looks like bad tracking rather than a missing flag. This makes auto the default so that mistake is no longer possible.

Safe in both directions:

  • On a PICO, auto resolves to pico — no correction, zero wrist bias, identical to the old default.
  • On a Quest, it goes from silently wrong to corrected.
  • If detection cannot decide, it leaves the skeleton uncorrected and logs — which is the old default behaviour, so the failure mode is what already shipped.

Explicit values still pin the behaviour.

How the headset is identified

From the body data itself. CloudXR normalises the Quest skeleton into the ByteDance layout, so I checked the obvious fields first and none separate the two headsets: joint order, positions, validity flags, sample rate, quantisation, bone-length variance. What does differ is the per-joint orientation convention — precisely what the correction fixes.

So the signature is each bone's direction (from positions, device-agnostic) expressed in its parent joint's frame (from the quaternions, device-specific).

Over 68 recorded captures — 34 paired poses on both headsets, 1020 random single frames — classification is 100% correct, worst margin 50° against a 20° refusal threshold. Correcting a Quest capture makes it classify as PICO, which is what shows the signature tracks the orientation convention rather than something incidental.

It does not gate startup

This matters more than the accuracy. An earlier auto-detect attempt waited for the headset to identify itself before letting the session proceed; controllers looked dead and the whole stack looked broken, which operators cannot distinguish from a real failure. That version was removed.

This one reads data that is already flowing, so controllers, video and OpenXR come up exactly as with an explicit source. Five consecutive agreeing frames are required before committing — measured at exactly 5 for every one of the 68 captures, about 80 ms at 60 Hz.

The streak requirement exists for a specific weakness: a frame of identity quaternions scores as PICO rather than refusing. Verified that four such frames commit nothing and real frames afterwards still resolve correctly.

Cross-check

When the runtime can report its interaction profile (NVIDIA/IsaacTeleop#926), it is compared against the geometry result: agreement is logged, disagreement warns and names both answers rather than silently picking one. Two independent signals with different failure modes.

That check is dormant today.venv_teleop runs isaacteleop 1.3.131, which has no get_interaction_profile — and activates automatically once that venv moves to a build containing #926. Auto works today from geometry alone.

Testing

Validated against the robot simulator on both headsets. Also replayed frame by frame over all 68 captures: 68/68 resolve to the correct headset.

pico verified unchanged: no correction, zero wrist bias.

Reviewer notes

Detection must run on raw data. It identifies the headset from the orientation convention — the very thing the correction changes. That is what makes it accurate, and it is also why a future refactor that applied a correction before detection would make the detector confidently report the wrong headset.

One caveat on generality. The reference signatures come from one operator on two headsets. The 20° refusal threshold against a 50° worst observed margin is the safety band; an unusual body should produce a refusal, not a wrong answer, but that has not been tested with a second operator.

The correction comes from isaacteleop, not from here. The in-repo copy is removed: SkeletonCorrection, its loader and startup axis guard are gone, and quest_skeleton_correction.py drops from 231 lines to 69. What remains there is the wrist bias, expressed in G1 wrist joint commands rather than skeleton space — robot-specific rather than headset-specific — so moving it upstream would mean re-deriving it as a skeleton-space rotation.

An ordering constraint comes with that move. The correction is applied in the reader, but detection identifies the headset from the orientation convention the correction rewrites. So under auto the reader starts uncorrected and the manager installs the correction via set_skeleton_profile once detection resolves; --skeleton-source quest corrects from the first frame. A refactor that corrected before detecting would make the detector confidently report the wrong headset.

jsepulveda-nvidia and others added 19 commits July 28, 2026 16:14
Quest 3 body tracking produces erratic, unsafe robot behaviour while the LOVR
body_tracking sample renders a correct-looking skeleton from the same data.
That asymmetry is explained by what each consumer reads: LOVR draws positions
only (main.lua:271,283 -- quaternions are stored but never rendered), whereas
compute_from_body_poses() derives the entire SMPL pose from the 24 quaternions
and uses only positions[0] for root translation. Positions can therefore look
correct while orientations are unusable.

input_readers._body_data_to_24x7() discards per-joint is_valid -- invalid
joints are skipped, leaving a zero position and a zero quaternion -- so no
existing code path can distinguish a measured joint from an unmeasured one.

This tool records the raw joints with validity preserved and reports, per
joint, valid%, positional spread relative to the pelvis, orientation spread,
and correlation with root motion. A joint reported 100% valid with near-zero
orientation variance and root correlation ~1.0 is being synthesised rather
than measured. --compare puts two recordings side by side.

Relevant because Meta IOBT does not camera-track the legs; the BD 24-joint
layout demands 8 lower-body joints (1,2,4,5,7,8,10,11) the Quest never
measures, and the robot's failure mode is falling over. Upstream acknowledges
reduced Quest lower-body accuracy in IsaacTeleop
docs/source/device/body_tracking.rst:221-225.

Analysis path validated against synthetic fixtures: correctly flags 8 frozen
leg joints and no upper-body joints.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The --compare mode reports orientation *variance*, which is the wrong metric
for a held pose. --rest-pose reports the per-joint mean-orientation offset of
one capture relative to another, and tests whether a single rotation explains
each anatomical group.

Guards on staticness first: mean orientation is only the rest pose if the
capture was actually held. During free motion the mean reflects whatever
movement was performed, which invalidated an earlier round of this analysis.

Also prints the caveat that one static pose cannot separate a constant
rest-pose offset from a pose-dependent error -- that needs a second pose.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Quest body pose reaches this machine already converted to the ByteDance
24-joint layout by the closed CloudXR client SDK. Joint order and positions
survive that conversion -- the LOVR body_tracking sample, which renders
positions only, looks anatomically correct on both headsets -- but the joint
orientations do not, and orientation is what drives the robot:
compute_from_body_poses() builds the whole SMPL pose from the 24 quaternions
and uses positions only for root translation.

Adds a per-joint correction measured in parent-relative space from a held
T-pose captured on both headsets, applied behind --skeleton-source quest.
Default stays 'pico', so existing behaviour is untouched.

The dominant defect fixed is LEFT_HIP arriving ~180 deg flipped (164.8 -> 10.2
deg) while RIGHT_HIP is nearly correct; that asymmetry inverts the left leg and
is a plausible cause of the observed falling.

Validation, held out against an independently captured A-pose:
  - local-frame joint error: 18/23 joints within 20 deg, vs 12/23 before
  - SMPL joint error vs Pico: 647 -> 305 mm mean (53% better)
Still a partial fix. The ~300 mm residual is genuine pose error, not a facing
artifact (yaw-aligning changes it by <5 mm), and is spread fairly evenly across
legs, spine and arms rather than concentrated in the known-bad joints.
RIGHT_SHOULDER, LEFT_ELBOW and both WRISTs remain pose-dependent and are
flagged LOW_CONFIDENCE_JOINTS.

Test in sim before hardware; the failure mode being corrected is the robot
falling over.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Switching headsets is the expensive part of this calibration, so capture all
poses in one session per device rather than one invocation per pose. --batch
prompts for each pose, counts down, records, and reports staticness immediately
so a bad take can be redone on the spot.

Eight poses, chosen for REPRODUCIBILITY across sessions rather than coverage --
an earlier round was wasted because "arms down" is not a well-defined pose and
the two captures did not match. Each has an unambiguous endpoint (straight,
90 degrees, seated). Between them every joint group lands in at least two
clearly different configurations, which is what separates a constant
rest-pose offset from a pose-dependent one:

  tpose                  baseline, matches the current correction table
  arms_forward           shoulder flexion 90, elbows straight
  arms_forward_palms_up  isolates wrist roll (only wrists change vs previous)
  goalpost               elbow 90 with shoulder abducted
  arms_overhead          far end of shoulder range
  bend_forward           spine + hip flexion, never previously exercised
  lean_right             lateral spine bend, breaks left/right symmetry
  sitting                hip and knee flexion 90, the only leg-bending pose

Prints the facing/position ground rules up front: a whole-body turn between
captures previously corrupted an entire round of analysis.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The operator is wearing a headset and holding controllers while posing, so
pressing ENTER between poses is impossible. Two ways to advance now:

  * Pull either controller trigger (or press ENTER -- whichever comes first).
    Requires a release before a press, so a trigger still held from the
    previous pose does not instantly advance the next one.
  * --auto: fully hands-free, each pose gets --pose-delay seconds to assume.

Adds terminal-bell cues on the countdown and at the start of recording, since
the operator cannot read the terminal while posing.

_stdin_ready() requires a tty: a closed or redirected stdin selects as
readable immediately at EOF, which would silently auto-advance every pose.

Controller reads are wrapped so missing or unsupported controllers degrade to
the ENTER path rather than crashing mid-battery.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two changes, the second of which was a real bug.

1. Refit across the full 8-pose battery instead of a single T-pose.

2. Fix the frame. compute_from_body_poses() post-multiplies every global
   rotation by Ry(180 deg) before forming parent-relative rotations, so its
   local frame is the raw skeleton's local frame conjugated by Ry180. The
   previous table was fitted from raw captures and applied without that
   conjugation. The stored values are now conjugated to match.

   This was measurable, not theoretical: an 8-pose refit applied in the wrong
   frame scored 643 mm versus 599 mm for no correction at all -- worse than
   doing nothing -- while the same fit in the correct frame scores 216 mm.

   Joint 0 is exempt: it is not parent-relative, so it takes the plain global
   offset with no conjugation. Its correction also keeps yaw; the two headsets
   reported consistently different pelvis facing (Pico ~6 deg, Quest ~88 deg,
   each stable within ~10 deg across all 8 poses), so that is a device frame
   convention rather than operator stance. Dropping yaw cost 722 mm vs 190 mm.

Leave-one-pose-out validation (fit on 7, evaluate on the held-out 8th),
mean SMPL joint error vs the Pico reference:

    raw                   599 mm
    previous T-pose table 319 mm
    this table            216 mm      64% better than raw, 32% better than v1

Wins on all 8 held-out poses individually.

LOW_CONFIDENCE_JOINTS is now LEFT_SHOULDER, RIGHT_SHOULDER and RIGHT_HAND --
offsets varying >30 deg across poses. Legs and spine, which govern balance, are
well corrected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fitting the main battery left LEFT_SHOULDER and RIGHT_SHOULDER pose-dependent
(leave-one-out residual 43 and 73 deg) while every other joint corrected well.
Two explanations were tested against the existing captures and both failed: the
collar+shoulder composite is no more stable than the shoulder alone (44/58 deg
vs 38/60), and the offset does not track arm elevation. So it is neither a
redistribution artefact nor a simple function of arm pose.

With one capture per pose we cannot distinguish a structured pose-dependent
offset (modellable with a richer fit) from an unrepeatable one (nothing will
fix it, and arm swing should instead be derived from positions, which agree
2-4x better than the shoulder quaternions).

Six poses:
  tpose_r2, arms_forward_r2, goalpost_r2  exact repeats of main-battery poses,
      captured in a fresh session -- comparing the same pose across sessions
      measures repeatability directly.
  elbow90_forward / _inward / _outward    upper arm held in one place while
      only shoulder axial roll varies. Roll is the component positions
      constrain least and the likeliest place for the SDK conversion to fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The constant per-joint correction leaves both shoulders 50-69 deg out while
every other joint sits near 6 deg. Since the shoulder is upstream of the elbow,
that error rotates the whole forearm -- the reported symptom of a wrong left
elbow orientation with otherwise-good wrists.

Swing-from-position, the obvious fix, is provably a no-op and is NOT used.
Each device's shoulder quaternion already points exactly at its own elbow
(0.0 deg bone-axis spread over 8 poses), so positions and the quaternion carry
the same directional information. Measured end to end: 190 mm either way.

Also ruled out with the new captures: collar/shoulder redistribution (composite
no more stable than the shoulder alone), arm elevation (no relationship), and
shoulder axial roll (an elbow-at-side roll trio moved the offset only 15-22 deg,
within noise).

What does work: the offset is repeatable enough to interpolate. Repeating three
poses in a fresh session reproduced the shoulder offset to ~21 deg versus ~6 deg
for other joints, so the 50-69 deg pose-dependence is well above the noise
floor. Shoulder offsets are now stored as 14 (arm_direction, offset) samples and
blended with Gaussian weights on arm direction at runtime.

Leave-one-pose-out, mean SMPL joint error vs the Pico reference:

    raw       599 mm
    constant  190 mm
    adaptive  163 mm      14% better than constant, held out

Elbows deliberately keep the constant offset -- blending did not help them
(37.4 -> 37.3 and 42.5 -> 35.3).

The blend is smooth by construction rather than nearest-neighbour: measured
max 2.67 deg change per 1 deg of arm motion, so it will not step the correction
and jerk the robot. Queries further than MAX_ANGLE_DEG from every sample fall
back to the constant offset rather than extrapolating.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Operator yaw was being turned into robot pitch, and operator pitch into robot
roll: turning left made the robot lean forward, leaning forward made it drop a
shoulder, and ~30 deg of body rotation was enough to topple it.

Cause: the fitted root correction is a 117 deg rotation that cyclically permutes
the axes. The root correction left-multiplies the body orientation, so it also
remaps which robot axis an operator rotation drives -- corr.inv() sent world +Y
to -X. Predicted axis mapping matches the reported symptoms exactly.

Why the fit produced it: every capture was taken facing the same direction, as
instructed, to avoid a different confound. The devices differ by both a
world-frame rotation A and a body-frame rotation B, G_quest = A * G_pico * B.
Only A should be corrected, since A is what maps rotation axes. From a single
body orientation the fit returns A composed with a conjugated B -- still minimal
static pose error, but the wrong axis mapping. Separating them needs captures at
several body orientations (an AX = XB problem); that data does not exist yet.

Root correction is now identity. This costs nothing for body pose -- 126 mm
root-relative either way -- because it only ever affected absolute heading, which
the teleop heading calibration (A+B+X+Y) establishes anyway.

Also adds a startup guard: for each world axis a, corr.inv().apply(a) must
return a. Anything else raises rather than starting, so a future refit cannot
reintroduce this silently. Verified the guard fires on the old value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous commit set the root correction to identity to stop it permuting
the rotation axes. That fixed the axis mapping but broke uprightness: the legs
collapsed immediately on connect.

Both failures come from trying to do two jobs with one rotation. The devices
differ by a world-frame rotation A and a body-frame rotation B:

    G_quest = A * G_pico * B

A decides which robot axis an operator rotation drives. B decides whether the
body reads as upright. A single left-multiply can only be one of them:

  * the original 117 deg fit kept the body upright but permuted the axes --
    operator yaw became robot pitch, toppling the robot within ~30 deg of turn;
  * identity restored the axis mapping but left B uncorrected, so SMPL was told
    the body was tilted ~90 deg while the operator stood upright.

Measured pelvis frames confirm B is real and large: Pico's pelvis local +Y is
world-up (6 deg off vertical) while Quest's local +X is world-DOWN (177 deg).

Solved for A and B jointly across all 14 poses, constraining A to a rotation
about the vertical since both devices agree on gravity and their world frames
can therefore only differ in heading. A = -3.1 deg yaw, B = 122.7 deg, fit
residual median 9.6 deg over 14 poses. Applied as

    corrected_root = ROOT_LEFT.inv() * local_rots[0] * ROOT_RIGHT

with ROOT_RIGHT folding in the Ry180 conjugation. Because A is near identity the
axis mapping is safe by construction; the startup guard now checks ROOT_LEFT and
still refuses to run on a permuting fit.

Mean SMPL joint error vs the Pico reference across the 8 main poses:

    raw                     599 mm
    identity root (broken)  545 mm
    two-sided root          145 mm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Correcting a joint properly needs a two-sided fit,
local_quest = A * local_pico * B, where A is the parent-frame rotation that
decides which axis a motion emerges about and B is the child-frame relabelling.
Separating them requires the joint to rotate about at least two independent
axes. With one axis the fit still matches every static pose while reproducing
motion about the wrong one -- which is how a correction can look good on every
static metric and still destabilise the robot.

Measured axis diversity from the first two batteries (0 = all motion about a
single axis): SPINE1 0.08, SPINE2 0.15, SPINE3 0.13, PELVIS 0.61 over only
21 deg, hips/ankles/feet ~0. Arms scored 0.5-0.95, which is why they solve and
the torso does not.

That under-determination is the direct cause of two reported symptoms: operator
waist flexion arriving as robot lateral bend (spine motion axis off by ~86 deg
measured), and a persistent leg crouch.

Twelve poses rotating the torso about all three axes (flexion, extension,
lateral both ways, twist both ways), putting the legs through squat and stride,
and standing at four body headings. The headings also condition the pelvis,
which every earlier battery held fixed by instruction -- so this battery flips
that rule and says so at startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…teral bend

Every joint now gets both a left and a right multiply:

    local_quest = A * local_pico * B
    corrected   = LEFT.inv() * local * RIGHT     (LEFT = A, RIGHT = B.inv())

A is the parent-frame rotation and decides which axis a motion comes out about.
B is the child-frame relabelling and decides rest orientation. One rotation
cannot do both, and getting it wrong is invisible to static metrics -- which is
how three separate versions of this bug survived every check I had:

  * a fitted root kept the body upright but permuted the axes, so operator yaw
    became robot pitch and the robot toppled within ~30 deg of turn;
  * zeroing that root fixed the axes but dropped B, so SMPL was told the body
    was tilted ~90 deg and the legs collapsed on connect;
  * the spine stayed left-only and turned operator waist flexion into robot
    lateral bend -- 87 deg between the operator's rotation axis and the robot's.

Separating A from B needs each joint to rotate about at least two independent
axes. The torso battery supplied that: SPINE1 axis diversity 0.08 -> 0.58, and
the pelvis went from one fixed heading to four. Fitted over 26 poses by
alternating SVD; median static residual 8 deg.

Motion-axis error over pose pairs with >30 deg of real motion:

    SPINE1        87.0 -> 7.7      LEFT_SHOULDER    50.4 -> 11.4
    LEFT_HIP      87.6 -> 8.5      RIGHT_SHOULDER  107.6 -> 12.8
    RIGHT_HIP     85.9 -> 7.7      elbows        71/102 -> 30/29
    LEFT_KNEE    103.9 -> 3.8      wrists         27/17 -> 14/14
    RIGHT_KNEE    79.2 -> 2.4

Mean SMPL joint error vs the Pico reference across 20 poses: 629 mm raw,
168 mm with the previous left-only table, 127 mm now.

The pelvis A came out at 4.2 deg from an unconstrained fit, independently
confirming that both devices agree on gravity and their world frames differ only
in heading -- so the axis mapping is safe rather than merely constrained to be.
The startup guard still checks this and refuses to run on a permuting fit.

Removes quest_shoulder_adaptive.py. Its pose-dependent blending was compensating
for a left-only fit forced onto a two-sided problem; the two-sided fit beats it
outright (shoulder residual 11-13 deg vs 21-29) with no runtime interpolation.

RIGHT_HAND keeps a one-sided correction -- two-sided made its motion-axis error
worse (14 -> 64 deg) with no SMPL benefit.

Documents that Quest infers legs from vision: residual is 6.9 deg median but
12.4 deg on leg-bending poses, worst RIGHT_HIP at 27 deg in a deep squat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
At the rest pose an operator expects to be neutral -- forearms forward, palms
facing each other, captured as 04_elbow90_forward -- the commanded G1 wrist
pitch is not neutral on either headset:

    side    Pico      Quest     delta
    left    -11.7     -14.3      -2.6
    right   -10.1     -16.4      -6.3

WRIST_PITCH_BIAS_RAD cancels only the delta, so the Quest lands where the Pico
already sits. Verified: residual after bias is 0.07 deg left, 0.02 deg right.

Pico is zero by intent, not oversight. Its teleop has been tuned by people over
a long time and its non-zero rest value may be correct for the robot's
mechanical neutral rather than an error; this data cannot distinguish those, so
changing it would risk invalidating that tuning on an assumption. The Pico path
is bit-identical to before.

This is a BIAS fix only and does not address the reported range asymmetry. Across
all 26 captured poses the commanded wrist pitch spans just 17-27 deg on an axis
with roughly 180 deg of travel: no pose in any battery flexes the wrist, and the
one wrist-specific pair (palms down / palms up) exercises roll rather than
flexion. Fitting a gain from data that never leaves the middle of the range
would be fitting noise.

Adds --battery wrist for that: neutral, flexion max, extension max, both
half-way points, and a deviation pose to separate the flexion axis from the
other two. The forearm is held fixed throughout so elbow motion does not leak
into the measurement, since the retargeting already folds elbow swing into the
wrist channels.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… bias

The wrist entries in the correction table were fitted from poses that never
flexed the wrist, leaving them essentially unconstrained. The wrist battery
supplies the missing motion: L_WRIST axis diversity 0.46 -> 0.65 and range
42 -> 77 deg, R_WRIST 0.54 -> 0.73 and 39 -> 75 deg.

Refit over all 32 poses. On the wrist sweep specifically:

    joint     static residual   motion-axis error
    L_WRIST     30.6 -> 20.7       18.6 -> 12.7
    R_WRIST     25.6 -> 15.2       23.0 -> 11.3

Also better across all 32 poses (L 23.3 -> 18.8, R 26.4 -> 16.1 axis error), so
this is not a trade against the rest of the body. Whole-body SMPL error over a
9-pose spread including the wrist sweep: 584 mm raw -> 124 mm corrected.

The pitch bias is re-anchored on 01_wrist_neutral, a genuine neutral wrist. The
previous anchor (04_elbow90_forward) was chosen before any battery flexed the
wrist, so it pinned the bias against a pose whose own wrist value was unverified.
Bias moves from +2.60/+6.30 to +11.37/+16.72 deg; residual at neutral is now
0.000 deg on both sides. Pico stays zero and its path is bit-identical.

Documents a separate limitation this table cannot fix. The battery showed the
commanded G1 wrist PITCH barely responds to flexion on BOTH headsets: a full
flex-to-extend sweep rotates the raw joint 132 deg (Pico) and 104 deg (Quest)
while commanded pitch moves ~25 deg, and is not even monotonic on the Pico. In
this arm configuration the flexion sits almost entirely in the Z euler component
of the SMPL wrist rotation -- Pico left wrist Z spans 131 deg while Y spans 19 --
and the retargeting takes Y as pitch while routing Z to wrist YAW. That is
shared, long-tuned code affecting the Pico identically, so it is left alone and
recorded instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The pitch-only bias was already exact at neutral (delta -0.0 and +0.1 deg) yet
the robot's hands still sat visibly extended there. Measuring all three channels
at 01_wrist_neutral showed why: the error was in YAW, ~29 deg per side.

    channel     Pico     Quest    bias applied
    L roll     -33.6     -24.5        -9.37
    L pitch     +7.2      +7.1       +11.44
    L yaw      +15.2     +44.1       -29.88
    R roll     +22.3     +19.7        +2.74
    R pitch     +9.4      +9.4       +16.87
    R yaw      -19.9     -48.2       +28.13

That is consistent with the routing already documented under "Wrist range":
operator wrist flexion largely lands in the yaw channel rather than pitch, so a
pitch-only bias could not reach the visible error. All six channels now match
the Pico reference at neutral to 0.00 deg.

Deliberate tradeoff, accepted after review. The wrist error is
configuration-dependent, so a constant bias tuned at one arm configuration is
wrong at others -- the same reason the shoulders needed pose-dependent handling.
Mean absolute wrist-channel error vs Pico:

    pose group                          none    pitch-only   3-channel
    neutral                             16.3       11.6         0.2
    wrist battery (forearms forward)    14.8       15.1        10.0
    other configs (tpose/goalpost/...)  12.3       15.4        21.9
    all poses                           13.5       15.6        15.9

Chosen because the operator judges correctness at neutral and spends most time
near it; the cost lands on arm configurations that are further from rest.

Pico stays zero on all three channels and its path is bit-identical.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Pico commands -33.6 / +22.2 deg of wrist roll at a neutral wrist, which
renders as visibly rolled palms, and it is asymmetric: the left sits 11.4 deg
further round than the right. An operator reported exactly that -- both palms
rolled up, the left more -- so the flaw is inherited from the reference, and
matching the Pico faithfully reproduced it including the asymmetry.

Roll therefore departs from the reference and is driven to zero: L +24.24,
R -19.47 deg, giving -0.2 / +0.2 deg at neutral. Pitch and yaw still match the
Pico, since those have been improving in trials.

This costs nothing in responsiveness. A constant bias shifts the operating point
without compressing travel, so roll span across the wrist sweep is unchanged at
91 / 86 deg -- unlike the yaw bias, which traded accuracy at other arm
configurations.

Zero commanded roll is a HYPOTHESIS about where the robot's neutral lies, not a
measurement: there is no ground truth available for "thumbs facing each other"
in joint terms. Recorded as such in the module, with a note that these two
numbers can be nudged by whatever residual a trial shows, independently of the
rest of the table.

Pico stays zero on all three channels and its path is bit-identical.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Identifies the headset from the first frames of body tracking and applies the
matching correction, so an operator who forgets the flag does not drive the
robot with permuted joint axes.

Detection uses the skeleton's own geometry. CloudXR normalises the Quest
skeleton into the ByteDance layout, so joint order, positions, validity flags,
sample rate and quantisation are all indistinguishable between the two
headsets -- each was checked and none separates them. What does differ is the
per-joint orientation convention, which is what the correction fixes. So the
signature is each bone's direction (from positions, device-agnostic) expressed
in its parent joint's frame (from the quaternions, device-specific).

Over 68 recorded captures -- 34 paired poses on both headsets, 1020 random
single frames -- classification is 100% correct with a worst margin of 50 deg
against a 20 deg refusal threshold. Correcting a Quest capture makes it
classify as PICO, which is what shows the signature tracks the orientation
convention rather than something incidental.

Deliberately does not gate startup. The earlier auto-detect waited for the
headset to identify itself before proceeding, which made controllers look dead
and the whole stack look broken. This reads data that is already flowing, so
controllers, video and OpenXR come up exactly as with an explicit source. Five
consecutive agreeing frames are required before committing, measured at
exactly 5 frames for every capture -- about 80 ms at 60 Hz -- so a single
degenerate frame cannot decide. A frame of identity quaternions does score as
PICO rather than refusing, which the streak requirement is there to absorb.

When the runtime can report its interaction profile, that is cross-checked
against the geometry result: agreement is logged, disagreement warns and names
both answers rather than silently picking one. The check needs an isaacteleop
exposing get_interaction_profile (NVIDIA/IsaacTeleop#921's sibling branch);
older builds skip it, so auto works today on 1.3.131 from geometry alone.

'pico' remains the default and is untouched: no correction, zero wrist bias.

Also commits the quest-upstream A/B mode that was left uncommitted while the
correction was being moved into isaacteleop. It shares the argparse choices
line with this change, so the two could not be separated cleanly. It is
scaffolding for comparing the in-repo and upstream corrections and should be
removed once the in-repo copy is retired.
An operator who forgets the flag on a Quest currently drives the robot with
permuted joint axes and nothing says so. Defaulting to auto removes that as a
way to get it wrong.

Safe in both directions. On a PICO, auto resolves to pico, which is no
correction and no wrist bias -- identical to the old default. On a Quest it
goes from silently wrong to corrected. If detection cannot decide, it leaves
the skeleton uncorrected and logs, which is exactly the old default behaviour,
so the failure mode is what shipped before rather than something new.

Explicit values still pin the behaviour and are worth passing when the headset
is known.

run_pico_manager's signature default moves to auto with the CLI. PoseStreamer
keeps "pico" because _pose_stream_common leaves it unset, so that default
governs the non-manager path, where --skeleton-source is documented not to
apply; noted at the definition so the mismatch reads as deliberate.
The correction now lives upstream (NVIDIA/IsaacTeleop#921) and is applied in
the reader, so the in-repo copy is redundant. Removes SkeletonCorrection, its
loader and startup axis guard, and the correction parameter on
compute_from_body_poses; quest_skeleton_correction.py drops from 231 lines to
69.

--skeleton-source loses 'quest-upstream'. It only ever existed to A/B the two
implementations, which have been measured identical, so 'quest' now means the
upstream correction and the distinction is gone from the interface.

WRIST_BIAS_RAD stays. It is expressed in G1 wrist joint commands rather than
skeleton space, so it is specific to this robot rather than to the headset's
skeleton convention, and moving it upstream would mean re-deriving it as a
skeleton-space rotation.

Moving the correction into the reader creates an ordering constraint that did
not exist before: auto-detection identifies the headset from the orientation
convention the correction rewrites, so it has to see raw frames. Under auto the
reader therefore starts with no correction and the manager installs one via the
new set_skeleton_profile once detection resolves. An explicit --skeleton-source
quest still corrects from the first frame.

Verified against the recorded captures: the corrected path moves the pose by up
to 1577 mm against uncorrected, so the correction is demonstrably still being
applied; auto still resolves 68/68; pico is untouched, with no correction and
zero wrist bias.
@jsepulveda-nvidia
jsepulveda-nvidia changed the base branch from jsepulveda/quest_skeleton to main August 8, 2026 02:14
…v_teleop

--skeleton-source quest calls correct_body_orientations, which is added by
NVIDIA/IsaacTeleop#921 and ships in isaacteleop 1.5+, while .venv_teleop pins
1.3.131. Until that PR merges and this repo moves to 1.5, the correction only
reaches an operator who has a local IsaacTeleop checkout on the
jsepulveda/quest_remapping branch plus three hand edits inside the venv. Those
edits were undocumented and had to be reproduced from memory on each machine.

Three steps: symlink full_body_transform.py out of the IsaacTeleop checkout,
alias NUM_BODY_JOINTS (1.5 renamed it from NUM_BODY_JOINTS_PICO), and re-export
the symbols from utilities/__init__. --check reports without touching anything
and --revert undoes it.

Reinstalling the venv against 1.5 would be the honest fix, but it drags in a
C++ rebuild and a minor-version jump, which is a poor trade while the stack is
being tested.

The failure this is really guarding against is silent: the symlink target only
exists on one branch, so checking that repo out elsewhere leaves it dangling
and teleop fails with an unrelated-looking ImportError. The script names that
cause directly, and always finishes by importing the function and asserting
that pico is identity and quest is not -- a green checklist alone would not
catch a stale symlink or a version skew.

Verified by reverting the hand-made bridge and rebuilding it from scratch: the
corrected pose still differs from uncorrected by 1577 mm across the recorded
captures and auto-detection still resolves 68/68, matching the numbers before
the rebuild.
…w data

On a Quest with --skeleton-source auto, the robot turned sharply on the first
POSE -> PLANNER toggle, and never again in that session. Explicit
--skeleton-source quest was unaffected, and so was the PICO.

Detection resolved inside PoseStreamer.run_once, which only runs in POSE mode.
A+B+X+Y goes OFF -> PLANNER directly and calls ThreePointPose.calibrate_now()
straight away, so on that path the correction had not been installed yet and
calibration captured its neck reference from uncorrected Quest orientations.
That reference is captured once and deliberately never recaptured. Entering
POSE then resolved detection and installed the correction, and the next PLANNER
entry met corrected data against a stale calibration.

Measured on the recorded captures, installing the correction rotates
global_orient_quat by ~121 deg with a pose-dependent axis, so the turn an
operator sees depends on how they were standing when they calibrated.

Move detection into the reader, which sees raw frames in every stream mode:
AutoSkeletonSource now sits beside classify() in skeleton_source_detect.py, the
reader resolves and installs the correction itself and publishes
resolved_skeleton_source, and the manager only follows that for the wrist bias
and the cross-check. The correction is settled within about five frames of body
data arriving, long before an operator can press anything, so no consumer can
latch a reference from uncorrected data.

Detection accuracy is unchanged at 68/68 over the recorded captures, and
explicit --skeleton-source values still bypass detection entirely.
The correction ships in isaacteleop 1.5+ while this repo pins 1.3.131, so any
machine that has not run install_isaacteleop_skeleton_correction.sh cannot
correct a Quest skeleton. Previously that surfaced as a bare ImportError raised
inside the reader thread, which says nothing about the cause or the fix.

Raise SkeletonCorrectionUnavailable instead, naming the script to run and the
branch the correction lives on. Explicit --skeleton-source quest now refuses at
startup rather than beginning a session it cannot correct: driving a robot from
an uncorrected Quest skeleton gives it permuted joint axes, which is worse than
not starting.

Under auto the session is already running by the time the headset is known, so
it stays unresolved and logs the same message, throttled. Detection resolves
within about a second of body data, well before an operator can start the
policy, so the message is seen before the robot can move.

PICO is untouched and silent. Detection resolves to "pico", which needs no
correction, so the import is never attempted and an operator who knows nothing
about this work sees nothing new. Verified by removing the bridge and running
both paths: PICO resolves normally with no error, Quest reports and refuses.
NVIDIA/IsaacTeleop#926 moved identify_headset out of isaacteleop.deviceio, on
review feedback that deviceio is a C++ module plus its bindings. The
cross-check sits inside a try/except ImportError, so the move would not have
raised -- it would have silently stopped cross-checking, which is worse.

Try the new location and fall back to the old one, so this works against
isaacteleop builds from either side of that change.
With --skeleton-source auto on a machine without the bridge, a Quest was
detected, the correction could not be loaded, and the previous handling logged
the problem and kept going. Continuing means streaming an uncorrected Quest
skeleton, which drives the robot with permuted joint axes -- observed on
hardware, the robot fell over. Logging is not enough when the next step is the
robot moving.

The reader now records the failure, stops, and re-raises from get_latest() so
it surfaces on the calling thread rather than dying in a background one. That
matches what an explicit --skeleton-source quest already did at startup: refuse
rather than begin a session it cannot correct.

PICO is unchanged and silent: detection resolves to "pico", which needs no
correction, so the import is never attempted.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant