Introducción

¿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:

  1. Carga y exploración de imágenes de autos.
  2. Preprocesamiento: conversión a escala de grises, suavizado y mejora de contraste.
  3. Binarización: convertir la imagen a blanco y negro para facilitar el análisis.
  4. Detección de bordes: resaltar las fronteras entre regiones.
  5. Análisis de contornos: identificar las regiones candidatas a ser una placa.
  6. Discriminación geométrica: filtrar candidatos usando criterios proporcionales.

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!


Parte 1 — Exploración Inicial de Imágenes

Antes de aplicar cualquier técnica, es fundamental entender con qué tipo de datos trabajamos. En esta sección cargamos imágenes de prueba y exploramos sus propiedades básicas: dimensiones, número de canales y representación visual.

1.1 Importación de librerías

Usaremos tres librerías esenciales para el procesamiento de imágenes en Python.

In [9]:
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)

1.2 Carga de imagen en color y en escala de grises

OpenCV puede cargar imágenes en dos modos principales:

  • Color (BGR): devuelve una matriz 3D → (alto, ancho, 3 canales)
  • Escala de grises: devuelve una matriz 2D → (alto, ancho)

Nota: OpenCV usa el orden de canales BGR (Azul-Verde-Rojo), no RGB. Por eso al visualizar con matplotlib los colores pueden verse invertidos si no se hace la conversión.

In [10]:
# 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()
Imagen color   → alto: 248 | ancho: 400 | canales: 3
Imagen en gris → alto: 248 | ancho: 400
No description has been provided for this image

1.3 Aplicación de un Kernel personalizado (filtro de detección de bordes)

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.

In [11]:
# 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()
No description has been provided for this image

1.4 Filtro Gaussiano — Suavizado de imagen

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.

In [12]:
# 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()
No description has been provided for this image
In [13]:
# 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()
No description has been provided for this image

1.5 Ecualización de histograma por canal

La ecualización de histograma redistribuye la intensidad de los píxeles para mejorar el contraste global de la imagen. Cuando se aplica a una imagen a color, se debe procesar cada canal (R, G, B) por separado para evitar alterar los tonos de color.

In [14]:
# 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()
No description has been provided for this image
In [15]:
# 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()
No description has been provided for this image

Parte 2 — Pipeline de Reconocimiento de Placas (Imagen Única)

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

Paso 1 — Lectura y conversión a escala de grises

Trabajar en escala de grises reduce la dimensionalidad de la imagen (de 3 canales a 1), lo que simplifica todos los cálculos posteriores sin perder la información estructural necesaria para detectar bordes y formas.

In [16]:
# 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)

Paso 2 — Suavizado con filtro Gaussiano

Antes de detectar bordes, suavizamos la imagen para eliminar el ruido de alta frecuencia (pequeñas variaciones de píxeles que no corresponden a estructuras reales). Sin este paso, el detector de bordes produciría muchos falsos positivos.

In [17]:
# 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()
No description has been provided for this image

Paso 3 — Mejora de contraste con CLAHE

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:

  • Opera en regiones locales (tileGridSize) en lugar de la imagen completa.
  • Limita la amplificación del contraste (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.

In [18]:
# 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()
No description has been provided for this image

Paso 4 — Binarización con el método de Otsu

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.

In [19]:
# 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()
Umbral calculado por Otsu: 114.0
No description has been provided for this image

Paso 5 — Detección de bordes con Canny

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).

  • Umbral bajo (150): bordes con gradiente mayor a este valor son candidatos.
  • Umbral alto (350): bordes con gradiente mayor a este valor se aceptan directamente.
  • Los bordes entre ambos umbrales se aceptan solo si están conectados a un borde seguro.
In [20]:
# 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()
No description has been provided for this image

Paso 6 — Extracción de contornos y filtrado por perímetro

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.

In [21]:
# 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)]

Paso 7 — Análisis geométrico y marcado de la placa

Para cada contorno candidato calculamos su bounding box (rectángulo mínimo envolvente) y aplicamos dos criterios geométricos:

  1. Posición vertical: la placa debe estar en la mitad inferior de la imagen.
  2. Orientación: el ancho debe ser mayor que la altura (prop < 0), ya que las placas son horizontales.

Las regiones que cumplen ambos criterios se marcan con un rectángulo verde.

In [22]:
# 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)
In [23]:
# Mostramos el resultado final con la(s) placa(s) marcadas
plt.title('Resultado — Placa(s) detectada(s)')
plt.imshow(car_color)
plt.show()
No description has been provided for this image

Parte 3 — Pipeline Mejorado: Procesamiento de Múltiples Imágenes

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.

In [24]:
# 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()
── Procesando: resources/Cars11.png ──
  [✓] Placa detectada | bbox=(102,187,197,80) | AR=2.46
  [✓] Placa detectada | bbox=(106,116,190,48) | AR=3.96
  Placas encontradas: 2
No description has been provided for this image
── Procesando: resources/Cars129.png ──
  [✓] Placa detectada | bbox=(40,179,90,39) | AR=2.31
  Placas encontradas: 1
No description has been provided for this image
── Procesando: resources/Cars133.png ──
  Placas encontradas: 0
No description has been provided for this image
── Procesando: resources/Cars14.png ──
  [✓] Placa detectada | bbox=(96,115,189,42) | AR=4.50
  Placas encontradas: 1
No description has been provided for this image
── Procesando: resources/Cars167.png ──
  [✓] Placa detectada | bbox=(380,280,90,36) | AR=2.50
  [✓] Placa detectada | bbox=(37,280,92,36) | AR=2.56
  [✓] Placa detectada | bbox=(49,186,200,62) | AR=3.23
  Placas encontradas: 3
No description has been provided for this image

Conclusiones

Lo que aprendimos

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:

  • El preprocesamiento importa tanto como el algoritmo. Pasos como el suavizado Gaussiano y la mejora de contraste con CLAHE no son opcionales; son los que hacen que los detectores de bordes y contornos trabajen con información limpia y confiable.
  • Los criterios geométricos son sorprendentemente efectivos. Una placa vehicular tiene proporciones, área y posición características que podemos explotar sin necesidad de aprendizaje automático. La función es_placa() demuestra que reglas bien pensadas pueden ser muy discriminativas.
  • El ajuste de parámetros es un proceso iterativo. Los umbrales de Canny, el rango de perímetros, el aspect ratio mínimo y máximo: todos estos valores requieren experimentación con el dataset específico en el que se trabaja.

Limitaciones y próximos pasos

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:

  1. Integrar OCR (como Tesseract) para leer el texto dentro de la placa detectada.
  2. Usar modelos de deep learning (YOLO, EfficientDet) para una detección más robusta.
  3. Aplicar corrección de perspectiva para normalizar placas capturadas en ángulo.

Reflexión final

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! 🚀