Home

Camera Calibration Guide

Step-by-step guide to calibrate your camera using ChArUco boards

What You Need

  • ChArUco board (generate from the homepage)
  • Camera to calibrate
  • OpenCV (Python) installed — pip install opencv-contrib-python
  • Good, even lighting

Step-by-Step Calibration

Step 1: Generate & Print the Board

  • Go to the homepage and generate your ChArUco board with your desired parameters.
  • Choose a paper size (A4 or A3 recommended) and download the PDF.
  • Print on matte paper (not glossy — glossy causes glare).
  • Mount the print on a flat, rigid surface (acrylic sheet, aluminium composite, or thick cardboard).
  • Measure the printed square side length with a ruler — do not trust the nominal value. Input the measured value during calibration.

Step 2: Capture Calibration Images

  • Take 10–15 photos of the board from different angles and positions.
  • Cover the entire frame area, including edges and corners.
  • Vary the tilt angle from 0° (frontal) up to about 45°.
  • Rotate the board in all axes (pitch, yaw, roll).
  • Ensure lighting is even — avoid strong shadows or highlights on the board.
  • Save all images in a single folder (JPG or PNG).

Step 3: Run Calibration Script

  • Download the Python script below (charuco_calibration.py).
  • Open a terminal in the folder containing your images.
  • Run:
  • python charuco_calibration.py --images ./calib_images/ --square_length 25.0
  • The script automatically detects the ChArUco board, runs calibration, and saves results.
  • Output files: calibration_results.json, calibration_results.xml, and visualisations.

Step 4: Verify Results

  • Check the RMS reprojection error in the output:
  • • < 0.3 pixels — excellent
  • • 0.3 – 0.5 pixels — good
  • • 0.5 – 0.8 pixels — acceptable
  • • > 0.8 pixels — retake images
  • Verify focal length values are reasonable (e.g., ~fx = image_width × 1.2 for a typical webcam).
  • Check the principal point (cx, cy) is near the image centre.
  • Review the per-view reprojection errors plot to spot bad frames.
  • If results are poor, add more images with better coverage and re-run.

Step 5: Use the Calibration

  • Use the saved camera matrix and distortion coefficients in your application:
  • import cv2 as cv
    import numpy as np
    
    # Load calibration
    fs = cv.FileStorage("calibration_results.xml", cv.FILE_STORAGE_READ)
    mtx = fs.getNode("camera_matrix").mat()
    dist = fs.getNode("distortion_coefficients").mat()
    fs.release()
    
    # Undistort image
    img = cv.imread("image.jpg")
    undistorted = cv.undistort(img, mtx, dist)
  • Use the calibration for measurement (pixel-to-mm conversion), AR, or 3D reconstruction.
  • Re-calibrate if the lens focus or zoom changes.

Calibration Script

Below is the complete Python script. Download it or copy directly.

charuco_calibration.py
#!/usr/bin/env python3
"""
charuco_calibration.py — ChArUco Camera Calibration
OpenCV >= 4.7, Python >= 3.8
"""
import numpy as np
import cv2
import glob
import argparse
import json
import os
import sys
from pathlib import Path


def parse_args():
    parser = argparse.ArgumentParser(description="ChArUco Camera Calibration")
    parser.add_argument("-i", "--images", type=str, required=True,
                        help="Path to calibration images (dir or glob)")
    parser.add_argument("--squares_x", type=int, default=7,
                        help="Number of chessboard squares in X (default: 7)")
    parser.add_argument("--squares_y", type=int, default=5,
                        help="Number of chessboard squares in Y (default: 5)")
    parser.add_argument("--square_length", type=float, required=True,
                        help="Square side length in mm")
    parser.add_argument("--marker_length", type=float, default=None,
                        help="ArUco marker side length in mm (default: 0.6 * square_length)")
    parser.add_argument("--dictionary", type=str, default="DICT_6X6_250",
                        help="ArUco dictionary (default: DICT_6X6_250)")
    parser.add_argument("-o", "--out", type=str, default="calibration_results",
                        help="Output basename (default: calibration_results)")
    parser.add_argument("--fix_principal_point", action="store_true",
                        help="Fix principal point at image centre")
    parser.add_argument("--sample", type=str, default=None,
                        help="Sample image to undistort (optional)")
    parser.add_argument("--stereo", action="store_true",
                        help="Stereo calibration (requires left/right subdirs in --images)")
    return parser.parse_args()


