Duck's-Eye Viewa hands-on lab

How a robot sees

Follow one camera frame through a real robot duck's vision stack: turn it, letterbox it, slide filters over it, drown in 2,100 guesses, shred the duplicates, squeeze it into 8 bits, and turn the box that survives into a head turn. Every robot number comes from its own code, and every toy is labelled as one. About 90 minutes of play.

0 · One look: the whole trip, one frame

A robot doesn't "see". It pushes one camera frame down a pipeline and gets back a number it can act on. This lab follows one frame through Microduck, a 25 cm open-source robot duck whose head camera finds other Microducks and turns the head toward them. Its head is drawn as a two-storey workshop staffed by ducklings in hard hats: every room is one step of this lab. Every robot number on these pages comes from the robot's own code, and every toy is labelled as one. Every term with a wavy red underline is tappable.

Eye window = the camera
an IMX219, streaming 1280×720 (step 1)
Darkroom = pre-processing
turn it, mount it on a grey card (step 1)
Stencil room = the CNN
stencils slid over the print (step 2)
Box desk = the detection head
2,100 pigeonholes, one sticky note each (step 3)
Shredder clerk = NMS
twenty notes on one duck become one (step 3)
Tiny ruler = INT8
only 256 notches; the thermometer and fan live here too (step 4)
Map room = geometry
pinhole string, protractor, intrinsics (steps 5–6)
Neck crank = the head turn
a bearing becomes an angle
Engine room = the walking policy
runs on proprioception; never looks at a photo

Covers HF CV Course, Unit 0–1 overview: what the course is for, and the shape of a perception pipeline · study guide 00-perception-spine.md

Take one look

Put another Microduck somewhere in the room (drag it on the map, or use the sliders), then press Take one look and watch one frame travel through every room. Everything shown is computed live: the frame, the turn, the letterbox, the 2,100 candidates, the clean-up, the bearing, the angle.

