<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://adithya.de/blog/feed.xml" rel="self" type="application/atom+xml" /><link href="https://adithya.de/" rel="alternate" type="text/html" /><updated>2026-10-13T17:49:25+02:00</updated><id>https://adithya.de/blog/feed.xml</id><title type="html">Adithya Balaji</title><subtitle>I&apos;m a physical AI engineer in München, helping robots learn to see, think, understand, and touch the world.</subtitle><author><name>Adithya Balaji</name></author><entry><title type="html">What work gloves do to hand tracking</title><link href="https://adithya.de/blog/what-work-gloves-do-to-hand-tracking/" rel="alternate" type="text/html" title="What work gloves do to hand tracking" /><published>2026-10-07T00:00:00+02:00</published><updated>2026-10-07T00:00:00+02:00</updated><id>https://adithya.de/blog/what-work-gloves-do-to-hand-tracking</id><content type="html" xml:base="https://adithya.de/blog/what-work-gloves-do-to-hand-tracking/"><![CDATA[<p>A lot of recent robot learning starts from people. You film someone doing a task, run a hand-pose estimator over the video, and turn the recovered hand motion into training data for a robot. The hand-pose model sits at the very front of that pipeline, so whatever it gets wrong, everything downstream inherits.</p>

<p>These models are trained and benchmarked almost entirely on bare hands. But the places where you’d most want to collect this kind of data, factories, warehouses, kitchens, are exactly the places where operators wear gloves. I wanted to know two things: what gloves actually break, and which choices at capture time (the glove, the cameras, a quality check) fix it.</p>

<h2 id="how-i-tested-it">How I tested it</h2>

<p>The plan was a two-camera rig at home with real gloves. I didn’t have a second camera, so the experiment moved to <a href="https://dex-ycb.github.io/">DexYCB</a>: 19 held-out grasping sequences, each filmed by 8 synchronised cameras, with ground-truth 3D hand joints. Most results use two of those cameras, about a metre apart at 70°.</p>

<p>The gloves are painted on. Inside each frame’s hand mask I replace the skin colour with a glove texture, but keep the original brightness as shading, and the ground-truth skeleton never moves. That last part matters: a real thick glove makes the hand <em>look</em> bigger without moving the bones, and that’s the effect I wanted to isolate.</p>

<table>
  <thead>
    <tr>
      <th>Code</th>
      <th>Glove</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>G0</td>
      <td>bare hand</td>
    </tr>
    <tr>
      <td>G1</td>
      <td>thin, bright nitrile</td>
    </tr>
    <tr>
      <td>G2</td>
      <td>grey knit</td>
    </tr>
    <tr>
      <td>G3a</td>
      <td>dark insulated work glove</td>
    </tr>
    <tr>
      <td>G3b</td>
      <td>G3a with the outline grown 1–4 px, a stand-in for thickness</td>
    </tr>
    <tr>
      <td>G4</td>
      <td>the same insulated glove, but bright (a colour control)</td>
    </tr>
  </tbody>
</table>

<figure class="figure figure--wide">
  <img src="/assets/blog/glove-gap/fig_glove_grid.jpg" alt="A grid of video frames showing hands with each synthetic glove condition, across three sequences and two camera views." loading="lazy" decoding="async" />
  <figcaption>Every glove condition on three sequences and two cameras.</figcaption>
</figure>

<p>I ran three hand-pose models: MediaPipe Hands, <a href="https://github.com/rolpotamias/WiLoR">WiLoR</a> and <a href="https://github.com/geopavlakos/hamer">HaMeR</a>. Because painted gloves are only an approximation, I also checked detection on 83 real photos of bare and gloved hands. Every number below is a mean over sequences, with a 95% bootstrap interval over sequences behind it.</p>

<h2 id="real-gloves-break-detection-and-painted-ones-undersell-it">Real gloves break detection, and painted ones undersell it</h2>

<p>On the real photos, both models find every bare hand. With worn gloves, WiLoR finds 52–65% of them and MediaPipe 7–13%. The range comes from how strictly a predicted box has to match the annotated one, and glove boxes often include the cuff, which makes strict matching harsher than it should be.</p>

<figure class="figure figure--3">
  <img src="/assets/blog/glove-gap/fig_real_detection.jpg" alt="Eight photos of gloved hands with annotated and predicted boxes. Top row: WiLoR detections. Bottom row: misses." loading="lazy" decoding="async" />
  <figcaption>Real gloved hands. White is the annotated box, orange is WiLoR, blue is MediaPipe. Top: WiLoR hits. Bottom: misses. Several of the misses do have a skeleton on the hand but fail the box match because the annotation includes the cuff. Photos from the <a href="https://universe.roboflow.com/1234-vcsmu/gloves-and-bare-hands-detection-ziyvj">Roboflow gloves and bare hands dataset</a>, CC BY 4.0.</figcaption>
</figure>

<p>Comparing that with the painted gloves was the first useful surprise.</p>

<figure class="figure figure--chart figure--2">
  <svg class="chart" viewBox="0 0 680 250" role="img" aria-label="Bar chart of the drop in hand detection compared with bare hands. MediaPipe: 87 to 90 points with synthetic gloves, 94 with real gloves. WiLoR: 0 to 4 points with synthetic gloves, 35 to 48 with real gloves." xmlns="http://www.w3.org/2000/svg">
