How to calibrate a camera and convert a pixel distance to a real-world distance

Based on contributions by LoukasLeftheriotis.

Standard OpenCV chessboard calibration removes lens distortion and gives you a camera matrix. Combined with a known chessboard square size, you can then turn a measured pixel distance into a real-world distance in millimetres — useful for measuring or sizing objects. This is not the same problem as finding a pick point’s X/Y location; for that, see How to calibrate a camera and map pixels to world coordinates with a ChArUco board or How to map camera coordinates to robot coordinates with a least-squares fit.

What you need

  • A printable chessboard pattern and a printer.
  • The camera you want to calibrate.
  • Python with opencv-python and numpy.
  • 15-20+ photos of the printed chessboard from different angles and distances.

Steps

1. Generate a printable chessboard image

import cv2 as cv
import numpy as np
import os

def generate_chessboard_image(square_size=25, chessboard_size=(8, 6), image_size=(200, 250), output_path='./images/calibration_chessboard.png'):
    chessboard_image = np.ones(image_size, dtype=np.uint8) * 255
    square_color = 0

    chessboard_width = chessboard_size[0] * square_size
    chessboard_height = chessboard_size[1] * square_size
    x_offset = (image_size[1] - chessboard_width) // 2
    y_offset = (image_size[0] - chessboard_height) // 2

    for i in range(chessboard_size[1]):
        for j in range(chessboard_size[0]):
            if (i + j) % 2 == 0:
                top_left = (x_offset + j * square_size, y_offset + i * square_size)
                bottom_right = (x_offset + (j + 1) * square_size, y_offset + (i + 1) * square_size)
                cv.rectangle(chessboard_image, top_left, bottom_right, square_color, -1)

    os.makedirs(os.path.dirname(output_path), exist_ok=True)
    cv.imwrite(output_path, chessboard_image)
    print(f"Chessboard saved to {output_path}")

generate_chessboard_image()

The default image_size is a small preview — increase it (and square_size) if you want a bigger printed board. Print at 100% scale; scaling in the print dialog throws off square_size.

2. Take calibration photos

Photograph the printed chessboard with the camera you’re calibrating, from a wide variety of angles and distances, and save them as .png files in your images folder.

3. Calibrate the camera

import numpy as np
import cv2 as cv
import glob
import json
import os

chessboardSize = (8, 6)  # inner corner count per row and column of YOUR printed board
frameSize = (1920, 1080)  # resolution of your calibration images
size_of_chessboard_squares_mm = 25

criteria = (cv.TERM_CRITERIA_EPS + cv.TERM_CRITERIA_MAX_ITER, 30, 0.001)

objp = np.zeros((chessboardSize[0] * chessboardSize[1], 3), np.float32)
objp[:, :2] = np.mgrid[0:chessboardSize[0], 0:chessboardSize[1]].T.reshape(-1, 2) * size_of_chessboard_squares_mm

objpoints = []
imgpoints = []

folder_path = './images'
os.makedirs(folder_path, exist_ok=True)

images = glob.glob(os.path.join(folder_path, '*.png'))
if not images:
    raise FileNotFoundError("No images found. Make sure your calibration photos are .png files in the images folder.")

detected_images = 0
for image in images:
    img = cv.imread(image)
    gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY)

    _, binary = cv.threshold(gray, 127, 255, cv.THRESH_BINARY | cv.THRESH_OTSU)
    ret, corners = cv.findChessboardCorners(binary, chessboardSize,
                                             cv.CALIB_CB_ADAPTIVE_THRESH + cv.CALIB_CB_NORMALIZE_IMAGE + cv.CALIB_CB_FAST_CHECK)

    if ret:
        objpoints.append(objp)
        corners2 = cv.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)
        imgpoints.append(corners2)
        detected_images += 1

        cv.drawChessboardCorners(img, chessboardSize, corners2, ret)
        cv.imshow('Detected Corners', img)
        cv.waitKey(500)
        print(f"Chessboard corners detected in {image}")
    else:
        print(f"Warning: Chessboard not detected in {image}")

cv.destroyAllWindows()

print(f"Number of images with detected corners: {detected_images}")
if detected_images == 0:
    raise ValueError("No valid chessboard corners detected in any images. Check chessboardSize and your images.")

ret, cameraMatrix, dist, rvecs, tvecs = cv.calibrateCamera(objpoints, imgpoints, frameSize, None, None)
if not ret:
    raise RuntimeError("Camera calibration failed.")

calibration_data = {
    'camera_matrix': cameraMatrix.tolist(),
    'dist_coeffs': dist.tolist(),
    'rvecs': [rv.tolist() for rv in rvecs],
    'tvecs': [tv.tolist() for tv in tvecs]
}
calibration_file_path = os.path.join(folder_path, 'calibration_data.json')
with open(calibration_file_path, 'w') as f:
    json.dump(calibration_data, f)
