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.
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.
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
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.
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.cy sits ~110 px low)" (mediad/src/camera.rs). You can't know it without calibrating.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)
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.
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
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.
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.
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
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.
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)
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.
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"
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:
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
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:
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.
Ten questions. Answer each by operating the widget in the step named, not from memory. Eight or more earns the check.
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.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)