Aprendizaje Automático - RoboticsURJC/tfg-jlopez GitHub Wiki

Se contemplan dos definiciones del Aprendizaje Automático:

  • El campo de estudio que otorga a los ordenadores la capacidad de aprender sin ser programados explícitamente (Arthur Samuel, 1959)

  • Se dice que un programa de ordenador aprende de la experiencia E con respecto a una clase de tareas T y una medida de rendimiento P, si su rendimiento en las tareas de T, memido por P, mejora con la experiencia E (Tom Mittchell, 1998)

El aprendizaje automático se puede dividir en:

Aprendizaje Supervisado

Al aprendizaje supervisado se le proporciona al algoritmo un conjunto de datos de entrada y la salida correcta. Este aprendizaje tiene las siguientes aplicaciones:

  • Regresión: toma valores continuos. Como pueden ser: regresión lineal, árboles de decisión y redes neuronales

  • Clasificación: toma valores discretos y puede ser binaria (2 valores) o multiclase (+2 valores). Como pueden ser: árboles de decisión, máquinas de soporte vectorial, KNN, redes neuronales y regresión logística binaria

Aprendizaje no Supervisado

Al aprendizaje no supervisado se le proporciona al algoritmo únicamente datos de entrada. Este aprendizaje tiene las siguientes aplicaciones:

  • Clustering. Como puede ser: K-means y redes neuronales

  • Reducir la dimensionalidad: Como puede ser: PCA

Aprendizaje por Refuerzo

El aprendizaje por refuerzo aprende tareas en base de recompensas y penalizaciones. Este aprendizaje tiene las siguientes aplicaciones: procesos de decisión de Markov, Q-Learning, redes neuronales y algoritmos genéticos.

Mi proyecto

En mi proyecto, he necesitado crear una red neuronal para la detección de baches usando aprendizaje supervisado la cual ha sido entrenada con un conjunto de datos de entrada y de salida para comprobar que ha sido correcto el entrenamiento.

Investigación antigua

Para poder conseguir dicho entrenamiento, he seguido los siguientes pasos:

Siguiendo estos tutoriales: tutorial1: video de los baches y tutorial2: video de las monedas

He aprendido a etiquetar imágenes con LabelImg (lo he hecho en mi ordenador local) y tenía problemas para poder usar la aplicación ya que cuando pinchaba en "crear rectángulo" se cerraba la aplicación y gracias a este tutorial lo he podido solucionar: Ha sido añadir al código fuente unos casting de ints.

Guardé los datos como phdetect2.zip en Google Drive (que no acepta directamente subir un .zip y tuve que usar algún truquito) y gracias a seguir los pasos del tutorial2 en la parte de entrenarlo pude crear mi primera versión.

TensorFlow Lite Inference

Sigue los siguientes pasos:

  1. Cargar el .tflite modelo en memoria que contiene el grafo de ejecución del modelo

  2. Transformar datos de la imagen para que sea compatible con el modelo .tflite

  3. Hacer la inferencia. Este proceso sigue varios pasos como puede ser: crear un intérprete y fijar los tensores

  4. Interpretar la salida según al gusto del desarrollador

Documentación

V1 de la IA

En la carpeta de IA puedes ver que hay un custom_model_litev1.zip que es el .zip resultante después del entrenamiento y testing y un jupiternotebookv1 que contiene toda la información que he necesitado para entrenar la primera versión de mi IA realizada a través de google collab usando mi cuenta de jlopezaugusto. Tiene pocos datos, en torno a 73 imágenes y tardó en torno menos de una hora en entrenarse.

Videos de demostración

Vista desde el ordenador

Vista desde el mundo real

Se puede ver que va muy lento y necesito mejorar esa reactividad.

V2 de la IA: Google Coral USB Accelerator

Tras mucho investigar y ver que el modelo entrenado era muy lento, decidí familiarizarme con Google Coral USB Accelerator (tiene un precio de 75€ pero en muchos casos supera los 120€).

Documentación oficial

Gracias a Ubuntu 20.04 pude instalar de fuente pycoral:

Tras intentar ver que mi modelo que tenía entrenado (v1: best_full_edgetpu_quant_edge.tflite) no era compatible con EdgeTPU y todas las operacioens se harían en CPU, tuve que encontrar otra solución.

Decidí buscar tutoriales/fuente de información que me pudiese dar más consistencia y encontré que YOLOV8 era muy buena opción. Gracias a este tutorial pude crar un modelo mucho más rico en datos.

Este es el Jupyter Notebook donde he entrenado el modelo: https://colab.research.google.com/drive/1CzBDGUCgVxTUVDRKZx5exwDQ3mealTJS?authuser=3#scrollTo=JGR8tiSjIT93

A continuación puedes ver unos ejemplos que demuestran la validación del modelo entrenado:

Sin embargo, este modelo entrenado tiene formato .pb (en mi caso se llama best.pb).

Para poder convertirlo, tuve que convertirlo a edgeTPU.

Cuando lo intentaba probar sobre el nodo que tenía hecho, tuve que hacer a nivel de código unas ligeras modificaciones:

# dentro del def __init__(self):

        self.interpreter = tflite.Interpreter(model_path=self.model_path, experimental_delegates=[tflite.load_delegate('/usr/lib/aarch64-linux-gnu/libedgetpu.so.1')])

# dentro del def timer_callbackFunction(self):
# convertir la imagen a int8
        scale, zero_point = self.input_details[0]['quantization']
        input_data = (input_data / scale + zero_point).astype(np.int8)

Cuando lo ejecutaba se moría el programa.

Leyendo en varios foros, me di cuenta que pycoral y tflite-runtime según las versiones que tenían instaladas no eran compatibles, por lo que tuve que cambiar la version de tflite-runtime.

pip3 install ftlite-runtime==2.5.0post1

Ahora sí que cargaba el intérpreter y demás pero aparecía el siguiente error por pantalla y moría el código.

Gracias a esta respuesta de este foro, pude darme cuenta que las imágenes eran muy grandes y que podía ser la causa de este problema. Lo volvía a exportar usando la siguiente línea de código, la descubrí gracias a esta issue:

yolo export model=yolov8n.pt format=edgetpu imgsz=192,192

Aquí está el Jupyter Notebook que he usado para car la converión.

Finalmente, lo volví a probar y ahora funciona perfectamente y se reduce considerablemente la latencia de detectar el bache. Se llama v2: bestv2_full_edgetpu_quant_edge.tflite

A continuación, puedes ver una imagen que demuestra que funciona.

TENGO QUE AÑADIR IMÁGENES Y VIDEOS.

Explicación del código

1º Cargar/inicializar el intérprete

self.interpreter = tflite.Interpreter(model_path=self.model_path, experimental_delegates=[tflite.load_delegate('/usr/lib/aarch64-linux-gnu/libedgetpu.so.1')])

2º Repartir los tensores

 self.interpreter.allocate_tensors()

3º Obtener los detalles de entrada del modelo

self.input_details = self.interpreter.get_input_details()

Tras ejecutar esta línea obtenemos la siguiente salida:

sabiendo que input data: 
[{'name': 'serving_default_images:0', 'index': 0, 'shape': array([  1, 192, 192,   3], dtype=int32), 'shape_signature': array([  1, 192, 192,   3], dtype=int32), 'dtype': <class 'numpy.int8'>, 'quantization': (0.01865844801068306, -14), 'quantization_parameters': {'scales': array([0.01865845], dtype=float32), 'zero_points': array([-14], dtype=int32), 'quantized_dimension': 0}, 'sparsity_parameters': {}}]

Esto indica que el modelo espera imágenes de tamaño 192x192 con 3 canales (RGB). Dtype: numpy.int8 — Los datos de entrada están en formato entero de 8 bits. (por eso es necesario hacer una conversión)

4º Obtener los detalles de salida del modelo

self.output_details = self.interpreter.get_output_details()