def get_aruco_dict(name: str):
    """Resolve ArUco dictionary name to OpenCV object."""
    dict_map = {
        "DICT_4X4_50": cv2.aruco.DICT_4X4_50,
        "DICT_4X4_100": cv2.aruco.DICT_4X4_100,
        "DICT_4X4_250": cv2.aruco.DICT_4X4_250,
        "DICT_4X4_1000": cv2.aruco.DICT_4X4_1000,
        "DICT_5X5_50": cv2.aruco.DICT_5X5_50,
        "DICT_5X5_100": cv2.aruco.DICT_5X5_100,
        "DICT_5X5_250": cv2.aruco.DICT_5X5_250,
        "DICT_5X5_1000": cv2.aruco.DICT_5X5_1000,
        "DICT_6X6_50": cv2.aruco.DICT_6X6_50,
        "DICT_6X6_100": cv2.aruco.DICT_6X6_100,
        "DICT_6X6_250": cv2.aruco.DICT_6X6_250,
        "DICT_6X6_1000": cv2.aruco.DICT_6X6_1000,
        "DICT_7X7_50": cv2.aruco.DICT_7X7_50,
        "DICT_7X7_100": cv2.aruco.DICT_7X7_100,
        "DICT_7X7_250": cv2.aruco.DICT_7X7_250,
        "DICT_7X7_1000": cv2.aruco.DICT_7X7_1000,
        "DICT_ARUCO_ORIGINAL": cv2.aruco.DICT_ARUCO_ORIGINAL,
    }
    if name not in dict_map:
        print(f"Unknown dictionary {name}. Using DICT_6X6_250.")
        name = "DICT_6X6_250"
    return cv2.aruco.getPredefinedDictionary(dict_map[name])


def load_images(path_pattern: str):
    """Load images from directory or glob pattern."""
    path = Path(path_pattern)
    if path.is_dir():
        patterns = ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.tiff"]
        files = []
        for p in patterns:
            files.extend(sorted(path.glob(p)))
            files.extend(sorted(path.glob(p.upper())))
    else:
        files = sorted(Path(".").parent.glob(path_pattern))
    if not files:
        print(f"No images found at: {path_pattern}")
        sys.exit(1)
    print(f"Found {len(files)} images")
    return files


def detect_charuco_board(
    images, dictionary, board, squares_x, squares_y
):
    """Detect ChArUco corners in all images."""
    all_corners = []
    all_ids = []
    valid_indices = []
    detector_params = cv2.aruco.DetectorParameters()

    for idx, img_path in enumerate(images):
        img = cv2.imread(str(img_path))
        if img is None:
            print(f"  [SKIP] Cannot read: {img_path.name}")
            continue

        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        marker_corners, marker_ids, _ = cv2.aruco.detectMarkers(
            gray, dictionary, parameters=detector_params
        )

        if marker_ids is None or len(marker_ids) < 4:
            print(f"  [SKIP] {img_path.name}: only {0 if marker_ids is None else len(marker_ids)} markers")
            continue

        # Refine marker detection
        cv2.aruco.refineDetectedMarkers(
            gray, board, marker_corners, marker_ids
        )

        # Interpolate chessboard corners from markers
        charuco_corners, charuco_ids = cv2.aruco.interpolateCornersCharuco(
            marker_corners, marker_ids, gray, board
        )

        if charuco_ids is None or len(charuco_ids) < 4:
            print(f"  [SKIP] {img_path.name}: too few ChArUco corners ({0 if charuco_ids is None else len(charuco_ids)})")
            continue

        all_corners.append(charuco_corners)
        all_ids.append(charuco_ids)
        valid_indices.append(idx)
        print(f"  [OK]   {img_path.name}: {len(charuco_ids)} corners, {len(marker_ids)} markers")

    if len(all_corners) < 5:
        print(f"\nERROR: Only {len(all_corners)} valid views (need >= 5). Add more images.")
        sys.exit(1)

    return all_corners, all_ids, valid_indices


