Estudo de caso: Quebrando um captcha

Estudo de caso: Quebrando um captcha

Suponha que você queira usufruir de um determinado serviço num site de forma automatizada, porém, nele há um captcha. Captcha são aquelas imagens com letras/números distorcidos da qual você precisa copiar a fim de provar que você é humano. Assim, você precisará reconhece-lo e digita-lo de forma automatizada para poder usufruir do serviço. Neste presente estudo de caso de uma experiência que tive, focarei-me somente na parte de reconhecimento dos caracteres do captcha de um determinado site do governo brasileiro. Eu decidir usar as bibliotecas Tesseract e o OpenCV para esse trabalho, e a linguagem Python.

Esse manual explica sobre como instalar o OpenCV no Python. Instale o pacote pyslibtesseract para o Python poder usar o Tesseract usando o seguinte comando no terminal: sudo pip3 install pyslibtesseract

Entendo os padrões

No primeiro momento, eu tive que baixar vários exemplos do captcha para saber como eles eram gerados. Aqui estão alguns deles:

Imediatamente, já percebemos o seguinte:

Para carregar a imagem numa variável, uso

img_start = cv.imread(file_name)
height, width = img_start.shape[:2]

Ruídos

No primeiro momento, eu tive que me livrar dos ruídos. A princípio, eu pensei em usar vários desses filtros e morphological transformations diferentes para me livrar deles, porém, percebi que não compensava, pois combinar vários modificações na imagem acaba sequelando severamente as letras, tornando-as muito difíceis de serem reconhecíveis. Então percebi que bastava usar somente um único filtro para me livrar dos ruídos: o closing.

img = cv.morphologyEx(img, cv.MORPH_CLOSE, np.ones((3, 3), np.uint8))

Nesse meu caso, usar esse único filtro me fez obter um resultado muito melhor

Nesse momento, percebi que o closing faz com que aquelas retas quase que sumam, ficando bem transparente, enquanto as letras ficam um pouco sequeladas, com partes um pouco mais claras que as outras. É sabido que o Tesseract consegue reconhecer bem mais as letras em imagens binárias. Percebendo isso, eu decidir transformar transformar a imagem apenas em escala de cinza e depois fazer a seguinte regra: pixels claros serão brancos, e os demais serão pretos.

img = cv.cvtColor(img, cv.COLOR_BGR2GRAY)
img = cv.cvtColor(img, cv.COLOR_GRAY2BGR)

for i in img:
    for i2 in i:
        if (np.array([230, 230, 230]) >= i2).any():
            i2[...] = 0
        else:
            i2[...] = 255

Você provavelmente estranhou as duas primeiras linhas: porque estou fazendo a transformação BGR2GRAY e logo depois GRAY2BGR? Isso não daria no mesmo? A resposta é: não. Após a primeira transformação, as cores passarão a ser em escala de cinza, e na segunda transformação, elas permanecerão na escala de cinza, a matriz de cores voltará a ser BGR. Eu pessoalmente acho mais fácil de trabalhar com a matriz de cores BGR do que com a matriz de cores GRAY.

Ótimo! Agora, em geral, já conseguimos uma imagem bem mais limpa! Assim, podemos facilmente mandar o tesseract lê-la! De fato, como no segundo exemplo mostrado acima, o "N" ficou colado com o "O", aparentando serem uma única letra, porém, ignoraremos isso por enquanto.

Começando a tesseraczar

Vendo um pouco as configurações do Tesseract, usando o comando tesseract -h, vejo que há um parâmetro chamado pagesegmode, da qual me possibilita escolher a forma em que lerá a imagem. Nesse momento, decidir fazer com que lesse uma única linha, pois, humanamente, eu vejo que há somente uma única linha. E assim fiz isso, usando o comando no terminal tesseract 'foo.jpg' foo -psm 7.

Então, em imagens como

obtive A SWTQ. Pareceu promissor! Porém, em imagens como

captchat-pb

obtenho 991R .c

Mas, vendo as imagens que baixei anteriormente, vejo que só há letras maiúsculas, nunca outros caracteres, então pesquisando mais as configurações do Tesseract, descubro que há como limitar o alfabeto que ele usa, usando a variável tessedit_char_whitelist. Desse modo, decido tesseraczar essa última imagem inserindo no terminal tesseract 'foo.jpg' foo -psm 7 -c tessedit_char_whitelist="QWERTYUIOPASDFGHJKLZXCVBNM" e então obtive QUE C. Obtive um resultado melhor, porém, ainda não satisfatório.

