Implementando um detector de objetos (Single-Shot Detection) do zero mesmo.

Adriano A. Santos
9 min readApr 9, 2021

--

Fala, pessoal! Tudo em paz? Espero que sim.

Hoje eu vou compartilhar com vocês como implementar um detector de objetos do zero. Bem, você já deve ter visto vários exemplos similares a esse na net. Sem dúvida… porém, a maioria dos exemplos disponíveis utilizam arquiteturas pré-definidas para o backbone e principalmente para o header. No nosso exemplo, nós não iremos utilizar nada pronto, inclusive, caso você deseje (e eu espero que sim) testar outras arquiteturas, agora é a hora!

Eu já disponibilizei o código no meu github. Fiquem livres para usar, evoluir, vender etc. Com toda certeza vocês podem melhorar o código que eu fiz. Claro que eu escrevi o código para que o entendimento sobre o processo seja simples e que vocês consigam, sem muitos esforços, entender todas as etapas. Resumindo o discurso: foco no aprendizado.

Este post tem tudo para ser extenso. Mas eu buscarei ser o mais objetivo possível nas explicações. Eu também comentei o código, então vocês terão informações complementares quando estiverem “bulindo”. Tudo bem?

Informações importantes

  1. Como pré-requisito, vocês devem ter conhecimento básico sobre o uso de python, Pytorch, CNN, funções de ativação e base sobre processos de treinamento de modelos. Não precisa ser expert para entender o exemplo proposto aqui. Mesmo que você não conheça tudo que eu tenha utilizado, eu deixo os links de materiais para te dar uma base.
  2. Eu utilizei a mesma base de projeto que eu venho utilizando nos posts anteroiores (aqui). Também não focarei demasiadamente em teoria; a minha ideia aqui é desmistificar algumas caixas pretas do processo de criação de arquiteturas de detecção de objetos.
  3. As imagens utilizadas em nosso exemplo veem de um subset do dataset apples-bananas-oranges (https://www.kaggle.com/sriramr/apples-bananas-oranges). Por que um subset e não todo o dataset? A primeira coisa a saber é que nós iremos criar, neste momento, um detector de objetos únicos (single object per image). Deixo aqui a promessa de escrever um post com um detector de múltiplos objetos — esse já está na lista. Então, em nosso exemplo, eu removi as imagens com mais de um objeto/detecção.
  4. O formato das anotações é Darknet. Por que? Porque eu gosto dele. Nada contra os demais formatos, mas eu o acho simples e intuitivo. O que você precisa saber sobre o formato:

a) Para cada imagem existe um arquivo equivalente. Ex: imagem1.jpg, imagem1.txt

b) O arquivo de anotações (imagem1.txt, por exemplo) é formado por 1 ou n linhas que representam os bounding boxes (bbox), e a sua composicão é classe x y w h, sendo:

  • classe = a classe a qual o objeto pertence;
  • x e y = ponto central do bbox;
  • w e h = largura e altura do bbox;
  • Os valores de x, y, w, h são normalizados pela largura (W) e altura (H) da imagem.

Vocês não precisarão realizar as marcações/anotações das imagens do nosso exemplo. Porém, se um dia você vier a precisar de uma ferramenta para realizar anotações de imagens, eu aconselho a Labeling (https://github.com/tzutalin/labelImg). Ela é simples de utilizar e faz bem o que se propõe a fazer.

Um pouco de teoria

Eu falei que nós iremos implementar um detector de objetos do tipo Single-Shot Detection. Mas o que isso significa? As primeiras arquiteturas de detecção de objetos consistem em dois estágios distintos: a) uma rede para a localização de objetos (region proposal network) e um classificador para detectar os tipos de objetos nas regiões propostas.

Em termos computacionais, este tipo de arquitetura, mesmo tendo uma acurácia bastante relevante, se tornam inviáveis para aplicativos de tempo real devido à necessidade de recursos para o seu processamento.

Os modelos de disparo único (Single-Shot Detection) encapsulam as tarefas de localização e detecção em uma única varredura direta da rede, resultando em detecções significativamente mais rápidas ao serem implantados em hardware com menos recursos computacionais — ou não. As arquiteturas clássicas que eu aconselho fortemente que vocês estudem são: YOLO e SDD. O nosso exemplo é bem mais simples do que essas duas arquiteturas, porém não significa dizer que ela não possa resolver problemas do mundo real.

Vamos ao projeto?

Como eu falei, o nosso projeto tem a mesma estrutura dos projetos anteriores (de novo, cara??? Sim… porque eu explico muito coisa lá). No entanto, alguns recursos importantes foram adicionados. Vamos dar uma olhada em cada um.

Modelo