Teaching model. The room is a painted toy and the scores come from a stand-in for the real network, built to fill the real output layout (2,100 candidates, three grids) and to behave the way the robot's code warns. The letterbox, the clean-up (decode and NMS) and the bearing are exact ports of the robot's code. Turning the bearing into an angle is standard pinhole math with our inferred field of view; the robot's own intrinsics are still placeholders (docs/ideas/autonomous_behavior.md:88).
Top-down map (drag the duck; the wedge is what the camera can see)
Duck: metres across (− left, + right)
Duck: metres ahead
Each press is a fresh frame: new sensor noise, same room.
Eye window1280×720 streamed–
Darkroom: turna quarter turn–
Darkroom: mountgrey-114 card–
Stencil roomyolo11n, one class–
Box desknotes posted–
Shredder clerkthe shredder keeps–
Map roombearing −1…+1–
Neck crankhead turn–
as streamed · 1280×720, on its side
upright · 720×1280
the input · 320×320, grey where there's no picture
the head turns · toward the box it kept
bearing = (box centre x ÷ 720) · 2 − 1  ·  angle = atan(bearing · tan(FOVLR ÷ 2))
Press Take one look to see one frame worked through.
Guess first!
There's one duck in the room. Before any clean-up, how many boxes does the detector's head hand back?
All 2,100. Every pigeonhole posts a note whether or not it saw anything; only the score tells them apart. On a duck at 3 m, a dozen or two clear the 0.35 threshold (the robot's code says “twenty”), and the shredder clerk keeps one. Take a look and read the box desk and shredder stations.
Guess first!
Does Microduck's walking policy use the camera?
Never. The walking policy's observations are proprioceptive: joint positions and speeds, the IMU, gravity. It never sees a photo. Vision is a behaviour-level sense ("where's the other duck, so I can look at it"), which is why it can afford to be slow. That policy is the subject of Policy Pond.

The time budget: one second in the head

The legs tick 50 times a second. The eye looks twice. Slide the looks-per-second dial and watch the board heat up; then switch the darkroom back to the way it used to work, when converting the whole frame before shrinking it ate 345 of the 407 ms a look took.

Looks per second
Darkroom
Teaching model. The temperature curve is a toy anchored at two real numbers: about 60 ms of work per look at 2 looks a second by design, and 95 °C flat out. Everything between is drawn, not measured.
walking loop, 50 ticks of 20 ms · a look: inference + decode, p50 25.7 ms, p95 58.4 ms · old darkroom: convert then shrink
95°75°55°
of each second spent looking
walking ticks between looks
board temperature (toy curve)

Why a look used to cost 407 ms

The old darkroom converted all 921,600 pixels of the 1280×720 frame to RGB, then shrank them to 320×320 and threw 89% away. The fix fills only the model's 102,400 input pixels (57,600 of them sampled from the picture, the rest grey card), straight from the camera's format. The code calls it nine times less arithmetic for the same answer, and it's nearest-neighbour on purpose (step 1).
Guess first!
At 2 looks per second, how many walking ticks go by between one look and the next?
25. Half a second between looks, 20 ms per tick: 500 ÷ 20 = 25 steps of walking per glance. The legs can't wait for the eyes, which is why the walking policy runs on its own senses and vision only steers the behaviour above it.
Say it out loud
One frame goes in, gets turned and mounted on a grey card, is read by stencils, comes back as 2,100 sticky notes, gets shredded down to one box, and that box becomes a bearing and a head turn. It happens twice a second, and the legs never wait for it.

1 · The darkroom: what a camera hands you, and the mat it goes on

A camera doesn't hand the robot a picture. It hands it 2,764,800 numbers: a grid 1280 wide by 720 tall, lying on its side, with three numbers per cell for red, green and blue, each 0–255. The darkroom's job is to make that grid look exactly like the ones the detector was trained on: turn it upright, shrink it by one factor onto a square grey card, keep the colour order. Nothing in here is clever. Everything in here is a contract, and breaking it doesn't crash anything, which is the problem.

The print = the frame
an H × W × 3 array of uint8
The quarter turn = orientation
1280×720 as streamed → 720×1280 upright
The grey card = the letterbox
one scale, 114-grey bars where there's no picture
The wall sign = RGB, not BGR
the channel order the model saw
The guillotine = stretching, forbidden
two scales make a duck 1.78× too wide

Covers HF CV Course, Unit 1: image as a function and an array, imaging, pre-processing (resize, pad, channel order, normalise) · study guide U1-fundamentals.md

Pixels are numbers

Hover or tap anywhere on the frame (arrow keys nudge by a pixel). The loupe shows the 11×11 pixels around your finger, and the readout shows what the computer actually has there: three whole numbers, and the position they live at in the array.

Duck: metres across (− left, + right)
Duck: metres ahead
Show
2,764,800
numbers: 1280 × 720 × 3
2.76 MB
as uint8, one byte each
11.06 MB
as float32, once it's ÷ 255
Teaching model. The room is painted, not photographed, so its pixels are cleaner than a real IMX219's. The array shape, the turn and the byte arithmetic are the robot's.
11 × 11 pixels, ×16
Guess first!
The bytes are the same, but a library reads them as BGR instead of RGB. What does the detector do?
Runs normally, scores worse. The array has the same shape and type, so nothing checks it. Red and blue are swapped, the model has never seen a duck in those colours, and every score drifts down. The "Quietly worse" bench below measures it.

The mount: turn it, then fit it on a square card

The sensor streams 1280×720 on its side. The darkroom turns it a quarter turn clockwise, then fits the 720×1280 print into the model's 320×320 square. Two ways to do that: letterbox (one scale for both axes, grey where the picture runs out) or stretch (a scale per axis, no grey, ducks squashed). The model was trained on the first.

as streamed · 1280×720
quarter turn · 720×1280
letterbox · ×0.25 → 180×320, pad 70
stretch · ×0.444 across, ×0.25 down
letterbox: scale, fitted size, side pads
of the 320² input is grey card
stretch: the duck comes out this much too wide
duck box in the letterboxed input (w × h px)
the same duck in the stretched input
Guess first!
What fraction of the 320×320 square is actually picture?
56.25%. 720×1280 shrunk by 0.25 is 180×320: it fills the height and only 180 of the 320 columns. 180 ÷ 320 = 56.25% picture, 43.75% grey card. Nearly half of what the detector looks at is a flat grey it learned to ignore.
Guess first!
Someone swaps the letterbox for a stretch to 320×320. What happens when the robot runs?
It runs, worse. 320×320×3 is still 320×320×3, so nothing complains. But every duck is now 1.78× wider than anything the model trained on, and the scores drift down. Try it on the bench below and watch the held-out score.

Quietly worse

The contract has three clauses: grey 114, RGB, letterbox. Break any of them below. Nothing errors. The preview still looks like a picture. The detector still returns boxes. Only the score moves, and you'd never see it without a held-out test set.

Pad colour (grey level)
Channel order
Fit
Teaching model. The toy detector docks a fixed amount for each broken clause (BGR most, then stretch, then any pad but 114), so every wrong grey costs the same here. The direction is the robot code's warning; the sizes are ours. The held-out set is 40 synthetic frames with 35 ducks, and the clean 0.979 is a toy number, not the real model's 0.976.
what the model is handed
best score on the duck in this frame
AP50 on 40 held-out frames
ran fine · no error

Nearest or bilinear? The robot picks the cheap one

Shrinking 720×1280 to 180×320 means every input pixel stands for a 4×4 block of the frame. Nearest-neighbour copies one pixel from the block. Bilinear blends the four around the sample point. Drag the square on the thumbnail to compare the same 40×40 patch both ways.

the 320 input · drag the square
nearest · 1 read per pixel · what the robot does
bilinear · 4 reads + 3 blends per pixel
average difference per channel, this patch (of 255)
3×
the cost of bilinear, per the robot's code
≈ 1 px
of box position, what bilinear would buy
Guess first!
Shrinking a blurry room photo by 4×, beside a 50 Hz control loop. Which resize does the robot use?
Nearest. The code says so and says why: bilinear "costs three times as much to move a box by a pixel", on an input that is a blurred photograph being downscaled by four. Accuracy for speed, chosen on purpose, and reversible if a measurement ever says otherwise.

Challenge: map it back

The detector answers in input pixels (the 320 square). The robot needs frame pixels (720×1280) to compute a bearing. Undo the mount: subtract the side pad, divide by the scale. Type the frame box for the highlighted input box.

xframe = (xin − padx) ÷ scale  ·  yframe = (yin − pady) ÷ scale
with scale = 0.25, padx = 70, pady = 0
Say it out loud
A frame is 1280 by 720 by 3 whole numbers. Turn it upright, shrink it by one factor onto a grey-114 card, keep RGB. Break any of that and nothing crashes; the detector just gets quietly worse. To use a box, subtract the pad and divide by the scale.

2 · The stencils: how a CNN finds edges, then ducks

The darkroom hands over a 320×320 print. The stencil room never looks at the whole thing at once. A duckling holds a 3×3 stencil over one spot, multiplies the nine pixels under it by the nine numbers on the stencil, adds them up, and writes the answer on a new sheet, the feature map. Then it slides over and does it again, 102,400 times. Stack a few dozen of those layers, each learning its own stencils, and they stop finding edges and start finding bills, heads and whole ducks. That stack is a CNN, and yolo11n's is what Microduck runs.

Stencil = a kernel
nine learned weights, slid over the print
New sheet = a feature map
where the stencil's pattern was found
Smaller prints = stride 2
halve the print, five times: 320 → 10
Tower floors = the three grids
strides 8, 16, 32 · 2,100 pigeonholes downstairs

Covers HF CV Course, Unit 2: CNN intro (kernels, stride, padding, feature maps), ResNet, MobileNet, transfer learning, and the YOLO page's grids · study guide U2-cnns.md

Slide the stencil

This is the grayscale of the letterboxed input, grey-114 card and all. Pick a stencil or type your own, then hover or tap any cell of the feature map to see the nine multiplications that made it. Exact: the same cross-correlation every deep-learning library calls “convolution” (nobody flips the kernel).

The print
Stencil presets
Stride, with padding 1 → sheet
the print: gray 0–255, 320×320
the new sheet · teal − · paper 0 · brick +
feature map
largest |response|
multiply-adds for this one sheet

Stride and padding: how big is the new sheet?

Four knobs decide it. Slide them and watch the window walk across the print. The study guide's worked 5×5 (the course's Prewitt stencil, slid with no padding) is one button away; its top-left answer is 13.

