Cómo diseñar un SDK de visión por computadora multiplataforma agnóstico del lenguaje: un tutorial práctico

(Cyrus Behroozi) (21 de octubre de 2020)

Recientemente tuve la oportunidad de presentarme en la reunión Venice Computer Vision . Si no está familiarizado, es un evento patrocinado por Trueface en el que tanto los desarrolladores como los entusiastas de la visión por computadora pueden exhibir investigaciones, aplicaciones y prácticas de la visión por computadora de vanguardia. tutoriales.

En este artículo, repasaré mi presentación tutorial sobre cómo diseñar un kit de desarrollo de software de visión por computadora (SDK) independiente del lenguaje para una implementación multiplataforma y máxima extensibilidad. Si desea ver la grabación en vivo de la presentación, puede hacerlo aquí . También he creado código abierto del proyecto completo , así que siéntase libre de usarlo como plantilla para su próximo proyecto de visión por computadora.

cyrusbehr / sdk_design

Cómo diseñar un SDK independiente del lenguaje para implementación multiplataforma y máxima extensibilidad. A Venice Computer Vision…

github.com

Por qué es importante este tutorial

En mi experiencia, nunca encontré una guía completa que resume todos los pasos pertinentes necesarios para crear un SDK multiplataforma independiente del lenguaje. Tuve que revisar documentación dispar para encontrar los bits correctos de información, aprender cada componente por separado y luego hacerlo todo junto yo mismo. Fue frustrante. Tomó mucho tiempo. Y ahora usted, querido lector, puede beneficiarse de todo mi trabajo. Más adelante, aprenderá a crear un SDK multiplataforma independiente del idioma. Todos los elementos esenciales están ahí. Nada de pelusa, salvo algunos memes. Disfrute.

En este tutorial, puede esperar aprender a:

  • Construir una biblioteca básica de visión por computadora en C ++
  • Compilar y cruzar compile la biblioteca para AMD64, ARM64 y ARM32
  • Empaquete la biblioteca y todas las dependencias como una sola biblioteca estática
  • Automatice las pruebas unitarias
  • Configure una canalización de integración (CI)
  • Escribir enlaces de Python para nuestra biblioteca
  • Generar documentación directamente desde nuestra API

Por el bien de esta demostración, construirá un SDK de detección de rostros y puntos de referencia utilizando un detector de rostros de código abierto llamado MTCNN.

Ejemplo de cuadros delimitadores de caras y puntos de referencia faciales

Nuestra función API tomará una ruta de imagen y luego devolverá las coordenadas del cuadro delimitador facial y los puntos de referencia faciales. La capacidad de detectar rostros es muy útil en la visión por computadora, ya que es el primer paso en muchos procesos, incluido el reconocimiento facial, la predicción de la edad y el desenfoque automático del rostro.

Nota: Para esto tutorial, estaré trabajando en Ubuntu 18.04.

¿Por qué usar C ++ para nuestra biblioteca?

Ejecutar código C ++ eficiente puede sentirse así

La mayor parte de nuestra biblioteca estará escrita en C ++, un lenguaje compilado y tipado estáticamente. No es ningún secreto que C ++ es un lenguaje de programación muy rápido; es lo suficientemente bajo para darnos la velocidad que deseamos y tiene una sobrecarga de tiempo de ejecución mínima adicional.

En aplicaciones de visión por computadora, generalmente manipulamos muchas imágenes, realizamos operaciones matriciales, ejecutamos inferencias de aprendizaje automático, todo una enorme cantidad de informática. Por tanto, la velocidad de ejecución es fundamental. Esto es especialmente importante en aplicaciones en tiempo real donde es necesario reducir la latencia para lograr la velocidad de fotogramas deseada; a menudo solo tenemos milisegundos para ejecutar todo nuestro código.

Otra ventaja de C ++ es que si compilar para una determinada arquitectura y vincular todas las dependencias estáticamente, luego podemos ejecutarlo en ese hardware sin requerir intérpretes o bibliotecas adicionales. Lo crea o no, ¡incluso podemos ejecutarlo en un dispositivo integrado sin sistema operativo!