<text x="0.0" y="14.0" text-anchor="start" class="t-title">drop in hands detected vs bare hands, percentage points</text>
<rect class="m-b" x="0.0" y="31.0" width="10" height="10" rx="2" /><text x="16.0" y="40.0" text-anchor="start" class="t-ink">synthetic gloves</text><rect class="m-a" x="157.2" y="31.0" width="10" height="10" rx="2" /><text x="173.2" y="40.0" text-anchor="start" class="t-ink">real gloves</text>
<line class="grid" x1="150.0" x2="150.0" y1="54" y2="216" />
<text x="150.0" y="234.0" text-anchor="middle">0</text>
<line class="grid" x1="272.5" x2="272.5" y1="54" y2="216" />
<text x="272.5" y="234.0" text-anchor="middle">−25</text>
<line class="grid" x1="395.0" x2="395.0" y1="54" y2="216" />
<text x="395.0" y="234.0" text-anchor="middle">−50</text>
<line class="grid" x1="517.5" x2="517.5" y1="54" y2="216" />
<text x="517.5" y="234.0" text-anchor="middle">−75</text>
<line class="grid" x1="640.0" x2="640.0" y1="54" y2="216" />
<text x="640.0" y="234.0" text-anchor="middle">−100</text>
<text x="0.0" y="90.0" text-anchor="start" class="t-ink">MediaPipe</text>
<g class="mark"><title>MediaPipe, synthetic gloves: −87 to −90 pp</title><path class="m-b" d="M150.0,64.0 H573.3 Q576.3,64.0 576.3,67.0 V83.0 Q576.3,86.0 573.3,86.0 H150.0 Z" /><rect class="m-b" x="576.3" y="64.0" width="14.7" height="22" rx="3" opacity="0.4" /><text x="599.0" y="80.0" text-anchor="start" class="t-ink">−87 to −90</text><rect class="hit" x="150.0" y="62.0" width="490.0" height="26.0" /></g>
<g class="mark"><title>MediaPipe, real gloves: −94 pp</title><path class="m-a" d="M150.0,92.0 H607.6 Q610.6,92.0 610.6,95.0 V111.0 Q610.6,114.0 607.6,114.0 H150.0 Z" /><text x="618.6" y="108.0" text-anchor="start" class="t-ink">−94</text><rect class="hit" x="150.0" y="90.0" width="490.0" height="26.0" /></g>
<text x="0.0" y="164.0" text-anchor="start" class="t-ink">WiLoR</text>
<g class="mark"><title>WiLoR, synthetic gloves: −0 to −4 pp</title><path class="m-b" d="M150.0,138.0 H151.5 Q152.9,138.0 152.9,139.5 V158.5 Q152.9,160.0 151.5,160.0 H150.0 Z" /><rect class="m-b" x="150.0" y="138.0" width="19.6" height="22" rx="3" opacity="0.4" /><text x="177.6" y="154.0" text-anchor="start" class="t-ink">−0 to −4</text><rect class="hit" x="150.0" y="136.0" width="490.0" height="26.0" /></g>
<g class="mark"><title>WiLoR, real gloves: −35 to −48 pp</title><path class="m-a" d="M150.0,166.0 H318.5 Q321.5,166.0 321.5,169.0 V185.0 Q321.5,188.0 318.5,188.0 H150.0 Z" /><rect class="m-a" x="321.5" y="166.0" width="63.7" height="22" rx="3" opacity="0.4" /><text x="393.2" y="182.0" text-anchor="start" class="t-ink">−35 to −48</text><rect class="hit" x="150.0" y="164.0" width="490.0" height="26.0" /></g>
<line class="axis" x1="150.0" x2="150.0" y1="54" y2="216" />
</svg>

  <figcaption>How much detection drops once gloves go on. Painted gloves predict MediaPipe’s collapse well, but badly underestimate how often WiLoR loses real gloved hands.</figcaption>
</figure>

<p>For MediaPipe the synthetic and real drops agree: it simply stops seeing hands. For WiLoR they don’t. Painted gloves cost it almost nothing, while real ones cost 35–48 points. Box size doesn’t explain it away (the glove photos are mostly close-ups, which should be the easy case), and cropping doesn’t rescue MediaPipe either. So the rest of this post measures pose on frames where a hand <em>was</em> found, and WiLoR’s synthetic detection rates should be read as optimistic.</p>

<h2 id="a-bigger-looking-hand-is-a-closer-hand">A bigger-looking hand is a closer hand</h2>

<p>A single camera has no direct way to know how far away a hand is. It has to infer depth from how big the hand looks. A glove changes how big the hand looks.</p>

<figure class="figure figure--chart figure--2">
  <svg class="chart" viewBox="0 0 680 364" role="img" aria-label="Bar chart of single-camera wrist depth error by glove: bare +4 mm, thin nitrile +41, grey knit −28, dark insulated −45, dark insulated with a 4 pixel thicker outline −124, bright insulated −41." xmlns="http://www.w3.org/2000/svg">
<text x="52.0" y="14.0" text-anchor="start" class="t-title">single-camera wrist depth error, mm (below zero = hand placed too close)</text>
<line class="grid" x1="52" x2="668" y1="256.0" y2="256.0" /><text x="44.0" y="260.0" text-anchor="end">-120</text><line class="grid" x1="52" x2="668" y1="208.0" y2="208.0" /><text x="44.0" y="212.0" text-anchor="end">-80</text><line class="grid" x1="52" x2="668" y1="160.0" y2="160.0" /><text x="44.0" y="164.0" text-anchor="end">-40</text><line class="grid" x1="52" x2="668" y1="112.0" y2="112.0" /><text x="44.0" y="116.0" text-anchor="end">0</text><line class="grid" x1="52" x2="668" y1="64.0" y2="64.0" /><text x="44.0" y="68.0" text-anchor="end">+40</text>
<g class="mark"><title>G0 bare: +4 mm</title><path class="m-ctx" d="M78.3,112.0 V110.2 Q78.3,107.2 81.3,107.2 H125.3 Q128.3,107.2 128.3,110.2 V112.0 Z" /><text x="103.3" y="99.2" text-anchor="middle" class="t-ink">+4</text><rect class="hit" x="52.0" y="40.0" width="102.7" height="240.0" /></g>
<text x="103.3" y="302.0" text-anchor="middle" class="t-ink">G0</text><text x="103.3" y="318.0" text-anchor="middle">bare</text>
<g class="mark"><title>G1 thin nitrile: +41 mm</title><path class="m-a" d="M181.0,112.0 V65.8 Q181.0,62.8 184.0,62.8 H228.0 Q231.0,62.8 231.0,65.8 V112.0 Z" /><text x="206.0" y="54.8" text-anchor="middle" class="t-ink">+41</text><rect class="hit" x="154.7" y="40.0" width="102.7" height="240.0" /></g>
<text x="206.0" y="302.0" text-anchor="middle" class="t-ink">G1</text><text x="206.0" y="318.0" text-anchor="middle">thin</text><text x="206.0" y="334.0" text-anchor="middle">nitrile</text>
<g class="mark"><title>G2 grey knit: -28 mm</title><path class="m-a" d="M283.7,112.0 V142.6 Q283.7,145.6 286.7,145.6 H330.7 Q333.7,145.6 333.7,142.6 V112.0 Z" /><text x="308.7" y="163.6" text-anchor="middle" class="t-ink">-28</text><rect class="hit" x="257.3" y="40.0" width="102.7" height="240.0" /></g>
<text x="308.7" y="302.0" text-anchor="middle" class="t-ink">G2</text><text x="308.7" y="318.0" text-anchor="middle">grey</text><text x="308.7" y="334.0" text-anchor="middle">knit</text>
<g class="mark"><title>G3a dark insulated: -45 mm</title><path class="m-a" d="M386.3,112.0 V163.0 Q386.3,166.0 389.3,166.0 H433.3 Q436.3,166.0 436.3,163.0 V112.0 Z" /><text x="411.3" y="184.0" text-anchor="middle" class="t-ink">-45</text><rect class="hit" x="360.0" y="40.0" width="102.7" height="240.0" /></g>
<text x="411.3" y="302.0" text-anchor="middle" class="t-ink">G3a</text><text x="411.3" y="318.0" text-anchor="middle">dark</text><text x="411.3" y="334.0" text-anchor="middle">insulated</text>
<g class="mark"><title>G3b +4 px outline: -124 mm</title><path class="m-a" d="M489.0,112.0 V257.8 Q489.0,260.8 492.0,260.8 H536.0 Q539.0,260.8 539.0,257.8 V112.0 Z" /><text x="514.0" y="278.8" text-anchor="middle" class="t-ink">-124</text><rect class="hit" x="462.7" y="40.0" width="102.7" height="240.0" /></g>
<text x="514.0" y="302.0" text-anchor="middle" class="t-ink">G3b</text><text x="514.0" y="318.0" text-anchor="middle">+4 px</text><text x="514.0" y="334.0" text-anchor="middle">outline</text>
<g class="mark"><title>G4 bright insulated: -41 mm</title><path class="m-a" d="M591.7,112.0 V158.2 Q591.7,161.2 594.7,161.2 H638.7 Q641.7,161.2 641.7,158.2 V112.0 Z" /><text x="616.7" y="179.2" text-anchor="middle" class="t-ink">-41</text><rect class="hit" x="565.3" y="40.0" width="102.7" height="240.0" /></g>
<text x="616.7" y="302.0" text-anchor="middle" class="t-ink">G4</text><text x="616.7" y="318.0" text-anchor="middle">bright</text><text x="616.7" y="334.0" text-anchor="middle">insulated</text>
<line class="axis" x1="52" x2="668" y1="112.0" y2="112.0" />
</svg>

  <figcaption>Single-camera WiLoR’s error in wrist depth, by glove. Insulated gloves pull the hand towards the camera; thickening the outline by four pixels pulls it 12 cm.</figcaption>