Input W
Stencil K
Padding P
Stride S
The stencil: right column minus left
Window
Exact. Cell values are core.conv2d on the numbers shown (padding cells are 0). The size rule is not written down in the course; its Keras summary (28 → 26 → 13 → 11 → 5) obeys it.
Guess first!
Run the 320×320 input through a 3×3 stencil with padding 1 and stride 2. How big is the feature map?
160×160. ⌊(320 − 3 + 2)÷2⌋ + 1 = ⌊159.5⌋ + 1 = 160. Padding 1 keeps a 3×3 from eating the border; stride 2 halves it. Set the stride switch above to 2 and the sheet shrinks to 160 on the spot. Do that five times and 320 → 160 → 80 → 40 → 20 → 10, which is where the next widget goes.

Why three grids: the print shrinks, the cells see more

A stride-2 stencil halves the print. Do it five times and 320 becomes 10. Each halving also doubles what one cell stands for, its receptive field. YOLO keeps the last three prints, 40×40, 20×20 and 10×10, and gives every cell one pigeonhole downstairs: 2,100 in all.

Stride-2 layers stacked

Which floor suits the duck?

Grid
Duck straight ahead, at
Guess first!
At 320 px, how many sticky notes (candidate boxes) does the box desk post for one look?
2,100: one per cell on the three floors, 40² + 20² + 10² = 1,600 + 400 + 100. The same head at the usual 640 input would post 8,400. The robot's own decoder says “2100 candidate boxes out” in its first lines. Step 3 is about what happens to them.

Cheap stencils: MobileNet's trick

A standard 3×3 stencil taking 32 channels to 64 is really 64 stencils, each 3×3×32. MobileNet's depthwise-separable version splits the job: one 3×3 stencil per input channel (space), then a 1×1 stencil that mixes channels. Same shape out, a fraction of the arithmetic.

Stencil size k
Output size (positions)
Multiply-adds for one look through this layer. The shortcut: with 3×3 stencils the saving heads toward 9× as Cout grows.
Two more Unit 2 ideas, in one breath. ResNet's skip connection hands a copy of the print past each stencil table, y = F(x) + x, so a layer only has to learn the edit and very deep stacks stay trainable (YOLO backbones, yolo11n included, are built from such blocks; a general fact about the Ultralytics family, not a repo citation). Transfer learning is why 150 frames can train a detector at all: the edge and texture stencils come pre-cut from a big dataset and only the duck-specific layers need the robot's footage (the standard Ultralytics workflow; the repo doesn't say which starting weights were used).
Aside: a stencil works on any 2D grid. Microduck's pet-detect is a ~20 KB CNN that hears the head being scratched: two 3×3 conv layers over a 40-band × 100-frame log-mel spectrogram, frequency down one axis and time along the other. Same slide-multiply-add; the print just happens to be a sound (from the study guide's reading of pet-detect/training/train.py).
Teaching model. The room in the print is the synthetic camera's, not a photo from the robot, and the “which floor suits the duck” rule is this lab's detector's rule of thumb. The convolution, the size formula, the receptive-field arithmetic and every parameter count are exact.
Guess first!
Swap the course's 3×3, 32 → 64 layer for a depthwise-separable one. How many fewer weights?
About 8×. Standard: 3·3·32·64 = 18,432 (+64 biases = 18,496). Separable: 3·3·32 + 32·64 = 288 + 2,048 = 2,336 (+96 biases = 2,432). That's 7.9× without biases, 7.6× with. The shortcut 1/Cout + 1/k² = 1/64 + 1/9 = 0.127 says the same. Press “The course's 32 → 64 layer” to see it.
Say it out loud
A stencil slides over the print: multiply, add, write it down. Output size is input minus kernel plus twice the padding, over stride, plus one. Stride 2 halves the print; five halvings take 320 to 10, and the last three prints, 40, 20 and 10 on a side, hold the 2,100 pigeonholes.

3 · The box desk: 2,100 guesses, one duck

The stencil room's last three prints come downstairs as a wall of 2,100 pigeonholes: 1,600 small, 400 medium, 100 large. Every hole posts one sticky note per look, a candidate box and a score, whether or not it saw anything. Nobody at the desk throws a note away. That is YOLO's bargain: one pass, a fixed number of guesses, and a clean-up afterwards. On Microduck the clean-up is decode: a gate (the threshold), a sort, the shredder clerk (NMS), and a trip back out of the letterbox. Then the inspector grades the whole desk with one number, mAP50.

Pigeonholes = 2,100 candidates
40² + 20² + 10², one note each
Sticky note = box + score
cx, cy, w, h, and how sure
Shredder clerk = NMS
“IoU ≥ 0.5 with a kept note → shred”
Inspector = mAP50
precision, recall, one grade

Covers HF CV Course, Unit 6: object detection, IoU, mAP, a segmentation aside · and Unit 2's YOLO page (grids, NMS) · study guide U6-detection-and-yolo.md

The pigeonholes

One frame: a duck straight ahead. The heat is every hole's score on the floor you pick; the gold outlines are the notes that clear the gate. Tap any hole to read its note, pulled straight out of the raw planar tensor by index.

