Eye Exama hands-on lab

How a robot's camera gets calibrated

Microduck goes to the optician. A checkerboard is the eye chart, every photo of it held at a new angle is one view, the lens dial is the distortion model, and the prescription card is the camera matrix K. You'll measure the numbers Duck's-Eye View took as given, with Zhang's method, the one inside OpenCV's calibrateCamera, running for real in your browser. About 70 minutes of play.

0 · The exam room: what the prescription card holds

A camera turns a direction into a pixel. In Duck's-Eye View's map room you dragged a duck around and watched the pinhole put it in the frame; there the camera's numbers were given. Here they're the unknowns. The intrinsics are five numbers in a 3 × 3 matrix K, plus two for the lens. Calibration finds them from photos of something whose shape you know exactly. Every term with a wavy red underline is tappable.

The duck = the camera being calibrated
Microduck's head camera, 1280 × 720
The eye chart = a checkerboard
9 × 6 inner corners, 25 mm squares
A photo = one view
the chart held at a new angle
The magnifier = the corner detector
finds corners to a fraction of a pixel
The tracing card = a homography
chart → photo, one per view (step 1)
The clue tally = constraints on K
two per photo (step 2)
The prescription card = K
fx, fy, cx, cy, skew (step 3)
The lens dial = distortion
k1 and k2 (step 4)
"Better one, or two?" = refinement
Levenberg–Marquardt (step 5)

Covers Zhang (2000) §2: the pinhole s·m = K[R t]M · OpenCV calibrateCamera: fx, fy, cx, cy and distortion coefficients · Duck's-Eye View step 5: where these numbers get used

The duck looks at the chart

This is the duck's view: the 1280 × 720 frame its camera sends, unrotated. Tilt and turn the chart, move it nearer or further, and switch the lens on and off. The hollow circles show where a perfect pinhole would put each corner; the dots are where the real lens puts them.

tilt30°
tilt direction30°
distance450 mm
a corner, as the camera sees itwhere a perfect pinhole puts it+the principal point (cx, cy)+the frame's middle
–
top row, end to end
–
furthest the lens moved a corner
–
top row's bend from straight
–
chart's middle, distance
The answer key. This lab's camera is Microduck's own calibration, as its repo ships it (robotd-params/src/lib.rs, CameraIntrinsics::alpha): "Solved on unit graphite, 2026-09-08 — ChArUco board on a screen …, 80 views, 0.79 px RMS, on the 1280×720 frame as mediad sends it (unrotated)." We round it and keep Zhang's two radial terms, k1 and k2; the real solve also has two tangential terms and a k3, which together move a frame-corner pixel by up to 19 px. Every photo in this lab is drawn through this camera, so every estimate can be checked against it. The solver never sees it.
Guess first!
The principal point is where the optical axis hits the sensor. On Microduck's 1280 × 720 frame, where is it?
(596.78, 474.52): 42.7 px left and 115.0 px down. The repo's own comment says it plainly: "the principal point is assumed central — which is the part a real calibration corrects (the measured cy sits ~110 px low)" (mediad/src/camera.rs). You can't know it without calibrating.
Guess first!
Lens off, chart square-on to the camera at 400 mm. Its top row of corners is 200 mm long. How many pixels across?
fx · 200 / 400 = 1061.81 / 2 = 530.9 px: a pixel offset is the focal length times the ray's slope. Switch the lens on and the same row shrinks to 515.5 px, because a barrel lens pulls everything towards the middle. Set tilt 0 and distance 400 to read it off.
Say it out loud
Intrinsics turn a direction into a pixel: focal lengths fx and fy in pixels, the principal point (cx, cy) where the optical axis lands, a skew, and the lens's own bend, k1 and k2. Calibration measures them from photos of a pattern whose shape you know.

1 · One photo: the tracing card

