A camera maps three-dimensional geometry onto a flat sensor through a lens, and that projection can bend straight lines or shift measured positions. OpenCV estimates the intrinsic camera matrix and lens-distortion coefficients from repeated views of a known flat pattern.
Chessboard dimensions are passed to cv.findChessboardCorners() as inner corners in columns, rows order. A board printed with 8 columns by 7 rows of squares therefore uses a 7×6 pattern, while the measured square width sets the real-world unit used by later pose calculations.
Use at least ten sharp, same-resolution frames from the exact camera, lens, focus, and zoom configuration that will use the calibration. Move and tilt the board across the frame between captures; repeated near-identical views can produce a weak estimate even when the RMS reprojection error is low.
Related: How to install OpenCV on Ubuntu
$ ls calibration left01.jpg left03.jpg left05.jpg left07.jpg left09.jpg left12.jpg left14.jpg left02.jpg left04.jpg left06.jpg left08.jpg left11.jpg left13.jpg
A usable frame shows the whole chessboard with a light border around it. Varied positions, distances, and angles provide stronger calibration constraints.
from pathlib import Path import cv2 as cv import numpy as np IMAGE_DIR = Path("calibration") OUTPUT_PATH = Path("calibration.npz") PATTERN_SIZE = (7, 6) SQUARE_SIZE = 25.0 MIN_FRAMES = 10 image_paths = sorted( path for path in IMAGE_DIR.iterdir() if path.suffix.lower() in {".jpg", ".jpeg", ".png", ".tif", ".tiff"} ) if not image_paths: raise SystemExit(f"no calibration images found in {IMAGE_DIR}") columns, rows = PATTERN_SIZE object_template = np.zeros((rows * columns, 3), np.float32) object_template[:, :2] = np.mgrid[0:columns, 0:rows].T.reshape(-1, 2) object_template *= SQUARE_SIZE
PATTERN_SIZE counts inner corners across and down. SQUARE_SIZE carries the measured square width in millimeters, inches, or the unit required by later pose calculations.
criteria = ( cv.TERM_CRITERIA_EPS + cv.TERM_CRITERIA_MAX_ITER, 30, 0.001, ) object_points = [] image_points = [] image_size = None for path in image_paths: image = cv.imread(str(path)) if image is None: print(f"skipped unreadable image: {path.name}") continue gray = cv.cvtColor(image, cv.COLOR_BGR2GRAY) current_size = gray.shape[::-1] if image_size is None: image_size = current_size elif current_size != image_size: raise SystemExit( f"image size changed at {path.name}: {current_size} != {image_size}" ) found, corners = cv.findChessboardCorners( gray, PATTERN_SIZE, cv.CALIB_CB_ADAPTIVE_THRESH + cv.CALIB_CB_NORMALIZE_IMAGE, ) if not found: print(f"skipped no corners: {path.name}") continue refined = cv.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) object_points.append(object_template.copy()) image_points.append(refined) print(f"accepted: {path.name}") if len(object_points) < MIN_FRAMES: raise SystemExit( f"found {len(object_points)} usable frames, need at least {MIN_FRAMES}" )
Frames without all 7×6 inner corners are skipped. A different image size stops the run because one calibration matrix cannot be applied unchanged across mixed resolutions.
rms, camera_matrix, dist_coeffs, rvecs, tvecs = cv.calibrateCamera( object_points, image_points, image_size, None, None, ) np.savez( OUTPUT_PATH, camera_matrix=camera_matrix, dist_coeffs=dist_coeffs, image_size=np.array(image_size), pattern_size=np.array(PATTERN_SIZE), square_size=np.array([SQUARE_SIZE]), rms=np.array([rms]), ) np.set_printoptions(precision=4, suppress=True) print(f"accepted frames: {len(object_points)} of {len(image_paths)}") print(f"image size: {image_size[0]}x{image_size[1]}") print("camera matrix:") print(camera_matrix) print("distortion coefficients:") print(dist_coeffs.ravel()) print(f"opencv rms: {rms:.4f} px") print(f"wrote: {OUTPUT_PATH}")
cv.calibrateCamera() returns RMS reprojection error in pixels. Low values indicate that the fitted model projects detected corners close to their observed positions. Frame coverage and sharp corner detections still determine whether the result is reusable.
$ python3 calibrate_camera.py accepted: left01.jpg accepted: left02.jpg ##### snipped ##### accepted: left14.jpg accepted frames: 10 of 13 image size: 640x480 camera matrix: [[533.8228 0. 341.1336] [ 0. 533.874 231.9884] [ 0. 0. 1. ]] distortion coefficients: [-0.2933 0.1189 0.0013 -0.0002 0.0157] opencv rms: 0.1577 px wrote: calibration.npz
Too few accepted images or a high RMS error indicates that blurred, cropped, or similarly positioned frames need replacement. The reported image size belongs with the calibration profile.
$ python3 - <<'PY'
import numpy as np
calibration = np.load("calibration.npz")
camera_matrix = calibration["camera_matrix"]
dist_coeffs = calibration["dist_coeffs"]
rms = float(calibration["rms"][0])
if camera_matrix.shape != (3, 3):
raise SystemExit(f"unexpected camera matrix shape: {camera_matrix.shape}")
if dist_coeffs.size < 4:
raise SystemExit(f"too few distortion coefficients: {dist_coeffs.size}")
if not np.isfinite(camera_matrix).all() or not np.isfinite(dist_coeffs).all():
raise SystemExit("calibration contains non-finite values")
print(f"camera matrix: {camera_matrix.shape}")
print(f"distortion coefficients: {dist_coeffs.size}")
print(f"opencv rms: {rms:.4f} px")
print("calibration archive: valid")
PY
camera matrix: (3, 3)
distortion coefficients: 5
opencv rms: 0.1577 px
calibration archive: valid
The calibration.npz archive applies only to frames from the same camera, lens state, and resolution.