¿Alguna vez te has preguntado cómo funcionan esos sistemas que leen automáticamente las placas de los autos en casetas de peaje, estacionamientos o cámaras de tránsito? La respuesta está en una disciplina fascinante llamada Visión por Computadora, y en este artículo vamos a construir uno de esos sistemas desde cero.
La detección automática de matrículas (conocida en inglés como License Plate Recognition o LPR) es uno de los casos de uso más clásicos y prácticos del procesamiento de imágenes. Desde sistemas de seguridad hasta administración de flotas vehiculares, esta tecnología está en todas partes, y lo mejor de todo es que los fundamentos son accesibles con herramientas de código abierto como OpenCV.
En este notebook vamos a recorrer el proceso completo paso a paso:
No necesitas ser experto en inteligencia artificial para seguir este tutorial. Si tienes nociones básicas de Python y curiosidad por entender cómo las máquinas "ven" el mundo, este es tu punto de partida. ¡Comencemos!
import cv2 as cv # OpenCV: librería principal de visión por computadora
import matplotlib.pyplot as plt # Para visualizar imágenes dentro del notebook
import numpy as np # Para operaciones matriciales (las imágenes son matrices de píxeles)
OpenCV puede cargar imágenes en dos modos principales:
(alto, ancho, 3 canales)(alto, ancho)Nota: OpenCV usa el orden de canales BGR (Azul-Verde-Rojo), no RGB. Por eso al visualizar con
matplotliblos colores pueden verse invertidos si no se hace la conversión.
# Carga la imagen en modo color (3 canales: B, G, R)
img01 = cv.imread('resources/Cars96.png')
# Carga la misma imagen en escala de grises (1 canal)
img02 = cv.imread('resources/Cars96.png', cv.IMREAD_GRAYSCALE)
# Extraemos las dimensiones de cada versión
alto1, ancho1, canales1 = img01.shape # shape devuelve (alto, ancho, canales)
alto2, ancho2 = img02.shape # En grises no hay dimensión de canal
print("Imagen color → alto:", alto1, "| ancho:", ancho1, "| canales:", canales1)
print("Imagen en gris → alto:", alto2, "| ancho:", ancho2)
# Visualizamos la imagen (matplotlib espera formato RGB, OpenCV usa BGR)
plt.title("img01 — Imagen original")
plt.imshow(img01)
plt.show()
Un kernel (o filtro de convolución) es una pequeña matriz que se desliza sobre la imagen para transformarla. El kernel que usamos aquí es conocido como filtro Laplaciano, y su efecto es resaltar los bordes y cambios bruscos de intensidad.
[-1, -1, -1]
[-1, 8, -1] → El valor central 8 amplifica el píxel actual,
[-1, -1, -1] mientras los vecinos negativos restan su influencia.
Suma total del kernel = 0, lo que garantiza que regiones uniformes queden en negro.
# Cargamos la imagen en escala de grises para aplicar el filtro
car_grey = cv.imread('resources/Cars3.png', cv.IMREAD_GRAYSCALE)
# Definimos manualmente un kernel Laplaciano 3x3 para resaltar bordes
kernel = np.array([[-1, -1, -1],
[-1, 8, -1],
[-1, -1, -1]])
# Aplicamos la convolución: cada píxel se reemplaza por la suma ponderada de sus vecinos
# ddepth=-1 significa que la imagen de salida tendrá el mismo tipo que la entrada
img_ker = cv.filter2D(src=car_grey, ddepth=-1, kernel=kernel)
# Mostramos la imagen original (sin el filtro aplicado) para comparación
plt.title('Imagen base en gris (antes del kernel)')
plt.imshow(car_grey, cmap='gray')
plt.show()
El filtro Gaussiano reduce el ruido de la imagen promediando los píxeles con sus vecinos, dando más peso a los píxeles cercanos al centro. Es el paso previo ideal antes de la detección de bordes, ya que evita que el ruido sea detectado como borde falso.
El parámetro (9, 9) indica el tamaño del kernel (9×9 píxeles). A mayor tamaño, mayor suavizado.
# Cargamos Cars296 en escala de grises
Cars296 = cv.imread('resources/Cars296.png', cv.IMREAD_GRAYSCALE)
plt.title('Cars296.png — Imagen original en gris')
plt.imshow(Cars296, cmap='gray')
plt.show()
# Aplicamos el filtro Gaussiano para suavizar la imagen
# (9, 9) = tamaño del kernel | 10 = desviación estándar (sigma) en ambos ejes
# A mayor sigma, mayor difuminado
kernel = cv.GaussianBlur(Cars296, (9, 9), 10)
# Mostramos la imagen original para comparar después
plt.title('Cars296.png — Comparación antes del filtro')
plt.imshow(Cars296, cmap='gray')
plt.show()
# Cargamos la imagen en color (formato BGR de OpenCV)
car_color = cv.imread('resources/Cars296.png')
plt.title('Cars296 — Imagen original a color')
plt.imshow(car_color)
plt.show()
# Separamos la imagen en sus 3 canales de color: Azul (b), Verde (g), Rojo (r)
b, g, r = cv.split(car_color)
# Ecualizamos el histograma de cada canal de forma independiente
# Esto amplía el rango dinámico de cada color, mejorando el contraste
b_eq = cv.equalizeHist(b)
g_eq = cv.equalizeHist(g)
r_eq = cv.equalizeHist(r)
# Recombinamos los canales ya ecualizados en una sola imagen a color
img_eq = cv.merge((b_eq, g_eq, r_eq))
plt.title('Imagen con histograma ecualizado por canal')
plt.imshow(img_eq)
plt.show()
Aquí construimos paso a paso el flujo completo de procesamiento para detectar la placa de un vehículo en una sola imagen. Este pipeline se convertirá más adelante en un proceso repetible para múltiples imágenes.
El flujo sigue esta secuencia:
Imagen original → Escala de grises → Suavizado → Contraste → Binarización → Bordes → Contornos → Detección
# Cargamos directamente en escala de grises con el flag IMREAD_GRAYSCALE
# Equivale a cargar en color y luego hacer cv.cvtColor(img, cv.COLOR_BGR2GRAY)
car_gray = cv.imread('resources/Cars11.png', cv.IMREAD_GRAYSCALE)
# Kernel (11, 11): ventana de 11x11 píxeles. Debe ser impar para tener un centro definido.
# Sigma = 0: OpenCV calcula automáticamente la desviación estándar óptima a partir del tamaño del kernel
blured_car = cv.GaussianBlur(car_gray, (11, 11), 0)
plt.title('Paso 2 — Imagen suavizada (Gaussian Blur)')
plt.imshow(blured_car, cmap='gray')
plt.show()
CLAHE (Contrast Limited Adaptive Histogram Equalization) es una versión mejorada de la ecualización de histograma estándar. A diferencia de esta última, CLAHE:
tileGridSize) en lugar de la imagen completa.clipLimit) para evitar que el ruido sea amplificado.Esto es especialmente útil para imágenes con iluminación irregular, como fotos de autos bajo diferentes condiciones de luz.
# Creamos el objeto CLAHE con sus parámetros:
# clipLimit=1.5 → límite de amplificación de contraste (valores bajos = más conservador)
# tileGridSize=(8,8) → divide la imagen en una cuadrícula de 8x8 regiones
clahe = cv.createCLAHE(clipLimit=1.5, tileGridSize=(8, 8))
# Aplicamos CLAHE a la imagen suavizada
equ01 = clahe.apply(blured_car)
plt.title('Paso 3 — Contraste mejorado con CLAHE')
plt.imshow(equ01, cmap='gray')
plt.show()
La binarización convierte la imagen en solo dos valores: blanco (255) y negro (0). El método de Otsu calcula automáticamente el umbral óptimo analizando el histograma de la imagen: busca el valor que mejor separa los píxeles oscuros de los claros.
El resultado es una imagen donde las regiones de interés (bordes, textos, estructuras) aparecen claramente diferenciadas.
# cv.threshold devuelve dos valores:
# otsu_threshold → el umbral calculado automáticamente por Otsu
# otsu01 → la imagen binarizada resultante
# El 0 como umbral manual es ignorado cuando se usa THRESH_OTSU (Otsu lo calcula)
otsu_threshold, otsu01 = cv.threshold(equ01, 0, 255, cv.THRESH_OTSU)
print(f"Umbral calculado por Otsu: {otsu_threshold}")
plt.title('Paso 4 — Imagen binarizada (Otsu)')
plt.imshow(otsu01, cmap='gray')
plt.show()
El algoritmo de Canny es el detector de bordes más popular en visión por computadora. Funciona en varias etapas: suavizado, cálculo de gradientes, supresión de no-máximos y umbralización con histéresis (dos umbrales).
# Aplicamos Canny sobre la imagen ya binarizada
# threshold1=150 → umbral bajo | threshold2=350 → umbral alto
edges = cv.Canny(otsu01, 150, 350)
plt.title('Paso 5 — Bordes detectados con Canny')
plt.imshow(edges, cmap='gray')
plt.show()
Los contornos son curvas que unen los puntos continuos de un mismo borde. A partir de la imagen de Canny, extraemos todos los contornos externos y luego filtramos por longitud de perímetro para quedarnos solo con los que tienen un tamaño compatible con una placa vehicular.
# Extraemos contornos externos de la imagen de bordes
# RETR_EXTERNAL → solo contornos externos (ignora huecos internos)
# CHAIN_APPROX_SIMPLE → comprime segmentos rectos a sus extremos (ahorra memoria)
contours, hierarchy = cv.findContours(edges, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE)
# Filtramos por longitud de arco (perímetro aproximado del contorno)
# Solo nos quedamos con contornos cuyo perímetro esté entre 200 y 700 píxeles
# → Descarta objetos demasiado pequeños (ruido) o demasiado grandes (carrocería)
long_contours = [cnt for cnt in contours
if (cv.arcLength(cnt, True) > 200
and cv.arcLength(cnt, True) < 700)]
Para cada contorno candidato calculamos su bounding box (rectángulo mínimo envolvente) y aplicamos dos criterios geométricos:
prop < 0), ya que las placas son horizontales.Las regiones que cumplen ambos criterios se marcan con un rectángulo verde.
# Cargamos la imagen en color para dibujar los resultados visualmente
car_color = cv.imread('resources/Cars11.png')
alto, ancho, canales = car_color.shape
for lonc in long_contours:
# Extraemos las coordenadas x e y de todos los puntos del contorno
r = [x for [[x, y]] in lonc] # coordenadas horizontales
s = [y for [[x, y]] in lonc] # coordenadas verticales
# prop = diferencia entre alto y ancho del bounding box:
# prop < 0 → el rectángulo es más ancho que alto (forma de placa ✓)
# prop >= 0 → el rectángulo es más alto que ancho (descartado ✗)
prop = (max(s) - min(s)) - (max(r) - min(r))
# Criterios de selección:
# max(s) > alto/2 → el contorno está en la mitad inferior de la imagen
# prop < 0 → el contorno es más ancho que alto
if (max(s) > round(alto / 2)) and (prop < 0):
# Dibujamos un rectángulo verde sobre la región detectada como placa
cv.rectangle(car_color, (min(r), min(s)), (max(r), max(s)), (0, 255, 0), 3)
# Mostramos el resultado final con la(s) placa(s) marcadas
plt.title('Resultado — Placa(s) detectada(s)')
plt.imshow(car_color)
plt.show()
El pipeline anterior funciona para una imagen fija. Ahora lo generalizamos y mejoramos con criterios de discriminación más robustos para procesar automáticamente una lista de imágenes.
La clave de esta mejora es la función es_placa(), que aplica cuatro filtros geométricos combinados en lugar de solo dos, reduciendo significativamente los falsos positivos.
# Diccionario con las imágenes a procesar (índice → ruta del archivo)
imagenes = {
0: 'resources/Cars11.png',
1: 'resources/Cars129.png',
2: 'resources/Cars133.png',
3: 'resources/Cars14.png',
4: 'resources/Cars167.png'
}
# ──────────────────────────────────────────────────────────────────────
# PARÁMETROS DE DISCRIMINACIÓN — Ajustables según el dataset utilizado
# ──────────────────────────────────────────────────────────────────────
ASPECT_RATIO_MIN = 2.0 # Las placas son más anchas que altas: mínimo 2:1
ASPECT_RATIO_MAX = 5.5 # Relación ancho/alto máxima esperada para una placa
AREA_MIN = 1500 # Área mínima de una región candidata en píxeles cuadrados
AREA_MAX = 40000 # Área máxima: evita seleccionar toda la carrocería del auto
FILL_RATIO_MIN = 0.4 # Compacidad mínima: el contorno debe "llenar" al menos 40% de su bbox
def es_placa(x, y, w, h, contorno, img_shape):
"""
Determina si una región candidata es una placa vehicular.
Aplica cuatro filtros en cascada (si uno falla, se descarta la región):
1. PROPORCIÓN GEOMÉTRICA (aspect ratio)
Las placas tienen una forma rectangular horizontal bien definida.
Filtramos regiones que no tengan la proporción típica de una placa.
2. ÁREA DEL BOUNDING BOX
Descarta regiones demasiado pequeñas (ruido) o demasiado grandes
(capó, parabrisas, etc.).
3. RATIO DE LLENADO (fill ratio)
Compara el área real del contorno con el área de su bounding box.
Una placa es bastante rectangular, por lo que este ratio debe ser alto.
Si es bajo, probablemente sea un objeto irregular o ruidoso.
4. POSICIÓN VERTICAL
Las placas están típicamente en la parte delantera/trasera del auto,
que en la imagen suele corresponder a la mitad inferior.
Descartamos regiones en la parte superior (cielo, techo, etc.).
Parámetros:
x, y, w, h → coordenadas y dimensiones del bounding box
contorno → objeto contorno de OpenCV (array de puntos)
img_shape → shape de la imagen original (para calcular posición relativa)
Retorna:
True si la región cumple todos los criterios, False en caso contrario
"""
alto_img, ancho_img = img_shape[:2]
# ── Filtro 1: Proporción ancho/alto ──────────────────────────────
if h == 0: # Evitar división por cero
return False
aspect_ratio = w / h
if not (ASPECT_RATIO_MIN <= aspect_ratio <= ASPECT_RATIO_MAX):
return False # No tiene la forma horizontal esperada de una placa
# ── Filtro 2: Área del bounding box ──────────────────────────────
area_bbox = w * h
if not (AREA_MIN <= area_bbox <= AREA_MAX):
return False # Demasiado pequeña (ruido) o demasiado grande
# ── Filtro 3: Compacidad (fill ratio) ────────────────────────────
area_contorno = cv.contourArea(contorno) # Área real del contorno
fill_ratio = area_contorno / area_bbox # Qué fracción del bbox está llena
if fill_ratio < FILL_RATIO_MIN:
return False # El contorno es demasiado irregular o hueco
# ── Filtro 4: Posición vertical en la imagen ──────────────────────
centro_y = y + h / 2 # Centro vertical del bounding box
if centro_y < alto_img * 0.35: # Si está en el tercio superior, lo descartamos
return False
return True # ✓ Pasó todos los filtros → es una placa candidata
# ── Bucle principal: procesamos cada imagen del conjunto ──────────────
for im in imagenes.values():
print(f"\n── Procesando: {im} ──")
# Cargamos la imagen en color (para dibujar el resultado final)
img_color = cv.imread(im)
if img_color is None:
print(f" [!] No se pudo cargar {im}")
continue
# ── Preprocesamiento (igual al pipeline de imagen única) ──────────
# Convertimos a escala de grises para el procesamiento
img_gris = cv.cvtColor(img_color, cv.COLOR_BGR2GRAY)
alto, ancho = img_gris.shape
# Suavizado Gaussiano para eliminar ruido
blur = cv.GaussianBlur(img_gris, (11, 11), 0)
# Mejora de contraste adaptativa (CLAHE)
clahe = cv.createCLAHE(clipLimit=1.5, tileGridSize=(8, 8))
equ01 = clahe.apply(blur)
# Binarización con Otsu
_, th4 = cv.threshold(equ01, 0, 255, cv.THRESH_OTSU)
# Detección de bordes con Canny
edges = cv.Canny(th4, 150, 350)
# ── Extracción de contornos ───────────────────────────────────────
contours, _ = cv.findContours(edges, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE)
# Primer filtro: longitud de perímetro (descarta ruido y objetos gigantes)
long_contours = [
cnt for cnt in contours
if 200 < cv.arcLength(cnt, True) < 700
]
# ── Discriminación con criterios geométricos combinados ───────────
placas_encontradas = 0
for cnt in long_contours:
# boundingRect devuelve el rectángulo mínimo alineado a los ejes que encierra el contorno
x, y, w, h = cv.boundingRect(cnt)
if es_placa(x, y, w, h, cnt, img_color.shape):
# ✅ Placa válida → marcamos con rectángulo verde
cv.rectangle(img_color, (x, y), (x + w, y + h), (0, 255, 0), 3)
# Etiqueta con la proporción (útil para ajustar parámetros)
ratio = round(w / h, 2)
label = f"AR:{ratio}"
cv.putText(img_color, label, (x, y - 8),
cv.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
placas_encontradas += 1
print(f" [✓] Placa detectada | bbox=({x},{y},{w},{h}) | AR={ratio:.2f}")
else:
# ❌ Candidata rechazada → marcamos en rojo tenue (solo para debug/diagnóstico)
cv.rectangle(img_color, (x, y), (x + w, y + h), (0, 0, 180), 1)
print(f" Placas encontradas: {placas_encontradas}")
# Visualizamos el resultado de cada imagen
plt.figure(figsize=(10, 6))
plt.title(f'Resultado: {im} — {placas_encontradas} placa(s) detectada(s)')
plt.imshow(cv.cvtColor(img_color, cv.COLOR_BGR2RGB)) # Convertimos BGR→RGB para matplotlib
plt.axis('off')
plt.show()
A lo largo de este notebook recorrimos un pipeline completo de detección de placas vehiculares utilizando únicamente técnicas clásicas de procesamiento de imágenes. Sin redes neuronales ni datasets de entrenamiento masivos, construimos un sistema funcional apoyándonos en fundamentos sólidos de visión por computadora:
es_placa() demuestra que reglas bien pensadas pueden ser muy discriminativas.Este enfoque tiene limitaciones claras: puede fallar ante variaciones de iluminación extremas, placas con daños, ángulos pronunciados o imágenes de baja resolución. El siguiente nivel natural sería:
La visión por computadora es una de las áreas más ricas e inmediatamente aplicables de la inteligencia artificial. Lo que hoy construiste con OpenCV y Python es exactamente el tipo de fundamento que te permite entender —y después criticar con criterio— por qué los modelos modernos funcionan como funcionan.
Antes de confiar en una caja negra, vale la pena haber construido la caja blanca. ¡Sigue explorando! 🚀