Floor
Gate (threshold)
Duck straight ahead, at
Guess first!
The head hands back twenty boxes on one duck. Is the detector broken?
No. Neighbouring holes, often on more than one floor, all see the same duck, and the exported head “does not suppress anything”. Twenty overlapping notes is the normal case, not a fault. Raising the threshold would eventually leave one, but at the price of missing every duck the model is less sure of. The shredder clerk is the right tool: sort by score, keep the top note, shred whatever overlaps it. Slide the gate above and watch the count; then go shred them below.

The shredder: greedy NMS, one note at a time

Same frame, same notes. The clerk sorts the ones past the gate by score, keeps the first, and shreds every later note whose IoU with a kept note reaches the limit. Step through it, or run it, then raise the limit and watch duplicates survive. Exact: core.decode is the robot's decode, including its stable sort.

Gate (threshold)
Shred at IoU ≥
zoomed ×3 on the duck · white = waiting · gold = the note in the clerk's hand · green = kept · red dashed = shredded
Back out of the letterbox
The kept box lives in 320-px input coordinates. decode undoes the darkroom's letterbox so the box lands on the upright 720×1280 frame. (The real code un-letterboxes every note before the sort. Shifting and scaling all boxes alike leaves every IoU unchanged, so the order doesn't change the answer.) The daemon keeps it upright “because that is the space the boxes are in” (mediad/src/detect.rs:260).
the upright frame, kept boxes mapped back
Guess first!
Raise the shredder's limit from IoU ≥ 0.5 to IoU ≥ 0.9. How many boxes come back for the one duck?
More. At 0.9 a note has to overlap a kept box almost perfectly to be shredded, so slightly shifted notes on the same duck all survive, and one duck comes back as several. Too low a limit has the opposite failure: two ducks standing side by side merge into one. The study guide's worked NMS example agrees: at limit 0.9, its four boxes on two ducks all survive. Slide the limit and count the green boxes.

IoU by hand

Drag your box over the true duck box; drag the corner square to resize. Brick = the overlap, both tints together = the union. Every grid unit is one pixel, so the arithmetic is exact and the challenges have exact answers.

The study guide's worked pair, A = [100, 100, 200, 200] and B = [150, 120, 250, 220], is a 10×10 box shifted by 5 across and 2 down at this scale: overlap 40, union 160, IoU 0.25.
Guess first!
Two 10×10 boxes, one shifted 5 px sideways. What's their IoU?
1/3. Overlap 5 × 10 = 50; union 100 + 100 − 50 = 150; 50 ÷ 150 = 0.333. Half the box overlapping gives only a third of an IoU because the union grows as the overlap shrinks. That's why a “50% match” at IoU 0.5 is a fairly tight box. Make it above: a 10×10 box, offset by 5.

The report card: a held-out session

The inspector grades a whole recording the detector never trained on, a held-out session: 40 frames of the toy room with real ducks, rubber ducks and wall prints, scored by the same precision–recall arithmetic as any detection benchmark and summed up as AP50. Slide the gate and watch TP · FP · FN move; switch the look-alikes off to see what hard negatives cost.

Gate (threshold)
Teaching model. The room, the recording and every score are this lab's stand-in detector, so the AP here is a toy number. The matching rule, precision, recall and both AP integrals are exact.
solid = precision vs recall as the gate sweeps from strict to loose · dashed + shaded = the envelope AP integrates · red dot = your gate
False positives at this gate
Misses at this gate

Read the tensor right

The head's output is [1, 5, 2100]: five long rows, not 2,100 short records. Candidate i's numbers sit at raw[k·2100 + i]. Read it as five numbers per box and all five values come from one row, five neighbouring holes' cx (or cy, or w…). Four in five of the “scores” are really pixel coordinates or box sizes, far over any threshold, and many of the boxes are giants. It doesn't crash. It looks like a weak model.

Read it as
Candidate i
The tensor as five shelves of 2,100. Teal = value out of 320 px; brick = score out of 1. Gold = where the reader looks for candidate i.
Aside: boxes or masks? Unit 6 also covers segmentation: label every pixel instead of drawing a box. Semantic (all duck pixels one colour), instance (each duck its own mask), panoptic (both). Scored with the same IoU, mask against mask, and the Dice coefficient. Microduck never needs it: the behaviour wants a direction, and a box centre gives that.
Say it out loud
The head posts 2,100 notes and never throws one away. decode reads them planar, drops the ones under the gate, sorts by score, and the shredder shreds any note whose IoU with a kept note is half or more. IoU is overlap over union, and mAP50 is the area under precision–recall, counting a box right at IoU ≥ 0.5.

4 · The tiny ruler: squeezing the model into 8 bits

Up at the box desk every sticky note was written with a float32 pen: any value, seven digits. The NPU is built for a cheaper pen: 8-bit integers, only 256 of them. So before the model boards the robot it is quantised: every weight and every output is measured with a tiny ruler that has 256 notches, and the gap between notches is set by the biggest number that ruler has to reach. Choose the ruler well and you lose at most half a notch. Share one ruler between 320-pixel box coordinates and 0–1 scores, and the scores vanish. That is Microduck's "about 1.3" bug, and it is written into the real config.

The tiny ruler = INT8
256 notches: a scale and a zero point
Notes reading 1.3 = per-tensor scores
one ruler shared with the box coordinates
The calculator chip = the NPU
0.8 TOPS of INT8, one core
Thermometer + fan = the thermal limit
why it looks only twice a second

Covers HF CV Course, Unit 9: model optimisation (quantisation, pruning, distillation), deployment (ONNX, edge), tools · study guide U9-model-optimization.md

The 256-notch ruler

Pick a float range, pick how many bits, and the ruler is fixed: the scale is the gap between notches, the zero point is the notch that stands for 0.0. Drop a value on it and read what comes back. Inside the range the error is never more than half a step. Outside, the value is clipped to the end of the ruler.

