Como projetar um SDK de visão computacional independente de linguagem e plataforma cruzada: um tutorial prático

(Cyrus Behroozi) (21 de outubro de 2020)

Recentemente, tive a oportunidade de apresentar no encontro Venice Computer Vision . Se você não está familiarizado, é um evento patrocinado pela Trueface , onde desenvolvedores de visão computacional e entusiastas podem apresentar pesquisas, aplicativos e práticas de visão computacional de ponta tutoriais.

Neste artigo, examinarei minha apresentação do tutorial sobre como projetar um kit de desenvolvedor de software de visão computacional (SDK) independente de linguagem para implantação de plataforma cruzada e extensibilidade máxima. Se desejar ver a gravação ao vivo da apresentação, você pode fazer isso aqui . Eu também fiz o projeto inteiro de código aberto , então fique à vontade para usá-lo como um modelo para seu próximo projeto de visão computacional.

cyrusbehr / sdk_design

Como projetar um SDK agnóstico de linguagem para implantação de plataforma cruzada e extensibilidade máxima. A Venice Computer Vision…

github.com

Por que este tutorial é importante

Em minha experiência, nunca encontrei um guia abrangente que resume todas as etapas pertinentes necessárias para criar um SDK de plataforma cruzada independente de linguagem. Tive que vasculhar documentações díspares em busca das informações certas, aprender cada componente separadamente e, então, dividi-los sozinho. Foi frustrante. Demorou muito. E agora você, caro leitor, aproveite todo o meu trabalho. À frente, você aprenderá como construir um SDK de plataforma cruzada independente de linguagem Todos os fundamentos estão lá. Nada disso, exceto alguns memes. Aproveite.

Neste tutorial, você pode esperar aprender como:

  • Construir uma biblioteca básica de visão computacional em C ++
  • Compilar e cruzar compilar a biblioteca para AMD64, ARM64 e ARM32
  • Empacotar a biblioteca e todas as dependências como uma única biblioteca estática
  • Automatizar testes de unidade
  • Configurar um sistema contínuo pipeline de integração (CI)
  • Escreva ligações Python para nossa biblioteca
  • Gerar documentação diretamente de nossa API

Por causa desta demonstração, nós criará um SDK de detecção de rosto e pontos de referência usando um detector de rosto de código aberto chamado MTCNN.

Exemplo de caixas delimitadoras de rosto e marcos faciais

Nossa função API pegará um caminho de imagem e retornará as coordenadas da caixa delimitadora facial e marcos faciais. A capacidade de detectar rostos é muito útil na visão computacional, pois é a primeira etapa em muitos canais, incluindo reconhecimento de rosto, previsão de idade e desfoque automático de rosto.

Observação: para isso tutorial, estarei trabalhando no Ubuntu 18.04.

Por que usar C ++ para nossa biblioteca?

A execução de código C ++ eficiente pode parecer assim

A maioria de nossa biblioteca será escrita em C ++, uma linguagem compilada e estaticamente tipada. Não é nenhum segredo que C ++ é uma linguagem de programação muito rápida; é baixo o suficiente para nos dar a velocidade que desejamos e tem um mínimo adicional de sobrecarga de tempo de execução.

Em aplicativos de visão computacional, geralmente manipulamos muitas imagens, executamos operações de matriz, executamos inferência de aprendizado de máquina, tudo isso envolve uma grande quantidade de computação. A velocidade de execução é, portanto, crítica. Isso é especialmente importante em aplicativos de tempo real, onde você precisa reduzir a latência para atingir a taxa de quadros desejada – muitas vezes, temos apenas milissegundos para executar todo o nosso código.

Outra vantagem do C ++ é que, se nós compilar para uma determinada arquitetura e vincular todas as dependências estaticamente, então podemos executá-lo naquele hardware sem a necessidade de quaisquer interpretadores ou bibliotecas adicionais. Acredite ou não, podemos até mesmo executar em um dispositivo embarcado bare metal sem sistema operacional!

Estrutura de diretório

Usaremos a seguinte estrutura de diretório em nosso projeto.

3rdparty conterá as bibliotecas de dependência de terceiros exigidas por nosso projeto.

dist conterá os arquivos que são distribuídos aos usuários finais do SDK. No nosso caso, será a própria biblioteca e o arquivo de cabeçalho associado.

docker conterá o arquivo docker que será usado para gerar uma imagem docker para as compilações de CI.

docs conterá os scripts de compilação necessários para gerar a documentação diretamente de nosso arquivo de cabeçalho.

include conterá todos os arquivos de inclusão para a API pública.

models conterá os arquivos de modelo de aprendizado profundo de detecção de rosto.

