Camera calibration in OpenCV estimates how a lens projects a flat chessboard into the image sensor. The calibration output is needed before undistortion, measurement, pose estimation, or stitching work that depends on straight lines and repeatable pixel geometry.

cv2.findChessboardCorners() detects inner chessboard corners from multiple same-size images, cv2.cornerSubPix() refines them, and cv2.calibrateCamera() estimates the camera matrix and distortion coefficients. The corner pattern is counted by inner corners, so a physical board with 8 by 7 squares is passed as 7×6.

Use images from the same camera, lens focus, zoom, and resolution that will be corrected later. A low reprojection error and a saved calibration.npz file confirm that the script accepted enough frames, but visually poor captures or a mixed image set can still produce calibration values that should not be reused.

Steps to calibrate a camera with OpenCV:

  1. Put at least 10 same-size chessboard images in a directory named calibration.
    $ ls calibration
    frame-01.png
    frame-02.png
    frame-03.png
    frame-04.png
    frame-05.png
    frame-06.png
    frame-07.png
    frame-08.png
    frame-09.png
    frame-10.png
    frame-11.png
    frame-12.png

    Use real photos from the camera being calibrated. Move the board between frames so OpenCV sees different positions and angles, but keep the same image resolution throughout the set.
    Related: How to capture camera video with OpenCV

  2. Create the calibration script.
    calibrate_camera.py
    from argparse import ArgumentParser
    from pathlib import Path
     
    import cv2
    import numpy as np
     
     
    def parse_pattern(value):
        try:
            cols, rows = value.lower().split("x", 1)
            return int(cols), int(rows)
        except ValueError as exc:
            raise SystemExit("pattern must use the form columnsxrows, for example 7x6") from exc
     
     
    parser = ArgumentParser()
    parser.add_argument("image_dir", help="Directory containing chessboard calibration images")
    parser.add_argument("--pattern", default="7x6", help="Inner-corner pattern as columnsxrows")
    parser.add_argument("--square-size", type=float, default=25.0, help="Square size in real units")
    parser.add_argument("--output", default="calibration.npz", help="Output NumPy archive")
    parser.add_argument("--min-frames", type=int, default=10, help="Minimum accepted chessboard frames")
    args = parser.parse_args()
     
    pattern_size = parse_pattern(args.pattern)
    image_dir = Path(args.image_dir)
    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}")
     
    cols, rows = pattern_size
    object_template = np.zeros((rows * cols, 3), np.float32)
    object_template[:, :2] = np.mgrid[0:cols, 0:rows].T.reshape(-1, 2)
    object_template *= args.square_size
     
    criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
    object_points = []
    image_points = []
    image_size = None
     
    for path in image_paths:
        image = cv2.imread(str(path))
        if image is None:
            print(f"skipped unreadable image: {path.name}")
            continue
     
        gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
        if image_size is None:
            image_size = gray.shape[::-1]
        elif image_size != gray.shape[::-1]:
            print(f"skipped different size: {path.name}")
            continue
     
        found, corners = cv2.findChessboardCorners(gray, pattern_size)
        if not found:
            print(f"skipped no corners: {path.name}")
            continue
     
        refined = cv2.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) < args.min_frames:
        raise SystemExit(
            f"found {len(object_points)} usable frames, need at least {args.min_frames}"
        )
     
    rms, camera_matrix, dist_coeffs, rvecs, tvecs = cv2.calibrateCamera(
        object_points,
        image_points,
        image_size,
        None,
        None,
    )
     
    total_error = 0.0
    total_points = 0
    for object_point, image_point, rvec, tvec in zip(object_points, image_points, rvecs, tvecs):
        projected, _ = cv2.projectPoints(
            object_point,
            rvec,
            tvec,
            camera_matrix,
            dist_coeffs,
        )
        error = cv2.norm(image_point, projected, cv2.NORM_L2)
        total_error += error * error
        total_points += len(object_point)
     
    reprojection_rmse = (total_error / total_points) ** 0.5
    output_path = Path(args.output)
    np.savez(
        output_path,
        camera_matrix=camera_matrix,
        dist_coeffs=dist_coeffs,
        rvecs=np.asarray(rvecs),
        tvecs=np.asarray(tvecs),
        image_size=np.array(image_size),
        pattern_size=np.array(pattern_size),
        square_size=np.array([args.square_size]),
        rms=np.array([rms]),
        reprojection_rmse=np.array([reprojection_rmse]),
    )
     
    np.set_printoptions(precision=4, suppress=True)
    print(f"images found: {len(image_paths)}")
    print(f"accepted frames: {len(object_points)}")
    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"reprojection rmse: {reprojection_rmse:.4f} px")
    print(f"wrote: {output_path}")

    --pattern is the number of inner corners across and down, not the number of printed squares. Set --square-size to the real square size in millimeters, inches, or another unit that should also be used for later pose estimates.

  3. Run the calibration script.
    $ python3 calibrate_camera.py calibration --pattern 7x6 --square-size 25 --output calibration.npz
    accepted: frame-01.png
    accepted: frame-02.png
    accepted: frame-03.png
    accepted: frame-04.png
    accepted: frame-05.png
    accepted: frame-06.png
    accepted: frame-07.png
    accepted: frame-08.png
    accepted: frame-09.png
    accepted: frame-10.png
    accepted: frame-11.png
    accepted: frame-12.png
    images found: 12
    accepted frames: 12
    image size: 900x700
    camera matrix:
    [[856.3883   0.     464.9652]
     [  0.     847.5984 355.29  ]
     [  0.       0.       1.    ]]
    distortion coefficients:
    [  -0.2894    9.2245    0.0011    0.0052 -146.5785]
    opencv rms: 0.4267 px
    reprojection rmse: 0.4267 px
    wrote: calibration.npz

    A lower reprojection error means the detected image points are closer to the points projected from the estimated camera model. Re-shoot blurry, cropped, repeated, or mixed-resolution frames before reusing a poor calibration.

  4. Verify the saved calibration arrays.
    $ python3 - <<'PY'
    import numpy as np
    data = np.load("calibration.npz")
    print(f"camera_matrix: {data['camera_matrix'].shape}")
    print(f"dist_coeffs: {data['dist_coeffs'].shape}")
    print(f"rmse_under_one_pixel: {float(data['reprojection_rmse'][0]) < 1.0}")
    PY
    camera_matrix: (3, 3)
    dist_coeffs: (1, 5)
    rmse_under_one_pixel: True

    Keep calibration.npz with the camera profile and use it only for images captured with the same camera, lens setting, focus, and resolution.
    Related: How to undistort a camera image with OpenCV