Float range: min
Float range: max
Bits (notches = 2bits)
Integers
Value to drop on the ruler, x
The course's worked example: [−1.0, 3.0] as uint8 gives scale 0.015686 and zero point 64; 0.5 → 96 → 0.50196; 2.2 → 204 → 2.19608; −0.7 → 19 → −0.70588; worst error 0.00784.
Drag on the top ruler to move x. Hatched ends = outside the range (clipped). Gold = x, teal = what comes back (x̂), red = the error. The magnifier zooms in on the notches around x; dotted lines are the half-step boundaries where rounding flips.
scale s (one notch is worth)
zero point z (the notch for 0.0)
q, the byte that gets stored
x̂, what comes back
error x̂ − x
worst possible inside: s ÷ 2
Exact. This is affine (asymmetric) quantisation, the arithmetic behind every INT8 tool on the course's page 9.3, including the Edge TPU notebook that reads scale, zero_point off the model. The course names quantisation without ever showing it; the study guide's worked example fills that gap, and this ruler reproduces it.

4× smaller?

A model file is roughly parameters × bytes per parameter. Four bytes for float32, two for float16, one for INT8: the course's "about 4 times". Microduck ships both files side by side, so we can check.

Parameters
Bytes per parameter
textbook, from the slideryour pickthe real files
duck_detect.onnx (float, our inference)
duck_detect.rknn (INT8)
real shrink, not 4×
On the robot. ·
The doc says ·
10,477,940 B ÷ 4 B ≈ 2.62 M parameters, which fits a float32 ·
Why 2.72× and not 4× (our inference): a .rknn holds the compiled graph as well as the weights (container overhead), and some layers are typically kept at higher precision. We haven't opened the files to check.
Guess first!
Does INT8 always shrink a model 4×?
In principle, 4 bytes → 1 byte is 4×, and that is why INT8 is the default for edge chips. The two real files say 2.72×: 10.48 MB of ONNX against 3.85 MB of RKNN. The compiled file carries more than weights, and not every layer is 8-bit. Set the slider to 2.62 M and compare the textbook bars with the real ones.

The 1.3 mystery

The real config says it plainly: · . Here is the mechanism, on the teaching detector's raw [5, 2100] output for a duck scene. The box rows need a ruler that reaches ~320 px. One ruler for the whole tensor means one notch ≈ 1.26, and a 0–1 score can only land on notch 0 or notch 1.

Quantisation
Score threshold
Scene
candidates ≥ threshold
kept after NMS
one notch on the score row
values a score can take
The five rows of the head's output, each as a histogram (log-count). Grey = float; teal = after the quantisation you picked. The score row is highlighted: under one shared ruler it collapses to two spikes.
Every one of the 2,100 scores as a tick, before and after. Slide the threshold: in per-tensor mode every score is 0 or one notch (≈1.26), so any threshold from 0.01 up to that notch keeps exactly the same boxes, and above it nothing survives. The cut-over at half a notch (≈0.63) is where a float score rounds up or down, not a threshold. Two positions, not a dial.

The inspector's report card, on the held-out session

Forty frames the detector never trained on (35 ducks, plus rubber ducks and wall prints as look-alikes). Same frames, three ways of storing the head's output.

Highlight
–
AP50, float
–
AP50, INT8 one shared scale
–
AP50, INT8 per-channel
Precision against recall as the inspector walks down the ranked list. Float and per-channel lie on top of each other. Per-tensor can't rank: every survivor scores one notch (1.25 to 1.27 here, because the toy measures each frame's range; a real export fixes one scale), so the order comes from grid position, not confidence, and the curve stair-steps.
Teaching model. The scene, the detector and its scores are ours (the real yolo11n scored mAP50 0.976 on its own held-out session · ). The quantisation, decode, NMS, IoU and AP arithmetic are exact. The 0.979 is a toy number that happens to sit near the real one.
Why the ranking is lost, and why precision can even look better. NMS and the inspector both sort by score. With every real detection reading exactly one notch, the sort has nothing to sort: ties stay in grid order, so the box that survives is whichever cell came first, not the most confident one. Meanwhile the weak look-alikes (a rubber duck at 0.4–0.6) round to 0 and disappear, so precision at 0.35 can rise while AP falls. Toggle per-channel and the score row gets its own ruler (one notch ≈ 0.004): the scores, the ranking and the AP all come back.
On the robot. · . The bring-up doc's float-vs-INT8 check: · . Two detections is a smoke test, not a benchmark; a fuller check would compare float and INT8 AP on the held-out session, which is what the card above does for the toy. Box coordinates survive the shared ruler because half a notch is ≈0.63 px in the 320 input, about 2.5 px in the 1280-px frame: small next to a duck.
Guess first!
Why does every detection on the robot read about 1.3?
One shared scale. The output tensor holds box coordinates up to ~320 px and scores from 0 to 1, under one scale and one zero point. A notch is ≈1.26, so a score rounds to 0 or to one notch. Real detections read ≈1.3, everything else reads 0, and the conversion is working exactly as asked. Give the score row its own scale (per-channel) and the probabilities return.
Guess first!
INT8 with one shared scale. You move the threshold from 0.2 up to 0.6. How many detections change?
None. Every surviving score is exactly one notch (≈1.26) and every other score is exactly 0, so any threshold above 0 and below one notch keeps the same boxes. The same threshold = 0.35 is a real probability cut when the float .onnx runs on the CPU (the file size says it is float; our inference). On the NPU the same number is an on/off switch.

Walks badly to see well

A look costs about 60 ms, so speed alone would allow ~16 looks a second. The robot takes two. · . Slide the rate and watch the board, the clock, and the 50 Hz walking loop that shares the CPU.