def run_calibration(
    all_corners, all_ids, board, image_size, fix_principal_point
):
    """Run ChArUco calibration."""
    flags = 0
    if fix_principal_point:
        flags |= cv2.CALIB_FIX_PRINCIPAL_POINT

    print(f"\nRunning calibration with {len(all_corners)} views...")
    ret, mtx, dist, rvecs, tvecs = cv2.aruco.calibrateCameraCharuco(
        all_corners, all_ids, board, image_size, None, None, flags=flags
    )
    print(f"RMS reprojection error: {ret:.4f} pixels\n")
    return ret, mtx, dist, rvecs, tvecs


def save_results_json(ret, mtx, dist, rvecs, tvecs, image_size, out_path):
    """Save calibration results as JSON."""
    data = {
        "rms_error": float(ret),
        "image_size": list(image_size),
        "camera_matrix": mtx.tolist(),
        "distortion_coefficients": dist.tolist(),
        "per_view_errors": [],
    }
    with open(out_path, "w") as f:
        json.dump(data, f, indent=2)
    print(f"Saved: {out_path}")


def save_results_xml(ret, mtx, dist, image_size, out_path):
    """Save calibration results as OpenCV XML."""
    fs = cv2.FileStorage(out_path, cv2.FILE_STORAGE_WRITE)
    fs.write("rms_error", ret)
    fs.write("image_width", image_size[0])
    fs.write("image_height", image_size[1])
    fs.write("camera_matrix", mtx)
    fs.write("distortion_coefficients", dist)
    fs.release()
    print(f"Saved: {out_path}")


def compute_per_view_errors(all_corners, all_ids, rvecs, tvecs, mtx, dist, board):
    """Compute reprojection error for each view."""
    errors = []
    for i in range(len(all_corners)):
        img_points = all_corners[i].reshape(-1, 2)
        obj_points = board.matchImagePoints(all_corners[i], all_ids[i])

        proj_points, _ = cv2.projectPoints(
            obj_points, rvecs[i], tvecs[i], mtx, dist
        )
        proj_points = proj_points.reshape(-1, 2)
        error = np.sqrt(np.mean(np.sum((img_points - proj_points) ** 2, axis=1)))
        errors.append(float(error))
    return errors


def visualize_results(
    images, all_corners, all_ids, valid_indices, mtx, dist,
    valid_calib_indices, rvecs, tvecs, board, out_dir
):
    """Draw detected corners and save visualisation images."""
    viz_dir = out_dir / "visualisations"
    viz_dir.mkdir(parents=True, exist_ok=True)

    for i, img_idx in enumerate(valid_indices):
        if i >= len(valid_calib_indices):
            break
        img = cv2.imread(str(images[img_idx]))
        if img is None:
            continue

        # Draw detected ChArUco corners
        cv2.aruco.drawDetectedCornersCharuco(
            img, all_corners[i], all_ids[i]
        )

        # Draw axes
        cv2.drawFrameAxes(
            img, mtx, dist, rvecs[i], tvecs[i], 0.03
        )

        out_file = viz_dir / f"frame_{img_idx:03d}.jpg"
        cv2.imwrite(str(out_file), img)

    print(f"Visualisations saved to: {viz_dir}/")


def undistort_sample(sample_path, mtx, dist, out_dir):
    """Undistort a sample image."""
    img = cv2.imread(sample_path)
    if img is None:
        print(f"Cannot read sample image: {sample_path}")
        return

    h, w = img.shape[:2]
    new_mtx, roi = cv2.getOptimalNewCameraMatrix(mtx, dist, (w, h), 1, (w, h))

    # Method 1: cv.undistort
    undistorted = cv2.undistort(img, mtx, dist, None, new_mtx)
    x, y, w_roi, h_roi = roi
    if w_roi > 0 and h_roi > 0:
        undistorted = undistorted[y : y + h_roi, x : x + w_roi]

    out_path = out_dir / "undistorted_sample.jpg"
    cv2.imwrite(str(out_path), undistorted)

    # Method 2: remap (for video)
    mapx, mapy = cv2.initUndistortRectifyMap(
        mtx, dist, None, new_mtx, (w, h), cv2.CV_32FC1
    )
    remapped = cv2.remap(img, mapx, mapy, cv2.INTER_LINEAR)
    remap_path = out_dir / "undistorted_remap_sample.jpg"
    cv2.imwrite(str(remap_path), remapped)

    print(f"Undistorted samples saved to: {out_dir}/")
    print(f"New camera matrix (with alpha=1):\n{new_mtx}")