python conterá o código necessário para gerar vínculos python.

src conterá todos os arquivos cpp que serão compilados, e também quaisquer arquivos de cabeçalho que não serão distribuídos com o SDK (arquivos de cabeçalho internos).

test conterá nossos testes de unidade.

tools conterá nossos arquivos de conjunto de ferramentas CMake necessários para compilação cruzada.

Instalando as bibliotecas de dependência

Para este projeto, o terceiro as bibliotecas de dependência necessárias são ncnn , uma biblioteca de inferência de aprendizado de máquina leve, OpenCV , uma biblioteca de aumento de imagem, Catch2 , uma biblioteca de teste de unidade e, finalmente, pybind11 , uma biblioteca usado para gerar ligações Python. As duas primeiras bibliotecas precisarão ser compiladas como bibliotecas independentes, enquanto as duas últimas são apenas cabeçalho e, portanto, exigimos apenas a fonte.

Uma maneira de adicionar essas bibliotecas aos nossos projetos é por meio de submódulos git . Embora essa abordagem funcione, eu pessoalmente sou um fã de usar scripts de shell que puxam o código-fonte e constroem para as plataformas desejadas: em nosso caso, AMD64, ARM32 e ARM64.

Aqui está um exemplo de qual desses scripts de construção se parece com:

O script é bastante direto. Ele começa puxando o código-fonte da versão desejada do repositório git. Em seguida, CMake é usado para preparar a construção e, em seguida, make é invocado para conduzir o compilador para construir o código-fonte.

O que você notará é que a principal diferença entre a construção AMD64 e as construções ARM é que as compilações ARM passam um parâmetro CMake adicional chamado CMAKE_TOOLCHAIN_FILE. Este argumento é usado para especificar ao CMake que a arquitetura de destino da construção (ARM32 ouARM64) é diferente da arquitetura do host (AMD64 / x86_64). O CMake é, portanto, instruído a usar o compilador cruzado especificado no arquivo do conjunto de ferramentas selecionado para construir a biblioteca (mais sobre os arquivos do conjunto de ferramentas posteriormente neste tutorial). Para que este script de shell funcione, você terá que ter os compiladores cruzados apropriados instalados em sua máquina Ubuntu. Eles podem ser instalados facilmente usando apt-get e as instruções sobre como fazer isso são mostradas aqui .

Nossa API de biblioteca

Nossa biblioteca de API tem a seguinte aparência:

Como sou super criativo, decidi nomear meu SDK como MySDK. Em nossa API, temos um enum chamado ErrorCode, temos uma estrutura chamada Point e, finalmente, temos uma função de membro pública chamada getFaceBoxAndLandmarks. Para o escopo deste tutorial, não irei entrar em detalhes sobre a implementação do SDK. A essência é que lemos a imagem na memória usando OpenCV e, em seguida, realizamos inferência de aprendizado de máquina usando ncnn com modelos de código aberto para detectar a caixa delimitadora de rosto e os pontos de referência. Se desejar mergulhar na implementação, você pode fazê-lo aqui .