Looks per second
board temperature
CPU clock
time between looks
duty cycle at 60 ms a look
Teaching model. Only the flat-out anchor is from the repo: ~60 ms a look, 95 °C, and the CPU throttling to 408 MHz. The curve between idle and flat out, the 90 °C throttle line, and the missed-tick picture are ours, drawn to show the shape of the trade.
One second of the walking loop: 50 ticks of 20 ms. Each look lays ~60 ms (3 ticks) of work on the shared CPU. Throttled to 408 MHz, the tick's own work no longer fits its 20 ms and ticks are missed: a robot that walks badly to see well.
On the robot. · · · · latency · · ·
Not in the course. Rockchip's RKNN toolchain (rknn-toolkit2 to convert, rknpu2 to run) appears nowhere in Unit 9. The nearest analogue is the Edge TPU notebook: full-integer post-training quantisation with a representative dataset, then a vendor compiler for one chip. The per-tensor-vs-per-channel choice that decides the 1.3 bug isn't in the course either; the study guide covers it.
Say it out loud
INT8 measures every number on a ruler with 256 notches: the scale is the gap, the zero point is where 0 sits, and inside the range you lose at most half a notch. Share one ruler between 320-pixel coordinates and 0–1 scores and every score lands on notch 0 or notch 1: about 1.3 or nothing. And the robot looks twice a second because of heat, not speed.

5 · The map room: turning a pixel into a direction

The box desk hands the neck crank a bearing: where the box sits across the frame, −1 to +1. That is a position on paper, not an angle in the room. To turn it into "9.6° to the right" you need the camera's intrinsics, the numbers that say how the pinhole maps the world onto the sensor. Microduck's are still placeholders, and this mount has a trap: the camera is turned on its side, so the sensor's famous 62° runs up and down, and the left-right view is at most about 37°.

Pinhole + strings = projection
u = cx + f · X ÷ Z, exact
The protractor = bearing → angle
atan(b · tan(FOV ÷ 2)), needs the focal length
The sensor, cropped and turned = the sensor mode
4:3 chip, 16:9 crop, quarter turn
The neck crank = the head turn
and the over-turn a wrong f causes

Covers HF CV Course, Unit 8 (part 1): camera models, intrinsics, linear algebra for 3D · study guide U8a-camera-geometry.md

Where's the duck?

Drag the other duck around the room. The camera frame on the right shows exactly where the pinhole model puts it, with its box. The readouts turn that box into an angle four ways: the truth, the correct intrinsics, a placeholder that puts "62° across the width" (our guess at what a placeholder would do), and the lazy linear guess.

Sensor mode (sets the true intrinsics)
Duck: metres to the right (x)
Duck: metres ahead (z)
pixel box in the 720×1280 frame
bearing b, the robot's number
true directionplaceholder's guesslazy linear guessreal view wedgethe doc's 62°
the upright frame, box in green, bearing arrow below
true angle, atan2(x, z)
from b with the correct f
from b with a 62°-wide placeholder (f = 599.1, our guess)
lazy linear guess, b × 31°
the head's over-turn
off to the side at the duck's distance
On the robot. · . The open TODO: · . The repo doesn't show the placeholder values, so the red tags are our guess at them. Whatever they are, nothing crashes when the intrinsics are wrong: every pixel-to-angle conversion is off by the same factor in tan(angle). If the placeholder put 62° across the width, the head would turn too far, and at 3 m the gaze would land about 0.39 m beside a 25 cm duck. Whether the gaze code will use a bearing or a full 3D point is theirs to decide; the geometry is the same either way (our reading).
Guess first!
A duck comes back at bearing +0.5. How many degrees to the right is it?
About 9.6°. The 62° belongs to the sensor's long axis, which the mount makes vertical. Left to right the widest any 16:9 mode can see is 37.35°, so b = 0.5 is atan(0.5 × tan 18.7°) = 9.6°. The placeholder's 16.7° would turn the head 7° too far. Press b = +0.5 at 3 m and read the red tags.

The sensor-mode puzzle

The IMX219 is a 4:3 chip, 3280×2464. Every 16:9 video mode has to throw rows away, and some throw columns away too. Then the mount turns the picture a quarter turn. Which mode the robot runs decides the true left-right field of view, and the repo doesn't pin it down yet.

16:9 mode
left-right FOV, after the turn
up-down FOV
focal length f, pixels
a 25 cm duck at 3 m, in the frame
the same duck in the 320 input
Left-right FOV on a scale. The teal marker is your mode. 37.35° is the ceiling for any 16:9 frame with square pixels: a full-width crop keeps all 3280 columns but only 1845 of the 2464 rows. The whole 4:3 short side would be 48.6° by this model (the Pi Camera v2 sheet quotes 48.8°), and a 16:9 frame can't use it. 62° is the long axis, which now points up.
Narrower is bigger. A tighter field of view means a longer focal length, and the duck covers more pixels: 22.2 px in the input for the full-width mode, 37.9 px for a 1080p centre crop. So "22 px at 3 m" is the floor for how big the duck can look, not the ceiling. Which mode mediad runs is a question for the team.
On the robot. · · · · · . The 3280×2464 active area is the sensor's spec, not from the repo.
Guess first!
The design doc says the camera has a ~62° field of view. How wide does the robot actually see left to right?
At most about 37°. The 62° is along the sensor's long axis, and the mount turns that axis vertical. Left to right the frame runs along the sensor's short axis, and a 16:9 frame with square pixels can only use 1845 of the 2464 rows, so it tops out at 2·atan(tan 31° × 1845 ÷ 3280) = 37.35°. A mode that also crops the width is narrower still. Calibration settles it.

The 25 px check

The design doc's range estimate: · . Three ways to get that number, all in one box, with a distance slider.

Distance to the duck
"pixels per degree" shortcut
pinhole, 320 px across 62°
portrait mount, 180-px picture area
The model's 320×320 input: 70 px of grey 114 each side, a 180-px-wide picture, and the duck's height in pixels. Pixels per degree are not constant in a pinhole image (the shortcut averages them), which is the ~10% gap between 24.6 and 22.2. The portrait mount gives the same 22.2 because 180 ÷ 320 = 720 ÷ 1280: the crop and the letterbox scale cancel.