Revendo as configurações do Tesseract, vejo outro argumento para o parâmetro pagesegmode: "Treat the image as a single character". Então, decido que agora preciso isolar cada letra para lê-la individualmente.

Lendo as letras isoladamente

Pesquisando bastante sobre o OpenCV, cheguei à essa excelente série de manuais da qual explica sobre contornos e assim consigo obter imagens como:

im2, contours, hierarchy = cv.findContours(cv.cvtColor(img, cv.COLOR_BGR2GRAY), cv.RETR_TREE, cv.CHAIN_APPROX_SIMPLE)

for i2 in contours:
    cv.drawContours(img, [i2], -1, (0, 255, 0), 1)

Com os contornos, consigo dividir a imagem em fronteiras de acordo com a diferença de cores. Porém, tive um problema: as vezes, há pequenos ruídos na imagem, normalmente formado quando duas retas se cruzam e formam alguns pixeis mais nítidos que posteriormente ficam pretos, ou em casos como letras A e Q.

Repare que nas letras "A" e "Q" há dois contornos: na parte externa e na parte interna. O certo seria só ter o contorno externo. Outro problema é: como salvarei cada letra isoladamente, com base no contorno dela?

A solução que encontrei para ambos problemas foi o comando minEnclosingCircle, que recebe como parâmetro os contornos. Com ele, eu consigo transformar determinada uma série fechada de contornos em um círculo com o menor raio necessário. Desse modo, após alguns testes e chutando alguns números, percebi que ignorar círculos com raios menores que 16 era promissor, pois assim eu terei apenas os contornos externos das letras e ignorarei os ruídos. Também decidir ignorar círculos com raio maior que 30, pois seriam duas letras coladas, o que o Tesseract não conseguiria ler.

Agora faltava o problema em como salvar em arquivos separados o círculo formado pela letra. Passei um tempão tentando descobrir como salvar um ROI (region of intesser) circular, até que decidir salvar o círculo como se fosse um quadrado. Só tive que tomar uns cuidados, pois tinha momentos que parte do círculo ficava fora da área da imagem, o que obviamente daria problema na hora de salva, então tive que impor uns limites.

Percebi que precisava salvar um pouco além da área do círculo mínimo e pintar de branco as bordas da imagem, pois as vezes pegava um pequeno pedaço da letra vizinha.

Pronto! Agora estou com cada arquivo com letra separadamente! Está jambalaio!

###
# isolar cada letra
letters = []
img_circulada = copy.copy(img)
for i2 in contours:
    (x, y), radius = cv.minEnclosingCircle(i2)

    if radius > 16 and radius < 30:
        print(radius)
        center = (int(x), int(y))

        radius_int = int(radius + 15)

        x_min = x - radius_int
        y_min = y - radius_int
        if x_min < 0:
            x_min = 0
        if y_min < 0:
            y_min = 0
        letters.append((x, img[y_min : y + radius_int, x_min : x + radius_int]))
        cv.circle(img_circulada, center, radius_int + 1, (0,255,0), 1)

steps.append(img_circulada, 0)

letters_out = []
if len(letters) > 0:
    # ler as letras isoladas
    loop = 0
    text = ''
    for i in letters:
        current_letter = i[1]

        # Salvar para tesseraczar
        cv.imwrite('letter' + str(loop) + '.png', current_letter)

        # Pegar o valor ASCII da letra
        new_char = pyslibtesseract.LibTesseract.simple_read(tesseract_config, 'letter' + str(loop) + '.png')[0]
        if len(new_char) and new_char[0] != ' ':
            text += new_char[0]
        else:
            text += '?'

        loop += 1
        letters_out.append((i[0], new_char[0]))

letters_out = sorted(letters_out)
letters_only = [i[1] for i in letters_out]

Repare em letters.append((x, img[y_min : y + radius_int, x_min : x + radius_int])). Quando executo o primeiro loop, usando a variável contours, ele não itera dos contornos da esquerda para a direita, mas sim numa ordem bagunçada. Isso deixaria nossa resposta fora de ordem. Por conta disso, dou append na lista letters uma tupla de dois elementos, sendo o primeiro a posição X da letra e o segundo elemento da tupla seus pixeis na imagem. Ao final, uso o sorted. O sorted do Python é fantástico: ele vê que na lista há tuplas, e vai ordenando usando como parâmetro o primeiro elemento de cada tupla. Esse exemplo esclarece o que aconteceu:

>>> foo = [(2, 'bar'), (1, 'foo'), (3, 'baz')]
>>> sorted(foo)
[(1, 'foo'), (2, 'bar'), (3, 'baz')]

É importante que os pixeis da imagem sejam o segundo elemento da tupla, pois causaria erro se eles fossem o primeiro, pois não saberia como ordena-los.

Lendo as imagens o Tesseract, conseguir obter uma boa taxa de sucesso, como em

e retornou ASWTQ, o que é correto.

Porém, ainda não fiquei satisfeito, pois em casos como

retornou PBDDV

E também em

retorna apenas I XT

O problema é que as letras ficam levemente curvadas, atrapalhando a leitura do Tesseract. A princípio, pensei em tentar descobrir o ângulo em que a letra estava curvada, porém, isso seria extremamente difícil de se implementar. Então decidir girar a imagem da letra um pouco, de -X para +X graus graus e escolher o caractere com o melhor valor de confiança do Tesseract. Porém, até então a único biblioteca de Tesseract do Python que até então existia, o pytesseract, não me possibilita saber a confiança em que ele tem a respeito da leitura da letra, apesar do Tesseract em si possibilitar.

Na ausência de uma biblioteca de Tesseract para Python que me forneça o que eu gostaria, tive que desenvolver a minha, na qual chamei de pyslibtesseract. Nele, eu posso obter uma lista das letras em que o Tesseract acha que é e as respectivas confianças em cada uma delas, por exemplo

>>> for pyslibtesseract import *
>>> config_single_char = TesseractConfig(psm=PageSegMode.PSM_SINGLE_CHAR)
>>> LibTesseract.read_and_get_confidence_char(config_single_char, 'char.png')
[('E', 58.27500915527344), ('Y', 56.93630599975586), ('F', 56.4453125), ('T', 51.12168884277344), ('Q', 47.19916534423828), ('W', 46.1181640625), ('V', 45.31656265258789), ('G', 43.49636459350586)]

A princípio, decidir girar a letra em -50 graus e +50 graus, porém, percebi isso não era bom, por duas razões: demorar de mais em obter o resultado e  há casos como, por exemplo, no grau 20 tem a resposta certa com confiança 80, porém no grau 50, tem confiança de 90 numa letra errada.

Reparando nisso, decidir limitar para somente -20 e +20 graus e, somente se nesse intervalo não conseguisse uma boa confiança numa determinada letra, ou ficasse com um intervalo muito curto de confiança entre uma letra e outra, aí sim iria para o -50 e +50

Histogramar?

Está ficando perfeito! Porém, em captcha como

há duas letras coladas uma na outra, diferenciando na coloração. Em busca de melhorar mais ainda a taxa de sucesso, decidir separar a imagem em duas: onde fosse colorido, e onde fosse apenas tons de cinza (estou tratando aqui preto apenas como mais um dos tons de cinza).

Assim nessa imagem resolveria o caso, porém não em captcha como

Mesmo ciente que isso não resolveria meu problema em todos os casos do gênero, decidir começar a implementar, porém, percebi que isso só piorava a minha taxa de acerto, pois aquelas retas fragmentavam as letras cinzas, cortando-as, pois a parte onde a reta colorida passou sobre a letra a letra ficava branco.

Eu teria que implementar uma lógica muito mais bem bolada para resolver esse problema, e teria que histogramar para conhecer o espectro de cores do captcha em que realmente precisaria dividir a imagem nas cores corretas. Teria que tomar alguma medida para o caso das retas fragmentarem as letras, como expliquei no parágrafo acima.

Como o captcha tem uma quantidade de letras fixas, 5, eu não preciso me preocupar em sempre conseguir isolar e acertar todas. Caso eu veja que eu separei uma quantidade de letras diferente de 5, então basta eu da F5 na página e tentar quebrar o novo captcha mostrado. Desse modo, decidir simplificar e apenas descarto os captcha com letras coladas.

Código final

Aqui você pode baixar uma amostra com 31 captchas para testar o código abaixo. Ele tem alguns detalhes a mais que não descrevi aqui na postagem, como a classe Steps, da qual me ajuda na debugação do código. Tem uns partes ou outra no código que podem ser melhor escrita, porém, já fiquei de saco cheio dele e como funcionou, deixei assim mesmo. Sinta-se livre para melhorar =)

Caso você queria saber mais sobre o assunto, aqui encontra-se os slides da minha apresentação no XII Pylestras abordando sobre captcha e esse estudo de caso.