def stereo_calibrate(images_dir: Path, board, square_length, dictionary, out_dir):
    """Run stereo ChArUco calibration."""
    left_dir = images_dir / "left"
    right_dir = images_dir / "right"

    if not left_dir.is_dir() or not right_dir.is_dir():
        print("ERROR: Stereo mode requires 'left/' and 'right/' subdirectories.")
        sys.exit(1)

    left_images = load_images(str(left_dir))
    right_images = load_images(str(right_dir))

    print(f"\nStereo calibration with {len(left_images)} left / {len(right_images)} right images")

    # Detect separately
    left_corners, left_ids, left_idx = detect_charuco_board(
        left_images, dictionary, board, board.getChessboardSize()[0], board.getChessboardSize()[1]
    )
    right_corners, right_ids, right_idx = detect_charuco_board(
        right_images, dictionary, board, board.getChessboardSize()[0], board.getChessboardSize()[1]
    )

    # Match pairs by index
    paired_indices = set(left_idx) & set(right_idx)
    if len(paired_indices) < 3:
        print(f"ERROR: Only {len(paired_indices)} matching stereo pairs (need >= 3).")
        sys.exit(1)

    paired_indices = sorted(paired_indices)
    left_matched = [left_corners[left_idx.index(i)] for i in paired_indices]
    left_ids_matched = [left_ids[left_idx.index(i)] for i in paired_indices]
    right_matched = [right_corners[right_idx.index(i)] for i in paired_indices]
    right_ids_matched = [right_ids[right_idx.index(i)] for i in paired_indices]

    # Get object points
    obj_points = []
    for i in range(len(paired_indices)):
        _, obj_pts, _ = board.matchImagePoints(left_corners_flat(left_matched[i], left_ids_matched[i]))
        obj_points.append(obj_pts)

    # Stereo calibration
    criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 100, 1e-5)
    flags = cv2.CALIB_FIX_INTRINSIC
    ret, _, _, _, _, R, T, E, F = cv2.stereoCalibrate(
        obj_points,
        left_matched,
        right_matched,
        None, None, None, None,
        (0, 0),
        criteria=criteria,
        flags=flags,
    )

    print(f"\nStereo RMS: {ret:.4f} pixels")
    print(f"Rotation matrix:\n{R}")
    print(f"Translation vector:\n{T}")

    # Stereo rectify
    R1, R2, P1, P2, Q, _, _ = cv2.stereoRectify(
        None, None, None, None, (0, 0), R, T, alpha=0
    )

    # Save stereo results
    stereo_out = out_dir / "stereo_calibration.json"
    data = {
        "rms_error": float(ret),
        "rotation_matrix": R.tolist(),
        "translation_vector": T.tolist(),
        "essential_matrix": E.tolist(),
        "fundamental_matrix": F.tolist(),
    }
    with open(stereo_out, "w") as f:
        json.dump(data, f, indent=2)
    print(f"\nStereo results saved to: {stereo_out}")

    return ret, R, T, E, F


def left_corners_flat(corners, ids):
    """Helper to flatten charuco corners and return (corners_flat, ids_flat, obj_points)."""
    return corners, ids


def main():
    args = parse_args()

    # Resolve paths
    images_dir = Path(args.images)
    out_dir = Path(args.out)
    out_dir.mkdir(parents=True, exist_ok=True)

    # Board parameters
    squares_x = args.squares_x
    squares_y = args.squares_y
    square_length = args.square_length / 1000.0  # convert mm to metres
    marker_length = args.marker_length
    if marker_length is None:
        marker_length = square_length * 0.6
    else:
        marker_length = marker_length / 1000.0

    # Create dictionary and board
    dictionary = get_aruco_dict(args.dictionary)
    board = cv2.aruco.CharucoBoard(
        (squares_x, squares_y),
        square_length,
        marker_length,
        dictionary,
    )

    print(f"ChArUco board: {squares_x}x{squares_y}, "
          f"square={args.square_length}mm, marker={args.square_length * (0.6 if args.marker_length is None else args.marker_length / args.square_length):.1f}mm")
    print(f"Dictionary: {args.dictionary}")
    print()

    if args.stereo:
        stereo_calibrate(images_dir, board, args.square_length, dictionary, out_dir)
        return

    # Load and detect
    images = load_images(str(images_dir))
    all_corners, all_ids, valid_indices = detect_charuco_board(
        images, dictionary, board, squares_x, squares_y
    )

    # Get image size from first valid image
    img = cv2.imread(str(images[valid_indices[0]]))
    image_size = (img.shape[1], img.shape[0])

    # Run calibration
    ret, mtx, dist, rvecs, tvecs = run_calibration(
        all_corners, all_ids, board, image_size, args.fix_principal_point
    )

    # Per-view errors
    errors = compute_per_view_errors(
        all_corners, all_ids, rvecs, tvecs, mtx, dist, board
    )
    print("Per-view reprojection errors:")
    for i, (idx, err) in enumerate(zip(valid_indices, errors)):
        marker = "!" if err > 0.8 else ""
        print(f"  [{i:2d}] {images[idx].name}: {err:.3f} px {marker}")
    print()

    # Camera matrix summary
    print(f"Camera matrix:\n{mtx}")
    print(f"\nDistortion coefficients: {dist.ravel()}")
    fx = mtx[0, 0]
    fy = mtx[1, 1]
    cx = mtx[0, 2]
    cy = mtx[1, 2]
    print(f"\nFocal length: fx={fx:.1f}, fy={fy:.1f}")
    print(f"Principal point: cx={cx:.1f}, cy={cy:.1f}")
    print(f"Image size: {image_size[0]}x{image_size[1]}")

    # Sanity checks
    print(f"\n--- Sanity Checks ---")
    if fx > image_size[0] * 0.5 and fx < image_size[0] * 5:
        print("[OK] Focal length X looks reasonable")
    else:
        print("[WARN] Focal length X seems unusual")
    if abs(cx - image_size[0] / 2) < image_size[0] * 0.2:
        print("[OK] Principal point X is near centre")
    else:
        print("[WARN] Principal point X is far from centre")
    if abs(cy - image_size[1] / 2) < image_size[1] * 0.2:
        print("[OK] Principal point Y is near centre")
    else:
        print("[WARN] Principal point Y is far from centre")
    if ret < 0.5:
        print("[OK] RMS error is excellent (< 0.5 px)")
    elif ret < 0.8:
        print("[OK] RMS error is acceptable (< 0.8 px)")
    else:
        print("[WARN] RMS error is high (>= 0.8 px) — consider adding/improving images")
    print()

    # Save
    json_path = out_dir / f"{args.out}.json"
    xml_path = out_dir / f"{args.out}.xml"
    save_results_json(ret, mtx, dist, rvecs, tvecs, image_size, json_path)
    save_results_xml(ret, mtx, dist, image_size, xml_path)

    # Visualise
    visualize_results(
        images, all_corners, all_ids, valid_indices,
        mtx, dist, list(range(len(all_corners))),
        rvecs, tvecs, board, out_dir
    )

    # Undistort sample
    if args.sample:
        undistort_sample(args.sample, mtx, dist, out_dir)

    print(f"\nAll results saved to: {out_dir.resolve()}")


if __name__ == "__main__":
    main()

Accuracy Reference

Recommended board configurations for different accuracy needs:

Square (mm)A4 fit?A3 fit?Board size (mm)Recommended use
25175×125High accuracy, close range
30180×120Standard calibration
40200×120Large board, balanced
50200×150Webcam / far distance

Downloads

Get the calibration script and Jupyter notebook for offline use.

Tips for Best Results

  • Use a board with at least 10×7 squares for best accuracy.
  • Ensure the board is perfectly flat — mounting on glass or acrylic works best.
  • Avoid motion blur — use a tripod or fast shutter speed.
  • Capture images at the same resolution you will use in your application.
  • Remove any images where the board is not fully visible.
  • The square-to-marker ratio should be ~2:1 (default marker length = 0.6 × square length).