O que quero que você preste atenção, porém, é o padrão de design que estamos usando. Estamos usando uma técnica chamada Ponteiro para implementação, ou pImpl para abreviar, que basicamente remove os detalhes de implementação de uma classe, colocando-os em uma classe separada. No código acima, isso é conseguido declarando a classe Impl, então tendo um unique_ptr para esta classe como uma variável de membro privada. Ao fazer isso, não apenas escondemos a implementação dos olhos curiosos do usuário final (o que pode ser muito importante em um SDK comercial), mas também reduzimos o número de cabeçalhos dos quais nosso cabeçalho de API depende (e assim evitamos nossa Cabeçalho de API de #include ing cabeçalhos de biblioteca de dependência).

Uma nota sobre arquivos de modelo

Eu disse que não iríamos examinar os detalhes da implementação, mas há algo que acho que vale a pena mencionar. Por padrão, o detector facial de código aberto que estamos usando, chamado MTCNN, carrega os arquivos do modelo de aprendizado de máquina no tempo de execução. Isso não é ideal porque significa que precisaremos distribuir os modelos para o usuário final. Esse problema é ainda mais significativo com modelos comerciais em que você não deseja que os usuários tenham acesso gratuito a esses arquivos de modelo (pense nas incontáveis ​​horas gastas no treinamento desses modelos). Uma solução é criptografar os arquivos desses modelos, o que eu absolutamente aconselho fazer.No entanto, isso ainda significa que precisamos enviar os arquivos de modelo junto com o SDK. Em última análise, queremos reduzir o número de arquivos que enviamos a um usuário para facilitar o uso de nosso software (menos arquivos é igual a menos lugares para errar). Portanto, podemos usar o método mostrado abaixo para converter os arquivos de modelo em arquivos de cabeçalho e incorporá-los ao próprio SDK.

O comando xdd bash é usado para gerar hex dumps e pode ser usado para gerar um arquivo de cabeçalho a partir de um arquivo binário. Podemos, portanto, incluir os arquivos de modelo em nosso código como arquivos de cabeçalho normais e carregá-los diretamente da memória. Uma limitação desta abordagem é que não é prático com arquivos de modelo muito grandes, pois consome muita memória em tempo de compilação. Em vez disso, você pode usar uma ferramenta como ld para converter esses arquivos de modelo grandes diretamente em arquivos de objeto.

CMake e compilar nossa biblioteca

Agora podemos usar o CMake para gerar os arquivos de construção para o nosso projeto. Caso você não esteja familiarizado, CMake é um gerador de sistema de construção usado para gerenciar o processo de construção. Abaixo, você verá a aparência de parte da raiz CMakeLists.txt (arquivo CMake).

Basicamente, criamos uma biblioteca estática chamada my_sdk_static com os dois arquivos de origem que contêm nossa implementação, my_sdk.cpp e mtcnn.cpp. A razão de estarmos criando uma biblioteca estática é que, em minha experiência, é mais fácil distribuir uma biblioteca estática para os usuários e é mais amigável para dispositivos embarcados. Como mencionei acima, se um executável estiver vinculado a uma biblioteca estática, ele pode ser executado em um dispositivo embutido que nem mesmo tem um sistema operacional. Isso simplesmente não é possível com uma biblioteca dinâmica. Além disso, com as bibliotecas dinâmicas, temos que nos preocupar com as versões de dependência. Podemos até precisar de um arquivo de manifesto associado à nossa biblioteca. Bibliotecas estaticamente vinculadas também têm um perfil de desempenho ligeiramente melhor do que suas contrapartes dinâmicas.

A próxima coisa que fazemos em nosso script CMake é dizer ao CMake onde encontrar os arquivos de cabeçalho de inclusão necessários que nossos arquivos de origem requerem. Algo a se observar: embora nossa biblioteca seja compilada neste ponto, quando tentamos fazer um link com nossa biblioteca (com um executável, por exemplo), obteremos uma tonelada absoluta de referências indefinidas para erros de símbolo. Isso ocorre porque não vinculamos nenhuma de nossas bibliotecas de dependência. Portanto, se quisermos vincular com sucesso um executável a libmy_sdk_static.a, teríamos que rastrear e vincular todas as bibliotecas de dependência também (módulos OpenCV, ncnn, etc). Ao contrário das bibliotecas dinâmicas, as bibliotecas estáticas não podem resolver suas próprias dependências. Eles são basicamente apenas uma coleção de arquivos de objetos empacotados em um arquivo.

Posteriormente neste tutorial, demonstrarei como podemos agrupar todas as bibliotecas de dependência em nossa biblioteca estática para que o usuário não precise preocupe-se com a vinculação a qualquer uma das bibliotecas de dependência.

Compilação cruzada de nossos arquivos de biblioteca e conjunto de ferramentas

A computação de ponta é tão… nervosa

Muitos aplicativos de visão computacional são implantados na ponta. Isso geralmente envolve a execução do código em dispositivos embarcados de baixo consumo de energia que geralmente possuem CPUs ARM. Como C ++ é uma linguagem compilada, devemos compilar nosso código para a arquitetura de CPU na qual o aplicativo será executado (cada arquitetura usa diferentes instruções de montagem).

Antes de mergulharmos nisso, vamos também abordar o diferença entre ARM32 e ARM64, também chamado de AArch32 e AArch64. AArch64 se refere à extensão de 64 bits da arquitetura ARM e é dependente da CPU e do sistema operacional. Por exemplo, mesmo que o Raspberry Pi 4 tenha uma CPU ARM de 64 bits, o sistema operacional padrão Raspbian é de 32 bits. Portanto, tal dispositivo requer um binário compilado AArch32. Se fôssemos rodar um sistema operacional de 64 bits como o Gentoo neste dispositivo Pi, então precisaríamos de um binário compilado AArch64. Outro exemplo de dispositivo embarcado popular é o NVIDIA Jetson, que tem uma GPU integrada e executa AArch64.

Para fazer a compilação cruzada, precisamos especificar para o CMake que não estamos compilando para a arquitetura do máquina em que estamos construindo. Portanto, precisamos especificar o compilador cruzado que o CMake deve usar. Para AArch64, usamos o aarch64-linux-gnu-g++ compilador e para AArch32 usamos o arm-linux-gnuebhif-g++ compilador (hf significa hard float ).

A seguir está um exemplo de um arquivo de conjunto de ferramentas. Como você pode ver, estamos especificando o uso do compilador cruzado AArch64.

De volta à nossa raiz CMakeLists.txt, podemos adicione o seguinte código ao topo do arquivo.

Basicamente, estamos adicionando opções do CMake que pode ser habilitado na linha de comando para compilar. Ativar as opções BUILD_ARM32 ou BUILD_ARM64 selecionará o arquivo de conjunto de ferramentas apropriado e configurará a compilação para uma compilação cruzada.

Empacotando nosso SDK com bibliotecas de dependência

Conforme mencionado anteriormente, se um desenvolvedor quiser vincular nossa biblioteca neste ponto, ele também precisará vincular a todas as bibliotecas de dependência para resolver todos os símbolos de bibliotecas de dependência. Mesmo que nosso aplicativo seja bastante simples, já temos oito bibliotecas de dependências! O primeiro é ncnn, depois temos três bibliotecas de módulos OpenCV, depois temos quatro bibliotecas de utilitários que foram construídas com OpenCV (libjpeg, libpng, zlib, libtiff). Poderíamos exigir que o usuário construísse as bibliotecas de dependência por conta própria ou até mesmo as enviasse junto com nossa biblioteca, mas, no final das contas, isso dá mais trabalho para o usuário e estamos todos preocupados em diminuir a barreira de uso. A situação ideal é se pudermos enviar ao usuário uma única biblioteca que contém nossa biblioteca junto com todas as bibliotecas de dependência de terceiros, exceto as bibliotecas padrão do sistema. Acontece que podemos conseguir isso usando um pouco da magia do CMake.

Primeiro adicionamos um destino personalizado para nosso CMakeLists.txt e execute o que é chamado de script de ressonância magnética. Este script de MRI é passado para o ar -M comando bash, que basicamente combina todas as bibliotecas estáticas em um único arquivo. O que é legal sobre esse método é que ele lida perfeitamente com nomes de membros sobrepostos dos arquivos originais, então não precisamos nos preocupar com conflitos aí. Construir este destino personalizado produzirá libmy_sdk.a que conterá nosso SDK junto com todos os arquivos de dependência.

Espere um segundo: vamos fazer um balanço do que nós já fiz até agora.

Respire fundo. Faça um lanche. Ligue para sua mãe.

Neste ponto, temos uma biblioteca estática chamada libmy_sdk.a que contém nosso SDK e todas as bibliotecas de dependência, que empacotamos em um único arquivo. Também temos a capacidade de compilar e compilar (usando argumentos de linha de comando) para todas as nossas plataformas de destino.

Testes de unidade

Quando você executa seus testes de unidade pela primeira vez

Provavelmente não preciso explicar por que os testes de unidade são importantes, mas basicamente, eles são uma parte crucial do design do SDK que permite ao desenvolvedor garantir que o SDK está funcionando como indentado. Além disso, se quaisquer alterações importantes forem feitas na linha, isso ajuda a rastreá-las e enviar correções mais rapidamente.

Nesse caso específico, criar um executável de teste de unidade também nos dá a oportunidade de vincular ao biblioteca combinada que acabamos de criar para garantir que possamos vincular corretamente como pretendido (e não recebemos nenhum desses erros desagradáveis ​​de referência para símbolo indefinido).

Estamos usando Catch2 como nossa estrutura de teste de unidade . A sintaxe é descrita abaixo:

Como Catch2 funciona é que temos essa macro chamada TEST_CASE e outra macro chamada SECTION. Para cada SECTION, o TEST_CASE é executado desde o início. Portanto, em nosso exemplo, mySdk será inicializado primeiro e, em seguida, a primeira seção chamada “Imagem sem rosto” será executada. Em seguida, mySdk será desconstruído antes de ser reconstruído e, em seguida, a segunda seção chamada “Rostos na imagem” será executada. Isso é ótimo porque garante que tenhamos um novo objeto MySDK para operar em cada seção. Podemos então usar macros como REQUIRE para fazer nossas afirmações.

Podemos usar o CMake para construir um executável de teste de unidade chamado run_tests. Como podemos ver na chamada para target_link_libraries na linha 3 abaixo, a única biblioteca que precisamos vincular é a nossa libmy_sdk.a e nenhuma outra bibliotecas de dependência.

Documentação

Se apenas os usuários pudessem ler a maldita documentação.

Usaremos doxygen para gerar a documentação diretamente de nosso arquivo de cabeçalho. Podemos prosseguir e documentar todos os nossos métodos e tipos de dados em nosso cabeçalho público usando a sintaxe mostrada no trecho de código abaixo.Certifique-se de especificar todos os parâmetros de entrada e saída para todas as funções.

Para gerar a documentação , precisamos de algo chamado doxyfile, que é basicamente um projeto para instruir o doxygen sobre como gerar a documentação. Podemos gerar um doxyfile genérico executando doxygen -g em nosso terminal, supondo que você tenha o doxygen instalado em seu sistema. Em seguida, podemos editar o doxyfile. No mínimo, precisamos especificar o diretório de saída e também os arquivos de entrada.

Em nosso Nesse caso, queremos apenas gerar documentação a partir de nosso arquivo de cabeçalho da API, e é por isso que especificamos o diretório de inclusão. Finalmente, você usa o CMake para construir a documentação, o que pode ser feito assim .

Python Bindings

Você já está cansado de ver GIFs semirrelevantes? Sim, nem eu.

Sejamos honestos. C ++ não é a linguagem mais fácil ou amigável para desenvolver. Portanto, queremos estender nossa biblioteca para suportar ligações de linguagem para torná-la mais fácil de usar para os desenvolvedores. Vou demonstrar isso usando Python, pois é uma linguagem de prototipagem de visão computacional popular, mas outras ligações de linguagem são tão fáceis de escrever. Estamos usando o pybind11 para conseguir isso:

Começamos usando o PYBIND11_MODULE macro que cria uma função que será chamada quando uma instrução de importação for emitida de dentro do python. Portanto, no exemplo acima, o nome do módulo python é mysdk. Em seguida, podemos definir nossas classes e seus membros usando a sintaxe pybind11.

Aqui está algo a ser observado: em C ++, é muito comum passar variáveis ​​usando referência mutável que permite acesso de leitura e gravação. Isso é exatamente o que fizemos com nossa função de membro da API com os parâmetros faceDetected e fbAndLandmarks. Em python, todos os argumentos são passados ​​por referência. No entanto, certos tipos básicos de python são imutáveis, incluindo bool. Coincidentemente, nosso faceDetected parâmetro é um bool que é passado por referência mutável. Devemos, portanto, usar a solução alternativa mostrada no código acima nas linhas 31 a 34, onde definimos o bool dentro de nossa função wrapper Python e, em seguida, passamos para nossa função C ++ antes de retornar a variável como parte de uma tupla.

Depois de construir a biblioteca de vinculações python, podemos utilizá-la facilmente usando o código abaixo:

Integração Contínua

Para nosso pipeline de integração contínua, usaremos uma ferramenta chamada CircleCI, que eu realmente gosto porque se integra diretamente com o Github. Uma nova construção será acionada automaticamente toda vez que você enviar um commit. Para começar, acesse o site do CircleCI, conecte-o à sua conta do Github e selecione o projeto que deseja adicionar. Depois de adicionado, você precisará criar um diretório .circleci na raiz do seu projeto e criar um arquivo chamado config.yml dentro desse diretório.

Para quem não está familiarizado, YAML é uma linguagem de serialização comumente usada para arquivos de configuração. Podemos usá-lo para instruir quais operações queremos que o CircleCI execute. No snippet YAML abaixo, você pode ver como construímos primeiro uma das bibliotecas de dependência, em seguida construímos o próprio SDK e, finalmente, construímos e executamos os testes de unidade.

Se formos inteligentes (e presumo que você seja se tiver chegado até aqui), podemos usar o cache para reduzir significativamente os tempos de compilação. Por exemplo, no YAML acima, armazenamos em cache a construção OpenCV usando o hash do script de construção como a chave de cache. Dessa forma, a biblioteca OpenCV só será reconstruída se o script de construção tiver sido modificado – caso contrário, a construção em cache será usada. Outra coisa a observar é que estamos executando a compilação dentro de uma imagem docker de nossa escolha. Selecionei uma imagem docker personalizada ( aqui é o Dockerfile) na qual instalei todas as dependências do sistema.

Fin.

E aí está. Como qualquer produto bem projetado, queremos oferecer suporte às plataformas mais demandadas e torná-lo fácil de usar para o maior número de desenvolvedores. Usando o tutorial acima, construímos um SDK que pode ser acessado em várias linguagens e implementável em várias plataformas. E você nem mesmo precisou ler a documentação do pybind11. Espero que você tenha achado este tutorial útil e divertido. Feliz construção.

Deixe uma resposta

O seu endereço de email não será publicado. Campos obrigatórios marcados com *