Computer Vision
Como excluir artefatos indesejados numa imagem?
O intuito desse post é compartilhar uma solução simples que resolvou um problema complexo que estávamos enfrentando em de nossos produtos aqui na Studos, a Leitora de Gabaritos. Dessa forma, outros que estejam com problemas similares possam talvez ter um ponto de vista diferente na resolução do problema.
O que é a leitora de gabaritos?
Dando um pouco de contexto sobre o produto, a Leitora de Gabaritos é uma API desenvolvida utilizando (principalmente) a seguinte stack:
- FastAPI
- Celery
- ReportLab
- OpenCV
- RabbitMQ
- Redis
- Docker
- Kubernets
Essa API é responsável por automatizar a criação e a leitura de cartões respostas. Utilizando ativamente o OpenCV nas etapas de leitura.
Algumas features do ponto de vista técnico da leitora, envolvem:
- Extração do qrcode da imagem
- Rotação de imagens em diversas orientações
- Identificação de shapes
- Recorte
- Extração de segmentos de leitura
- Extração das marcações
Esses segmentos, no nosso caso, são as regiões: das marcações dos itens, do código de matrícula, das questões optativas e do modelo de prova. As regiões amarelas da imagem abaixo representa melhor os segmentos.
Problema
O problema que estávamos enfrentando era justamente na leitura desses segmentos. Mais especificamente, na etapa extração das marcações, onde é necessário que todas as marcações sejam extraídas sem trazer nenhum resíduo do gabarito.
É nesse ponto que nos deparamos com a complexidade do problema, pois a quantidade de variáveis envolvidas no processo é absurda, e nem sempre conseguíamos fazer a melhor separação possível, impactando na ponta e gerando insatisfação do usuário. Essa variabilidade de imagens, conta com:
…imagens de qualquer faixa de DPI, imagens coloridas, escala de cinza e preto e branco, canetas pretas e azuis, pouca luz, sombra e ruídos ao longo da imagem, imagens tiradas com foto, imagens digitalizadas com diferentes scanners…
O ponto específico onde o OpenCV entra para realizar a transformação que precisamos é, mostrado abaixo no código:
img_black_and_white = cv.threshold(
img_warp_gray, thresh_param, 255, cv.THRESH_BINARY)[1]
img_threshold = cv.threshold(img_black_and_white, 215, 350, cv.THRESH_BINARY_INV)[1]
Veja que, como input para a função cv.threshold
, precisamos fornecer uma imagem em escala de cinza e um parâmetro de threshold que será utilizado como um ponto de corte. É justamente esse threshold que precisava ser otimizado.
Antes dessa solução, realizávamos dois checks: a imagem é colorida, ou escala de cinza? e a imagem tem caneta azul ou preta?. Com base nos outputs, ajustávamos o threshold de acordo.
Porém, tal ajuste não funcionava 100% das vezes, e algumas imagens estavam com a leitura impossibilitada justamente por não conseguir ajustar o threshold. Veja abaixo um caso onde o ajuste errado do threshold leva a uma leitura incorreta.
Com a aplicação desse threshold, poucas marcações seriam de fato lidas pela aplicação.
Solução
Sendo assim, tivemos a ideia de buscar por um padrão entre as diferentes imagens que populavam nosso banco. Coletamos 200 imagens das mais diversas possível e começamos olhando para a distribuição de pixels. Nosso objetivo nesse primeiro momento era conseguir definir uma hipótese mais acertiva de como abordar o problema de forma mais generalizável possível.
Essa é a distribuição dos pixels para o segmento do cartão apresentado acima. Quanto mais valores em direção ao 255, mais marcado está esse cartão. Valores tendendo ao 0, estão relacionados ao preto.
Distribuição de pixels
Ao analisar a distribuição de pixels, vimos que as imagens similares possuem distribuições de pixels bem características. E que todas possuem uma calda longa a esquerda com um ponto onde a distribuição começa a “subir” (aumento no valor de pixel).
- Imagem digitalizada em preto e branco
- Imagem em escala de cinza
- Imagem colorida de alto dpi
- Imagem colorida de baixo dpi
Esse ponto de inflexão da curva nos chamou a atenção, por ser uma região que estava próxima do threshold que já vínhamos utilizando. Verificamos visualmente para todas as imagens e o padrão se repetiu.
Dae em diante conseguimos definir melhor nossa abordagem e o que de fato gostaríamos de coletar das distribuições: “Como identificar automaticamente esse ponto de inflexão da distribuição de pixel, para qualquer distribuição?"
Método utilizado
Antes de chegar na técnica que resolveu o problema, algumas outras foram testadas, algumas mais complexas e outras mais simples, mas nenhuma generalizou do jeito que precisávamos.
Nossa hipótese aqui foi a seguinte:
Considerando que a maior parte da distribuição, com pixels maiores, correspondem as marcações, a zona mais escura (região da calda longa da distribuição) corresponde ao gabarito. Dessa forma, se considerarmos uma porcentagem e eliminarmos parte da calda, seria possível saber onde está o ponto ótimo de corte.
Para melhor visualizar essa abordagem, criamos um ECDF das distribuições dos pixels e traçamos uma linha horizontal na porcentagem de pontos que gostaríamos de remover da distribuição.
Dae em diante, para testar se realmente esse método funcionava, o que precisávamos fazer era encontrar os pontos de intersercção da reta na horizontal com a distribuição, coletar o máximo e retornar como ponto de corte. A implementação está no código abaixo.
def get_param(img_warp_gray):
# x and y for the ecdf plot
x = np.sort(img_warp_gray.ravel())
y = np.arange(1, len(x) + 1) / len(x)
# ecdf dataframe
ecdf_plot = pd.DataFrame({"img": x, "prop": y})
# get intersection values on 5% of the distribution
thresh_params = ecdf_plot[round(ecdf_plot["prop"], 2) == 0.05]["img"].values
thresh_params_max = max(thresh_params)
return thresh_params_max
# running
def main():
img = cv.imread(f"src/files/snippets/pic_7.png")
img_warp_gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY)
try:
thresh_param = get_param(img_warp_gray)
img_threshold = image_transformation(thresh_param, img)
except ValueError:
thresh_param = 0
img_threshold = image_transformation(thresh_param, img)
# plotting the ecdf, 0.05 horizontal line, image before and image after the
# transformation
plt.subplot(3, 1, 1)
_ = sns.ecdfplot(x=img_warp_gray.ravel())
plt.hlines(xmin=0, xmax=255, y=0.05)
plt.subplot(3, 1, 2)
plt.imshow(img_warp_gray, cmap="gray")
plt.subplot(3, 1, 3)
plt.imshow(img_threshold, cmap="gray")
plt.show()
if __name__ == "__main__":
main()
Espantosamente, nessa linha de 5%, foi justamente a região de inflexão da distribuição. E aplicando o parâmetro que a função get_param()
retornou, o resultado da transformação foi o seguinte:
Após encontrar esse pontos, rodamos o script para as 200 imagens que estávamos utilizando como teste e o resulado foi 100% de remoção do gabarito das marcações.
Dessa forma, agora conseguimos identificar as marcações, em qualquer tipo de imagem, qualidade e cor de caneta marcada, sem propagar para as próximas etapas de leitura os ruídos do cartão.
Caso queira conferir o código e as imagens testes, elas estão nesse repositório.