print(f"Calibration data saved to {calibration_file_path}")

# Reprojection error -- lower is better; well under 1.0 pixel is a good calibration
mean_error = 0
for i in range(len(objpoints)):
    imgpoints2, _ = cv.projectPoints(objpoints[i], rvecs[i], tvecs[i], cameraMatrix, dist)
    error = cv.norm(imgpoints[i], imgpoints2, cv.NORM_L2) / len(imgpoints2)
    mean_error += error
print(f"Total reprojection error: {mean_error / len(objpoints)}")

def undistort_image(image_filename):
    """Undistort an image using the saved camera calibration data."""
    with open(calibration_file_path, 'r') as f:
        calibration_data = json.load(f)
    cameraMatrix = np.array(calibration_data['camera_matrix'])
    dist = np.array(calibration_data['dist_coeffs'])

    img_path = os.path.join(folder_path, image_filename)
    img = cv.imread(img_path)
    if img is None:
        print(f"Image '{image_filename}' not found in {folder_path}")
        return

    h, w = img.shape[:2]
    newCameraMatrix, roi = cv.getOptimalNewCameraMatrix(cameraMatrix, dist, (w, h), 1, (w, h))
    dst = cv.undistort(img, cameraMatrix, dist, None, newCameraMatrix)

    x, y, w, h = roi
    dst = dst[y:y + h, x:x + w]

    undistorted_filename = f"undistorted_{image_filename}"
    cv.imwrite(os.path.join(folder_path, undistorted_filename), dst)
    print(f"Undistorted image saved as {undistorted_filename}")

undistort_image('img0.png')  # replace with any image in your images folder

:warning: Check: chessboardSize in cv.findChessboardCorners must be the number of inner corners, i.e. (squares_per_row - 1, squares_per_column - 1) of the board you actually printed — not the number of squares. If your printed board has 8×6 squares, set chessboardSize = (7, 5); use (8, 6) only if your board actually has 9×7 squares. Double-check this against your own printed board before relying on this value — a mismatch here means findChessboardCorners will silently fail to find any corners.

4. Convert a pixel distance to a real-world distance

This loads the saved calibration, detects the chessboard again in a reference image to measure how many pixels one square spans, and uses that as a scale factor.

import numpy as np
import json
import cv2 as cv
import os

def pixel_to_real_distance(pixel_distance, image_path, calibration_file_path='./images/calibration_data.json',
                            real_checkerboard_size_mm=25, checkerboard_size=(8, 6)):
    """
    Calculate the real-world distance in mm for a given pixel distance.

    pixel_distance: distance in pixels to convert.
    image_path: path to an image containing the checkerboard pattern (used only to measure the scale).
    calibration_file_path: path to the saved calibration JSON.
    real_checkerboard_size_mm: real-world size of one checkerboard square, in mm.
    checkerboard_size: inner corner count of the checkerboard used for the scale reference.
    """
    if not os.path.exists(calibration_file_path):
        raise FileNotFoundError(f"{calibration_file_path} not found.")
    with open(calibration_file_path, 'r') as f:
        calibration_data = json.load(f)
    camera_matrix = np.array(calibration_data['camera_matrix'])
    dist_coeffs = np.array(calibration_data['dist_coeffs'])

    img = cv.imread(image_path)
    if img is None:
        raise FileNotFoundError(f"Failed to load image at {image_path}")
    gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY)

    ret, corners = cv.findChessboardCorners(gray, checkerboard_size, None)
    if not ret:
        raise Exception("Checkerboard not detected in the image!")

    corners2 = cv.cornerSubPix(gray, corners, (11, 11), (-1, -1),
                                (cv.TERM_CRITERIA_EPS + cv.TERM_CRITERIA_MAX_ITER, 30, 0.001))

    pixel_checkerboard_distance = np.linalg.norm(corners2[0] - corners2[1])
    mm_per_pixel = real_checkerboard_size_mm / pixel_checkerboard_distance
    return pixel_distance * mm_per_pixel

Common mistakes

  • chessboardSize mismatch. See the :warning: Check note above — this is the most common reason findChessboardCorners returns nothing.
  • Fewer than ~10-15 good detections. Camera calibration needs enough varied views (different angles, distances, positions in frame) to solve for the lens distortion reliably; if detected_images is low, take more photos with more variation.
  • Poor lighting or glare on the board. Corner detection is sensitive to uneven lighting and reflections — matte-print the chessboard if possible.
  • camera_matrix and dist_coeffs from this calibration are also exactly what How to calibrate a camera and map pixels to world coordinates with a ChArUco board and How to do high-precision robot-camera calibration with ArUco markers need as their starting point — you don’t need to redo it with ChArUco if you already have this.

Related


Rewritten and consolidated (Sept 2026) from the original student how-to’s: How to Calibrate a Camera and Convert Pixel Distance to Real-World Distance.