6.4 Segmentação

Nesse post vamos discutir um pouco sobre modelar CAPTCHAs. Vou assumir que você já viu o post de introdução e o post sobre download, leitura e classificação manual de CAPTCHAs.

Digamos que você tenha uma base de dados de treino composta por \(N\) imagens com os textos classificados. Nossa resposta nesse caso é uma palavra de \(k\) caracteres (vamos considerar \(k\) fixado), sendo que cada caractere \(c\) pode ter \(p\) valores.

O problema de modelar o CAPTCHA diretamente é que a variável resposta tem um número exponencial de combinações de acordo com o número de caracteres:

\[ \Omega = p^k. \]

Por exemplo, um CAPTCHA com \(k=6\) e \(p=36\) (26 letras e 10 números), que é muito comum, possui um total de 2.176.782.336 (> 2 bilhões) combinações! E não preciso dizer que é completamente inviável baixar e modelar tudo isso de CAPTCHAs.

A alternativa imediata que aparece é tentar separar a imagem em um pedaço para cada caractere e fazer um modelo para prever caracteres. Assim nossa resposta é reduzida para \(p\) categorias, que é bem mais fácil de tratar.

Vamos usar como exemplo o CAPTCHA do TJMG. Primeiro, o download:

arq_captcha <- decryptr::download_captcha("tjmg", n = 1, path = 'imgs/captcha')

Visualizando a imagem:

library(decryptr)
arq_captcha <- "imgs/captcha/captcha4c9f411d0391.jpeg"
arq_captcha  %>% 
  read_captcha() %>% 
  plot()
CAPTCHA do TJMG.

Figura 6.5: CAPTCHA do TJMG.

Exercício:

  1. Sem separar as letras, quantas combinações temos no TJMG?

Infelizmente, segmentar a imagem nos lugares corretos é uma tarefa difícil. Pior até do que predizer as letras. Para simplificar, vamos fazer um corte fixado das letras:

arq_captcha %>% read_captcha() %>% plot()
abline(v = 26 + 20 * 0:3, col = 'red')
Linhas verticais separando letras

Figura 6.6: Linhas verticais separando letras

Podemos também limitar os eixos x (tirar os espaços vazios à esquerda e à direita) e y (superiores e inferiores).

op <- graphics::par(mar = rep(0, 4))
arq_captcha %>% 
  read_captcha() %>% 
  dplyr::first() %>% 
  with(x) %>% 
  magrittr::extract(-c(1:7, 34:dim(.)[1]), -c(1:06, 107:dim(.)[2]), TRUE) %>%
  grDevices::as.raster() %>% 
  graphics::plot()
abline(v = 20 * 1:4, col = 'red')
abline(h = c(0, 26), col = 'blue')

Agora temos uma imagem de tamanho dimensões 26x20 por caractere. Nosso próximo desafio é transformar isso em algo tratável por modelos de regressão. Para isso, colocamos cada pixel em uma coluna da nossa base de dados.

No caso do TRT, cada CAPTCHA gerará uma tabela de 6 linhas e 520 (26 * 20) colunas. Podemos usar esse código para montar:

arq_captcha %>% 
  read_captcha() %>% 
  dplyr::first() %>% 
  with(x) %>% 
  magrittr::extract(-c(1:7, 34:dim(.)[1]), -c(1:06, 107:dim(.)[2]), TRUE) %>%
  as_tibble() %>% 
  rownames_to_column('y') %>% 
  gather(x, value, -y) %>% 
  mutate_at(vars(x, y), funs(parse_number)) %>% 
  mutate(letra = (x - 1) %/% 20 + 1,
         x = x - (letra - 1) * 20) %>% 
  mutate_at(vars(x, y), funs(sprintf('%02d', .))) %>% 
  unite(xy, x, y) %>% 
  spread(xy, value, sep = '') %>% 
  mutate(y = c('1', '0', '0', '1', '7')) %>% 
  select(y, everything(), -letra)
p <- progress::progress_bar$new(total = 1500)
dados_segment <- 'data-raw/captcha' %>% 
  dir(full.names = TRUE, pattern = '_') %>% 
  map_dfr(~{
    
    p$tick()
    
    # pega a resposta dos arquivos
    words <- .x %>% 
      basename() %>% 
      tools::file_path_sans_ext() %>% 
      stringr::str_match("_([0-9]+)$") %>% 
      magrittr::extract(TRUE, 2) %>% 
      stringr::str_split('', simplify = TRUE) %>% 
      as.character()
    
    # carrega o bd do arquivo (codigo anterior)
    .x %>% 
      read_captcha() %>% 
      dplyr::first() %>% 
      with(x) %>% 
      magrittr::extract(-c(1:7, 34:dim(.)[1]), -c(1:06, 107:dim(.)[2]), TRUE) %>%
      as_tibble() %>% 
      rownames_to_column('y') %>% 
      gather(x, value, -y) %>% 
      mutate_at(vars(x, y), funs(parse_number)) %>% 
      mutate(letra = (x - 1) %/% 20 + 1,
             x = x - (letra - 1) * 20) %>% 
      mutate_at(vars(x, y), funs(sprintf('%02d', .))) %>% 
      unite(xy, x, y) %>% 
      spread(xy, value, sep = '') %>% 
      mutate(y = words) %>% 
      select(y, everything(), -letra)
  }, .id = 'captcha_id')

