Skip to content

Calibration

Turn captures of a real camera into a measured catalog entry; see the calibration guide.

geometry

Camera geometry from ChArUco captures: intrinsics, distortion, and stereo extrinsics.

A ChArUco board is a chessboard with ArUco markers in its white squares, so every detected corner has an identity: partial views, oblique angles and boards at the image edges all count. That is what a wide-angle lens needs, since the distortion lives at the edges.

Conventions match twinrobo.geometry.CameraIntrinsics and OpenCV: pixel (0, 0)'s center is 0; distortion is OpenCV's k1 k2 p1 p2 k3 on normalized coordinates, which is what a catalog camera.yaml stores (calibration.distortion.model: opencv).

Board dataclass

Board(squares_x: int = 11, squares_y: int = 8, square_m: float = 0.015, marker_m: float = 0.011, dictionary: str = 'DICT_5X5_100')

A ChArUco calibration board.

Parameters:

Name Type Description Default
squares_x int

Squares across.

11
squares_y int

Squares down.

8
square_m float

Printed side of a chessboard square, in meters (measure the print).

0.015
marker_m float

Printed side of an ArUco marker, in meters.

0.011
dictionary str

OpenCV ArUco dictionary name.

'DICT_5X5_100'

image

image(px_per_square: int = 120, margin_px: int = 60) -> ndarray

The board as a uint8 image for printing (print it flat, then measure a square).

GeometryResult dataclass

GeometryResult(width: int, height: int, fx: float, fy: float, cx: float, cy: float, k1: float, k2: float, p1: float, p2: float, k3: float, rms_px: float, views: int, corners: int, per_view_rms_px: list[float] = list())

Fitted intrinsics and distortion at the images' resolution.

rms_px: reprojection error over all corners; below ~0.3 px is a good calibration, above ~1 px usually means blurred or mis-measured views.

scaled

scaled(width: int, height: int) -> GeometryResult

The same camera read out at width x height (e.g. calibrated on a binned mode).

Distortion acts on normalized coordinates, so only the intrinsics scale.

fov_deg

fov_deg() -> dict[str, float]

Pinhole field of view (before distortion), horizontal and vertical.

to_spec

to_spec() -> dict

The calibration.intrinsic / calibration.distortion blocks of a camera.yaml.

StereoResult dataclass

StereoResult(translation_m: tuple[float, float, float], rotation_rpy_deg: tuple[float, float, float], baseline_m: float, rms_px: float, views: int)

