>>> Descarga el código de este post aquí <<<
A pesar de la importancia del chasis y la transmisión en un automóvil, es inútil sin la presencia de un motor para aprovechar tal infraestructura.
Lo mismo sucede con un detector de objetos. Aunque dispongamos de un mecanismo para escanear la imagen en búsqueda de elementos de interés en diferentes escalas, sin un clasificador poderoso, eficaz y veloz, no hemos logrado nada.
Así, pues, hoy iniciaremos el estudio e implementación de este crucial componente. Puesto que la creación de un clasificador involucra múltiples etapas, el foco de hoy estará puesto en la extracción de los features que serán ingeridos por un algoritmo de machine learning durante el entrenamiento.
Este artículo es denso, por lo que ve, prepárate un delicioso café, realiza unos estiramientos y alístate para este viaje.
¿Listo? ¡Empecemos!
Características de un Clasificador de Objetos
Un clasificador de objetos basado en machine learning es un algoritmo que, esencialmente, extrae patrones de una representación matemática de un banco de imágenes para determinar la mejor manera de diferenciar entre las categorías de un problema.
Salvo algunas redes neuronales especializadas, la inmensidad de los modelos de aprendizaje supervisado trabajan con data unidimensional. En otras palabras, sólo lidian con vectores.
El problema que esto nos presenta tiene varias aristas:
- Los imágenes son bidimensionales (grises) y, en su mayoría, tridimensionales.
- Pasar de un espacio multidimensional a uno unidimensional conlleva una inevitable pérdida de información.
- Los seres humanos no estamos equipados para entender información vectorizada de la misma forma que la información visual.
Afortunadamente, más allá de estos indudables contratiempos, existen muchas formas de aprovechar el poder predictivo de los modelos de machine learning, minimizando la pérdida de información, a la vez que maximizamos nuestros resultados (es decir, precisión).
Curiosamente, la eficacia de un clasificador radica primordialmente en la calidad de los datos, no en la escogencia particular de un algoritmo. Después de todo, por algo una de las máximas en machine learning es: “Basura entra, basura sale.”
Cuando hablamos de machine learning aplicado a computer vision, este adagio aplica tanto a las imágenes como a la calidad de los vectores (features) derivados de estas.
Uno de las herramientas a nuestro alcance se conoce como Histograma de Gradientes Orientados (HOG, por sus siglas en inglés). Como veremos en breve, es uno de los extractores de features más poderosos en el mercado.
Histograma de Gradientes Orientados (HOG)
HOG es un descriptor (nombre alternativo para “extractor de features”) típicamente usado en el contexto de computer vision y machine learning, aunque sus aplicaciones se extienden a la cuantificación y representación de otras características interesantes, como forma y textura.
Las cinco etapas del algoritmo son:
- Normalizar la imagen antes de pasarla al descriptor.
- Computar los gradientes en la dirección de los ejes X e Y.
- Obtener una votación ponderada por celdas.
- Normalización de contraste en celdas superpuestas.
- Recopilar todos los histogramas para formar el vector final.
El resultado del proceso descrito arriba será un vector de números reales. La dimensionalidad de dicho vector dependerá fuertemente de los parámetros orientations
, pixels_per_cell
y cells_per_block
.
La premisa de HOG es que la apariencia de un objeto puede modelarse mediante la distribución de los gradientes en regiones rectangulares de una imagen.
Estas regiones conectadas se conocen como celdas y para cada una de ella calcularemos el histograma del gradiente de los pixeles. Posteriormente, acumularemos los histogramas de cada celda para producir el vector final.
Recordemos que el gradiente de una imagen nos dice la dirección de cambio de la intensidad de los pixeles. Así mismo, tal gradiente es útil para detectar bordes, y los bordes, a su vez, definen los límites entre los objetos de una fotografía.
Las regiones están conectadas entre sí porque la manera en la que las seleccionamos es con el deslizamiento de ventanas. Por tanto, dependiendo de cómo configuremos el descriptor, éstas se superpondrán en mayor o menor medida.
Podemos aprovechar este hecho para hacer nuestro descriptor más robusto, mediante la normalización de bloques.
Pero, ¿qué es un bloque?
Buena pregunta. Un bloque es a las celdas, lo éstas a los pixeles. Es decir, una celda es un grupo de píxeles, y un bloque es un grupo de celdas.
Para culminar esta sección, detallemos un poco más lo que ocurre en cada etapa enunciada arriba:
- Normalización de la imagen: Aunque es un paso opcional, solemos aplicarlo ya que conduce, en la mayoría de los casos, a un mejor desempeño de HOG. Las formas de normalización más populares son, entre otras:
- Normalización gamma: Se computa el logaritmo de cada píxel.
- Normalización con raíz cuadrada: Se computa la raíz cuadrada de cada píxel.
- Normalización de varianza: Se computa la media y la desviación estándar de los píxeles en la imagen, para luego restarle a cada uno la media, dividiendo el resultado entre la desviación estándar.
- Cálculo de gradientes: Primera obtenemos el gradiente en ambas direcciones, posteriormente lo combinamos. Para mayor detalle sobre el cálculo de gradientes, lee este artículo (NOTA: Se calculan sobre toda la imagen).
- Voto ponderado por celda: Con la magnitud y orientación de los gradientes de cada pixel, dividimos la imagen en celdas (grupos de píxeles) y bloques (grupos de celdas). En el código que veremos más adelante, el número de píxeles por celda lo especifica el parámetro
pixels_per_cell
. Análogamente, el número de celdas por bloque esblocks_per_cell
.
Hecha esta división, tenemos que calcular el histograma de cada celda. ¿Cuántas particiones (bins) tendrá? Tantas como indiquemos enorientations
. Típicamente usamos entre 9 y 12 orientaciones.
Cada píxel tiene un peso asociado en el histograma, el cual no es más que la magnitud de su gradiente. - Normalización de contraste por bloques: En teoría, podríamos juntar todos los histogramas del paso 3 para generar el vector final. No obstante, reducir el impacto de la variación de contraste e iluminación en el resultado tiende a producir mejores descriptores. Como en el caso de las celdas, los bloques también se superponen debido a la naturaleza del deslizamiento de ventanas subyacente.
Para cada celda en un bloque dado, obtendremos su histograma de gradientes correspondiente. Luego, los concatenaremos todos en un único vector, para posteriormente aplicar normalización L1 o L2.
¡Uf! ¡Qué cantidad de teoría! Mejor pasemos a la práctica, ¿no crees?
Un Momento… ¿Tengo Que Implementar Todo Esto Desde Cero?
Sí… Si quieres. Pero no tienes que hacerlo. Tanto OpenCV como scikit-image ofrecen implementaciones de HOG, para nuestra gran fortuna y sanidad mental.
Como de costumbre, la versión de OpenCV, aunque indudablemente útil, es engorrosa y confusa, por lo que la mayoría de las personas, incluyéndome, se decantan por la de scikit-image.
Código
>>> Descarga el código de este post aquí <<<
El proyecto de hoy es extenso, por lo que iremos por partes.
Primeramente, tenemos que crear el virtualenv
para instalar los paquetes requeridos. Lo primero lo llevamos a cabo así:
1 2 3 |
virtualenv -p python3 venv source venv/bin/activate pip install -r requirements.txt |
Las librerías instaladas son:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
cycler==0.10.0 decorator==4.4.2 h5py==2.10.0 imageio==2.8.0 imutils==0.5.3 joblib==0.14.1 kiwisolver==1.1.0 matplotlib==3.1.3 networkx==2.4 numpy==1.18.1 opencv-contrib-python-headless==4.2.0.32 Pillow==7.0.0 progressbar==2.5 pyparsing==2.4.6 python-dateutil==2.8.1 PyWavelets==1.1.1 scikit-image==0.16.2 scikit-learn==0.22.2 scipy==1.4.1 six==1.14.0 |
Algunas menciones especiales:
- h5py: La usaremos para manipular archivos en formato HDF5, extremadamente útil para lidiar rápidamente con conjuntos de datos numéricos multidimensionales.
- scikit-image: Entre otras cosas, contiene la implementación de HOG que nos interesa.
- scikit-learn: Aunque no entrenaremos ningún modelo hoy, la función
extract_patches_2d
, la cual reside ensklearn
será de gran utilidad para extraer ejemplos negativos más adelante.
La clave de un descriptor de imágenes exitoso es la consistencia. Esto se traduce a que el mismo conjunto de parámetros debe usarse para generar el vector de cada imagen, ya que de lo contrario, sería imposible compararlos entre sí.
Una fórmula sencilla para garantizar tal consistencia es encapsular la llamada a la función features.hog
de skimage
en una clase, como a continuación:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
from skimage import feature # Paquete que contiene la implementación de HOG class HOG(object): """ Objeto usado específicamente para garantizar una aplicación consistente de la función feature.hog en el contexto de un descriptor de objetos. """ def __init__(self, orientations=12, pixels_per_cell=(4, 4), cells_per_block=(2, 2), normalize=True): """ Inicializa la instancia con los parámetros que se usarán en el método `describe`. :param orientations: Número de particiones en cada histograma. :param pixels_per_cell: Dimensiones del rectángulo correspondiente a cada celda (ejemplo: (4, 4) = 16 píxeles). :param cells_per_block: Dimensiones del rectángulo correspondiente a cada bloque (ejemplo: (2, 2) = 4 celdas) :param normalize: Indica si se debe aplicar normalización por bloques o no. """ self.orientations = orientations self.pixels_per_cell = pixels_per_cell self.cells_per_block = cells_per_block self.normalize = normalize def describe(self, image): """ Toma una imagen y retorna el vector correspondiente al histograma de gradientes orientados computado con los parámetros de la clase. :param image: Imagen de entrada :return: Vector correspondiente al HOG de la imagen. """ histogram = feature.hog(image, orientations=self.orientations, pixels_per_cell=self.pixels_per_cell, cells_per_block=self.cells_per_block, transform_sqrt=self.normalize, block_norm='L1') histogram[histogram < 0] = 0 # No queremos valores negativos. return histogram |
La clase HOG
implementada arriba (ubicada en descriptors/hog.py
) recibe los parámetros sólo durante la inicialización; de resto, los reutiliza en cada invocación a describe
.
La parte difícil está lista. No obstante, tenemos otras tareas más mecánicas por delante, por lo que aún no acabamos.
Tenemos que guardar los vectores en alguna parte, ¿no? No hay necesidad de extraer vectores más de una vez.
Usaremos HDF5 para guardar los features entregados por HOG, ya que es excelente para manipular arreglos numéricos bastante volumétricos velozmente.
En el extracto de abajo definimos dos funciones auxiliares para guardar y cargar conjuntos de datos en este formato, respectivamente:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
import h5py import numpy as np def dump_dataset(data, labels, path, dataset_name, write_method='w'): """ Guarda un conjunto de datos en formato HDF5. :param data: Instancias del conjunto de datos. :param labels: Etiquetas del conjunto de datos. :param path: Ruta donde se guardará el archivo HDF5. :param dataset_name: Nombre del conjunto de datos. :param write_method: 'w' para escibir/sobreescribir, 'a' para añadir sin sobreescribir. """ db = h5py.File(path, write_method) # Crea un archivo HDF5 para escritura. # Escrbe los datos del conjunto en el archivo como floats. dataset = db.create_dataset(dataset_name, (len(data), len(data[0]) + 1), dtype='float') dataset[:len(data)] = np.c_[labels, data] # Cierra el archivo. db.close() def load_dataset(path, dataset_name): """ Carga un conjunto de datos en formato HDF5. :param path: Ruta donde se encuentra el archivo HDF5. :param dataset_name: Nombre del conjunto de datos. :return: Arreglos de instancias y etiquetas por separado. """ db = h5py.File(path, 'r') labels, data = db[dataset_name][:, 0], db[dataset_name][:, 1:] db.close() return data, labels |
Estas funciones se encuentran en el archivo utils/dataset.py
.
Tal parece que tenemos todas las partes que necesitamos, lo único que falta es juntarlas, ¿cierto?
Sí, pero antes de seguir adelante, agrandemos nuestro archivo de configuración (resources/airplanes.json
, del cual hablamos en el artículo anterior) con los parámetros relevantes a HOG y a la extracción de features propiamente:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
{ "image_dataset": "/Users/jesus/Data/101_ObjectCategories/airplanes", "image_annotations": "/Users/jesus/Data/Annotations/Airplanes_Side_2", "image_distractions": "/Users/jesus/Data/SceneClass13", "features_path": "datasmarts/output/airplanes/airplanes_features.hdf5", "percent_gt_images": 0.5, "offset": 5, "use_flip": true, "num_distraction_images": 500, "num_distractions_per_image": 10, "orientations": 9, "pixels_per_cell": [4, 4], "cells_per_block": [2, 2], "normalize": true, "window_step": 4, "overlap_threshold": 0.3, "pyramid_scale": 1.5, "window_dimensions": [148, 48], "min_probability": 0.7 } |
Repasemos brevemente los nuevos valores:
features_path
: Ruta donde se guardará el archivo HDF5 con los vectores correspondientes a las imágenes.percent_gt_images
: Proporción de las imágenes que se usarán para entrenar el modelo.offset
: Número de píxeles que constituirán el margen de las anotaciones de las imágenes usadas para compilar el conjunto de datos. Este margen añade contexto al objeto, lo que hace al clasificador más robusto.use_flip
: Interruptor booleano que indica si debemos aumentar nuestro conjunto de datos volteando las imágenes.num_distraction_images
: Cantidad de imágenes negativas que usaremos para muestrear instancias de objetos que no son aviones.num_distractions_per_images
: Cantidad de muestras aleatorias que extraeremos de cada imagen negativa.orientations
: Número de particiones de cada histograma del descriptor HOG.pixels_per_cell
: Número de píxeles por celda del descriptor HOG.cells_per_block
: Número de celdas por bloque del descriptor HOG.normalize
: Interruptor booleano que indica si debemos aplicar normalización por bloques o no.
Algunos de los siguientes parámetros no serán usados en este artículo, puesto que se corresponden al detector de objetos, el cual estudiaremos en el próximo post. Sólo window_dimensions
es relevante por el momento:
window_step
: Número de píxeles a mover la ventana deslizante en ambas direcciones.overlap_threshold
: Proporción de superposición entre dos ventanas. Relevante para Non-Maxima Suppression, tópico que cubriremos en un futuro.pyramid_scale
: Factor de reducción para la generación de la pirámide de imágenes.window_dimensions
: Dimensiones de la ventana deslizante, la cual usaremos también para extraer los ejemplos positivos y negativos. Para una explicación sobre cómo hallamos las dimensiones, lee el artículo anterior y los párrafos venideros.min_probability
: Probabilidad mínima que debe arrojar el clasificador para considerar una clasificación como positiva (es decir, un avión).
Ahora sí estamos listos para armar, y vectorizar nuestro conjunto de datos. De ello se encarga el script de abajo:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
import argparse import os import random import cv2 import progressbar from imutils import paths from scipy import io as io from sklearn.feature_extraction.image import extract_patches_2d # Importamos todas las clases y funciones aleatorios que definimos previamente. from datasmarts.descriptors.hog import HOG from datasmarts.helpers import crop_caltech101_bounding_box from datasmarts.utils.conf import Conf from datasmarts.utils.dataset import dump_dataset # El único argumento de entrada es la ruta al archivo de configuración argument_parser = argparse.ArgumentParser() argument_parser.add_argument('-c', '--conf', required=True, help='Ruta al archivo de configuración.') arguments = vars(argument_parser.parse_args()) # Traemos la configuración a la memoria principal. configuration = Conf(arguments['conf']) # Instanciamos el descriptor HOG con los parámetros presentes en la configuración. hog = HOG(orientations=configuration['orientations'], pixels_per_cell=tuple(configuration['pixels_per_cell']), cells_per_block=tuple(configuration['cells_per_block']), normalize=configuration['normalize']) # En estas listas guardaremos las intancias del conjunto de datos, junto con sus respectivas etiquetas. data = [] labels = [] # Seleccionamos sólo una porción de las imágenes para entrenar, mientras que el resto constituirán el conjunto de # pruebas. training_paths = list(paths.list_images(configuration['image_dataset'])) training_paths = random.sample(training_paths, int(len(training_paths) * configuration['percent_gt_images'])) print('[INFO] Describiendo las regiones de interés de entrenamiento.') # Configuramos el widget para obtener una representación visual en la consola del progreso de la extracción de features. widgets = ['Extracting: ', progressbar.Percentage(), ' ', progressbar.Bar(), ' ', progressbar.ETA()] progress_bar = progressbar.ProgressBar(maxval=len(training_paths), widgets=widgets).start() # Iteramos por cada imagen de entrenamiento. for index, training_path in enumerate(training_paths): # Cargamos la imagen y la convertimos a escala de grises. image = cv2.imread(training_path) image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # Extraemos el identificador de la imagen de su ruta en disco. image_id = training_path[training_path.rfind('_') + 1:].replace('.jpg', '') # Cargamos la anotación correspondiente y la usamos para extraer la región de interés donde se encuentra el avión. path = os.path.sep.join([configuration["image_annotations"], f'annotation_{image_id}.mat']) bounding_box = io.loadmat(path)['box_coord'][0] roi = crop_caltech101_bounding_box(image, bounding_box, padding=configuration['offset'], destination_size=tuple(configuration['window_dimensions'])) # Volteamos la imagen si así lo establecimos en la configuración. if configuration['use_flip']: rois = (roi, cv2.flip(roi, 1)) else: rois = (roi,) # Calculamos el vector HOG de cada región de interés. for roi in rois: features = hog.describe(roi) data.append(features) labels.append(1) # 1 quiere decir que la imagen contiene un avión. progress_bar.update(index) # Actualizamos el widget. progress_bar.finish() # Ahora generaremos las distracciones o muestras negativas. Una vez más, usaremos el widget de la barra de progreso. destination_paths = list(paths.list_images(configuration['image_distractions'])) progress_bar = progressbar.ProgressBar(maxval=configuration['num_distraction_images'], widgets=widgets).start() for index in range(configuration['num_distraction_images']): # Cargamos la imagen y la convertimos a escala de grises. image = cv2.imread(random.choice(destination_paths)) image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # Extraemos tantos parches aleatorios de la imagen como hayamos establecido en la configuración. patches = extract_patches_2d(image, tuple(configuration['window_dimensions']), max_patches=configuration['num_distractions_per_image']) # Extraemos el vector HOG de cada muestras negativa. for patch in patches: features = hog.describe(patch) data.append(features) labels.append(-1) # -1 inica la ausencia de un avión. progress_bar.update(index) # Actualizamos el widget. progress_bar.finish() print('[INFO] Guardando los features y etiquetas en disco...') dump_dataset(data, labels, configuration['features_path'], 'features') |
Muchas cosas cosas están sucediendo en el extracto anterior, por lo que te exhorto a leer detenidamente los comentarios que acompañan al código. Como notarás, sin la ayuda de la serie de funciones y clases auxiliares definidas con anterioridad, este programa sería considerablemente más complejo.
La modularización siempre es una buena idea.
Imagino que habrá llamado tu atención la función auxiliar crop_caltech101_bounding_box
, usada en la línea 53. Se trata de una función utilitaria para extraer la región de interés contenedora de un aeroplano de una imagen del conjunto de datos CALTECH101. Está definida en datasmarts/helpers.py
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
def crop_caltech101_bounding_box(image, bounding_box, padding=10, destination_size=(32, 32)): """ Función utilitaria para extraer una región de interés de una imagen del conjunto de datos CALTECH101. :param image: Imagen objetivo. :param bounding_box: Anotación. :param padding: Número de píxeles a dejar de margen externo en cada dirección. :param destination_size: Dimensiones de la región de interés resultante. :return: """ # Desempacamos las coordenadas de la anotación, aplicamos el margen y extraemos la región de interés de la imagen. y, height, x, width = bounding_box x, y = max(x - padding, 0), max(y - padding, 0) roi = image[y:height + padding, x:width + padding] # Redimensionamos la región de interés. roi = cv2.resize(roi, destination_size, interpolation=cv2.INTER_AREA) return roi |
Una de las interrogantes que quedaron abiertas en el post anterior es cómo escoger las dimensiones de la ventana deslizante, considerando que esta tiene un impacto importante en el funcionamiento de HOG. En esa ocasión nuestra exploración de los datos nos informó que el ancho promedio de una región de interés es 293.11, la altura promedio es 95.45 y la relación de aspecto es 3.07.
La cantidad de trabajo llevado a cabo por HOG no es trivial. Por tanto, mientras más ventanas tengamos, más tardaremos en extraer los features. Por otro lado, si nuestras ventanas son muy grandes o insuficientes, corremos el riesgo de no capturar los objetos de interés para su posterior detección. El punto es que escoger las dimensiones de la ventana no es tan fácil y directo como supondríamos. De hecho, tiene mucho de ensayo y error.
Una buena estrategia inicial es tomar las dimensiones de la exploración y dividirlas entre 2, puesto que nos provee de un buen balance entre eficiencia y eficacia. Eso nos daría 146.55 para el ancho y 47.725 para el alto. Redondeando, tendríamos una ventana de dimensiones 147×48. Sin embargo, como las dimensiones de pixels_per_cell
son 4×4, lo ideal es que las dimensiones de la ventana sean divisibles entre 4; así es como llegamos al 148×48 final.
Lo último que nos queda por hacer es ejecutar el script:
1 |
PYTHONPATH=. python datasmarts/extract_features.py -c datasmarts/resources/airplanes.json |
Al cabo de unos segundos (dependiendo de las prestaciones de tu sistema), tendremos un archivo HDF5 en la ubicación indicada en la configuración, el cual corresponde a los vectores generados por HOG, tanto para las instancias positivas como negativas:
1 2 |
ls datasmarts/output/airplanes airplanes_features.hdf5 |
>>> Descarga el código de este post aquí <<<
¡Wow! Ha sido todo un viaje, pero por fin hemos arribado a nuestro destino. No caben dudas sobre la versatilidad y capacidad descriptiva de HOG. A pesar de ello, hoy aprendimos que la selección de los parámetros adecuados juega un rol crucial en la calidad de los vectores resultantes. Siempre que podamos, hemos de apuntar a un balance entre eficacia (vectores suficientemente representativos de la imagen original), y eficiencia (vectores de un tamaño manejable).
En el próximo artículo tomaremos los features creados el día de hoy para, finalmente, entrenar un clasificador de aviones.
¡Hasta pronto!