O nosso modelo se chama AdrianoNet. Como você deve ter percebido, eu tenho uma grande criatividade para nomes…

O backbone (ou feature extractor) de nossa arquitetura é composta por 4 camadas convolucionais (https://pytorch.org/docs/stable/generated/torch.nn.Conv2d.html), funções ReLu (https://pytorch.org/docs/stable/generated/torch.nn.ReLU.html), MaxPool2d (https://pytorch.org/docs/stable/generated/torch.nn.MaxPool2d.html) e uma função Dropout (https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html).

Como header da nossa arquitetura, nós temos uma estrutura Linear (https://pytorch.org/docs/stable/generated/torch.nn.Linear.html). Agora perceba uma coisa: ela tem um número MUITO grande como fator de entrada (in_features=1038336). Podemos diminuir este número parâmetros de entrada? Sim. Claro. Se adicionarmos mais camadas podemos reduzir bastante o valor de entrada — isso também pode ser um ótimo exercício para você. A segunda observação é sobre a saída (out_features=4+num_classes). A nossa saída será formada por 4 + o número de classes do nosso projeto. Os quatro primeiros valores da saída da nossa arquitetura são os nossos valores de predição de bbox. Os demais valores dependerão do número das classes. Legal, né?

AdrianoNet: nossa arquitetura de “brinquedo”.

Neste momento nós poderíamos utilizar duas cabeças (uma para a classificação e a outra para a regressão). Também funcionaria muito bem. Duas cabeças significa ter duas camadas do tipo Linear ao invés de uma apenas.

Em síntese o que vocês precisam sacar aqui é como “a mágica” acontece. Quando nós criamos os modelos de classificação, nós utilizamos apenas uma saída. Até aí é simples de compreender, mas em nosso exemplo atual é diferente! Percebam que nós utilizamos regressão para estimarmos os bbox. Se nós pararmos para pensar, podemos fazer uma analogia com os primeiros modelos clássicos que aprendemos para classificação e predição.

Eu não sei quais foram as primeiras teorias que vocês estudaram em ML, mas eu me lembro de ter estudado regressão logística (classificação) e regressão linear (regressão). Os dois conceitos aprendidos lá no início são vitais agora, mesmo conversando sobre conceitos bem mais complexos (deep learning e visão computacional). Perceberam?

Dataset

O nosso arquivo dataset se chama DatasetBase. Ele é bem similar aos utilizados nos exemplos dos posts anteriores. Duas funções do arquivo util.py são utilizados no construtor do DatasetBase: a) obtemDataFrame, cria um dataframe com base nos arquivos de um determinado diretório. b) obtemDataSetsDeTreinamentoEValidacao, faz a etapa de separação dos dados para treinamento e validação. Vale a pena dar uma olhada em ambas as funções.

Funcoes auxiliares para pré-processamento dos dados.
Classe dataset.

Trabalhando com os dados

