Calibración de cámara con un tablero ChArUco en OpenCV
Para estimar la pose de un marcador, medir distancias en una imagen o publicar una cámara en ROS 2 necesitas conocer sus parámetros intrínsecos: la distancia focal en píxeles, el centro óptico y la distorsión del lente. Esos valores salen de una calibración, y la forma más cómoda de hacerla hoy con OpenCV es con un tablero ChArUco. Un tablero ChArUco es un tablero de ajedrez con un marcador ArUco dentro de cada cuadro blanco. Las esquinas del ajedrez dan la precisión subpíxel de siempre y los marcadores le dicen al detector qué esquina es cuál. Gracias a eso el tablero no tiene que verse completo en cada foto, y eso nos va a servir más adelante para cubrir las orillas de la imagen.
Imprimir el tablero a escala
En el generador de marcadores de Ingenium Hub abre la pestaña Tablero ChArUco. Los valores por defecto son 7 × 5 cuadros de 30 mm con marcadores de 22 mm. Para un tablero así se necesitan 17 marcadores, uno por cada cuadro blanco, y cualquier diccionario ArUco alcanza; aquí usamos ArUco 6×6, que en OpenCV es DICT_6X6_1000. La casilla de patrón legado solo hace falta si vas a calibrar con un tablero hecho con OpenCV anterior a 4.6. Deja activada la regla de verificación. Baja el SVG o imprime directo, siempre con escala al 100 % o «Tamaño real», nunca con «Ajustar a la página». Usa papel mate para evitar reflejos y pega la hoja sobre algo plano y rígido, como vidrio, acrílico o MDF. Una hoja ondulada mete un error que ninguna cantidad de fotos corrige.
Medir lo que salió de la impresora
Con una regla o un vernier mide la regla impresa y el lado de un cuadro. Ese valor medido es el que va en el script, no el que pediste en el generador. Aquí hay un detalle que vale la pena entender. Si la impresora escaló la hoja igual en ambos ejes, la matriz K y la distorsión no cambian, porque la calibración intrínseca no depende de la escala del tablero. Lo comprobamos en el sandbox: con las mismas fotos, calibrar con un cuadro de 30 mm o de 29.4 mm da exactamente la misma K. Lo que sí cambia es la traslación de cada pose, que se escala en la misma proporción (0.4004 m contra 0.3924 m en la primera foto, un 2 %). Si después vas a medir distancias o poses, el tamaño real importa. El caso que sí afecta a K es una escala distinta en cada eje. Al estirar 1 % solo el eje x del tablero, y se movieron alrededor de 4 px y el error de reproyección subió. Por eso conviene medir la regla en las dos direcciones de la hoja.
Tomar las fotos
Toma entre 20 y 30 fotos con la misma resolución que vas a usar después. Si la cámara tiene autoenfoque o zoom, déjalos fijos: cada cambio de enfoque es, en la práctica, otra cámara. Algunas reglas que sí se notan en el resultado:
- Mueve el tablero por toda la imagen, incluidas las esquinas y las orillas.
- Inclínalo hacia los lados y hacia arriba y abajo; las vistas de frente aportan poco a la focal.
- Cambia la distancia entre fotos.
- Evita fotos movidas. Con poca luz es mejor apoyar la cámara.
La primera regla es la que más se olvida. Para medirla generamos 25 vistas sintéticas de este mismo tablero con una cámara inventada de 1280 × 720, , y distorsión de barril marcada, y calibramos dos veces. En la primera solo aceptamos vistas con el tablero completo dentro del cuadro, que es lo que obligaría un tablero de ajedrez normal. En la segunda dejamos que el tablero se saliera parcialmente por las orillas.

Con el tablero siempre completo, las detecciones cubrieron el 39 % de la imagen. El error de la corrección, comparado contra la cámara verdadera, llegó a 37 px en las esquinas, donde no hubo datos. Con vistas parciales la cobertura subió al 56 % y el error máximo bajó a 5.6 px.

El script de calibración
El generador entrega un fragmento de código listo para tu tablero. Esta es una versión un poco más larga que además reporta el error de cada foto y escribe el archivo para ROS 2. Pon las fotos en una carpeta fotos/ y ajusta las medidas.
# Calibración de cámara con un tablero ChArUco (OpenCV 4.8 o posterior)
import glob
import sys
import cv2
import numpy as np
CARPETA = sys.argv[1] if len(sys.argv) > 1 else "fotos"
# Datos del tablero: columnas x filas y medidas REALES de la impresión, en metros
COLUMNAS, FILAS = 7, 5
CUADRO = 0.030 # lado del cuadro negro medido con regla
MARCADOR = 0.022 # lado del marcador ArUco medido con regla
diccionario = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_6X6_1000)
tablero = cv2.aruco.CharucoBoard((COLUMNAS, FILAS), CUADRO, MARCADOR, diccionario)
# tablero.setLegacyPattern(True) # solo si imprimiste el patrón legado
detector = cv2.aruco.CharucoDetector(tablero)
rutas = sorted(glob.glob(f"{CARPETA}/*.png") + glob.glob(f"{CARPETA}/*.jpg"))
puntos_obj, puntos_img, nombres, tam = [], [], [], None
for ruta in rutas:
gris = cv2.imread(ruta, cv2.IMREAD_GRAYSCALE)
if tam is None:
tam = gris.shape[::-1] # (ancho, alto)
elif gris.shape[::-1] != tam:
raise ValueError(f"{ruta} tiene otra resolución; usa una sola")
esquinas, ids, _, _ = detector.detectBoard(gris)
if ids is None or len(ids) < 6:
print(f"{ruta}: muy pocas esquinas, se descarta")
continue
obj, img = tablero.matchImagePoints(esquinas, ids)
puntos_obj.append(obj)
puntos_img.append(img)
nombres.append(ruta)
print(f"{len(nombres)} de {len(rutas)} fotos útiles")
rms, K, dist, rvecs, tvecs = cv2.calibrateCamera(puntos_obj, puntos_img, tam, None, None)
np.set_printoptions(precision=4, suppress=True)
print(f"Error de reproyección RMS: {rms:.3f} px")
print("K =\n", K)
print("dist (k1 k2 p1 p2 k3) =", dist.ravel())
for nombre, o, i, r, t in zip(nombres, puntos_obj, puntos_img, rvecs, tvecs):
proy, _ = cv2.projectPoints(o, r, t, K, dist)
e = np.sqrt(np.mean(np.sum((proy - i) ** 2, axis=2)))
print(f" {nombre}: {e:.3f} px, {len(i)} esquinas")
nuevaK, _ = cv2.getOptimalNewCameraMatrix(K, dist, tam, 0)
P = np.hstack([nuevaK, np.zeros((3, 1))])
lista = lambda m: "[" + ", ".join(f"{v:.6f}" for v in np.ravel(m)) + "]"
yaml = f"""image_width: {tam[0]}
image_height: {tam[1]}
camera_name: mi_camara
camera_matrix:
rows: 3
cols: 3
data: {lista(K)}
distortion_model: plumb_bob
distortion_coefficients:
rows: 1
cols: 5
data: {lista(dist.ravel()[:5])}
rectification_matrix:
rows: 3
cols: 3
data: {lista(np.eye(3))}
projection_matrix:
rows: 3
cols: 4
data: {lista(P)}
"""
with open("mi_camara.yaml", "w") as f:
f.write(yaml)
print("Guardado mi_camara.yaml")
Sobre las versiones de OpenCV hay dos cosas que comprobamos instalando cada paquete. CharucoDetector y matchImagePoints aparecieron en 4.7, pero en 4.7.0 matchImagePoints interpretaba mal las esquinas ChArUco desde Python y la calibración daba un RMS de 226 px con las mismas fotos; se corrigió en 4.8 (PR #23138). Con 4.8.0, 4.10.0 y 5.0.0 el script da resultados equivalentes, con un RMS de 0.28 px en los tres. La función vieja cv2.aruco.calibrateCameraCharuco sigue en los paquetes 4.x con contrib, pero ya no existe en 5.0, así que el camino de matchImagePoints y calibrateCamera es el que vale la pena aprender.
Qué significan K, la distorsión y el RMS
OpenCV usa el modelo de cámara estenopeica. Un punto en el marco de la cámara se normaliza a , , se distorsiona y se convierte a píxeles con la matriz K:
y son la distancia focal expresada en píxeles y es el punto principal, que casi nunca cae justo en el centro geométrico. El vector de distorsión plumb_bob tiene cinco términos, . Con , la parte radial es
y algo equivalente para . Un negativo es distorsión de barril, la típica de lentes gran angular; y modelan un lente ligeramente inclinado respecto al sensor. En la prueba sintética con vistas parciales, la calibración recuperó esto:
| Parámetro | Cámara verdadera | Estimado |
|---|---|---|
| , (px) | 920, 915 | 921.6, 916.3 |
| , (px) | 652, 351 | 650.0, 352.7 |
| , | −0.32, 0.12 | −0.321, 0.124 |
| RMS (px) | 0.283 |
El error RMS es la distancia promedio, en píxeles, entre las esquinas detectadas y las mismas esquinas reproyectadas con el modelo calibrado:
Mide qué tan bien el modelo explica las fotos que tomaste, no qué tan bien va a funcionar en el resto de la imagen. En nuestra prueba, la calibración con tableros completos tuvo un RMS más bajo (0.223 px) que la de vistas parciales (0.283 px), y aun así era la peor de las dos en las orillas. Un RMS bajo con poca cobertura no garantiza nada. Revisa también el error por foto que imprime el script: si una sola foto está muy por encima de las demás, quítala y vuelve a calibrar.
Usar la calibración en ROS 2
El YAML que escribe el script sigue el formato de camera_calibration_parsers, el mismo que lee camera_info_manager. La mayoría de los drivers de cámara aceptan un parámetro camera_info_url, por ejemplo file:///home/tu_usuario/mi_camara.yaml, y con eso publican el tópico camera_info con tus valores. La matriz de proyección P se calcula con getOptimalNewCameraMatrix y alpha = 0, que recorta la imagen rectificada para que no queden bordes negros. Cargar la calibración no corrige la imagen por sí solo. Para obtener image_rect necesitas pasar la imagen por image_proc, y es esa imagen rectificada, junto con camera_info, la que espera un detector como apriltag_ros. Si vas a usar marcadores fiduciales, en el artículo sobre AprilTag y ArUco explicamos cómo escoger familia y tamaño.
Cómo probamos el script
Además de la versión del generador, corrimos el script completo sobre las 25 vistas sintéticas. Las generamos con OpenCV a partir de la imagen del tablero (CharucoBoard.generateImage), una homografía por pose, la distorsión aplicada con remap, un desenfoque leve y ruido. Como conocemos la cámara verdadera, pudimos comparar el resultado contra ella y no solo contra el RMS. Las cifras de este artículo salen de esas pruebas; con una cámara real los valores serán otros, pero el procedimiento y la forma de leerlos son los mismos.
Referencias
- Generador de AprilTag, ArUco y ChArUco de Ingenium Hub: https://ingeniumhub.org/es/herramientas/generador-de-tags
- OpenCV, Calibration with ArUco and ChArUco: https://docs.opencv.org/4.x/da/d13/tutorial_aruco_calibration.html
- OpenCV, Detection of ChArUco Boards: https://docs.opencv.org/4.x/df/d4a/tutorial_charuco_detection.html
- OpenCV, PR #23138 «fix charuco matchImagePoints»: https://github.com/opencv/opencv/pull/23138
- OpenCV, Camera Calibration and 3D Reconstruction (modelo de cámara y distorsión): https://docs.opencv.org/4.x/d9/d0c/group__calib3d.html
- ROS 2, camera_calibration, Monocular Calibration: https://docs.ros.org/en/rolling/p/camera_calibration/doc/tutorial_mono.html
- camera_calibration_parsers, formato YAML: https://github.com/ros-perception/image_common/blob/rolling/camera_calibration_parsers/src/parse_yml.cpp