Depth is lost

The projection only ever uses the ratios X ÷ Z and Y ÷ Z. Scale a point away from the camera along its ray and it lands on exactly the same pixel. That is why a single camera can say "which direction" and never "how far", and why step 6 brings in the tape measure.

X, metres right
Y, metres down (up is negative)
Z, metres ahead
Slide the point along its ray: × k
Toy camera from the study guide: a 320×240 image, 62° across it, so f = 266.3 px, cx = 160, cy = 120. OpenCV axes: +X right, +Y down, +Z forward.
pixel of (X, Y, Z)
pixel of k·(X, Y, Z)
Same math as the arm. Moving points between the camera, the head and the trunk uses 4×4 homogeneous transforms: a rotation block, a translation column, chains read right to left. That is exactly the Robotics course's forward kinematics, and Microduck's kinematics crate does it for the head: it stores each pose as a position plus a quaternion, the same information (our reading of kinematics/src/head.rs); the ToF points reach the trunk that way too (). If you built the Arm Playground, the extrinsics here are a recap.
On the robot. · . The camera gives the direction; the 8×8 ToF grid gives the distance. Step 6.
Guess first!
Move the duck from 3 m to 6 m along the same line of sight. What happens to the pixel its centre lands on?
It stays put. u = cx + f · X ÷ Z, and doubling X and Z together leaves X ÷ Z unchanged: (0.5, −0.2, 3.0) and (1.0, −0.4, 6.0) both land on (204.4, 102.2). The box gets smaller, and that size is the only hint of distance a single frame carries, which is why the plan pairs the camera with a ToF sensor.
Say it out loud
A pixel is the focal length times the ray's slope, plus the centre. Bearing is a position across the frame, not an angle: to make it one you need the intrinsics, and on this sideways mount the left-right view is at most about 37°, so +0.5 is about 9.6°, not 16.7°. And one pixel is a whole ray: depth is lost.

6 · How far? One eye can't tell

The map room turned a box into a bearing. It cannot turn a box into a distance: a pinhole keeps only the ray, and every point along a ray lands on the same pixel. This step is about getting distance back. People do it with two eyes (stereo). Networks try to guess it from one image (monocular depth), and usually get the shape but not the metres. Microduck's plan sidesteps both: the tape measure with 64 beams measures distance directly, and the radio mast says which duck it is, because the camera cannot tell two identical Microducks apart. The last widget makes you do the fusion yourself.

Tape measure = the ToF sensor
8 × 8 zones of distance over a 45° square
Radio mast = the BLE beacon
identity and presence, not position
Two eyes on a bar = stereo
depth from disparity; Microduck has one eye
A known-size ruler = Z = f·H ÷ h
only works if you know what you're looking at

Covers HF CV Course, Unit 8: stereo vision, disparity, monocular depth (relative vs metric) · Unit 10 (one line): synthetic data with BlenderProc · study guide U8b-depth-and-stereo.md

Small and near, or big and far?

A 10 cm rubber duck and a 25 cm Microduck. Slide the rubber duck toward the camera and watch its box grow. At one distance it is exactly as tall in the picture as the robot duck at 3 m. The known-size cue Z = f·H ÷ h can turn that height back into metres, but only if you already know which object it is.