Vocês já devem ter ouvido falar nas operações de transforms (https://pytorch.org/vision/stable/transforms.html). Em linhas gerais, nós utilizamos essas transformações para dois fatores principais: diversificar os nossos dados e aumentar número de possibilidades. Por exemplo: se eu tenho um dataset com 100 imagens e eu utilizo uma operação simples de espelhamento horizontal (flipping), nós duplicamos o nosso dataset. Então… vale a pena dar uma olhada nesse processo, ok? Eu não adicionei muitas funções nesta etapa. Mas você pode fazê-lo também! Inclusive, eu tenho uma biblioteca com algumas funções já implementadas e que você pode se espelhar. Se vocês podem ter acesso aqui.

Vocês devem ter visto o link que eu compartilhei sobre o transforms, correto? Eu poderia utilizar a implementação do PyTorch, mas eu resolvi implementar as minhas próprias operações de transformações. Mas não fiz isso para ficar mais díficil… eu fiz porque não estamos trabalhando apenas com imagens, nós também trabalharemos com as anotações e isso significa que: todas as operações que nós realizarmos sobre as imagens, nós deveremos replicar sobre as marcações.

Vocês podem me perguntar, então, como podemos espelhar essas operações sobre as imagens e sobre as suas marcações? Uma técnica bastante utilizada neste momento é a criação de uma imagem que chamaremos de máscara. Simplificando o processo, as partes internas do bbox serão preenchidas com a cor branca e a parte exterior será preenchida de preto, por exemplo. Então, no momento em que fizermos uma transformação na imagem real, nós também a realizaremos na imagem denominada de máscara. Depois realizaremos uma operação inversa; isso significa que, a partir da imagem máscara, extraímos os valores ajustados do bbox.

Funcão letterbox

Obs: Eu criei um arquivo chamado test.py para que vocês vejam isoladamente esta transformação. Se vocês o executar, vocês verão um resultado igual ao da Imagem 1. Vejam o objeto, o bbox e, ao lado, uma imagem da máscara criada. A área referente ao bbox foi preenchida com a cor branca e o resto da imagem com a cor preta.

Imagem 1: Exemplo de execução da função de criação de máscara.

Existe uma transformação muito importante para o nosso projeto e eu devo mencionar aqui. Ela se chama letterbox (https://en.wikipedia.org/wiki/Letterboxing_(filming)). De forma simplificada, esta operação redimensiona uma imagem mantendo as proporcionalidades e preenchendo os espações restantes com a cor branca (em nosso exemplo). A grande parte das arquiteturas são projetadas para receberem imagens com dimensões quadradas, tais como: 300x300, 416x416 etc. Em nosso exemplo, nós utilizaremos 416x416.

Eu criei uma função chamada de transformacao() que está no arquivo de transformacao.py. Perceba que ela é chamada dentro da função __getitem__() do dataset. Perceba, também, que as operações internas dessa função estão condicionadas ao self.transformacao, isso porque só devemos realizar transformações nas imagens quando estivermos realizando o treinamento do modelo. No momento da validação, nós não devemos realizar alterações nas imagens.

Funcão de transformacão de imagens.

Treinamento

A etapa de treinamento é bem similar aos exemplos de classificação que fizemos anteriormente — se você estiver sempre se perguntando sobre que exemplos são esses, então você não viu os primeiros exemplos e, neste caso, seria bem bacana dar uma passada lá.

Nós iremos utilizar a mesma estrutura de código dos exemplos passados. A única diferença aqui é que nós também devemos tratar dos bbox preditos/estimados e não só da classe da qual o objeto pertence. Além da função cross_entropy para a classificação, nós também utilizaremos a funcao l1_loss (https://pytorch.org/docs/stable/generated/torch.nn.L1Loss.html) para calcular o mean absolute error (MAE) entre os bbox preditos e os bbox reais (ground truth’s). Relacionando ambas as funções de perdas (cross_entropy e l1_loss) os pesos serão atualizados a cada época. Bem… eu acredito que você já tenha esse conceito bem ajustado em sua mente.

Para concluir a fase de treinamento, eu defini uma etapa de validação na qual, de acordo com a acurácia do momento (capacidade de classificar corretamente) e o menor valor da soma dos erros, o melhor modelo é selecionado. Existem outras formas mais interessantes aqui? Sim. Sem dúvida. Mas por agora este exemplo é mais do que suficiente, visto que o processo pode ser aprendido passo a passo.

Eu defini alguns parâmetros iniciais e vocês podem alterar para o que vocês acharem interessante. Na verdade, a minha maior preocupação aqui é tentar desmistificar um pouco a complexidade desses frameworks e modelos. A gente se depara com arquiteturas muito grandes mas, no fundo mesmo, o princípio é o mesmo.

Código completo: https://github.com/adrianosantospb/detector_classificaodor_do_zero/blob/main/train.py

Testando o modelo treinado

Para vocês testarem se o modelo está realmente classificando bem e se o bbox está sendo gerado de forma satisfatória (localizando bem o objeto), eu criei o script deteccao.py. Nele vocês encontrarão o passo a passo para utilizar o modelo que acabamos de treinar.

Mas o que acontece se eu defino uma imagem contendo dois objetos? O modelo vai criar um bbox e tentará cobrir os dois objetos. Na verdade, ele buscará um ponto médio entre ambos os objetos (Imagem 2). Isso porque o nosso modelo não foi projetado para múltiplas detecções em uma mesma imagem — este é o passo seguinte.

Imagem 2: Dois objetos detectados como um.

Considerações finais

Então, pessoal… espero que vocês tenham aprendido algo legal com esse post e com o código também. Fiquem livres para evoluir o código e a arquitetura também. Testem tudo! Quebrem e arrumem a bagunça que vocês venham a fazer. Isso tudo faz parte do processo de aprendizagem.

Estão na minha lista duas novas postagens: a primeira é a continuação dessa arquitetura aqui, porém para detecção de múltiplos objetos. A segunda é uma postagem sobre algoritmo de tracking; para ser mais exato, eu escreverei sobre Kalman Filter. Eu já codifiquei, agora falta escrever sobre. Eu acredito que vocês tenham noção o quão demorado é para criar o código e ainda escrever sobre o mesmo… Mas eu o farei, ok?

Bem… é isso! Grande abraço.

--

--

Adriano A. Santos

Senior Computer Vision Data Scientist at Conception Ro-Main (Quebec — CA). DSc in Computer Science. MTAC Brazil. https://github.com/adrianosantospb