Juan Diego Andrés PRADA··RAMÍREZ Entrar
Lección 5 de 7

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:

  1. Cargar la foto y pasarla a escala de grises.
  2. Umbralizar: convertir a blanco y negro puro para separar marca de papel.
  3. Encontrar la hoja y corregir la perspectiva (vista cenital, "de frente").
  4. Detectar las burbujas (contornos) y medir cuál está rellena.
  5. 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

  1. Toma una foto real de una hoja de respuestas, corre los pasos 1 y 2, y abre debug_binaria.png. Ajusta el GaussianBlur y observa cómo cambia el ruido. Anota qué umbral eligió Otsu.
  2. Sobre bin_e, imprime el boundingRect de todos los contornos detectados y calibra los rangos de w/h hasta que el paso 4 detecte el número correcto de burbujas de tu hoja.
Tu progreso se guarda en este navegador. Inicia sesión para guardarlo en tu cuenta y verlo desde cualquier dispositivo.