Tras ejecutar esta línea obtenemos la siguiente salida:

 [{'name': 'PartitionedCall:0', 'index': 2, 'shape': array([  1,  38, 756], dtype=int32), 'shape_signature': array([  1,  38, 756], dtype=int32), 'dtype': <class 'numpy.int8'>, 'quantization': (0.017339961603283882, 12), 'quantization_parameters': {'scales': array([0.01733996], dtype=float32), 'zero_points': array([12], dtype=int32), 'quantized_dimension': 0}, 'sparsity_parameters': {}}, {'name': 'PartitionedCall:1', 'index': 1, 'shape': array([ 1, 48, 48, 32], dtype=int32), 'shape_signature': array([ 1, 48, 48, 32], dtype=int32), 'dtype': <class 'numpy.int8'>, 'quantization': (0.020256992429494858, -114), 'quantization_parameters': {'scales': array([0.02025699], dtype=float32), 'zero_points': array([-114], dtype=int32), 'quantized_dimension': 0}, 'sparsity_parameters': {}}]

Tenemos 2 salidas: PatitionedCall:0 y PartitionedCall:1

  • PatitionedCall:0 (Shape: [1, 38, 756]): Esto parece ser una salida con batch de 1 una dimensión de "38" y "756" en cada imagen de la inferencia. La forma sugiere que no es relevante para la segmentación.

  • PartitionedCall:1 (Shape: [1, 48, 48, 32]): Esta salida tiene cuatro dimensiones, con un batch de 1, máscara de 48x48 y con 32 clases. En mi caso sólo necesito la clase 0 y 1 porque mi .yaml tiene la siguiente pinta:

description: Ultralytics YOLOv8l-seg model trained on /content/YOLOv8_Segmentation_DeepSORT_Object_Tracking/ultralytics/yolo/v8/segment/Pothole-Detection-Project-New-1/data.yaml
author: Ultralytics
date: '2024-09-02T14:45:03.384482'
version: 8.2.86
license: AGPL-3.0 License (https://ultralytics.com/license)
docs: https://docs.ultralytics.com
stride: 32
task: segment
batch: 1
imgsz:
- 192
- 192
names:
  0: object # es la que mejor detecta baches
  1: pothole

Ya dentro del callback hago las modificaciones:

5º Redimensiono los datos de entrada siguiendo el tipo de datos int8

6º Hago la inferencia con los datos mios de entrada redimensionados con los datos de entrada del modelo

 self.interpreter.set_tensor(self.input_details[0]['index'], input_data)
 self.interpreter.invoke()

7º Tras haber visto que solo nos importa la salida 1, empezamos a trabajar con ella:

output_data1 = self.interpreter.get_tensor(self.output_details[1]['index'])
# elimina la dimensión del batch
prediction1 = np.squeeze(output_data1)

np.squeeze elimina la dimensión del batch porque no es necesria. Prediction1 tiene la siguiente forma: [48,48,32]

Como hay máscaras de 48x48 de 32 clases, solo nos quedamos con la que nos interesa como indica el .yaml:

 pothole_mask = prediction1[:, :, 0]

Si imprimimos pothole_mask obtenemos la siguiente salida:

[[-120 -125 -126 ... -117 -118 -119]
 [-125 -128 -127 ... -110 -112 -111]
 [-125 -128 -127 ... -111 -110 -103]
 ...
 [-103 -108 -107 ...  -96  -94  -93]
 [-104 -108 -107 ...  -97  -97  -97]
 [-103 -104 -101 ...  -99 -103 -103]]
pothole_mask.shape
(48, 48)

Se traduce a una máscara de 48x48

8º: Descuantificar la máscara para su uso

 scale, zero_point = self.output_details[1]['quantization']
 pothole_mask = (pothole_mask.astype(np.float32) - zero_point) * scale

De todos esos valores hay que encontrar el valor mayor usando:

max_value = np.max(pothole_mask)

De aquí se establece un umbral que se prefiera.

Fuentes de información importante

https://www.youtube.com/watch?v=DGzQ7XIQyE8

https://www.youtube.com/watch?v=DPRgcquPCpI

https://www.youtube.com/watch?v=w4yHORvDBw0

https://www.digikey.es/en/maker/projects/how-to-perform-object-detection-with-tensorflow-lite-on-raspberry-pi/b929e1519c7c43d5b2c6f89984883588

Para conseguir la detección, se procedió al entrenamiento de un modelo de aprendizaje supervisado que detectaba baches. Se decidió usar YOLOv8, el cual hace uso de una única red neuronal convolucional para detectar objetos en imágenes en tiempo real y con facilidad de exportación a diferentes dispositivos. Para poder entrenar dicho modelo, fue necesario usar un dataset tomadode moinfaisal, 2023 que estaba formado por 202 imágenes ya segmentadas. El entrenamiento duró alrededor de una hora y con una duración de 120 epochs. Una vez pasada esa hora, se pudo obtener el modelo entrenado en formato .pt y todos los resultados del entrenamiento aparecen mostrados a continuación:

Se puede ver que aparecen cuatro gráficas en la parte superior midiendo el error obtenido durante el entrenamiento del modelo y se puede apreciar que disminuye a medida que pasa el tiempo y por lo tanto, se puede decir que existe un correcto entrenamiento. Por otro lado, las cuatro imágenes que aparecen debajo se tratan de las pérdidas calculadas en el conjunto de validación. Si las pérdidas en validación aumentan mientras las pérdidas de entrenamiento disminuyen, puede ser una señal de overfitting (sobreajuste), en este caso al solo usar una imagen de validación el valor permanece constante sobre el 0.

En esta imagen se puede ver las métricas de precisión y exhaustividad (recall) que evalúan la capacidad del modelo para identificar correctamente las instancias de cada clase ya que valores son crecientes. En la parte inferior, la métrica Mean Average Precision (mAP) es creciente e indica que el modelo está mejorando en la detección y clasificación ya que, evalúa el equilibrio entre precisión y exhaustividad.

Para comprobar que el modelo funciona correctamente, se ha aplicado sobre una serie de imágenes mostradas a continuación y sobre un video. Todo está documentado en un Jupyter notebook basado en el tutorial de Muhammad Moin

Una vez demostrado que el modelo es capaz de detectar bien los baches, es el momento de convertir ese modelo para que fuera capaz de trabajar dentro de la RPi 4 y Google Coral. Gracias a este video, se pudo conseguir realizar este Jupyter notebook que convierte el modelo entrenado a un formato edgetpu de 192x192 imágenes, para que el dispositivo Google Coral pueda operar con él, llamado bestv2_full_integer_quant_edgetpu.tflite

Valores obtenidos del modelo

Del presente modelo se pueden obtener los detalles de entrada y de salida del modelo, una vez se ha cargado. De entrada podemos encontrar los siguientes valores:

[{’name’: ’serving_default_images:0’, ’index’: 0, ’shape’:
array([1, 192, 192,3], dtype=int32), ’shape_signature’:
array([1, 192, 192,3], dtype=int32), ’dtype’: <class ’numpy.int8’>,
’quantization’: (0.01865844801068306, -14), ’quantization_parameters’:
{’scales’: array([0.01865845], dtype=float32),
’zero_points’: array([-14], dtype=int32), ’quantized_dimension’: 0},
’sparsity_parameters’: {}}]

Este tensor define la entrada para un modelo cuantizado que toma imágenes RGB de tamaño 192x192, en formato entero de 8 bits (int8). La cuantización utiliza una escala de 0.0187 y un punto cero de -14, mapeando los valores al rango del tensor int8, lo que ayuda a mejorar la eficiencia del modelo. Por otro lado, de salida podemos encontrar los siguientes valores:

[{’name’: ’PartitionedCall:0’, ’index’: 2, ’shape’:
array([1,38, 756], dtype=int32), ’shape_signature’:
array([1,38, 756], dtype=int32), ’dtype’: <class ’numpy.int8’>,
’quantization’: (0.017339961603283882, 12), ’quantization_parameters’:
{’scales’: array([0.01733996], dtype=float32),
’zero_points’: array([12], dtype=int32), ’quantized_dimension’: 0},
’sparsity_parameters’: {}},
{’name’: ’PartitionedCall:1’, ’index’: 1, ’shape’:
array([ 1, 48, 48, 32], dtype=int32), ’shape_signature’:
array([ 1, 48, 48, 32], dtype=int32), ’dtype’: <class ’numpy.int8’>,
’quantization’: (0.020256992429494858, -114), ’quantization_parameters’:
{’scales’: array([0.02025699], dtype=float32),
’zero_points’: array([-114], dtype=int32), ’quantized_dimension’: 0},
’sparsity_parameters’: {}}]

Estos son dos tensores de salida en un modelo cuantizado. El primer tensor (PartitionedCall:0) tiene forma [1, 38, 756], y el segundo tensor (PartitionedCall:1) tiene forma [1, 48, 48, 32]. Ambos utilizan el formato int8 y aplican parámetros de cuantización especı́ficos que ajustan el rango de valores, optimizando el modelo para dispositivos de bajo consumo. De ambos tensores el que nos interesa es el segundo y es debido a su forma. Posee 32 canales y para este proyecto únicamente nos interesan los canales 0 y 1 como muestra la figura anterior.

Aplicación del modelo

Una vez se conocen las caracterı́sticas que posee el modelo, es necesario extraer la información y convertirla en el formato adecuado para poder operar con ella (Código más abajo)

Los valores de pothole_mask_class0 y pothole_mask_class1 ya se pueden tratar y establecer una umbralizacización de 0.6 para considerar dichos valores si han detectado bache o no. Sin embargo, los valores de pothole_mask_class0 y pothole_mask_class1 eran muy inestables y se consideró la técnica de Media Móvil Exponencial (EMA) (Ecuación 6.5) para que no fluctuasen tanto los valores y darle más peso a las medidas más recientes. Esta técnica es común en análisis técnico, como se describe en [Hansun, 2013], pero se decidió incluir en ese proyecto ya que satisfacı́a las necesidades buscadas y por su liviana carga computacional.

      St = α · Yt + (1 − α) · St−1

Siendo α el valor de grado de disminución de ponderación, Yt el valor actual en tiempo t, St es el valor de EMA en tiempo t y St−1 es el valor de EMA en tiempo t-1.

Obtención del contorno y coordenadas

Después de saber si en una imagen habı́a bache o no, se considerarı́a bache si la detección ocurre durante 3 segundos sin interrupciones. Una vez pasado ese tiempo, habı́a que publicar los pı́xeles asociados a ese contorno. Para conseguir los pı́xeles, se toma la máscara binarizada usando el umbral definido previamente de 0.6. De esa forma, es posible encontrar los pı́xeles del contorno más grande de la máscara pero, esos pı́xeles están en base a las dimensiones de la máscara que es de 48x48 pı́xeles; ası́ que, es necesario aplicar un factor de escala para que los pı́xeles estén en base a la imagen que es de 192x192 pı́xeles. Se puede ver un resultado final del contorno detectado en la siguiente figura.

# Redimensionar la imagen a las dimensiones requeridas por el modelo
input_shape = self.input_details[0][’shape’]
height, width = input_shape[1], input_shape[2]
resized_frame = cv2.resize(frame, (width, height))
# Convertir la imagen a formato adecuado
input_data = np.expand_dims(resized_frame, axis=0).astype(np.float32)
# Normalizacion
input_data = (input_data - 127.5) / 127.5
# Convertir la imagen a int8
scale, zero_point = self.input_details[0][’quantization’]
input_data = (input_data / scale + zero_point).astype(np.int8)
# Establecer el tensor de entrada y realizar la inferencia
self.interpreter.set_tensor(self.input_details[0][’index’], input_data)
self.interpreter.invoke()
# Saca informacion del tensor de salida 2 que tiene forma [1, 48, 48,
32]
output_data1 =
self.interpreter.get_tensor(self.output_details[1][’index’])
# elimina la dimensipn del batch (el primer valor del array)
prediction1 = np.squeeze(output_data1)
# Descuantificar la mascara de baches mirando el canal 0
pothole_mask_class0 = (prediction1[:, :, 0].astype(np.float32) -
zero_point) * scale
# Descuantificar la mascara de baches mirando el canal 1
pothole_mask_class1 = (prediction1[:, :, 1].astype(np.float32) -
zero_point) * scale

⚠️ **GitHub.com Fallback** ⚠️