saveRDS(dados_segment, 'data/dados_segment.rds', compress = 'bzip2')

Muito bem! Agora basta rodar o mesmo para toda a base de treino e rodar um modelo. Vamos usar uma base de 1500 CAPTCHAs classificados. Essa base fica com 7500 linhas e 520 colunas. Vamos usar 6000 linhas para treino e as 1500 restantes para teste. O modelo utilizado será um randomForest padrão.

library(randomForest)
dados <- readRDS('data/dados_segment.rds') %>% 
  mutate(y = factor(y))

# monta bases de treino e teste
set.seed(4747) # reprodutibilidade
ids_treino <- sample(seq_len(nrow(dados)), 6000, replace = FALSE)
d_train <- dados[ids_treino, ]
d_test <- dados[-ids_treino, ]
model_rf <- randomForest(y ~ . - captcha_id, data = d_train) 
# saveRDS(model_rf, 'data/model_segment_rf.rds', compress = 'bzip2')
model_rf <- readRDS('data/model_segment_rf.rds')

O resultado do modelo pode ser verificado na tabela de observados versus preditos na base de teste. O acerto foi de 99.5% em cada caractere! Assumindo que o erro não depende da posição do caractere no CAPTCHA, teremos um acerto de aproximadamente 97.5% para a imagem.

d_test %>% 
  mutate(pred = predict(model_rf, newdata = .)) %>% 
  count(y, pred) %>% 
  spread(pred, n, fill = '.') %>% 
  remove_rownames() %>% 
  knitr::kable(caption = 'Tabela de acertos e erros.')
y 0 1 2 3 4 5 6 7 8 9
0 156 . . . . . . . . .
1 . 160 . . . . . . . .
2 . . 147 . . . . . . .
3 . . 1 140 . . . . . .
4 . 2 . . 150 . . . . .
5 . . . . . 153 . . . .
6 . . . . . . 143 . . .
7 . . . . . . . 152 . .
8 . . . 2 1 . . . 139 .
9 . . . . . . . . . 154

6.4.1 Nem tudo são rosas

O resultado para o CAPTCHA do TJMG é bastante satisfatório, mas infelizmente não generaliza para outros CAPTCHAs. Tome por exemplo o CAPTCHA da Receita Federal abaixo. Nesse caso, a posição dos caracteres muda significativamente de imagem para imagem, e assim fica difícil cortar em pedaços.

dir('imgs/receita', full.names = TRUE) %>% 
  purrr::walk(~plot(magick::image_read(.x)))

O mesmo modelo aplicado ao CAPTCHA da Receita possui acerto de 78.8% do caractere, o que equivale a apenas 23.8% de acerto para toda a imagem. Veja os resultados na tabela abaixo.

6.4.2 Exercício

  1. Por quê você acha que o modelo da receita é pior?
  2. O que você faria para melhorar o poder preditivo?

Claro que seria possível melhorar o poder preditivo com uma modelagem mais cuidadosa: nós usamos todos os parâmetros padrão da randomForest e não consideramos outros possíveis modelos. Mas acreditamos que o problema essencial está na segmentação, e não na modelagem após a segmentação.

Nos próximos posts, vamos mostrar como resolver o CAPTCHA da Receita com maior acurácia utilizando técnicas de Deep Learning que consideram a etapa de segmentação dentro da modelagem.

6.4.3 Wrap-up

  • Não dá para considerar todas as combinações de valores de um CAPTCHA diretamente num modelo de regressão.
  • Uma forma de resolver um CAPTCHA é segmentando a imagem em pedaços de mesma largura.
  • Para montar a base de treino, criamos uma coluna para cada pixel. Um CAPTCHA corresponde a uma base com \(k\) linhas e número de colunas igual ao número de pixels.
  • No CAPTCHA do TJMG os resultados são satisfatórios.
  • Já para CAPTCHA da Receita essa estratégia pode ser ineficaz.
  • Vamos evoluir essa análise para técnicas que consideram a etapa de segmentação dentro da modelagem.