Estructura de directorio

Usaremos la siguiente estructura de directorio para nuestro proyecto.

3rdparty contendrá las bibliotecas de dependencia de terceros requeridas por nuestro proyecto.

dist contendrá los archivos que se distribuyen a los usuarios finales del SDK. En nuestro caso, esa será la biblioteca en sí y el archivo de encabezado asociado.

docker contendrá el archivo de la ventana acoplable que se usará para generar una imagen de la ventana acoplable para las compilaciones de CI.

docs contendrá los scripts de compilación necesarios para generar documentación directamente desde nuestro archivo de encabezado.

include contendrá los archivos de inclusión para la API pública.

models contendrá los archivos del modelo de aprendizaje profundo de detección de rostros.

python contendrá el código necesario para generar enlaces de Python.

src contendrá todos los archivos cpp que se compilarán, y también cualquier archivo de encabezado que no se distribuya con el SDK (archivos de encabezado interno).

test contendrá nuestras pruebas unitarias.

tools contendrá nuestros archivos de la cadena de herramientas CMake necesarios para la compilación cruzada.

Instalación de las bibliotecas de dependencia

Para este proyecto, el tercero las bibliotecas de dependencia que son necesarias son ncnn , una biblioteca de inferencia de aprendizaje automático ligera, OpenCV , una biblioteca de aumento de imágenes, Catch2 , una biblioteca de pruebas unitarias y, finalmente, pybind11 , una biblioteca utilizado para generar enlaces de Python. Las dos primeras bibliotecas deberán compilarse como bibliotecas independientes, mientras que las dos últimas son solo de encabezado y, por lo tanto, solo requerimos la fuente.

Una forma de agregar estas bibliotecas a nuestros proyectos es a través de submódulos git . Aunque este enfoque funciona, personalmente soy un fanático del uso de scripts de shell que extraen el código fuente y luego compilan para las plataformas deseadas: en nuestro caso AMD64, ARM32 y ARM64.

Aquí hay un ejemplo de lo que uno de estos scripts de compilación se ve así:

El script es bastante sencillo. Comienza extrayendo el código fuente de lanzamiento deseado del repositorio de git. A continuación, se utiliza CMake para preparar la compilación, luego se invoca make para impulsar el compilador a compilar el código fuente.

Lo que notará es que la principal diferencia entre la compilación AMD64 y las compilaciones ARM es que las compilaciones ARM pasan un parámetro CMake adicional llamado CMAKE_TOOLCHAIN_FILE. Este argumento se utiliza para especificar a CMake que la arquitectura de destino de la compilación (ARM32 o ARM64) es diferente de la arquitectura del host (AMD64 / x86_64). Por lo tanto, se indica a CMake que use el compilador cruzado especificado dentro del archivo de cadena de herramientas seleccionado para construir la biblioteca (más sobre archivos de cadena de herramientas más adelante en este tutorial). Para que este script de shell funcione, deberá tener los compiladores cruzados apropiados instalados en su máquina Ubuntu. Estos se pueden instalar fácilmente usando apt-get y las instrucciones sobre cómo hacerlo se muestran aquí .

API de nuestra biblioteca

La API de nuestra biblioteca se ve así:

Como soy muy creativo, decidí nombrar mi SDK MySDK. En nuestra API, tenemos una enumeración llamada ErrorCode, tenemos una estructura llamada Point y, finalmente, tenemos una función miembro pública llamada getFaceBoxAndLandmarks. Para el alcance de este tutorial, no entraré en detalles de la implementación del SDK. La esencia es que leemos la imagen en la memoria usando OpenCV y luego realizamos inferencias de aprendizaje automático usando ncnn con modelos de código abierto para detectar el cuadro delimitador facial y los puntos de referencia. Si desea profundizar en la implementación, puede hacerlo aquí .

