Visión por computador con OpenCV: el pipeline OMR
¿Qué es?
La visión por computador es el campo que enseña a las máquinas a "ver": extraer información útil de una imagen o un video, como si fueran ojos digitales. Para el computador una foto no es más que una rejilla de números (los píxeles), así que "ver" significa hacer cálculos sobre esos números. OpenCV es la librería de Python con la que haremos ese trabajo: convierte cada foto en datos manipulables y trae cientos de operaciones listas para usar. En este curso la usamos para leer hojas de respuestas a partir de una simple foto.
¿Cómo funciona?
Una imagen entra como una rejilla de píxeles y se transforma paso a paso hasta dejar solo la información que nos importa. Se aplican operaciones en cadena: pasar a escala de grises, separar lo oscuro de lo claro, enderezar la hoja y localizar las formas (contornos) que buscamos. Cada paso recibe una imagen y devuelve otra un poco más limpia, hasta que el resultado es fácil de interpretar con código. A esa cadena ordenada de transformaciones la llamamos un pipeline.
¿Para qué sirve?
Sirve para automatizar cualquier tarea que hoy hace un humano mirando: contar objetos, leer texto, detectar caras o revisar marcas en un papel. En nuestro proyecto final, el Calificador de exámenes con la cámara (OMR), le tomamos una foto a una hoja de respuestas y el programa decide qué burbuja rellenó cada estudiante y la compara con la plantilla correcta. Lo que antes era calificar a mano, examen por examen, se vuelve una foto y un clic. Todo lo que viene a continuación construye, paso a paso, ese pipeline.
¿Qué es OpenCV?
OpenCV (Open Source Computer Vision) es la librería más usada para procesar imágenes y video en Python. Se importa como cv2. Lee imágenes como arrays de NumPy y ofrece cientos de operaciones: convertir a gris, umbralizar, detectar bordes y contornos, corregir perspectiva. Es el corazón de nuestro proyecto final.
¿Cómo funciona?
OpenCV trabaja sobre el array de píxeles que ya conoces. El pipeline OMR (lectura de hojas de respuestas) es una secuencia de transformaciones:
- Cargar la foto y pasarla a escala de grises.
- Umbralizar: convertir a blanco y negro puro para separar marca de papel.
- Encontrar la hoja y corregir la perspectiva (vista cenital, "de frente").
- Detectar las burbujas (contornos) y medir cuál está rellena.
- Comparar con la plantilla y calificar.
Cada paso recibe un array y devuelve otro. Construyamos el pipeline completo.
Necesitas una imagen real para probar. Usa una foto de una hoja de respuestas y guárdala como hoja.jpg junto a tu script. Si no tienes una, sirve cualquier foto con marcas oscuras sobre fondo claro para seguir los pasos.
Paso 1: cargar y pasar a gris
import cv2
img = cv2.imread("hoja.jpg") # BGR, array (alto, ancho, 3)
if img is None:
raise FileNotFoundError("No encuentro hoja.jpg")
gris = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # ahora es 2D
print(gris.shape) # (alto, ancho)
OpenCV carga las imágenes en orden BGR (azul, verde, rojo), no RGB. Si las muestras con matplotlib sin convertir, los colores salen invertidos. Convierte con cv2.cvtColor(img, cv2.COLOR_BGR2RGB) antes de imshow.
Paso 2: umbralización (Otsu)
Convertimos a blanco y negro puro. Otsu elige automáticamente el umbral (el valle del histograma que vimos en visualización). Usamos THRESH_BINARY_INV para que las marcas oscuras queden en blanco (255) y el papel en negro: así lo "interesante" tiene valor alto.
import cv2
gris = cv2.cvtColor(cv2.imread("hoja.jpg"), cv2.COLOR_BGR2GRAY)
# Un desenfoque leve quita ruido antes de umbralizar
gris = cv2.GaussianBlur(gris, (5, 5), 0)
umbral, binaria = cv2.threshold(
gris, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU
)
print("Umbral elegido por Otsu:", umbral)
cv2.imwrite("debug_binaria.png", binaria) # revísala con tus ojos
Guarda imágenes intermedias con cv2.imwrite y ábrelas (o usa matplotlib del módulo anterior). Depurar el pipeline mirando cada paso es lo que más tiempo te ahorra.
Paso 3: encontrar la hoja y corregir la perspectiva
Detectamos el contorno más grande (la hoja) y lo "enderezamos" a una vista cenital. Esto hace que las burbujas queden siempre en la misma posición sin importar el ángulo de la foto.
import cv2
import numpy as np
def ordenar_puntos(pts):
"""Ordena 4 puntos: superior-izq, superior-der, inferior-der, inferior-izq."""
pts = pts.reshape(4, 2)
orden = np.zeros((4, 2), dtype="float32")
suma = pts.sum(axis=1)
orden[0] = pts[np.argmin(suma)] # menor x+y -> arriba-izquierda
orden[2] = pts[np.argmax(suma)] # mayor x+y -> abajo-derecha
dif = np.diff(pts, axis=1)
orden[1] = pts[np.argmin(dif)] # menor y-x -> arriba-derecha
orden[3] = pts[np.argmax(dif)] # mayor y-x -> abajo-izquierda
return orden
gris = cv2.cvtColor(cv2.imread("hoja.jpg"), cv2.COLOR_BGR2GRAY)
bordes = cv2.Canny(gris, 75, 200)
contornos, _ = cv2.findContours(bordes, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
contornos = sorted(contornos, key=cv2.contourArea, reverse=True)
hoja = None
for c in contornos:
perim = cv2.arcLength(c, True)
aprox = cv2.approxPolyDP(c, 0.02 * perim, True) # simplifica a polígono
if len(aprox) == 4: # 4 esquinas = la hoja
hoja = aprox
break
if hoja is not None:
src = ordenar_puntos(hoja)
ancho, alto = 600, 800
dst = np.array([[0, 0], [ancho-1, 0],
[ancho-1, alto-1], [0, alto-1]], dtype="float32")
M = cv2.getPerspectiveTransform(src, dst)
enderezada = cv2.warpPerspective(gris, M, (ancho, alto))
cv2.imwrite("debug_enderezada.png", enderezada)
Tech English: threshold = umbral; contour = contorno; warp / perspective transform = transformación de perspectiva; bounding box = caja delimitadora; grayscale = escala de grises.
Paso 4: detectar burbujas y medir cuál está rellena
Sobre la imagen enderezada y binarizada, buscamos los contornos circulares (las burbujas), los agrupamos por pregunta y, para cada uno, contamos píxeles blancos dentro de una máscara. La burbuja con más píxeles blancos es la marcada (recuerda: invertimos, así que marca = blanco).
import cv2
import numpy as np
# Partimos de 'enderezada' (gris). La umbralizamos invertida.
enderezada = cv2.imread("debug_enderezada.png", cv2.IMREAD_GRAYSCALE)
_, bin_e = cv2.threshold(enderezada, 0, 255,
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
contornos, _ = cv2.findContours(bin_e, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
burbujas = []
for c in contornos:
(x, y, w, h) = cv2.boundingRect(c)
proporcion = w / float(h)
# Una burbuja es ~cuadrada y de cierto tamaño
if 20 <= w <= 60 and 20 <= h <= 60 and 0.8 <= proporcion <= 1.2:
burbujas.append((x, y, w, h))
print("Burbujas detectadas:", len(burbujas))
def relleno(bin_img, caja):
x, y, w, h = caja
region = bin_img[y:y+h, x:x+w]
return cv2.countNonZero(region) # nº de píxeles blancos (marca)
# Ejemplo: ordenar una fila de burbujas por X y elegir la más rellena
fila = sorted(burbujas, key=lambda b: b[0]) # izquierda a derecha = A,B,C,D
puntajes = [relleno(bin_e, b) for b in fila]
if puntajes:
marcada = int(np.argmax(puntajes)) # 0=A, 1=B, 2=C, 3=D
letra = ["A", "B", "C", "D"][marcada] if marcada < 4 else "?"
print("Opción marcada:", letra, "| puntajes:", puntajes)
Para una hoja completa: ordena todas las burbujas por fila (coordenada Y), divide en grupos de 4 (las opciones A–D de cada pregunta), y dentro de cada grupo ordena por X. Así reconstruyes pregunta por pregunta.
Si detecta cero burbujas, casi siempre es por los filtros de tamaño (w, h). Imprime los boundingRect de todos los contornos y ajusta los rangos a tu hoja real. No hay tamaño mágico universal: depende de la resolución de tu foto.
Paso 5: calificar contra la plantilla
plantilla = ["A", "C", "B", "D", "A"] # respuestas correctas
respuestas_alumno = ["A", "C", "B", "C", "A"] # salida del paso 4
aciertos = sum(r == c for r, c in zip(respuestas_alumno, plantilla))
nota = round(aciertos / len(plantilla) * 5, 1)
print(f"Aciertos: {aciertos}/{len(plantilla)} Nota: {nota}")
Esas respuestas y notas son exactamente lo que el módulo de Pandas convierte en el acta CSV.
Ejercicios
- Toma una foto real de una hoja de respuestas, corre los pasos 1 y 2, y abre
debug_binaria.png. Ajusta elGaussianBlury observa cómo cambia el ruido. Anota qué umbral eligió Otsu. - Sobre
bin_e, imprime elboundingRectde todos los contornos detectados y calibra los rangos dew/hhasta que el paso 4 detecte el número correcto de burbujas de tu hoja.