Rubber duck distance
The robot duck stays at 3.0 m. Full-frame focal length f = 1065.1 px (this lab's full-width 16:9 model, step 5); the model input is ¼ of the frame.
–
rubber duck height in the frame
–
Microduck height in the frame (3 m)
–
rubber duck, in the 320 input
–
Microduck, in the 320 input
the rubber duck, cut out
the Microduck, cut out
Both cut-outs use the same magnification. When the boxes are the same height, their height says nothing about which one is nearer. Depth was lost the moment the world hit the sensor; only knowing what each object is (and so its real size) can bring it back.
Guess first!
Going by box height alone, can one camera tell a 10 cm rubber duck at 1.2 m from a 25 cm Microduck at 3 m?
No. 0.10 ÷ 1.2 = 0.25 ÷ 3 = 0.0833, the same ray slope, so both are f × 0.0833 = 88.8 px tall in the frame (22.2 px in the input). Slide the rubber duck to 1.2 m and compare the cut-outs. The height alone is silent. A detector that learned what a Microduck looks like can break the tie by recognising it, which is the known-size cue: once you know which object it is, you know its real size.

Two eyes: depth from disparity

Two cameras a baseline B apart see the same point at slightly different pixels. That shift is the disparity d, and depth is Z = f·B ÷ d. The catch is in the derivative: one pixel of disparity error moves the depth by about Z² ÷ (f·B), so the error grows with the square of the distance. The course's numbers are the OAK-D Lite's: f = 452.9 px, B = 7.5 cm.

Rig
Focal length f (px)
Baseline B (cm)
Distance Z (cm)
–
disparity d (px)
–
Z back from d (cm)
–
depth error per px of d
Z² ÷ (f·B), the depth error for one pixel of disparity · where your slider is · the two dots are the course's worked rows (94.35 cm and 3 m)
Guess first!
Double the distance to the point. What happens to stereo's depth error per pixel of disparity?
About 4×: the error is Z² ÷ (f·B), so doubling Z quadruples it. The course's rows show it: 2.62 cm per pixel at 94 cm becomes 26.5 cm at 3 m, a 3.18× distance for a 10.1× error. Drag Z from 100 to 200 cm and read the red tag.

64 beams: the tape measure

Microduck's ToF sensor ranges an 8 × 8 grid of zones over a 45° × 45° square: 64 slant distances, once per scan. The robot's kinematics/src/tof.rs turns each zone into a point and then throws two kinds away: beams that reached the floor (a head looking down sees floor at every range), and returns under about 10 cm, where cover-glass crosstalk makes phantoms. Drag the objects around the plan view. The grid on the right is what the sensor would publish, filtered as the code filters it.

Row drawn in the plan view (row 0 is the top of the grid)
Head pitch (+ is looking down)
–
zones that hit something
–
zones the code calls floor
–
too close (< 10 cm), dropped
–
no return within 4 m
Teaching model. The beam table, the floor rule (range × downward part ≥ 0.85 × height) and the 10 cm cut are the code's. The sensor sitting 0.20 m above the floor, the objects as boxes, and the 4 m range cap are ours; the real code takes the sensor's pose from the head's forward kinematics and the trunk height from the robot model.
Plan view, sensor at the bottom looking up the page. Drag the Microduck, the rubber duck or the box.
hit, nearhit, farfloortoo closeno return
Numbers are horizontal distance in cm, which is what Zone::Hit.range carries. Click a row to draw it.
Guess first!
At 3 m, how wide is one ToF zone (45° ÷ 8 = 5.625° each)?
About 29 cm: 2 × 3 × tan(2.8125°) = 0.29 m. The 25 cm duck is narrower than one zone at that range, so the ToF can say "something at 3.0 m in column 5" but not "a duck". That is why the plan pairs it with the camera: camera = direction, ToF = distance. Drag the Microduck to the top of the plan view and count the columns it lights.

Relative or metric? A depth net's blind spot

A network can learn depth from one image, but most are trained with a loss that first normalises every depth map (subtract the median, divide by the mean absolute deviation). That makes the training data from a hundred cameras agree, and it makes the model blind to scale and shift. Drag the two sliders: whatever scale and shift you apply, the loss stays zero.

Scale a
Shift b
Prediction = a × truth + b, on three pixels whose true (inverse) depth is [1, 2, 4].
So what. Depth Anything V2's general model is relative: it tells you the tree is in front of the house, not how many metres away either is. Metres need a domain fine-tune, a known-size object, a second eye, or a sensor that measures. Microduck's plan uses the sensor. Also: a metric fine-tune learned on one camera's focal length is off on another, which is the intrinsics TODO again.

Which duck? Fuse three senses to point the head at Jo

Two identical Microducks are in view. The box desk found both and gave each a bearing; it cannot say which is which. The tape measure gives a distance along each bearing. Jo's radio beacon says Jo is present, and how strong the signal is. Put the three together, click the box you think is Jo, set the neck crank, and turn the head.

1Camera = direction.
2ToF = distance along each bearing.
3BLE = identity.
Your pick: click a box in the camera view
Neck crank (head yaw)
Teaching model. The two boxes come straight from the room's geometry (the box desk's job was steps 3 and 4). The beacon's "strong / weak signal" as a near / far hint is our toy: the plan only promises identity and presence from BLE, and radio strength is a rough cue at best.
Plan view. The teal line is where the head looks; the green dot is Jo once you've found it.
Say it out loud
One eye keeps the ray and loses the distance. Two eyes get it back from disparity, with an error that grows as Z². A depth net gets the shape but not the metres. Microduck's plan: measure distance with 64 beams, learn who is who from a radio beacon, and only ask the camera which way to look.

★ The bug board: three real bugs, fix them

Three things that go wrong in Microduck's vision stack, each one a warning written into its own source. For each: read the evidence, turn the knobs, re-run the real pipeline (the same detect → quantise → decode → AP functions as the earlier steps, on the 40-frame held-out session), and name the cause. A case closes only when the behaviour is fixed and the fix is the right kind. Tempting wrong fixes are on the board too; they fail for a stated reason.

☆☆☆
Teaching model, exact tools. The room and the detector's scores are the lab's toy (its clean AP50 of 0.979 is a toy number; the real model's held-out mAP50 is 0.976). The letterbox, decode + NMS, IoU, AP, the INT8 quantiser and the pinhole geometry are exact, so the directions every knob pushes are the real ones.

✓ Field test

Eight questions. Answer each by operating the widget in the step named, not from memory: set its controls to the values in the question and read the number off the page. Numeric answers have a tolerance. Six or more earns the check.

What's exact here, and what's a teaching model

  • Exact: the letterbox geometry and pixel sampling, decode + greedy NMS, ported line for line from duck-detect's lib.rs; IoU, precision, recall and AP (all-point and 101-point); affine INT8 quantisation (scale, zero point, per-tensor vs per-channel); convolution, its output size and parameter counts; the pinhole camera, field of view and stereo maths; the ToF beam table and its floor and too-close filters; and every robot fact, pinned to pollen-robotics/microduck @ 590b986 with its source line.
  • Teaching models: the toy room and its objects; the detector's synthesised scores (the [1, 5, 2100] layout and the three grids are real, the numbers are not yolo11n's, and its 0.979 is a toy AP); the thermal curve (only the 95 °C / 408 MHz flat-out anchor is real); the camera and sensor sitting 0.20 m above the floor; which sensor mode the camera runs (we assume the widest full-width crop; the 1080p crop is hypothetical) and what the placeholder intrinsics are (we assume 62° across the width); the duck-head stereo rig; and the ToF + BLE fusion toy, including "strong signal = near".
  • "The directions are real; the numbers are a toy's."
Sources.
  • Hugging Face Community Computer Vision Course: Units 1 (fundamentals, pre-processing), 2 (CNNs), 3 (transformers, distillation), 6 (detection, IoU, mAP, YOLO), 8 (camera geometry, stereo, monocular depth), 9 (model optimisation, INT8), and one line of 10 (synthetic data).
  • pollen-robotics/microduck @ 590b986 (Apache-2.0): duck-detect/src/lib.rs, deploy/robotd.toml, docs/project/npu-bringup.md, docs/ideas/autonomous_behavior.md, kinematics/src/tof.rs.
  • Ultralytics YOLO11, the detector family Microduck's yolo11n comes from.
  • Further reading: CNN Explainer for convolution layer by layer, and Setosa's Image Kernels for sliding a stencil by hand.