</figure>

<p>The dark insulated glove puts the hand 45 mm too close. Grow its outline by four pixels and that becomes 124 mm, roughly 20 mm per pixel of extra silhouette. A bright colour removes the bias for HaMeR but not for WiLoR, so it’s a property of the model rather than something you can rely on. The rule I’d write into any capture spec: when operators wear gloves, never take hand <em>position</em> from a single RGB camera.</p>

<h2 id="cameras-fix-where-the-hand-is-not-what-the-fingers-do">Cameras fix where the hand is, not what the fingers do</h2>

<p>The obvious fix is more cameras. With two calibrated views you can triangulate the hand instead of guessing its depth. To compare fixes I use the share of the glove-induced error that each one removes: 100% means the glove no longer hurts at all.</p>

<figure class="figure figure--chart figure--2">
  <svg class="chart" viewBox="0 0 680 262" role="img" aria-label="Range chart of how much of the glove-induced error each fix removes. Hand position: two calibrated views 72 to 88%, thin nitrile glove 67 to 87%. Finger pose: two calibrated views only 3 to 49%, thin nitrile glove 73 to 92%." xmlns="http://www.w3.org/2000/svg">
<text x="0.0" y="14.0" text-anchor="start" class="t-title">share of the glove damage removed, %</text>
<rect class="m-a" x="0.0" y="31.0" width="10" height="10" rx="2" /><text x="16.0" y="40.0" text-anchor="start" class="t-ink">add a second calibrated camera</text><rect class="m-b" x="258.0" y="31.0" width="10" height="10" rx="2" /><text x="274.0" y="40.0" text-anchor="start" class="t-ink">switch to a thin nitrile glove</text>
<line class="grid" x1="150.0" x2="150.0" y1="54" y2="228" />
<text x="150.0" y="246.0" text-anchor="middle">0%</text>
<line class="grid" x1="267.5" x2="267.5" y1="54" y2="228" />
<text x="267.5" y="246.0" text-anchor="middle">25%</text>
<line class="grid" x1="385.0" x2="385.0" y1="54" y2="228" />
<text x="385.0" y="246.0" text-anchor="middle">50%</text>
<line class="grid" x1="502.5" x2="502.5" y1="54" y2="228" />
<text x="502.5" y="246.0" text-anchor="middle">75%</text>
<line class="grid" x1="620.0" x2="620.0" y1="54" y2="228" />
<text x="620.0" y="246.0" text-anchor="middle">100%</text>
<text x="0.0" y="90.0" text-anchor="start" class="t-ink">Hand position</text>
<g class="mark"><title>Hand position, 2 calibrated views: 72 to 88%</title><rect class="m-a" x="488.4" y="64.0" width="75.2" height="22" rx="3" /><text x="571.6" y="80.0" text-anchor="start" class="t-ink">72–88%</text><rect class="hit" x="150.0" y="62.0" width="470.0" height="26.0" /></g>
<g class="mark"><title>Hand position, thin nitrile glove: 67 to 87%</title><rect class="m-b" x="464.9" y="92.0" width="94.0" height="22" rx="3" /><text x="566.9" y="108.0" text-anchor="start" class="t-ink">67–87%</text><rect class="hit" x="150.0" y="90.0" width="470.0" height="26.0" /></g>
<text x="0.0" y="164.0" text-anchor="start" class="t-ink">Finger pose</text>
<g class="mark"><title>Finger pose, 2 calibrated views: 3 to 49%</title><rect class="m-a" x="164.1" y="138.0" width="216.2" height="22" rx="3" /><text x="388.3" y="154.0" text-anchor="start" class="t-ink">3–49%</text><rect class="hit" x="150.0" y="136.0" width="470.0" height="26.0" /></g>
<g class="mark"><title>Finger pose, thin nitrile glove: 73 to 92%</title><rect class="m-b" x="493.1" y="166.0" width="89.3" height="22" rx="3" /><text x="590.4" y="182.0" text-anchor="start" class="t-ink">73–92%</text><rect class="hit" x="150.0" y="164.0" width="470.0" height="26.0" /></g>
</svg>

  <figcaption>Share of the single-camera glove damage each fix removes, across the grey knit, dark insulated and thickened gloves. A second camera mostly fixes position. Only a thinner glove fixes the fingers.</figcaption>
</figure>

<p>A second camera removes 72–88% of the position error, and very little of the finger-pose error: 3–49%, depending on the glove. Going to eight cameras pushes position a bit further (83–93%) and does nothing new for the fingers. Swapping the work glove for thin nitrile, on the other hand, removes 73–92% of the finger error.</p>

<p>I first assumed this was about colour, since the nitrile glove is bright and the work glove is dark. That’s why G4 exists: the <em>same</em> insulated glove, painted bright. It detects 3 points better than the dark version and its finger pose doesn’t improve at all. Holding colour fixed instead, thin nitrile beats the insulated glove by 12.8 mm of finger error. It’s thinness, and the finger shading and creases it preserves, that does the work.</p>

<p>One more result I didn’t expect: for finger pose, a single camera’s learned hand model beats triangulating two views (8.8 mm vs 12.3 mm bare). Triangulation is great at <em>where</em>, and a model with a strong prior on hand shape is better at <em>how</em>.</p>

<h2 id="a-quality-gate-flags-batches-not-episodes">A quality gate flags batches, not episodes</h2>

<p>In a real pipeline you’d want an automatic check that throws out bad data. A natural one is two-view reprojection error: a frame is valid if the two cameras agree to within 3.1 px (the bare-hand 95th percentile), and an episode passes if at least 80% of its frames are valid.</p>

<figure class="figure figure--chart figure--2">
  <svg class="chart" viewBox="0 0 680 316" role="img" aria-label="Bar chart of episodes passing a two-view reprojection check: bare 95%, thin nitrile 74%, grey knit 37%, dark insulated 5%, thicker outline 0%, bright insulated 5%." xmlns="http://www.w3.org/2000/svg">
