Blog · Camera geometry ·
Zhang's method from first principles
Point a camera at a sheet of paper a few times from angles you never measure, and out falls the camera. A planar target collapses the projection to a homography, each homography constrains the image of the absolute conic, and a Cholesky hands back K in closed form.
- Interactive
- computer-vision
- camera-calibration
- homography
- svd
- linear-algebra
- wasm
The previous post ended with a few hundred sub-pixel corner positions and a shrug: they are only coordinates. This one turns them into the camera.
The claim is genuinely strange the first time you meet it. Print a checkerboard. Hold it up to your camera at half a dozen angles you do not measure, at distances you do not measure, in orientations you never write down. Take a photo each time. From nothing but the corner positions in those photos (no ruler, no rig, no knowledge whatsoever of where the board was), you can recover the camera’s focal lengths in pixels, where its optical axis pierces the sensor, and how far its pixel grid is from square.
That is Zhang’s method, and it is why every calibration tutorial on the internet involves a sheet of A4 taped to a cereal box. What follows is the chain that makes it work, written from the ~140 lines of NumPy I wrote for it in 2019, plus the parts I got wrong.
From a lens to three numbers
A real lens obeys the thin-lens equation, and the report opens with it (Eq. 2.1):
which is only a reasonable approximation when the lens is much thinner than its radius of curvature. Even so it is too complicated to calibrate, because a single point in the world maps to a region of the sensor whenever it is out of focus: light from one source spreads across several photosites, and once information has overlapped like that there is nothing to undo. So you assume the interesting parts of the scene are in focus and swap the lens for an ideal pinhole: an aperture whose size tends to zero, blocking every ray from a source except the one that goes straight through.
Figures 1a and 1b of the report, redrawn. A real aperture smears one world point across a run of pixels; a pinhole passes exactly one ray, so every world point has exactly one image point. The image lands inverted, which cameras quietly undo by pretending the image plane sits in front of the pinhole.
Projection through a pinhole is then two operations. First the perspective divide: push every 3-D point onto the plane one unit in front of the camera by dividing through by its own depth (Eq. 2.2):
Then scale that normalised plane up to pixels. If a lens were perfect and a sensor were perfectly made and perfectly placed, one number would do it (Eq. 2.3):
It will not do. The lens does not scale equally in and , and the sensor applies its own scaling in and , so splits into and . The sensor is not perfectly centred on the optical axis either (you would like the shift to be exactly half the resolution each way, and it never is), so two more parameters move the origin. And if the sensor is not quite square to the imaging plane you get a shear, absorbed into a single parameter . That is the whole camera calibration matrix (Eq. 2.4):
Five numbers. Every one of them is a property of the camera and none of them is a property of the scene, which is exactly why they are worth recovering once and reusing forever.
Where the camera is takes six more numbers (three of rotation and three of translation) combined into one transformation (Eq. 2.5):
Twelve entries, six degrees of freedom: the nine rotation entries are three angles wearing a disguise, because a rotation matrix is orthonormal. That constraint looks like bookkeeping here and turns out to be the entire trick later.
Why a plane changes everything
The straightforward way to calibrate is the Direct Linear Transform: photograph an object whose 3-D geometry you know exactly, write down the correspondences, and solve directly for the projection. Four correspondences are enough in principle; in practice you use many more, the system is over-determined, and you minimise the residual with an SVD. It works well (the report calls it highly accurate) but it comes with two conditions that are painful in real life. The reference points must not all lie on one plane, and no three of the four may be collinear. Building an accurate non-planar 3-D calibration object is a machining problem, not a printing problem.
Zhang’s method makes the opposite bargain. It insists that all the reference points lie on one plane, and gains a great deal from it. Put the world coordinate frame on the board itself and every board point has , which kills the third column of the rotation matrix (it is multiplied by zero) and collapses the projection into a homography (Eq. 3.10):
The scale is free: a homography is only defined up to scale, which sounds like a loss and is actually a convenience: you solve for up to scale and fix the scale afterwards. The price is stated plainly in the report: the extrinsic parameters cannot be fully recovered, and the method is less accurate than a true DLT against a machined target. You need at least four points from at least two views; in practice, as we will see, three views is the real floor.
Step one: one homography per view
Rearranging to eliminate gives two linear equations per correspondence in the nine unknowns of , and points stack into a system (Eq. 3.11), where is whatever residue the noise in the corner detection leaves behind:
Minimising subject to is the classic homogeneous
least-squares problem: decompose
and take the right singular vector belonging to the smallest singular value. NumPy does
that for every view at once, because np.linalg.svd broadcasts over leading axes. From
Modules/ZhangAlgV2.py, L56–68:
M = np.empty((Nx.shape[0], 2 * Nx.shape[1], 9), dtype=np.float64)
M[:, 0::2] = np.stack((-NX[:, :, 0], -NX[:, :, 1], nones,
zeros, zeros, zeros,
NX[:, :, 0] * Nx[:, :, 0], NX[:, :, 1] * Nx[:, :, 0], Nx[:, :, 0]),
axis=2)
M[:, 1::2] = np.stack((zeros, zeros, zeros,
-NX[:, :, 0], -NX[:, :, 1], nones,
NX[:, :, 0] * Nx[:, :, 1], NX[:, :, 1] * Nx[:, :, 1], Nx[:, :, 1]),
axis=2)
_, s_1, Vt = np.linalg.svd(M, full_matrices=False)
H = Vt[:, -1].reshape(-1, 3, 3)
Hs = np.dot(np.einsum("ijk,ikm->ijm", iMSx, H), MSX[0])
Hs /= Hs[:, 2, 2][:, None, None]
Thirteen lines for every homography in the capture. The [:, 0::2] / [:, 1::2] slicing is
how the two rows per point get interleaved without a loop, and the einsum on the
second-to-last line is the un-normalising step, which needs its own section.
Step two: normalisation, and what it is actually for
The rows of that matrix are a mess of scales. Board coordinates are tens of millimetres; image coordinates are hundreds of pixels; the products are hundreds of thousands. Columns whose entries differ by five orders of magnitude make for an ill-conditioned matrix, and the standard fix (Hartley’s) is to pre-transform both point sets so they are centred on their own mean with an RMS radius of , solve, and then undo the transform on the answer (Eq. 3.12):
get_scale_matrix() builds and in the same call (L6–20):
def get_scale_matrix(pts, inverted_matrix=True):
avg = np.mean(pts, axis=1)
std = 1.4142135623730951 / np.std(pts, axis=1)
mtx = np.zeros((std.shape[0], 3, 3))
mtx[:, 2, 2] = 1
if inverted_matrix:
mtx[:, 0, 0] = 1 / std[:, 0]
mtx[:, 1, 1] = 1 / std[:, 1]
mtx[:, 0, 2] = avg[:, 0]
mtx[:, 1, 2] = avg[:, 1]
else:
mtx[:, 0, 0] = std[:, 0]
mtx[:, 1, 1] = std[:, 1]
mtx[:, 0, 2] = -std[:, 0] * avg[:, 0]
mtx[:, 1, 2] = -std[:, 1] * avg[:, 1]
1.4142135623730951 is , typed out. One deviation worth flagging: np.std(pts, axis=1) is taken per axis, so the scaling is anisotropic ( across and
down) where Hartley’s prescription uses one isotropic radius. It
conditions the matrix just as well; it is simply not quite the textbook transform, and my
Rust port copies the Python rather than the textbook so the two agree.
Now the honest part, because I set out to write “watch the recovered K explode when you
turn normalisation off” and it does not explode.
Measured, on the real dataset 3 corners with a 25 mm board: the worst per-view condition
number is 5.5 normalised and 2.1 × 10⁵ raw, four and a half orders of magnitude, exactly
as advertised. And the recovered K moves from to . Three parts in
four thousand. On dataset 2 the condition number goes from 5.3 to 4.4 × 10⁵ and moves
by one pixel.
Two things are covering for the bad conditioning, and both are worth knowing:
- Double precision has about sixteen digits and the problem only eats five of them. A condition number of is not large when your arithmetic has of headroom.
- One-sided Jacobi never forms . It rotates pairs of columns
until they are mutually orthogonal and reads the singular values off the column norms,
which buys it high relative accuracy on badly scaled matrices. That is the algorithm in my
Rust. NumPy goes a different way: LAPACK’s
gesddbidiagonalises and then divides and conquers, but it is backward stable, so at a condition number of it too loses only about five of its sixteen digits. Either way, the arithmetic absorbs the damage.
Take either away and it falls over immediately. The textbook shortcut for a homogeneous system is to form the Gram matrix and take the eigenvector of its smallest eigenvalue, cheap, and it squares the condition number in the working precision, turning into . In single precision that is past the end of the number system. Here is dataset 3 solved four ways, with the LM refinement off so you are looking at the algebraic solution alone:
| Null space found by | Normalised | |||||
|---|---|---|---|---|---|---|
Jacobi SVD, f64 | yes | 4343 | −24.1 | 406 | 4303 | 154 |
Jacobi SVD, f64 | no | 4340 | −17.8 | 393 | 4303 | 174 |
, f32 | yes | 4343 | −24.1 | 406 | 4303 | 154 |
, f32 | no | 5085 | 960 | 4523 | 2456 | 18646 |
A principal point 18 646 pixels down a 576-pixel image. On the synthetic camera in the
widget the same combination does not even get that far: the re-projection error climbs to
11 pixels and comes back indefinite, so there is no K at all. Switch
normalisation back on and that same naive solver lands within three pixels of the truth.
The honest summary, then, is that normalisation is insurance rather than a fix. With a careful solver and double precision you will not miss it; it costs four lines, and it makes the problem well conditioned enough that a naive solver, a single-precision pipeline or a future you who reaches for the Gram matrix cannot get hurt. Take it.
The earlier version of the file, Modules/ZhangAlg.py, is the foil. It does not derive the
scaling from the data at all: it hard-codes the sensor size (L13–17):
ScaleM = np.array([[2/640.0, 0, -1],
[0, 2/480.0, -1],
[0, 0, 1]])
iScaleM = np.linalg.inv(ScaleM)
all_detected_points_scaled = 2*all_detected_points / [[480.0], [640.0]] -1
which maps a VGA image to and does nothing at all to the world coordinates. It is better than nothing and it stops being correct the moment you point it at a 768×576 frame. That constant is why V2 exists.
Step three: Levenberg–Marquardt, which Zhang does not do
The SVD minimises , which is an algebraic residual: a quantity with no units and no geometric meaning. What you actually care about is the re-projection error in pixels (Eq. 3.13):
The report is explicit that this step “isn’t done in the original paper by Zhang” and comes from Burger’s tutorial instead. It is non-linear because of the perspective divide, so it wants Levenberg–Marquardt, and LM wants a Jacobian. Writing one out by hand is a small pleasure. With and :
The first six entries are the quotient rule’s easy half. The last three are where the perspective divide bites: every parameter in the bottom row of moves the denominator, so it drags the projected point by times the numerator, which is , the point’s own position scaled down by its depth. That is the whole content of the terms, and it is why a homography Jacobian looks different from a linear one.
Vectorised over all points, that is getJ (L33–44):
def getJ(H_vals, X, _):
H = H_vals.reshape(3, 3)
proj = np.dot(H, X)
sw = 1 / proj[2]
sw2 = -1 / (proj[2] * proj[2])
swx = proj[0] * sw2
swy = proj[1] * sw2
zeros = np.zeros(X.shape[1])
J = np.empty((9, 2 * X.shape[1]))
J[:, 0::2] = np.stack((X[0] * sw, X[1] * sw, sw, zeros, zeros, zeros, X[0] * swx, X[1] * swx, swx), axis=0)
J[:, 1::2] = np.stack((zeros, zeros, zeros, X[0] * sw, X[1] * sw, sw, X[0] * swy, X[1] * swy, swy), axis=0)
return J.T.reshape(2 * X.shape[1], -1)
swx = proj[0] * sw2 is and X[0] * swx is : the derivation above, exactly,
in five lines of arithmetic and two np.stacks. It is handed to SciPy with the residual
function and MINPACK does the rest (L27–30 and L75–84):
def getError(H_vals, X, x):
H = H_vals.reshape(3, 3)
hold = np.dot(H, X)
return np.square(x - (hold[:2] / hold[2]).T).flatten()
...
for i in range(len(Hs)):
ret = optAlg(
getError,
Hs[i].flatten(),
jac=getJ,
args=(h_X, x[i]),
method='lm'
)
if ret.success:
Hs[i] = ret.x.reshape(3, 3) / ret.x[8]
Step four: the absolute conic, or how a rotation matrix pays you back
Here is the step that makes Zhang’s method feel like a card trick.
Write with the -th column of , so . Now use the constraint from the very first section: is a rotation, so its columns are orthonormal. Two facts follow, for free, for every single view, whatever the pose was:
Substituting turns both into statements about , so define (Eq. 3.15):
is symmetric, so it has six unknowns, and the two constraints are linear in them. Collecting the coefficients gives the vector (Eq. 3.14):
and stacking two rows per view gives a homogeneous system for (Eq. 3.16):
Six unknowns, defined up to scale, so five degrees of freedom; two equations per view. Three views. That is the whole answer to “how many photos do I need”, and it falls straight out of the shapes. More views are noise averaging, not new geometry.
is over-determined once you have four or more views, so it goes through the same SVD null-vector machinery as the homographies did. The code builds the three vectors it needs by hand (L86–92, and two near-identical blocks after it):
v_m_1_2 = np.array((
Hs[:, 0, 0] * Hs[:, 0, 1],
Hs[:, 0, 0] * Hs[:, 1, 1] + Hs[:, 1, 0] * Hs[:, 0, 1],
Hs[:, 0, 0] * Hs[:, 2, 1] + Hs[:, 2, 0] * Hs[:, 0, 1],
Hs[:, 1, 0] * Hs[:, 1, 1],
Hs[:, 2, 0] * Hs[:, 1, 1] + Hs[:, 1, 0] * Hs[:, 2, 1],
Hs[:, 2, 0] * Hs[:, 2, 1]))
Compare that to Eq. 3.14 and the third and fourth entries have swapped places. It is not a
bug: the code has simply chosen
where the report and Zhang’s paper use ,
and the matrix it assembles from at the end of the function uses the same ordering. Both are
self-consistent. Mixing them silently gives you a beautiful, plausible, wrong K, so it is
worth checking before you assume you have found a mistake in someone’s code, as I did, in
my own, twice.
Step five: Cholesky, and one line that hides a failure mode
has the shape of a Cholesky factorisation already. Cholesky gives with lower triangular; is upper triangular because is; and the factorisation is unique. So , and
which is where the function ends (L124–132 and L136, the three commented-out lines in between hold the closed-form expressions, the alternative to a Cholesky, still sitting in the source where I abandoned them):
B = np.array([[b[0], b[1], b[2]],
[b[1], b[3], b[4]],
[b[2], b[4], b[5]]])
B = B/B[2,2]
try:
KnT = np.linalg.cholesky(B)
except LinAlgError:
return "Did not Converge"
K = np.linalg.inv(KnT.T)
return K/K[2,2]
That return "Did not Converge" is my favourite line in the repository, and not because it
is good. A function whose contract is “returns a 3×3 array” returning a string on failure
is exactly the kind of thing that gets TypeErrord in a batch job at 2 a.m. But it is
pointing at something real, and it is worth understanding what.
A null vector from an SVD has an arbitrary sign. ’s columns are only
determined up to , so and are equally valid answers and which one you get
depends on the arithmetic. If you get , then is negative definite, Cholesky
finds a negative pivot on the very first row, and NumPy raises LinAlgError.
Except that B = B/B[2,2] on the line above quietly fixes it. Multiply
out and comes to
,
a sum of squares plus one, so strictly positive for any camera whatsoever. Dividing by it
restores the sign whichever way the SVD went. V2 is immune. V1 is not: ZhangAlg.py L68–72 calls
np.linalg.cholesky(B) on the raw B with no scaling and no try, so a coin-flip in
LAPACK’s sign convention crashes it outright. That is the sign ambiguity in the wild, and
my Rust exposes both paths so the tests can pin the behaviour down:
// V1: straight to Cholesky. Fails.
assert_eq!(k_from_b(&bmat, false).unwrap_err(), Status::NotPositiveDefinite);
// V2: divide by B[2][2] first. Same K as the unflipped run.
let (k, _, _) = k_from_b(&bmat, true).expect("sign fixed");
What is left of "Did not Converge" in V2, then, is the genuinely interesting failure:
that is not positive definite on its own merits, because the views did not
constrain it. Hold the board parallel to the sensor in every shot and each view repeats the
constraints of the last one; the system has an effectively two-dimensional null
space; the vector you pull out of it is an arbitrary mixture and corresponds to no real
camera at all. You can do that on purpose in the widget below: pick “all fronto-parallel”,
set the noise slider to zero, and there it is.
Calibrate a camera
Two modes. Synthetic builds a camera you control, projects a board through it at poses
you choose, adds pixel noise, and hands the corners (and nothing else) to the same
zhang.rs the tests exercise. The K on the right is what the algorithm recovers from
those dots alone; the error column is against a truth it never saw. Real corners loads
the corner sets my detector actually extracted from the calibration datasets, so you can
reproduce a row of the results table below in your browser.
With JavaScript on, this becomes a live calibrator: sliders for a ground-truth camera’s
fu, fv, cu, cv and skew, a pixel-noise
control, a choice of board poses, and the recovered K beside them with the
per-parameter error. Switches turn Hartley normalisation and the Levenberg–Marquardt
refinement on and off and change how the null space is computed. It also reads the
ordered-corner JSON exported by the detector post.
Things worth doing to it:
- Select “AᵀA in f32: the naive way” and untick both normalisation and LM. The
re-projection error jumps from 0.40 px to 10.9 px and the solver gives up: B is not
positive definite. Tick normalisation back on, leaving the naive solver selected, and it
recovers
Kto within three pixels. That is the entire argument for the four lines ofget_scale_matrix, and it is why the LM box has to come off too. With the refinement running, LM starts from the wrecked homography and walks back to the right answer anyway, which is a nice illustration of what a re-projection-error minimiser is worth. - Choose “all fronto-parallel” with the noise slider at zero. Every board parallel to
the sensor, so every view repeats the constraints of the last one, the conic system is
genuinely rank-deficient, and you get the real thing: B is not positive definite, the
literal
"Did not Converge"of the Python. Now push the noise up to 0.3 px and it is worse, not better: the noise breaks the tie, aKappears, and it claims for a 640-pixel sensor. The tell is the conic system’s condition number, which goes from about 3 × 10³ on a good capture to 8 × 10⁵ here; the widget says so above the table. Then try “barely tilted”, which is not degenerate and still moves by five pixels. Tilting the board is not a nicety. It is the measurement. - Drop to two views. The solver refuses rather than returning a number, which is the behaviour I wish more numerical code had.
- Turn LM off with noise at 1 px, everything else sane.
Kmoves by a pixel or two and the RMS barely twitches. The refinement is worth having and it is not the difference between working and not: Zhang shipped without it. - Load dataset 3 and pull “views used” down from 15. Watch (always the least determined parameter) swing around until there are enough views to pin it.
- Load your own corners. The detector post’s Download corners (JSON) button produces a
checkerboard-corners/v1file, and this widget reads it. One file is one view, so you will want several; the shipped bundles show the{"board": …, "views": […]}shape that holds a whole capture.
Results
The benchmark harness runs four combinations (OpenCV’s corner detector or mine, OpenCV’s
calibrateCamera or mine) over each dataset, with every OpenCV distortion coefficient
fixed at zero so the two are solving the same problem. This is Table 2 of the report, in
full. The bottom row of every matrix is and is omitted:
| # | OpenCV corners, OpenCV calibration | OpenCV corners, this calibration | Detected corners, OpenCV calibration | Detected corners, this calibration |
|---|---|---|---|---|
| 1 | 2740, 0, 567 / 0, 2734, 371 | 2746, 4, 565 / 0, 2738, 370 | 2743, 0, 567 / 0, 2737, 370 | 2746, 4, 565 / 0, 2739, 370 |
| 2 | 692, 0, 318 / 0, 688, 268 | 684, −2.8, 321 / 0, 678, 279 | 671, 0, 319 / 0, 681, 246 | 685, −2.8, 321 / 0, 678, 279 |
| 3 | 4343, 0, 394 / 0, 4311, 157 | 4361, −10, 407 / 0, 4328, 117 | 4389, 0, 387 / 0, 4353, 164 | 4361, −10, 407 / 0, 4328, 117 |
| 5 | 738, 0, 325 / 0, 741, 225 | 741, 0, 323 / 0, 744, 229 | 736, 0, 324 / 0, 740, 225 | 741, 0, 323 / 0, 744, 229 |
| 6 | 755, 0, 392 / 0, 762, 235 | 753, 3, 391 / 0, 759, 229 | NA | NA |
| 7 | 1312, 0, 791 / 0, 1230, 514 | 1336, −5, 792 / 0, 1249, 524 | 1319, 0, 793 / 0, 1235, 517 | 1336, −5, 792 / 0, 1249, 524 |
Dataset 4 is absent, and dataset 6 has no “detected corners” columns because the detector scored 0 out of 10 on it: the previous post has that story.
Focal lengths agree to within about half a percent across the board: 2740 against 2746, 4343 against 4361, 1312 against 1336. The report’s own summary of the discrepancy is the one I would still give:
That is the right explanation and it is worth spelling out. OpenCV’s model has no ; it pins the skew to zero and lets its tangential distortion terms absorb the same physical effect. This implementation has an and no distortion at all. So when this code reports for dataset 3, that number has to come out of somewhere, and it comes out of the other four, which is exactly where you see dataset 3’s 13-pixel disagreement in and its 40-pixel one in . Two models, both under-specified in different directions, meeting a few pixels apart. Neither column is “the truth”.
What the Rust gives
The widget is not running the NumPy. It is running my port, on corners produced by my port
of the detector, so it is a second implementation of both halves, and that makes it a
check. Detection first: 15/15 frames on dataset 3 and 10/25 on dataset 2, which is Table 1
exactly. Then, feeding those corners through zhang.rs with normalisation and LM on:
| Dataset | Rust port, its own corners | Report, detected corners + OpenCV calibration | Report, this calibration | RMS |
|---|---|---|---|---|
| 3 | 4337, −17.4, 400 / 4300, 178 | 4389, 0, 387 / 4353, 164 | 4361, −10, 407 / 4328, 117 | 0.569 px |
| 2 | 672, −1.2, 321 / 681, 260 | 671, 0, 319 / 681, 246 | 685, −2.8, 321 / 678, 279 | 1.339 px |
Dataset 2 is the striking one: against the report’s 671, against 681, against 319. That column is my 2019 corners fed to OpenCV’s calibration, and the Rust here reproduces it to within a pixel on three of five parameters: two independent implementations of a detector and two independent implementations of a calibrator, agreeing. Dataset 3 is looser, about 1% on the focal lengths, and is the usual offender.
What this does not do
Worth saying plainly, because a calibration tool that overstates itself is worse than none:
- No lens distortion. Not radial, not tangential, no coefficients at all. A wide-angle or
cheap lens will not be well described by this
Kat the edges of the frame. - No extrinsics.
get_camera_calib_mreturnsKand stops. Zhang’s method can recover and per view (they are one away) but this implementation never does, and the report lists that as a known drawback of the planar approach as implemented here. - No uncertainty. There is no covariance on the recovered parameters, so "" and "" look equally confident and are not.
The Rust
The maths lives in two new modules of calib-wasm, the crate the detector post established,
and it stayed dependency-free: the shapes here are so small that pulling in a linear-algebra
library would have cost more bytes than writing the two decompositions.
linalg.rs: a one-sided Jacobi SVD (rotate pairs of columns until they are mutually orthogonal; the column norms are then the singular values and the accumulated rotation is ), the same loop again inf32, the shortcut, a textbook Cholesky that returnsNoneon the first non-positive pivot, and a 9×9 Gaussian solve for the LM normal equations.zhang.rs: normalisation, the DLT, LM, the conic solve,K, and the C ABI.
Where it differs from the NumPy, and why:
| NumPy | Rust | |
|---|---|---|
| SVD | np.linalg.svd, LAPACK gesdd | hand-rolled one-sided Jacobi; a f32 variant and an variant for the widget’s solver switch |
| LM | scipy.optimize.root(method='lm'), MINPACK lmder, on the squared residual with a mismatched Jacobian | damped normal equations on the unsquared residual with the matching Jacobian |
| Normalisation | always on | switchable |
| Failure | the string "Did not Converge" | a Status enum the JS side reads as an integer |
| Diagnostics | discarded | condition numbers, the conic system’s singular spectrum, B, L and per-view RMS all reported |
cargo test -p calib-wasm covers the SVD against a closed-form decomposition built from two
explicit rotations, Cholesky against the standard integer hand case, a synthetic camera
recovered from noiseless projections to within of a K with non-zero skew, the
condition-number gap with normalisation on and off, that the naive solver only survives when
normalised, that LM never makes the RMS worse, and that a sign-flipped null vector breaks
Cholesky unless the B[2,2] division is applied. The crate grew from 40.7 kB to 60.0 kB.
The next thing to build on this is obvious and I did not build it: the detector and the calibrator on one page, pointed at a webcam, so you print a board and walk away with your own camera’s numbers. That needs a capture protocol, a pose-diversity indicator so you are not quietly collecting eight fronto-parallel views, and the honesty section above stapled to the bottom of it.