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.

Steps to calibrate a camera with OpenCV:

  1. Place at least ten same-resolution chessboard photos in calibration.
    $ 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.

  2. Create the calibration inputs and 7×6 chessboard model with a 25.0-unit square size in calibrate_camera.py.
    calibrate_camera.py
    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.

  3. Append the chessboard-corner collection section to calibrate_camera.py.
    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.

  4. Append the result-writing calibration section to calibrate_camera.py.
    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.

  5. Run calibrate_camera.py from the directory that contains calibration.
    $ 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.

  6. Inspect the saved calibration arrays with a fail-capable NumPy check.
    $ 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.