Full text
Proyecto Fin de Carrera Ingeniería de Telecomunicación Formato de Publicación de la Escuela Técnica Superior de Ingeniería Autor: F. Javier Payán Somet Tutor: Juan José Murillo Fuentes Dep. Teoría de la Señal y Comunicaciones Escuela Técnica Superior de Ingeniería Universidad de Sevilla Sevilla, 2013 Trabajo Fin de Máster Máster Universitario en Ingeniería Industrial Reconocimiento y clasificación de imágenes utilizando técnicas de aprendizaje profundo (Deep Learning) Autor: José María Trigos Sena Tutor: Rubén Martín Clemente Dpto. Teoría de la Señal y Comunicaciones Escuela Técnica Superior de Ingeniería Universidad de Sevilla Sevilla, 2023
Trabajo Fin de Máster Máster Universitario en Ingeniería Industrial Reconocimiento y clasificación de imágenes utilizando técnicas de aprendizaje profundo (Deep Learning) Autor: José María Trigos Sena Tutor: Rubén Martín Clemente Profesor Titular Dpto. Teoría de la Señal y Comunicaciones Escuela Técnica Superior de Ingeniería Universidad de Sevilla Sevilla, 2023
Trabajo Fin de Máster: Reconocimiento y clasificación de imágenes utilizando técnicas de aprendizaje profundo (Deep Learning) Autor: José María Trigos Sena Tutor: Rubén Martín Clemente El tribunal nombrado para juzgar el trabajo arriba indicado, compuesto por los siguientes profesores: Presidente: Vocal/es: Secretario: acuerdan otorgarle la calificación de: El Secretario del Tribunal Fecha:
Agradecimientos Am i familia, que me han apoyado incondicionalmente desde siempre y me han ayudado a seguir cuando a los suspensos les seguían las dudas. Su apoyo y esfuerzo son los que me permiten seguir perseverando en este camino y los que le siguen. A mis amigos, por su ayuda y motivación, tanto dentro, como fuera de la ETSI. A mis profesores, por sus enseñanzas para convertirme en un buen profesional en el futuro. En especial, me gustaría agradecerle la ayuda y guía prestada durante este Trabajo Fin de Máster al tutor: Rubén Martín Clemente. José María Trigos Sena Sevilla, 2023 I
Resumen Ap esar de que el texto es uno de los medios de comunicación más antiguos, su importancia y presencia no ha hecho más que crecer. Este crecimiento, sumado a la variedad de formas e idiomas en los que puede aparecer este texto, hace que su identificación manual sea cada vez más compleja. La Inteligencia Artificial y, en concreto, el “Deep Learning” ha permitido el desarrollo de algoritmos basados en redes neuronales que automatizan y aceleran la identificación de texto, facilitando enormemente esta tarea. En este Trabajo Fin de Máster se buscará desarrollar una aplicación que, a través de la webcam, permita detectar texto en la imagen y reconocerlo para escribirlo por pantalla. Esto supone el primer paso para obtener sistemas de identificación automática de texto, que puedan ser usados para la traducción instantánea, por ejemplo. La detección de texto en la imagen será realizada por una algoritmo compuesto por una red neuronal completamente convolucional y un sistema de posprocesado. Este algoritmo recibirá la imagen y devolverá las coordenadas de unas cajas delimitadoras o “bounding boxes” orientadas, que destacará las zonas de la imagen que tienen texto. El reconocimiento del texto detectado se hará usando una red neuronal convolucional de clasificación de caracteres. Por tanto, se necesitará segmentar y ordenar los caracteres que se detecten previamente. El resultado de esta última fase es el texto reconocido, ordenado y corregido, para poder escribirlo por pantalla. III
XÍndice 3.3.3 Resultados del entrenamiento 59 3.4 Algoritmo entrenado con Chars74k 60 3.4.1 Diseño de la red neuronal 60 3.4.2 Proceso de entrenamiento 61 3.4.3 Resultados del entrenamiento 62 3.5 Discusión de los resultados y comentario 63 4 Aplicación Final 65 4.1 Adaptación del detector de texto 65 4.1.1 Preparación de la entrada 65 4.1.2 Predicción y filtrado de la localización del texto 66 4.1.3 Ordenación de las predicciones 67 4.2 Adaptación del algoritmo de reconocimiento de texto 69 4.2.1 Proceso de segmentación de caracteres y generación de la salida 69 4.2.2 Filtrado y ordenación de los caracteres segmentados 72 4.2.3 Reconocimiento de los caracteres para la formación de palabras 74 4.3 Corrección del texto reconocido 76 4.4 Resultado final 77 5 Conclusiones 81 Apéndice A Códigos para la Detección de Texto 83 A.1 Preprocesamiento de las anotaciones del “dataset” de ICDAR 2013 83 A.2 Funciones de uso general de EASTlite 85 A.3 Proceso de entrenamiento e inferencia 109 Apéndice B Códigos para el Reconocimiento de Texto 117 B.1 Construcción y entrenamiento de una red neuronal con el “dataset” EMNIST 117 B.2 Construcción y entrenamiento de una red neuronal con el “dataset” Chars74k 123 Apéndice C Códigos para la Aplicación Final 129 C.1 Funciones de uso general en la Aplicación Final 129 C.2 Aplicación Final 143 Índice de Figuras 147 Índice de Tablas 149 Índice de Códigos 151 Bibliografía 153
1 Introducción La escritura es una de las formas de comunicación más antiguas de la historia humana y, a pesar de su antigüedad, sigue teniendo una importancia capital, hasta el punto de evaluarse el nivel básico de estudios de una persona en función de si sabe leer y escribir (uno de los primeros hitos que debe lograr un niño después de nacer en países desarrollados). Esto se debe a que el texto se encuentra en todos los aspectos de la vida de una persona: en libros, en señales, en envases, entre otros, transmitiendo información muy valiosa del entorno, como por ejemplo su historia. En la imagen de la Figura 1.1 se muestra un ejemplo donde se aprecia información útil en forma de texto, como la parada de metro o la señal de tráfico. Esto es una muestra de que el texto no solo es un medio de comunicación, sino una necesidad para que las personas puedan desenvolverse adecuadamente en el mundo que les rodea. Figura 1.1 Imagen del “dataset” de entrenamiento de ICDAR 2015 [12]. En términos generales, un ser humano no tiene muchos problemas a la hora de detectar y comprender el texto que aparece en la Figura 1.1, ya que es un caso sencillo. La realidad es que la complejidad de identificar texto en una imagen, en un vídeo o en documentos, puede escalar de forma muy rápida. Por ejemplo, si el texto de la Figura 1.1 estuviera en un idioma que no conociera, el lector estaría completamente fuera de juego. Otro ejemplo, que supondría un desafío, sería la situación en la que se tuviera una secuencia de imágenes donde la cantidad de texto, su posición y orientación variara. Si esta secuencia va a una determinada velocidad, con total seguridad, el lector no identificaría todo el texto mostrado. Esto son solo dos ejemplos que muestran como el ser humano se vería abrumado ante situaciones del día a día. Es por eso que la identificación de texto de forma automática adquiere más importancia con el aumento de la complejidad de las situaciones donde el texto es el protagonista. Volviendo al ejemplo de que una persona fuera incapaz de reconocer el texto que tiene delante porque está en otro idioma, aplicaciones móviles como Google Lens ® [ 19 ] permiten al usuario, a través de una foto, detectar, reconocer, leer (ideal para personas invidentes) e incluso traducir el texto que ahí aparece. En la captura de la Figura 1.2 se muestra el uso de esta aplicación, demostrándose la gran utilidad de la identificación automática de texto. Esta identificación no solo se tiene que limitar al plano del usuario, sino 1
2Capítulo 1. Introducción que también tiene formas de aplicarse en el mundo de la industria (detección y reconocimiento del texto en etiquetas o números de serie de productos y/o subproductos) o en el de la seguridad (identificación de texto de matrículas de vehículos). Figura 1.2 Google Lens®identificando texto y traduciéndolo. 1.1 Alcance del Proyecto Comprendida la importancia de la información en formato de texto y de las posibles aplicaciones que se pueden lograr automatizado su identificación, se procede a establecer los límites de este Proyecto. Para ello es importante definir y acotar el área de estudio detrás de la detección y reconocimiento de texto de forma automática. Esta área se puede definir en dos grupos principales, como indica [52]: • Identificación de texto en documentos impresos o también conocido como “Optical Character Recognition” (OCR). Un claro ejemplo para entender este grupo es imaginarse un libro al se quiere escanear una de sus páginas para tenerla en formato PDF y editar el contenido de ésta. • Identificación de texto en el entorno o más conocido como “text in the wild” o “scene text”. El ejemplo más claro es el texto que se ve en la Figura 1.1. La diferencia entre ambos grupos es el estado en el que se encuentra el texto que se quiere reconocer. En el primer grupo, poniendo como ejemplo un documento, existe un alto contraste entre el fondo y el texto, siendo poco necesario detectar dónde está el texto. En estos casos, las técnicas clásicas de OCR cumplen bien su propósito [ 52 ]. El problema aparece cuando se cogen estas técnicas y se intenta aplicar al segundo grupo, donde aparecen las siguientes dificultades: • La diversidad del texto en forma, tipografía, colores, entre otros; que se puede encontrar en una misma imagen, como ocurre en la Figura 1.1. En esta imagen el texto de la parada de metro es muy diferente a de la señal de tráfico. • La complejidad de la propia imagen. Volviendo a la Figura antes mencionada, es necesario “buscar” dónde está el texto, es decir, éste puede no estar en la misma zona y hay elementos de la imagen que pueden confundirse como texto. • La distorsión que puede sufrir la imagen. Por ejemplo, la Figura 1.1 pertenece al banco de datos denominado ICDAR 2015 [ 12 ]. Estas imágenes se caracterizan por haber sido tomadas por una Google Glasses ® sin que el dueño se percate. Esto supone que las imágenes son sensibles a diferentes fenómenos, como el desenfoque o “blur” [43], que diluye la capacidad de detectar de forma clara el texto. En resumen, aplicar técnicas de OCR en está clase de imágenes resulta en fracaso o en un resultado poco perfilado para lo que se puede esperar en una aplicación real.
1.2 Objetivo del Proyecto 3 Para este Proyecto se va a buscar explorar el segundo de los grupos, es decir, identificar texto del tipo “scene text”, ya que se considera que es el escenario que presenta más retos a un sistema de identificación de texto automático. Como se muestra en [ 52 ], para la detección y reconocimiento de texto de tipo “text in the wild” se usan técnicas que se pueden agrupar en dos grupos: “Classical Machine Learning” (técnicas clásicas de extracción de características junto con algoritmos de “Machine Learning”) y “Deep Learning” (basado en redes neuronales). Gracias al “boom” que ha experimentado la Inteligencia Artificial y el desarrollo de las redes neuronales en los últimos años, el segundo grupo de técnicas se ha desarrollado enormemente y muestra resultados muy competentes cuando las condiciones de la imagen tomada son adversas. Por este motivo, se ha decido que el alcance del Proyecto se centre en la exploración e implementación de este segundo grupo de técnicas, las basadas en “Deep Learning”. Fijado el alcance del Proyecto: detección y reconcomiendo de “scene text” con algoritmos basados en “Deep Learning”, queda marcar el objetivo. Dado lo útil e interesante que resultan herramientas del día a día como puede ser Google Lens ® [ 19 ], el objetivo de este Proyecto buscará acercarse lo más posible a este ejemplo de identificación de texto automático. En la siguiente sección se marcará el objetivo a seguir, junto con las premisas y pasos que deben cumplirse. 1.2 Objetivo del Proyecto El objetivo del Proyecto será generar una aplicación informática que use la webcam del ordenador portátil y analice cada fotograma en busca de texto. Una vez localizado el posible texto, segmentar éste en caracteres y reconocerlos. Posteriormente, con todos los caracteres reconocidos, formar el texto original y corregirlo si es necesario. Mientras tanto, el usuario verá la imagen captura por la webcam en vivo, donde se resaltará el texto detectado, los caracteres, el texto reconocido en crudo y el texto corregido. Figura 1.3 Fotograma tomado de la aplicación resultante del Proyecto. En la Figura 1.3 puede verse el resultado final del Proyecto, donde se muestra cómo se le enseña a la webcam el lomo del libro titulado “Mundo Anillo”. Posteriormente, el algoritmo de detección marca el texto reconocido con una caja delimitadora roja y los caracteres se destacan con cajas azules. En ese momento entra en juego el algoritmo de reconocimiento para determinar qué dice el texto: “mund0 ani110” (letras rojas en la esquina inferior izquierda), que corregido dice: “mundo anilla” (letras azules en la esquina inferior izquierda). Todo este proceso lleva un total de 1.3 segundos (letras negras en la esquina superior derecha). Para lograr todo esto, se propone la siguiente hoja de ruta con los pasos a seguir: 1. Investigar e implementar un sistema basado en “Deep Learning” con la capacidad para detectar texto en una imagen y aportar coordenadas aproximadas de ese texto. Al querer que este sistema sea apto para la detección de texto en vivo en el ordenador portátil disponible, se debe buscar un sistema eficiente. 2. Con la zona de la imagen resaltada donde hay texto, se debe diseñar algún programa que sea capaz de segmentar los caracteres y después reconocer cada uno de ellos con técnicas de “Deep Learning”. De nuevo, debe ser un sistema tan eficiente como el primero.
4Capítulo 1. Introducción 3. El último paso será unir los dos objetivos anteriores y ordenar el texto reconocido para que tenga sentido. Finalmente, se hace la corrección de éste y se muestra al usuario. 1.3 Recursos y herramientas usados en el Proyecto La aplicación que se desea conseguir al final de este Proyecto se realizará en el lenguaje de programación de alto nivel Python [ 35 ], debido a su muy extendido uso en el mundo de la ciencia de datos y el “Deep Learning”. Este lenguaje de programación permite añadir de forma sencilla librerías, instaladas previamente, para extender las funcionalidades que el usuario puede usar. Para la realización del Proyecto se harán uso de las librerías expuestas en el Listing 1.1 y de la versión de Python 3.9.7. Para simplificar el desarrollo del Proyecto se usará la interfaz Anaconda Navigator [ 3 ]. Esta es una plataforma que permite la gestión de entornos e instalar paquetes y librerías de forma sencilla, convirtiéndola en un recursos muy recomendado en el mundo de la ciencia de datos con Python. Esta plataforma además tiene incluido varias aplicaciones e IDEs (Entorno Integrado de Desarrollo) para realizar la programación de los algoritmos necesarios por el Proyecto. La aplicación web JupiterLab [ 34 ] será la seleccionada para esa tarea, ya que permite la creación de “Notebooks” (documentos de cuadernos Jupyter con terminación .ipynb ) para poder programar en un entorno de desarrollo interactivo. Se usará la versión 2.1.1 del Anaconda Navigator y la 3.2.1 del JupiterLab. Código 1.1 Librerías de Python usadas durante el Proyecto. # Liberías para la gestión de archivos y directorios: import os #Modulo base import glob #Modulo base #Librerías para la gestión de vectores y tablas de datos: import numpy as np #v1.20.3 import pandas as pd #v1.3.4 # Libería y modulo dedicados al "Deep Learning": import tensorflow as tf #v2.7.0 from tensorflow import keras # Libería y funciones para crear y representar figuras: import matplotlib.pyplot as plt #v3.4.3 from matplotlib.patches import Polygon from matplotlib.backends.backend_pdf import PdfPages # Librería dedicada a los archivos CSV: import csv #Modulo base # Libería para gestión de tipo de los porcesos: import time #Modulo base # Libería para la gestión de imágenes: import cv2 #v4.5.4.60 (opencv-python) # Libería para trbajar con poligonos (interecciones, áreas, etc): import shapely as shap #v2.0.1 # Libería para hacer correcciones ortográficas: import enchant #v3.2.2 (pyenchant)
1.4 Conceptos básicos a considerar sobre el Proyecto 5 Definidos los recursos software, se pasa al entorno hardware. Para la realización del Proyecto se usará principalmente el ordenador portátil con las características técnicas recogidas en la Tabla 1.1. Será en este terminal donde se programará y se ejecutará la mayoría de código, especialmente el que requiera webcam, y donde está instalado el Anaconda Navigator. Aunque la potencia del portátil es suficiente para los programas ejecutados, durante el entrenamiento de modelos basado en “Deep Learning” el consumo de recursos es enorme y, por tanto, se hará uso de una máquina virtual de mayor potencia ofrecido por Google Colab ® [ 28 ]. Este es un servicio que dota al usuario de una conexión a una máquina virtual, con tiempo y recursos limitados, pensada para el desarrollo de modelos de Inteligencia Artificial. Esta máquina virtual tiene como especificaciones técnicas las mostradas en la Tabla 1.2, donde se destaca la memoria de la Unidad de Procesamiento Gráfico (GPU) que viene con la versión de pago de este servicio: Google Colab Pro ® . Adicionalmente, la máquina virtual viene dotada de serie con Python y muchas de las librerías necesarias del Código 1.1, mostrando una interfaz basada en JupiterLab. Tabla 1.1 Características técnicas del ordenador portátil usado en el Proyecto. Modelo HP ENVY 17 Notebook PC Sistema operativo Windows 10 Home 22H2, 64-bits Procesador (CPU) Intel(R) Core(TM) i7-5500U CPU @ 2.40GHz 2.40GHz Gráfica (GPU) NVIDIA GeForce GTX 850M, 2 GB Memoria RAM 12 GB Tabla 1.2 Características técnicas de Google Colab Pro®. Sistema operativo Ubuntu 22.04.2 LTS Procesador (CPU) Intel(R) Xeon(R) CPU @ 2.20GHz 2.20GHz Gráfica (GPU) NVIDIA Tesla T4, 16 GB Memoria RAM 12.7 GB Apunte sobre la instalación de “PyEnchant” La librería de Python para la corrección ortográfica de las palabras “PyEnchant” ( import enchant en el Código 1.1) se instaló siguiendo los pasos recomendados de http:// pyenchant.github.io/ pyenchant/ install.html como “On Windows: Using the binary wheel”, es decir, usando la instancia pip pyenchant . Esto cargará la librería con el diccionario en inglés por defecto. Si se desea usar el diccionario en español se debe ir al directorio C:/.../enchant/hunspell , donde están los archivos de diccionario almacenados, y añadir estos dos archivos: es_ES.dic yes_ES.aff (pulsando en esos archivos irá a la zona para descargarlos). 1.4 Conceptos básicos a considerar sobre el Proyecto Hasta este punto, se tiene todos recursos necesarios para llevar a cabo el Proyecto. Aun así, durante este Capítulo se han hablado de Inteligencia Artificial, “Machine Learning”, “Deep Learning”, de la necesidad de tener una buena GPU para el entrenamiento de modelos basados en los anteriores conceptos, pero no se ha tratado ninguno de estos. En esta sección se pretende hacer un breve resumen y plantear ciertas cuestiones que serán de ayuda para la compresión total del Proyecto. El concepto de Inteligencia Artificial ha cobrado una notoriedad asombrosa en los últimos años y, de forma acelerada, aparece en las vidas de las personas en forma de algoritmos o herramientas de gran utilidad, como puede ser Google Lens ® [ 19 ]. Según el capítulo 2 de [ 24 ], la Inteligencia Artificial se puede definir como la inteligencia que muestra una máquina, sin ser igual que la inteligencia humana, y que busca automatizar
6Capítulo 1. Introducción tareas consideradas intelectuales que las personas realizan de forma natural. En sí, la Inteligencia Artificial es toda un área de conocimiento con subcampos en constante evolución. Uno de esos campos es el conocido como aprendizaje automático o “Machine Learning”, que puede verse en la Figura 1.4. Figura 1.4 Inteligencia Artificial y sus subcampos [24]. Los distintos algoritmos que viven dentro del área del “Machine Learning” tiene la misión de, dada una entrada particular, hacer una predicción sin necesidad de una programación directa. Esto se logra haciendo que estos algoritmos “aprendan” de los datos, es decir, que estos busquen tendencias y patrones en los datos que les permita realizar una predicción acorde a la entrada recibida. Como se indica en el capítulo 2 de [ 24 ], la familia de algoritmos de “Machine Learning” se pueden clasificar en tres grupos: • El grupo denominado como “aprendizaje supervisado” se caracteriza en que cada unidad de los datos aparecen como una pareja: una entrada (por ejemplo, la foto de un gato) y una salida o etiqueta o “label” (en el mismo ejemplo, la salida del algoritmo será decir que la foto de la entrada representanta un gato). Entre los algoritmos asociados a este grupo se encuentran las redes neuronales1(“Deep Learning”). • El siguiente grupo es el conocido como “aprendizaje no supervisado”. Como se puede intuir del anterior grupo, este se caracteriza por tener los datos sin “label”. Por tanto, la misión de estos algoritmos será agrupar los datos en función de su similitud o disparidad. • El último grupo es el que recibe el nombre de “aprendizaje por refuerzo” o “Reinforcement Learning”. Aquí el algoritmo debe explorar un espacio desconocido y determinar las acciones a realizar mediante prueba y error. La forma de aprender de este algoritmo se basa en las políticas: estrategias de recompensa y penalización, que obliga al algoritmo acertar en el menor tiempo posible buscando el máximo de recompensas. El “Deep Learning” o aprendizaje profundo son una familia de técnicas que forman parte del área del “Machine Learning” y están representadas por los algoritmos conocidos como redes neuronales artificiales. Las redes neuronales está formadas por múltiples capas de procesamiento interconectadas para aportar varios niveles de abstracción y descubrir patrones complejos al realizar transformaciones lineales y no lineales a los datos de entrada. El objetivo final es afinar estas transformaciones para que la salida o “label” sea la predicción esperada. Aunque, como se puede ver en la Figura 1.4, el “Deep Learning” forma una pequeña parte del campo de la Inteligencia Artificial, es la que más se está desarrollado y cuyos resultados son los que más repercusión mediática tienen. El despegue acelerado que está viviendo el “Deep Learning” y, por consiguiente, la Inteligencia Artificial actualmente se debe gracias a cuestiones como: el acceso a grande bancos de datos para los entrenamiento de los algoritmos, la capacidad de que personas normales tengan acceso a recuso computacionales potentes (por ejemplo, los entornos virtuales de Google Colab ® [ 28 ]), la programación con software libre y mejora de estos algoritmos, y la extensa comunidad investigadora detrás (por ejemplo, la web https:// paperswithcode.com/ se dedica a mostrar artículos, con su código, de los algoritmos más recientes y su comparación con los existentes). A lo largo del Proyecto se ejemplificará que la cuestión de la programación de algoritmos de “Deep Learning” no supone un problema en su desarrollo, de hecho, todo lo contrario. A pesar de lo hasta ahora 1 Como se indica en el capítulo 1 de [ 6 ], no todas las redes neuronales se asocian a este grupo. Por ejemplo, los “autoencoders” pueden pertenecer al grupo “aprendizaje no superviado”. Aun así, para este Proyecto todos los algoritmos desarrollados pertenecerán al grupo “supervisado”.
Análisis del transito de información en la red de la Figura 1.5 7 visto del mundo del “Deep Learning”, aunque se antoje un disciplina compleja, se tiene a disposición herramientas fáciles de usar y muy potentes que automatizan la mayoría de procesos. Es aquí donde entra el uso de Python, como lenguaje de programación usado en el Proyecto, y las librerías más populares para trabajar en el área del “Deep Learning” (como se adelantó en el Código 1.1): TensorFlow [18] y la Interfaz de Programación de Aplicaciones (API) de Keras [ 17 ]. Siendo la segunda absorbida como API oficial de la primera, por eso en el Código 1.1 se llama a Keras a través de TensorFlow. A lo largo del Proyecto se verá como de sencillo es crear modelos de redes neuronales, adaptarlos y entrenarlos. También permite, a través de pocas lineas de código, gestionar las GPUs disponibles y aumentar la velocidad de ejecución de los modelos. 1.4.1 Redes neuronales y su tipología Definida la Inteligencia Artificial, sus subcampos y contextualizar un poco este mundillo, se procede a centrarse en el área del “Deep Learning”. En concreto se buscará profundizar en las redes neuronales y determinar cómo éstas “aprenden”. Como ya se ha comentado, las redes neuronales son algoritmos de varias capas acopladas dedicadas a la transformación de los datos de entrada para buscar patrones que les permitan realizar una predicción o “label” adecuada. Las redes neuronales no son algoritmos fijos, tienen una estructura y una tipología según el tipo de entrada que reciban y la salida que se espera. Figura 1.5 Esquema de una red neuronal densa generado por NN-SVG. La primera de las tipologías, la más sencilla y representativa de los algoritmos de redes neuronales, es la red neuronal de la Figura 1.5. Esta se denomina como red neuronal densamente conectada o densa (nombre que recibe debido a que las neuronas de cada capa están completamente conectada con las neuronas de la capa anterior) y está compuesta por capas de neuronas (las circunferencias de la imagen mencionada) y es en estas neuronas donde se aplican las transformaciones necesarias para que el algoritmo evolucione. En cada neurona se dispone de una o varias entradas xi , un peso por cada entrada wi , un sesgo o “bias” b y una salida y . La clave del “aprendizaje” de una red neuronal reside en el ajuste de los parámetros de ésta, es decir, los pesos wiy sesgo b. y=∑ i wixi+b(1.1) En la Ecuación 1.1 se puede ver que el resultado o salida de cada neurona y es una relación lineal entre las entradas de la misma xi y los parámetros de esta (pesos wi y sesgo b ) . Para añadir cierta componente no lineal y aumentar la riqueza del aprendizaje, a la salida y se la hace pasar por una función de activación. Las que se usarán en este Proyecto serán: la sigmoide (la salida tiende a 1 cuando x→∞ y a 0 cuando x→ −∞ ), la softmax (bastante parecida a sigmoide) y la ReLU (los valores negativos de y se saturan a 0, el resto se dejan igual). Se recuerda que esto es lo que ocurre en una neurona, para comprender el transito de información a través de la red neuronal, es necesario ir acoplando las neuronas de cada capa desde la entrada hasta la salida (a continuación, se facilita un resumen que permitirá al lector ver cómo se realiza este flujo).
8Capítulo 1. Introducción Análisis del transito de información en la red de la Figura 1.5 La red neuronal de la Figura 1.5 está compuesta por cuatro capas que, denominadas de izquierda a derecha, son: una capa de entrada o “input layer” de 10 neuronas, dos capas de ocultas o “hidden layers” de 20 neuronas cada una y una capa de salida o “output layer” de 4 neuronas. El transito de información empieza en la “input layers”, donde cada neurona j (con su peso wj y su sesgo bj ) de la capa recibe una entrada xj y genera una salida yj=wjxj+bj (aplicando o no función de activación a yj). La salida de cada una de estas neuronas de entradas se transmite a la siguiente capa, la primera “hidden layer”. Cada una de las neuronas j de la capa oculta recibiera la salida de todas las neuronas de entrada, que ahora se conocerán como características o “features” x1,...,10; j , y tendrá que aplicarles la Ecuación 1.1, asociándole a cada característica un peso w1,...,10; j y un sesgo único bj . Finalmente, de cada neurona j de la primera capa oculta se obtendrá una salida yj (con o sin función de activación, decisión del diseñador) que se deberán trasmitir a cada neurona de la siguiente capa oculta. El proceso se repetiría por todas las capas ocultas que hubiera hasta llegar a la capa de salida o “output layer”. En este punto, todas las salidas de las neuronas de la última capa oculta pasan a cada neurona de salida para aplicarle la Ecuación 1.1. Como resultado se tendrán a la salida de la red neuronal cuatro salidas o etiquetas o “labels” que componen la predicción. La segunda tipología que es interesante comentar es la conocida como red neuronal convolucional, que supone un pequeño salto en complejidad y en forma de “aprender” de los datos. Como se explica ampliamente en el capítulo 14 de [ 6 ], las redes neuronales convolucionales están basadas en cómo funcionan las neuronas dentro la corteza visual de una persona: éstas disponen de un pequeño campo receptivo especializados que va recorriendo la imagen en busca de patrones locales como bordes u orientaciones, independientemente de su posición en el campo visual. Estos patrones locales son a su vez captados por otras neuronas para conformar patrones más complejos. Las redes neuronales convolucionales funcionan de una forma parecida y han demostrado rendir de forma sobresaliente en el análisis de imágenes 2 (aunque hay formas de usarlas para otros tipos de entradas como puede ser la voz). Figura 1.6 Funcionamiento del proceso de convolución [24]. Una capa de redes neuronales convolucionales cuenta como campo receptivo el llamado filtro o “kernel”. Ésta es una pequeña ventana de dos dimensiones que va recorriendo los píxeles de una imagen de izquierda a derecha y de arriba a abajo para aplicar una operación de convolución (al final de la subsección hay un ejemplo para ver como se realiza) a los píxeles dentro de la ventana y volcarlo en el llamado mapa de características, como se puede ver en la Figura 1.7. Este contiene la información extraída de cada región de la imagen y representa las características “aprendidas” de la imagen (muy parecido a lo que se explicó de la corteza visual). Precisamente, en aras de obtener un mapa de características rico con varios patrones “aprendidos”, se 2 La red neuronal densas puede adaptarse para recibir imágenes, como se hace en el capítulo 3 de [ 24 ]. Pero, en vista de la gran mejora que traen las redes neuronales, estas no se suelen usar para imágenes.
1.4 Conceptos básicos a considerar sobre el Proyecto 9 configura que la capa convolucional tenga más de un “kernel”, por tanto, se tendrá un mapa de características en tres dimensiones. Como también ocurre en la corteza visual, la combinación de varias capas convoluciones permite ir obteniendo patrones más complejos. Para comprender qué le sucede a una imagen a su paso por una capa convolucional y ver lo fácil que es programar redes neuronales en TensorFlow y Keras, se ha creado el ejemplo del Código 1.2. En él se carga la imagen a color de la Figura 1.1, cuyas dimensiones son [720 ×1280 ×3] (alto, ancho y canales de color RGB), y se normaliza a valores comprendidos entre [0,1] (técnica de preprocesado típica al trabajar con imágenes que entran en una red neuronal). A continuación, se define un modelo de red neuronal convolucional compuesto por: una entrada (no tiene efectos, aparte de indicar que la entrada será del tipo [h×w×3] ) y una capa convolucional. Ésta estará configurada para generar 9 filtros o “kernels”, es decir, que el mapa de características a la salida de esta capa tendrá 9 canales o versiones. También, se ha indicado que las dimensiones de cada “kernel” será [3×3] y usará una función de activación ReLU. Finalmente, se define este modelo de red y se calcula la salida, es decir, se hace pasar la imagen por la capa generada para ver el mapa de características resultante. Código 1.2 Someter una imagen a capa convolucional de 9 filtros. # Cargar la imagen y adaptarla a la CNN: img = cv2.imread("Sample_Img.jpg")[:, :, ::-1] #BRG -> RGB input_img = img.copy()/255 #Nomalizado [0, 1] # Crear la CNN y hacer la predicción: input_ly = tf.keras.Input(shape=[None, None, 3], dtype=tf.float32) conv_ly = tf.keras.layers.Conv2D(filters=9, kernel_size=(3,3), activation="relu ", padding="valid")(input_ly) model = tf.keras.Model(inputs=input_ly, outputs=[conv_ly], name="TFM_Conv") output_img = model.predict(tf.convert_to_tensor(input_img[np.newaxis, :, :, :]) , batch_size=1) Figura 1.7 Imagen de la Figura 1.1 al pasar por la capa de convolución. El mapa de características resultante de la Figura 1.7 tiene dimensiones [718 ×1278 ×9] , destacándose que los valores de alto y ancho se han reducido respecto a la imagen de entrada. Como ya se ha indicado, los “kernels” actúan como ventanas que se desplazan con un “stride” de 1 por el ancho y alto de la imagen (el “stride” es un parámetro modificable para que la ventana no se solape con píxeles anteriores y recorra la imagen en saltos más largos). Esto hace que necesariamente se pierdan filas y columnas; tantas como el tamaño del “kernel” y el “stride” definan. Para evitar la perdida de dimensiones del mapa de características a su paso por la red, se usa el parámetro de “padding” en “same” (rellenar con 0 las filas y columnas que se pierden al paso del “kernel”). Una de las últimas cuestiones a resolver en esta subsección y que sí estaba claro en las redes neuronales densas, es dónde están los parámetros ( w pesos y b sesgos) que se debe ajustar. Estos se encuentran en los
2 Detección de Texto La detección de texto es la primera etapa para realizar este Proyecto. Esto es así, debido a que el texto categorizado como “text in the wild” o “scene text” tiene la dificultada añadida de que, para poder reconocer ese texto, hay primero que saber dónde está éste. Un ejemplo claro se puede encontrar en la Figura 2.1, donde se ha destacado con cajas delimitadoras verdes (texto reconocible) y rojas (texto no reconocible) la zonas de la imagen que incluyen texto. Esto, aparte de ilustrar el objetivo que se persigue en este Capítulo, muestra que ante una foto del entorno no se puede lograr reconocer el texto automáticamente con éxito si no se extraen las zonas concretas que lo contienen, ya que el resto de la imagen actuaría como distracción para algoritmo de reconocimiento. Figura 2.1 Imagen y anotaciones del “dataset” de entrenamiento de ICDAR 2015 [12]. Teniendo claro el objetivo y el motivo de este Capítulo, se procede a realizar un breve estudio de los algoritmos de detección de texto que se presentan en el estado del arte. A continuación, se elegirá uno y se implementará con ayuda de TensorFlow [ 18 ] y Keras [ 17 ] en Python [ 35 ]. Ese algoritmo deberá cumplir con las especificaciones comentadas en el Capítulo anterior: basado en “Deep Learning”, que sea sencillo de implementar y sea eficiente para poder ejecutar durante una toma en vivo por webcam. 2.1 Estado del arte Como ya se ha introducido, el primer paso será explorar el estado del arte de los detectores de texto y elegir el algoritmo que más se preste a las especificaciones establecidas. Como ya indicó en el Capítulo anterior y se muestra en el esquema de la Figura 2.2, los detectores de texto en imagen se dividen en dos grupos: • Los algoritmos basados en “Classical Machine Learning”, el cual tiene dos vertientes: “Sliding Window” y “Connected Component”. El primero de ellos consiste en realizar una representación piramidal de la imagen para ser escaneada por una ventana deslizante de dos dimensiones. Las características extraídas con este primer método se clasifican para saber si esa posibles ubicaciones contiene texto. El método 17
18 Capítulo 2. Detección de Texto “Connected Component” es más robusto que el primero y se basa en extracción de regiones de la imagen con características similares para, después, clasificarlas en zona con texto y sin texto a nivel de caracteres. Posteriormente, se componen las regiones con texto para formar palabras o líneas. Ambas familias de métodos presentan varios pasos complejos que son propensos a ir propagando errores y presentan dificultad a la hora de texto con múltiples orientaciones. • El segundo grupo es el que más se ha desarrollado en los últimos años y en el que se centrará el estudio de este Capítulo, éste está basado en “Deep Learning”. Esta familia de métodos se desgrana a su vez en tres grupos: “Bounding-Box Regression”, “Segmentation” y “Hybrid”. El primero de ellos trata al texto como un objeto y resuelve la detección como una tarea de detección, explicada en el Capítulo anterior. La gran desventaja de este método es que, en general, sufre para poder detectar el texto cuando las orientaciones son muy variadas y falla cuando está frente a texto curvo. Por otro lado, los algoritmos basados en la “Segmentation” buscan resolver una tarea de segmentación (también explicada en el Capítulo anterior en sus dos vertientes: “semantic” y “instance”), es decir, clasificar cada píxel de la imagen como texto o no-texto. La versión “Hybrid”, como su propio nombre indica, plantea un enfoque conjunto donde se resuelve una tarea de segmentación y posteriormente se le asigna una “bounding box”. Text Detection and Recognition in the Wild: A Review 3 Text Detection Methods Classical Machine‐ Learning Deep-Learning Sliding Window Connected Component Bounding-Box Regression Segmentation Hybrid Sementic Instance Fig. 2: General taxonomy for the various text detection approaches. Text Detection “HUMP” “AHEAD” Text Recognition Input Image Output String Fig. 3: General schematic diagram of scene text detection and recognition, where sample image is from the public dataset in [84]. In sliding window-based methods, such as [17–22], a given test image is used to construct an image pyramid to be scanned over all the possible text locations and scales by using a sliding window of certain size. Then, a certain type of image features (such as mean difference and standard deviation as in [19], histogram of oriented gradients (HOG) [85] as in [20,86,87] and edge regions as in [21]) are obtained from each window and classified by a classical classifier (such as random ferns [88] as in [20], and adaptive boosting (AdaBoost) [89] with multiple weak classifiers, for instance, decision trees [21], log-likelihood [18], likelihood ratio test [19]) to detect text in each window. For example, in an early work by Chen and Yuille [18], intensity histograms, intensity gradients and gradient directions features were obtained at each sliding window location within a test image. Next, several weak log-likelihood classifiers, trained on text represented by using the same type of features, were used to construct a strong classifier using the AdaBoost framework for text detection. In [20], HOG features were extracted at every sliding window location and a Random Fern classifier [90] was used for multi-scale character detection, where the non-maximal suppression (NMS) in [91] was performed to detect each character separately. However, these methods [18,20,21] are only applicable to detect horizontal text and have a low detection performance on scene images, which have arbitrary orientation of text [92]. Connected-component based methods aim to extract image regions of similar properties (such as color [23–27], texture [93], boundary [94–97], and corner points [98]) to create candidate components that can be categorized into text or non-text class by using a traditional classifier (such as support vector machine (SVM) [78], Random Forest [82] and nearest-neighbor [99]). These methods detect characters of a given image and then combine the extracted characters into a word [58,78,79] or a text-line [100]. Unlike slidingwindow based methods, connected-component based methods are more efficient and robust, and they offer usually a lower false positive rate, which is crucial in scene text detection [73]. Maximally stable extremal regions (MSER) [57] and stroke width transform (SWT) [58] are the two main representative connected-component based methods that constitute the basis of many subsequent text detection works [30,72,78,79,82,83,97,100–102]. However, the mentioned classical methods aim to detect individual characters or components that may easily cause discarding regions with ambiguous characters or generate a large number of false detection that reduce their detection performance [103]. Furthermore, they require multiple complicated sequential steps, which lead to easily propagating errors to later steps. In addition, these methods might fail in some dif- Figura 2.2 Tipología de métodos de detección de texto según [52]. Entre todo los métodos basados en “Deep Learning” propuestos en [ 52 ] destaca uno por su sencillez y por varías características que ahora se explicarán. Ese detector es el denominado como EAST [ 47 ], el cual se presentó en el “Congreso sobre el Reconocimiento de Patrones y la Visión por Ordenador” [ 1 ] de 2017. Este es un detector agrupado en la familia “Bounding-Box Regression” y, al compararse con varios métodos detección del estado del arte, resalta gracias a estas tres ventajas: • Como se puede ver en la Figura 2.3, la detección de texto requiere de varias pasos para dar un resultado satisfactorio. El algoritmo EAST simplifica el método de detección general a solo dos etapas: red neuronal de detección y; posprocesado por umbralización o “thresholding” y “Non-Maximum Suppression” o NMS [ 21 ]. Esta estrategia se basa en el diseño general de DenseBox [ 27 ], que permite evitar pasos redundantes y optimizar los recursos. • El resultado de la detección de texto da como resultado unas cajas delimitadoras o “bounding boxes” que encierran la zona de la imagen donde hay texto, similar a lo visto en el ejemplo de la Figura 2.1. El algoritmo EAST permite una detección de texto por palabra o línea con formas geométricas cuadrangulares con múltiples orientaciones (horizontal, vertical y cualquier orientación intermedia). Esta flexibilidad de detección hace que se desmarque del resto de algoritmos del estado del arte. • Además de su gran simplicidad y flexibilidad, EAST tiene un nivel de precisión y velocidad a la hora de detectar texto que lo hace insuperable a los algoritmos comparados, como se describe en [ 47 ]. Esto lo habilita para ser una buena opción a la hora de detectar texto en vídeo. De hecho, gracias a estas características, el detector EAST debe su nombre: “Efficient and Accurate Scene Text”. Como ya se ha comentado, estas tres ventajas ha permitido que EAST se haya abierto hueco en el estado del arte de los detectores de texto y sea mencionado en [ 52 ]. Y precisamente esas ventajas son las que convierten el detector de texto EAST en el mejor candidato para este Proyecto, al cumplir con todas las exigencias que se platearon al inicio. Es más, esta decisión se vio reforzada al saberse que uno de los investigadores del proyecto, Xinyu Zhou, logró implementar de forma muy sencilla el algoritmo en TensorFlow (librería con la que se desea hacer este Trabajo) en el repositorio de GitHub [46].
2.2 Red neuronal del detector de texto 19 Input Image (a) (b) (c) (d) Multi-channel FCN Multi-orient text-line/word boxes Thresholding & NMS Text-line/word score map rotated boxes / quadrangle Multi-channel FCN multi-orient text-line boxes Score maps & linking orientation Delaunay triangulation Edge-weight Generation Text-line Generation Word partition Multi-orient word boxes Character linking graph Text-block FCN Character FCN Text-block score map Text-line candidate generation Text-line score map Character score map Rule-based filtering multi-orient text-line boxes Word partition Multi-orient word boxes Fine-scale text proposals Horizontal text-line/word boxes Connectionist Text Proposal Network Text-line Formation Proposal extraction Bounding box regression Word box proposals Recognition Thresholding & merging Hoizontal word boxes Proposal filtering Word box proposals Word boxes (e) our pipeline Figure 2. Comparison of pipelines of several recent works on scene text detection: (a) Horizontal word detection and recognition pipeline proposed by Jaderberg et al. [12]; (b) Multi-orient text detection pipeline proposed by Zhang et al. [48]; (c) Multi-orient text detection pipeline proposed by Yao et al. [41]; (d) Horizontal text detection using CTPN, proposed by Tian et al. [34]; (e) Our pipeline, which eliminates most intermediate steps, consists of only two stages and is much simpler than previous solutions. COCO-Text [36], outperforming previous state-of-the-art algorithms in performance while taking much less time on average (13.2fps at 720p resolution on a Titan-X GPU for our best performing model, 16.8fps for our fastest model). The contributions of this work are three-fold: •We propose a scene text detection method that consists of two stages: a Fully Convolutional Network and an NMS merging stage. The FCN directly produces text regions, excluding redundant and time-consuming intermediate steps. •The pipeline is flexible to produce either word level or line level predictions, whose geometric shapes can be rotated boxes or quadrangles, depending on specific applications. •The proposed algorithm significantly outperforms state-of-the-art methods in both accuracy and speed. 2. Related Work Scene text detection and recognition have been active research topics in computer vision for a long period of time. Numerous inspiring ideas and effective approaches [5, 25, 26, 24, 27, 37, 11, 12, 7, 41, 42, 31] have been investigated. Comprehensive reviews and detailed analyses can be found in survey papers [50, 35, 43]. This section will focus on works that are mostly relevant to the proposed algorithm. Conventional approaches rely on manually designed features. Stroke Width Transform (SWT) [5] and Maximally Stable Extremal Regions (MSER) [25, 26] based methods generally seek character candidates via edge detection or extremal region extraction. Zhang et al. [47] made use of the local symmetry property of text and designed various features for text region detection. FASText [2] is a fast text detection system that adapted and modified the well-known FAST key point detector for stroke extraction. However, these methods fall behind of those based on deep neural networks, in terms of both accuracy and adaptability, especially when dealing with challenging scenarios, such as low resolution and geometric distortion. Recently, the area of scene text detection has entered a new era that deep neural network based algorithms [11, 13, 48, 7] have gradually become the mainstream. Huang et al. [11] first found candidates using MSER and then employed a deep convolutional network as a strong classifier to prune false positives. The method of Jaderberg et al. [13] scanned the image in a sliding-window fashion and produced a dense heatmap for each scale with a convolutional neural network model. Later, Jaderberg et al. [12] employed both a CNN and an ACF to hunt word candidates and further refined them using regression. Tian et al. [34] developed vertical anchors and constructed a CNN-RNN joint model to detect horizontal text lines. Different from these methods, Zhang et al. [48] proposed to utilize FCN [23] for heatmap generation and to use component projection for orientation estimation. These methods obtained excellent performance on standard benchmarks. However, as illustrated in Fig. 2(a-d), they mostly consist of multiple stages and components, such as false positive removal by post filtering, candidate aggregation, line formation and word partition. The multitude of stages and components may require exhaustive tuning, leading to sub-optimal performance, and add to processing time of the whole pipeline. In this paper, we devise a deep FCN-based pipeline that directly targets the final goal of text detection: word or textline level detection. As depicted in Fig. 2(e), the model abandons unnecessary intermediate components and steps, and allows for end-to-end training and optimization. The resultant system, equipped with a single, light-weighted neural network, surpasses all previous methods by an obvious margin in both performance and speed. 3. Methodology The key component of the proposed algorithm is a neural network model, which is trained to directly predict the existence of text instances and their geometries from full Figura 2.3 Comparativa de varios algoritmos de detección de texto basados en “Deep Learning” [47]. En este punto hay que recordar que, una de las condiciones que había permitido el gran desarrollo del “Deep Learning” en estos años, es la liberalización de los estudios en este sector. Esto permite que, dando un detector de texto como EAST, otros investigadores decidan plantear alternativas y modificaciones al trabajo original. En el Trabajo Fin de Máster [ 7 ], el autor diseña el algoritmo EASTlite al modificar la red neuronal de EAST buscando obtener un modelo más ligero, manteniendo o mejorando las métricas de desempeño originales, y adaptándolo a poder usarse en dispositivos móviles. Sumado a esto, EASTlite también está implementado en TensorFlow. Por tanto, el detector de texto EASTlite se ha considerado el mejor candidato para resolver esta tarea del Proyecto. En las siguientes secciones se va presentar el algoritmo completo del detector de texto elegido, EASTlite. Aunque esta moderna versión sólo implica una serie de modificaciones leves, ya que hablar de EASTlite en esencia es hablar del algoritmo EAST. Una vez que se defina el algoritmo e se implemente en Python, se pasará a ver su desempeño en el entrenamiento y los resultados que éste proporcione. Ya se ha indicado que el algoritmo debe su diseño a DenseBox [ 27 ], por tanto, se verán varias similitudes que se irán comentado durante toda la implementación. 2.2 Red neuronal del detector de texto Como el título de la actual sección sugiere, se comenzará hablar del bloque más influyente de todo el algoritmo: la red neuronal. Como ya se ha comentado, la red neuronal debe estar muy capacitada para detectar texto en la imagen y solo requerir de los pequeños ajustes que ofrece el posprocesado. 7×7, 16, /2 conv stage 1 64, /2 conv stage 2 128, /2 conv stage 3 256, /2 conv stage 4 384, /2 unpool, ×2 3×3, 128 1×1, 128 concat 3×3, 64 1×1, 64 concat 3×3, 32 1×1, 1 1×1, 1 Feature extractor stem (PVANet) Feature-merging branch Output layer score map text rotation angle 1×1, 8 text quadrangle coordinates unpool, ×2 3×3, 32 1×1, 32 concat unpool, ×2 1×1, 4 text boxes f1 f2 f3 f4 h2 h3 h4 h1 RBOX geometry QUAD geometry Figure 3. Structure of our text detection FCN. images. The model is a fully-convolutional neural network adapted for text detection that outputs dense per-pixel predictions of words or text lines. This eliminates intermediate steps such as candidate proposal, text region formation and word partition. The post-processing steps only include thresholding and NMS on predicted geometric shapes. The detector is named as EAST, since it is an Efficient and Accuracy Scene Text detection pipeline. 3.1. Pipeline A high-level overview of our pipeline is illustrated in Fig. 2(e). The algorithm follows the general design of DenseBox [9], in which an image is fed into the FCN and multiple channels of pixel-level text score map and geometry are generated. One of the predicted channels is a score map whose pixel values are in the range of [0,1]. The remaining channels represent geometries that encloses the word from the view of each pixel. The score stands for the confidence of the geometry shape predicted at the same location. We have experimented with two geometry shapes for text regions, rotated box (RBOX) and quadrangle (QUAD), and designed different loss functions for each geometry. Thresholding is then applied to each predicted region, where the geometries whose scores are over the predefined threshold is considered valid and saved for later nonmaximum-suppression. Results after NMS are considered the final output of the pipeline. 3.2. Network Design Several factors must be taken into account when designing neural networks for text detection. Since the sizes of word regions, as shown in Fig. 5, vary tremendously, determining the existence of large words would require features from late-stage of a neural network, while predicting accurate geometry enclosing a small word regions need lowlevel information in early stages. Therefore the network must use features from different levels to fulfill these requirements. HyperNet [19] meets these conditions on features maps, but merging a large number of channels on large feature maps would significantly increase the computation overhead for later stages. In remedy of this, we adopt the idea from U-shape [29] to merge feature maps gradually, while keeping the upsampling branches small. Together we end up with a network that can both utilize different levels of features and keep a small computation cost. A schematic view of our model is depicted in Fig. 3. The model can be decomposed in to three parts: feature extractor stem, feature-merging branch and output layer. The stem can be a convolutional network pre-trained on ImageNet [4] dataset, with interleaving convolution and pooling layers. Four levels of feature maps, denoted as fi, are extracted from the stem, whose sizes are 1 32 ,1 16 ,1 8and 1 4of the input image, respectively. In Fig. 3, PVANet [17] is depicted. In our experiments, we also adopted the well-known VGG16 [32] model, where feature maps after pooling-2 to pooling-5 are extracted. In the feature-merging branch, we gradually merge them: gi=(unpool(hi)if i≤3 conv3×3(hi)if i= 4 (1) hi=(fiif i= 1 conv3×3(conv1×1([gi−1;fi])) otherwise (2) where giis the merge base, and hiis the merged feature map, and the operator [·;·]represents concatenation along the channel axis. In each merging stage, the feature map from the last stage is first fed to an unpooling layer to double its size, and then concatenated with the current feature map. Next, a conv1×1bottleneck [8] cuts down the number of channels and reduces computation, followed by a conv3×3that fuses the information to finally produce the output of this merging stage. Following the last merging stage, a conv3×3layer produces the final feature map of the merging branch and feed it to the output layer. The number of output channels for each convolution is shown in Fig. 3. We keep the number of channels for convolutions in branch small, which adds only a fraction of computation overhead over the stem, making the network computation-efficient. The final output layer contains several conv1×1operations to project 32 channels of feature maps into 1 channel of score map Fsand a multi-channel geometry map Fg. The geometry output can be either one of RBOX or QUAD, summarized in Tab. 1 For RBOX, the geometry is represented by 4 channels of axis-aligned bounding box (AABB) Rand 1 channel rotation angle θ. The formulation of Ris the same as that in [9], where the 4 channels represents 4 distances from the Figura 2.4 Estructura de la red neuronal de EAST [47]. La red neuronal del algoritmo EAST debe su arquitectura a una red neuronal completamente convolucional o FCN. Como se puede apreciar en la Figura 2.4, esta red está compuesta por tres bloques: el extractor de características o “feature extractor stem”, el fusionador de características o “feature-merging branch” y la capa de salida de la red o “output layer”. La modificación de la FCN por parte de EASTlite solo supone
20 Capítulo 2. Detección de Texto cambiar la red neuronal representativa del extractor de características e insertar regularizadores de los pesos de las capas convolucionales del resto de bloques para evitar el temido “overfitting” durante el entrenamiento. Cada uno de estos aspectos se irá desgranado en las sucesivas subsecciones con extractos de código. 2.2.1 Extractor de características El primero de los bloques de esta FCN se denomina como “feature extractor stem” o extractor de características. Este bloque es conocido comúnmente como columna vertebral o “backbone” de la red neuronal. Este concepto hace alusión a que es la arquitectura central que soporta el proceso de aprendizaje y permite a la red extraer características [ 31 ]. En este caso concreto, el “backbone” se caracteriza por extraer mapas de características a diferentes niveles, lo que hace que mejore el rendimiento en tareas como la detección [27]. La extracción de características a distintos niveles en EAST [ 47 ] y EASTlite [ 7 ] adquiere una importancia aun mayor debido a la variabilidad de los tamaños de las palabras o líneas detectadas en la imagen. Por ejemplo, en el caso de las palabras detectadas de gran tamaño se necesitará de las características obtenidas de las últimas capas convoluciones (el avance a través la red convolucional hace que destaquen detalles locales importantes para encontrar partes de apariencia discriminatoria). En contraste, cuando la geometría de la palabra es más pequeña, las características deseadas se encuentran en las primeras capas de la red (las características a mayor alto nivel aparecen en las primeras capas donde campo receptivo es más amplio, el cuál, cuanto mayor sea, aporta información contextual para predecir resultados más precisos [27]). 2 copy and crop input image tile output segmentation map 641 128 256 512 1024 max pool 2x2 up-conv 2x2 conv 3x3, ReLU 572 x 572 284² 64 128 256 512 570 x 570 568 x 568 282² 280²140² 138² 136² 68² 66² 64² 32² 28² 56² 54² 52² 512 104² 102² 100² 200² 30² 198² 196² 392 x 392 390 x 390 388 x 388 388 x 388 1024 512 256 256 128 64 128 64 2 conv 1x1 Fig. 1. U-net architecture (example for 32x32 pixels in the lowest resolution). Each blue box corresponds to a multi-channel feature map. The number of channels is denoted on top of the box. The x-y-size is provided at the lower left edge of the box. White boxes represent copied feature maps. The arrows denote the different operations. as input. First, this network can localize. Secondly, the training data in terms of patches is much larger than the number of training images. The resulting network won the EM segmentation challenge at ISBI 2012 by a large margin. Obviously, the strategy in Ciresan et al. [1] has two drawbacks. First, it is quite slow because the network must be run separately for each patch, and there is a lot of redundancy due to overlapping patches. Secondly, there is a trade-off between localization accuracy and the use of context. Larger patches require more max-pooling layers that reduce the localization accuracy, while small patches allow the network to see only little context. More recent approaches [11,4] proposed a classifier output that takes into account the features from multiple layers. Good localization and the use of context are possible at the same time. In this paper, we build upon a more elegant architecture, the so-called “fully convolutional network” [9]. We modify and extend this architecture such that it works with very few training images and yields more precise segmentations; see Figure 1. The main idea in [9] is to supplement a usual contracting network by successive layers, where pooling operators are replaced by upsampling operators. Hence, these layers increase the resolution of the output. In order to localize, high resolution features from the contracting path are combined with the upsampled Figura 2.5 Estructura de la red neuronal de U-Net. Tabla 2.1 Variación del mapa de características a su paso por el “feature extractor stem”. Nombre de la capa Tamaño del mapa Designación Entrada (imagen de entrada) h×w×3− Etapa de convolución 1 (h/4)×(w/4)×... f4 Etapa de convolución 2 (h/8)×(w/8)×... f3 Etapa de convolución 3 (h/16)×(w/16)×... f2 Etapa de convolución 4 (h/32)×(w/32)×... f1 Este hecho fuerza a tener que diseñar la red neuronal siguiendo una estructura en U-Net [ 30 ], la cual se aprecia en Figura 2.5. La elección de esta estructura, y no otra, es debido a que se pueden fusionar y extraer los mapas de características gradualmente, al tiempo que se mantiene un consumo de recursos bajo. Esto se materializa en EAST y EASTlite haciendo extracciones en el “backbone”, designadas como fi , a cuatro niveles. Como se ve en la Tabla 2.1, la imagen de entrada tiene como dimensiones: [alto ×ancho ×canales] o [h×w×3] (el número de canales es 3, haciendo alusión a una imagen a color RGB). A medida que la imagen avanza por el “feature extractor stem”, las dimensiones [alto ×ancho] , o [h×w] , originales del
2.2 Red neuronal del detector de texto 21 mapa de características se van reduciendo1y los canales aumentan (ver la Tabla 2.1). Durante el desarrollo de EAST original, la red neuronal que conforma el “backbone” (que se definen a partir de cosas como: estructura de la red, número de capas usada, tamaño del kernel de las capas convolucionales, hiperparámetros de diseño de las capas, etc) y el número de los canales de los mapas de características extraídos ha ido cambiando. Estos cambios tienen como restricción cumplir con el [alto ×ancho] del mapa de características indicado en la Tabla 2.1 para cada etapa de extracción. Las tres redes neuronales usadas como “backbone” han sido las siguientes: • El artículo original de EAST [ 47 ] usa como “backbone” la red neuronal PVAMet [ 26 ]. Esta red neuronal permite tener un modelo de EAST bastante ligero en número de parámetros y rápido, ideal para la aplicación que se desea obtener el final. Como se ve en la Figura 2.4, se hace uso de toda la red neuronal y las extracciones de mapas de características se aplica a las salidas de las siguientes etapas: conv2_3 (f4, 64 canales), conv3_4(f3, 128 canales), conv4_4(f2, 256 canales) y conv5_4(f1, 384 canales). • La estructura anterior seria ideal, pero PVAMet no está integrado en la API de Keras [ 17 ]. Por tanto, en la implementación de EAST en TensorFlow [ 46 ], Xinyu Zhou decide usar la red neuronal ResNet-50 [ 25 ] como “backbone”. De nuevo, se hace uso de toda la red neuronal y se hace las extracción a la salida de estas etapas: conv2_3x3 ( f4 , 256 canales), conv3_3x4 ( f3 , 512 canales), conv4_3x6 ( f2 , 1024 canales) y conv5_3x3 ( f1 , 2048 canales). Que en la API de Keras serían: pool2 , pool3 , pool4 y pool5 , respectivamente. • El cambio de PVAMet a ResNet-50, para poder implementar EAST en TensorFlow, viene con un problema añadido: el número de parámetros de la FCN se dispara a 24 millones, lo que supone que EAST pierde la capacidad de red neuronal ligera que tenía antes del cambio. Este punto es destacado y solucionado al pasar a implementar las modificaciones propuestas en EASTlite, siendo la más importante usar como “backbone” la red neuronal MobileNet [5]. Esto permite volver a red neuronal ligera con 3 millones de parámetros manteniendo las métricas de precisión. En cuanto al “backbone”, se usa MobileNet completamente, a excepción de la última capa convolucional estándar, y realizando las extracciones de mapas de características a la salida de estas etapas: conv_dw_2 ( f4 , 128 canales), conv_dw_5 ( f3 , 256 canales), conv_dw_11 ( f2 , 512 canales) y conv_dw_13 ( f1 , 1024 canales). Que en la API de Keras serían: conv_pw_2_relu , conv_pw_5_relu , conv_pw_11_relu y conv_pw_13_relu , respectivamente. En aras de obtener la red neuronal EASTlite [ 7 ], la última versión del “backbone” será la implementada en el Proyecto. La implementación en Python se puede ver en el Código 2.1. En él se configura al “backbone” para recibir imágenes a color en RGB y valores compendiados entre [0,225] . Posteriormente, la imagen se encontraría con una capa de preprocesamiento que, entre otras cosas, cambia el orden de los canales de la imagen a BRG (está configurado que sea el preprocesamiento de la ResNet-50, pero puede ser el de la MobileNet). A continuación, se carga el modelo de red neuronal MobileNet y se recorre este, definiendo las capas donde se hará las extracción fi. Código 2.1 Generación del “feature extractor stem” con MobileNet. # Cargar la capa de entrada y de preprocesamiento de la entrada: input_ly = keras.Input(shape=[None, None, 3], dtype=tf.float32) input_ly = keras.applications.resnet.preprocess_input(input_ly) # Se carga el modelo MobileNet completo adapatado a la entrada: base_model = keras.applications.MobileNet(input_tensor=input_ly, include_top= False) # Se inicializa la lista que alamcenará los estado de extracción: base_model_layers = [] 1 La reducción del [alto ×ancho] de los mapas de características extraídos se debe a que alguna de las capas convolucionales usadas en cada etapa tiene un valor de “strides” distinto de (1,1) . Como se puede ver en la Figura 2.4, en cada etapa de extracción se especifica un “strides” de /2= (2,2) . Sumado a esto, las capas convolucionales con “stride” modificado también pueden tener el “padding” en “valid” (no compensar la perdida de dimensiones del mapa de características con ceros).
22 Capítulo 2. Detección de Texto # Se recorren todas las capas del modelo MobileNet: for layer in base_model.layers: # Se permite a la capas sean entrenables: layer.trainable = True # Se alamcenan aquellas capas que se van a usar: if layer.name in ["conv_pw_2_relu", "conv_pw_5_relu", "conv_pw_11_relu", " conv_pw_13_relu"]: base_model_layers.append(layer) #f1...f4 # Se invierte el orden de las capas y se define su salida: base_model_layers.reverse() #f1...f4 -> f4...f1, de abajo a arriba f = [layer.output for layer in base_model_layers] Como último apunte, el “backbone” de una red neuronal puede ser sustituido por un modelo preentrenado. Esto se conoce como “transfer-learning” (capítulo 5 de [ 9 ]) y se basa en la siguiente idea, supóngase que se debe desarrollar una red neuronal convolucional para realizar una tarea de clasificación, es decir, se diseñaría un modelo compuesto por una base convolucional (extracción de características) y un bloque de clasificación (capas de redes neuronales densas que usen las características extraídas para determinar a qué clase pertenece la imagen entrante). Si durante el proceso de entrenamiento se somete esta red neuronal hipotética a un gran banco de de imágenes con muchas clases, haría que la base convolucional adquiriera una capacidad de extracción de características muy genérica o, lo que es lo mismo, los mapas de características extraídos contendrían información genérica de la imagen introducida. Esta capacitación de la base convolucional puede aprovecharse para otras tareas, en este caso para la detección de texto. Como se indica en el capítulo 14 de [ 6 ] y en [ 47 ], se puede usar la base convolucional entrenada en otra tarea como “backbone” de la red EAST actual y evitar entrenar esas capas, lo que haría un entrenamiento más liviano y requeriría menos datos. La API de Keras hace este proceso tremendamente sencillo, ya que, al cargar un modelo como ResNet-50 o MobileNet, se puede habilitar para que este modelo esté preentrenado y que no se incluya la parte del clasificador (instrucción include_top=False en el Código 2.1). A pesar de las ventajas del “transfer-learning”, en el desarrollo de EASTlite [ 7 ] se decidió no cargar el modelo de MobileNet preentrenado y que estas capas que conforman el “backbone” se entrenen con el resto del modelo durante el entrenamiento. Esto se hace visible en la instrucción layer.trainable = True en el Código 2.1, que habilita que las capas de MobileNet se puedan entrenar. El motivo de esta decisión se explicará más adelante. 2.2.2 Rama de fusión de características Definido e implementado el “feature extractor stem”, se pasa a la rama de fusión de características o “featuremerging branch”. Este bloque va gradualmente fusionando los mapas de características extraídos en la fase anterior, como se puede ver en Figura 2.4, a través de las ecuaciones que se verán a continuación. A partir de este punto, ya no habrán grandes variaciones con lo que se explica para EAST y su variante EASTlite. gi=(unpool (hi)i≤3 conv3×3(hi)i=4(2.1) hi=(fii=1 conv3×3conv1×1gi−1;fi en cualquier otro caso (2.2) Como se explica en el artículo de EAST [ 47 ], la Ecuación 2.1 calcula la base de fusión de características, denominada como gi , y la Ecuación 2.2 calcula el mapa de características fusionado, designado como hi . El proceso de cada etapa de fusión de características comienza desagrupando o “unpooling” el mapa de características fusionado de la última etapa hi−1 , es decir, se duplican sus dimensiones pasando de [h×w×k] a [2h×2w×k] . Este cambio dimensional permite que el gi−1 generado pueda concatenarse (designado con la simbología [...;...] ) con la siguiente extracción de mapa de características del “feature extractor stem” fi .
2.2 Red neuronal del detector de texto 23 La idea tras este proceso se describe en el capítulo 14 de [ 6 ] y es el que realiza las “skip layers” para restaurar un poco la resolución espacial del mapa de características del nivel inferior, en este caso hi−1. El resultado de la concatenación, denominada gi−1;fi , se hace pasar por una convolucional conv1×1 de cuello de botella o “bottleneck”, que reduce el tamaño de los canales k del mapa de características. El mapa de características concatenado y reducido se le hace pasar por la otra capa de convolución conv3×3 para fusionar la información, generando la salida de esta etapa, hi . Este proceso finaliza cuando se llega a h4 , como se ve en la Figura 2.4. Este último mapa de características pasa por otra capa convolucional que terminará el proceso de fusionado y generará el mapa de características final g4 que se enviará al bloque de salida, el cual mantiene las dimensiones de h42. Ejemplo numérico del proceso de extracción y fusión Para comprender mejor el proceso de extracción y fusión de los mapas de características se ha propuesto el siguiente ejemplo: se supone que la imagen a color RGB entrante en la red neuronal EAST tiene las siguientes dimensiones [224×224 ×3] . Siguiendo las restricciones marcadas en la Tabla 2.1 y la red convolucional MobileNet [ 5 ], las extracción de los mapas de características tendrá estas dimensiones: [56×56 ×128]para f4,[28 ×28 ×256]para f3,[14×14×512]para f2y[7×7×1024]para f1. El proceso de fusión de los mapas de características de las dos últimas extracciones empieza por denominar h1 a la extracción f1 , según lo que indica la Ecuación 2.2. A continuación, h1 sufre un proceso de “unpool” para duplicar su ancho y alto, pasando a tener unas dimensiones iguales a [14×14 ×1024] . Este mapa de características ampliado pasa a llamarse g1 y tiene las dimensiones adecuadas para concatenarse con el siguiente nivel de extracción f2 , dando como resultado un nuevo mapa de características [g1;f2] de dimensiones [14×14 ×(1024 +512)] . Este mapa de características concatenado se hace pasar por la capa de convolución de cuello de botella para reducir el tamaño de los canales, quedado con las dimensiones [14×14 ×128] (ver Figura 2.4). Finalmente, se hace pasar este resultado por otra capa de convolución de mayor “kernel” para fusionar la información del nuevo mapa de características, que ahora se denomina como h2con dimensiones [14×14×128]. Si se replica este proceso con el resto de etapas de fusión de mapas de características se llegaría a tener: [28×28 ×64] para h3 y [56×56 ×32] para h4 . Este último mapa de características se hace pasar por otra capa convolucional, como indica Ecuación 2.1, para llegar a tener g4 con las mismas dimensiones que h4 . Este último mapa de características será el que vaya a las capas de salida, el último bloque. Antes de proseguir, es necesario comentar por qué usar capas de cuello de botella o “bottleneck” o, lo que es lo mismo, tener esta estructura convolucional del tipo conv3×3conv1×1(...) . Como se indica en el capítulo de 14 de [6], estas estructuras cumplen los siguientes tres propósitos: • Una capa convolucional de “kernel” 1×1 no puede capturar características en el plano 2D de un mapa de características de dimensiones [h×w×k] . Esto es así, ya que la ventana que especifica el kernel es de un solo píxel y no se puede nutrir de información de su alrededor. Aun así, si que puede capturar patrones a través de la dimensión de los canales k. • Se configuran para producir menos canales de los que entraron en un inicio en la capa de convolucional, de ahí su nombre “bottleneck”. Esta reducción del tamaño de los canales busca: reducir el coste computacional, reducir el numero de parámetros, reducir el tiempo de entrenamiento y mejorar la generalización de este. Para el caso concreto de EAST [ 47 ], los nuevos canales producidos aparecen en la Figura 2.4 como: 128 para h2 , 64 para h3 y 32 para h4 (el mismo número canales que saldrán de la ultima capa de convolución del “feature-merging branch”). Estos nuevos canales son pequeños respectos los vistos en “feature extractor stem” y añaden muy poca capacidad computacional, garantizando la eficiencia de EAST. 2 En contraste con lo que se vio en la sección anterior, las capas convolucionales del “feature-merging branch” no alteran el [alto ×ancho] de los mapas de características, ya que el valor de “strides” es (1,1) y el valor de “padding” es “same” (añade ceros para evitar la perdida de dimensiones de los mapas de características).
24 Capítulo 2. Detección de Texto • La combinación de capas convoluciones como conv3×3conv1×1(...) permite capturar patrones más complejos, ya que actúan como una capa convolucional única que barre el mapa de características introducido. El siguiente y último paso de esta subsección es traducir todo lo comentado a código. Como ocurrió con la implementación en Python del “feature extractor stem” en Código 2.1, el nuevo extracto de código se basará completamente en el aportado en EASTlite[ 7 ]. El cual difiere un poco en el aportado por Xinyu Zhou [ 46 ]. En el fragmento de código siguiente se puede observar como se van generado gi y hi , siguiendo las Ecuaciones 2.1 y 2.2. Haciendo uso de API de Keras y TensorFlow se logra fácilmente hacer las concatenaciones con tf.concat() o duplicar las dimensiones 2D del mapa de características con keras.layers.UpSampling2D(). Código 2.2 Generación del “feature-merging branch”. # Inicialización de entradas/salidas de "Feature-merging branch": h = [None, None, None, None] #Merged feature map g = [None, None, None, None] #Merge base outputs_size = [None, 128, 64, 32] #Channels # Se recorre cada una de las extracciones (como índice): for idx in range(len(f)): # En el caso de 0, ambas salidas coincide (f1 = h1): if idx == 0: h[idx] = f[idx] # En caso contario "h" se define como Conv3x3(Conv1x1(...)): else: conv_1x1 = keras.layers.Conv2D(filters=outputs_size[idx], kernel_size=1, activation="relu", padding="same", kernel_regularizer=keras. regularizers.L2(0.001))(tf.concat([g[idx - 1], f[idx]], axis=-1)) h[idx] = keras.layers.Conv2D(filters=outputs_size[idx], kernel_size=3, activation="relu", padding="same", kernel_regularizer=keras. regularizers.L2(0.001))(conv_1x1) # Definido la "merged feature map", se le acopla la "merge base": if idx <= 2: g[idx] = keras.layers.UpSampling2D(size=(2, 2), data_format=" channels_last", interpolation="bilinear")(h[idx]) else: g[idx] = keras.layers.Conv2D(filters=outputs_size[idx], kernel_size=3, activation="relu", padding="same", kernel_regularizer=keras. regularizers.L2(0.001))(h[idx]) Como último comentario, si se observa la declaración de las capas convolucionales del Código 2.2, se podrá ver hay un parámetro designado como “kernel_regularizer” (única incorporación que hizo el autor de EASTlite [ 7 ] en este bloque). Como se indica en el capítulo 4 de [ 9 ], el “kernel_regularizar” busca reducir el valor de los pesos que usa las redes neuronales para generar su salida, haciendo que su distribución sea más regular. Para lograr esto, se altera la función de costes de la red neuronal añadiéndole un término que penalice el hecho de tener pesos de valores altos. Este término extra puede venir dado por la norma L1, la norma L2 (matemáticamente idéntico al concepto de “weight decay” en redes neuronales) o una combinación de ambas. En la Ecuación 2.3 se puede ver cómo a la función de costes original Lcost se le añade el termino de regulación L2, compuesto por: factor de regulación de la capa i , denominado λi , y peso j de la capa i , denominado αj i(con número de capas Ny número de pesos de la capa icomo pi). L′ cost =Lcost + N ∑ i=1 λi pi ∑ j=1αj i2(2.3)
2.2 Red neuronal del detector de texto 25 En la implementación del Código 2.2 se puede ver, por la instrucción keras.regularizers.L2(0.001) , que la función de regulación a usar en todas las capas es la L2 y su factor de regulación es 0.01 (igual en todas las capas). El motivo por el cual interesa mantener el valor de los pesos de las capas bajo y usar los “kernel_regularizar” ya se ha comentado: evitar el fenómeno de “overfitting” en el entrenamiento. 2.2.3 Salida de la red neuronal Se pasa pues al último bloque de la red neuronal, denominado como “Output layer” en Figura 2.4. Este consta de varias capas convolucionales independientes que trabajarán con la salida “feature-merging branch”, g4 de dimensiones [h/4×h/4×32] (respecto a las dimensiones de la imagen entrante). Cada una de estas capas buscará generar un o varios mapa de características únicos con los que poder después reconstruir las “bounding boxes” que delimiten el texto detectado. Estos mapas se dividen en dos grupos: • El primero de ellos es el mapa de puntuación o “score mapa”, el cual tendrá como dimensiones [h/4×h/4×1] y de valores en el rango [0;1] . Cuando se tenga una confianza alta de que en cierta región rectangular de la imagen hay texto se le asignará valores altos a esta zona. Es aquí donde se descubre que el problema de detección en realidad es un problema de clasificación, siendo cada píxel de la imagen clasificado entre “zona dentro del rectángulo con texto” (1) o “zona fuera del rectángulo con texto” (0) (ver capítulo 14 de [6]). • El resto de los mapas son mapas geométricos o “geometries maps”, de las mismas dimensiones 2D que la entrada. Como se comenta en el artículo de EAST[ 47 ], hay dos alternativas para describir geométricamente estas regiones de texto: caja rotada (RBOX) o cuadrangular (QUAD). Dependiendo de una u otra se tendrá mas o menos mapas de características únicos a la salida de la red neuronal y una función de costes diferente. Como las aplicaciones en TensorFlow se han centrado en usar RBOX se elegirá este formato, el cual implica tener 4 “geometries maps” que representan la distancia en píxeles de la imagen original entre los bordes de esta y los 4 límites (superior o “top”, derecho o “right”, inferior o “bottom” y izquierdo o “left”) de la “bounding box” detectada. Adicionalmente, se tiene un mapa geométrico más que representa el ángulo de inclinación de la mencionada “bounding box”, como se puede ver en Figura 2.6. Geometry channels description AABB 4 G=R={di|i∈ {1,2,3,4}} RBOX 5 G={R, θ} QUAD 8 G=Q={(∆xi,∆yi)|i∈ {1,2,3,4}} Table 1. Output geometry design box edge distances line angle (a) (b) (c) (d) (e) Figure 4. Label generation process: (a) Text quadrangle (yellow dashed) and the shrunk quadrangle (green solid); (b) Text score map; (c) RBOX geometry map generation; (d) 4 channels of distances of each pixel to rectangle boundaries; (e) Rotation angle. pixel location to the top, right, bottom, left boundaries of the rectangle respectively. For QUAD Q, we use 8 numbers to denote the coordinate shift from four corner vertices {pi|i∈{1,2,3,4}} of the quadrangle to the pixel location. As each distance offset contains two numbers (∆xi,∆yi), the geometry output contains 8 channels. 3.3. Label Generation 3.3.1 Score Map Generation for Quadrangle Without loss of generality, we only consider the case where the geometry is a quadrangle. The positive area of the quadrangle on the score map is designed to be roughly a shrunk version of the original one, illustrated in Fig. 4 (a). For a quadrangle Q={pi|i∈ {1,2,3,4}}, where pi= {xi, yi}are vertices on the quadrangle in clockwise order. To shrink Q, we first compute a reference length rifor each vertex pias ri= min(D(pi, p(imod 4)+1), D(pi, p((i+2) mod 4)+1)) (3) where D(pi, pj)is the L2distance between piand pj. We first shrink the two longer edges of a quadrangle, and then the two shorter ones. For each pair of two opposing edges, we determine the “longer” pair by comparing the mean of their lengths. For each edge hpi, p(imod 4)+1i, we shrink it by moving its two endpoints inward along the edge by 0.3riand 0.3r(imod 4)+1 respectively. 3.3.2 Geometry Map Generation As discussed in Sec. 3.2, the geometry map is either one of RBOX or QUAD. The generation process for RBOX is illustrated in Fig. 4 (c-e). For those datasets whose text regions are annotated in QUAD style (e.g., ICDAR 2015), we first generate a rotated rectangle that covers the region with minimal area. Then for each pixel which has positive score, we calculate its distances to the 4 boundaries of the text box, and put them to the 4 channels of RBOX ground truth. For the QUAD ground truth, the value of each pixel with positive score in the 8-channel geometry map is its coordinate shift from the 4 vertices of the quadrangle. 3.4. Loss Functions The loss can be formulated as L=Ls+λgLg(4) where Lsand Lgrepresents the losses for the score map and the geometry, respectively, and λgweighs the importance between two losses. In our experiment, we set λgto 1. 3.4.1 Loss for Score Map In most state-of-the-art detection pipelines, training images are carefully processed by balanced sampling and hard negative mining to tackle with the imbalanced distribution of target objects [9, 28]. Doing so would potentially improve the network performance. However, using such techniques inevitably introduces a non-differentiable stage and more parameters to tune and a more complicated pipeline, which contradicts our design principle. To facilitate a simpler training procedure, we use classbalanced cross-entropy introduced in [38], given by Ls= balanced-xent( ˆ Y,Y∗) =−βY∗log ˆ Y−(1 −β)(1 −Y∗) log(1 −ˆ Y)(5) where ˆ Y=Fsis the prediction of the score map, and Y∗ is the ground truth. The parameter βis the balancing factor between positive and negative samples, given by β= 1 −Py∗∈Y∗y∗ |Y∗|.(6) This balanced cross-entropy loss is first adopted in text detection by Yao et al. [41] as the objective function for score map prediction. We find it works well in practice. 3.4.2 Loss for Geometries One challenge for text detection is that the sizes of text in natural scene images vary tremendously. Directly using L1 Figura 2.6 Generación del mapa geométrico según RBOX. Código 2.3 Generación de la salida de la red neuronal. # Se define la capa de salida del mapa de puntuación: score_map = keras.layers.Conv2D(filters=1, kernel_size=1, activation=tf.nn. sigmoid, padding="same", kernel_regularizer=keras.regularizers.L2(0.001))(g[3]) # Se define la capa de salida de los mapas geométricos: geo_map = (keras.layers.Conv2D(filters=4, kernel_size=1, activation=tf.nn. sigmoid, padding="same", kernel_regularizer=keras.regularizers.L2(0.001))(g [3])*512)
32 Capítulo 2. Detección de Texto del ICDAR 2013 son: {xmin,ymax,xmax,ymin,texto} (en píxeles de la imagen original), ya que el texto es horizontal y con estas coordenadas se define el rectángulo. El primer paso para poder usar ambos “datasets” es pasar las anotaciones de ICDAR 2013 al sistema QUAD. Figura 2.11 Imagen y archivo de anotación de ICDAR15 (arriba) e ICDAR13 (abajo). Descargada las imágenes y sus correspondientes anotaciones de ICDAR13 y ICDAR15, y realizado la transición para que todas las anotaciones estén en formato QUAD, el siguiente paso será agrupar esas imágenes y anotaciones en cuatro carpetas: Training_Images (las 1000 imágenes del ICDAR15 más las 233 imágenes del ICDAR13, cuidando de que no coincidan archivos), Training_Annotations (las 1000 anotaciones del ICDAR15 más las 233 anotaciones del ICDAR13, cuidando de que no coincidan archivos), Validation_Images (las 500 imágenes del ICDAR15) y Validation_Annotations (las 500 anotaciones del ICDAR15). A pesar de todo el esfuerzo, el trabajo no está completo. Como se ha dicho antes: en cualquier entrenamiento supervisado, los datos deben ser una pareja de entradas y salidas. Pero tal y como se vio en Subsección 2.2.3 y en Sección 2.3, la salida de la red neuronal es una serie de mapas: mapa de puntuación y geométrico. Por tanto, los archivos de anotaciones “per se” no aportan nada, ya que la salida de la red neuronal no espera esta ristra de valores como “labels”. Para convertir las anotaciones en los mapas requeridos, y preparar las imágenes y estos para el entrenamiento, se diseña la función de Python [ 35 ] llamada load_non_augmententation_- images(...) . Esta función a su vez llama a otra, maps_generator(...) , que es la que genera los mapas. Para ejemplificar el proceso a seguir se usará la imagen de la Figura 2.12. Figura 2.12 Imagen de ICDAR 2015 [12] con las anotaciones de archivo y las reducidas. El primer paso es extraer las anotaciones del archivo .txt asociada a la imagen en cuestión y destacar aquellas cuyo texto es legible, es decir, en el apartado texto hay texto reconocible (en la imagen marcadas con trazo verde y línea continua), y aquellas donde el texto no sea legible, en otras palabras, que en el apartado
2.4 Entrenamiento del detector de texto 33 texto no haya nada o ### (en la imagen marcadas con trazo rojo y línea continua) 6 . Durante el proceso de extracción de las “bounding boxes” de las anotaciones es conveniente comprobar que las coordenadas de éstas no se salen de las dimensiones de la imagen, que el área que encierra las “bounding boxes” superan el mínimo ( |A|>1 ) y que las coordenadas de cada vértice tiene el sentido de las agujas del reloj, es decir, el área calculada es negativa (que las coordenadas tengan realmente el formato QUAD que se espera de las anotaciones y no se ha cambiado el orden de las {x,y} ). A parte de estas corporaciones, se tiene que escalar la imagen y las “bounding boxes” validadas a las dimensiones de entrada que espera la red neuronal que, como se vio en Subsección 2.2.3 y aparece reflejado en EAST [47], es [512 ×512]. El siguiente paso, el cual viene heredado del precursor de EAST, DenseBox [ 27 ], consiste en reducir el tamaño de las “bounding boxes” en un factor del 0.3 . En la Figura 2.12 se puede ver la representación de estas “bounding boxes” reducidas denotadas con el trazo discontinuo (tanto verdes como rojas). Esto se hace a través de la función de Python llamada poly_shrinker(...) , la cual es una función interna de la función maps_generator(...), y consta de los siguientes pasos: 1. Sabiendo que una “bounding box”, definida cuadrangularmente (QUAD), tiene la forma: {x1,y1,x2,y2,x3, y3,x4,y4} , se pueden definir los vértices en el orden de las agujas del reloj como: {p1,p2,p3,p4} , donde pi={xi,yi}. 2. Para poder hacer que la “bounding box” encoja hay que calcular la longitud de referencia: ri= min(L2(pi;... p(i+1)%4);L2(pi;p(i−1)%4)) . Esta expresión viene directamente del artículo original de EAST [ 47 ] y determina el mínimo valor entre la norma L2 del vértice i y su siguiente, y la norma L2 del vértice i y el anterior ( %4 significa modulo o resto de dividir por 4). Al final de este proceso se tendrá cuatro longitudes de referencia. 3. La reducción comienza comprobando primero que par de aristas de la “bounding box” es más larga, es decir, si L2(p1;p2) + L2(p3;p4) (los largos de la caja) es mayor que L2(p1;p4) + L2(p2;p3) (los anchos de la caja). Aquel par de aristas más grandes se reducirán primero y las aristas más pequeñas les seguirán. La reducción se aplica a cada borde o arista {pi;p(i+1)%4} de tal forma que el extremo pi se desplaza al interior de la “bounding box” una distancia de 0.3ri y el extremo p(i+1)%4 hace lo mismo, pero una distancia 0.3r(i+1)%4, siempre a lo largo del borde o arista contemplado {pi;p(i+1)%4}. Al final de estos pasos se tendrán todas las “bounding boxes” reducidas y marcaran el nuevo “Ground Truth” de la detección, que se pasará al mapa de puntuación. Todo píxel que esté dentro de las “bounding boxes” reducidas tendrá un 1 y, si no, un 0. El motivo de esta reducción no se explica en ningún lado de la documentación EAST [ 47 ], pero se asume que permite al modelo hacer foco en únicamente en la zona con texto (la reducción asegura que gran parte del área sea texto) y evitar que cualquier zona circundante inunde de ruido al modelo (aportar datos que en realidad no sirve para identificar el texto). Score map Geo map (top) Geo map (right) Geo map (bottom) Geo map (left) Geo map (angle) Figura 2.13 Mapas generados a partir de la Figura 2.12. Ya con el mapa de puntuación, se puede centrar el foco en los mapas geométricos, que también se calculan con la ayuda de la función de Python maps_generator(...) . El proceso para generar estos mapas comienza calculando el rectángulo rotado de mínima área que se obtiene de las coordenadas de cada “bounding box”. Esto, aunque no se explica el motivo, se entiende que sirve para aumentar la precisión del modelo para 6 Esta distinción entre legible y no legible no es importante por ahora, pero es conveniente hacerla, ya que afectará a cómo se calcula el error en la función de perdida.
34 Capítulo 2. Detección de Texto localizar el texto en la imagen. Este cálculo del rectángulo rotado de mínima área se hace a través de la función cv2.minAreaRect(...) (de la librería OpenCV [ 20 ]). La cual, como se puede ver en [ 44 ], desordena el orden de los vértices de la nueva “bounding box” de mínima área durante el cálculo. Para solucionar esto se diseña la función interna a maps_generator(...) llamada sort_rectangle(...) que reordenará los vértices y calculará el ángulo de rotación. El último paso será calcular la distancia entre los bordes de la nueva “bounding box” con los 4 límites de la imagen escalada (superior o “top”, derecho o “right”, inferior o “bottom” y izquierdo o “left”). Estos valores de distancia y ángulo de rotación se proyectan en los cinco mapas geométricos dentro del área de lo considerado como 1 en el mapa de puntuación (“bounding boxes” reducidas marcadas como nueva “Ground Truth” de la detección del anterior párrafo). En la Figura 2.13 se puede ver el resultado de todos los procesos anteriores: el mapa de puntuación y los mapas geométricos. Hasta este punto, ya se tendría la imagen (entrada de la red neuronal) y los mapas (salida de la red neuronal) ajustados a las dimensiones de entrada [512 ×512] . Aun así, falta comentar dos puntos importantes antes de cerrar esta subsección: • Falta crear un mapa de la imagen más que no aparece en el artículo de EAST [ 47 ], pero si en su implantación en Python [ 46 ] (en EASTlite no haya cambios respecto a esto). Este mapa se denomina “training mask” y tiene su parecido al mapa de puntuación. La diferencia está en que la “training mask” está llena de unos y solo tiene ceros en el área que cubren ciertas “bounding boxes” de las anotaciones (aparentemente al contrario que el mapa de puntuación), como se puede ver en Figura 2.14. Esto se usa en el entrenamiento para eliminar del cálculo de la función de perdida aquellas “bounding boxes” que se denominan “hard areas”. Estas son aquellas cajas delimitadoras que no tiene texto legible (recordar los trazos rojos de la Figura 2.12) y que el largo o el ancho de estas “bounding boxes” no supera los 10 píxeles. Esto fuerza al algoritmo a aprender sólo de las “bounding boxes” que tiene texto en buenas condiciones (legible y no muy pequeño). • En cuanto a las dimensiones de la imagen y los mapas, se ha dicho que todos tienen una dimensión en alto y largo de [512 ×512] . Pero, la salida de la red neuronal, los mapas, deben tener unas dimensiones acordes a lo que se puede ver en la Tabla 2.1 y Subsección 2.2.2, es decir, que, si las dimensiones 2D de entrada del la red neuronal es [h×w] , las de salida deben ser [h/4×w/4] . Esta disminución del alto y ancho de los mapas no se puede realizar como un simple escalado, ya que debe ser una reducción debida al parámetros “stride” de las capas convolucionales del “feature extractor stem” de EASTlite [ 7 ]. Esto se hace descartando una fila y columna de los mapas cada cuatro filas y columnas, respectivamente. Como resultado se tendría que estas dos dimensiones quedarían como [128 ×128] (lo mismo que se hace con los mapas, se repite con la “training mask”). Figura 2.14 Imagen de la Figura 2.12 y la máscara generada.
2.4 Entrenamiento del detector de texto 35 Llegados a este punto y gracias a la función diseñada load_non_augmententation_images(...) , se tendría ya definido la entrada de la red neuronal: imagen de dimensiones [512 ×512 ×3] (los 3 canales de color RGB), y las salida de la red neuronal acorde a la entrada: mapa de puntuación de dimensiones [128 ×128 ×1] (poner una canal es una formalidad, realmente con no poner nada se expresa lo mismo), mapas geométricos de dimensiones [128 ×128 ×5] (“top”, “right”, “bottom”, “left” y “angle”) y “training mask” de dimensiones [128 ×128 ×1] . Por lo que, en un principio, ya se podría iniciar el proceso de entreno. 2.4.2 Aumento de datos Identificados los datos a usar durante el entrenamiento, puede surgir la siguiente pregunta: ¿Son estos datos suficientes?. La realidad es que es difícil responder esta pregunta, ya que depende de la complejidad de la tarea y de la capacidad de la red neuronal para aprender. Quizás la forma más directa y sencilla para ver si hacen faltan más datos es entrenar el modelo con los datos actuales y ver si este tiende al “overfitting”. Como se vio en el Capítulo anterior, la forma más directa de combatir este fenómeno es aumentar el número de datos de entreno, justo la respuesta que se buscaba. Como ya se ha comentado, EAST [ 47 ] se entrenó con varios “dataset”, siendo uno de ellos COCO-Text [ 4 ]. Esta base de datos cuenta con 63686 imágenes anotadas, por lo que, comparando con el número de imágenes de ICDAR 2013 + ICDAR 2015, se puede llegar a la conclusión que efectivamente se tiene pocos datos sin tener que hacer un entrenamiento de prueba y ver si surge el “overfitting”. Por tanto, entendiendo que hace falta más dato, la siguiente pregunta sería: ¿Cómo aumento el tamaño del “dataset”?. La respuesta lógica sería añadir más datos de otros “datasets”, pero esto puede ser bastante tedioso, por lo que la solución más sencilla es usar la técnica de aumento de datos. Figura 2.15 Ejemplo del aumento de datos (ver fuente). El aumento de datos o “data augmentation” es una técnica que se basa en generar variaciones realistas de un mismo dato. Por ejemplo, en la Figura 2.15 se puede ver como, a partir de una imagen, se pueden generar diez variaciones alterando cuestiones como la orientación o el brillo, o aplicando cortes o añadiendo ruido. En definitiva, se consigue aumentar artificialmente el banco de dato original alterando los propios datos. Esta técnica se puede aplicar durante el mismo entrenamiento, aplicando variaciones a los datos de forma aleatoria, haciendo que la red neuronal no vea el mismo dato dos veces. Como se comenta en el capítulo 5 de [ 9 ] y en el capítulo 14 de [ 6 ], el “data augmentation” permite combatir el “overfitting” (aunque eventualmente será necesario usar técnicas de regularización) y mejora la generalización del modelo, haciéndolo más tolerante a cambios de posición, orientación etc. Tanto es así, que esta técnica se usa de forma recurrente y casi de serie en proyectos de visión artificial. Aun así, esto no debe dar licencia a generar todo un “dataset” a partir de una imagen, ya que en el fondo sólo se está exponiendo versiones de la misma. Ya se verá como TensorFlow [ 18 ] y la API de Keras [ 17 ] dispone de funciones dedicadas al “data augmentation”, creando capas que se colocan encima de la red neuronal a entrenar para que aplique alteraciones a los datos entrantes. Pero esto no se puede hacer en el caso de EAST, ya que, si se altera la entrada, los mapas de la salida no serán iguales. Por tanto, en la implementación en Python [ 46 ] y en EASTlite [ 7 ], se diseña una función que en este Proyecto se llama load_augmententation_images(...) . La cual es muy parecida a
36 Capítulo 2. Detección de Texto load_non_augmententation_images(...) (vista en la subsección anterior), pero con la diferencia de que permite aplicar las siguientes transformaciones a los datos: • La primera modificación viene justo después de cargar la imagen y las anotaciones (segmentadas entre legibles y no legibles, y habiendo aplicado todas las comprobaciones previas sobre las anotaciones comentadas en la subsección anterior) en sus dimensiones originales. Ésta es aplicar un escalado de un valor aleatorio, igual tanto el alto como en ancho, entre estas opciones ×[0.5,1,2,3] (reducir a la mitad, dejar como estaba, aumentar al doble o aumentar al triple). Este cambio en la escala modificará el tamaño de la imagen y de las anotaciones. A nivel de código es muy sencillo como se puede apreciar en el Código 2.7. Código 2.7 Extracto de código dedicado al escalado. rd_scale = np.random.choice(np.array([0.5, 1, 2.0, 3.0])) img = cv2.resize(img, dsize=None, fx=rd_scale, fy=rd_scale) ann_xy *= rd_scale • La segunda transformación trabaja a partir del resultado de la primera y esta condicionada por el resultado de np.random.rand()<3/8 . En el caso de que se cumpla dicha condición, se hará una llamada a la función interna de load_augmententation_images(...) conocida como crop_- area(...) . Esta función, bajo las condiciones de entrada, intentará realizar un corte aleatorio a la imagen buscando que no aparezcan ninguna “bounding box” dentro de ella (tiene 50 intentos para realizarlo y en caso de no lógralo se deja como está, siendo una condición que la imagen tras el corte no sea inferior al 10% de la imagen original). El resultado de dicha acción, si tiene éxito, es una imagen sin “bounding boxes” y mapas vacíos, como se puede ver en la Figura 2.16. Ahora bien, si no se cumple con la condición np.random.rand()<3/8 , se hace una llamada a la misma función crop_area(...) , pero ahora el objetivo será hacer un corte que sí incluya “bounding boxes” (bajo las mismas limitaciones en intentos y corte que antes). El resultado de dicho corte se mandará a la función de obtención de mapas maps_generator(...) (subsección anterior), ya que se tiene “boundign boxes” que mapear, como se puede ver en la Figura 2.17. Si en una u otra opción no se logra llegar al objetivo, se pasa de imagen y se vuelve a intentar todo el proceso. Figura 2.16 Imagen y anotaciones de ICDAR15 [12] bajo “data augmentation” (versión 1). En definitiva, la función load_augmententation_images(...) permitirá suplir de datos pasados por “data augemtation” a la red neuronal durante el entrenamiento, garantizando que tanto las entradas como las salidas están en buen estado para que el modelo aprenda correctamente. Esto cierra los apartados referentes a los datos y se puede pasar a la siguiente subsección, donde se describirá la forma que tiene ESTlite [ 7 ] para regular el aprendizaje de los datos.
2.4 Entrenamiento del detector de texto 37 Figura 2.17 Imagen y anotaciones de ICDAR15 [12] bajo “data augmentation” (versión 2). 2.4.3 Función de pérdida y optimizador Como se vio en el Capítulo anterior de este Proyecto, durante el entrenamiento supervisado, la red neuronal recibirá los datos de entrada y generará sus propias predicciones. La forma de ver el error cometido es haciendo uso de las salidas de los datos y comprarlas con las predicciones, misión que realiza la función de perdida. La implementación de esta función de perdida de la mano de TensorFlow [ 18 ] es muy sencilla como se puede ver a lo largo de [ 24 ]. Pero en el caso de este detector de texto, donde se tiene una mapa de puntuación y varios mapas geométricos, es conveniente optar por una función de perdida personalizada, siguiendo las indicaciones del Capítulo 12 de [6]. Tanto EAST [ 47 ] como EASTlite [ 7 ] usan la misma función de coste con leves modificaciones. Como se ha dicho, al tener varios mapas de salida de distinta naturaliza, se debe tener una parte de la función de coste que calcule el error del mapa de puntuación y otra parte para los mapas geométricos. Siguiendo la guía del artículo original de EAST, la forma de calcular el error del mapa de puntuación es a través de “class-balanced cross-entropy” [ 41 ] y que se resume en las expresiones de la Ecuación 2.4, donde destaca: ˆ Y como la predicción del mapa de puntuación, Y∗ como mapa de puntuación verdadero (salida o “label” de los datos) y β como factor de balance entre las muestras positivas y negativas (fracción de las muestras que son de la clase más prominente). Ls=balance-xent(ˆ Y,Y∗) = −βY∗log ˆ Y−(1−β)(1−Y∗)log(1−ˆ Y); siendo: β=1−∑y∗∈Y∗y∗ |Y∗|(2.4) En el artículo de EAST [47] se comenta que en el entrenamiento de sistemas de detección avanzados los datos son cuidadosamente procesados mediante técnicas como “balanced sampling” o “hard negative mining” [ 27 ] para evitar la distribución no balanceada en la detección. Este concepto de la distribución no balanceada se refiere a que, como ya se ha comentado y aparece en el capítulo 14 de [ 6 ], la tarea de detección de un objeto en una imagen supone una tarea de clasificación de cada píxel de la imagen entre las clases objeto ono objeto (en la detección de texto es análogo), y normalmente hay más píxeles asociados a la clase no objeto. Esto hace que durante el entrenamiento se use una base de datos no balanceada al haber más píxeles de una clase que de otra. Por ese motivo se introducen las técnicas antes mencionadas y que supondría una mejora del desempeño de EAST, pero a costa de añadir etapas no diferenciables (calculo del gradiente durante el entrenamiento del Capítulo anterior) y añadir más parámetros que ajustar. En definitiva, sería complicar el modelo, lo cual va en contra de la filosofía de EAST y, por ello, se usa “class-balanced cross-entropy”. Esta forma de calcular el costo permite quitarle peso a la clase domínate para contrarrestar el efecto de la distribución no balanceada de los datos. En la web [ 36 ] se profundiza aun más la cuestión del desbalanceo de clases y el porqué no usar otra función de coste para el mapa de puntuación como “binary cross-entropy”, que si aparece en la API de Keras [17]. Aun así, en la implementación de EAST [ 46 ] y EASTlite no se usa “class-balanced cross-entropy”, si no “dice loss” [ 8 ]. Esta forma de calcular el error durante el entrenamiento de algoritmos enfocados en la tarea de segmentación, la cual no es muy diferente a la tarea de detección por clasificación de píxeles entre objeto ono objeto (ver Capítulo anterior), se basa en la resolución de la misma problemática anterior de clases no
38 Capítulo 2. Detección de Texto balanceadas. Gracias a esa similitud, su utilidad en tareas similares a las de este Proyecto y su sencillez a la hora de implementarla, se decide usar esta función de perdida en vez de “class-balanced cross-entropy”. La función “dice loss” se calcula según la expresión: dice_loss =1−dice_coe f , donde dice_coe f hace referencia a “S ∅ rensen–Dice coefficient”, que es una versión optimizada del “Intersection Over Union” o IoU [ 42 ] (visto en Sección 2.3) para tareas de segmentación y que se puede observar en la Ecuación 2.5 con la notación de la Ecuación 2.4. IoU =ˆ Y∩Y∗ ˆ Y∪Y∗→Dice =2|ˆ Y∩Y∗| |ˆ Y|+|Y∗|(2.5) Esto cerraría la parte de la función de coste que se dedicará a calcular el error del mapa de puntuación, Ls o dice_loss . La forma de implementarlo siguiendo las indicaciones de EAST [ 46 ] y EASTlite [ 7 ] se puede ver en el extracto del Código 2.8. En el se empieza declarando la función de coste completa, denominada loss_- function(y_true, y_pred) , que recibirá los mapas “Ground True” (salida de los datos) y los mapas predichos por la red neuronal 7 . A continuación, se declara la función interna dic_coeff(y_true_score, y_pred_score, training_mask) que se encargará de calcular la “dice loss” a través de “S ∅ rensen–Dice coefficient” con los mapas de puntuación (último canal del vector de mapas). El resultado se obtiene aplicando la Ecuación 2.5, añadiendo 1e−5 para evitar dividir por 0, y se almacenará en la variable classi f ication_loss (está escalada con un factor 0.01 para que esté acorde con el error de los mapas geométricos). En el extracto de código también se puede ver como se extrae las “training masks” y se aplica cada vez que se hace una operación con los mapas predichos y verdaderos para eliminar las “bounding boxes” que no superaron el filtrado visto en la Subsección 2.4.1. Código 2.8 Declaración de la función de perdida de las redes neuronales EASTlite (Parte I). def loss_function(y_true, y_pred): # Función Interna 1: Calculo del "Dice coefficient": def dic_coeff(y_true_score, y_pred_score, training_mask): intersection = tf.reduce_sum(y_true_score*y_pred_score*training_mask) union = tf.reduce_sum(y_true_score*training_mask) + tf.reduce_sum( y_pred_score*training_mask) + 1e-5 return (1.0 - (2*intersection/union)) # Extracción de la máscara de entrenamiento y la salida verdadera: training_mask = y_true[:, :, :, -1:] y_true = y_true[:, :, :, :-1] #Reordenar para tener [geo, score] # Se calcula la perdida de calsificación "Dice": classification_loss = 0.01*dic_coeff(y_true[:, :, :, -1:], y_pred[:, :, :, -1:], training_mask) Como aclaración, el uso de las “training masks” en el cálculo de la variable union de la función interna dic_coeff(y_true_score, y_pred_score, training_mask) del Código 2.8 no se realiza en los códigos EASTlite [ 7 ]. Pero esto debe ser un error del autor, porque en la impremeditación original si está [ 46 ]. Se ha decidido corregir en la implementación de este Proyecto para respectar el enfoque original. Completado todo lo referente al mapa de puntuación, se pasa a la parte de la función de coste dedicada a los mapas geométricos. Según el artículo de EAST [ 47 ], el gran reto de la detección de texto es que, este texto en la tipología “scene text”, aparece en multitud de tamaños. Como se busca que la red neuronal genere predicciones precisas de cualquier geometría/tamaño de texto, la mejor solución es optar por una función de 7 Se puede percatar que cuando se usa los mapas, predichos o verdaderos, estos tiene cuatro dimensiones. Las cuales son: índice de las imágenes del “batch”, alto de los mapas, ancho de los mapas y canales de los mapas (geométricos y puntuación).
2.4 Entrenamiento del detector de texto 39 perdida basada en “Intersection Over Union” o IoU [ 42 ] (recordar que EAST se podría definir la geometría por RBOX o QUAD, la función de perdida elegida solo funciona para RBOX). El motivo de usar IoU, frente a la norma L1 o L2 como usa [ 27 ], es que la pedida de regresión (técnica de “Machine Learning” supervisado para la predicción de valores continuos, capítulo 1 de [ 6 ]) debe ser invariable en función de la escala del texto. Esto no ocurre con la norma L1 o L2, ya que orientaría el sesgo de la perdida a regios más grandes, ignorando le texto pequeño. Se recuerda que los mapas geométricos está formados por: “top”, “right”, “bottom”, “left” y “angle”. Los cuatro primeros, que definen la forma de la “bounding box”, son los que estará sujetos la función “IoU loss”, la cual se implementara como aparece en [ 23 ]. Esta función de perdida invariante a la escala de las “bounding boxes” se define según la Ecuación 2.6. En esta expresión, los cuatro mapas que define el cuadrangulado de las “bounding boxes” se llama R y se desgrana en las siguientes cuatro distancias de los píxeles de las “bounding boxes” a los extremos de la imagen: d1 (arriba), d2 (derecha), d3 (abajo) y d4 (izquierda). Por tanto, ˆ R es la geometría de las “boundign boxes” predichas y R∗ son las geometrías “Gound Truth” (salida de los datos). LAABB =−log IoU(ˆ R,R∗) = −log|ˆ R∩R∗| |ˆ R∪R∗|=−log |ˆ R∩R∗| |ˆ R|+|R∗|−|ˆ R∩R∗|(2.6) Visualizando la expresión de la Ecuación 2.6 y según como aparece en [ 23 ], |R| se define como el área que cubre la “bounding box” y se calcula como: (d1+d3) + (d2+d4) . Por otro lado, la intersección de ambos conjuntos ˆ R∩R∗ está definida por el área del rectángulo de intersección, el cual se define por la multiplicación entre el ancho de intersección wi=min(ˆ d2,d∗ 2) + min(ˆ d4,d∗ 4) y el alto de intersección hi=min(ˆ d1,d∗ 1)+min(ˆ d3,d∗ 3) . La forma de calcular este error de la geometría es una aproximación, ya que supone que las “bounding boxes” son completamente rectangulares al descartarse el ángulo de rotación. El motivo de esto es que no se lograr hacer una predicción perfecta del ángulo en el entrenamiento como para incluirlo en la “IoU loss”, pero si se logra obtener un gradiente del error adecuado para que la red neuronal aprenda como estimar este ángulo. Como se ha comentado, hasta este punto se lograría calcular el error de los primeros cuatro mapas geométricos, pero falta el último mapa, el angular. Para calcular el error de este último parámetros se hace a través de la expresión: Lθ=1−cos(ˆ θ−θ∗) , donde θ es el ángulo de rotación predicho y “Ground Truth”. El error en ángulo de rotación se añade al error geométrico, a través de una suma ponderada, para formar el error global de los mapas geométricos: Lg=LAABB +λθLθ ( λθ sirve para escalar el error del ángulo de rotación con el geométrico y tiene un valor de 20). Código 2.9 Declaración de la función de perdida de las redes neuronales EASTlite (Parte II, final). # Se obtiene los lados/ángulos verdaderos/predecidos: d1_gt, d2_gt, d3_gt, d4_gt, theta_gt = tf.split(value=y_true[:, :, :, :-1], num_or_size_splits=5, axis=3) d1_pred, d2_pred, d3_pred, d4_pred, theta_pred = tf.split(value=y_pred[:, :, :, :-1], num_or_size_splits=5, axis=3) # Se calcula el area contenida verfadera/predecida: area_gt = (d1_gt + d3_gt)*(d2_gt + d4_gt) area_pred = (d1_pred + d3_pred)*(d2_pred + d4_pred) # Se calcula la intersección de ambas áreas: w_intersect = tf.minimum(d2_gt, d2_pred) + tf.minimum(d4_gt, d4_pred) h_intersect = tf.minimum(d1_gt, d1_pred) + tf.minimum(d3_gt, d3_pred) area_intersect = w_intersect*h_intersect area_union = area_gt + area_pred - area_intersect # Se calcula la "loss" y se devuelve el valor corregido: Lg = (-tf.math.log((area_intersect + 1.0)/(area_union + 1.0))) + (20*(1 - tf.math.cos(theta_pred - theta_gt)))
40 Capítulo 2. Detección de Texto return (300*(tf.reduce_mean(Lg*y_true[:, :, :, -1:]*training_mask) + classification_loss)) La implementación del calculo de esta parte del error se aprecia en el extracto del Código 2.9, que es la continuación de la función loss_function(y_true, y_pred) . La forma de obtener Lg se hace justo como se describe en los párrafos anteriores, extrayendo las distancias di y el ángulo θ con la función tf.split(...) (de la librería TensorFlow [ 18 ]). Hay que comentar que en el cálculo de la variable Lg se suman 1.0 en las áreas de intersección y unión, para evitar errores al calcular el logaritmo con tf.math.log(...) . Como ocurrió con los mapas de puntuación, se eliminan aquellos errores geométricos correspondiente a las “bounding boxes” descartables. Esto se hace calculando la media tf.reduce_mean(...) de la multiplicación del error geométrico Lg, del mapa de puntuación “Ground Truth” y_true[:,:,:,−1 :]y la “training mask”. El último paso es sumar la parte del error del mapa de puntuación y la parte del error geométrico, formando la expresión: L=Ls+λgLg ( λg regula la importancia del error geométrico sobre el de puntuación y es 1). La última línea del extracto del Código 2.9 genera ese error global y lo da como salida de la función de costes loss_function(y_true, y_pred) , recordando que la variable classi f ication_loss es el error del mapa de puntuación. También se destaca que, antes de devolver el error global, se multiplica éste por 300. Esto es algo que sólo se realiza en ESTlite [ 7 ], ya que, aunque no se aclara, esa multiplicación pretende escalar o dar más importancia al error global. El motivo de eso es que, como se comentó al final de la Subsección 2.2.2, al error que comete la red neuronal por las predicciones hay que añadir al error que se calcula por la norma L2 de la regularización de los pesos de las capas convolucionales. Como se desgrana de la Ecuación 2.3, la función de coste no solo incluye la parte asociada a la red neuronal, si no que penaliza también si los pesos de las capas convolucionales aumentan mucho (ver capítulo 11 de [ 6 ]). Esta error extra, que se añade la función de perdida, sólo afecta durante el cálculo del error global usando los datos de entrenamiento; cuando se usen los datos de validación, la técnica de regularización de pesos se elimina. Hasta este punto se ha explicado todo lo relacionado a la función de perdida, es momento de pasar al optimizador. Éste es otra de las piezas claves del entrenamiento y busca calcular el combinación de parámetros que permita minimizar el error global calculado con la función de pérdida. Como se explicó en el Capítulo anterior, el optimizador debe calcular el gradiente del error global respecto a los parámetros de la red neuronal y ajusta dichos parámetros para reducir el error. Por ejemplo, un optimizador genérico es el “Gradient Descent” (ver capítulo 4 de [ 6 ] sobre todo lo relacionado con el optimizador y sus hiperparámetros), el cual, como se ve en la Figura 2.18 va eligiendo diferentes valores de mismo parámetros en aras de bajar el coste asociado a ese parámetro y llegar al mínimo global. El gradiente de ese coste permite al optimizador saber si debe incrementar o disminuir el valor, en cuánto lo disminuya o aumente se conoce como “learning rate”. Como se puede intuir, cuanto más pequeño sea el “learning rate” más precisión se consigue en cada interacción, pero más lento serñá el proceso de entrenamiento. Figura 2.18 “Gradient Descent” durante varias iteraciones [24]. Para el detector EAST [ 47 ] y EASTlite [ 7 ], ya que es un red neuronal grande de más de 3 millones de parámetros, se decide usar un optimizador rápido. En concreto se usará el “Adaptative moment estimation” o ADAM, que se presenta en el capítulo 11 de [ 6 ]. Este optimizador aprovecha el concepto de “momentum” y algoritmia de otros optimizadores rápidos, para crea una versión más competente del “Gradient Descent”. El “momentum” hace referencia a la capacidad de añadir un impulso al “learning rate” para que pueda superar
2.4 Entrenamiento del detector de texto 41 mínimos locales (estado subóptimo del parámetro) y aumentar la velocidad de acabar en el mínimo global. En ADAM este “momentum” tiene dos ordenes: media móvil de los gradientes (primer orden) y media móvil del cuadrado de los gradientes (segundo orden), los cuales se estiman y se adaptan durante el algoritmo (ver el capitulo 2 de [9]). Código 2.10 Compilación del modelo. # Optimizador: op=tf.keras.optimizers.Adam( learning_rate=tf.keras.optimizers.schedules.ExponentialDecay( 1e-4, decay_steps=100000, decay_rate=0.94)) # Compilacción: model.compile(optimizer=op, loss=loss_function, metrics=[loss_function]) En cuanto a la implementación del optimizador y la compilación del modelo de red neuronal, este aparece en el Código 2.10. El optimizador se define con una instrucción de TensorFlow [ 18 ] para añadir ADAM tf.keras.optimizers.Adam(...) , donde el único hiperpárametro a modificar será el “learning rate”. Éste se configurará como tf.keras.optimizers.schedules.ExponentialDecay(...) , es decir, que el “learning rate” no será fijo, si no que evolucionará siguiendo un “learning rate scheduling” de tipo exponencial. Este sigue la formula: learn_rate(s) = learn_rate0∗λs/steps , donde el “learning rate” se calcula para cada interacción s y siendo: “learning rate” inicial ( learn_rate0 ) de 0.0001 ( learn_rate0 ), “decay rate” ( λ ) de 0.94 y “decay steps” ( steps ) de 1000000 (valores muy diferentes a los que se dan en el artículo EAST [ 47 ], pero que son los que se usan en la implementación). En cuanto la compilación del modelo, se hace con model.compile(...) y sirve para añadir el optimizador, designar la función de coste y establecer las métricas que se quieren ver evolucionar en cada época (de las métricas se halará más adelante). Todo esto pondría a la red neuronal en disposición para ser entrenada. 2.4.4 Proceso de entrenamiento Este es el punto decisivo de toda la Sección 2.4: el entrenamiento de la red neuronal. Se han tocado todas las piezas claves: la red neuronal, los datos, la forma de medir el error y el optimizador, en esta subsección es cuando todo se junta para realizar lo que se describió teóricamente en la Subsección 1.4.1. Como ya se comentó, debido a las limitaciones de recursos, el entretenimiento deberá llevarse a cabo en Google Colab Pro ® [ 28 ] y usar la GPU dada (Tabla 1.2). Esto viene con un problema añadido: aunque se use la versión de pago de Google Colab, los recursos tiene un tiempo límite, por lo que no se podrá entrenar del tirón muchas épocas. La solución es entrenar la red neuronal por fases de 25 épocas, cuya duración es de 3 horas y 27 minutos. Código 2.11 Comprobacción si el entrenamiento de EASTlite es desde cero o parte desde un punto. if not os.path.exists(os.path.join(ckp_path, model_name)): # Se crea el modelo y se añade el compilador: model_name = "TFM_EMNIST_model_ep0.keras" #Nuevo modelo desde 0 EAST_model = TDF.model_assembler() EAST_model.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=tf. keras.optimizers.schedules.ExponentialDecay(1e-4, decay_steps=100000, decay_rate=0.94)), loss= TDF.loss_function, metrics=[TDF.loss_function ]) else: # Se ha encontrado el modelo, se carga pues su último estado y se le añade # el optimizador personalizado: custom_opt = tf.keras.optimizers.Adam(learning_rate=tf.keras.optimizers. schedules.ExponentialDecay(1e-4, decay_steps=100000, decay_rate=0.94)) custom_loss = TDF.loss_function
3 Reconocimiento de Texto En el Capítulo anterior, dada una imagen con texto, el algoritmo desarrollado era capaz de dar las coordenadas de las cajas delimitadoras o “bounding boxes” que enmarcaba el texto en la imagen. En este Capítulo, se busca coger ese relevo y transformar la región de la imagen con texto en caracteres y/o palabras (en informática, se conoce como texto plano). Esto se conoce como reconocimiento de texto y supone el siguiente paso en la identificación de texto automático. En la Figura 3.1 se muestra un ejemplo de reconocimiento donde se tiene que cada carácter de un cartel callejero (no se incluye dicho cartel porque tiene nombre y número de teléfono ajenos) se ha sustituido por un rectángulo rojo con el carácter reconocido. Aunque el reconocimiento no es muy bueno, la idea detrás del concepto queda clara. Figura 3.1 Segmentación y reconcomiendo de caracteres de una imagen. La tarea propuesta requiere de unir el algoritmo de detección del Capítulo anterior con el algoritmo de reconocimiento todavía por desarrollar. Para simplificar esto, se va a descartar esta fusión hasta el siguiente Capítulo y centrarse solo en elaborar un algoritmo de reconocimiento que reciba ya las zonas de la imagen con texto (segmentadas por las “bounding boxes” de detección). Para ello, se va a repasar algunos métodos de reconocimiento de texto que se presentan en el estado del arte y se elegirá uno. Como ocurrió con el algoritmo de detección, se debe buscar que el algoritmo de reconocimiento esté basado en “Deep Learning”, sea sencillo y eficiente. La implementación del algoritmo de reconocimiento seleccionado se materializará a través de TensorFlow [18] y Keras [17] en Python [35]. 3.1 Estado del arte Como se comentó en el primer Capítulo, las imágenes con texto de tipo “text in the wild” o “scene text” presentan particularidades (baja resolución, condiciones ambientales, diferentes tipos de letra, idiomas, etc) que hace que resulte muy difícil reusar métodos basado en el OCR [ 52 ]. Esto quiere decir, que incluso con las zonas de texto de la imagen acotadas por una “bounding box” gracias a la parte de detección, los métodos usados típicamente en documentos siguen sin funcionan correctamente. Como ocurrían con los algoritmos 49
50 Capítulo 3. Reconocimiento de Texto de detección, los métodos capaces de hacer un reconocimiento del texto más fructíferos se dividen en dos grupos: • Los métodos clásicos basados en “Machine Learning” se pueden resumir en el algoritmos que cuentan con: un extractor de características, seguidos de un clasificador y, finalizando con modelo estadístico o visual para eliminar caracteres mal clasificados. Bajo esta idea general se obtienen dos enfoques: “bottom-up” y “top-down”. El primero se refiere a la formación de palabras a través de los caracteres clasificados. Por desgracia, este enfoque no es lo suficiente generalista y requiere de ajustes manuales que no son capaces de ser eficaces en “text in the wild”. El otro enfoque, el “top-down”, busca reconocer las palabras directamente de la imagen, pero falla a la hora de reconocer palabras que no tenga previamente incluidas en un diccionario. • La segunda familia de métodos son los basados en “Deep Learning” y son los que aglutinan los mejores resultados en el reconocimiento de texto. Aquí también existen dos grupos. El primero de los grupos está dedicado al reconocimiento de caracteres a través redes neuronales convolucionales actuando como clasificadores. La principal problemática de este grupo es que requiere segmentar los caracteres de la región con texto y esto puede ser difícil, ya que los cráteres pueden estar muy pegados o haya píxeles que distraigan al clasificador. El otro grupo es muy variado y ataca varias vertientes, pero el concepto central es la combinación de redes neuronales convolucionales con capas recurrentes (esto se conoce como RNN y se puede ver en el capítulo 15 de [ 6 ]). Esta unión permite atacar el texto como una secuencia de caracteres que forma una palabra, en vez de un mezcla de caracteres diferentes e independientes. Este segundo grupo es el que mejores resultado está mostrando al ser capaz de compensar distorsiones y distracciones a la hora de reconocer el texto. En vista de los diferentes métodos basados en “Deep Learning” aportados por [ 52 ], se va a usar la opción más sencilla, es decir, el grupo de método de clasificación de caracteres. Aunque es cierto que no es la familia de técnicas que mejores resultados da para “text in the wild”, para la aplicación final del Proyecto se ajustarán bien. Además, evitar tener que complicar más el contenido del Proyecto con la introducción de redes neuronales recurrente (RNN). Pero esto significa que en el siguiente Capítulo habrá que idear formas para segmentar los caracteres dentro de las “bounding box” del detector de texto. En cuanto a que sistema desarrollar que permita clasificar los caracteres por redes neuronales, se ha decido usar el propuesto en el Trabajo Fin de Grado [ 40 ]. Aunque no es uno de los expuestos en el artículo [ 52 ], el autor del TFG mencionado desarrolla una versión sencilla de los expuestos en el estado del arte y realiza un trabajo excepcional a la hora de determinar los hiperparámetros que mejor resultado dan en entrenamiento. La red neuronal convolucional resultante de ese estudio corresponde a una parecida a la de la Figura 1.8, es decir, una combinación de capas convolucionales y densas para clasificar imágenes de caracteres en las clases representativas de cada carácter previsto (ver Subsección 1.4.2). 3.2 Datos para el entrenamiento La red neuronal convolcucional de clasificación que se desarrolla en el TFG [ 40 ] se entrenó con el “dataset” de EMNIST [ 10 ] y distingue 47 clases/caracteres diferentes. El objetivo de esta red neuronal era reconocer texto manuscrito y es ahí donde radica el problema. Las imágenes con texto de tipo “scene text” se caracteriza por tener texto variado, pero no del estilo que se considera manuscrito (más propio de documentos). La solución para aumentar la variabilidad de los caracteres es cambiar de “dataset” a uno más parecido al que se puede encontrar en “scene text”. Este cambio supone modificaciones en la red neuronal de [ 40 ] para ajustarse a las nuevas condiciones de los datos (complejidad y número de clases). Por ese motivo, a diferencia de como se tenía acostumbrado al lector con el anterior Capítulo, se va a tratar antes los tipos de “datasets”, ya que estos van a determinar el modelo que habrán que diseñar para que “aprendan” de los datos. El objetivo es que al final de este Capítulo se tenga dos modelos, entrenados con distintos “dataset”, y ver cual opera mejor. 3.2.1 EMNIST o MNIST extendido El primero de los “datasets” será el original del TFG [ 40 ]: EMNIST [ 10 ], de sus siglas en ingles, “Extended Modified National Institute of Standards and Technology”. Se trata de una base de datos compuesta por
3.2 Datos para el entrenamiento 51 614255 imágenes de dimensiones [28 ×28] (alto y ancho) de caracteres escritos a mano, concretamente: 10 dígitos [0−9] y 52 letras [A−Z,a−z] (la letra ñ no está), en total 62 clases distintas. El “dataset” completo tiene el inconveniente de que no tiene un número de muestras uniformes para cada clases, es decir, es un “dataset” desbalanceado. 30,000 01 2 3456 7 8 9 8,000 01 2 3456 7 8 9 EMNIST Digits Dataset EMNIST MNIST Dataset Training Testing Training Testing Digit Classes Digit Classes H I JKLM N OPQRSTUV W X Y ZA B C D E FG h i j k l m n o p q r s t u v w x y za b c d e f g 3,000 Training Testing 3,000 01 2 3456 7 8 9 EMNIST Balanced Dataset Digit Classes Letter Classes 47 Classes, 131,600 Samples 30,000 H I JKLM N OPQRSTUV W X Y ZA B C D E FG h i j k l m n o p q r s t u v w x y za b c d e f g Training Testing 50,000 01 2 3456 7 8 9 EMNIST By_Merge Dataset Digit Classes Letter Classes 47 Classes, 814,255 Samples H I JKLM N OPQRSTUV W X Y ZA B C D E FG 30,000 h i j k l m n o p q r s t u v w x y za b c d e f g Training Testing 01 2 3456 7 8 9 50,000 EMNIST By_Class Dataset Digit Classes Letter Classes 62 Classes, 814,255 Samples H I JKLM N OPQRSTUV W X Y ZA B C D E FG h i j k l m n o p q r s t u v w x y za b c d e f g 6,000 Training Testing EMNIST Letters Dataset Letter Classes 6,000 26 Classes 145,600 Samples 70,000 Samples280,000 Samples Training Testing Training Testing Training Testing Fig. 2. Visual breakdown of the EMNIST datasets. The class breakdown, structure and splits of the various datasets in the EMNIST dataset are shown. Each dataset contains handwritten digits, handwritten letters or a combination of both. The number of samples in each class is shown for each dataset and highlights the large variation in the number of samples in the unbalanced datasets. The training and testing split for each class is also shown using either solid or hatched portions of the bar graphs. In the datasets that contain merged classes, a vertical arrow is used to denote the class into which the lowercase letter is merged. Figura 3.2 Información detallada del contenido del “dataset balanced” de EMNIST [10]. Existen varias versiones del “dataset” contempladas en el articulo de EMNIST [ 10 ], siendo una de ellas la de la Figura 3.2. Como se puede ver, a través de la eliminación de muestras y la fusión de caracteres, se logra un “dataset” balanceado de 131600 muestras totales y 47 clases (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z, a, b, d, e, f, g, h, n, q, r, t). Ésta y el resto de versión de EMNIST se pueden descargar en la web https:// www.kaggle.com/ datasets/ crawford/ emnist (es necesario darse de alta) en formato .csv , donde las fila representa una muestra del “dataset” y las columna el valor entre [0−255] de cada píxel de la muestra (la primera columna es número de la clase o “label” y el resto son los píxeles, en total: 1+ (28 ×28) = 785 columnas). La versión de datos de entrenamiento de EMNIST balanceado se encuentra en el archivo emnist-balanced-train.cvs (112800 muestras), la de evaluación está en emnist-balanced-test.cvs (18800 muestras) y existe un mapa llamado emnist-balanced-mapping.txt que asocia cada carácter con su código en la primera columna de los archivos .csv (estos archivos no tiene ni una fila ni una columna inicial que de paso a los datos, todo el archivo son datos). Figura 3.3 Muestra aleatoria de caracteres de EMNIST. En la Figura 3.3 se enseña algunas de las imágenes que contiene el “dataset” listas para entrar en la red neuronal. Para llegar a este punto, es necesario aplicar un preprocesado a los datos, para llevarlos desde los archivos .csv a estar listos para el entreno. Ese preprocesado se realiza en la función expuesta en el ?? , la cual recibe cada uno de los archivos .csv y genera las parejas de entrada/salida como se espera en el entrenamiento supervisado. Las imágenes de entrada ( x ) se regeneran de los archivos, normalizándolas a los valores [0−1] y dándole unas dimensiones de [28 ×28 ×1] (el canal es simplemente figurativo). En cuanto a la salidas o “labels” ( y ), se usa la instrucción de Keras .to_categorical(...) para transformar los códigos de cada muestra (primera columna de los archivos de datos) en un vector de tantas componente como clases distintas hay [47 ×1] (segunda componente también solo figurativa), con un solo 1 en la componente que corresponde a la clase de la muestra y el resto a 0. Esta forma de expresar las “labels” se conoce como “one-hot vector” (ver capítulo 13 de [ 6 ]) y es la forma que se espera que devuelva los datos la red neuronal: un vector con 47 componentes y un porcentaje muy alto en la clase de la imagen entrante. Código 3.1 Función de extracción y preprocesado del “dataset” EMNIST. def preporcess_data(file_csv):
52 Capítulo 3. Reconocimiento de Texto # Generar el nombre del archivo de texto en función del "file": file_txt = file_csv.split("-") #Las palabras claves separadas por "-" file_txt = file_txt[0] + "-" + file_txt[1] + "-mapping.txt" # Obtención del mapa entre las "labels" y los caracteres, según # especificaciones: raw_map = pd.read_csv(file_txt, sep=" ", header=None) # Generar el diccionario que relacione "label" con los caracteres: label_map = raw_map.drop(0, axis="columns") #Eliminar la primera columna label_map[1] = label_map[1].apply(chr) #Pasar de código ASCII a caracter label_map = dict(enumerate(label_map[1].tolist())) #Pasar a diccionario con #el índice (filas) como guía # Lectura de los datos del CSV, especificando el separador y que no # tiene cabecera: raw_data = pd.read_csv(file_csv, sep=",", header=None) # Creación del set de entrada "x" (imágenes): x = raw_data.drop(0, axis="columns") #Eliminar la primera columna "label" x = x.astype("float32") #Especificar el tipo de dato x = x/255 #Normalizado del valor de los píxeles entre [0-1] x = x.values.reshape(-1, 28, 28, 1) #Conversión a un vector 28x28x1 x = np.transpose(x, [0, 2, 1, 3]) #Transposición de alto por el ancho # Creación del set de salida "y" ("label") en modo "on-hot encoding": y = keras.utils.to_categorical(raw_data[0], len(label_map)) # Devolución de los set creados: return x, y, label_map En definitiva a la salida de la función se tendrá un vector de entrada en la red neuronal de dimensiones [Nºde muestras ×28 ×28 ×1] y uno de salida de dimensiones [Nºde muestras ×47×1] , donde el número de muestras depende del archivo .csv . Estos vectores se han generado con la librería NumPy [ 45 ] y funciona de forma indistinta que con TensorFlow, tal y como aparece en el capítulo 12 de [6] En el TFG [ 40 ] los datos de entrenamiento emnist-balanced-train.cvs se dividen en entrenamiento y validación, y los datos de evaluación emnist-balanced-test.cvs se usan para “test”. Se recuerda que en la Subsección 1.4.3 se comentó que ésto puede pasar para que, con el “dataset” de “test”, se de una métrica final del modelo. Aunque esto no es obligatorio y se puede usar ese “test dataset” para evaluación, y no dividir los datos de entrenamiento. En este Proyecto se ha decidido seguir el procedimiento descrito por el TFG [ 40 ], por tanto se requerirá de la función del Código 3.2. Esta función hace una división y barajado aleatorio de los datos extraídos de emnist-balanced-train.cvs para obtener los dos “datasets”, entreno y validación. Código 3.2 Función para la divisón de los datos en “set” de entrenamiento y validación. def dataset_splitter(train_data, split_factor, seed): # Se extaer el "dataset" de entrenamiento y sus "labels" en crudo: raw_train_x = train_data[0] raw_train_y = train_data[1] # Se calcula cauntas componetes hay en total y cuantas se destinana # a "val": N_total = raw_train_x.shape[0] N_val = int(split_factor*N_total)
3.2 Datos para el entrenamiento 53 # Configurar el generador de números aleatorios y selección las componetes # de cada "dataset": rand_gen = np.random.default_rng(seed=seed) idx_val = rand_gen.choice(N_total, size=N_val, replace=False) idx_train = np.delete(np.arange(N_total), np.where(np.isin(np.arange( N_total), idx_val))) # Devolución de cada uno de los "dataset": return raw_train_x[idx_train], raw_train_y[idx_train], raw_train_x[idx_val ], raw_train_y[idx_val] 3.2.2 Chars74k Hasta ahora se ha visto el banco de datos de EMNIST [ 10 ]. Pero como ya se comentó al principio de esta sección, este “dataset” representa caracteres manuscritos y puede no ser la mejor opción para clasificar caracteres de texto de tipo “scene text”. Sumado a esto, como se ha visto, EMNIST balanceado solo puede distinguir 47 caracteres de los 62 totales que se pueden reconocer (sin entrar en signos de puntuación y caracteres especiales). La solución entonces es buscar un “dataset” que, en principio, se ajuste mejora a la tarea que se desarrolla. Este será el llamado Char74k [ 13 ] y del cual se puede extraer la muestra aleatoria de la Figura 3.4. Figura 3.4 Muestra aleatoria de caracteres de Chars74k. Este “dataset”, según como está descrito en el artículo y en la web oficial [ 13 ], busca suplir las carencias que tienen los sistemas tradicionales de OCR a la hora de reconocer texto de tipo “scene text”, lo cual es justo lo que se está buscando. Este banco de datos recogen caracteres tanto del inglés como del canarés (éste último se agrupa como clases a parte), y el cual contiene: 7705 recorte de caracteres de imágenes con texto del tipo “text in the wild”, 3410 caracteres escritos a mano y 62992 caracteres de escritura por ordenador en diferentes fuentes y variaciones (normal, negrilla e itálica). Los caracteres representados se pueden clasificar en las 62 clases posibles ( [0−9] dígitos, [A−Z] letras mayúsculas y [a−z] letras minúsculas; en ese orden), además de la clases reservadas al canarés. La dificulta que añade este “dataset” es que las imágenes correspondientes a los recortes y las escritas a mano, no presentan una uniformidad en tamaños y colores. Lo que obligaría a realizar un preprocesado profundo que alterara los tamaños de las imágenes o diseñar una red neuronal completamente convolucional (que admite en principio imágenes de cualquier tamaño como se vio en los Capítulos anteriores y como se ve en el capítulo 14 de [ 6 ]). Además, habría que adaptar la red neuronal para recibir imágenes en los 3 canales de color RGB (ya que el color puede aportar información del carácter a clasificar). En definitiva, sería complicar más el posible modelo de red neuronal. Para evitar esto, se usa la recomendación de la web https:// www.kaggle.com/ datasets/ supreethrao/ chars74kdigitalenglishfont, la cual facilita sólo la partición de 62992 imágenes de caracteres a ordenador del “dataset” de Char74k. Esta partición tiene la ventaja de ser la más numerosa y de que todas la imágenes están en blanco y negro con unas dimensiones [64 ×64] (alto y ancho), muy parecido a lo que se tiene en EMNIST, pero con la variedad que aporta el formato a ordenador y que es más común que el texto manuscrito en “scene text”. Estas imágenes de carácter está divida en las 62 clases en inglés (dígitos y letras, sin nada de canarés)
54 Capítulo 3. Reconocimiento de Texto Usando el enlace anterior, previo a darse de alta, se puede descargar el archivo EnglishFnt.tgz . Este contiene un directorio con 62 carpetas, llamadas SampleXXX , y que cada una abarca 1016 imágenes de caracteres. Cada carpeta representa una de las clases y tiene el orden: [0−9] - [A−Z] - [a−z] . Ante esta forma de estructura de datos, se puede usar la instrucción de Keras .image_dataset_from_directory(...) como aparece en el Código 3.3. Esto permite generar una estructura de datos de tipo dataset en TensorFlow que incluye las entradas y salidas (en modo “one-hot vector” al indicar categorical ) de la red neuronal lista para entrenar. También es posible ordenar la estura de datos en “batches” de 128 imágenes, que las imágenes estén en blanco y negro (solo un canal de color), ajustar éstas al tamaño [64 ×64] (alto y ancho) y dividir/barajar en “sets” de entreno y validación en proporción 80%/20% (50394 imágenes para entreno y 12598 para validación). Una forma rápida y sencilla de generar y manejar el “dataset”, como se describe en el capítulo 13 de [6]. Código 3.3 Cargado y partición del “dataset” Chars74k. # Obtención de los directorios de trabajo y del "dataset": work_path = os.getcwd() dataset_path = os.path.join(work_path, "Chars74k_Dataset") # Prepara los conjuntos "dataset" de entrenamiento y validación a las # condiciones que espera la red neuronal: train_ds = keras.utils.image_dataset_from_directory(directory=dataset_path, labels="inferred", label_mode="categorical", class_names=os.listdir( dataset_path), color_mode="grayscale", batch_size=128, image_size=(64, 64), shuffle=True, seed=117, validation_split=0.2, subset="training", interpolation="nearest", crop_to_aspect_ratio=False) val_ds = keras.utils.image_dataset_from_directory(directory=dataset_path, labels="inferred", label_mode="categorical", class_names=os.listdir( dataset_path), color_mode="grayscale", batch_size=128, image_size=(64, 64), shuffle=True, seed=117, validation_split=0.2, subset="validation", interpolation="nearest", crop_to_aspect_ratio=False) Con el “dataset” en este formato es más fácil poder aplicarle las tareas de preprocesamiento, las cuales simplemente se limitan en ajustar los valores de las imágenes a [0−1] e invertir estos valores. El motivo para invertir estos valores se debe a que las imágenes tal y como se descargan, no parecen como las de la Figura 3.4. Si no, que el carácter está en colores negros y el fondo en blanco. Al invertir los valores de los píxeles, entonces los caracteres aparecen con valores entorno a 1 (color blanco), como se tiene en los datos de EMNIST. El motivo para que las similitudes entre usar un “dataset” u otro sean tanta es para que, al desarrollar la aplicación final en el próximo Capítulo, se pueda usar indistintamente uno u otro modelo y poder comparar de forma sencilla. Para eso se requiere que las entradas a los modelos se parezca lo mas posible a las de entrenamiento: fondo en negro y caracteres en blanco. Para aplicar el preprocesado al “dataset” Chars74k se hace uso del extracto del Código 3.4, donde se crean dos capas con keras.layers... , como si de una red neuronal se tratase, para cambiar todos los datos al usar el método .map(...). Código 3.4 Preprocesamiento del “dataset” Chars74k. def preprocess_data(raw_x): # Se normaliza los valores de [0-255] a [0-1]: norm_x = keras.layers.Rescaling(1./255)(raw_x) # Se invierten los valores del fondo y caracter: inver_x = keras.layers.Lambda(lambda x: 1 - x)(norm_x) return inver_x #Se devuelve la transformación:
3.3 Algoritmo entrenado con EMNIST 55 # Se normalizan los "datasets": train_ds = train_ds.map(lambda x, y: (preprocess_data(x), y)) val_ds = val_ds.map(lambda x, y: (preprocess_data(x), y)) Finalmente, se tiene ambos “datasets” preparados para el entrenamiento. Se procede ahora a definir los modelos y entrenarlos para obtener el mejor resultado posible en la aplicación final. 3.3 Algoritmo entrenado con EMNIST El primero de los algoritmos a diseñar y entrenar con los datos de EMNIST será el propuesto por el TFG [ 40 ]. Como ya se ha dicho, el autor hace un estudio muy riguroso y amplio de los hiperparámetros y diseños de la red neuronal de clasificación. Con respecto a los diseños, el TFG acaba entrenando más de cuatro modelos distintos de red neuronal, siendo la red neuronal convolucional con capa “Dropout” la que muestra mayor ratio de aciertos (esto no es del todo cierto, ya que un modelo que discrimina cifras y letras es mejor, pero por simplificar se coge el segundo mejor que es el de “Dropout”) y, por tanto, la elegida para ser el algoritmo de esta parte. 3.3.1 Diseño de la red neuronal La red neuronal convolucional seleccionada del TFG [ 40 ] es la que aparece en el Código 3.5 y consta de las siguientes capas: • El primer grupos de capas representa la entrada del modelo y el aumento de datos. La capa de entrada fija que va admitir imágenes de dimensiones [28 ×28] (alto y ancho de EMNIST) y, en blanco y negro. En el Capítulo anterior, se vio las ventajas de usar aumento de datos y se vio que se podía aplicar durante la administración del “dataset” en el entrenamiento. Ahora se muestra otra opción de inyectar datos de entreno aumentados y es añadiendo capas especiales de la API de Keras [ 17 ]. Estas capas sólo afectan en el entrenamiento y se desactiva en la inferencia (con datos de validación y en el uso normal de la red). Estas capas funcionan igual que los visto en el Capítulo anterior, cada vez que recibe un “batch” de datos, los altera aleatoriamente y luego lo administra a la red. En este caso, esas alteraciones son: rotaciones de [−18◦,18◦] , zoom de [0,5]% y traslaciones [0,5]% . En la Figura 3.5 puede verse un ejemplo de este aumento en EMNIST. Figura 3.5 Muestra aleatoria de la Figura 3.3 al sufrir aumento de datos. • El siguiente grupo es la primera capa convoluccional de 32 “kernels” (los mapas de características perderán dimensiones 2D al no tener “padding” en “same”) seguido de una capa de “pooling”. Estas capas no son muy diferentes de las capas convolucionales, usan un campo receptivo ajustable y asocian el valor máximo de los píxeles de la región que apunta el campo (en el caso de “Max-Pooling”). Como se puede ver en el capítulo 14 de [ 6 ], estas capas buscan reducir el tamaño 2D (alto y ancho) de los mapas de características, extraer aquellas características más relevantes y aportar al modelo más invariabilidad ante pequeños cambios locales en las imágenes. Seguido de este grupo convolucional hay un segundo, cuya única diferencia es aumentar el número de “kernel” al doble. Esto se hace típicamente después de capas “pooling”, ya que la disminución del mapa de características en el plano espacial se compensa con el aumento de canales. Además, sirve para unir características de menor importancia en características más relevantes.
56 Capítulo 3. Reconocimiento de Texto • Al final del segundo grupo de capas convolucional y “Max-Pooling”, se instala la capa de “Dropout” con tasa de olvido al 50%. Ésta, como ya se ha comentado, sirve como técnica de regularización (evitar el fenómeno de “overfitting”) al anular salidas de la segunda capa “Max-Pooling”. A la capa “Dropout” le sigue una capa de “Flatten” que sirve de transición o escalado entre capas convolucionales y capas densas. Se visualiza como si todos los mapas de características de conv2_ly se desplegaran como un vector donde cada componente se le asocia una neurona (no tiene parámetros). Aquí, es donde se ve la limitación de las redes neuronales convolucionales combinadas con las capas densas, ya que si se introducirá una imagen más grande de lo que fija la capa de entrada, en la capa de de “Flatten” se necesitaría de más conexiones para transicionar, lo cual significa más parámetros que no se han entrenado. • Al final se tienen dos capas de neuronas densas que es el clasificador como tal, ya que el resto pertenece al extractor de características. Esta compuesto por una capa densa oculta de 100 neuronas y otra capas, la de salida, de 47 neuronas, que hacen referencia a las 47 clases de EMNIST. La función de activación del resto de capas es la ReLU, pero en esta última capa se tiene una función softmax. En el Capítulo anterior, cuando solo se tenían dos clases (texto y no texto) se usaba la función de activación sigmoidea, pero ahora, donde hay más de dos clases, lo correcto es usar la softmax. Con esto lo que ese logra es que se tenga un vector al final de cada predicción de la red neuronal con un valor entre cero y uno de las posibilidades de que cada clase sea la que entró en la red neuronal (existe información muy detallada sobre este asunto en el capítulo 3 de [24]). Código 3.5 Función encargada de crear el modelo entrenado con EMNIST. def model_assembler(): # Cargar la capa de entrada y las capas de aumento de datos [1/8 -> pi/4] # (se pone rotación algo menor): input_ly = keras.Input(shape=[28, 28, 1], dtype=tf.float32) aug_ly = keras.layers.RandomRotation(factor=(-1/20, 1/20), fill_mode=" nearest", interpolation="nearest", seed=177)(input_ly) aug_ly = keras.layers.RandomZoom(height_factor=(0, 0.05), width_factor=None , fill_mode="nearest", interpolation="nearest", seed=177)(aug_ly) aug_ly = keras.layers.RandomTranslation(height_factor=(0, 0.05), width_factor=(0, 0.05), fill_mode="nearest", interpolation="nearest", seed=177)(aug_ly) # Se añade la primera capa de convolución: conv1_ly = keras.layers.Conv2D(filters=32, kernel_size=(3,3), activation=" relu")(aug_ly) conv1_ly = keras.layers.MaxPooling2D(pool_size=(2, 2))(conv1_ly) # Se añade la segunda capa de convolución: conv2_ly = keras.layers.Conv2D(filters=64, kernel_size=(3,3), activation=" relu")(conv1_ly) conv2_ly = keras.layers.MaxPooling2D(pool_size=(2, 2))(conv2_ly) # Se regulariza de convolucional a densas y se aplica el "dropout": dropout_ly = keras.layers.Dropout(rate=0.5, seed=117)(conv2_ly) dense_ly = keras.layers.Flatten()(dropout_ly) # Se añaden las capas densas y la salida de 62 clases: dense1_ly = keras.layers.Dense(units=100, activation="relu")(dense_ly) output_ly = keras.layers.Dense(units=47, activation="softmax")(dense1_ly) # Se crea el modelo y se devuelve: model = keras.Model(inputs=input_ly, outputs=[output_ly], name="TFM_EMNIST" )
3.3 Algoritmo entrenado con EMNIST 57 return model Hasta este punto se tendría el modelo de red neuronal completamente caracterizado y con un tamaño de 183663 parámetros. Faltaría definir la función de costes y el optimizador a usar durante el entreno. Empezando por el segundo, se ha decidido volver a usar el optimizador ADAM. No solo por los buenos resultados que dió en el segundo Capítulo del Proyecto, sino porque el autor del TFG [ 40 ] demuestra que es el optimizador que mejor resultados da en comparación con otros como RMSProp (volver al capítulo 11 de [ 6 ]). La única modificación del del ADAM original es que se va a cambiar el “learning rate” a un valor fijo igual a 0.001, según como indica el autor del TFG. En cuanto a la función de perdida, recuperando la discusión de la Subsección 2.4.3, al tener un “dataset” de clases balanceadas, se usar la función “categorical cross-entropy” (comúnmente se llama “softmax loss”, recordando la función de activación en la capa de salida de la red neuronal). Esta función es muy usada cuando se tiene una tarea de clasificación de varias clases 1 como en el caso actual, y espera que las “labels” estén codificadas como “one-hot vector”. Tanto el optimizador como esta función de costes está incluido en TensorFlow, no es necesario programarlas. 3.3.2 Proceso de entrenamiento Se pasa ahora a la etapa de entrenamiento, la cual va a ser muy similar a la vista en cuando se trabajó con el detector de texto. Se empieza con el extracto de código del Código 3.6 que comprueba si se dispone de un modelo de red neuronal ya entrenado para seguir con su entrenamiento o, en su defecto, empezar de cero. Como ya se ha comentado, esto se hace para evitar consumir los recursos de Google Colab Pro ® [ 28 ] en entrenamientos muy largos y hacerlo mejor en ciclos de entreno, aunque ya se adelanta que solo será necesario un ciclo de entreno En el caso de que no haya modelo con el que seguir el entreno, se genera el modelo de red neuronal con la función ya vista model_assembler() y se configura el compilador: optimizador ADAM con “learning rate” fijo de 0.001, función de perdida “categorical cross-entropy” y métrica de “accuracy”. Aunque la tarea de implementar el detector de texto requería de diseñar la formar de calcular las métricas por la necesidad de aplicar la etapas de posprocesado, para este clasificador no es necesario ser tan creativo y se puede utilizar alguna de las múltiples métricas que ofrece TensorFlow [ 18 ]. Para los algoritmos de clasificación, la métrica más usada es “accuracy” o exactitud, es decir, el ratio de que la predicción coincide con la “label”, como se puede ver en el capítulo 3 de [ 6 ]. En el artículo de la web [ 15 ] existe información muy detallada sobre las diferentes métricas a aplicar en relación con las vistas en el Capítulo anterior. Código 3.6 Comprobacción si el entrenamiento del modelo EMNIST es desde cero o parte desde un punto. if not os.path.exists(os.path.join(ckp_path, model_name)): # Se crea el modelo y se añade el compilador: model_name = "TFM_EMNIST_model_ep0.keras" #Nuevo modelo desde 0 EMNIST_model = model_assembler() EMNIST_model.compile(optimizer=keras.optimizers.Adam(learning_rate=0.001), loss="categorical_crossentropy", metrics=["accuracy"]) else: # Se ha encontrado el modelo, se carga pues su último estado y se le añade # el optimizador personalizado: custom_opt = tf.keras.optimizers.Adam(learning_rate=0.001) EMNIST_model = keras.models.load_model(os.path.join(ckp_path, model_name), custom_objects={"Custom>Adam":custom_opt}) En el caso de que hubiera un modelo para seguir entrenando, se cargaría con keras.models.load_- model(...) . De nuevo, al haber alterado las configuraciones estándar de ADAM, hay que declararlo como 1 Las clasificaciones donde hay más de dos clases se conoce como “multiclass classification”, pero, en el caso de que una imagen contenga más de “label”, se conoce como “multilabel classification”. El primer caso es la situación actual de clasificar caracteres en 47 clases. Para el segundo caso se tiene la situación vista con el detector de texto, donde cada píxel de una imagen se clasifica como texto o no texto. Se puede encontrar más información en el capítulo 3 de [6].
64 Capítulo 3. Reconocimiento de Texto entreno y aplicación del detector de texto del Capítulo anterior. Esto armonizaría las condiciones que espera tanto el detector como el clasificador de caracteres. Aunque ambas opciones puede suponer complicar algo más el modelo, se puede conseguir un mejor nivel de reconocimiento en la aplicación final del Proyecto. En cuanto al modelo entrenado con el “set” de Chars74k, las posibles mejoras son las siguientes: • Para aumentar el número de muestras por clase habría que descargarse el “dataset” de Chars74k completo y no sólo los datos sintéticos. En mayor nivel de importancia que aumentar el número de muestras, el añadir el “dataset” completo permitiría meter muchas más variedad al modelo que los caracteres sintéticos. Estos, por desgracia, tiene bastante similitud y algunas clases están mal ordenadas, añadiendo muestras en clases que se parecen muchas a otras muestras de otras clases distintas, lo que puede hacer que se confunda el modelo. • Al igual que se propuso como mejora para el modelo de EMNIST, se debería mejorar las capas de aumento de datos para que abarquen un abanico de posibilidades que implique más una similitud con las condiciones del detector de texto. Estas dos proposiciones, sin olvidar que se debe solucionar el efecto “under-fitting”, supone modificar el modelo a una versión más compleja. Esto, como ocurriría con el modelo basado en EMNIST, va a suponer un aumento de las necesidades de computacionales para que el modelo prediga. En el caso del modelo entrenado con Chars74k puede implicar, como ya se comentó, tener que hacer que la red admita imágenes con los 3 canales de color RGB y poner muchos más niveles de convolución. Otras soluciones implican ya irse fuera de los conceptos de red neuronal convolucional como clasificadores de caracteres que se han visto. Estas soluciones más radicales pueden abordar dos problemas principales que tiene ambos modelos: necesitan recibir imágenes con un mismo tamaño fijado en el entrenamiento (debido a las capas densas) y segmentar las palabras que se detecten en caracteres puede ser complejo. Para el primero de los problemas, podría encontrar su solución en el desarrollo de una red neuronal completamente convolucional, como se vio en el detector de texto. Aunque una red de esas características dedicada a la tarea de clasificación de caracteres es difícil de encontrar, sí existen redes convolucionales recurrentes que se salta la tarea de segmentación de caracteres (el segundo de los problemas) y trabaja en secuencia. Esto quiere decir, que en vez de ver que caracteres están entrando por la red, evalúa la palabra completamente. Un ejemplo se puede encontrar en el artículo [ 39 ], el cual está pensado para el reconocimiento de texto manuscrito (no es lo mejor para “scene text”, pero hay versiones adaptadas en [ 52 ]). Como ya se ha comentado, esto es un salto en dificulta y en consumo de recursos alto, pero es la solución que se aplica en identificadores de texto profesionales. Aquí concluye el Capítulo dedicado al reconocimiento de texto, implementando con éxito dos modelos con métricas aceptables y que se comprobará que son eficientes. Se continua ahora con el siguiente objetivo: unir el detector y el nuevo algoritmo en la aplicación final.
4 Aplicación Final Ha sta este punto, se tendrá un algoritmo de detección de texto y otro de reconocimiento de texto por clasificación de caracteres. Ahora se procede a unir ambos algoritmos y desarrollar la aplicación final de este Trabajo Fin de Máster, cuyo resultado puede verse en la Figura 4.1. Aun así, la aplicación final no se simplifica a fusionar los dos mencionados algoritmos, se requieren de varios pasos previos para que funcionen como un todo. En las sucesivas secciones se irán viendo cada uno de los pasos previos hasta llegar a la solución final. Figura 4.1 Otro fotograma tomado de la aplicación resultante del Proyecto. 4.1 Adaptación del detector de texto La primera parada consiste en preparar al detector de texto para poder acoplarse al algoritmo final. Como se desarrolló en el Capítulo 2, el detector de texto está basado en EASTlite [ 7 ] y está formado por una red neuronal completamente convolucional y un posprocesado. Por tanto, la forma de adaptarlo a la aplicación objetivo es generar una función que marque los pasos para realiza la predicción y filtrarla. Esta función se llamará Text_Detection(...) y recibe el modelo EASTlite listo para predecir ( TD_model ) y la imagen en cuestión (img). 4.1.1 Preparación de la entrada Como ya se ha dicho y como se vio a lo largo de la Sección 2.2, el predictor de texto es una red neuronal complemente convolucional. Eso quiere decir que admite imágenes de cualquier tamaño, pero con matices como se ve en [46]: • Debe ser una imagen lo suficientemente grande para que el “feature extractor stem” (visto en la Subsección 2.2.1) no termine por desintegrar la imagen durante las extracciones. Esto se evita forzando que la imagen deba tener como mínimo unas dimensiones de [32 ×32]. 65
66 Capítulo 4. Aplicación Final • Por el mismo motivo y recordando las dimensiones de los mapas de características de la Tabla 2.1, se debe cumplir que la imagen tenga dimensiones múltiplos de 32. Aun así, no se puede tolerar un tamaño excesivo de la imagen, por lo que se fija un tamaño máximo de 2400 píxeles, ya sea de alto o ancho. Respetando estas limitaciones, que son las mismas que se deben imponer en el proceso de inferencia de EASTlite para sacar las métricas en la Subsección 2.4.5, se hacen uso de las instrucciones del Código 4.1 (versión obtenida de [ 46 ]y[ 7 ]). El resultado de estas líneas en Python es el ratio de aumento o disminución, del ancho y el alto, de la imagen que entre para ajustarse a las exigencias de la red neuronal de EASTlite. Supone la primera parte de la función objetivo de esta sección: Text_Detection(...). Código 4.1 Adaptación del tamaño de la imagen de entrada. # Se carga dimensiones de la imagen entrante: height, width, _ = img.shape # Se calcula el ratio de limitación del lateral según el máximo admisible: if max(height, width) > 2400: ratio = (2400/height if height > width else 2400/width) else: ratio = 1.0 # Se corrige los laterales para que no superen el ratio de limitación: height_rs = int(height*ratio) width_rs = int(width*ratio) # Se limita dichas dimensiones a multiplos de 32 y generandose las # nuevas dimensiones: height_rs = height_rs if height_rs % 32 == 0 else (height_rs // 32 - 1) * 32 width_rs = width_rs if width_rs % 32 == 0 else (width_rs // 32 - 1) * 32 height_rs = max(32, height_rs) width_rs = max(32, width_rs) 4.1.2 Predicción y filtrado de la localización del texto Con los ratios de ajuste de tamaño, ya se puede escalar la imagen y realizar la predicción de EASTlite. En el Código 4.2, se realiza eso mismo y se dan los pasos para el filtrado. Como se vio en la Sección 2.3, una vez que se tienen los mapas de puntuación y geométricos predichos, se aplican técnicas de umbralización o “thresholding” para eliminar aquellas zonas con una puntuación inferior a 0.8, y se reconstruyen las “bounding boxes” a partir de los mapas con restore_rectangle(...) (tratada como función interna). A esto le sigue el resto de tareas de filtrado al aplicar el algoritmo de NMS desarrollado en el Código 2.5 (también como función interna). Código 4.2 Predicción de la localización del texto la imagen. # Se rescala la imagen a las nuevas dimensiones, se hace la predicción y se # segmenta los mapas predecidos: img_rs = cv2.resize(img.copy(), (int(width_rs), int(height_rs))) pred_maps = TD_model.predict(img_rs[np.newaxis, :, :, :], batch_size=1) geo_maps = np.array(pred_maps[0, :, :, :5]) #Mapa "Top-Right-Bottom-Left-Angle" score_map = np.array(pred_maps[0, :, :, 5]) # Mapa de puntuación # Filtrado del mapa de puntuación por "threshold" y se ordena los recuador # por eje y: xy_text = np.argwhere(score_map > 0.8) xy_text = xy_text[np.argsort(xy_text[:, 0])]
4.1 Adaptación del detector de texto 67 # Se procede a restaurar los recuadros a partir de los mapas y se ajustan # al formato adecuado: box_restored = restore_rectangle(xy_text[:, ::-1]*4, geo_maps[xy_text[:, 0], xy_text[:, 1], :]) #-> N*4*2 boxes = np.zeros((box_restored.shape[0], 9), dtype=np.float32) boxes[:, :8] = box_restored.reshape((-1, 8)) boxes[:, 8] = score_map[xy_text[:, 0], xy_text[:, 1]] # Se aplica el algoritmo de NMS para agrupar las cajas detectadas: boxes = NMS(boxes.astype("float32"), 0.2) En estas instrucciones no haya muchas diferencias de lo que se vio en la Sección 2.3. Aun así, se destaca que la gestión de los mapas se realiza completamente con NumPy [ 45 ] en vez de TensorFlow [ 18 ]. Algo perfectamente posible como se ve en el capítulo 12 de [ 6 ]. También destaca que se aplica el algoritmo NMS propio y no la versión más eficiente, ofrecida en [ 46 ] y programado en C++. Esto se hace así, porque, como ya se dijo, la versión más eficiente platea problemas en Windows y no se puede usar la webcam del portátil desde Google Colab Pro®[28] (único entorno donde esta versión del NMS funciona). 4.1.3 Ordenación de las predicciones El código de la siguiente adaptación es el del Código 4.3 y supone una versión altera del visto en el Código 2.6. El segundo filtrado de las “bounding boxes”, después del NMS, sigue igual. Al final del segundo filtrado, usando los ratios de escalado calculados en Código 4.1, se recupera el tamaño original de la imagen y se escala a ese tamaño las “bounding boxes” doblemente filtradas. Hasta este punto, se tendría las “bounding boxes”, que señalan las zonas del texto de la imagen, listas para usarse en el reconocimiento de texto. Es aquí donde surge un problema, las “bounding boxes” de EASTlite contienen palabras del texto explorado y no tienen porqué estar ordenadas, de hecho, no tienen ninguna ordenación. Por lo tanto, cuando se pase a la fase de reconocimiento, el texto generado no va a tener sentido al estar las palabras detectadas por EASTlite completamente mezcladas. Código 4.3 Ultimo filtrado de las “bounding boxes” y ordenación. # Se intenta realizar el proceso de filrtado y ordenación a las"bounding boxes" # que hayan sobrevivido a MNS: try: # Aquí se filtra algunos cuadros de puntaje bajo por el mapa de puntaje # promedio (diferente del EAST original): for ii, box in enumerate(boxes): mask = np.zeros_like(score_map, dtype=np.uint8) cv2.fillPoly(mask, box[:8].reshape((-1, 4, 2)).astype(np.int32)//4, 1) boxes[ii, 8] = cv2.mean(score_map, mask)[0] # El filtrado final se hace por "threshold" de detección de caja: boxes = boxes[boxes[:, 8] > 0.1] # Se ajusta sus dimensiones finales a un vector [x1,y1],...,[x4,y4] y # al ratio de redimensión: boxes = boxes[:, :8].reshape(-1, 4, 2) boxes[:, :, 0] /= width_rs/float(width) #Ratio de escaldo en "width" boxes[:, :, 1] /= height_rs/float(height) #Ratio de escalado en "height" boxes = boxes.astype(np.int32) ### -> A partir de aqui se hace la ordenación de las cajas !!!
68 Capítulo 4. Aplicación Final # Se calcula los CDG/centroides de cada una de las "bouniding boxes" # y se reordenan los índices según Y: cdgs = np.column_stack((np.sum(boxes[:, :, 0], axis=1)/boxes[:, :, 0].shape [1], np.sum(boxes[:, :, 1], axis=1)/boxes[:, :, 1].shape[1])) idx_cdgs = np.argsort(cdgs[:, 1]) # Se inicaliza la lista que almacenará cada una de las filas detectadas # y el vector de filas actual: box_rows = [] actual_row = np.array([idx_cdgs[0]]) # Se recorre cada uno de los índices para agrupar las "bounding boxes" # por filas: for idx in idx_cdgs[1:]: # Se calcula la diferencia de CDG en Y de la "boundign box" aspirante # y el de la fila actual: dif_y = np.abs(cdgs[idx, 1] - cdgs[actual_row[-1], 1]) # Se comprueba si por la diferencia en Y es una nueva línea o la misma: if dif_y <= 0.05*height: actual_row = np.append(actual_row, [idx]) #Misma línea else: box_rows.append(actual_row) actual_row = np.array([idx]) # Al finalizar el bucle se añade la última fila a la lista de filas: box_rows.append(actual_row) # Se recorre cada una de las filas detectadas y se reoredenan los índices # según X: for idx, row in enumerate(box_rows): box_rows[idx] = boxes[row[np.argsort(cdgs[row, 0])], :, :] #Se introducen directamente las "bounding boxes" # Se devuelven las "bounding boxes" regeneradas y ordenadas: return box_rows except: # Algo ha fallado en el proceso anterior, por lo que se devuelve # conjunto vacio: return [] La parte nueva que tiene el fragmento de código del Código 4.3 es precisamente la fase de ordenación de las “bounding boxes” de EASTlite. Esto se hace calculando el centroide o centro de masa de cada “bounding box” y ordenándolas según la coordenada y , la altura. Esto permite que el vector de “bounding box”, de dimensiones [NºBB ×4×2] (4 vértices de 2 componentes {x,y} ), empiece por la caja delimitadora más alta (menor y ). Se continua cogiendo esa “bounding box” más alta y se va medir la diferencia de altura con el resto de “bounding boxes”. Aquellas “bounding boxes” con una diferencia menor del 5% de la altura total de la imagen se consideran que están en la misma línea y forman una frase (grupo de palabras). Aquellas “bounding boxes” que estén en una misma frase se guardando en un vector de dimensiones [NºBB en esa frase ×4×2] . Si por el contrario, alguna “bounding box” tiene una diferencia mayor al del 5%, se le considera una nueva frase que se debe explorar. Cada nueva frase se va guardando en una componente de una lista de Python. El resultado de este proceso es una lista donde cada frase o grupo de palabras se guarda en una componente de la lista en formato vector NumPy [ 45 ]. Idealmente, en vez de una lista, se debería trabajar con un vector Numpy al ser más eficiente, pero, como el número de “bounding boxes” de palabras puede variar entre líneas, solo es posible usar listas. Posteriormente, cada frase se debe ordenar de izquierda a derecha. Esto se hace
4.2 Adaptación del algoritmo de reconocimiento de texto 69 accediendo a cada frase y ordenarlas por la componente xde su centroide o centro de masa. El resultado se puede verse en la Figura 4.2 donde, por un lado, se tiene las “bounding boxes” predichas sin ordenar y, por el otro lado, ya ordenadas con un código que indica la fila y la palabra o columna. f-1,c-1 f-2,c-1 f-3,c-1 f-3,c-2 Figura 4.2 Detección (a la izquierda) y ordenación (a la derecha) del texto en la portada de un libro. Si se compara el Código 4.3 con el Código 2.6, se podrá ver otra diferencia: se ha eliminado el if...else por try...except . El motivo es que, con la nueva modificación para la ordenación, se requería de otro if...else . Una forma de saltarse esas instrucciones es poner try...except , protegiendo el código de cualquier error y ganado cierta eficiencia al no hacer comprobaciones, simplemente avanzar hasta fallar. En el caso de fallar, se devolvería una lista vacía, indicativo de que no hay texto en la imagen. 4.2 Adaptación del algoritmo de reconocimiento de texto Finalmente, gracias a la sección anterior, se tendría el detector de texto completamente adaptado y representado por la función Text_Detection(...) . Ahora se pasa a la parte de reconocimiento de texto, que está representada por la función Text_Recognition_by_Characters(...) . Esta recibe el modelo de reconocimiento de caracteres TR_model , la imagen en dimensiones originales img y las lista de “bounding boxes” ordenadas T D_boxes, y devuelve las “bounding boxes” de los caracteres y el mensaje reconocido. 4.2.1 Proceso de segmentación de caracteres y generación de la salida Como se comentó en el Capítulo 3, una de las principales dificultades que aparece con el reconcomiendo de caracteres en “scene text” es segmentar los caracteres del texto detectado. La tipología de la imagen tiene distractores que pueden aumentar mucho la dificultad. Para solucionar esto hay redes neuronales entrenadas expresamente para segmentar caracteres de una imagen como [ 50 ], pero esto aumentaría mucho la dificulta del Trabajo. La solución es hacer uso de algunas función de la librería OpenCV [ 20 ], una librería dedicada para el tratamiento de imagen. Pero esto añade la limitación de que el texto a reconocer, especialmente el fondo, no debe ofrecer muchas dificultadas. Figura 4.3 Imagen de contraste de una palabra de la Figura 4.2. En el Código 4.4 se puede ver como se va recorriendo cada una de las frases que aporta el detector de texto y se pasa a recorrer cada palabra de cada frase. A continuación, gracias a las coordenadas de “bounding
70 Capítulo 4. Aplicación Final box” de cada palabra ( {(x1,y1),(x2,y2),(x3,y3),(x4,y4)} ; siendo las coordenadas {x,y} de la esquina superior izquierda {1} , la esquina superior derecha {2} , la esquina inferior derecha {3} y la esquina inferior izquierda {4} , en píxeles de la imagen original), se determina el recorte de la imagen que incluya la palabra detectada. Este recorte es en forma de rectángulo completamente recto al ser más fácil de manejar, pero limita el ángulo de detección con el que se trabaja. Este recorte se pasa al blanco y negro, y se le aplica cv2.threshold(...) . Esta función aplica una umbralización inteligente que permite destacar trazos del fondo según el histograma de la imagen. Como resultado se tiene la Figura 4.3. Código 4.4 Extracción de los caracteres para el reconocimiento. # Se inicializa la lista que alamcenarán todas las "bounding boxes" de # los caracteres (por fila): line_boxes = [] # Se inicializa el mensaje a generar por el reconocmiento de caracter: msg = "" # Se recorre cada una de las "bounding boxes" de cada fila: for row_BBs in TD_boxes: # Se inicializa la lista que alamcenarán todas las "bounding boxes" de # los caracteres (por columna/palabras): words_boxes = [] # Se recorre cada "bounding box" que se encuentra en la fila designada: for BB in row_BBs: # Se sacan las cordenadas/dimensiones de caja que incluye la # "bounding box" y se hace el recorte: crop_x, crop_y, w, h = cv2.boundingRect(BB) img_crop = img[crop_y:(crop_y + h), crop_x:(crop_x + w), :] # Salto a la sigiente palabra si el recorte esá vacio: if not np.any(img_crop): continue # Se pasa el recorte a BW y se aplica la técnica de "threshold": crop_gray = cv2.cvtColor(img_crop, cv2.COLOR_RGB2GRAY) crop_retval, crop_thres = cv2.threshold(crop_gray, 0, 255, cv2. THRESH_OTSU | cv2.THRESH_BINARY_INV) # Se calcula el histograma de esta y se regulariza al nivel de # intensidad más usado: if np.argmax(cv2.calcHist([crop_thres], [0], None, [256], [0, 256])) >= (255/2): crop_thres = cv2.bitwise_not(crop_thres) crop_thres=cv2.filter2D(crop_thres,-1,np.array([[-1,-1,-1], [-1,9,-1], [-1,-1,-1]])) #Sharping Aplicar la función de umbralización cv2.threshold(...) para sacar los contornos de los caracteres tiene un precio. Dependiendo del fondo y del tamaño de los caracteres, puede que sea el fondo el que este blanco (tenga un valor de 1) y los caracteres estén en negro (valor igual a 0). Esto es inadecuado para realizar el proceso que sigue, por lo que se comprueba el histograma del recorte de imagen umbralizada (por ejemplo, la Figura 4.3) y, si el número de píxeles que son blancos es mayor, se asume que es el fondo el que está a color blanco y es necesario invertir los valores del recorte. Esta comprobación garantiza que sea los los caracteres los que estén a 1 y no el fondo. Una vez invertido o no los valores, se aplica un “Laplacian kernel”
4.2 Adaptación del algoritmo de reconocimiento de texto 71 [ 2 ] (distinto concepto al que se vio en las redes neuronales convolucionales) para mejorar la visión de las zonas destacadas por la función. Código 4.5 Obtención de los contornos, corrección y reconocimiento de los caracteres. # Se sacan todos los contornos (posibles caracteres) y se incializa el # vector de alamcenamiento: crop_contours, crop_hierarchy = cv2.findContours(crop_thres, cv2. RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) word_arrays = np.array([]).reshape((0, 4, 2)) #Formato EASTlite # Se recorre cada contorno (posible caracter) detectados: for crop_char in crop_contours: # Obtiene las dimensiones del rectangulo que cubre el contorno # detectado: x, y, w, h = cv2.boundingRect(crop_char) # Se calcula y almacena las coordenadas (X, Y) de los caracteres # en modo EASTlite: word_arrays = np.append(word_arrays, [np.array([[x, y], [x + w, y], [x + w, y + h], [x, y + h]])], axis=0) # Se filtan los caracteres detectados y se ordenan de izquierda a derecha: word_arrays = character_filter(word_arrays.copy()) # Se clasifica cada uno de los caracteres detectados para generar la # palabra examinada: msg = msg + character_recognition(TR_model, crop_thres, word_arrays) # Se actualiza las coordenadas locales a absolutas (uso de la esquina # superior-izquierda [x1, y1] en EASTlite): word_arrays[:, :, 0] += crop_x word_arrays[:, :, 1] += crop_y # Se alamcenana las "bounding boxes" absolutas en la lista de palabras: words_boxes.append(word_arrays) #Filtradas y ordenadas msg = msg + " " #Paso entre palabras # Cada grupo de palabras se va alamcenado en la lista de filas: line_boxes.append(words_boxes) msg = msg[:-1] + "\n" #Paso entre líneas # Se devuelve la lista de listas de "bounding boxes" de caracteres filtrada # y ordenada, además del mensaje: return line_boxes, msg[:-1].replace("\x00", "").lower() El siguiente paso que se mencionaba es, por cada recorte de palabra detectada en formato umbralizado como la Figura 4.3, buscar los contornos del recorte, es decir, dedicar una “bounding box” por cada segmento o píxel suelto que esté a 1. Esto se logra en el Código 4.5 con la función de OpenCV [ 20 ]: cv2.findContours(...) . Este devuelve una nube de puntos por cada segmento, que con cv2.boundingRect(...) , se logra una “bounding box” con forma de rectángulos horizontales. De nuevo, al tener este tipo de configuración, si se aporta a la imagen ángulos muy extremos, las “bounding boxes” rectas pueden albergar información de los caracteres contiguos y eso no interesa. La función cv2.boundingRect(...) devuelve la “bounding box”
72 Capítulo 4. Aplicación Final rectangular en un formato: coordenadas de la esquina superior-izquierda, el ancho y alto, pero es mejor trabajar con el formato de las “bounding boxes” de EASTlite: {(x1,y1),(x2,y2),(x3,y3),(x4,y4)}. Recapitulando, gracias a las funciones de OpenCV, por cada palabra o “bounding box” del detector de texto, se tendría un vector Numpy de dimensiones [Nºcontornos ×4×2] (formato EASTlite cuadrangular). Este vector almacena las “bounding boxes” de todo los contornos detectados en el recorte de cada palabra. Idealmente todos los contornos serán caracteres, pero para eso es necesario que ese vector pase por la función interna character_filter(...) , que aplicará un proceso de filtrado y ordenación. Al pasar por esta función, que se explicará en la subsección siguiente, se tendrá, ahora si, un vector con todas las “bounding boxes” de los caracteres de la palabra analizada. La ordenación que se menciona es necesaria, ya que, como pasa con el detector de texto, las “bounding boxes” de los contornos no tienen por qué estar ordenadas de izquierda a derecha, que es el sentido normal de lectura y comprensión de una palabra. Es por eso que hace falta ordenar los caracteres segmentados para el reconocimiento. Hasta este punto, el resultado del proceso se puede ver en la Figura 4.4, donde se logra segmentar cada carácter con una “bounding box” y ordenarlas. 123456 12345 6 789 1234567123456 Figura 4.4 Detección (a la izquierda) y ordenación (a la derecha) de los caracteres de la Figura 4.2. El siguiente paso del Código 4.5 es hacer el reconocimiento de cada carácter contenido el vector de “bounding boxes” de caracteres. Esto se hace en la función interna character_recognition(...) y se tratará en la próxima subsección. Cuando se reconoce los caracteres, el código actualiza las coordenadas del vector de las “bounding boxes” de caracteres de local a global, ya que estas coordenadas se obtuvieron a partir de un recorte de la imagen y al extrapolarlas a la imagen original no estaría en la escala adecuada. Hecho esto, se hace un almacenamiento de los vectores de “boundign boxes” de caracteres muy similar al de “bounding boxes” del detector de texto: las coordenadas de los caracteres estarán almacenadas en una lista, donde cada componente será los caracteres de una frase. En cada componente de una frase se tendrá otra lista con el mismo número de componentes que palabras hay en una frase. Y a su vez, cada componente de la lista de palabras se tendrá el vector de “bounding boxes” de los caracteres que forman la palabra. Esta macrolista será uno de los resultados de la función Text_Recognition_by_Characters(...). 4.2.2 Filtrado y ordenación de los caracteres segmentados En esta subsección se va a tratar la labor de la función interna mencionada character_filter(...) . Su misión es eliminar cualquier contorno que no sea carácter y formar aquellos caracteres que estén desagrupados. También deberá ordenar los caracteres de izquierda a derecha para que las palabra a formar tenga sentido a la hora de aplicar el reconcomiendo por carácter. Esto se hace en el Código 4.6, el cual recibe el vector de “bounding boxes” de contornos en formato cuadrangular.
4.2 Adaptación del algoritmo de reconocimiento de texto 73 Código 4.6 Filtrado y ordenación de los posibles caracteres detectados. # Se calcula el área de todas las "bounding boxes" de posibles caracteres y se # saca el área de corte: boxes_area = (all_chars_boxes[:, 1, 0] - all_chars_boxes[:, 0, 0])*( all_chars_boxes[:, 3, 1] - all_chars_boxes[:, 0, 1]) area_lim = 0.2*np.mean(boxes_area) # Se divide todas las "bounding boxes" según pasan el corte de area (caracteres ) # o no (píxeles sueltos): char_boxes = all_chars_boxes[np.where(boxes_area >= area_lim)[0], :, :] pix_boxes = all_chars_boxes[np.where(boxes_area < area_lim)[0], :, :] # Se aplica el algoritmo si hay píxeles sueltos, si no se pasa directamente # a la ordenación: if len(pix_boxes) != 0: # Se recorre cada uno de los píxeles y se busca si es el punto de la "i" # (caso de que hay píxeles): for pix_BB in pix_boxes: # Se cálcula el CDG de la "bounding box" de píxeles y posible punto de # la "i": cdg_coord = [np.sum(pix_BB[:, 0])/pix_BB[:, 0].shape[0], np.sum(pix_BB [:, 1])/pix_BB[:, 1].shape[0]] # Se recorren todas las "bounding boxes" que superan el corte de área: for idx, char_BB in enumerate(char_boxes): # Se determina si el posible punto de la "i" está en una posición # propicia: if cdg_coord[0] >= np.min(char_BB[:, 0]) and cdg_coord[0] <= np.max( char_BB[:, 0]) and cdg_coord[1] < np.min(char_BB[:, 1]): # Se recalcula las coordenadas de la "bounding box" para añadir # el punto de la "i" (formato rectangular): char_boxes[idx, 0, :] = [np.minimum(char_BB[:, 0], pix_BB[:, 0]). min(), np.minimum(char_BB[:, 1], pix_BB[:, 1]).min()] char_boxes[idx, 1, :] = [np.maximum(char_BB[:, 0], pix_BB[:, 0]). max(), np.minimum(char_BB[:, 1], pix_BB[:, 1]).min()] char_boxes[idx, 2, :] = [np.maximum(char_BB[:, 0], pix_BB[:, 0]). max(), np.maximum(char_BB[:, 1], pix_BB[:, 1]).max()] char_boxes[idx, 3, :] = [np.minimum(char_BB[:, 0], pix_BB[:, 0]). min(), np.maximum(char_BB[:, 1], pix_BB[:, 1]).max()] break #No es necesario buscar otro candidato # Se calcula el CDGs y se reordenan los caracteres de izquierda a derecha # (con X): cdgs = np.column_stack((np.sum(char_boxes[:, :, 0], axis=1)/char_boxes[:, :, 0].shape[1],np.sum(char_boxes[:, :, 1], axis=1)/char_boxes[:, :, 1].shape [1])) chars_boxes_filtered = char_boxes[np.argsort(cdgs[:, 0]), :, :] # Se devuelve las "bounding boxes" filtradas y ordenadas: return chars_boxes_filtered
5 Conclusiones Al o largo de todo el Proyecto se ha visto una forma de poder detectar y reconocer texto a través de la webcam del ordenador portátil haciendo uso de modelos basados en redes neuronales. En este último Capítulo se va a hacer un repaso de los logros conseguidos y de posibles caminos para futuras mejoras. Como ya se ha visto, la primer dificultad a la hora de identificar texto de forma automática, ya sea en vivo o en imagen, es saber dónde está el texto. Si bien en un documento esto puede ser tarea sencilla, por el alto contraste entre los caracteres y el fondo, esto se complica en situaciones cambiantes como las que registra una webcam. Es por eso que se implementó el detector de texto EASTlite [ 7 ]. Un algoritmo compuesto por una red neuronal, enfocada en tareas de detección, y un posprocesado. La combinación de ambas piezas permite generar “bounding boxes”, destacando las zonas de la imagen con texto, manteniendo un consumo de recursos relativamente bajo (algo muy impotente si se quiere implementar con una webcam). Como se ha visto, el detector de texto implementado funciona realmente bien, pero tiene cierta carencias o potenciales mejoras que se deben destacar. Muchas de estas se han tratado al final de la Sección 2.5. Aun así, cuando el detector de texto se ha fusionado en la aplicación final, ha presentado dificultades cuando el texto está muy cerca del objetivo de la webcam o, ante palabras largas, las “bounding boxes” predichas no cubran en su totalidad la extensión de éstas. Esta dificultan es más intensa cuando la imagen presenta reflejos o alteraciones, o cuando se intenta hacer una detección de texto vertical. Todo esto tiene su origen en que, al final, una red neuronal sólo aplica lo que ha aprendido, es decir, dará buenos resultados cuando la imagen a la que se enfrenta se parece más al “dataset” que se ha usado para entrenarse. En el caso de EASTlite, esta se ha entrenado con ICDAR 2013 [ 11 ] y 2015 [ 12 ], “datasets” donde el texto está relativamente lejos y en orientaciones primordialmente horizontales. La solución a esto es ampliar la gama de imágenes a las que se enfrenta la red en el entrenamiento o aplicar un preprocesado a las imágenes de la webcam para que se parezca a las del ICDAR. Una vez con el texto detectado, aparecía la siguiente dificultad: el reconocimiento de ese texto. Por su simplicidad, el camino elegido ha sido segmentar los caracteres de las palabras detectadas y reconocerlos, uno a uno, con una red neuronal convolucional como clasificador. Para segmentar los caracteres se ha buscado extraer la zona de la imagen que apunta la “bounding box” de detección y aplicar algunas instrucciones de la librería OpenCV [ 20 ] para umbralizar la zona extraída y buscar contornos (posibles caracteres). Con esto se ha logrado extraer los caracteres de las palabras detectadas y reconocerlos con la red convolucional. Esta tarea aunque parece sencilla no ha estado exenta de dificultades, siendo la más importante la segmentación de los caracteres. Por ejemplo, un problema típico que se ha tenido usando la comentada técnica de segmentación y reconocimiento fue determinar la distancia del texto a la webcam para que se cumpla con que las “bounding boxes” de detección fueran completas (cuanto más lejos de la webcam mejor) y que los caracteres no se segmentaran como un todo, al no apreciarse en la imagen separación entre ellos (cuanto más lejos de la webcam peor). A parte de las posibles mejoras expuestas en la Sección 3.5 para afinar las redes neuronales de reconocimiento de caracteres, se puede mejorar este apartado buscando una alternativa para segmentar los caracteres o al tratamiento de las “bounding boxes”. Actualmente, como se ha dicho, se plantea que cada “bounding box” detectada se trata como un recorte recto de la imagen original. Algunas alterativas probadas (recortes escalados o el uso de máscaras), pero que fracasaron, fueron en la línea de respetar lo más posible la forma de la “bounding box” detectada en aras de mantener la versatilidad de ángulos que ofrece EASTlite. Aunque 81
82 Capítulo 5. Conclusiones como se ha dicho, fracasaron al añadir problemas en el manejo de los recortes de la imagen y en la ordenación de las palabras, aspecto muy importante si se quiere tener un texto con sentido. La solución encontrada es la que finalmente se implementó, que implica ser conscientes de las limitaciones en el rango de detección que se puede esperar. En cuanto a la segmentación de los caracteres, también se está limitado a un fondo de alto contraste para el texto y, de caracteres bien definidos y separados. Quizás en este aspecto, lo más inteligente es optar por redes neuronal de detección como CRAFT [ 49 ], cuya detección es a nivel de carácter, pero a costa de ser más demandantes a nivel computacional. Al final, como se ha visto, la unión de ambos algoritmos y el refuerzo de técnicas de corrección del texto se ha logrado un buen resultado entendiendo siempre las limitaciones de cada sistema. Una de las limitaciones no comentadas son los recursos computacionales disponibles: • Aunque es cierto que Google Colab Pro ® [ 28 ] ofrece unos potentes recursos, estos están sujetos a tantas condiciones que entrenar modelos complejos supone una tarea difícil. Por ejemplo, la versión implementada de EASTlite podría tener un mejor desempeño si su entrenamiento no fuera interrumpido o si se pudiera probar más de una alternativa. Estas limitaciones hacen que lo que puede lograr un modelo de red neuronal esté muy sujeto al entorno de entrenamiento. Pero hay que ser consciente de que, para los parámetros de este Proyecto, no se puede llegar a esperar superar modelos realizados por profesionales, como los de EAST [47]. • También están las limitaciones computacionales del ordenador portátil en que corre la aplicación realizada. Como se ha dicho en reiteradas ocasiones, ésta se ejecuta en la CPU del portátil. Por lo que, para comparar la capacidad de identificación de texto en tiempo real, lo más adecuado es hacer uso de la GPU del terminal o mejorar aun más la eficiencia del algoritmo final. En ese sentido se quiere transmitir que el producto de este Proyecto puede ser mejorado en términos de eficiencia y llegar a comparase con versiones más profesionales como Keras-OCR [16]. Por último, durante todo el Proyecto, el enfoque ha sido dividir el objetivo en pequeñas tareas para ir teniendo poco a poco la aplicación final. Esto a nivel ingenieril es lo más típico, pero no es el único camino. Como el autor del TFM [ 7 ] demuestra, la identificación de texto se puede llevar a cabo en una única tarea en forma de red neuronal que haga ambas, detección y reconocimiento. Un ejemplo es la red neuronal “end-to-end”: Fast Oriented Text Spotting with a Unified Network o FOTS [ 48 ]. Precisamente, este tipo de redes neuronales “end-to-end”, que resuelvan varias tareas a la vez, son las que esta simplificando tareas tan complejas como la identificación de texto automático.
Apéndice A Códigos para la Detección de Texto En este apéndice se facilitaran los códigos necesarios para llevar a cabo todo lo relacionado con el Capítulo 2, la detección de texto en imágenes. A.1 Preprocesamiento de las anotaciones del “dataset” de ICDAR 2013 El Código A.1 representa el archivo .ipynb que se dedica al proceso descrito en Subsección 2.4.1 para transformar “dataset” de ICDAR13 al formato ICDAR15. Esto consiste en cambiar las anotaciones del primer banco de datos {xmin,ymax,xmax,ymin,texto} a las del segundo {x1,y1,x2,y2,x3,y3,x4,y4,texto} (siendo las coordenadas {x,y} de la esquina superior izquierda {1} , la esquina superior derecha {2} , la esquina inferior derecha {3} y la esquina inferior izquierda {4} , en píxeles de la imagen original). También, se traslada las imágenes y anotaciones de ICDAR13 a las carpetas correspondientes. Código A.1 Adpatacción del “dataset” de ICDAR13 al formato ICDAR15. ## LIBRERIAS: import glob import os import pandas as pd import numpy as np import cv2 import shutil ## FUNCIONES: def visualization(img_path, ann_df): # Se carga la imagen: imagen = cv2.imread(img_path) # Se recorre cada anotación y se extrae los verties para dibujarlo: for idx, row in ann_df.iterrows(): vert = np.array([[row["x1"], row["y1"]], [row["x2"], row["y2"]], [row["x3"], row["y3"]], [row["x4"], row["y4"]]], dtype=np.int32).reshape((-1, 1, 2)) cv2.polylines(imagen, [vert], True, (0, 255, 0), 2) cv2.putText(imagen, "1", (vert[0,0,0], vert[0,0,1]), cv2. FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 1) #Esquina izq-sup cv2.putText(imagen, "2", (vert[1,0,0], vert[1,0,1]), cv2. FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 1) #Esquina der-sup 83
84 Capítulo A. Códigos para la Detección de Texto cv2.putText(imagen, "3", (vert[2,0,0], vert[2,0,1]), cv2. FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 1) #Esquina der-inf cv2.putText(imagen, "4", (vert[3,0,0], vert[3,0,1]), cv2. FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 1) #Esquina izq-inf # Mostrar la imagen con el polígono: cv2.imshow(’Imagen con Polígono’, imagen) cv2.waitKey(0) cv2.destroyAllWindows() #return ## MAIN: # Determinar el directorio de trabajo y establecer los "path" de las carpetas # finales: work_path = os.getcwd() ICDAR15_im_path = work_path + chr(92) + "Training_Images" ICDAR15_ann_path = work_path + chr(92) + "Training_Annotations" # Establecer el "path" de las imágenes y anotaciones a modificar: ICDAR13_im_path = work_path + chr(92) + "Challenge2_Training_Task12_Images" ICDAR13_ann_path = work_path + chr(92) + "Challenge2_Training_Task1_GT" # Se recorre y recompila cada uno de los archivos de tipo imagen dentro de la # carpeta de ICDAR15: all_ICDAR15_imgs = [] for ext in ["jpeg", "jpg", "png"]: all_ICDAR15_imgs.extend(glob.glob(os.path.join(ICDAR15_im_path, "*.{}". format(ext)))) # Se determina cual es el archivo del ICDCAR15 con mayor numeración (el último) # y se extrae el número: all_ICDAR15_img = max(all_ICDAR15_imgs, key=lambda x: int(x.split(chr(92))[-1]. split(".")[0].split("_")[-1])) last_num = int(all_ICDAR15_img.split(chr(92))[-1].split(".")[0].split("_")[-1]) + 1 # NOTA : Al hacer "split(chr(92))[-1]" se elige la última parte del "path", es # decir: "img_xxx.ext" (otra opción es "os.path.basename(...)"). Luego se # divide por el punto y quedaría: "img_xxx". Finalmente se divide por la # barra baja y se elige el último de la lista (-1) que será el número. # Al máximo número se le añade un "+1" para cuando se pasen los nuevos # archivos. # Se recorre y recompila cada uno de los archivos de tipo imagen dentro de la # carpeta de ICDAR13: all_ICDAR13_imgs = [] for ext in ["jpeg", "jpg", "png"]: all_ICDAR13_imgs.extend(glob.glob(os.path.join(ICDAR13_im_path, "*.{}". format(ext)))) # Se recorre cada "path" de las imágenes ICDAR13 y se va pasando junto con su # archivo TXT: for img in all_ICDAR13_imgs: # Se determina el nombre del TXT adjunto a la imagen seleccionada de las # ICDAR13 y se determina si existe:
A.2 Funciones de uso general de EASTlite 85 txt_path = os.path.join(ICDAR13_ann_path, "gt_" + os.path.basename(img). split(".")[0] + ".txt") if not os.path.exists(txt_path): print("La imagen \"\" no tiene anotaciones adjuntas y se descarta.". format(os.path.basename(img))) continue #Pasa a la siguiente imagen # Se carga el archivo de anotaciones y se inicializa el nuevo formato de # anotaciones: txt_df = pd.read_csv(txt_path, delimiter=" ", names=["left", "top", "right" , "bottom", "text"]) new_txt_df = pd.DataFrame({"x1":[], "y1":[], "x2":[], "y2":[], "x3":[], "y3 ":[], "x4":[], "y4":[], "text":[]}) # Se genera las anotaciones en el formato ICDAR15 y se guardan de nuevo en # el TXT anterior: new_txt_df["x1"] = txt_df["left"] #Esquina izquierda-superior new_txt_df["y1"] = txt_df["top"] #Esquina izquierda-superior new_txt_df["x2"] = txt_df["right"] #Esquina derecha-superior new_txt_df["y2"] = txt_df["top"] #Esquina derecha-superior new_txt_df["x3"] = txt_df["right"] #Esquina derecha-inferior new_txt_df["y3"] = txt_df["bottom"] #Esquina derecha-inferior new_txt_df["x4"] = txt_df["left"] #Esquina izquierda-inferior new_txt_df["y4"] = txt_df["bottom"] #Esquina izquierda-inferior new_txt_df["text"] = txt_df["text"].astype("str").apply(lambda x: x.replace (",", " ")) new_txt_df.to_csv(txt_path, sep=",", index=False, header=False) #-> visualization(img, new_txt_df) #Se puede visualizar si se desea para # comprobar la orientación de las esquinas # Se extrae la extensión de la imagen ICDAR13 a mover (estrategia ya # aplicada): ext = img.split(chr(92))[-1].split(".")[-1] # Se genera el nuevo "path" de destino de la imagen/TXT ICDAR13 con cambio # de nombre: new_img_ICDAR15_path = os.path.join(ICDAR15_im_path, "img_{}.{}".format( last_num, ext)) new_ann_ICDAR15_path = os.path.join(ICDAR15_ann_path, "gt_img_{}.txt". format(last_num)) # Se mueve la imagen/TXT de carpeta y se actualiza la numeración: shutil.move(img, new_img_ICDAR15_path) shutil.move(txt_path, new_ann_ICDAR15_path) print("La imagen \"{}\" se ha pasado con exito.".format(img.split(chr(92)) [-1])) last_num += 1 A.2 Funciones de uso general de EASTlite En esta sección se mostrarán todas las funciones contenidas en el archivo de función TextDetection_- Functions.py . Se empieza por cargar la librería o función encargada de aplicar el algoritmo NMS, que se comentó en la Sección 2.3, y que aparece en el Código A.2.
86 Capítulo A. Códigos para la Detección de Texto Código A.2 Cargado de la libería “lanms” o la función de NMS. try: import lanms def NMS(raw_boxes, iou_threshold): return lanms.merge_quadrangle_n9(raw_boxes, iou_threshold) except: def NMS(raw_boxes, iou_threshold): #Aplica el algoritmo de "Non-Maximum # Suppression" a las cajas resultantes # Ordenas los indices de las cajas en función de la puntuación y # se inicializa una lista de indices seleccionados: sorted_idx = np.argsort(raw_boxes[:, 8])[::-1] #De mayor a menor score selected_idx = [] #Indices que no serán eliminados al aplicar el algoritmo # Se recorre los índices ordenados hasta que no queden más índices: while len(sorted_idx) > 0: # Se selecciona/añade el índice de mayor puntuación (el primero): actual_idx = sorted_idx[0] selected_idx.append(actual_idx) # Se inicializa los vectores que almacenará el IoU y se genera # el polinomio de la caja seleccionada: IoU_array = np.zeros(len(raw_boxes)) selected_poly = shap.geometry.Polygon(raw_boxes[actual_idx, :8]. reshape((4, 2))) #Descartando la puntuación # Se calcula las áreas de poligono/interesección entre el # seleccionado y el resto: for idx in sorted_idx[1:]: # Se genera el poligono de comparación: other_poly = shap.geometry.Polygon(raw_boxes[idx, :8].reshape((4, 2))) #Descartando la puntuación # Se determina si hay intersección y si es así se calcula # el IuO: if selected_poly.intersects(other_poly): inter_area = selected_poly.intersection(other_poly).area IoU_array[idx] = inter_area/(selected_poly.area + other_poly. area - inter_area) else: IoU_array[idx] = 0.0 # Determinar que cajas no seleccionadas cumplen con el "threshold" # y se puede eliminar: overlap_idx = np.where(IoU_array >= iou_threshold)[0] sorted_idx = np.delete(sorted_idx, np.where(np.in1d(sorted_idx, overlap_idx))[0]) # Para continuar iterando se elimina el índice actual ya alamcenado: sorted_idx = np.delete(sorted_idx, 0)
A.2 Funciones de uso general de EASTlite 87 # Al finalizar se tendrá la lista de índices seleccionados y se # devuelve las boxes independientes: return raw_boxes[selected_idx, :] El siguiente extracto de código (ver Código A.3) se trata de la función encargada de extraer todas las imágenes dentro de un “path” absoluto imgs_path y las anotaciones asociadas anns_path , del “dataset” de ICDAR. Se emparejan comprobando de que no se entrega una imagen sin su archivo .txt adjunto. La lista de parejas están ordenadas según el número de identificación. Código A.3 Cargado de los “path” de las imágenes y anotaciones de los ICDAR. def load_images_path(imgs_path, anns_path): # Se inicializan la listas que alamcenará todos los "path" absolutos # de las imágenes y anotaciones: all_img_files = [] #Imágenes -> En general ".jpg" all_ann_files = [] #Anotaciones -> ".txt" # Se recorre cada una de las exteniones de imagen y se saca la lista # completa de imágenes: for ext in ["jpeg", "jpg", "png"]: all_img_files.extend(glob.glob(os.path.join(imgs_path, "*.{}".format( ext)))) # Se extrae la lista completa de notaciones: all_ann_files.extend(glob.glob(os.path.join(anns_path, "*.txt"))) # Se ordenan ambas listas según número de identificación de la # imagen/anotación de los "path" de ambos: all_img_files = sorted(all_img_files.copy(), key=lambda x: int(os.path. basename(x).split(’_’)[1].split(’.’)[0])) all_ann_files = sorted(all_ann_files.copy(), key=lambda x: int(os.path. basename(x).split(’_’)[2].split(’.’)[0])) # Se inicialia una lista que alamecenará las parejas de imagen/anotación: all_pair_files = [] # Se recorre cada uno de los "path" de las imagenes y se busca su # anotación adjunta: for file in all_img_files: # Se genera el nombre del archivo de anotación adjunto a parir # de la imagen: pair_ann_file = os.path.join(anns_path, "gt_" + os.path.basename(file). split(".")[0] + ".txt") # Se comprueba si está y si es así se genera la pareja, si no, # se descarta: if pair_ann_file in all_ann_files: all_pair_files.append([file, pair_ann_file]) # Se finaliza el proceso pasando la lista de pares como "array": return np.array(all_pair_files) En el Código A.4 se muestra la función encargada recibir las dimensiones de la imagen como input_sz y las anotaciones (tantas filas como “bounding boxes” y como columnas: {(x1,y1),(x2,y2),(x3,y3),(x4,y4)} ;
88 Capítulo A. Códigos para la Detección de Texto siendo las coordenadas {x,y} de la esquina superior izquierda {1} , la esquina superior derecha {2} , la esquina inferior derecha {3} y la esquina inferior izquierda {4} , en píxeles de la imagen original). Con estos datos se generan: mapa de puntuación ( score_map ), mapas geométricos (“Top-Right-Botom-Left-Angle”) y máscara de entrenamiento Training_mask , según el método RBOX. Hace uso de las funciones internas: poly_shrinker(...) ysort_rectangle(...). Código A.4 Generador de mapas para el entrenamiento de EASTlite. def maps_generator(anns, labels, input_sz): # Función Interna 1: Encargada de "encoger" la "Bounding Box" definida # en las anotaciones def poly_shrinker(poly, r): # PARÁMETROS: # poly -> Vertices de las anotaciones [x1,y1], [x2,y2], # [x3,y3] y [x4,y4]. # r -> Ratio en la imagen original rescalada. # return -> Vertices ajustados al rescalado. R = 0.3 #shrink ratio # Find the longer pair if np.linalg.norm(poly[0] - poly[1]) + np.linalg.norm(poly[2] - poly[3]) > np.linalg.norm(poly[0] - poly[3]) + np.linalg.norm(poly[1] - poly [2]): # First move (p0, p1), (p2, p3), then (p0, p3), (p1, p2) ## p0, p1 theta = np.arctan2((poly[1][1] - poly[0][1]), (poly[1][0] - poly [0][0])) poly[0][0] += R * r[0] * np.cos(theta) poly[0][1] += R * r[0] * np.sin(theta) poly[1][0] -= R * r[1] * np.cos(theta) poly[1][1] -= R * r[1] * np.sin(theta) ## p2, p3 theta = np.arctan2((poly[2][1] - poly[3][1]), (poly[2][0] - poly [3][0])) poly[3][0] += R * r[3] * np.cos(theta) poly[3][1] += R * r[3] * np.sin(theta) poly[2][0] -= R * r[2] * np.cos(theta) poly[2][1] -= R * r[2] * np.sin(theta) ## p0, p3 theta = np.arctan2((poly[3][0] - poly[0][0]), (poly[3][1] - poly [0][1])) poly[0][0] += R * r[0] * np.sin(theta) poly[0][1] += R * r[0] * np.cos(theta) poly[3][0] -= R * r[3] * np.sin(theta) poly[3][1] -= R * r[3] * np.cos(theta) ## p1, p2 theta = np.arctan2((poly[2][0] - poly[1][0]), (poly[2][1] - poly [1][1])) poly[1][0] += R * r[1] * np.sin(theta) poly[1][1] += R * r[1] * np.cos(theta) poly[2][0] -= R * r[2] * np.sin(theta) poly[2][1] -= R * r[2] * np.cos(theta) else: ## p0, p3
A.2 Funciones de uso general de EASTlite 89 theta = np.arctan2((poly[3][0] - poly[0][0]), (poly[3][1] - poly [0][1])) poly[0][0] += R * r[0] * np.sin(theta) poly[0][1] += R * r[0] * np.cos(theta) poly[3][0] -= R * r[3] * np.sin(theta) poly[3][1] -= R * r[3] * np.cos(theta) ## p1, p2 theta = np.arctan2((poly[2][0] - poly[1][0]), (poly[2][1] - poly [1][1])) poly[1][0] += R * r[1] * np.sin(theta) poly[1][1] += R * r[1] * np.cos(theta) poly[2][0] -= R * r[2] * np.sin(theta) poly[2][1] -= R * r[2] * np.cos(theta) ## p0, p1 theta = np.arctan2((poly[1][1] - poly[0][1]), (poly[1][0] - poly [0][0])) poly[0][0] += R * r[0] * np.cos(theta) poly[0][1] += R * r[0] * np.sin(theta) poly[1][0] -= R * r[1] * np.cos(theta) poly[1][1] -= R * r[1] * np.sin(theta) ## p2, p3 theta = np.arctan2((poly[2][1] - poly[3][1]), (poly[2][0] - poly [3][0])) poly[3][0] += R * r[3] * np.cos(theta) poly[3][1] += R * r[3] * np.sin(theta) poly[2][0] -= R * r[2] * np.cos(theta) poly[2][1] -= R * r[2] * np.sin(theta) return poly # Función Interna 2: Busca las coordenadas de los 4 vertices dados por # "poly" (explorados "clockwise"): def sort_rectangle(poly): # First find the lowest point p_lowest = np.argmax(poly[:, 1]) # If the bottom line is parallel to x-axis, then p0 must be the # upper-left corner if np.count_nonzero(poly[:, 1] == poly[p_lowest, 1]) == 2: p0_index = np.argmin(np.sum(poly, axis=1)) p1_index = (p0_index + 1) % 4 p2_index = (p0_index + 2) % 4 p3_index = (p0_index + 3) % 4 return poly[[p0_index, p1_index, p2_index, p3_index]], 0.0 else: # Find the point that sits right to the lowest point p_lowest_right = (p_lowest - 1) % 4 p_lowest_left = (p_lowest + 1) % 4 angle = np.arctan(-(poly[p_lowest][1] - poly[p_lowest_right][1])/( poly[p_lowest][0] - poly[p_lowest_right][0])) # This point is p2 if angle / np.pi * 180 > 45: p2_index = p_lowest p1_index = (p2_index - 1) % 4 p0_index = (p2_index - 2) % 4
96 Capítulo A. Códigos para la Detección de Texto if (precision + recall) != 0: fscore = 2*(precision*recall)/(precision + recall) else: fscore = 0.0 # Se devuelve las metricas en un diccionario: return {"IoU":IoU_table, "recall":recall*100, "precision":precision*100, "fscore":fscore*100} # En el caso de que no hay predicciones: return {"IoU":np.array([]), "recall":0, "precision":0, "fscore":0} La función del Código A.7 se encarga de cargar la imagen y anotación emparejada en el “path” absoluto. Estos “path” corresponde a las imágenes de validación a la que poder aplicar métricas. Las imágenes deben escalarse a unas dimensiones, múltiplos de 32, que permita su entrada en el modelo de red neuronal EASTlite. Esto se realiza en la función interna load_image_and_resize(...) , la cual devuelve: la imagen escalada, las dimensiones originales (altura y ancho) y los ratios de escalado en el mismo orden. Las anotaciones se debe corregir a los mimos ratios con la función interna load_and_correct_annotations(...). Código A.7 Cargado y escaldo de las imágenes y anotaciones para la inferencia de EASTlite. def load_inference_image(img_and_ann_path): # Función 1: Carga la imagen dada por el "path" absoluto y se reescala def load_image_and_resize(img_path): # Se carga la imagen y sus dimensiones: im =cv2.imread(img_path)[:, :, ::-1] #BRG -> RGB height, width, _ = im.shape # Se calcula el ratio de limitación del lateral según el máximo # admisible: if max(height, width) > 2400: ratio = (2400/height if height > width else 2400/width) else: ratio = 1.0 # Se corrige los laterales para que no superen el ratio de # limitación: height_rs = int(height*ratio) width_rs = int(width*ratio) # Se limita dichas dimensiones a multiplos de 32: height_rs = height_rs if height_rs%32 == 0 else (height_rs//32-1)*32 width_rs = width_rs if width_rs%32 == 0 else (width_rs//32-1)*32 height_rs = max(32, height_rs) width_rs = max(32, width_rs) # Se calculan los ratios de redimensión final: h_rat = height_rs/float(height) w_rat = width_rs/float(width) # Se devuelve la imagen rescalada, junto con las dimensiones # originales y los ratios de escalado: return cv2.resize(im, (int(width_rs), int(height_rs))), (height, width), (h_rat, w_rat)
A.2 Funciones de uso general de EASTlite 97 # Función Interna 2: Lectura del TXT según el "path" absoluto # de las anotaciones y se corrigen def load_and_correct_annotations(ann_path, orig_size, resize_rat): # Se inicializa las listas donde irán los vertices de # las "Bounding Box": BB_list = [] # Se abre el archivo, se pasa por el lector de CSV y se analiza # linea a linea: with open(ann_path, "r") as file_opener: reader = csv.reader(file_opener) for line in reader: # A cada línea se elimina los BOM y se segmenta en elementos: line = [word.strip("\ufeff").strip("\xef\xbb\xbf") for word in line] # Si el número de elementos es superior a 9, se # elimina los restantes: if len(line) > 9: line = line[:9] # Se extae las coordenadas de las 4 esquinas de la # "Bounding Box" y se almacenan: x1, y1, x2, y2, x3, y3, x4, y4 = list(map(float, line[:8])) BB_list.append([[x1, y1], [x2, y2], [x3, y3], [x4, y4]]) # Se constituye las anotaciones como "array" y se extraen las # dimensiones de las imagen original: BB_array = np.array(BB_list, dtype=np.float32) height, width = orig_size # Se valida que las coordenadas (x,y) están dentro de la # imagen (ancho x alto): BB_array[:, :, 0] = np.clip(BB_array[:, :, 0], 0, width - 1) #X BB_array[:, :, 1] = np.clip(BB_array[:, :, 1], 0, height - 1) #Y # Se calcula el área del polígono resultante de cada # uno de las anotaciones: area = np.sum([(BB_array[:, 1, 0] - BB_array[:, 0, 0]) * (BB_array[:, 1, 1] + BB_array[:, 0, 1]), (BB_array[:, 2, 0] - BB_array[:, 1, 0]) * (BB_array[:, 2, 1] + BB_array[:, 1, 1]), (BB_array[:, 3, 0] - BB_array[:, 2, 0]) * (BB_array[:, 3, 1] + BB_array[:, 2, 1]), (BB_array[:, 0, 0] - BB_array[:, 3, 0]) * (BB_array[:, 0, 1] + BB_array[:, 3, 1])], axis=0)/2.0 # Se va analizando una a una las anotaciones para modificar esta: for ii in range(len(area)): # Se elimina aquellas anotaciones con area pequeña: if abs(area[ii]) < 1: BB_array = np.delete(BB_array, ii, axis=0)
98 Capítulo A. Códigos para la Detección de Texto # Se cambia la orientación a "clockwise" si el area es positiva: if area[ii] > 0: BB_array[ii, :, :] = BB_array[ii, (0, 3, 2, 1), :] # Se rescalan las anotaciones a las dimensiones de entrada # respetando los ratios: BB_array[:, :, 0] *= resize_rat[1] #Ancho BB_array[:, :, 1] *= resize_rat[0] #Alto # Se devuelve las anotaciones corregidas y escaladas: return BB_array # (INICIO DEL MAIN) Se carga la imagen reescalada y las anotaciones # contenidas en el txt adjunto: img_resize, orig_dim, ratios = load_image_and_resize(img_and_ann_path[0]) ann_xy = load_and_correct_annotations(img_and_ann_path[1], orig_dim, ratios ) # NOTA : La estructura de las anotaciones es un vector dimesniones # (X,Y,Z), donde X es el número de anotaciones distintas (cada # "Bounding Box"), Y es cada uno de los vertices que define una # "Bounding Box" (hay 4) y Z es si la coordenada x (0) o y (1) # del vertice. # Se devuelve la imagen escalda y anotaciones corregidas: return img_resize.astype(np.float32), ann_xy La función del Código A.8 se encarga de cargar las imágenes y anotaciones emparejadas en los “path” absolutos. Posteriormente, debe corregir las anotaciones y generar los mapas de la imagen, para agruparlos en unas listas. Todo escalado a las dimensiones cuadradas que debe recibir el modelo input_size . Usa la función interna: correct_annotations(...) ycorrect_and_resize(...). Código A.8 Cargado de las imagenes y genración de los mapas para el entrenamiento de EASTlite (sin aumento de datos). def load_non_augmententation_images(imgs_and_anns_paths, input_size): # Función Interna 1: Lectura del TXT según el "path" absoluto de # las anotaciones def load_annotations(ann_path): # Se inicializa las listas donde irán los vertices de las # "Bounding Box" y su "label": BB_list = [] label = [] # Se abre el archivo, se pasa por el lector de CSV y se analiza # linea a linea: with open(ann_path, "r") as file_opener: reader = csv.reader(file_opener) for line in reader: # A cada línea se elimina los BOM y se segmenta en elementos: line = [word.strip("\ufeff").strip("\xef\xbb\xbf") for word in line]
A.2 Funciones de uso general de EASTlite 99 # Si el número de elementos es superior a 9, se elimina # los restantes: if len(line) > 9: line = line[:9] # Se extae las coordenadas de las 4 esquinas de la # "Bounding Box" y se almacenan: x1, y1, x2, y2, x3, y3, x4, y4 = list(map(float, line[:8])) BB_list.append([[x1, y1], [x2, y2], [x3, y3], [x4, y4]]) # Se extare la "label" (ultimo elemento) y se filtra según # esté identificada o no: if line[-1] == "###": label.append(True) else: label.append(False) # Se devuelven las anotaciones extraidas en formato "array": return np.array(BB_list, dtype=np.float32), np.array(label, dtype=np. bool_) # Función Interna 2: Corrección y reescalado de la imagen y anotaciones # a las dimensiones de entrada def correct_and_resize(orig_im, orig_ann, orig_label, input_sz): # Se saca las dimensiones de la imagen original: height, width, _ = orig_im.shape # Se valida que las coordenadas (x,y) están dentro de la imagen # (ancho x alto): orig_ann[:, :, 0] = np.clip(orig_ann[:, :, 0], 0, width - 1) #X orig_ann[:, :, 1] = np.clip(orig_ann[:, :, 1], 0, height - 1) #Y # Se calcula el área del polígono resultante de cada uno de # las anotaciones: area = np.sum([(orig_ann[:, 1, 0] - orig_ann[:, 0, 0]) * (orig_ann[:, 1, 1] + orig_ann[:, 0, 1]), (orig_ann[:, 2, 0] - orig_ann[:, 1, 0]) * (orig_ann[:, 2, 1] + orig_ann[:, 1, 1]), (orig_ann[:, 3, 0] - orig_ann[:, 2, 0]) * (orig_ann[:, 3, 1] + orig_ann[:, 2, 1]), (orig_ann[:, 0, 0] - orig_ann[:, 3, 0]) * (orig_ann[:, 0, 1] + orig_ann[:, 3, 1])], axis=0)/2.0 # Se va analizando una a una las anotaciones para modificar # esta y su "label": for ii in range(len(area)): # Se elimina aquellas anotaciones/"label" con area pequeña: if abs(area[ii]) < 1: orig_ann = np.delete(orig_ann, ii, axis=0) orig_label = np.delete(orig_label, ii, axis=0) # Se cambia la orientación a "clockwise" si el area es positiva: if area[ii] > 0: orig_ann[ii, :, :] = orig_ann[ii, (0, 3, 2, 1), :]
100 Capítulo A. Códigos para la Detección de Texto # Se rescalan las anotaciones a las dimensiones de entrada # respetando los ratios: orig_ann[:, :, 0] *= input_sz/width #Ancho orig_ann[:, :, 1] *= input_sz/height #Alto # Se devuelve las anotaciones corregidas y la imagen reescalda: return cv2.resize(orig_im, dsize=(input_sz, input_sz)), orig_ann, orig_label # (INICIO DEL MAIN) Se inicializa los vectores vacios de imagenes # y mapas a generar: images = np.empty((0, input_size, input_size, 3)) score_maps = np.empty((0, int(input_size/4), int(input_size/4), 1)) geo_maps = np.empty((0, int(input_size/4), int(input_size/4), 5)) training_masks = np.empty((0, int(input_size/4), int(input_size/4), 1)) # Se recorre cada pareja de "paths" de ficheros de imagen y anotación: for pair_path in imgs_and_anns_paths: # Se lee la imagen y las anotaciones contenidas en el txt adjunto: img = cv2.imread(pair_path[0])[:, :, ::-1] #BRG -> RGB ann_xy, ann_label = load_annotations(pair_path[1]) # NOTA : Al haberse leido la imagen con cv2, esta devuelve la # imagen en BGR, pero en realidad se debe mostrar como RGB. # En cuanto a la estructura de las anotaciones, es un vector # dimensiones (X,Y,Z), donde X es el número de naotaciones # distintas (cada "Bounding Box"), Y es cada uno de los vertices # que define una "Bounding Box" (hay 4) y Z es si la coordenada # x (0) o y (1) del vertice. # Se rescala la imagen y las anotaciones a las dimensiones de # entrada del modelo: img_rs, ann_xy_rs, ann_label_rs = correct_and_resize(img, ann_xy, ann_label, input_size) # Se generan los mapas de puntuaión y geométricos: score_map, geo_map, training_mask = maps_generator(ann_xy_rs, ann_label_rs, input_size) # Se almacena las imagenes y los mapas generados en el formato adecuado: images = np.vstack((images, (img_rs[np.newaxis, :].astype(np.float32)))) score_maps = np.vstack((score_maps, score_map[np.newaxis, ::4, ::4, np. newaxis].astype(np.float32))) geo_maps = np.vstack((geo_maps, geo_map[np.newaxis, ::4, ::4, :].astype (np.float32))) training_masks = np.vstack((training_masks, training_mask[np.newaxis, ::4, ::4, np.newaxis].astype(np.float32))) # NOTA : En cuanto a los mapas, se reducen a una cuarta parte al # cogerse una componente cada 4 y aquellos mapas que sea de un # solo canal se le añade un eje extra para que estén en la misma # tipologia de tensor. return images, score_maps, geo_maps, training_masks
A.2 Funciones de uso general de EASTlite 101 La función del Código A.9 es similar a la Código A.8, pero en esta ocasión aplica aumento de datos a las imágenes y anotaciones cargadas. Requiere de una función interna extra: crop_area(...). Código A.9 Cargado de las imagenes y genración de los mapas para el entrenamiento de EASTlite (con aumento de datos). def load_augmententation_images(imgs_and_anns_paths, input_size): # Función Interna 1: Lectura del TXT según el "path" absoluto de # las anotaciones def load_annotations(ann_path): # Se inicializa las listas donde irán los vertices de las # "Bounding Box" y su "label": BB_list = [] label = [] # Se abre el archivo, se pasa por el lector de CSV y se analiza # linea a linea: with open(ann_path, "r") as file_opener: reader = csv.reader(file_opener) for line in reader: # A cada línea se elimina los BOM y se segmenta en elementos: line = [word.strip("\ufeff").strip("\xef\xbb\xbf") for word in line] # Si el número de elementos es superior a 9, se elimina # los restantes: if len(line) > 9: line = line[:9] # Se extae las coordenadas de las 4 esquinas de la # "Bounding Box" y se almacenan: x1, y1, x2, y2, x3, y3, x4, y4 = list(map(float, line[:8])) BB_list.append([[x1, y1], [x2, y2], [x3, y3], [x4, y4]]) # Se extare la "label" (ultimo elemento) y se filtra según # esté identificada o no: if line[-1] == "###": label.append(True) else: label.append(False) # Se devuelven las anotaciones extraidas en formato "array": return np.array(BB_list, dtype=np.float32), np.array(label, dtype=np. bool_) # Función Interna 2: Corrección de las anotaciones a las dimensiones # originales de la imagen def correct_annotations(orig_im, orig_ann, orig_label): # Se saca las dimensiones de la imagen original: height, width, _ = orig_im.shape
102 Capítulo A. Códigos para la Detección de Texto # Se valida que las coordenadas (x,y) están dentro de la imagen # (ancho x alto): orig_ann[:, :, 0] = np.clip(orig_ann[:, :, 0], 0, width - 1) #X orig_ann[:, :, 1] = np.clip(orig_ann[:, :, 1], 0, height - 1) #Y # Se calcula el área del polígono resultante de cada uno de # las anotaciones: area = np.sum([(orig_ann[:, 1, 0] - orig_ann[:, 0, 0]) * (orig_ann[:, 1, 1] + orig_ann[:, 0, 1]), (orig_ann[:, 2, 0] - orig_ann[:, 1, 0]) * (orig_ann[:, 2, 1] + orig_ann[:, 1, 1]), (orig_ann[:, 3, 0] - orig_ann[:, 2, 0]) * (orig_ann[:, 3, 1] + orig_ann[:, 2, 1]), (orig_ann[:, 0, 0] - orig_ann[:, 3, 0]) * (orig_ann[:, 0, 1] + orig_ann[:, 3, 1])], axis=0)/2.0 # Se va analizando una a una las anotaciones para modificar esta # y su "label": for ii in range(len(area)): # Se elimina aquellas anotaciones/"label" con area pequeña: if abs(area[ii]) < 1: orig_ann = np.delete(orig_ann, ii, axis=0) orig_label = np.delete(orig_label, ii, axis=0) # Se cambia la orientación a "clockwise" si el area es positiva: if area[ii] > 0: orig_ann[ii, :, :] = orig_ann[ii, (0, 3, 2, 1), :] # Se devuelve las anotaciones corregidas a dimensiones originales: return orig_ann, orig_label # Función Interna 3: Recorte de la imagen de forma aleatoria def crop_area(im, anns, labels, crop_flag): # Se saca las dimensiones de la imagen y las de la máscara de corte: height, width, _ = im.shape pad_h = height//10 pad_w = width//10 # Se inicializa las máscaras de corte: h_array = np.zeros((height + pad_h*2), dtype=np.int32) w_array = np.zeros((width + pad_w*2), dtype=np.int32) # Se recorre cada uno de los anotaciones de texto de la imagen: for BB in anns: # Se redondea las anotaciones y se completa las máscaras de recorte: BB = np.round(BB, decimals=0).astype(np.int32) h_array[(np.min(BB[:, 1]) + pad_h):(np.max(BB[:, 1]) + pad_h)] w_array[(np.min(BB[:, 0]) + pad_w):(np.max(BB[:, 0]) + pad_w)] # Comprobar que el area de recorte y si no cumple se devuelve todo # sin recortar: h_axis = np.where(h_array == 0)[0] w_axis = np.where(w_array == 0)[0] if len(h_axis) == 0 or len(w_axis) == 0:
A.2 Funciones de uso general de EASTlite 103 return im, anns, labels # Si si cumple se procede a realizar varios intentos hasta recortarla: for intent in range(50): #50 intentos # Se calcula una selección de punto aleatorios de las máscaras # de corte: x_select = np.random.choice(w_axis, size=2) y_select = np.random.choice(h_axis, size=2) # Se busca un min/max dentro de las dimensiones en x: xmin = np.clip((np.min(x_select) - pad_w), 0, width - 1) xmax = np.clip((np.max(x_select) - pad_w), 0, width - 1) # Se busca un min/max dentro de las dimensiones en y: ymin = np.clip((np.min(y_select) - pad_h), 0, height - 1) ymax = np.clip((np.max(y_select) - pad_h), 0, height - 1) # Se comprueba si no es demasiado pequeña y se reintenta: if (xmax - xmin < 0.1*width) or (ymax - ymin < 0.1*height): continue # Se comprueba si se tiene alguna anotación filtrando si están # dentro de los limites de corte: if anns.shape[0] != 0: anns_in_area = (anns[:, :, 0] >= xmin) & (anns[:, :, 0] <= xmax) & (anns[:, :, 1] >= ymin) & (anns[:, :, 1] <= ymax) select_anns = np.where(np.sum(anns_in_area, axis=1) == 4)[0] #Se selecciona los indices de las anotaciones dentro de los lí mites else: select_anns = np.empty((0,)) #Si no hay anotaciones se tiene un listado vacio # Si no se tiene anotaciones dentro del area de corte: if len(select_anns) == 0: # Se decide si recortar o no y pasar: if crop_flag: return im[ymin : ymax + 1, xmin : xmax + 1, :], anns[ select_anns], labels[select_anns] else: continue # Si si hay anotaciones, se ajusta para el recorte (dejando # aquellos selleccionados) y se devuelve: anns = anns[select_anns] labels = labels[select_anns] anns[:, :, 0] -= xmin anns[:, :, 1] -= ymin return im[ymin : ymax + 1, xmin : xmax + 1, :], anns, labels # Caso de que no se logre en los 50 intentos, se deja como está: return im, anns, labels # (INICIO DEL MAIN) Se inicializa los vectores vacios de imagenes # y mapas a generar:
104 Capítulo A. Códigos para la Detección de Texto images = np.empty((0, input_size, input_size, 3)) score_maps = np.empty((0, int(input_size/4), int(input_size/4), 1)) geo_maps = np.empty((0, int(input_size/4), int(input_size/4), 5)) training_masks = np.empty((0, int(input_size/4), int(input_size/4), 1)) # Se recorre cada pareja de "paths" de ficheros de imagen y anotación: for pair_path in imgs_and_anns_paths: # Se lee la imagen y las anotaciones contenidas en el txt adjunto: img = cv2.imread(pair_path[0])[:, :, ::-1] #BRG -> RGB ann_xy, ann_label = load_annotations(pair_path[1]) # NOTA : Al haberse leido la imagen con cv2, esta devuelve la # imagen en BGR, pero en realidad se debe mostrar como RGB. # En cuanto a la estructura de las anotaciones, es un vector # dimesniones (X,Y,Z), donde X es el número de naotaciones # distintas (cada "Bounding Box"), Y es cada uno de los # vertices que define una "Bounding Box" (hay 4) y Z es si # la coordenada x (0) o y (1) del vertice # Se corriengen anotaciones a las dimensiones originales de la imagen: ann_xy, ann_label = correct_annotations(img, ann_xy, ann_label) # (AUMENTO DE DATOS) Se elige una escala aleatoria para la image # y se escala la imagen: rd_scale = np.random.choice(np.array([0.5, 1, 2.0, 3.0])) img = cv2.resize(img, dsize=None, fx=rd_scale, fy=rd_scale) # Se corregien las dimensiones de las anotaciones a la nueva escala: ann_xy *= rd_scale #Ancho y ALto # Se hace un recorte aleatoria del area de la imagen: if np.random.rand() < 3/8: img, ann_xy, ann_label = crop_area(img, ann_xy, ann_label, True) # Si se tiene anotaciones todavía -> "cannot find background" # y se salta la imagen: if ann_xy.shape[0] > 0: continue # Se sacan las dimensiones de la nueva imagen y se compara con # la dimensión de entrada: new_h, new_w, _ = img.shape max_hw_input = np.max([new_h, new_w, input_size]) # Se rellena con 0 la parte de la imgen que no llegue a # la dimensión de entrada: im_padded = np.zeros((max_hw_input, max_hw_input, 3), dtype=np.uint8 ) im_padded[:new_h, :new_w, :] = img.copy() # Se reajusta la imagen a las dimensiones de entrada y se # generaran los mapas vacios (0 anotaciones): img = cv2.resize(im_padded, dsize=(input_size, input_size)) score_map = np.zeros((input_size, input_size), dtype=np.uint8) geo_map = np.zeros((input_size, input_size, 5), dtype=np.float32) training_mask = np.ones((input_size, input_size), dtype=np.uint8)
A.2 Funciones de uso general de EASTlite 105 # Si no cumple con las condiciones de "background" del recorte se # hace un recorte con anotaciones: else: img, ann_xy, ann_label = crop_area(img, ann_xy, ann_label, False) # Si no se tiene anotaciones, se salta la imagen: if ann_xy.shape[0] == 0: continue # Se sacan las dimensiones de la nueva imagen y se compara con # la dimensión de entrada: new_h, new_w, _ = img.shape max_hw_input = np.max([new_h, new_w, input_size]) # Se rellena con 0 la parte de la imgen que no llegue a la # dimensión de entrada: im_padded = np.zeros((max_hw_input, max_hw_input, 3), dtype=np.uint8 ) im_padded[:new_h, :new_w, :] = img.copy() # Se genera la nueva imagen y se extraen sus dimensiones modificadas : img = im_padded new_h, new_w, _ = img.shape # Se rescala la imagen y la lista de anotaciones a las # nuevas dimensiones: img = cv2.resize(img, dsize=(input_size, input_size)) ann_xy[:, :, 0] *= input_size/float(new_w) #Ancho ann_xy[:, :, 1] *= input_size/float(new_h) #Alto # Se generan los mapas de puntuaión y geométricos: score_map, geo_map, training_mask = maps_generator(ann_xy, ann_label , input_size) # Se almacena las imagenes y los mapas generados en el formato adecuado: images = np.vstack((images, (img[np.newaxis, :].astype(np.float32)))) score_maps = np.vstack((score_maps, score_map[np.newaxis, ::4, ::4, np. newaxis].astype(np.float32))) geo_maps = np.vstack((geo_maps, geo_map[np.newaxis, ::4, ::4, :].astype (np.float32))) training_masks = np.vstack((training_masks, training_mask[np.newaxis, ::4, ::4, np.newaxis].astype(np.float32))) # NOTA : En cuanto a los mapas, se reducen a una cuarta parte # al cogerse una componente cada 4 y aquellos mapas que sea de # un solo canal se le añade un eje extra para que estén en la # misma tipologia de tensor. # Se devuelve todo el listado de imágenes y mapas: return images, score_maps, geo_maps, training_masks A continuación, en Código A.10, se muestra la función de costes. Esta se encarga de calcular el valor de perdida del mapa de puntuación y geométrico durante el entrenamiento de la red neuronal EASTlite (requiere de la función interna: dic_coeff(...) ). También, se facilitan las clases dedicadas a aportar datos durante
112 Capítulo A. Códigos para la Detección de Texto # Se guarda la imagencomo PDF de alta resolución: if save_flag: pdf_pages = PdfPages("RESULT_annotations_and_metrics.pdf") #Creacción del PDF pdf_pages.savefig(fig, bbox_inches="tight", dpi=600) pdf_pages.close() #Se cierra el PDF return plt.gcf() # Obtención del directorio de trabajo, generación de los "path" de validación # y el "path" de los pesos: work_path = os.getcwd() im_path = os.path.join(work_path, "Scene_Text_Datasets", "Validation_Images") ann_path = os.path.join(work_path, "Scene_Text_Datasets", " Validation_Annotations") weight_path = os.path.join(work_path, "Model_Ckeckpoints", " TFM_EAST_weights_ep200.h5") # Se genera el modelo y se le añaden los pesos: print("Se carga el modelo y los \"paths\" de las imágenes:") EAST_model = TDF.model_assembler() EAST_model.load_weights(weight_path) # Se cargan todos los "path" absolutos de las imágenes y anotaciones: all_pair_path = TDF.load_images_path(im_path, ann_path) # Se inicializa los vectores que almacene las métricas: recall = np.array([]) precision = np.array([]) fscore = np.array([]) # Se recorre cada uno de los pares y se predice: print("\nSe procede a sacar las métricas de las imágenes de validación:") for pair_path in all_pair_path: #!!->Se seleccionan unas cuentas # Se carga la imagen y anotaciones ajustadas al EAST y se predice: img, ann_true = TDF.load_inference_image(pair_path) pred_maps = EAST_model.predict(tf.convert_to_tensor(img[np.newaxis, :, :, :]), batch_size=1) # Se hace una división de los mapas predecidos y se regenera las # "Bounding Boxes": geo_maps, score_map = tf.split(pred_maps, [5, 1], -1) boxes = TDF.box_regeneration(np.array(score_map[0, :, :, 0]), np.array( geo_maps[0]), (1, 1)) #Ratio (ancho, alto) # Se hace un calculo de métricas por imagen y se alamcenan: metrics_dict = TDF.metrics(boxes, ann_true) recall = np.append(recall, metrics_dict["recall"]) precision = np.append(precision, metrics_dict["precision"]) fscore = np.append(fscore, metrics_dict["fscore"]) # Se informa y se visualiza: metrics_values = (round(metrics_dict["precision"], ndigits=2), round( metrics_dict["recall"], ndigits=2), round(metrics_dict["fscore"], ndigits=2))
A.3 Proceso de entrenamiento e inferencia 113 print("\t- La imagen \"{}\" ha obtendido un \"precision\" de {}%, una \" recall\" de {}% y una \"fscore\" de {}%.".format(os.path.basename( pair_path[0]), metrics_values[0], metrics_values[1], metrics_values[2]) ) fig1 = image_visualization(img, ann_true, boxes, metrics_values, False) plt.show() print() # Se saca los resultados medios de las metricas: metrics_values = (round(np.sum(precision)/len(precision), ndigits=2), round(np. sum(recall)/len(recall), ndigits=2), round(np.sum(fscore)/len(fscore), ndigits=2)) print("\nLos resultados medios son:\n\t- \"precision\" -> {}%\n\t- \"recall\" -> {}%\n\t- \"fscore\" -> {}%".format(metrics_values[0], metrics_values[1], metrics_values[2])) Código A.14 Representador de datos de perdida y métricas. import os import numpy as np import pandas as pd import matplotlib.pyplot as plt from matplotlib.patches import Polygon from matplotlib.backends.backend_pdf import PdfPages # Función Visualizado 1: Repersenta las métricas de entrenamiento def training_visualization(csv_file, save_flag): # Se cargan los datos del CSV: hist_data = pd.read_csv(csv_file, sep=chr(9)) # Se extraen los datos del historial de entrenamiento y se informa # de los valores característicos: epochs = np.arange(1, hist_data.shape[0] + 1) train_loss = np.array(hist_data[["loss", "loss_function"]].max(axis=1)) val_loss = np.array(hist_data[["val_loss", "val_loss_function"]].max(axis =1)) print("\t- Entrenamiento: Se ha detectado un valor de \"Loss\" mínimo de {} en la época {}.".format(np.amin(train_loss), np.argmin(train_loss)+1)) print("\t- Validación: Se ha detectado un valor de \"Loss\" mínimo de {} en la época {}.\n".format(np.amin(val_loss), np.argmin(val_loss)+1)) # Identificar los datos máximos/mínimos alterados para los # "xticks"/"yticks" del eje: ep_max = np.max(epochs) ep_min = np.min(epochs) loss_max = np.max(np.maximum(train_loss, val_loss)) loss_min = np.min(np.minimum(train_loss, val_loss)) # Crear la figura y el subplot de representación de los datos de historial: fig, ax = plt.subplots(figsize=(8.3, 11.7/2)) # NOTA : Un A4 tiene [210mm X 297mm] o [8.3in X 11.7in] (Ancho X Alto). # Si quiero que la figura ocupe media cuartilla se debe limitar el alto.
114 Capítulo A. Códigos para la Detección de Texto # Se representa los datos de "loss": ax.plot(epochs, train_loss, ’bo-’, linewidth=2, markersize=5, label=" $training$") ax.plot(epochs, val_loss, ’rD-’, linewidth=2, markersize=5, label=" $validation$") ax.grid(True, which="both", linestyle=":", alpha=0.8) ax.set_xlim(ep_min, ep_max) ax.set_ylim(loss_min, loss_max) # Ajsutar los valores de representación de los ejes X-Y del eje: ax.set_xticks(np.linspace(ep_min, ep_max, num=11)) ax.set_xticklabels(ax.get_xticks().astype(np.int64), fontsize=12) ax.set_yticks(np.linspace(loss_min, loss_max, num=6)) ax.set_yticklabels(np.round(ax.get_yticks(), decimals=2), fontsize=12) # Se configuran los ejes de los "subplots": ax.set_position([0.06,0.1,0.92,0.92]) for lb in ["top", "right", "bottom", "left"]: ax.spines[lb].set_visible(True) ax.set_title("Loss progression during training", fontsize=15) ax.set_ylabel("Loss []", fontsize=15) ax.set_xlabel("Epoch", fontsize=15) ax.legend(loc="upper right", fontsize=12) # Se guarda la imagencomo PDF de alta resolución: if save_flag: pdf_pages = PdfPages("GRAPH_EAST_Loos.pdf") #Creacción del PDF pdf_pages.savefig(fig, bbox_inches="tight", dpi=600) pdf_pages.close() #Se cierra el PDF return plt.gcf() # Función Visualizado 2: Repersenta las métricas de evaluación del modelo def metrics_visualization(csv_file, save_flag): # Se cargan los datos del CSV: all_data = pd.read_csv(csv_file, sep=chr(9)) # Se devuelve el resultado del creador de EASTLite: print("""\nEl creador de EASTLite obtuvo en la época {} las métricas: \t- \"precision\" -> {}% \t- \"recall\" -> {}% \t- \"fscore\" -> {}%""".format(400, 84.14, 67.45, 74.88)) # Se sacan los datos reales obtenidos: epochs = np.array(all_data["epoch"]).astype(np.int64) precision = np.array(all_data["precision"]) recall = np.array(all_data["recall"]) fscore = np.array(all_data["fscore"]) print("""\nLos resultados reales de la EASTLite son: \t- \"precision\" -> {}% en la época {} \t- \"recall\" -> {}% en la época {} \t- \"fscore\" -> {}% en la época {}\n""".format(np.amax(precision), epochs[np.argmax(precision)], np.amax(recall), epochs[np.argmax( recall)], np.amax(fscore), epochs[np.argmax(fscore)]))
A.3 Proceso de entrenamiento e inferencia 115 # Identificar los datos máximos/mínimos alterados para los # "xticks"/"yticks" de cada eje: ep_max = np.max(epochs) ep_min = np.min(epochs) precision_max = np.max(1.05*precision) #Leve aumento para que se vea mejor precision_min = np.min(precision) recall_max = np.max(1.05*recall) #Leve aumento para que se vea mejor recall_min = np.min(recall) fscore_max = np.max(1.05*fscore) #Leve aumento para que se vea mejor fscore_min = np.min(fscore) # Crear la figura y los subplot de representación de los datos: fig, axs = plt.subplots(1, 3, figsize=(8.3, 11.7/2)) # NOTA : Un A4 tiene [210mm X 297mm] o [8.3in X 11.7in] (Ancho X Alto). # Si quiero que la figura ocupe media cuartilla se debe limitar el alto # Se representa los datos de "precision": axs[0].plot(epochs, precision, ’bo-’, linewidth=2, markersize=5) #axs[0].plot(creator_data["epoch"], creator_data["precision"], ’y*’, markersize=12) axs[0].grid(True, which="both", linestyle=":", alpha=0.8) axs[0].set_xlim(ep_min, ep_max) axs[0].set_ylim(precision_min, precision_max) # Se representa los datos de "recall": axs[1].plot(epochs, recall, ’ro-’, linewidth=2, markersize=5) #axs[1].plot(creator_data["epoch"], creator_data["recall"], ’y*’, markersize=12) axs[1].grid(True, which="both", linestyle=":", alpha=0.8) axs[1].set_xlim(ep_min, ep_max) axs[1].set_ylim(recall_min, recall_max) # Se representa los datos de "fscore": axs[2].plot(epochs, fscore, ’mo-’, linewidth=2, markersize=5) #axs[2].plot(creator_data["epoch"], creator_data["fscore"], ’y*’, markersize=12) axs[2].grid(True, which="both", linestyle=":", alpha=0.8) axs[2].set_xlim(ep_min, ep_max) axs[2].set_ylim(fscore_min, fscore_max) # Ajsutar los valores de representación de los ejes X-Y de cada "subplots": axs[0].set_xticks(epochs) axs[0].set_xticklabels([], fontsize=12) axs[0].set_yticks(np.linspace(precision_min, precision_max, num=5)) axs[0].set_yticklabels(np.round(axs[0].get_yticks(), decimals=1), fontsize =12) axs[1].set_xticks(epochs) axs[1].set_xticklabels([], fontsize=12) axs[1].set_yticks(np.linspace(recall_min, recall_max, num=5)) axs[1].set_yticklabels(np.round(axs[1].get_yticks(), decimals=1), fontsize =12) axs[2].set_xticks(epochs) axs[2].set_xticklabels(axs[2].get_xticks(), fontsize=12) axs[2].set_yticks(np.linspace(fscore_min, fscore_max, num=5)) axs[2].set_yticklabels(np.round(axs[2].get_yticks(), decimals=1), fontsize =12)
116 Capítulo A. Códigos para la Detección de Texto # Se configuran los ejes de los "subplots": axs[0].set_position([0.08,0.7,0.9,0.25]) axs[1].set_position([0.08,0.4,0.9,0.25]) axs[2].set_position([0.08,0.1,0.9,0.25]) for lb in ["top", "right", "bottom", "left"]: axs[0].spines[lb].set_visible(True) axs[1].spines[lb].set_visible(True) axs[2].spines[lb].set_visible(True) axs[0].set_title("Metrics progression during training", fontsize=15) axs[0].set_ylabel("Precision [%]", fontsize=15) axs[1].set_ylabel("Recall [%]", fontsize=15) axs[2].set_ylabel("F-score [%]", fontsize=15) axs[2].set_xlabel("Epoch", fontsize=15) # Se guarda la imagencomo PDF de alta resolución: if save_flag: pdf_pages = PdfPages("GRAPH_EAST_Metrics.pdf") #Creacción del PDF pdf_pages.savefig(fig, bbox_inches="tight", dpi=600) pdf_pages.close() #Se cierra el PDF return plt.gcf() # Obtención del directorio de trabajo, generación de los "path" de validación y el "path" de los pesos: work_path = os.getcwd() data_path = os.path.join(work_path, "Model_Ckeckpoints") # Se representa los datos de entrenamiento: print("Se presentan los datos de entrenamiento:") train_fig = training_visualization(os.path.join(data_path, "Performance_Data. csv"), True) plt.show() # Se representa los datos de entrenamiento: print("\nSe presentan los datos de las métricas:") met_fig = metrics_visualization(os.path.join(data_path, "Metrics_Data.csv"), True) plt.show()
Apéndice B Códigos para el Reconocimiento de Texto En este apéndice se facilitaran los códigos necesarios para llevar a cabo todo lo relacionado con el Capítulo 3, el reconocimiento de texto en forma de clasificación de caracteres. B.1 Construcción y entrenamiento de una red neuronal con el “dataset” EMNIST El Código B.1 y el Código B.2 representan los extractos de un mismo archivo .ipynb que se dedica a la generación y entrenamiento de la red neuronal convolucional que clasifique las imágenes del “dataset” EMNIST. En concreto, en el Código B.2 se representan las métricas del entrenamiento. Código B.1 Instrucciones para el entrenamiento de un modelo con el “dataset” EMNIST. ## LIBRERIAS: import os import numpy as np import pandas as pd import tensorflow as tf from tensorflow import keras ## FUNCIONES: # Función 1: Cargar los datos del archivo "file" y convertirlos al formato # exigido def preporcess_data(file_csv): # NOTA : El archivo de entrada "file" corresponde a un archivo CSV # con miles de filas y (1 + 28x28) columnas. Cada columna está # separado por comas y no tiene cabecera. Cada fila es una # imagen de 28x28 píxeles de valores entre [0-255] más una valor # de "label" (la primera columna). Asociado al archivo CSV hay # un archivo TXT que transforma la "label" en los caracteres del # Dataset. Este archivo de texto está compuesto por dos columnas # ("label" [0-46] y código ASCII) separadas por espacio. Primero # se debe extaer el TXT, ya que su tamaño permite hacer el # "one-hot encoding" de las "labels". # Generar el nombre del archivo de texto en función del "file": file_txt = file_csv.split("-") #Las palabras claves está separadas por "-" file_txt = file_txt[0] + "-" + file_txt[1] + "-mapping.txt" # Obtención del mapa entre las "labels" y los caracteres, según # especificaciones: raw_map = pd.read_csv(file_txt, sep=" ", header=None) 117
118 Capítulo B. Códigos para el Reconocimiento de Texto # NOTA : Al usar "read_csv" con "header=None" se genera una cabecera # genérica que asocia números del 0 hasta el número de columnas totales # a cada columna. Esta cabecera será números no "Strings". También, # tener cuidado al elimar una columna, porque la cabecera no se actualiza, # ejemplo: Si, elimino la columna 0, la primera columna será la 1, no la 0. # Generar el diccionario que relacione "label" con los caracteres: label_map = raw_map.drop(0, axis="columns") #Eliminar la primera columna label_map[1] = label_map[1].apply(chr) #Pasar de código ASCII a caracter label_map = dict(enumerate(label_map[1].tolist())) #Pasar a diccionario con el índice (filas) como guía # Lectura de los datos del CSV, especificando el separador y que no tiene # cabecera: raw_data = pd.read_csv(file_csv, sep=",", header=None) # Creación del set de entrada "x" (imágenes): x = raw_data.drop(0, axis="columns") #Eliminar la primera columna "label" x = x.astype("float32") #Especificar el tipo de dato x = x/255 #Normalizado del valor de los píxeles entre [0-1] x = x.values.reshape(-1, 28, 28, 1) #Conversión a un vector de 28x28x1 x = np.transpose(x, [0, 2, 1, 3]) #Transposición de alto por el ancho # Creación del set de salida "y" ("label") en modo "on-hot encoding": y = keras.utils.to_categorical(raw_data[0], len(label_map)) # Informar al usario sobre el Dataset tratado: print("\t- Los datos extraidos tienen las forma: " + str(x.shape)) print("\t- Las \"labels\" de los datos tienen las forma (formato \"out-hot encoding\"): " + str(y.shape)) print("\t- En total hay " + str(len(label_map)) + " \"labels\" diferentes. Son las siguientes: " + str(list(label_map.values())) + "\n") # Devolución de los set creados: return x, y, label_map # Función 2: Divede el "dataset" entrante en entrenamiento y validación: def dataset_splitter(train_data, split_factor, seed): # Se extaer el "dataset" de entrenamiento y sus "labels" en crudo: raw_train_x = train_data[0] raw_train_y = train_data[1] # Se calcula cauntas componetes hay en total y cuantas se destinana "val": N_total = raw_train_x.shape[0] N_val = int(split_factor*N_total) # Configurar el generador de números aleatorios y selección las componetes # de cada "dataset": rand_gen = np.random.default_rng(seed=seed) idx_val = rand_gen.choice(N_total, size=N_val, replace=False) idx_train = np.delete(np.arange(N_total), np.where(np.isin(np.arange( N_total), idx_val))) # Devolución de cada uno de los "dataset":
B.1 Construcción y entrenamiento de una red neuronal con el “dataset” EMNIST 119 return raw_train_x[idx_train], raw_train_y[idx_train], raw_train_x[idx_val ], raw_train_y[idx_val] # Función 3: Crea el modelo de red neuronal: def model_assembler(): # Cargar la capa de entrada y las capas de aumento de datos [1/8 -> pi/4] # (se pone rotación algo menor): input_ly = keras.Input(shape=[28, 28, 1], dtype=tf.float32) aug_ly = keras.layers.RandomRotation(factor=(-1/20, 1/20), fill_mode=" nearest", interpolation="nearest", seed=177)(input_ly) aug_ly = keras.layers.RandomZoom(height_factor=(0, 0.05), width_factor=None , fill_mode="nearest", interpolation="nearest", seed=177)(aug_ly) aug_ly = keras.layers.RandomTranslation(height_factor=(0, 0.05), width_factor=(0, 0.05), fill_mode="nearest", interpolation="nearest", seed=177)(aug_ly) # Se añade la primera capa de convolución: conv1_ly = keras.layers.Conv2D(filters=32, kernel_size=(3,3), activation=" relu")(aug_ly) conv1_ly = keras.layers.MaxPooling2D(pool_size=(2, 2))(conv1_ly) # Se añade la segunda capa de convolución: conv2_ly = keras.layers.Conv2D(filters=64, kernel_size=(3,3), activation=" relu")(conv1_ly) conv2_ly = keras.layers.MaxPooling2D(pool_size=(2, 2))(conv2_ly) # Se regulariza de convolucional a densas y se aplica el "dropout": dropout_ly = keras.layers.Dropout(rate=0.5, seed=117)(conv2_ly) dense_ly = keras.layers.Flatten()(dropout_ly) # Se añaden las capas densas y la salida de 62 clases: dense1_ly = keras.layers.Dense(units=100, activation="relu")(dense_ly) output_ly = keras.layers.Dense(units=47, activation="softmax")(dense1_ly) # Se crea el modelo y se devuelve: model = keras.Model(inputs=input_ly, outputs=[output_ly], name="TFM_EMNIST" ) return model ## MAIN: Entrenamiento del modelo desde 0 o continuación # Obtención del directorio de trabajo, el directorio del "dataset" y # el directorio de los "ckeckpoints": work_path = os.getcwd() dataset_path = os.path.join(work_path, "EMNIST_Dataset") ckp_path = os.path.join(work_path, "EMNIST_Model_Ckeckpoints") # Fijar el nombre del modelo a partir del cual se continua el entrenamiento y # el número de epocas de entreno: model_name = "TFM_EMNIST_model_ep67.keras" num_epoch = 100 # Se comprueba si se encuentra el modelo para continuar o si se parte # desde el inicio: print("Se busca el modelo \"{}\" para ser entrenado durante {} épocas".format( model_name, num_epoch)) if not os.path.exists(os.path.join(ckp_path, model_name)):
120 Capítulo B. Códigos para el Reconocimiento de Texto # Se crea el modelo y se añade el compilador: print("\t- No se han encontrado el modelo \"{}\", se genera el modelo de 0 pues.\n".format(model_name)) model_name = "TFM_EMNIST_model_ep0.keras" #Nuevo modelo desde 0 EMNIST_model = model_assembler() EMNIST_model.compile(optimizer=keras.optimizers.Adam(learning_rate=0.001), loss="categorical_crossentropy", metrics=["accuracy"]) else: # Se ha encontrado el modelo, se carga pues su último estado y se le # añade el optimizador personalizado: print("\t- Se carga el modelo \"{}\" y se sigue entrenando desde donde se dejo.\n".format(model_name)) custom_opt = tf.keras.optimizers.Adam(learning_rate=0.001) EMNIST_model = keras.models.load_model(os.path.join(ckp_path, model_name), custom_objects={"Custom>Adam":custom_opt}) # Se pregunta si hay GPU disponible para hacer el entreno (si no está, # no se hace nada): print("Se procede a entrenar el modelo, pero antes se debe determinar si esta operativa la GPU:") if tf.test.gpu_device_name() == "/device:GPU:0": # Con la GPU activa se cargan los "dataset" y se entrena: print("\t- Se dispone de GPU para entrenar el modelo \"{}\".".format( model_name)) with tf.device("/device:GPU:0"): # Se carga el "dataset" de entreno y se "splitea" para sacar el # de validación: print("\t- Se carga el \"dataset\" de entrenamiento y se genera el de validación:") x_train, y_train, _ = preporcess_data(os.path.join(dataset_path, " emnist-balanced-train.csv")) x_train, y_train, x_val, y_val = dataset_splitter((x_train, y_train), 0.2, 117) # Se crean las "Callback" que va a usar el modelo (se guarda el modelo # completo): print("\n\t- Cargado los datos, se cargan las \"Callbacks\" de entrenamiento.") checkpoint_callback = keras.callbacks.ModelCheckpoint(filepath=os.path. join(ckp_path, "CkeckPoints_epoch{epoch:03d}.keras"), save_weights_only=False, monitor="val_accuracy", mode="max", save_best_only=True) earlystopp_callback = keras.callbacks.EarlyStopping(monitor=" val_accuracy", patience=10) # Se hace el entrenamiento y se genera el "DataFrame" del historial # de entreno: print("\t- Se procede a entrenar el modelo \"{}\":".format(model_name)) training_hist = EMNIST_model.fit(x_train, y_train, batch_size=128, epochs=num_epoch, validation_data=(x_val, y_val), callbacks=[ checkpoint_callback, earlystopp_callback]) hist_df = pd.DataFrame.from_dict(training_hist.history, orient="index"). T
B.1 Construcción y entrenamiento de una red neuronal con el “dataset” EMNIST 121 # Con el entrenamiento completado, se determina hasta que epoca # se entreno y se guardan los pesos/modelo: print("\t- Entrenamiento completado, guardando modelo y datos.") final_model = "TFM_EMNIST_model_ep" + str(int((model_name.split("ep") [1]).split(".")[0]) + len(hist_df)) + ".keras" EMNIST_model.save(os.path.join(ckp_path, final_model), overwrite=True) EMNIST_model.save_weights(os.path.join(ckp_path, final_model.replace(" model", "weights").replace("keras", "h5")), overwrite=True) # Se guardan los datos del entrenamiento en un CSV: hist_df.to_csv(os.path.join(ckp_path, "Performance_Data.csv"), sep=chr (9), index=False, header=not os.path.exists(os.path.join(ckp_path, " Performance_Data.csv"))) # Se cargan los datos de "test" y hace una evaluación del modelo: print("\t- Se carga el \"dataset\" de \"test\" y se realiza la evaluaci ón:") x_test, y_test, _ = preporcess_data(os.path.join(dataset_path, "emnistbalanced-test.csv")) test_result = EMNIST_model.evaluate(x_test, y_test, batch_size=128) print("\t--> Resultado de la evaluación: \"Accuracy\" == {}% y \"Loss\" == {}".format(test_result[1]*100, test_result[0])) print("\nPorceso completado.") Código B.2 Representaión de las métricas de entrenamiento con EMNIST. ## LIBRERIAS: import os import numpy as np import pandas as pd import matplotlib.pyplot as plt from matplotlib.backends.backend_pdf import PdfPages ## FUNCIONES DE VISUALIZACIÓN: # Función Visualizado 1: Repersenta las métricas de entrenamiento def model_performance_explorer(csv_file, safe_flag): # Se cargan los datos del CSV: hist_data = pd.read_csv(csv_file, sep=chr(9)) # Se extraen los datos del historial de entrenamiento y se marcan # datos característicos: epochs = np.arange(1, hist_data.shape[0] + 1) train_loss = np.array(hist_data["loss"]) val_loss = np.array(hist_data["val_loss"]) train_acc = np.array(hist_data["accuracy"])*100 val_acc = np.array(hist_data["val_accuracy"])*100 print("Datos caracteristicos del entrenamiento:") print("\t- Entrenamiento: Se ha detectado un valor de \"Accuracy\" máximo de {}% en la época {}.".format(np.amax(train_acc), np.argmax(train_acc) +1)) print("\t- Entrenamiento: Se ha detectado un valor de \"Loss\" mínimo de {} en la época {}.".format(np.amin(train_loss), np.argmin(train_loss)+1))
128 Capítulo B. Códigos para el Reconocimiento de Texto axs[1].legend(loc="upper right", fontsize=12) # Se guarda la imagencomo PDF de alta resolución: if save_flag: pdf_pages = PdfPages("GRAPH_Chars74k_Metrics.pdf") #Creacción del PDF pdf_pages.savefig(fig, bbox_inches="tight", dpi=600) pdf_pages.close() #Se cierra el PDF return plt.gcf() ## MAIN: Visualización de las metricas del entrenamiento: # Obtención del directorio de trabajo y el de los datos de entrenamiento: work_path = os.getcwd() ckp_path = os.path.join(work_path, "Chars74k_Model_Ckeckpoints") # Se representa los datos de entrenamiento: perf_fig = model_performance_explorer(os.path.join(ckp_path, "Performance_Data. csv"), False)
Apéndice C Códigos para la Aplicación Final En este apéndice se facilitaran los códigos necesarios para llevar a cabo todo lo relacionado con el Capítulo 4, la Aplicación Final de este Proyecto. C.1 Funciones de uso general en la Aplicación Final En esta sección se mostrarán todas las funciones contenidas en el archivo de función Text_Detection_- and_Recognition_Functions.py . Se empieza por la función encargad de generar los modelos de red neuronal utilizado y añadirle los peso entrenados necesarios para que funcionen según se vio en el Proyecto. Esto se hace en el Código C.1, el cual admite poder decidir si se carga uno u otro clasificador (EMNIST o Chars74k), con sus respectivas clases. Código C.1 Generar los modelos de red neruonal y cargar los pesos entrenados. def models_assembler(models_dict): # Función Interna 1: Se encarga de generar el modelo de EASTlite def EASTlite(): # Cargar la capa de entrada junto con la de preprocesamiento de entrada: input_ly = keras.Input(shape=[None, None, 3], dtype=tf.float32, name=" Input_LY") input_ly = keras.applications.resnet.preprocess_input(input_ly) # Se carga el modelo base acomplado a la capa de entrada y se # inicializa el listado de capas: base_model = keras.applications.MobileNet(input_tensor=input_ly, include_top=False) base_model_layers = [] #Aqui se almacenarán sólo las capas a usar # Se recoren todas las capas del modelo base: for layer in base_model.layers: # Se permite a la capa ser entrenada y se alamcenan aquellas # capas que se van a usar: layer.trainable = True if layer.name in ["conv_pw_2_relu", "conv_pw_5_relu", " conv_pw_11_relu", "conv_pw_13_relu"]: base_model_layers.append(layer) # Se inicializa los flujos "outputs" de la EAST: 129
130 Capítulo C. Códigos para la Aplicación Final base_model_layers.reverse() #De abajo a arriba f = [layer.output for layer in base_model_layers] #Feature maps h = [None, None, None, None] #Merged feature map g = [None, None, None, None] #Merge base outputs_size = [None, 128, 64, 32] # Se recorre cada uno de los estados de ambos bloques del EAST: for idx in range(len(f)): # En el caso de 0, ambas salidas coincide (f1 = h1): if idx == 0: h[idx] = f[idx] # En caso contario "h" se define como Conv3x3(Conv1x1(...)): else: conv_1x1 = keras.layers.Conv2D(filters=outputs_size[idx], kernel_size=1, activation="relu", padding="same", kernel_regularizer=keras.regularizers.L2(0.001))(tf.concat([ g[idx - 1], f[idx]], axis=-1)) h[idx] = keras.layers.Conv2D(filters=outputs_size[idx], kernel_size=3, activation="relu", padding="same", kernel_regularizer=keras.regularizers.L2(0.001))(conv_1x1) # Definido la "merged feature map", se le acopla la "merge base": if idx <= 2: g[idx] = keras.layers.UpSampling2D(size=(2, 2), data_format=" channels_last", interpolation="bilinear")(h[idx]) else: g[idx] = keras.layers.Conv2D(filters=outputs_size[idx], kernel_size=3, activation="relu", padding="same", kernel_regularizer=keras.regularizers.L2(0.001))(h[idx]) # Completado el modelo, se define la capa de salida del mapa de # puntuación, geometrico y angulo: score_map = keras.layers.Conv2D(filters=1, kernel_size=1, activation=tf. nn.sigmoid, padding="same", kernel_regularizer=keras.regularizers.L2 (0.001))(g[3]) geo_map = (keras.layers.Conv2D(filters=4, kernel_size=1, activation=tf. nn.sigmoid, padding="same", kernel_regularizer=keras.regularizers.L2 (0.001))(g[3])*512) angle_map = ((keras.layers.Conv2D(filters=1, kernel_size=1, activation= tf.nn.sigmoid, padding="same", kernel_regularizer=keras.regularizers .L2(0.001))(g[3]) - 0.5)* np.pi/2) # Se juntan el mapa geometrico con el de ángulo y se prepara la # salida del modelo: complete_geo_map = tf.concat([geo_map, angle_map], axis=-1) output_ly = tf.concat([complete_geo_map, score_map], axis=-1) # Se crea el modelo y se devuelve: model = keras.Model(inputs=input_ly, outputs=[output_ly], name=" TFM_EAST") return model # Función Interna 2: Se encarga de generar el modelo de clasificación # con Chars74k def Chars74k_classification():
C.1 Funciones de uso general en la Aplicación Final 131 # Cargar la capa de entrada y las capas de aumento # de datos [1/8 -> pi/4] (se pone rotación algo menor): input_ly = keras.Input(shape=[64, 64, 1], dtype=tf.float32) aug_ly = keras.layers.RandomRotation(factor=(-1/10, 1/10), fill_mode=" nearest", interpolation="nearest", seed=177)(input_ly) aug_ly = keras.layers.RandomZoom(height_factor=(0, 0.2), width_factor= None, fill_mode="nearest", interpolation="nearest", seed=177)(aug_ly ) aug_ly = keras.layers.RandomTranslation(height_factor=(0, 0.2), width_factor=(0, 0.2), fill_mode="nearest", interpolation="nearest", seed=177)(aug_ly) # Se añade la primera capa de convolución: conv1_ly = keras.layers.Conv2D(filters=32, kernel_size=(3,3), activation="relu")(aug_ly) conv1_ly = keras.layers.MaxPooling2D(pool_size=(2, 2))(conv1_ly) # Se añade la segunda capa de convolución: conv2_ly = keras.layers.Conv2D(filters=64, kernel_size=(3,3), activation="relu")(conv1_ly) conv2_ly = keras.layers.MaxPooling2D(pool_size=(2, 2))(conv2_ly) # Se añade la tercera capa de convolución: conv3_ly = keras.layers.Conv2D(filters=128, kernel_size=(3,3), activation="relu")(conv2_ly) conv3_ly = keras.layers.MaxPooling2D(pool_size=(2, 2))(conv3_ly) # Se regulariza de convolucional a densas y se aplica el "dropout": dropout_ly = keras.layers.Dropout(rate=0.5, seed=117)(conv3_ly) dense_ly = keras.layers.Flatten()(dropout_ly) # Se añaden las capas densas y la salida de 62 clases: dense1_ly = keras.layers.Dense(units=150, activation="relu")(dense_ly) output_ly = keras.layers.Dense(units=62, activation="softmax")( dense1_ly) # Se crea el modelo y se devuelve: model = keras.Model(inputs=input_ly, outputs=[output_ly], name=" TFM_Charts74k") return model # Función Interna 3: Se encarga de generar el modelo de clasificación con EMNIST def EMNIST_classification(): # Cargar la capa de entrada y las capas de aumento de datos # [1/8 -> pi/4] (se pone rotación algo menor): input_ly = keras.Input(shape=[28, 28, 1], dtype=tf.float32) aug_ly = keras.layers.RandomRotation(factor=(-1/20, 1/20), fill_mode=" nearest", interpolation="nearest", seed=177)(input_ly) aug_ly = keras.layers.RandomZoom(height_factor=(0, 0.05), width_factor= None, fill_mode="nearest", interpolation="nearest", seed=177)(aug_ly ) aug_ly = keras.layers.RandomTranslation(height_factor=(0, 0.05), width_factor=(0, 0.05), fill_mode="nearest", interpolation="nearest" , seed=177)(aug_ly)
132 Capítulo C. Códigos para la Aplicación Final # Se añade la primera capa de convolución: conv1_ly = keras.layers.Conv2D(filters=32, kernel_size=(3,3), activation="relu")(aug_ly) conv1_ly = keras.layers.MaxPooling2D(pool_size=(2, 2))(conv1_ly) # Se añade la segunda capa de convolución: conv2_ly = keras.layers.Conv2D(filters=64, kernel_size=(3,3), activation="relu")(conv1_ly) conv2_ly = keras.layers.MaxPooling2D(pool_size=(2, 2))(conv2_ly) # Se regulariza de convolucional a densas y se aplica el "dropout": dropout_ly = keras.layers.Dropout(rate=0.5, seed=117)(conv2_ly) dense_ly = keras.layers.Flatten()(dropout_ly) # Se añaden las capas densas y la salida de 62 clases: dense1_ly = keras.layers.Dense(units=100, activation="relu")(dense_ly) output_ly = keras.layers.Dense(units=47, activation="softmax")( dense1_ly) # Se crea el modelo y se devuelve: model = keras.Model(inputs=input_ly, outputs=[output_ly], name=" TFM_EMNIST") return model # (INICIO DEL MAIN) Se incializa el diccionario que continene el # modelo e información extra: ready_models = {"TextDetection":None, "TextRecognition":{"model":None, " input_size":None, "class":None}} # Se va cargando el modelo EASTlite, añadiendo sus pesos, y se almacena: EASTlite_model = EASTlite() EASTlite_model.load_weights(models_dict["EASTlite"]) ready_models["TextDetection"] = EASTlite_model # Se cargan los modelos de clasifiación, se le añade sus pesos, y se almacena: if models_dict["Chars74k"] != None: Chars74k_model = Chars74k_classification() Chars74k_model.load_weights(models_dict["Chars74k"]) ready_models["TextRecognition"]["model"] = Chars74k_model ready_models["TextRecognition"]["input_size"] = (64, 64) #Ancho x Alto ready_models["TextRecognition"]["class"] = np.char.mod("%c", np. concatenate([np.arange(48, 57 + 1), np.arange(65, 90 + 1), np.arange (97, 122 + 1)])) #[0-9, A-Z, a-z] else: EMNIST_model = EMNIST_classification() EMNIST_model.load_weights(models_dict["EMNIST"]) ready_models["TextRecognition"]["model"] = EMNIST_model ready_models["TextRecognition"]["input_size"] = (28, 28) #Ancho x Alto ready_models["TextRecognition"]["class"] = np.array(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F", "G", "H ", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a", "b", "d", "e", "f", "g", "h", "n", "q", "r", "t "])
C.1 Funciones de uso general en la Aplicación Final 133 # Se devuelven los modelos listos para predecir: return ready_models La siguiente función aplica la detección de texto que se ha desgranado en la Sección 4.1. La función puede verse completa en el Código C.2 y depende de las funciones internas: restore_rectangle(...) (regeneración de las “bounding boxes” a parir de los mapas de salida de la red neuronal) y NMS(...) (posprocesado de EASTlite). La función, como ya se ha comentado, se encarga de recibir una imagen única previamente cargada en RGB, luego se escala a a unas dimensiones (múltiplos de 32) que permita su entrada en el modelo EASTlite. Posteriormente, se hace una predicción y se regeneran las “bounding boxes” a partir de los mapas predichos. Finalmente, estas se ordenan. Código C.2 Aplicación del algoritmo de EASTlite y ordenación de las “bounding boxes”. def Text_Detection(TD_model, img): # Función Interna 1: Obtiene el vector de vertices a través de los mapas # de puntuación y geométrico def restore_rectangle(orig, geomt): # Se extrae del mapa geometrico el "Top-Right-Bottom-Left" y "angle": TRDL = geomt[:, :4] angle = geomt[:, 4] # Se adapta para el caso de angulo mayor que 0 (filtrado): orig_0 = orig[angle >= 0] TRDL_0 = TRDL[angle >= 0] angle_0 = angle[angle >= 0] # El caso de que el angulo sea mayor que 0, se aplica: if orig_0.shape[0] > 0: # Se genera la matriz que condensa la estrucutra geometrica de la # caja: p = np.array([np.zeros(TRDL_0.shape[0]), -TRDL_0[:, 0] - TRDL_0[:, 2], TRDL_0[:, 1] + TRDL_0[:, 3], -TRDL_0[:, 0] - TRDL_0[:, 2], TRDL_0[:, 1] + TRDL_0[:, 3], np.zeros(TRDL_0.shape[0]), np.zeros (TRDL_0.shape[0]), np.zeros(TRDL_0.shape[0]), TRDL_0[:, 3], - TRDL_0[:, 2]]) p = p.transpose((1, 0)).reshape((-1, 5, 2)) #-> N*5*2 # Se determina las matrices de rotación que hay que aplicar a la # caja en ejes x e y: rotate_matrix_x = np.array([np.cos(angle_0), np.sin(angle_0)]). transpose((1, 0)) rotate_matrix_x = np.repeat(rotate_matrix_x, 5, axis=1).reshape(-1, 2, 5).transpose((0, 2, 1)) #-> N*5*2 rotate_matrix_y = np.array([-np.sin(angle_0), np.cos(angle_0)]). transpose((1, 0)) rotate_matrix_y = np.repeat(rotate_matrix_y, 5, axis=1).reshape(-1, 2, 5).transpose((0, 2, 1)) # Se rota la matriz geometrica generada: p_rotate_x = np.sum(rotate_matrix_x*p, axis=2)[:, :, np.newaxis] p_rotate_y = np.sum(rotate_matrix_y*p, axis=2)[:, :, np.newaxis] p_rotate = np.concatenate([p_rotate_x, p_rotate_y], axis=2)
134 Capítulo C. Códigos para la Aplicación Final # Se determina los 4 vertices del poligono: p3_in_origin = orig_0 - p_rotate[:, 4, :] new_p0 = p_rotate[:, 0, :] + p3_in_origin #-> N*2 new_p1 = p_rotate[:, 1, :] + p3_in_origin new_p2 = p_rotate[:, 2, :] + p3_in_origin new_p3 = p_rotate[:, 3, :] + p3_in_origin new_p_0 = np.concatenate([new_p0[:, np.newaxis, :], new_p1[:, np. newaxis, :], new_p2[:, np.newaxis, :], new_p3[:, np.newaxis, :]], axis=1) #-> N*4*2 else: new_p_0 = np.zeros((0, 4, 2)) #Caso de que el angulo sea negativo # En el caso de angulo negativo, se inicializa el otro punto # del poligono: orig_1 = orig[angle < 0] TRDL_1 = TRDL[angle < 0] angle_1 = angle[angle < 0] # El caso de que el angulo sea menor que 0, se aplica: if orig_1.shape[0] > 0: # Se genera la matriz que condensa la estrucutra geometrica de # la caja (ahora angulo negativo): p = np.array([-TRDL_1[:, 1] - TRDL_1[:, 3], -TRDL_1[:, 0] - TRDL_1 [:, 2], np.zeros(TRDL_1.shape[0]), -TRDL_1[:, 0] - TRDL_1[:, 2], np.zeros(TRDL_1.shape[0]), np.zeros(TRDL_1.shape[0]), -TRDL_1 [:, 1] - TRDL_1[:, 3], np.zeros(TRDL_1.shape[0]), -TRDL_1[:, 1], -TRDL_1[:, 2]]) p = p.transpose((1, 0)).reshape((-1, 5, 2)) #-> N*5*2 # Se determina las matrices de rotación que hay que aplicar a la # caja en ejes x e y (ahora angulo negativo): rotate_matrix_x = np.array([np.cos(-angle_1), -np.sin(-angle_1)]). transpose((1, 0)) rotate_matrix_x = np.repeat(rotate_matrix_x, 5, axis=1).reshape(-1, 2, 5).transpose((0, 2, 1)) #-> N*5*2 rotate_matrix_y = np.array([np.sin(-angle_1), np.cos(-angle_1)]). transpose((1, 0)) rotate_matrix_y = np.repeat(rotate_matrix_y, 5, axis=1).reshape(-1, 2, 5).transpose((0, 2, 1)) # Se rota la matriz geometrica generada (ahora angulo negativo): p_rotate_x = np.sum(rotate_matrix_x*p, axis=2)[:, :, np.newaxis] p_rotate_y = np.sum(rotate_matrix_y*p, axis=2)[:, :, np.newaxis] p_rotate = np.concatenate([p_rotate_x, p_rotate_y], axis=2) # Se determina los 4 vertices del poligono (ahora angulo negativo): p3_in_origin = orig_1 - p_rotate[:, 4, :] new_p0 = p_rotate[:, 0, :] + p3_in_origin #-> N*2 new_p1 = p_rotate[:, 1, :] + p3_in_origin new_p2 = p_rotate[:, 2, :] + p3_in_origin new_p3 = p_rotate[:, 3, :] + p3_in_origin new_p_1 = np.concatenate([new_p0[:, np.newaxis, :], new_p1[:, np. newaxis, :], new_p2[:, np.newaxis, :], new_p3[:, np.newaxis, :]], axis=1) #-> N*4*2 else: new_p_1 = np.zeros((0, 4, 2)) #Caso de que el angulo no sea negativo
C.1 Funciones de uso general en la Aplicación Final 135 # Se devuelve la resturación de la caja de texto: return np.concatenate([new_p_0, new_p_1]) # Función Interna 2: Aplica el algoritmo de "Non-Maximum Suppression" # a las cajas resultantes def NMS(raw_boxes, iou_threshold): # Ordenas los indices de las cajas en función de la puntuación y # se inicializa una lista de indices seleccionados: sorted_idx = np.argsort(raw_boxes[:, 8])[::-1] #Mayor a menor "score" selected_idx = [] #Indices que no serán eliminados # Se recorre los índices ordenados hasta que no queden más índices: while len(sorted_idx) > 0: # Se selecciona/añade el índice de mayor puntuación (el primero): actual_idx = sorted_idx[0] selected_idx.append(actual_idx) # Se inicializa los vectores que almacenará el IoU y se genera # el polinomio de la caja seleccionada: IoU_array = np.zeros(len(raw_boxes)) selected_poly = shap.geometry.Polygon(raw_boxes[actual_idx, :8]. reshape((4, 2))) #Descartando la puntuación # Se calcula las áreas de poligono/interesección entre el # seleccionado y el resto: for idx in sorted_idx[1:]: # Se genera el poligono de comparación: other_poly = shap.geometry.Polygon(raw_boxes[idx, :8].reshape((4, 2))) #Descartando la puntuación # Se determina si hay intersección y si es así se calcula el IuO : if selected_poly.intersects(other_poly): inter_area = selected_poly.intersection(other_poly).area IoU_array[idx] = inter_area/(selected_poly.area + other_poly. area - inter_area) else: IoU_array[idx] = 0.0 # Determinar que cajas no seleccionadas cumplen con el # "threshold" y se puede eliminar: overlap_idx = np.where(IoU_array >= iou_threshold)[0] sorted_idx = np.delete(sorted_idx, np.where(np.in1d(sorted_idx, overlap_idx))[0]) # Para continuar iterando se elimina el índice actual ya alamcenado: sorted_idx = np.delete(sorted_idx, 0) # Al finalizar se tendrá la lista de índices seleccionados y se # devuelve las boxes independientes: return raw_boxes[selected_idx, :] # (INICIO DEL MAIN) Se carga dimensiones de la imagen entrante:
136 Capítulo C. Códigos para la Aplicación Final height, width, _ = img.shape # Se calcula el ratio de limitación del lateral según el máximo admisible: if max(height, width) > 2400: ratio = (2400/height if height > width else 2400/width) else: ratio = 1.0 # Se corrige los laterales para que no superen el ratio de limitación: height_rs = int(height*ratio) width_rs = int(width*ratio) # Se limita dichas dimensiones a multiplos de 32 y generandose las #nuevas dimensiones: height_rs = height_rs if height_rs % 32 == 0 else (height_rs//32-1)*32 width_rs = width_rs if width_rs % 32 == 0 else (width_rs//32-1)*32 height_rs = max(32, height_rs) width_rs = max(32, width_rs) # Se rescala la imagen a las nuevas dimensiones, se hace la predicción # y se segmenta los mapas predecidos: img_rs = cv2.resize(img.copy(), (int(width_rs), int(height_rs))) pred_maps = TD_model.predict(img_rs[np.newaxis, :, :, :], batch_size=1) geo_maps = np.array(pred_maps[0, :, :, :5]) #"Top-Right-Bottom-Left-Angle" score_map = np.array(pred_maps[0, :, :, 5]) # Mapa de puntuación # Filtrado del mapa de puntuación por "threshold" y se ordena los # recuador por eje y: xy_text = np.argwhere(score_map > 0.8) xy_text = xy_text[np.argsort(xy_text[:, 0])] # Se procede a restaurar los recuadros a partir de los mapas y se # ajustan al formato adecuado: box_restored = restore_rectangle(xy_text[:, ::-1]*4, geo_maps[xy_text[:, 0], xy_text[:, 1], :]) #-> N*4*2 boxes = np.zeros((box_restored.shape[0], 9), dtype=np.float32) boxes[:, :8] = box_restored.reshape((-1, 8)) boxes[:, 8] = score_map[xy_text[:, 0], xy_text[:, 1]] # NOTA : La función interna "restore_rectangle" logra generar las # "bounding boxes" a partir de los mapas geometricos y de puntuación # a un formato (X,4,2), es decir: X cajas, 4 vertices de 2 coordenadas # (x,y). Posteriormete, se debe cambiar el formato a (X, 9), es decir, # X cajas y 8 componentes (4 vertices x 2 -> x1,y1,x2,y2,x3,y3,x4,y4) # más una componete más para el valor de putuación que tiene. # Se aplica el algoritmo de NMS para agrupar las cajas detectadas: boxes = NMS(boxes.astype("float32"), 0.2) # NOTA : Como la el paquete "lanms" da problemas, se va a crear # una función interna conocida como "NMS" que lo sutituya, # aunque no sea el mismo algoritmo. Su misión será reducir el # número de cajas recurrentes y devolver las cajas restantes en # formato (X’, 9). Posteriormente, se le aplicará otro filtrado # y se volverá finalmente al formato (X’,4,2). Siendo X’>X. # Se intenta realizar el proceso de filrtado y ordenación a las
C.1 Funciones de uso general en la Aplicación Final 137 # "bounding boxes" que hayan sobrevivido a MNS: try: # Aquí se filtra algunos cuadros de puntaje bajo por el mapa de # puntaje promedio (diferente del EAST original): for ii, box in enumerate(boxes): mask = np.zeros_like(score_map, dtype=np.uint8) cv2.fillPoly(mask, box[:8].reshape((-1, 4, 2)).astype(np.int32)//4, 1) boxes[ii, 8] = cv2.mean(score_map, mask)[0] # El filtrado final se hace por "threshold" de detección de caja: boxes = boxes[boxes[:, 8] > 0.1] # Se ajusta sus dimensiones finales a un vector [x1,y1],...,[x4,y4] # y al ratio de redimensión: boxes = boxes[:, :8].reshape(-1, 4, 2) boxes[:, :, 0] /= width_rs/float(width) #Ratio escaldo "width" boxes[:, :, 1] /= height_rs/float(height) #Ratio escalado "height" boxes = boxes.astype(np.int32) #-> A partir de aqui se hace la ordenación de las cajas !!! # Se calcula los CDG/centroides de cada una de las "bouniding boxes" # y se reordenan los índices según Y: cdgs = np.column_stack((np.sum(boxes[:, :, 0], axis=1)/boxes[:, :, 0]. shape[1], np.sum(boxes[:, :, 1], axis=1)/boxes[:, :, 1].shape[1])) idx_cdgs = np.argsort(cdgs[:, 1]) # Se inicaliza la lista que almacenará cada una de las filas detectadas # y el vector de filas actual: box_rows = [] actual_row = np.array([idx_cdgs[0]]) # Se recorre cada uno de los índices para agrupar las "bounding boxes" # por filas: for idx in idx_cdgs[1:]: # Se calcula la diferencia de CDG en Y de la "boundign box" aspirante # y el de la fila actual: dif_y = np.abs(cdgs[idx, 1] - cdgs[actual_row[-1], 1]) # Se comprueba si por la diferencia en Y es una nueva línea # o la misma: if dif_y <= 0.05*height: actual_row = np.append(actual_row, [idx]) #Misma línea else: box_rows.append(actual_row) actual_row = np.array([idx]) # NOTA : Si la diferencia de Y de los CDG es mayor que el 5% # de la altura, entonces es otra línea. # Al finalizar el bucle se añade la última fila a la lista de filas: box_rows.append(actual_row)