<text x="52.0" y="14.0" text-anchor="start" class="t-title">episodes passing the reprojection check, %</text>
<line class="grid" x1="52" x2="668" y1="240.0" y2="240.0" /><text x="44.0" y="244.0" text-anchor="end">0</text><line class="grid" x1="52" x2="668" y1="191.0" y2="191.0" /><text x="44.0" y="195.0" text-anchor="end">25</text><line class="grid" x1="52" x2="668" y1="141.9" y2="141.9" /><text x="44.0" y="145.9" text-anchor="end">50</text><line class="grid" x1="52" x2="668" y1="92.9" y2="92.9" /><text x="44.0" y="96.9" text-anchor="end">75</text><line class="grid" x1="52" x2="668" y1="43.8" y2="43.8" /><text x="44.0" y="47.8" text-anchor="end">100</text>
<g class="mark"><title>G0 bare: 95%</title><path class="m-ctx" d="M78.3,240.0 V56.6 Q78.3,53.6 81.3,53.6 H125.3 Q128.3,53.6 128.3,56.6 V240.0 Z" /><text x="103.3" y="45.6" text-anchor="middle" class="t-ink">95%</text><rect class="hit" x="52.0" y="34.0" width="102.7" height="206.0" /></g>
<text x="103.3" y="262.0" text-anchor="middle" class="t-ink">G0</text><text x="103.3" y="278.0" text-anchor="middle">bare</text>
<g class="mark"><title>G1 thin nitrile: 74%</title><path class="m-a" d="M181.0,240.0 V97.8 Q181.0,94.8 184.0,94.8 H228.0 Q231.0,94.8 231.0,97.8 V240.0 Z" /><text x="206.0" y="86.8" text-anchor="middle" class="t-ink">74%</text><rect class="hit" x="154.7" y="34.0" width="102.7" height="206.0" /></g>
<text x="206.0" y="262.0" text-anchor="middle" class="t-ink">G1</text><text x="206.0" y="278.0" text-anchor="middle">thin</text><text x="206.0" y="294.0" text-anchor="middle">nitrile</text>
<g class="mark"><title>G2 grey knit: 37%</title><path class="m-a" d="M283.7,240.0 V170.4 Q283.7,167.4 286.7,167.4 H330.7 Q333.7,167.4 333.7,170.4 V240.0 Z" /><text x="308.7" y="159.4" text-anchor="middle" class="t-ink">37%</text><rect class="hit" x="257.3" y="34.0" width="102.7" height="206.0" /></g>
<text x="308.7" y="262.0" text-anchor="middle" class="t-ink">G2</text><text x="308.7" y="278.0" text-anchor="middle">grey</text><text x="308.7" y="294.0" text-anchor="middle">knit</text>
<g class="mark"><title>G3a dark insulated: 5%</title><path class="m-a" d="M386.3,240.0 V233.2 Q386.3,230.2 389.3,230.2 H433.3 Q436.3,230.2 436.3,233.2 V240.0 Z" /><text x="411.3" y="222.2" text-anchor="middle" class="t-ink">5%</text><rect class="hit" x="360.0" y="34.0" width="102.7" height="206.0" /></g>
<text x="411.3" y="262.0" text-anchor="middle" class="t-ink">G3a</text><text x="411.3" y="278.0" text-anchor="middle">dark</text><text x="411.3" y="294.0" text-anchor="middle">insulated</text>
<g class="mark"><title>G3b +4 px outline: 0%</title><path class="m-a" d="M489.0,240.0 V240.0 Q489.0,238.4 490.6,238.4 H537.4 Q539.0,238.4 539.0,240.0 V240.0 Z" /><text x="514.0" y="232.0" text-anchor="middle" class="t-ink">0%</text><rect class="hit" x="462.7" y="34.0" width="102.7" height="206.0" /></g>
<text x="514.0" y="262.0" text-anchor="middle" class="t-ink">G3b</text><text x="514.0" y="278.0" text-anchor="middle">+4 px</text><text x="514.0" y="294.0" text-anchor="middle">outline</text>
<g class="mark"><title>G4 bright insulated: 5%</title><path class="m-a" d="M591.7,240.0 V233.2 Q591.7,230.2 594.7,230.2 H638.7 Q641.7,230.2 641.7,233.2 V240.0 Z" /><text x="616.7" y="222.2" text-anchor="middle" class="t-ink">5%</text><rect class="hit" x="565.3" y="34.0" width="102.7" height="206.0" /></g>
<text x="616.7" y="262.0" text-anchor="middle" class="t-ink">G4</text><text x="616.7" y="278.0" text-anchor="middle">bright</text><text x="616.7" y="294.0" text-anchor="middle">insulated</text>
<line class="axis" x1="52" x2="668" y1="240" y2="240" />
</svg>

  <figcaption>Share of episodes that pass a two-view reprojection check, by glove.</figcaption>
</figure>

<p>At the level of a glove type, it works: 95% of bare episodes pass and only 5% of dark insulated ones. But <em>within</em> one glove type, the fraction of valid frames doesn’t predict which episodes have worse pose (|ρ| ≤ 0.36, p &gt; 0.1). So it’s a good alarm for “this batch of data is out of the model’s domain” and a poor tool for picking individual episodes.</p>

<h2 id="what-id-put-in-a-capture-spec">What I’d put in a capture spec</h2>

<ul>
  <li><strong>Use a ViT/MANO-class model</strong> like WiLoR or HaMeR, not MediaPipe. MediaPipe’s detection goes from 93% to 6% or less with any glove.</li>
  <li><strong>Choose thin, form-fitting nitrile gloves.</strong> Colour matters much less than thickness. Each extra pixel of outline costs about 0.4 mm of pose error.</li>
  <li><strong>Capture with two or more calibrated views,</strong> roughly a metre apart at around 70°, or use RGB-D, for anything that depends on hand position.</li>
  <li><strong>Gate per batch:</strong> reprojection under 3.1 px on at least 80% of frames, and investigate a batch when fewer than about 75% of its episodes pass.</li>
</ul>

<h2 id="what-i-got-wrong-along-the-way">What I got wrong along the way</h2>

<p>Some of the findings above are corrections of earlier ones, and I think that’s worth saying out loud.</p>

<p>My notes originally credited glove <em>colour</em> as the lever. The G4 control was added specifically to test that, and it showed the opposite. The real-glove drop for WiLoR was first reported as 48 points, until looking at example images showed skeletons sitting on hands that failed the match only because the annotated boxes include the cuff. It’s now a range. My first sequence selection came entirely from the split HaMeR was trained on, which I caught before running inference. And the first render re-seeded the glove texture every frame, which would have made every gloved hand look jittery.</p>

<h2 id="limits">Limits</h2>

<p>The gloves are 2D image edits: no real 3D thickness, no folds or seams. For WiLoR they clearly underestimate the real detection drop. It’s one subject handling lab objects on a table, not a warehouse floor. The models may have seen this subject in training, so trust the <em>relative</em> drop from bare to gloved more than the absolute numbers. And the real-glove check is detection only, on 83 photos.</p>

<p>What I’m fairly confident about is the shape of it: gloves hurt detection more than synthetic tests suggest, they bias single-camera depth in a predictable direction, extra cameras fix position but not fingers, and if you control the glove, choose thin.</p>