The right eye's pose in the left eye's camera frame (a module's eyes.right).

translation_m and rotation_rpy_deg are in TwinRobo's camera frame (x right, y up, looking along -z), as module.yaml stores them.

detect

detect(image: ndarray, board: Board)

ChArUco corners of one image: (object_points [N, 3] m, image_points [N, 2] px, ids).

None if fewer than 6 corners are found (too few to constrain a view).

calibrate

calibrate(images: list[ndarray], board: Board, fix_k3: bool = False, min_views: int = 5) -> GeometryResult

Fit intrinsics and k1 k2 p1 p2 k3 from ChArUco images of one camera.

Capture 15-40 views: the board filling the frame and in every corner, tilted up to ~45 degrees, at the working distance, with the camera's own focus. fix_k3 helps narrow lenses, where k3 is poorly constrained.

calibrate_stereo

calibrate_stereo(left: list[ndarray], right: list[ndarray], board: Board, left_geometry: GeometryResult, right_geometry: GeometryResult) -> StereoResult

The right eye's pose from simultaneous ChArUco pairs (each eye calibrated first).

load_images

load_images(paths: list[str | Path]) -> list[ndarray]

Images as arrays (grayscale or RGB), in the given order.

sharpness

Sharpness from slanted edges (ISO 12233 style), and the lens focus that explains it.

A slanted edge is a straight dark/bright boundary tilted a few degrees off the pixel grid. Every row samples the edge at a slightly different sub-pixel phase, so projecting all pixels onto the edge normal gives the edge spread function (ESF) at 4x the pixel rate. Its derivative is the line spread function (LSF), and the LSF's Fourier magnitude is the MTF. MTF50, the frequency where contrast halves, is the usual single-number sharpness.

fit_focus compares measured MTF curves at known object distances with the lens model (DeepLens) refocused to candidate distances, and returns the focus distance that explains the captures best: the one number of the blur model a catalog entry cannot take from a datasheet when the camera's focus is fixed and unpublished.

EdgeMTF dataclass

EdgeMTF(freq: ndarray, mtf: ndarray, angle_deg: float, vertical: bool)

MTF of one slanted edge. freq in cycles per pixel (Nyquist = 0.5).

EdgeMeasurement dataclass

EdgeMeasurement(distance_m: float, field: tuple[float, float], mtf: list[float], vertical: bool = True, source: str | None = None)

A measured edge: where it was, and its MTF.

field: the ROI center in DeepLens' normalized field coordinates ([-1, 1], +y up); distance_m: the target's distance from the camera; mtf: the edge's MTF at FREQS, in cycles per pixel of the spec's resolution.

mtf_at

mtf_at(freq: ndarray, mtf: ndarray, level: float) -> float

First frequency where mtf falls to level (linear interpolation).

slanted_edge_mtf

slanted_edge_mtf(roi: ndarray, oversample: int = 4) -> EdgeMTF

MTF of the single slanted edge in roi (grayscale or RGB, linear or sRGB).

The ROI should hold one straight edge, tilted about 2-15 degrees from vertical or horizontal, with some flat dark and bright area on both sides. Use linear (not gamma-encoded) pixel values when available; sRGB images overstate MTF a little.

psf_mtf

psf_mtf(psf: ndarray, vertical_edge: bool = True) -> tuple[ndarray, ndarray]

MTF of a sampled PSF [ks, ks] across a vertical (or horizontal) edge.

sample_mtf

sample_mtf(freq: ndarray, mtf: ndarray, at: ndarray = FREQS) -> list[float]

An MTF curve resampled at at (0 beyond the measured range).

model_mtf

model_mtf(optics, distance_m: float, edges: list[EdgeMeasurement]) -> list[ndarray]

The lens model's MTF (green) at FREQS for edges at one distance, at its current focus.

fit_focus

fit_focus(optics, measurements: list[EdgeMeasurement], coarse: int = 15) -> FocusFit

The focus distance at which the lens model's MTF best matches the measured edges.

optics is a twinrobo.optics.deeplens.DeepLensOptics; it is refocused while fitting and left at the best focus. The search is in diopters (1/m), 10 cm to infinity: a coarse grid, then a golden-section refinement around its best point. Measure edges at several distances in front of and behind the expected focus: one distance cannot tell near from far focus.

vignetting

Relative illumination (vignetting) from flat-field captures.

Point the camera at a uniformly lit, featureless surface (a diffuser over the lens, a light panel or an evenly lit white wall), defocus if needed, and average a few frames at a mid-level exposure. The falloff towards the corners is the camera's relative illumination: lens vignetting, cos^4 falloff and the sensor's chief-ray angle response together.

Vignetting dataclass

Vignetting(a2: float, a4: float, a6: float, corner: float, rms_residual: float, width: int, height: int)

Radial relative illumination I(r) = 1 + a2 r^2 + a4 r^4 + a6 r^6.

r is the distance from the image center over the half-diagonal (1 at the corners); corner is I(1).

flat_field

flat_field(images: list[ndarray], black_level: float = 0.0, saturation: float | None = None) -> Vignetting

Fit relative illumination to flat-field images (averaged; linear values preferred).

The center is normalized to 1. Pixels at or above saturation (default: the integer type's maximum for integer images, none for float images) are ignored; keep the exposure below clipping.

spec_writer

Write calibration results into a catalog entry: a measured camera.yaml / module.yaml.

measured_camera_yaml

measured_camera_yaml(spec_path: str | Path, geometry=None, focus_m: float | None = None, vignetting=None, notes: str = '') -> str

The camera.yaml text of spec_path updated with measured values.

geometry (GeometryResult, rescaled to the spec's resolution if needed), focus_m (from fit_focus) and vignetting (Vignetting) replace the corresponding fields; validation.status becomes measured. The original header is kept and a "Measured" section is appended to it. Inline comments below the header are not preserved.

measured_module_yaml

measured_module_yaml(module_path: str | Path, stereo, notes: str = '') -> str

The module.yaml text with the right eye's measured pose (StereoResult).