The chart is flat, so put it at Z = 0. Then a pinhole maps every chart point (X, Y) to its pixel through one 3 × 3 matrix: s·[u, v, 1]ᵀ = H·[X, Y, 1]ᵀ, with H = K [r1 r2 t] (Zhang's eq. 2). That's a homography: 9 numbers, but only 8 matter because its scale is free. Each corner gives two equations, so the 54 corners give a 108 × 9 matrix L with L·h = 0, and the best h is the singular vector of L's smallest singular value. That's the direct linear transform. Steps 1 to 3 photograph the chart with the lens dial at 0, so a homography is exact; step 4 puts the lens back.

Covers Zhang App. A: the 2n × 9 system L x = 0, "the right singular vector of L associated with the smallest singular value", and "L poorly conditioned numerically … much better results … by performing a simple data normalization" (citing Hartley, 1995)

Trace one photo

photo
corner noise σ0.5 px
corners as found (with noise)the chart's corners sent through H
–
H vs the corners found, RMS
–
H vs the true corners, RMS
–
conditioning σ₁/σ₈ of L
–
equations for 8 unknowns
The nine singular values of L, on a log scale. Eight are large; the ninth is (nearly) zero, and its singular vector is H. With noise it lifts off zero: no H fits every noisy corner exactly.

Where is the chart's zero?

The DLT minimises an algebraic quantity, |L h|, not a distance in pixels, and what that quantity weighs depends on your coordinates. Number the corners from a point 1 m off the chart (say, the corner of the shop wall), or photograph the chart from across the room, and the un-normalised solve gets lopsided. Hartley's fix: shift each set of points so its centroid is at zero, scale it so the mean distance is √2, solve, and undo the shift. One hundred photos per run, each with fresh noise.

chart distance
origin offset1000 mm
–
un-normalised, median error
–
un-normalised, worst of 100
–
normalised, median error
–
normalised, worst of 100
Guess first!
The usual case: chart at 450 mm, corners numbered from its own corner, σ = 0.5 px. Over 100 photos, how much does normalising improve the median error?
0.189 px un-normalised against 0.188 px normalised. In double precision, with sensible coordinates, the un-normalised DLT is fine here even though L's conditioning is 2.8 × 10⁵ against 3.6. Normalisation is insurance against the cases below, and it costs nothing.
Guess first!
Now the chart is 2.5 m away and its corners are numbered from a point 1 m off. Un-normalised, what's the median error over 100 photos?
Median 5.41 px, worst 2,031 px; normalised, 0.187 px and 0.31 px, wherever the origin is. Same photos, same corners: only the bookkeeping changed. That's Hartley's point, and why Zhang's appendix tells you to normalise.
Say it out loud
One photo of a flat chart is one homography, H = K[r1 r2 t]: stack two equations per corner and take the smallest singular vector. Normalise both point sets first, or the answer depends on where you put the origin and how far away the chart is.

2 · Two clues per photo: why one view isn't enough

H = λK[r1 r2 t], and r1, r2 are two columns of a rotation: perpendicular, and the same length. Undo K and those two facts become two equations, h1ᵀBh2 = 0 and h1ᵀBh1 = h2ᵀBh2, on one matrix, B = K⁻ᵀK⁻¹. B is symmetric, so it's six numbers b, and since its scale is free that's five unknowns: fx, fy, cx, cy and the skew. Every photo adds two rows to a matrix V with V·b = 0 (Zhang's eq. 9). Count: one photo, 2 equations; two photos, 4; three photos, 6 ≥ 5. The pose of each photo (3 numbers of rotation, 3 of position) eats the other 6 of H's 8.

Covers Zhang §2.3: "we can only obtain 2 constraints on the intrinsic parameters" · §3.1: n ≥ 3 gives a unique b; n = 2 needs the skew-free assumption · §4, Proposition 1: a plane parallel to an earlier one adds no constraints

Build a set of photos

Tap photos to put them in the set. The first row is tilted every which way; the second is square-on, moved around and spun; the third is all at the same tilt, only slid around. No noise, so a zero singular value is exactly zero.

Tilted every which way
Square-on to the camera
One tilt, slid around
0
photos in the set
0
equations (rows of V)
–
zero singular values of V
–
a single answer?
The six singular values of V, largest first, on a log scale (relative to the largest). Exactly one zero means exactly one B, up to scale: one camera. Two or more zeros means a whole family of cameras fits the photos equally well.
Guess first!
Five square-on photos, each at a different place, distance and spin. How many zero singular values does V have?
4. Parallel planes meet the plane at infinity in the same line, so every square-on photo repeats the same two equations (Zhang's Proposition 1). Spinning the chart in its own plane doesn't help, and nor does sliding a tilted chart around without changing its tilt: that's a pure translation, and Zhang rules it out too.

How flat is too flat?

Exactly parallel is exactly degenerate. Nearly parallel is nearly degenerate, and noise finishes the job. Each run: 200 sessions of 3 photos at 500 mm, tilted by the same angle about random axes, with 0.5 px of corner noise. A session fails if B comes out impossible (not positive definite, so no real camera has it) or fx is more than 50% off.

Guess first!
Three photos tilted 5°, σ = 0.5 px. Of 200 sessions, how many blow up?
45 of 200, and the ones that survive are 13% off in fx on average. At 2° it's 150 of 200; from 20° on, 3 to 5. Zhang's own simulation (his camera, his chart) lost 40% of trials at 5° and did best around 45°.
Say it out loud
Each photo of a plane gives two equations on B = K⁻ᵀK⁻¹, which has five unknowns: three photos in general, two if you assume no skew. Photos of parallel planes repeat the same two equations, so what counts is how differently the chart is tilted, not how many photos you take.

3 · The closed form: writing the prescription

With three or more photos, stack every photo's two rows into V and take the right singular vector of its smallest singular value: that's b, so that's B. Zhang's Appendix B reads K straight out of B: cy = (B12B13 − B11B23)/(B11B22 − B12²), then λ, fx = √(λ/B11), fy, the skew and cx. Then every photo's pose follows from K⁻¹H: r1 = λK⁻¹h1, r2 = λK⁻¹h2, r3 = r1 × r2, t = λK⁻¹h3, and the nearest true rotation (App. C). No iterations, no starting guess: a closed form.

Covers Zhang §3.1: V b = 0 by SVD, K from B (App. B), extrinsics from K⁻¹H, the best rotation by SVD (App. C) · Table 1: on a real camera, 3 photos' closed form gave α = 917.65 against 830.80 after refinement

Solve it

photos3
corner noise σ0.5 px
where the chart really waswhere the closed form puts itseen from above; the camera at the bottom, looking up the page

More photos, steadier card

The closed form minimises an algebraic error, so noise leaks straight into K. Average 20 independent sessions at each count (σ = 0.5 px, lens dial at 0) and watch the errors fall.

Guess first!
No noise at all, three tilted photos. How close is the closed form to the answer key?
Exact: fx 1061.81, fy 1062.19, cx 596.78, cy 474.52, skew 0.00, and every chart position to 10⁻¹² mm. The method has no approximation in it; only noise and the lens (step 4) make it wrong. Set σ to 0 and press the button.
Guess first!
σ = 0.5 px. Going from 3 photos to 12, the closed form's principal point error (averaged over 20 sessions) goes from 8.9 px to…
3.2 px, and fx from 1.04% off to 0.25%. Better, but the principal point is the hardest number on the card, and the closed form is only the starting guess. With two photos (and the skew assumed 0) it's 5.1% and 49 px.
Say it out loud
Stack two rows per photo into V, take the singular vector of the smallest singular value, and read K straight out of B; then each photo's rotation and position follow from K⁻¹H. It's exact without noise and fast with it, and it's a starting point, not the answer.

4 · The lens dial: radial distortion

Now the real lens goes back in. A wide, cheap lens bends straight lines, more the further they are from the principal point. Zhang's model moves each point along its ray from the principal point: in normalised coordinates, x̆ = x(1 + k1·r² + k2·r⁴), with r² = x² + y². Negative k1 is barrel distortion, pulling the edges in; positive is pincushion. Microduck's lens has k1 = −0.347 and k2 = +0.146. A homography can't bend, so every step so far is now wrong, as you'll see.

Covers Zhang §3.3: the first two radial terms; "the distortion function is totally dominated by the radial components, and especially dominated by the first term"; distortion by linear least squares, k = (DᵀD)⁻¹Dᵀd (eq. 13)

Turn the dial

k1−0.347
k20.146
chart
–
top row's bend from straight
–
furthest a corner moved
–
a ray aimed at the frame's top-right corner lands this far in
Where a ray lands (distorted radius, in focal lengths) against where a pinhole would put it. The dashed line is no distortion; the dotted line marks the frame's furthest corner from the principal point.

What the closed form makes of a bent eye

The exam: 12 photos of the chart through Microduck's real lens, corners found to σ = 0.25 px. First, steps 1 to 3 exactly as before, pretending the lens is a pinhole. Then Zhang's linear fix: with that K and those poses, the pinhole predicts each corner at (u, v); the corner was seen at (ŭ, v̆); two equations per corner are linear in k1 and k2.

corner as seenwhere the current card predicts it→ the error, drawn 5× longer
Guess first!
With k1 = −0.347, where does the lens put a ray that a pinhole would put exactly at the frame's top-right corner?
131.0 px nearer the principal point (106.7 px for the top-left corner, which is closer to it). The radius shrinks by 1 + k1·r² + k2·r⁴ ≈ 0.84 out there. That's why the corners of the frame matter so much for estimating it (step 6).
Guess first!
The linear fix, starting from the lens-blind closed form: what sign does it give k1?
k1 = +0.097, k2 = −0.272, against the truth −0.347 and +0.146; the RMS only falls from 18.22 to 18.01 px. Zhang saw the same on his real camera (Table 1: closed form k1 = +0.161, final −0.227) and explains why: with the lens ignored, the closed form's predicted outer corners lie closer to the image centre than the seen ones, so the linear fit "tries to spread the outer points", a pincushion shape that doesn't match the real barrel. Only refining everything together untangles them.
Say it out loud
A real lens moves each point along its ray from the principal point by 1 + k1·r² + k2·r⁴; negative k1 is barrel. The closed form can't see that, so its K and even a linear k1 come out wrong, and only the joint refinement untangles them.

5 · Better one, or two? Refinement and its score

The optician's last step: flip between two lenses, keep the sharper one, repeat in smaller steps. The math version is Levenberg–Marquardt: start from the closed form and the linear k1, k2, and move all the numbers at once (fx, fy, cx, cy, k1, k2, and every photo's 3 rotation and 3 position numbers: 6 + 6 × 12 = 78 here, with the skew held at 0) to shrink the total squared distance between each corner as seen and where the card predicts it. The Jacobian says which way is downhill; λ blends a Gauss–Newton jump with a cautious gradient step. The score at the end is the reprojection RMS, the one number OpenCV's calibrateCamera returns.

Covers Zhang §3.2–3.3: maximum-likelihood refinement of eq. 14 with Levenberg–Marquardt, rotations as Rodrigues vectors · OpenCV calibrateCamera: "Run the global Levenberg-Marquardt optimization algorithm to minimize the reprojection error"; returns "the overall RMS re-projection error"

Better one, or two?

corner as seenwhere the card predicts it→ the error, drawn longer (the scale is on the photo)
Guess first!
The corners were found with σ = 0.25 px of noise on each axis. After refinement, what's the best RMS you could hope for?
The noise itself: σ√2 = 0.354 px per corner (two axes). The exam ends at 0.350 px, a hair under, as it should be: fitting 78 free numbers to 1,296 corner coordinates soaks up some of the noise (on average the floor drops to about σ√2·√(1 − 78/1296) ≈ 0.343 px). An RMS far below the corner detector's noise means the model is fitting noise, not the lens.

Is a low score a good eye?

The RMS says how well the card fits these photos. Two more judges: a second opinion, 10 photos the calibration never saw (fit only each photo's pose, keep the card fixed, report their RMS), and the answer key, which only a lab has: how many pixels off the card is across the whole frame, and how many degrees it would mis-turn the duck's head (step 6). Five sessions, the same chart, the same corner noise:

Guess first!
A calibration with the lens dial held at 0 scores RMS 4.36 px. Drop the 6 photos with the worst error and solve again: the RMS halves to 2.08 px. Is it a better calibration?
No. Hold-out RMS goes from 3.65 to 3.81 px; the worst head-turn error only shrinks from 10.6° to 7.1°, still enormous. Dropping the hard photos made the exam easier, not the eye better: the six it dropped were mostly the ones reaching furthest from the principal point, where the missing lens dial shows most. And the corner-blind session scores 0.347 px, as good as the real exam's 0.350, while being up to 58 px wrong in the corners it never saw.
Say it out loud
Refinement minimises the total reprojection error over every corner in every photo with Levenberg–Marquardt, starting from the closed form. The RMS it reports is how well the model fits these photos, not how right it is: judge it on photos it never saw, and suspect any fix that only lowers the score.

6 · The fitting: a good session, and what a bad one costs

You control four things in a real session: how many photos, how much the chart is tilted, whether its corners reach every part of the frame, and how sharply the corners are found (sub-pixel refinement, like OpenCV's cornerSubPix, places them to a fraction of a pixel; σ here is per axis). Book a session, calibrate it with the full pipeline, and read every judge. Then turn the card into what the duck actually does with it: a head turn.

Covers OpenCV tutorial: "you will probably need at least 10 good snapshots of the input pattern in different positions" · calibrateCamera notes: "use multiple board poses with significant tilt" · Zhang §5.1: errors grow linearly with noise; best near 45° · Microduck: bearing() in duck-detect, the quarter turn in uyvy

Book a session

photos12
tilt
where the chart goes
corner noise σ
–
RMS (what the optician sees)
–
second opinion (hold-out RMS)
–
answer key: mean / worst px off
–
answer key: worst head-turn error
a chart corner from the session+the principal point (cx, cy)+the frame's middle
Across the 1280 × 720 frame: how far the card puts a ray from where the true camera puts it. Pale is under 1 px; darker is worse. The dots are where the session's chart corners landed.
Guess first!
Keep everything else, but find the corners 8× less sharply: σ from 0.25 to 2 px. The RMS goes from 0.36 to 2.84 px. The worst head-turn error?
0.14°. Fuzzy corners raise the RMS (most of it is the noise itself) and smear the card by 14 px on average, but most of that is cx, 14.4 px off, which is up–down for the upright duck; fy and cy, which set its left–right, are off by only 0.51% and 1.4 px. The failures that matter for the duck are structural: the lens dial left at 0, flat photos, a frame left uncovered.

What it costs the duck

Microduck's detector works on the frame turned a quarter turn clockwise, 720 × 1280, upright: upright pixel (x′, y′) comes from stream pixel (y′, 719 − x′). Its bearing is b = (box centre x′ / 720) · 2 − 1, from −1 (hard left) to +1 (hard right). So left–right for the duck runs along the stream's vertical axis: fy and cy set the angle, and the principal point sits at x′ = 719 − 474.52 = 244.5, 115 px left of the upright frame's middle. The head should turn atan of the undistorted ray's slope (assuming the camera looks straight down the head's axis). Compare four cards:

bearing b0.50
What changed upstream. When Duck's-Eye View was built (microduck @ 590b986), the robot's intrinsics were "placeholder" (a TODO in its design notes), so that lab assumed a centred pinhole with f = 1065 px, the design figure the repo's mediad still uses as a fallback, and showed that a "62° across the width" placeholder would over-turn the head by about 7° at b = 0.5. The repo now ships the real solve (648bf22). Against it, the centred pinhole itself is off by 6.2° at b = 0 and up to 7.1° at the frame's edge, almost all of it the principal point. Distortion adds a little near the edges.
Guess first!
Duck's-Eye View read b = 0.5 as 9.6° with a centred pinhole. With Microduck's own calibration, b = 0.5 is…
15.95°. The optical axis is 115 px to the left of the upright frame's middle, so everything is 6.2° further right than a centred pinhole thinks. At 3 m, 6.2° is a 33 cm miss: more than the width of the 25 cm duck it's looking for. Your 12-photo calibration gets it to within 0.05°.
Say it out loud
Take ten or more photos, tilted 20 to 50 degrees, that put the chart's corners in every part of the frame, with sub-pixel corners, and judge on held-out photos. For a robot the payoff is in degrees: a good calibration turns Microduck's head to within a tenth of a degree, while assuming a centred pinhole costs six.

★ The optician's review board

Three prescriptions came back with complaints. For each: run the session as it was booked, pick a fix, re-run it, and name the cause. The tempting wrong fixes are on the list, and they fail for the reason the steps taught. Each case is scored on the re-run, judged against the answer key, and on the cause.

☆☆☆

✓ Field test

Ten questions. Answer each by operating the widget in the step named, not from memory. Eight or more earns the check.

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

  • The real math, written out in js/core.js: the direct linear transform for a homography and Hartley's normalisation (Zhang App. A); the two constraints per homography, V b = 0 by singular value decomposition, and K from B (§3.1, App. B); the extrinsics from K⁻¹H and the nearest rotation (App. C); radial distortion by linear least squares (eq. 13); Levenberg–Marquardt on the total reprojection error (eq. 14) with Rodrigues rotations; the reprojection RMS as OpenCV's calibrateCamera computes it, √(Σ|e|²/N). The SVD (one-sided Jacobi), the symmetric eigen-decomposition (Jacobi), the 3 × 3 inverse and the Cholesky solve are hand-written; tests/check.js checks them, and that DLT and Zhang recover a known H and K exactly with no noise.
  • Teaching models, labelled on the page: the photos are synthetic. The chart is a perfect 9 × 6 grid at 25 mm; each photo's pose is drawn from a seed and kept fully in frame; the corner detector is the true corner plus Gaussian noise of σ pixels per axis (real detectors are worse on steep, blurry or dim photos, which this ignores: Zhang notes foreshortening "is not considered" in his simulation either). The true camera is Microduck's published calibration, rounded, with k1 and k2 only; its tangential terms and k3 are left out. LM holds the skew at 0 by default, as OpenCV does; OpenCV's own first guess is simpler than Zhang's (principal point at the frame's middle, focal lengths by least squares), and its current 4.x source solves the same least-squares problem with a sparse bundle-adjustment formulation by default; this lab follows Zhang's paper. Its Jacobian is numeric (central differences), not analytic.
  • The head turn is geometry, not the robot's code. The bearing formula and the quarter turn are Microduck's (duck-detect, uyvy); turning a bearing into an angle with a calibration, and assuming the camera looks straight down the head's axis, is ours. Mounting the camera at an angle would add an extrinsic offset this lab doesn't model.
  • The directions are real; the numbers are this toy's. A real session has blur, lighting, a chart that isn't quite flat, and a detector that sometimes mislabels a corner. The failure modes here (flat photos, an uncovered frame, a model without a lens) are the real ones.

Sources (sections and exact quotes in SOURCES.md): Z. Zhang, A Flexible New Technique for Camera Calibration, MSR-TR-98-71 (journal version: IEEE TPAMI 22(11), 2000) · OpenCV calibrateCamera and its calibration tutorial · R. Hartley, In Defence of the 8-Point Algorithm (ICCV 1995), cited by Zhang for normalisation · pollen-robotics/microduck @ 648bf22 (Apache-2.0): robotd-params/src/lib.rs, mediad/src/camera.rs, duck-detect/src/lib.rs, uyvy/src/lib.rs · Duck's-Eye View, step 5.

Part of a series of hands-on labs: Inference Kitchen (serving) · Finishing School (post-training) · Arm Playground (kinematics and control) · Policy Pond (reinforcement learning) · Fork in the Road (diffusion policies) · Duck's-Eye View (robot vision) · Demos to Deployment (imitation learning and real-time execution) · Eye Exam (camera calibration)