Skip to content
URIAH CLEMMER
$ cat blog/building-lenscalc-two-inputs-never-three.md

Building LensCalc: two inputs, never three

SOFTWARE MACHINE VISION 9 min read

LensCalc has been on the tools page since August. The optics post that went with it explained the terms, derived the one equation the whole thing rests on, and showed the thirty lines of arithmetic that branch it three ways — which left a build post with a problem, because the maths is already written up and there is not much of it. So this is about the rest: the half-dozen decisions that turn thirty lines of arithmetic into a tool somebody else can trust, and the tests that keep them decided. It is the same shape as the NetCalc build post, for the same reasons, and I will not repeat those: the maths lives under app/Support/Vision with no framework in it so Pest can call it directly, and the query string is the whole state of the page, so any result is a link.

Exactly two, never three

The relationship is magnification: m = sensor ÷ FOV = f ÷ (WD − f). Three quantities, one equation, so any two determine the third — and the first design decision was what to do when the user supplies some other number of them.

ANY TWO GIVE THE THIRD · m = sensor ÷ FOV = f ÷ (WD − f) f WD FOV f = WD · m ÷ (m + 1) FOV = sensor · (WD − f) ÷ f WD = f · (m + 1) ÷ m inputs supplied → what the page does 0 · nothing asked · the form, no result 1 · underdetermined · "fill in exactly two" 2 · solve for the third 3 · may contradict · "fill in exactly two" and WD ≤ f is refused: no real image forms drop the − f (FOV = sensor · WD ÷ f) and the field is overstated by: WD = 4f33.3 % WD = 8f14.3 % · the test's case WD = 16f6.7 % WD = 40f2.6 %

None is easy: the form, no result, no complaint. One is underdetermined and gets a sentence saying which two to fill in. Three is refused, and that is the decision worth explaining, because the tempting alternative is to quietly use two of them and ignore the third. Three supplied values may contradict each other — a 50 mm lens at 400 mm does not give a 70 mm field, whatever the user typed — and a calculator that silently picks two is lying about the one it dropped. The controller counts what arrived, and anything but two is a message rather than a guess:

$supplied = count(array_filter([$focal, $distance, $field],
    static fn (?float $v): bool => $v !== null));

if ($supplied === 0) { return [null, null]; }          // nothing asked

if ($supplied !== 2) {
    return [null, 'Fill in exactly two of focal length, working distance
and field of view — the third is what gets calculated.'];
}

The calculator class enforces the same rule a second time with an exception, so it cannot be called wrongly from anywhere else, and a dataset test feeds it one value, none, and all three. One more refusal sits beside it: a working distance at or inside the focal length. The arithmetic would happily return a negative or infinite field; the physics says no real image forms on the sensor, so the tool says that instead.

The minus f, and the test that guards it

The shorthand everyone learns is FOV = sensor × WD ÷ f. It is the real equation with the focal length dropped from the denominator, and it is close enough at long range and wrong close in: 2.6 % over at forty focal lengths, 6.7 % at sixteen, 14.3 % at eight, a third at four. Machine-vision working distances are routinely under ten focal lengths. The optics post made that argument; what the build post can add is that the argument is a test:

it('is not the FOV = sensor x WD / f shorthand', function () {
    $solution = LensCalculator::solve(SensorFormat::find('two-thirds'),
focalLength: 50, workingDistance: 400);

    $shorthand = 8.8 * 400 / 50;

    expect($shorthand)->toBe(70.4)
        ->and(round(($shorthand - $solution->fieldWidth) / $solution->fieldWidth * 100, 1))
        ->toBe(14.3);
});

It asserts that the tool's answer is 14.3 % away from the shorthand's. Somebody tidying the code one day, seeing WD − f and "simplifying" it to WD because that is the formula they remember, breaks a test whose name tells them why. A comment would say the same thing and nobody would read it; a failing test is read by definition. The 1:1 macro case is pinned the same way — at unit magnification the object sits at exactly two focal lengths and the lens needs a full focal length of extension, two identities any optics text states — so the three solve directions are checked against each other and against something outside the code.

Two angles of view, on purpose

A lens datasheet quotes one angle of view, 2·atan(sensor ÷ 2f), and it assumes focus at infinity. Focus a 50 mm lens on something 400 mm away and the lens moves 7.14 mm further from the sensor to do it, so the angle it actually subtends on the target is narrower: 8.81° against the datasheet's 10.06°, for a 2/3-inch sensor. Both numbers are true and they answer different questions — "what does the datasheet say" and "what will the camera see" — and showing only one of them is how a calculator generates support questions. LensCalc prints both, labelled, and a test asserts the nominal angle is always the wider.

TWO ANGLES OF VIEW, BOTH TRUE · 2/3" · 50 mm · 400 mm sensor · 8.8 mm lens · f = 50 57.14 mm · f + 7.14 extension nominal · 2·atan(8.8 ÷ 100) 10.06° · the datasheet target at 400 · 61.6 mm wide actual · 2·atan(61.6 ÷ 800) 8.81° · what it sees the actual angle closes on the nominal as the distance grows: WD 200 → 7.55° · 400 → 8.81° · 800 → 9.43° · 2000 → 9.81° · ∞ → 10.06° angles exaggerated for the drawing · the figures are the tool's

The related convention is written on the page rather than hidden in it: working distance is measured to the lens's front principal plane, which is where a thin-lens model puts it, and a real lens separates its principal planes by an amount the manufacturer rarely publishes. So the result is good to a few millimetres — fine for choosing a lens, not for metrology, where you calibrate. Saying so on the tool is cheaper than saying so in every email.

A sensor table that knows no pixel counts

Eleven standard formats ship in the table, from 1/4" to full frame, each as a width and a height in millimetres and nothing else. The inch names are nominal — a 1" sensor is 16 mm on the diagonal, not 25.4, because the name descends from the diameter of the vidicon tube that produced a 16 mm image — and the test for that one is a sentence of history with an assertion under it. What the table deliberately does not carry is resolution. A sensor preset that knew its pixel count would be a camera part number in disguise, and part numbers go stale; a 2/3" format does not. So pixel pitch is derived from the format the user picked and the resolution they typed — 8.8 mm across 2,448 pixels is 3.59 µm — and nothing in the tool can be out of date. A custom width and height are accepted for the format the table lacks, and the aspect label snaps to 4:3, 16:9, 3:2, 5:4 or 1:1 when it is within two hundredths of one, and otherwise prints the ratio: 1.1" is 1.37:1, honestly.

Three pixels, not two

Nyquist says a feature must span two pixels to be detected at all. Detecting is not measuring: an edge that lands on two pixels can be found, and its position is good to about a pixel, which is to say not good. Three pixels across a feature is the usual working minimum for measuring it, and inspection work often wants four or more. The tool's default is therefore three, with Nyquist's two shown beside it as the floor — "smallest feature" and "workable feature" are two rows on the page, and the verdict on a feature size the user enters is computed at whichever sampling they chose. Here is the worked example from the optics post, run through the classes rather than typed, with the sampling half added:

QuantityValueHow
Inputs2/3" · 50 mm · 400 mm · 2448 × 2048 px · 0.5 mm feature at 3 pxas a link
Magnification0.142857f ÷ (WD − f) = 50 ÷ 350
Field of view61.6 × 46.2 mm8.8 ÷ m; height by the sensor's aspect
Image distance · extension57.14 mm · 7.14 mmf (1 + m) · f m
Angle of view, actual · nominal8.81° · 10.06°2·atan(FOV ÷ 2WD) · 2·atan(sensor ÷ 2f)
Pixel pitch3.59 µm8.8 mm ÷ 2448 px
Scale0.02516 mm/px · 39.74 px/mm61.6 mm ÷ 2448 px
Smallest · workable feature0.050 mm · 0.075 mmscale × 2 · scale × 3
0.5 mm feature at 3 pxresolvable · 370 px needed of 2,448⌈61.6 ÷ 0.5 × 3⌉

Every one of those is a unit-test assertion, and the page test runs the same query string through the controller and reads the verdict off the rendered page. A 0.02 mm feature on the same setup needs 6,161 pixels at Nyquist, which is the test's "not happening" case.

Three small things

The one-line answer lives on the result object, not in the template. Whichever of the three values was solved for is the headline — "61.6 × 46.2 mm", "50.00 mm lens", "400.0 mm away" — and a method on LensSolution decides which, so a unit test can assert all three strings and the Blade file has no branch in it. The default sensor format is non-nullable. SensorFormat::default() returns a format or throws a LogicException, never null, because the preset table is code and a missing default is a bug in the code rather than a condition a caller should handle; every caller that must have a sensor gets one without unwrapping an optional. The equations in the optics post are SVG, typeset by hand, inlined so they pick up the page's fonts — and the advance widths were measured in a browser rather than estimated, because the first attempt at estimating them produced overlapping glyphs and a minus sign that vanished. That minus sign was the one in WD − f. There is a joke in there about the shorthand, and I have decided not to make it.

What that bought

A tool that refuses to guess, that cannot be simplified into the wrong formula without a test saying so, that prints both of the angles people argue about, and that has no part numbers in it to go stale. The optics post is the maths; this is the discipline around it, and the discipline is most of the work. If you want the whole worked example on the page, here it is — change any number and the rest follows.

#machine-vision #lenscalc #php #testing
CONTINUE? Questions about this one? Say hello →