Há cerca de um ano, a
Intel Movidius lançou um dispositivo para inferência eficiente de redes neurais convolucionais - Movidius Neural Compute Stick (NCS). Este dispositivo permite o uso de redes neurais para o reconhecimento ou detecção de objetos em condições de consumo limitado de energia, inclusive em tarefas de robótica. O NCS possui uma interface USB e consome não mais que 1 watt. Neste artigo, falarei sobre a experiência de usar o NCS com o Raspberry Pi para a tarefa de detectar rostos em vídeo, incluindo o treinamento do detector Mobilenet-SSD e o lançamento no Raspberry.
Todo o código pode ser encontrado em meus dois repositórios:
treinamento de detectores e
demonstrações de detecção de faces .
No meu primeiro artigo, eu já escrevi sobre detecção de rosto usando o NCS: então estávamos conversando sobre um detector
YOLOv2 , que converti do formato
Darknet para o formato
Caffe e o
iniciei no NCS. O processo de conversão acabou não sendo trivial: como esses dois formatos definem a última camada do detector de maneiras diferentes, a saída da rede neural teve que ser analisada separadamente, na CPU, usando um pedaço de código da Darknet. Além disso, esse detector não me satisfez em velocidade (até 5,1 FPS no meu laptop) e em precisão - mais tarde fiquei convencido de que, devido à sua sensibilidade à qualidade da imagem, era difícil obter um bom resultado no Raspberry Pi.
No final, eu decidi apenas treinar meu detector. A escolha foi feita no detector
SSD com o codificador
Mobilenet : a convolução leve do Mobilenet permite alcançar alta velocidade sem perda de qualidade, e o detector SSD não é inferior ao YOLO e funciona no NCS imediatamente.
Como o detector Mobilenet-SSD funcionaVamos começar com a Mobilenet. Nesta arquitetura está completa
convolução (em todos os canais) é substituída por duas convoluções leves: primeiro
separadamente para cada canal e, em seguida, conclua

convolução. Após cada convolução,
BatchNorm e Não Linearidade (ReLU) são usados. A primeira convolução de rede que recebe uma imagem como entrada geralmente é deixada completa. Essa arquitetura pode reduzir significativamente a complexidade dos cálculos devido a uma ligeira diminuição na qualidade das previsões. Existe uma
opção mais avançada , mas ainda não a tentei.
O SSD (Single Shot Detector) funciona assim: nas saídas de vários codificadores de convolução são pendurados em dois