<hr />

<p><em>DexYCB (Chao et al., CVPR 2021) is CC BY-NC 4.0; frames shown here are for non-commercial research illustration. Real glove photos are from the Roboflow Universe “Gloves and bare hands detection” dataset, CC BY 4.0. All code, figures and numbers are in the <a href="https://github.com/adithyab94/glove-gap">repository</a>, and <code>make all</code> reproduces every number from cached predictions.</em></p>]]></content><author><name>Adithya Balaji</name></author><category term="hand pose" /><category term="data capture" /><category term="human video" /><summary type="html"><![CDATA[Work gloves break hand detection, bias single-camera depth, and blur finger pose. Extra cameras fix where the hand is, but only a thinner glove fixes what the fingers are doing.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://adithya.de/assets/blog/glove-gap/fig_pose_examples.jpg" /><media:content medium="image" url="https://adithya.de/assets/blog/glove-gap/fig_pose_examples.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The policy that ignored the instruction</title><link href="https://adithya.de/blog/the-policy-that-ignored-the-instruction/" rel="alternate" type="text/html" title="The policy that ignored the instruction" /><published>2026-08-09T00:00:00+02:00</published><updated>2026-08-09T00:00:00+02:00</updated><id>https://adithya.de/blog/the-policy-that-ignored-the-instruction</id><content type="html" xml:base="https://adithya.de/blog/the-policy-that-ignored-the-instruction/"><![CDATA[<p>LIBERO-Goal is a strange little benchmark. Its ten tasks all happen in the same kitchen, with the same objects sitting in the same places. <em>Put the bowl on the stove</em> and <em>open the middle drawer</em> start from scenes you could not tell apart. The only thing that changes between tasks is the sentence you hand the robot.</p>

<p>That makes it a good test of whether a vision-language-action model actually reads. So I took <a href="https://arxiv.org/abs/2504.05299">SmolVLM2-500M</a>, a small video-capable VLM, and fine-tuned it into a policy that looks at two camera views, reads one instruction, and outputs a 7-number action: six for moving the hand, one for the gripper.</p>

<p>The first version never completed a single task. This post is about why, because the why was more interesting than the fix.</p>

<h2 id="the-setup">The setup</h2>

<p>I followed the <a href="https://arxiv.org/abs/2406.09246">OpenVLA</a> recipe, the same pairing <a href="https://robot-learning-collective.github.io/vla-0-smol">VLA-0-Smol</a> uses. Each of the 7 action numbers is cut into 256 buckets, each bucket gets its own token, and predicting an action becomes ordinary next-token prediction. The language model <em>is</em> the policy. There is no separate controller bolted onto it.</p>

<p>A few choices mattered more than they look:</p>

<ul>
  <li><strong>Bucket edges at the 1st and 99th percentiles</strong>, so a handful of extreme actions don’t stretch every bucket.</li>
  <li><strong>Reusing existing vocabulary</strong> (token ids 48896 to 49151) instead of resizing the embedding matrix.</li>
  <li><strong>LoRA at rank 16</strong>, which trains 56.9M of the 564M weights.</li>
  <li><strong>No image splitting.</strong> With it, each prompt was 2,268 tokens. Without it, 156. That was the difference between fitting on an 8 GB card and not.</li>
  <li><strong>bf16 weights with the loss in fp32.</strong> VLA-0-Smol reports fp16 diverging on this setup, and I didn’t want to find out.</li>
</ul>

<p>Training ran in plain PyTorch at batch 4 with 4 accumulation steps, peaking at 5.8 GB.</p>

<h2 id="it-learned-the-average-demonstration">It learned the average demonstration</h2>

<p>The policy trained cleanly and scored 0%. Watching it, the failure was almost funny: it emitted the same action at every step, whatever it saw and whatever it was told. That action turned out to be the most common bucket in the training set on all six movement dimensions. The model had learned the average demonstration.</p>

<p>The loss said the same thing. A model that ignores its inputs entirely and only learns how often each bucket appears would reach a cross-entropy of 3.44. Run 1 plateaued at 3.40 by step 375 of 2,000. It had captured the bucket frequencies and very little else, so training longer was not going to help.</p>

<p>It wasn’t <em>exactly</em> nothing, though, and that corrected an earlier conclusion of mine. Taking the probability-weighted average over buckets instead of the top bucket gave 8 different outputs across 8 scenes. The input dependence existed. It was just too weak to move the most likely bucket, so greedy decoding turned a nearly flat model into a perfectly flat one.</p>

<p>Before going further I ruled out the boring explanations. The model can memorise a single batch down to near-zero loss. The adapter loads. Training and evaluation share bucket edges. The simulator renders on the GPU. Nothing was broken in the plumbing.</p>

<h2 id="asking-the-model-what-it-uses">Asking the model what it uses</h2>

<p>At 0% success, the usual test (give the policy a wrong instruction and see if it behaves differently) tells you nothing, because 0% looks the same either way. So I measured on the training loss instead. Hold the correct action fixed, corrupt one input, and see how much the loss goes up. Every condition sees the same examples in the same order, so batch difficulty cancels out.</p>

<p>Corrupting the camera images hurt about six times more than corrupting the instruction.</p>

<p>On this benchmark that is exactly backwards. The images <em>cannot</em> tell the tasks apart at the start, and only the sentence can. It’s also not a quirk of small models: robustness studies of LIBERO find much larger VLAs replaying memorised trajectories when the scene or wording changes.<sup id="fnref:plus"><a href="#fn:plus" class="footnote" rel="footnote" role="doc-noteref">1</a></sup></p>

<h2 id="three-fixes-that-made-it-worse">Three fixes that made it worse</h2>

<p>I tried three reasonable things: more scene variety from two other LIBERO suites, predicting five actions at a time, and classifier-free guidance on the instruction. All three made the imbalance worse. The worst run ended at 42-to-1 in favour of vision, against 6.4-to-1 before.</p>

<p>The scene-variety failure ended up being the most useful result of the project. I wanted to know whether the new scenes really were “harder”, so I built about the crudest task classifier possible: nearest-neighbour matching on 24 × 24 pixel thumbnails of the first frame.</p>

<figure class="figure figure--chart figure--2">
  <svg class="chart" viewBox="0 0 680 300" role="img" aria-label="Bar chart. A nearest-neighbour probe on 24 by 24 thumbnails names the task from the first frame in 4.7% of LIBERO-Goal episodes (below the 10% chance line), 91.2% of LIBERO-Spatial and 100% of LIBERO-Object." xmlns="http://www.w3.org/2000/svg">
<text x="52.0" y="14.0" text-anchor="start" class="t-title">task named from the first frame, % of episodes</text>
<line class="grid" x1="52" x2="668" y1="248.0" y2="248.0" /><text x="44.0" y="252.0" text-anchor="end">0</text><line class="grid" x1="52" x2="668" y1="199.4" y2="199.4" /><text x="44.0" y="203.4" text-anchor="end">25</text><line class="grid" x1="52" x2="668" y1="150.7" y2="150.7" /><text x="44.0" y="154.7" text-anchor="end">50</text><line class="grid" x1="52" x2="668" y1="102.1" y2="102.1" /><text x="44.0" y="106.1" text-anchor="end">75</text><line class="grid" x1="52" x2="668" y1="53.5" y2="53.5" /><text x="44.0" y="57.5" text-anchor="end">100</text>
<g class="mark"><title>LIBERO-Goal: 4.7%</title><path class="m-a" d="M108.7,248.0 V241.9 Q108.7,238.9 111.7,238.9 H197.7 Q200.7,238.9 200.7,241.9 V248.0 Z" /><text x="154.7" y="220.5" text-anchor="middle" class="t-ink">4.7%</text><rect class="hit" x="52.0" y="34.0" width="205.3" height="214.0" /></g>
<text x="154.7" y="268.0" text-anchor="middle" class="t-ink">LIBERO-Goal</text>
<text x="154.7" y="284.0" text-anchor="middle">what I train on</text>
<g class="mark"><title>LIBERO-Spatial: 91.2%</title><path class="m-ctx" d="M314.0,248.0 V73.6 Q314.0,70.6 317.0,70.6 H403.0 Q406.0,70.6 406.0,73.6 V248.0 Z" /><text x="360.0" y="62.6" text-anchor="middle" class="t-ink">91.2%</text><rect class="hit" x="257.3" y="34.0" width="205.3" height="214.0" /></g>
<text x="360.0" y="268.0" text-anchor="middle" class="t-ink">LIBERO-Spatial</text>
<g class="mark"><title>LIBERO-Object: 100.0%</title><path class="m-ctx" d="M519.3,248.0 V56.5 Q519.3,53.5 522.3,53.5 H608.3 Q611.3,53.5 611.3,56.5 V248.0 Z" /><text x="565.3" y="45.5" text-anchor="middle" class="t-ink">100.0%</text><rect class="hit" x="462.7" y="34.0" width="205.3" height="214.0" /></g>
<text x="565.3" y="268.0" text-anchor="middle" class="t-ink">LIBERO-Object</text>
<line class="ref" x1="52" x2="668" y1="228.5" y2="228.5" />
<text x="257.3" y="222.5" text-anchor="middle">chance, 10%</text>
<line class="axis" x1="52" x2="668" y1="248" y2="248" />
</svg>

  <figcaption>A thumbnail lookup names the task from the first frame. On LIBERO-Goal it’s below chance. On the two suites I added for variety, the picture gives the answer away.</figcaption>
</figure>

<p>On LIBERO-Goal the probe is below chance, as it should be. On the two suites I’d added, it names the task almost every time. So “more variety” had handed the model 70% more data in which the image answers the question outright. I had been feeding the shortcut.</p>

<h2 id="when-does-the-picture-give-the-task-away">When does the picture give the task away?</h2>

<p>If the first frame is uninformative on LIBERO-Goal, the next question is how long that lasts. I ran the same probe at every point in the episode.</p>

<figure class="figure figure--chart figure--2">
  <svg class="chart" viewBox="0 0 680 310" role="img" aria-label="Line chart. How often a pixel probe names the task, by frame within the episode: about 8% at frame 0, 51% by frame 8, 76% by frame 16 and 96% by frame 80." xmlns="http://www.w3.org/2000/svg">
<text x="52.0" y="14.0" text-anchor="start" class="t-title">task named from pixels, % of episodes</text>
<rect class="band" x="52.0" y="34" width="257.3" height="218" />
<text x="62.0" y="52.0" text-anchor="start" class="t-ink">first 8 frames:</text>
<text x="62.0" y="68.0" text-anchor="start" class="t-ink">only the sentence</text>
<text x="62.0" y="84.0" text-anchor="start" class="t-ink">says which task</text>
<line class="grid" x1="52" x2="664" y1="252.0" y2="252.0" /><text x="44.0" y="256.0" text-anchor="end">0</text><line class="grid" x1="52" x2="664" y1="200.1" y2="200.1" /><text x="44.0" y="204.1" text-anchor="end">25</text><line class="grid" x1="52" x2="664" y1="148.2" y2="148.2" /><text x="44.0" y="152.2" text-anchor="end">50</text><line class="grid" x1="52" x2="664" y1="96.3" y2="96.3" /><text x="44.0" y="100.3" text-anchor="end">75</text><line class="grid" x1="52" x2="664" y1="44.4" y2="44.4" /><text x="44.0" y="48.4" text-anchor="end">100</text>
<line class="ref" x1="52" x2="664" y1="231.2" y2="231.2" />
<text x="664.0" y="225.2" text-anchor="end">chance, 10%</text>
<polyline class="l-a" points="52.0,236.2 84.2,236.2 116.3,231.4 148.5,224.2 180.6,210.9 244.9,182.0 309.3,145.7 373.6,137.4 439.2,93.8 527.0,79.5 664.0,51.9" />
<g class="mark"><title>frame 0: 7.6%</title><circle class="dot-a" cx="52.0" cy="236.2" r="4.5" /><rect class="hit" x="40.0" y="34.0" width="24.0" height="218.0" /></g>
<g class="mark"><title>frame 1: 7.6%</title><circle class="dot-a" cx="84.2" cy="236.2" r="4.5" /><rect class="hit" x="72.2" y="34.0" width="24.0" height="218.0" /></g>
<g class="mark"><title>frame 2: 9.9%</title><circle class="dot-a" cx="116.3" cy="231.4" r="4.5" /><rect class="hit" x="104.3" y="34.0" width="24.0" height="218.0" /></g>
<g class="mark"><title>frame 3: 13.4%</title><circle class="dot-a" cx="148.5" cy="224.2" r="4.5" /><rect class="hit" x="136.5" y="34.0" width="24.0" height="218.0" /></g>
<g class="mark"><title>frame 4: 19.8%</title><circle class="dot-a" cx="180.6" cy="210.9" r="4.5" /><rect class="hit" x="168.6" y="34.0" width="24.0" height="218.0" /></g>
<g class="mark"><title>frame 6: 33.7%</title><circle class="dot-a" cx="244.9" cy="182.0" r="4.5" /><rect class="hit" x="232.9" y="34.0" width="24.0" height="218.0" /></g>
<g class="mark"><title>frame 8: 51.2%</title><circle class="dot-a" cx="309.3" cy="145.7" r="4.5" /><rect class="hit" x="297.3" y="34.0" width="24.0" height="218.0" /></g>
<g class="mark"><title>frame 10: 55.2%</title><circle class="dot-a" cx="373.6" cy="137.4" r="4.5" /><rect class="hit" x="361.6" y="34.0" width="24.0" height="218.0" /></g>
<g class="mark"><title>frame 16: 76.2%</title><circle class="dot-a" cx="439.2" cy="93.8" r="4.5" /><rect class="hit" x="427.2" y="34.0" width="24.0" height="218.0" /></g>
<g class="mark"><title>frame 30: 83.1%</title><circle class="dot-a" cx="527.0" cy="79.5" r="4.5" /><rect class="hit" x="515.0" y="34.0" width="24.0" height="218.0" /></g>
<g class="mark"><title>frame 80: 96.4%</title><circle class="dot-a" cx="664.0" cy="51.9" r="4.5" /><rect class="hit" x="652.0" y="34.0" width="24.0" height="218.0" /></g>
<text x="656.0" y="71.9" text-anchor="end" class="t-ink">96%</text>
<line class="axis" x1="52" x2="664" y1="252" y2="252" />
<text x="52.0" y="270.0" text-anchor="middle">0</text>
<text x="116.3" y="270.0" text-anchor="middle">2</text>
<text x="180.6" y="270.0" text-anchor="middle">4</text>
<text x="309.3" y="270.0" text-anchor="middle">8</text>
<text x="439.2" y="270.0" text-anchor="middle">16</text>
<text x="527.0" y="270.0" text-anchor="middle">30</text>
<text x="664.0" y="270.0" text-anchor="middle">80</text>
<text x="358.0" y="292.0" text-anchor="middle">frame within the episode (log scale past 10)</text>
</svg>

  <figcaption>The same pixel probe, run at each frame offset. It sits at chance for the first few frames, then climbs fast once the arm starts moving and its direction reveals the goal.</figcaption>
</figure>

<p>It sits at chance at the start and is well clear of it by about frame eight, once the arm has started moving and its direction says where it’s going. Those first eight frames are roughly 7% of a typical episode.</p>

<p>Every run so far had sampled training frames uniformly. Which means about 93% of every batch was made of frames where ignoring the instruction was the <em>correct</em> answer. The model wasn’t failing to learn. It was learning the data I gave it.</p>

<h2 id="changing-only-the-sampler">Changing only the sampler</h2>

<p>Run 4 changes one thing. Half of each batch is drawn from the first eight frames of an episode and half is drawn uniformly. Same model, same data, same loss, same schedule.</p>

<figure class="figure figure--chart figure--2">
  <svg class="chart" viewBox="0 0 680 336" role="img" aria-label="Grouped bar chart of the loss increase from corrupting the images versus the instruction. Run 1: 0.64 vs 0.099. Run 3b: 0.50 vs 0.012. Run 4, all frames: 1.18 vs 0.45. Run 4, first eight frames: 0.88 vs 0.98, the only case where the instruction matters more." xmlns="http://www.w3.org/2000/svg">
<text x="52.0" y="14.0" text-anchor="start" class="t-title">loss increase when one input is corrupted, nats</text>
<rect class="m-a" x="52.0" y="31.0" width="10" height="10" rx="2" /><text x="68.0" y="40.0" text-anchor="start" class="t-ink">corrupt the camera images</text><rect class="m-b" x="274.0" y="31.0" width="10" height="10" rx="2" /><text x="290.0" y="40.0" text-anchor="start" class="t-ink">corrupt the instruction</text>
<line class="grid" x1="52" x2="668" y1="262.0" y2="262.0" /><text x="44.0" y="266.0" text-anchor="end">0</text><line class="grid" x1="52" x2="668" y1="225.3" y2="225.3" /><text x="44.0" y="229.3" text-anchor="end">0.25</text><line class="grid" x1="52" x2="668" y1="188.7" y2="188.7" /><text x="44.0" y="192.7" text-anchor="end">0.5</text><line class="grid" x1="52" x2="668" y1="152.0" y2="152.0" /><text x="44.0" y="156.0" text-anchor="end">0.75</text><line class="grid" x1="52" x2="668" y1="115.3" y2="115.3" /><text x="44.0" y="119.3" text-anchor="end">1</text><line class="grid" x1="52" x2="668" y1="78.7" y2="78.7" /><text x="44.0" y="82.7" text-anchor="end">1.25</text>
<g class="mark"><title>Run 1 (uniform), images: 0.64</title><path class="m-a" d="M88.0,262.0 V171.1 Q88.0,168.1 91.0,168.1 H125.0 Q128.0,168.1 128.0,171.1 V262.0 Z" /><text x="108.0" y="161.1" text-anchor="middle" class="t-ink">0.64</text><rect class="hit" x="84.0" y="64.0" width="45.0" height="198.0" /></g>
<g class="mark"><title>Run 1 (uniform), instruction: 0.099</title><path class="m-b" d="M130.0,262.0 V250.5 Q130.0,247.5 133.0,247.5 H167.0 Q170.0,247.5 170.0,250.5 V262.0 Z" /><text x="150.0" y="240.5" text-anchor="middle" class="t-ink">0.099</text><rect class="hit" x="130.0" y="64.0" width="45.0" height="198.0" /></g>
<text x="129.0" y="282.0" text-anchor="middle" class="t-ink">Run 1</text>
<text x="129.0" y="298.0" text-anchor="middle">uniform</text>
<g class="mark"><title>Run 3b (more scenes), images: 0.5</title><path class="m-a" d="M242.0,262.0 V191.7 Q242.0,188.7 245.0,188.7 H279.0 Q282.0,188.7 282.0,191.7 V262.0 Z" /><text x="262.0" y="181.7" text-anchor="middle" class="t-ink">0.5</text><rect class="hit" x="238.0" y="64.0" width="45.0" height="198.0" /></g>
<g class="mark"><title>Run 3b (more scenes), instruction: 0.012</title><path class="m-b" d="M284.0,262.0 V262.0 Q284.0,260.2 285.8,260.2 H322.2 Q324.0,260.2 324.0,262.0 V262.0 Z" /><text x="304.0" y="253.2" text-anchor="middle" class="t-ink">0.012</text><rect class="hit" x="284.0" y="64.0" width="45.0" height="198.0" /></g>
<text x="283.0" y="282.0" text-anchor="middle" class="t-ink">Run 3b</text>
<text x="283.0" y="298.0" text-anchor="middle">more scenes</text>
<g class="mark"><title>Run 4 (all frames), images: 1.18</title><path class="m-a" d="M396.0,262.0 V91.9 Q396.0,88.9 399.0,88.9 H433.0 Q436.0,88.9 436.0,91.9 V262.0 Z" /><text x="416.0" y="81.9" text-anchor="middle" class="t-ink">1.18</text><rect class="hit" x="392.0" y="64.0" width="45.0" height="198.0" /></g>
<g class="mark"><title>Run 4 (all frames), instruction: 0.45</title><path class="m-b" d="M438.0,262.0 V199.0 Q438.0,196.0 441.0,196.0 H475.0 Q478.0,196.0 478.0,199.0 V262.0 Z" /><text x="458.0" y="189.0" text-anchor="middle" class="t-ink">0.45</text><rect class="hit" x="438.0" y="64.0" width="45.0" height="198.0" /></g>
<text x="437.0" y="282.0" text-anchor="middle" class="t-ink">Run 4</text>
<text x="437.0" y="298.0" text-anchor="middle">all frames</text>
<g class="mark"><title>Run 4 (first 8 frames), images: 0.88</title><path class="m-a" d="M550.0,262.0 V135.9 Q550.0,132.9 553.0,132.9 H587.0 Q590.0,132.9 590.0,135.9 V262.0 Z" /><text x="570.0" y="125.9" text-anchor="middle" class="t-ink">0.88</text><rect class="hit" x="546.0" y="64.0" width="45.0" height="198.0" /></g>
<g class="mark"><title>Run 4 (first 8 frames), instruction: 0.98</title><path class="m-b" d="M592.0,262.0 V121.3 Q592.0,118.3 595.0,118.3 H629.0 Q632.0,118.3 632.0,121.3 V262.0 Z" /><text x="612.0" y="111.3" text-anchor="middle" class="t-ink">0.98</text><rect class="hit" x="592.0" y="64.0" width="45.0" height="198.0" /></g>
<text x="591.0" y="282.0" text-anchor="middle" class="t-ink">Run 4</text>
<text x="591.0" y="298.0" text-anchor="middle">first 8 frames</text>
<line class="axis" x1="52" x2="668" y1="262" y2="262" />
</svg>

  <figcaption>How much the loss rises when each input is corrupted. In run 4’s early frames, damaging the instruction finally costs more than damaging both camera views.</figcaption>
</figure>

<p>Inside that early window, corrupting the instruction now costs more than corrupting both camera views, the first configuration in the project where language is the dominant input. Vision didn’t get worse. Both grew, and language grew faster, which is what climbing out of a degenerate solution looks like. Outside the window the ordering is unchanged, because there the image really does answer the question. In the simulator, 60-step rollouts from the same start under the true and a wrong instruction no longer share a single identical step.</p>

<h2 id="0-to-20">0% to 20%</h2>

<table>
  <thead>
    <tr>
      <th>Policy</th>
      <th>What changed</th>
      <th>Success</th>
      <th>95% interval</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Run 1</td>
      <td>uniform frame sampling</td>
      <td>0%</td>
      <td>no task completed under any decoding</td>
    </tr>
    <tr>
      <td>Run 4</td>
      <td>early frames weighted up</td>
      <td><strong>20.0%</strong> (10/50)</td>
      <td>11.2 – 33.0%</td>
    </tr>
    <tr>
      <td>Run 6</td>
      <td>continuous head instead of tokens</td>
      <td>12.0% (6/50)</td>
      <td>5.6 – 23.8%</td>
    </tr>
  </tbody>
</table>

<p>Closed-loop on LIBERO-Goal with the provided harness: 10 tasks × 5 episodes, 300-step cap.</p>

<p>Twenty percent is not a good LIBERO-Goal score, and I don’t want to dress it up. VLA-0-Smol reaches 95.6% with the same 500M backbone, but with 100k steps at batch 192, full fine-tuning, action masking and 8-step chunks, against my 20k steps at batch 16 with LoRA and one action at a time. SmolVLA’s own ablation puts a single-action policy at 54%,<sup id="fnref:smolvla"><a href="#fn:smolvla" class="footnote" rel="footnote" role="doc-noteref">2</a></sup> which is the fairer comparison.</p>

<p>What run 4 can do: turn on the stove, and put the bowl on the stove or on top of the cabinet. What it can’t: any drawer task, or anything that needs a precise grasp. The failures run out the full 300 steps instead of going wrong early. The policy never <em>finishes</em>, which is the signature of small errors accumulating with nothing to correct them. Five episodes per task is also a small sample (one success reads as 20%), so I trust the pooled number and not the per-task ones.</p>

<h2 id="what-it-cost-the-language-model">What it cost the language model</h2>

<p>The token ids I borrowed for action buckets were not spare slots. They are real English word pieces, including “Britain” and “selectivity”, and training on actions overwrote them. Worse, the action head is the language model’s own output layer, so every training step also reshaped how it predicts ordinary words.</p>

<table>
  <thead>
    <tr>
      <th>Model</th>
      <th>Perplexity</th>
      <th>Leakage into action tokens</th>
      <th>Multiple choice</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Base SmolVLM2</td>
      <td>12.7</td>
      <td>0.0001</td>
      <td>55%</td>
    </tr>
    <tr>
      <td>Run 4 (the 20% policy)</td>
      <td>15.5</td>
      <td>0.002</td>
      <td>60%</td>
    </tr>
    <tr>
      <td>Run 3b (trained hardest)</td>
      <td>16,257</td>
      <td>0.55</td>
      <td>60%</td>
    </tr>
    <tr>
      <td>Run 6 (output layer frozen)</td>
      <td>12.8</td>
      <td>0.0001</td>
      <td>50%</td>
    </tr>
  </tbody>
</table>

<p>The interesting part is that multiple-choice accuracy barely moves even when perplexity explodes. Perplexity is absolute, so losing half the probability mass to action tokens destroys it. Multiple choice only ranks four options, and the leaked mass shifts all four about equally. Fine-tuning damaged the model as a <em>writer</em> and largely spared it as a <em>reader</em>. (With 20 questions the error bar is about 11 points, so “no damage I can detect” is the honest version.)</p>

<p>Run 6 avoids the problem by using a separate small head and never touching the output layer, and comes back indistinguishable from the base model. A cheaper fix I didn’t get to is training only the 256 action rows of the output layer.</p>

<h2 id="two-smaller-results">Two smaller results</h2>

<p><strong>State tokens did nothing.</strong> I added the robot’s own position to the prompt as numbers. Corrupting them barely changes the loss, the signature of an input the model has learned to skip. The wrist camera is bolted to the gripper, so the image already encodes arm position more precisely than my numbers did.</p>

<p><strong>A continuous head is faster and closer on average, but less reliable.</strong> Run 6 replaces the 256-bucket classifier with a small network that outputs the 7 numbers directly. It is 1.7× faster and has lower average error, yet scores 12% against 20%. The pooled gap isn’t significant, so I re-ran the strongest task with three times the episodes, where it is (p = 0.0022).</p>

<p>The reason isn’t accuracy. When two actions are both valid, say passing left or right around an object, their average is the path straight through it. A classifier can commit to either bucket. A regressor has to pick the middle. Tokens buy reliability, not accuracy or speed.</p>

<h2 id="what-id-do-next">What I’d do next</h2>

<p>Action chunking is the biggest gap to published results and the thing most likely to fix the long-horizon failures. The catch is that chunking dilutes the language signal, which is part of what broke run 3b. So the experiment worth running is chunking <em>together with</em> run 4’s frame weighting, not either on its own.</p>

<p>The number I care about from this project was never the 20%. It was finding out why the model ignored the instruction, and then watching the same model listen once it was trained on the moments where listening pays off.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:plus">
      <p>Fei et al., <a href="https://arxiv.org/abs/2510.13626">LIBERO-Plus: In-depth Robustness Analysis of Vision-Language-Action Models</a>, 2025. <a href="#fnref:plus" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:smolvla">
      <p>Shukor et al., <a href="https://arxiv.org/abs/2506.01844">SmolVLA: A Vision-Language-Action Model for Affordable and Efficient Robotics</a>, 2025. <a href="#fnref:smolvla" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Adithya Balaji</name></author><category term="VLA" /><category term="LIBERO" /><category term="SmolVLM2" /><summary type="html"><![CDATA[Fine-tuning SmolVLM2-500M into a LIBERO-Goal policy: why it learned to ignore the instruction, and how weighting the first eight frames of each episode took it from 0% to 20%.]]></summary></entry></feed>