Sin embargo, lo que quiero que preste atención es patrón de diseño que estamos utilizando. Estamos usando una técnica llamada Puntero a la implementación o pImpl para abreviar, que básicamente elimina los detalles de implementación de una clase colocándolos en una clase separada. En el código anterior, esto se logra declarando hacia adelante la clase Impl, luego teniendo una unique_ptr a esta clase como una variable miembro privada. Al hacerlo, no solo ocultamos la implementación de las miradas indiscretas del usuario final (lo que puede ser bastante importante en un SDK comercial), sino que también reducimos la cantidad de encabezados de los que depende nuestro encabezado API (y por lo tanto evitamos nuestra Encabezado de API de #include ing encabezados de biblioteca de dependencias).

Una nota sobre archivos de modelo

Dije que no íbamos a repasar los detalles de la implementación, pero hay algo que creo que vale la pena mencionar. De forma predeterminada, el detector facial de código abierto que estamos usando, llamado MTCNN, carga los archivos del modelo de aprendizaje automático en tiempo de ejecución. Esto no es ideal porque significa que tendremos que distribuir los modelos al usuario final. Este problema es aún más importante con los modelos comerciales en los que no desea que los usuarios tengan acceso gratuito a estos archivos de modelos (piense en las innumerables horas que se dedicaron a entrenar estos modelos). Una solución es cifrar los archivos de estos modelos, lo que recomiendo absolutamente hacer.Sin embargo, esto todavía significa que debemos enviar los archivos del modelo junto con el SDK. En última instancia, queremos reducir la cantidad de archivos que enviamos a un usuario para facilitarle el uso de nuestro software (menos archivos equivalen a menos lugares en los que puede fallar). Por lo tanto, podemos usar el método que se muestra a continuación para convertir los archivos de modelo en archivos de encabezado e incrustarlos en el SDK.

El comando xdd bash se usa para generar volcados hexadecimales y se puede usar para generar un archivo de encabezado a partir de un archivo binario. Por lo tanto, podemos incluir los archivos de modelo en nuestro código como archivos de encabezado normales y cargarlos directamente desde la memoria. Una limitación de este enfoque es que no es práctico con archivos de modelo muy grandes, ya que consume demasiada memoria en tiempo de compilación. En su lugar, puede utilizar una herramienta como ld para convertir estos archivos de modelos grandes directamente en archivos de objetos.

CMake y compilación de nuestra biblioteca

Ahora podemos usar CMake para generar los archivos de compilación para nuestro proyecto. En caso de que no esté familiarizado, CMake es un generador de sistema de compilación que se utiliza para administrar el proceso de compilación. A continuación, verá qué parte de la raíz CMakeLists.txt (archivo CMake) se ve.

Básicamente, creamos una biblioteca estática llamada my_sdk_static con los dos archivos fuente que contienen nuestra implementación, my_sdk.cpp y mtcnn.cpp. La razón por la que estamos creando una biblioteca estática es que, en mi experiencia, es más fácil distribuir una biblioteca estática a los usuarios y es más amigable con los dispositivos integrados. Como mencioné anteriormente, si un ejecutable está vinculado a una biblioteca estática, se puede ejecutar en un dispositivo integrado que ni siquiera tiene un sistema operativo. Esto simplemente no es posible con una biblioteca dinámica. Además, con las bibliotecas dinámicas, tenemos que preocuparnos por las versiones de dependencia. Incluso podríamos necesitar un archivo de manifiesto asociado con nuestra biblioteca. Las bibliotecas vinculadas estáticamente también tienen un perfil de rendimiento ligeramente mejor que sus contrapartes dinámicas.

Lo siguiente que hacemos en nuestro script de CMake es decirle a CMake dónde encontrar los archivos de encabezado de inclusión necesarios que requieren nuestros archivos de origen. Algo a tener en cuenta: aunque nuestra biblioteca se compilará en este punto, cuando intentemos vincular nuestra biblioteca (con un ejecutable, por ejemplo), obtendremos una tonelada absoluta de referencias indefinidas a errores de símbolos. Esto se debe a que no hemos vinculado ninguna de nuestras bibliotecas de dependencia. Entonces, si quisiéramos vincular exitosamente un ejecutable con libmy_sdk_static.a, entonces tendríamos que rastrear y vincular todas las bibliotecas de dependencia también (módulos OpenCV, ncnn, etc.). A diferencia de las bibliotecas dinámicas, las bibliotecas estáticas no pueden resolver sus propias dependencias. Básicamente son solo una colección de archivos de objetos empaquetados en un archivo.

Más adelante en este tutorial, demostraré cómo podemos empaquetar todas las bibliotecas de dependencias en nuestra biblioteca estática para que el usuario no necesite preocuparse por la vinculación con cualquiera de las bibliotecas de dependencia.

Compilación cruzada de nuestra biblioteca y archivos de cadena de herramientas

La informática de borde es tan… vanguardista

Muchas aplicaciones de visión por computadora se implementan en el borde. Esto generalmente implica ejecutar el código en dispositivos integrados de bajo consumo que normalmente tienen CPU ARM. Dado que C ++ es un lenguaje compilado, debemos compilar nuestro código para la arquitectura de la CPU en la que se ejecutará la aplicación (cada arquitectura usa diferentes instrucciones de ensamblaje).

Antes de sumergirnos en él, también toquemos el diferencia entre ARM32 y ARM64, también llamado AArch32 y AArch64. AArch64 se refiere a la extensión de 64 bits de la arquitectura ARM y depende tanto de la CPU como del sistema operativo. Entonces, por ejemplo, aunque la Raspberry Pi 4 tiene una CPU ARM de 64 bits, el sistema operativo predeterminado Raspbian es de 32 bits. Por lo tanto, un dispositivo de este tipo requiere un binario compilado AArch32. Si tuviéramos que ejecutar un sistema operativo de 64 bits como Gentoo en este dispositivo Pi, entonces necesitaríamos un binario compilado AArch64. Otro ejemplo de un dispositivo integrado popular es NVIDIA Jetson, que tiene una GPU integrada y ejecuta AArch64.

Para realizar una compilación cruzada, debemos especificar a CMake que no estamos compilando para la arquitectura de la máquina en la que estamos construyendo actualmente. Por lo tanto, necesitamos especificar el compilador cruzado que debe usar CMake. Para AArch64, usamos el compilador aarch64-linux-gnu-g++, y para AArch32 usamos el compilador arm-linux-gnuebhif-g++ (hf significa hard float ).

El siguiente es un ejemplo de un archivo de cadena de herramientas. Como puede ver, estamos especificando el uso del compilador cruzado AArch64.

De vuelta en nuestra raíz CMakeLists.txt, podemos agregue el siguiente código al comienzo del archivo.

Básicamente, estamos agregando opciones de CMake que se puede habilitar desde la línea de comandos para realizar una compilación cruzada. Habilitar las opciones BUILD_ARM32 o BUILD_ARM64 seleccionará el archivo de cadena de herramientas apropiado y configurará la compilación para una compilación cruzada.

Empaquetar nuestro SDK con bibliotecas de dependencias

Como se mencionó anteriormente, si un desarrollador desea establecer un vínculo con nuestra biblioteca en este momento, también deberá vincularlo con todas las bibliotecas de dependencia para resolver todos los símbolos de bibliotecas de dependencia. Aunque nuestra aplicación es bastante simple, ¡ya tenemos ocho bibliotecas de dependencia! La primera es ncnn, luego tenemos tres bibliotecas de módulos OpenCV, luego tenemos cuatro bibliotecas de utilidades que fueron creadas con OpenCV (libjpeg, libpng, zlib, libtiff). Podríamos requerir que el usuario construya las bibliotecas de dependencia por sí mismo o incluso las envíe junto con nuestra biblioteca, pero en última instancia, eso requiere más trabajo para el usuario y todo se trata de reducir la barrera para el uso. La situación ideal es si podemos enviar al usuario una sola biblioteca que contenga nuestra biblioteca junto con todas las bibliotecas de dependencia de terceros que no sean las bibliotecas estándar del sistema. Resulta que podemos lograr esto usando algo de magia de CMake.

Primero agregamos un objetivo personalizado a nuestro CMakeLists.txt, luego ejecute lo que se llama un script de MRI. Este script MRI se pasa al comando bash ar -M, que básicamente combina todas las bibliotecas estáticas en un solo archivo. Lo bueno de este método es que manejará con elegancia los nombres de miembros superpuestos de los archivos originales, por lo que no tenemos que preocuparnos por los conflictos allí. La creación de este destino personalizado producirá libmy_sdk.a que contendrá nuestro SDK junto con todos los archivos de dependencia.

Espere un segundo: hagamos un balance de lo que estamos lo he hecho hasta ahora.

Respire. Toma un bocadillo. Llama a tu mamá.

En este punto, tenemos una biblioteca estática llamada libmy_sdk.a que contiene nuestro SDK y todas las bibliotecas de dependencia, que hemos empaquetado en un solo archivo. También tenemos la capacidad de compilar y compilar (usando argumentos de línea de comandos) para todas nuestras plataformas de destino.

Pruebas unitarias

Cuando ejecuta sus pruebas unitarias por primera vez

Probablemente no necesite Explique por qué las pruebas unitarias son importantes, pero básicamente, son una parte crucial del diseño del SDK que permite al desarrollador asegurarse de que el SDK funciona con sangría. Además, si se realizan cambios importantes en el futuro, es útil rastrearlos y eliminar las correcciones más rápido.

En este caso específico, la creación de un ejecutable de prueba unitaria también nos brinda la oportunidad de vincularnos con el biblioteca combinada que acabamos de crear para asegurarnos de que podemos vincular correctamente según lo previsto (y no obtenemos ninguno de esos desagradables errores indefinidos de referencia a símbolo).

Estamos usando Catch2 como nuestro marco de pruebas unitarias . La sintaxis se describe a continuación:

El funcionamiento de Catch2 es que tenemos esta macro llamada TEST_CASE y otra macro llamada SECTION. Para cada SECTION, el TEST_CASE se ejecuta desde el principio. Entonces, en nuestro ejemplo, mySdk se inicializará primero, luego se ejecutará la primera sección denominada «Imagen sin rostro». A continuación, mySdk se deconstruirá antes de ser reconstruido, luego se ejecutará la segunda sección llamada “Caras en imagen”. Esto es genial porque asegura que tenemos un nuevo objeto MySDK para operar en cada sección. Luego podemos usar macros como REQUIRE para hacer nuestras afirmaciones.

Podemos usar CMake para construir un ejecutable de pruebas unitarias llamado run_tests. Como podemos ver en la llamada a target_link_libraries en la línea 3 a continuación, la única biblioteca con la que debemos vincularnos es nuestra libmy_sdk.a y ninguna otra. bibliotecas de dependencia.

Documentación

Si tan solo los usuarios leyeran la maldita documentación.

Usaremos doxygen para generar documentación directamente desde nuestro archivo de encabezado. Podemos seguir adelante y documentar todos nuestros métodos y tipos de datos en nuestro encabezado público utilizando la sintaxis que se muestra en el fragmento de código a continuación.Asegúrese de especificar todos los parámetros de entrada y salida para cualquier función.

Para generar documentación , necesitamos algo llamado doxyfile que es básicamente un plano para instruir a doxygen sobre cómo generar la documentación. Podemos generar un doxyfile genérico ejecutando doxygen -g en nuestra terminal, asumiendo que tienes doxygen instalado en tu sistema. A continuación, podemos editar el archivo doxy. Como mínimo, necesitamos especificar el directorio de salida y también los archivos de entrada.

En nuestro En este caso, solo queremos generar documentación a partir de nuestro archivo de encabezado de API, por lo que hemos especificado el directorio de inclusión. Por último, usa CMake para crear la documentación, lo que se puede hacer así .

Python Bindings

¿Estás cansado de ver gifs semi-relevantes todavía? Sí, yo tampoco.

Seamos honestos. C ++ no es el lenguaje más fácil o amigable para desarrollar. Por lo tanto, queremos ampliar nuestra biblioteca para admitir enlaces de idiomas para que sea más fácil de usar para los desarrolladores. Estaré demostrando esto usando Python, ya que es un lenguaje popular de creación de prototipos de visión por computadora, pero otros enlaces de lenguaje son igualmente fáciles de escribir. Estamos usando pybind11 para lograr esto:

Comenzamos usando PYBIND11_MODULE macro que crea una función que se llamará cuando se emita una declaración de importación desde python. Entonces, en el ejemplo anterior, el nombre del módulo de Python es mysdk. A continuación, podemos definir nuestras clases y sus miembros usando la sintaxis pybind11.

Aquí hay algo a tener en cuenta: en C ++, es bastante común pasar variables usando una referencia mutable que permite acceso de lectura y escritura. Esto es exactamente lo que hemos hecho con nuestra función de miembro de API con los parámetros faceDetected y fbAndLandmarks. En Python, todos los argumentos se pasan por referencia. Sin embargo, ciertos tipos básicos de Python son inmutables, incluido bool. Casualmente, nuestro parámetro faceDetected es un bool que se pasa por referencia mutable. Por lo tanto, debemos usar la solución que se muestra en el código anterior en las líneas 31 a 34 donde definimos el bool dentro de nuestra función de envoltura de Python, luego lo pasamos a nuestra función de C ++ antes de devolver la variable como parte de una tupla.

Una vez que hayamos construido la biblioteca de enlaces de Python, podemos utilizarla fácilmente usando el siguiente código:

Integración continua

Para nuestra canalización de integración continua, usaremos una herramienta llamada CircleCI que realmente me gusta porque se integra directamente con Github. Se activará automáticamente una nueva compilación cada vez que presione una confirmación. Para comenzar, vaya al sitio web de CircleCI y conéctelo a su cuenta de Github, luego seleccione el proyecto que desea agregar. Una vez agregado, deberá crear un directorio .circleci en la raíz de su proyecto y crear un archivo llamado config.yml dentro de ese directorio.

Para cualquiera que no esté familiarizado, YAML es un lenguaje de serialización comúnmente utilizado para archivos de configuración. Podemos usarlo para indicar qué operaciones queremos que realice CircleCI. En el fragmento de YAML a continuación, puede ver cómo primero compilamos una de las bibliotecas de dependencias, luego compilamos el SDK y finalmente compilamos y ejecutamos las pruebas unitarias.

Si somos inteligentes (y supongo que lo eres si has llegado hasta aquí), podemos usar el almacenamiento en caché para reducir significativamente los tiempos de compilación. Por ejemplo, en el YAML anterior, almacenamos en caché la compilación de OpenCV utilizando el hash del script de compilación como clave de caché. De esta manera, la biblioteca OpenCV solo se reconstruirá si se ha modificado el script de compilación; de lo contrario, se utilizará la compilación en caché. Otra cosa a tener en cuenta es que estamos ejecutando la compilación dentro de una imagen acoplable de nuestra elección. Seleccioné una imagen de Docker personalizada ( aquí es el Dockerfile) en la que instalé todas las dependencias del sistema.

Fin.

Y ahí lo tienes. Como cualquier producto bien diseñado, queremos admitir las plataformas más demandadas y facilitar su uso para la mayor cantidad de desarrolladores. Utilizando el tutorial anterior, hemos creado un SDK al que se puede acceder en varios idiomas y se puede implementar en varias plataformas. Y ni siquiera tuvo que leer la documentación de pybind11 usted mismo. Espero que este tutorial le haya resultado útil y entretenido. Edificio feliz.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *