Chapter 1 — Fisheye lenses & what a "camera model" actually is¶
Run alongside this:
python examples/01_realdata_fisheye_tumvi.py(after the setup). Read this, then read the printed numbers.
You'll learn
- Why a pinhole model breaks for fisheye lenses (X/Z blows up past 90°), and what a
"camera model" actually is: a project/unproject pair sharing one
CameraModel contract.
- How to load a real published calibration — TUM-VI's Kannala-Brandt intrinsics, straight
from their Kalibr YAML — instead of calibrating one from scratch.
- How to verify project and unproject are true inverses on real pixels, to machine
precision (mean round-trip 1.55e-14 px, max 9.10e-14 px over a 1600-pixel grid).
- How fisheye undistortion works, and why the balance knob trades field of view against
black border in the rectified output.
Prerequisites - None — this is the first chapter. - Install the core library and the TUM-VI dataset:
(see the project setup for details).1. The problem with straight lines¶
A pinhole camera has one defining property: straight lines in the world stay
straight in the image. It's the model behind cv2.solvePnP, most SfM, and the matrix
K you've seen a hundred times.
It works because projection is a clean perspective division: u = fx·X/Z + cx.
A fisheye lens deliberately breaks this to buy field of view — often > 180°, more
than a hemisphere. Run the example and open results/learn/01_fisheye_raw.png: the
ceiling lights bow into arcs. A pinhole model literally cannot describe this.
Note
Worse than "can't describe it": X/Z blows up as a ray approaches 90° off-axis
(Z → 0), and goes nonsensical beyond it (Z < 0, i.e. light from behind the
pinhole). You need a different model.
2. A camera model is a pair of functions¶
Strip away the mystique. A camera model is just two maps plus a handful of numbers (the intrinsics):
- project: 3D point in the camera frame → 2D pixel.
(X,Y,Z) ↦ (u,v) - unproject: 2D pixel → 3D unit ray (bearing).
(u,v) ↦ (x,y,z), ‖·‖ = 1
That's the entire interface. In this library it's literally the
CameraModel
contract every model implements. Pinhole, Double Sphere, Kannala-Brandt, and the rest only
differ in the math inside those two functions — same interface, swappable internals.
examples/01_realdata_fisheye_tumvi.py
puts this to work on a real TUM-VI calibration (it needs the dataset from the
setup, so this excerpt doesn't run standalone):
cam, (W, H) = load_kalibr_with_resolution(CAMCHAIN, cam="cam0")
rays, ok = cam.unproject(pix) # 2D pixels -> 3D unit bearing rays
back, ok2 = cam.project(rays) # 3D rays -> 2D pixels again
3. A calibration is just numbers in a file¶
We don't calibrate anything in this chapter — we load a calibration that the TUM-VI dataset authors already computed and published, straight from their Kalibr YAML. Running the example prints the loaded model:
Six-plus numbers fully describe this fisheye camera:
fx, fy— focal lengths, in pixels.cx, cy— the optical center.k— four coefficients shaping the radial distortion.
TUM-VI's file says pinhole + equidistant, which is the Kannala-Brandt model — the
same one behind OpenCV's cv2.fisheye.
The library reads it and hands you a working object. (Later chapters swap in the Double Sphere model, which handles >180° more gracefully.)
4. The non-negotiable habit: verify, don't trust¶
Here's the habit that separates 3D-vision work from "it looked fine on my test image": project and unproject must be inverses.
Unproject a pixel to a ray, project it back, and you must land on the original pixel. The example measures exactly this on a grid of 1600 real pixels:
1e-14 is machine precision for float64 — the functions are inverse to the last bit
the hardware can represent. If that number were 0.3px instead, your unprojection has a
bug, full stop.
Always have a number that proves correctness. It's how you debug, and later, it's how you earn a reviewer's trust.
Note
Not every pixel is valid: some lie outside the lens's image circle, and not every 3D ray
is projectable. A correct model tells you which — via the ok masks above. Chapter 3
is entirely about that validity boundary for >180° lenses.
5. Undistortion: "what would a pinhole have seen?"¶
Finally the example rectifies a real frame into a virtual pinhole view and saves
results/learn/01_fisheye_rectified.png. Compare it to the raw frame: the bowed ceiling
lines are now straight.
Left: the raw fisheye frame. Right: the rectified pinhole view as the balance knob sweeps
from widest-FOV (more scene, black borders) to tightest-crop. The bent lines straighten out.
For every output pixel, Undistorter.maps()
builds a pinhole ray from a fresh K_new, then projects it through the fisheye model
to find where to sample the source image.
The balance knob trades field of view for how much black border you tolerate.
This is why undistortion can't keep a full >180° FOV: a pinhole image plane is infinite at 90°, so the periphery has nowhere to go.
Tip
Wide-FOV pixels aren't lost to a bug — they're geometrically un-pinhole-able. A rectified frame can never keep more than a hemisphere; Chapter 3 measures exactly how much is lost.
Try it yourself¶
- Re-run with
balance=1.0in the script — predict whether the rectified image gets more or less black border before you look. - Load
cam="cam1"(the other stereo camera) and confirm its intrinsics differ slightly. - Widen the verification grid toward the image edge (
np.linspace(2, W-2, …)) and watch how many pixels fall outside the valid mask.
Next: Chapter 2 opens up the Double Sphere model, derives its projection from a two-sphere geometric picture, and uses it to reproduce TUM-VI's published calibration to a fortieth of a pixel.
