Optical Mark Recognition (OMR) com Python e OpenCV: O Guia Definitivo
OpenCV é uma das mais populares e amigáveis libraries de Visão Computacional em tempo real. Nesse artigo, vamos aprender a implementar o OpenCV com Python voltado para Optical Mark Recognition (reconhecimento de marcações). Existem diversas aplicações de OMR no dia-a-dia, como: correção de provas, leitura de comandas e uma variedade de aplicações para a indústria.
Repositório no GitHub
Veja o código fonte completo e materiais de apoio no repositório do projeto
Resumo
- Recomendações de Hardware
- Ambiente virtual
- Instalando o pacote
- Explicando o método
Recomendações de Hardware
Primeiramente, é muito importante capturar as imagens com qualidade. Eu já gastei horas tentando fazer soluções de visão computacional funcionar com câmeras ruins. Então, o primeiro passo é usar o hardware correto para construir um software confiável e preciso. Minha recomendação pessoal é a câmera Logitech C922 ou um modelo similar da Logitech.
Ambiente Virtual
Uma boa prática ao trabalhar com um projeto em Python é a criação de um ambiente virtual. Esse passo é opcional, mas recomendado. O ambiente virtual, ou ainda venv, é um diretório independente que possui sua própria instalação do Python e pacotes necessários para o projeto. Isso garante que, independentemente da máquina usada, o projeto será executado com as versões corretas.
Para criar um ambiente virtual, navegue até o diretório do projeto e use o comando:
python3 -m venv nome-do-ambiente
Para ativar o ambiente virtual, use os comandos:
Windows:
nome-do-ambiente\Scripts\activate.batMac/Linux:
source nome-do-ambiente/bin/activate
Você pode ler mais sobre ambientes virtuais aqui.
Instalando o pacote
Com o ambiente virtual ativado, o OpenCV para Python pode ser instalado usando o PyPI:
pip install opencv-python
Caso tenha optado por não usar o ambiente virtual, o pacote será instalado no diretório padrão da instalação do Python da sua máquina.
Explicando o método
O método desse artigo é composto por 6 etapas:
- Capturar a imagem
- Detectar os contornos
- Corrigir a perspectiva
- Identificar as lacunas
- Identificar as marcações
- Processar os dados
1. Capturar a imagem
O primeiro passo é capturar a imagem. Vamos adotar a abordagem não simultânea, isto é, sem um feed de vídeo ao vivo. Assim, vamos capturar apenas um frame e processá-lo. Ao final, vamos aprender a converter a solução para tempo real.
Gabarito
Usaremos esse gabarito simples para ilustrar o princípio. Você pode baixá-lo no repositório do projeto.
Capturando imagens
Começamos importando o OpenCv e o NumPy, os pacotes que serão usados no projeto. Em seguida, definimos algumas constantes: Largura, Altura, Questões e Alternativas.
Nesse caso, temos 5 questões (A, B, C, D e E) e cada questão possui 5 alternativas.
Definimos a função capturaImagem( ), inicializamos a câmera e definimos os parâmetros para a captura. Por fim, verificamos se a câmera foi iniciada corretamente, giramos a imagem no sentido anti-horário e retornamos a imagem.
A função main
Para executar nossas funções, utilizaremos a função main.
Por enquanto, capturamos a imagem usando nossa função capturaImagem( ) e a mostramos em uma janela usando o método imshow(). O método waitKey() aguarda que uma tecla seja pressionada para seguir. O parâmetro 0 faz com que o programa aguarde indefinidamente, mas se um parâmetro n , maior do que 0, for passado, o programa aguardará por n milissegundos que uma tecla seja pressionada.
2. Detectar os contornos
detectaContornos()
Primeiramente, convertemos a imagem para preto e branco. Isso é necessário para que possamos aplicar o limite (threshold) e converter a imagem para binária. Em seguida, aplicamos um desfoque gaussiano. Isso serve para eliminar ruídos da imagem e tornar o processamento mais preciso.
Aplicamos o limite e, em seguida, uma dilatação, para tornar as linhas na imagem mais evidentes. Depois, usamos o método findContours() para encontrar todos os contornos na imagem.
Por fim, fazemos uma cópia da imagem original para que possamos desenhar todos os contornos. Retornamos o array de contornos, a imagem com os contornos desenhados e a imagem binária, com o limite aplicado.
encontraMaiorContorno()
Com todos os contornos detectados, precisamos encontrar o maior contorno, que será as bordas do documento. Usaremos as bordas para corrigir a perspectiva da imagem em seguida.
A função main
3. Corrigir a perspectiva
corrigePerspectiva()
Calculamos o perímetro do maior contorno usando o método arcLength() e, em seguida, reduzimos o número de vértices do contorno usando o método approxPolyDP(). Isso serve para remover possíveis imperfeições no contorno, torná-lo mais uniforme e ter certeza de que será um contorno fechado.
Depois desses procedimentos, precisamos garantir que os pontos estão na ordem correta para formar um retângulo. Para isso, utilizamos a nossa função auxiliar reordenarPontos().
Por fim, usamos os pontos e os métodos getPerspectiveTransform() e warpPerspective() para corrigir a perspectiva da imagem. Usamos um fator de correção para corrigir um possível alongamento da imagem. Esse fator precisa ser alterado de acordo com a sua câmera e o formato do documento.
A função main
4. Identificar as lacunas
Antes de identificar as lacunas, vamos cortar somente a área das lacunas a partir da imagem com a perspectiva corrigida. Isso será feito na função main. Após isso, usaremos a função de detectar contornos novamente, para identificarmos as lacunas.
identificaLacunas()
Aqui a lógica pode parecer um pouco mais difícil, mas garanto que é simples. Percorremos os contornos identificados e verificamos as proporções do contorno, isto é, a razão entre a largura e a altura do contorno. Queremos que essa proporção esteja dentro de um certo intervalo (nesse caso, entre 0.9 e 1.1). Com essa proporção, estamos procurando por contornos que sejam aproximadamente quadrados. Aumentar a tolerância nesse intervalo vai aumentar a tolerância a marcações que saiam das margens das lacunas, mas pode inserir erros na leitura.
Além disso, filtramos os contornos de acordo com o tamanho. Nesse caso, usamos um tamanho mínimo e um tamanho máximo. Esse tamanho irá variar de acordo com a câmera, tamanho da imagem e conformação do gabarito que você estiver usando. Então você vai precisar experimentar um pouco por aqui.
Finalmente, desenhamos as lacunas que foram identificadas.
A função main
5. Identificar as marcações
Depois de identificar as lacunas, precisamos ordená-las para que os contornos estejam alinhados com as lacunas da imagem. Para isso, usamos a função auxiliar ordenarLacunas(). As funções de ordenação nesse artigo foram criadas pelo blog pyimagesearch.
Primeiro vamos ordenar as lacunas de cima para baixo e depois, em cada linha, da esquerda para a direita.
identificaMarcacoes()
Essa função percorre as lacunas, linha por linha. Em cada linha, fazemos a ordenação da esquerda para a direita. Depois, de maneira opcional, colorimos as linhas para ter certeza de que as lacunas correspondem às questões da imagem.
Percorrendo cada linha das lacunas, analisamos cada uma das lacunas para contar o número de pixels não nulos. Nesse caso, pixels não nulos são aqueles em preto. Com a quantidade de cada lacuna medida, podemos usar técnicas de estatística para determinar quais estão marcadas e quais estão em branco. Nesse projeto, utilizamos a técnica de identificações de outliers através do desvio padrão, que funciona da seguinte maneira:
- Calculamos a média de pixels não nulos de todas as lacunas
- Calculamos o desvio padrão dos valores das lacunas
- Definimos um limite para que a lacuna seja um outlier (marcação). Um limite consagrado na estatística é dado pela média + 3 * desvio padrão. Nesse caso usamos um valor um pouco menor. Novamente, você vai precisar fazer experimentos aqui.
Por fim, para cada lacuna que ultrapassa o limite estipulado, desenhamos a marcação na imagem.
A função main
6. Processar os dados
Finalmente, podemos processar os dados obtidos. Essa etapa vai depender da solução que você está desenvolvendo. Por exemplo, caso você esteja corrigindo uma prova, como nesse exemplo, podemos armazenar as marcações em um array e compará-lo com um array pre-definido de respostas. Dessa forma, teremos as notas da prova.
No meu projeto do Leitor de Comandas, a situação foi um pouco diferente, pois é esperado que haja uma ou mais marcações por linha, visto que representam produtos. Nesse caso, eu resolvi retornar um array com o total de marcações por linha.
Transformando a solução para tempo real
Uma modificação simples no código permite que a solução seja adaptada para um feed ao vivo:
Um loop usando while e try/except executará a lógica que elaboramos de maneira constante.
Muito obrigado por ter chegado até aqui! Espero ter ajudado com um pouco de conhecimento sobre OpenCV e OMR. Fico à disposição para tirar dúvidas!
Um abraço e bom aprendizado!