camada convolucional: um prevê as probabilidades das classes, o outro - as coordenadas da caixa delimitadora. Há uma terceira camada que fornece as coordenadas e posições dos quadros padrão no nível atual. O significado é: a saída de qualquer camada é naturalmente dividida em células; mais perto do final da rede neural, eles estão se tornando menores (neste caso, devido à convolução com
stride=2
), e o campo de visão de cada célula aumenta. Para cada célula em cada uma das várias camadas selecionadas, definimos vários quadros padrão de tamanhos diferentes e com diferentes proporções, e usamos camadas convolucionais adicionais para corrigir coordenadas e prever probabilidades de classe para cada um desses quadros. Portanto, um detector SSD (como o YOLO) sempre considera o mesmo número de quadros. O mesmo objeto pode ser detectado em diferentes camadas: durante o treinamento, o sinal é enviado a todos os quadros que se cruzam fortemente com o objeto e, durante a aplicação, as detecções são combinadas usando a não supressão máxima (NMS). A camada final combina as detecções de todas as camadas, considera suas coordenadas completas, corta o limiar de probabilidade e produz NMS.
Treinamento de detectores
Arquitetura
O código para treinar o detector está localizado
aqui .
Decidi usar o
detector Mobilenet-SSD pronto para treinamento, treinado no
PASCAL VOC0712, e treiná-lo para detectar rostos. Primeiro, ajuda a treinar a rede muito mais rapidamente e, segundo, você não precisa reinventar a roda.
O projeto original incluiu o script
gen.py
, que coletou literalmente o arquivo de modelo
.prototxt
, substituindo os parâmetros de entrada. Transferi para o meu projeto, expandindo um pouco a funcionalidade. Este script permite gerar quatro tipos de arquivos de configuração:
- train : na entrada - uma base LMDB de treinamento, na saída - uma camada com o cálculo da função de perda e seus gradientes, existe o BatchNorm
- teste : na entrada - a base LMDB de teste, na saída - a camada com o cálculo da qualidade (precisão média média), existe o BatchNorm
- deploy : na entrada - a imagem, na saída - a camada com previsões, o BatchNorm está ausente
- deploy_bn : na entrada - a imagem, na saída - a camada com previsões, existe o BatchNorm
Adicionei a última opção posteriormente, para que nos scripts você pudesse carregar e converter a grade do BatchNorm sem tocar no banco de dados LMDB - caso contrário, na ausência do banco de dados, nada funcionaria. (Em geral, me parece estranho que no Caffe a fonte de dados esteja definida na arquitetura de rede - isso é pelo menos pouco prático).
Como é a arquitetura da rede (curta)- Login:
- Conv conv conv : 32 canais,
stride=2
- Mobil1 convolução conv1 - conv11 : 64, 128, 128, 256, 256, 512 ... 512 canais, alguns têm
stride=2
- Camada de detecção:
- Convolução Mobilenet conv12, conv13 : 1024 canais, conv12 tem
stride=2
- Camada de detecção:
- Convolução completa conv14_1, conv14_2 : 256, 512 canais, o primeiro
kernel_size=1
, o segundo stride=2
- Camada de detecção:
- Convolução completa conv15_1, conv15_2 : 128, 256 canais, o primeiro
kernel_size=1
, o segundo stride=2
- Camada de detecção:
- Convoluções completas conv16_1, conv16_2 : 128, 256 canais, o primeiro
kernel_size=1
, o segundo stride=2
- Camada de detecção:
- Convolução completa conv17_1, conv17_2 : 64, 128 canais, o primeiro
kernel_size=1
, o segundo stride=2
- Camada de detecção:
- Saída de detecção da camada final
Corrigi levemente a arquitetura da rede. Lista de alterações:
- Obviamente, o número de classes mudou para 1 (sem contar o plano de fundo).
- Limitações na proporção das manchas cortadas durante o treinamento: alterado de em (Decidi simplificar um pouco a tarefa e não aprender com fotos muito esticadas).
- Dos quadros padrão, apenas os quadrados permaneceram, dois para cada célula. Reduzi bastante seus tamanhos, pois os rostos são significativamente menores que os objetos no problema clássico de detecção de objetos.
O Caffe calcula os tamanhos de quadro padrão da seguinte maneira: ter um tamanho de quadro mínimo
e máximo
, cria um quadro pequeno e grande com dimensões
e
. Como eu queria detectar o mínimo possível de faces, calculei o
stride
completo para cada camada de detecção e igualei o tamanho mínimo do quadro a ela. Com esses parâmetros, os pequenos quadros padrão serão localizados próximos um do outro e não se cruzarão. Portanto, temos pelo menos uma garantia de que a interseção com o objeto existirá para algum tipo de estrutura. Defino o tamanho máximo duas vezes mais. Para as camadas
conv16_2, conv17_2, defino as dimensões no olho como iguais. Desta maneira
para todas as camadas foram:
Aparência de alguns quadros padrão (ruído para maior clareza) Dados
Eu usei dois
conjuntos de dados :
WIDER Face e
FDDB . O WIDER contém muitas fotos com rostos muito pequenos e embaçados, e o FDDB é mais inclinado a imagens grandes de rostos (e uma ordem de magnitude menor que o WIDER). O formato da anotação é um pouco diferente, mas esses já são detalhes.
Para o treinamento, não usei todos os dados: joguei rostos muito pequenos (menos de seis pixels ou menos de 2% da largura da imagem), joguei todas as imagens com uma proporção de menos de 0,5 ou mais que 2, joguei fora todas as imagens marcadas como "embaçadas" no conjunto de dados WIDER, já que eles correspondiam em grande parte a pessoas muito pequenas, e eu tive que pelo menos de alguma maneira alinhar a proporção de rostos pequenos e grandes. Depois disso, fiz todos os quadros quadrados, expandindo o lado menor: decidi que não estava muito interessado em proporções faciais e a tarefa para a rede neural foi um pouco simplificada. Também expulsei todas as imagens em preto e branco, das quais havia poucas e nas quais o script de construção do banco de dados falha.
Para usá-los para treinamento e teste, você precisa montar uma base LMDB a partir deles. Como fazer:
- Para cada imagem, a marcação é criada no formato
.xml
. - Um arquivo
train.txt
é train.txt
com linhas no formato "path/to/image.png path/to/labels.xml"
, o mesmo é criado para teste. - Um arquivo
test_name_size.txt
é test_name_size.txt
com linhas no formato "test_image_name height width"
- Cria um arquivo
labelmap.prototxt
com correspondências numéricas de labelmap.prototxt
O
ssd-caffe/scripts/create_annoset.py
(exemplo no Makefile):
python3 /opt/movidius/ssd-caffe/scripts/create_annoset.py --anno-type=detection \ --label-map-file=$(wider_dir)/labelmap.prototxt --min-dim=0 --max-dim=0 \ --resize-width=0 --resize-height=0 --check-label --encode-type=jpg --encoded \ --redo $(wider_dir) \ $(wider_dir)/trainval.txt $(wider_dir)/WIDER_train/lmdb/wider_train_lmdb ./data
labelmap.prototxt item { name: "none_of_the_above" label: 0 display_name: "background" } item { name: "face" label: 1 display_name: "face" }
Exemplo de marcação .xml <?xml version="1.0" ?> <annotation> <size> <width>348</width> <height>450</height> <depth>3</depth> </size> <object> <name>face</name> <bndbox> <xmin>161</xmin> <ymin>43</ymin> <xmax>241</xmax> <ymax>123</ymax> </bndbox> </object> </annotation>
O uso de dois conjuntos de dados ao mesmo tempo significa apenas que você precisa mesclar cuidadosamente os arquivos correspondentes em pares, não esquecendo de registrar corretamente os caminhos e embaralhar o arquivo para treinamento.
Depois disso, você pode começar o treinamento.
Treinamento
O código para o treinamento de modelos pode ser encontrado no meu
Colab Notebook .
Fiz o treinamento no Google Colaboratory, porque meu laptop mal fez os testes da grade e, geralmente, desligou o treinamento. O Colaboratory me permitiu treinar a rede de maneira rápida e gratuita. O único problema é que tive que escrever um script de compilação SSD-Caffe para o Colaboratory (incluindo coisas estranhas como recompilar o impulso e editar a fonte), o que leva cerca de 40 minutos. Mais detalhes podem ser encontrados
na minha publicação anterior .
O Colaboratório tem mais uma característica: após 12 horas, o carro morre, apagando permanentemente todos os dados. A melhor maneira de evitar a perda de dados é montar seu disco do Google no sistema e salvar os pesos da rede a cada 500-1000 iterações de treinamento.
Quanto ao meu detector, em uma sessão no Colaboratory, ele conseguiu desaprender 4.500 iterações e foi totalmente treinado em duas sessões.
A qualidade das previsões (precisão média média) no conjunto de dados de teste que destaquei (mesclou o WIDER e o FDDB com as restrições listadas acima) foi de cerca de 0,87 para o melhor modelo. Para medir o mAP nas escalas salvas durante o treinamento, existe um script
scripts/plot_map.py
.
O detector funciona em um exemplo (muito estranho) de um conjunto de dados:
Lançamento no NCS
Uma demonstração de detecção de rosto está
aqui .
Para compilar uma rede neural para o Neural Compute Stick, é necessário o
Movidius NCSDK : ele contém utilitários para compilar e criar perfis de redes neurais, bem como as APIs C ++ e Python. Vale ressaltar que a segunda versão foi lançada recentemente, o que não é compatível com a primeira: todas as funções da API foram renomeadas por algum motivo, o formato interno das redes neurais mudou, o FIFO foi adicionado para interagir com o NCS e (finalmente) a conversão automática de float 32 bits para float 16 bits, que faltava em C ++. Atualizei todos os meus projetos para a segunda versão, mas deixei algumas muletas para compatibilidade com a primeira.
Após o treinamento do detector, vale a pena mesclar as camadas BatchNorm com as convoluções adjacentes para acelerar a rede neural. O script
merge_bn.py
isso daqui , que também emprestei do projeto Mobilenet-SSD.
Então você precisa chamar o utilitário
mvNCCompile
, por exemplo:
mvNCCompile -s 12 -o graph_ssd -w ssd-face.caffemodel ssd-face.prototxt
Há uma meta
graph_ssd
para isso no Makefile do projeto. O arquivo
graph_ssd
resultante é uma descrição da rede neural em um formato entendido pelo NCS.
Agora, sobre como interagir com o próprio dispositivo. O processo não é muito complicado, mas requer uma quantidade bastante grande de código. A sequência de ações é aproximadamente a seguinte:
- Obter descritor de dispositivo pelo número de série
- Dispositivo aberto
- Leia o arquivo de rede neural compilado no buffer (como um arquivo binário)
- Crie um gráfico de cálculo vazio para o NCS
- Coloque o gráfico no dispositivo usando os dados do arquivo e selecione FIFO na entrada / saída; o buffer com o conteúdo do arquivo agora pode ser liberado
- Início do detector:
- Obtenha a imagem da câmera (ou de qualquer outra fonte)
- Processe: dimensione para o tamanho desejado, converta para float32 e faça a conversão para o intervalo [-1,1]
- Carregar imagem no dispositivo e solicitar inferência
- Solicitar um resultado (o programa será bloqueado até que o resultado seja recebido)
- Analise o resultado, selecione os quadros dos objetos (sobre o formato - mais adiante)
- Exibir previsões
- Libere todos os recursos: remova o FIFO e o gráfico de cálculo, feche o dispositivo e remova sua alça
Quase todas as ações com o NCS têm sua própria função separada, e no C ++ parece muito complicado, e você precisa monitorar cuidadosamente a liberação de todos os recursos. Para não sobrecarregar o código, criei
uma classe de wrapper para trabalhar com o NCS . Nele, todo o trabalho de inicialização está oculto no construtor e na função
load_file
, e na liberação de recursos - no destruidor, e o trabalho com o NCS é reduzido a chamar 2-3 métodos de classe. Além disso, existe uma função conveniente para explicar os erros que ocorreram.
Crie um wrapper passando o tamanho da entrada e o tamanho da saída (número de elementos) para o construtor:
NCSWrapper NCS(NETWORK_INPUT_SIZE*NETWORK_INPUT_SIZE*3, NETWORK_OUTPUT_SIZE);
Carregamos o arquivo compilado com a rede neural, inicializando simultaneamente tudo o que precisamos:
if (!NCS.load_file("./models/face/graph_ssd")) { NCS.print_error_code(); return 0; }
Convertemos a imagem em float32 (a
image
é
cv::Mat
no formato
CV_32FC3
) e a transferimos para o dispositivo:
if(!NCS.load_tensor_nowait((float*)image.data)) { NCS.print_error_code(); break; }
Obtemos o resultado (
result
é um ponteiro de
float
livre, o buffer de resultado é suportado pelo wrapper); até o final dos cálculos, o programa está bloqueado:
if(!NCS.get_result(result)) { NCS.print_error_code(); break; }
De fato, o wrapper também possui um método que permite carregar dados e obter o resultado ao mesmo tempo:
load_tensor((float*)image.data, result)
. Recusei-me a usá-lo por um motivo: usando métodos separados, você pode acelerar um pouco a execução do código. Depois de carregar a imagem, a CPU permanecerá ociosa até que o resultado da execução com o NCS chegue (neste caso, são cerca de 100 ms) e, nesse momento, você pode fazer um trabalho útil: leia um novo quadro e converta-o, além de exibir detecções anteriores . É exatamente assim que o programa de demonstração é implementado, no meu caso, aumenta ligeiramente o FPS. Você pode ir além e iniciar o processamento de imagens e o detector de rosto de forma assíncrona em dois fluxos diferentes - isso realmente funciona e permite acelerar um pouco mais, mas não é implementado no programa de demonstração.
O detector, como resultado, retorna uma matriz flutuante de tamanho
7*(keep_top_k+1)
. Aqui,
keep_top_k
é o parâmetro especificado no arquivo
.prototxt
do modelo e mostra quantas detecções (na ordem de confiança decrescente) devem ser retornadas. Esse parâmetro, assim como o parâmetro responsável por filtrar detecções pelo valor mínimo de confiança e parâmetros de supressão não máximos, podem ser configurados no arquivo
.prototxt
do modelo na última camada. Vale ressaltar que, se o Caffe retornar tantas detecções quanto as encontradas na imagem, o NCS sempre retornará
keep_top_k
detecções para que o tamanho da matriz seja constante.
A matriz de resultados em si é
keep_top_k+1
seguinte forma: se a considerarmos como uma matriz com
keep_top_k+1
linhas e 7 colunas, na primeira linha, no primeiro elemento, haverá o número de detecções e, a partir da segunda linha, haverá as próprias detecções no formato
"garbage, class_index, class_probability, x_min, y_min, x_max, y_max"
. As coordenadas são especificadas no intervalo [0,1], portanto, elas precisam ser multiplicadas pela altura / largura da imagem. Os elementos restantes da matriz serão lixo. Nesse caso, a supressão não máxima é realizada automaticamente, mesmo antes de o resultado ser obtido (ao que parece, diretamente no NCS).
Análise de Detector void get_detection_boxes(float* predictions, int w, int h, float thresh, std::vector<float>& probs, std::vector<cv::Rect>& boxes) { int num = predictions[0]; float score = 0; float cls = 0; for (int i=1; i<num+1; i++) { score = predictions[i*7+2]; cls = predictions[i*7+1]; if (score>thresh && cls<=1) { probs.push_back(score); boxes.push_back(Rect(predictions[i*7+3]*w, predictions[i*7+4]*h, (predictions[i*7+5]-predictions[i*7+3])*w, (predictions[i*7+6]-predictions[i*7+4])*h)); } } }
Recursos de lançamento do Raspberry Pi
O programa de demonstração em si pode ser executado em um computador ou laptop comum com o Ubuntu ou em um Raspberry Pi com um Raspbian Stretch. Estou usando um Raspberry Pi 2 modelo B, mas a demonstração deve funcionar em outros modelos também. O makefile do projeto contém dois objetivos para alternar os modos:
make switch_desk
para o computador / laptop e
make switch_rpi
para o Raspberry Pi. A diferença fundamental no código do programa é que, no primeiro caso, o OpenCV é usado para ler dados da câmera e, no segundo caso, da biblioteca
RaspiCam . Para executar a demonstração no Raspberry, você deve compilar e instalar.
Agora, um ponto muito importante: a instalação do NCSDK. Se você seguir as instruções de instalação padrão do Raspberry Pi, nada de bom terminará: o instalador tentará arrastar e compilar SSD-Caffe e Tensorflow. Em vez disso, o NCSDK precisa ser
compilado no modo somente API . Nesse modo, apenas as APIs C ++ e Python estarão disponíveis (ou seja, não será possível compilar e criar gráficos de redes neurais). Isso significa que o gráfico da rede neural deve primeiro ser compilado em um computador comum e depois copiado para o Raspberry. Por conveniência, adicionei dois arquivos compilados ao repositório, para YOLO e SSD.
Outro ponto interessante é a conexão puramente física do NCS ao Raspberry. Parece que não é difícil conectá-lo a um conector USB, mas é preciso lembrar que o gabinete bloqueará os outros três conectores (é bastante saudável, pois desempenha a função de um radiador). A saída mais fácil é conectá-lo através de um cabo USB.
Também é importante lembrar que a velocidade de execução variará para diferentes versões do USB (para esta rede neural específica: 102 ms para USB 3.0, 92 ms para USB 2.0).
Agora, sobre o poder do NCS. De acordo com a documentação, consome até 1 watt (a 5 volts no conector USB será de até 200 ma; para comparação: a câmera Raspberry consome até 250 ma). Quando alimentado por um carregador regular de 5 volts, 2 amperes, tudo funciona muito bem. No entanto, tentar conectar dois ou mais NCSs ao Raspberry pode causar problemas. Nesse caso, é recomendável usar um divisor USB com a possibilidade de alimentação externa.
No Raspberry, a demonstração é mais lenta que em um computador / laptop: 7,2 FPS versus 10,4 FPS. Isso se deve a vários fatores: primeiro, é impossível livrar-se dos cálculos na CPU, mas eles são realizados muito mais lentamente; segundo, a velocidade de transferência de dados afeta (para USB 2.0).
Além disso, para comparação, tentei executar um detector de rosto no Raspberry YOLOv2 no meu primeiro artigo, mas funcionou muito mal: a uma velocidade de 3,6 FPS, ele perde muitos rostos, mesmo em quadros simples. Aparentemente, é muito sensível aos parâmetros da imagem de entrada, cuja qualidade no caso da câmera Raspberry está longe de ser ideal. O SSD funciona muito mais estável, embora eu tenha que ajustar um pouco as configurações de vídeo nas configurações do RapiCam. ele também sente falta dos rostos no quadro, mas faz isso muito raramente. Para aumentar a estabilidade em aplicativos reais, você pode adicionar um
rastreador de centróide simples.
A propósito: a mesma coisa pode ser reproduzida no Python, há um
tutorial no PyImageSearch (o Mobilenet-SSD é usado para a tarefa de detecção de objetos).
Outras ideias
Também testei algumas idéias para acelerar a própria rede neural:
Primeira idéia: você pode deixar apenas a detecção das camadas
conv11
e
conv13
e remover todas as camadas extras. Você receberá um detector que detecta apenas rostos pequenos e funciona um pouco mais rápido. Em suma, não vale a pena.
A segunda ideia foi interessante, mas não deu certo: tentei lançar convoluções da rede neural com pesos próximos a zero, esperando que se tornasse mais rápido. No entanto, houve poucas complicações, e sua remoção apenas diminuiu a velocidade da rede neural (o único palpite: isso se deve ao fato de o número de canais ter deixado de ser uma potência de dois).
Conclusão
Pensei em detectar rostos no Raspberry por um longo tempo, como uma subtarefa do meu projeto robótico. Não gostei dos detectores clássicos em termos de velocidade e qualidade e decidi experimentar métodos de rede neural, ao mesmo tempo testando o Neural Compute Stick, como resultado dos quais havia dois projetos no GitHub e três artigos no Habré (incluindo o atual). Em geral, o resultado combina comigo - provavelmente, usarei esse detector no meu robô (talvez haja outro artigo sobre ele). Vale ressaltar que minha solução pode não ser ótima - no entanto, este é um projeto de treinamento, feito em parte por curiosidade para o NCS. No entanto, espero que este artigo seja útil para alguém.