Skip to content

Commit

Permalink
identificação de ordem do texto corrido com origem em colunas
Browse files Browse the repository at this point in the history
  • Loading branch information
luizanisio committed Feb 26, 2023
1 parent a1e6c74 commit ba8de3e
Show file tree
Hide file tree
Showing 12 changed files with 185 additions and 22 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,5 @@ app/ocr_saida_img/
app/tokens/
app/exemplos2/
app/config.json
*- Copia*.py
*- Copia*.py
testes/
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
- O objetivo é analisar qualidade, performance e regiões identificadas pelo Tesseract para permitir a criação de regras ou treinamento de modelos para identificar regiões como Citações, Estampas laterais, Cabeçalho e Rodapé. A identificação pode ser feita por regras simples, como margens em páginas padronizadas (A4, Carta, Legal etc). E também pode ser identificado por repetições de textos em áreas específicas, como cabeçalhos e rodapés.

## O que está disponível
- Foi criado um serviço flask que recebe imagens ou PDFs e processa eles em batch, permitindo acompanhar a fila de tarefas e visualizar os arquivos da extração ou baixar uma versão Markdown ou PDF da análise realizada.
- Foi criado um serviço flask que recebe imagens ou PDFs e processa eles em batch, permitindo acompanhar a fila de tarefas e visualizar os arquivos da extração (html) ou baixar uma versão Markdown ou PDF da análise realizada.
- As regiões estão sendo identificadas por posicionamento (estampas e citações) ou repetição e posicionamento (cabeçalhos e rodapés).
- A tela apresenta o motivo da identificação da região
- A tela apresenta o motivo da identificação do tipo da região
- Pode-se filtrar o retorno, removendo regiões não desejadas
- O arquivo `config.json` contém configurações do serviço como o nome das pastas, DPIs para as análises, número de workers, dentre outros. Caso não exista, ele será criado com o padrão de cada configuração.
- O campo `token` do serviço é usado para listar as tarefas do usuário, podendo ser digitado livremente ou será criado ao enviar um arquivo a primeira vez. A ideia é o usuário enviar vários arquivos no mesmo token. O usuário precisa dele para acompanhar as tarefas enviadas. Não é garantida a segurança com esse token, apenas restringe um pouco o livre acesso às tarefas entre usuários pois é só uma poc.
Expand Down Expand Up @@ -51,8 +51,8 @@
```

## TODO
- análise de colunas (está identificando incorretamente como citação)
- apresentação da análise feita nas imagens enviadas para o Tesseract
- em `util_ocr.py` tem um exemplo funcional, falta apresentar no serviço
- exportação de trechos para fine tunning do Tesseract
- acionamento por api para uso em outros projetos
- criação de componente para reaproveitamento
Expand Down
2 changes: 2 additions & 0 deletions app/app_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def processar_envio_arquivo(self, token, exemplo_ou_request,
'tipo' : 'pdf',
'nome_real_pdf': nome_real,
'tipo_real_pdf': tipo_real,
'finalizado_pdf' : False,
'inicio_pdf': Util.data_hora_str(),
'tamanho_inicial_pdf' : round(os.path.getsize(arquivo_entrada)/1024,2)}
print(f'Processar PDF "{arquivo_entrada}" >> "{destino}"')
Expand All @@ -101,6 +102,7 @@ def processar_envio_arquivo(self, token, exemplo_ou_request,
'tipo' : 'img',
'nome_real_img': nome_real,
'tipo_real_img': tipo_real,
'finalizado_img' : False,
'inicio_img': Util.data_hora_str(),
'tamanho_inicial_img' : round(os.path.getsize(arquivo_entrada)/1024,2)}
print(f'Processar IMG "{arquivo_entrada}" >> "{destino}"')
Expand Down
Binary file added app/exemplos/Exemplo texto colunas.docx
Binary file not shown.
Binary file added app/exemplos/Exemplo texto colunas.pdf
Binary file not shown.
3 changes: 2 additions & 1 deletion app/servico.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ def frm_visualizar_arquivo():
res = controller.processar_envio_arquivo(token = token,
exemplo_ou_request= exemplo or request,
gerar_img = gerar_img,
gerar_pdf = gerar_pdf )
gerar_pdf = gerar_pdf,
ignorar_cache = ignorar_cache )
listar = True

elif id_arquivo and atualizar and status.get('finalizado_img'):
Expand Down
6 changes: 4 additions & 2 deletions app/util_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ def box_2_html(box):
html = '\n'.join( Util.unir_paragrafos_quebrados(str(box['texto']).split('\n')) )
return html

def arquivo_aimg_2_html(arquivo_aimg):
def arquivo_aimg_2_html(arquivo_aimg, reanalisar = False):
if not arquivo_aimg:
return
arquivo_html = os.path.splitext(arquivo_aimg)[0] + '.html'
Expand Down Expand Up @@ -204,7 +204,9 @@ def arquivo_aimg_2_html(arquivo_aimg):

arquivo = 'testes-extração.json'
arquivo ='Artigo Seleção por consequências B F Skinner.json'
arquivo = './temp/Exemplo texto colunas.json'

arquivo_aimg_2_html(f'{arquivo}')

arquivo_aimg_2_html(f'./temp/{arquivo}')

print('Finalizado')
3 changes: 2 additions & 1 deletion app/util_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ def arquivo_aimg_2_md(arquivo_aimg):

arquivo = 'testes-extração.json'
arquivo ='Artigo Seleção por consequências B F Skinner.json'
arquivo = './temp/Exemplo texto colunas.json'

arquivo_aimg_2_md(f'./temp/{arquivo}')
arquivo_aimg_2_md(f'{arquivo}')

print('Finalizado')
168 changes: 156 additions & 12 deletions app/util_ocr.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from copy import deepcopy
import re
from PIL import Image
import os

class AnaliseImagensOCR():
CONF_LIMITE = 30
Expand All @@ -42,6 +43,7 @@ class AnaliseImagensOCR():
MAX_PALAVRAS_FOLHAS = 5

RE_FOLHA = re.compile('[0-9]')
TIPOS_NAO_MIOLO = ['C','R','F','E']

def __init__(self, img, file_2_grayscale = True, linguagem='por', armazenar_imagem = True):
self.file_2_grayscale = file_2_grayscale
Expand Down Expand Up @@ -131,6 +133,7 @@ def incluir_dados(dados_incluir):
if _bloco != _bloco_o:
#if _pagina != _pagina_o:
# self.__pagina__ += 1
# a páigna é contada por chamada à função processar_img_ocr
_pagina_o = _pagina
_bloco_o = _bloco
_parag_linha_o = ''
Expand All @@ -155,13 +158,16 @@ def incluir_dados(dados_incluir):
dados['texto'] += f' {_texto}{ql}'

incluir_dados(dados)
self.__pagina__ += 1
#self.__pagina__ += 1
### indica que precisa enriquecer os dados de análise novamente,
### pois leva em conta todos os objetos
self.__enriquecidos__ = False

def dados(self):
if not self.__enriquecidos__:
def carregar_dados(self, dados):
self.__load_dados__(dados)

def dados(self, reanalisar = False):
if reanalisar or (not self.__enriquecidos__):
self.__enriquecer_dados__()
return self.__dados__

Expand Down Expand Up @@ -206,7 +212,7 @@ def __enriquecer_dados__(self):
box['tipo_sugerido'] = ''
# margens laterais, superior e inferior
# margens_edsi - margem até o box mais próximo ou a página
for p in paginas:
for pagina in paginas:
linhas_h[pagina].sort()
linhas_v[pagina].sort()
#print('linhas: ', linhas_h)
Expand Down Expand Up @@ -249,10 +255,14 @@ def __enriquecer_dados__(self):
elif y >= ph - ph * margens.MARGEM_RODAPE:
box['bordas'].append('I')
box['ordem_extra'] = 3
# vai dar preferência para o y na ordenação,
# mas levando em conta o x por conta das colunas ou erro. O x = largura da página soma linha fonte de altura
box['ordem_y'] = box['box_xyla'][1] + (box['alt_linhas'] * box['box_xyla'][0] / pw)

# ordena pela página, depois pela posição y e depois pela posição x
# box nas margens direita ou esquerda ficam no final
self.__dados__.sort(key = lambda box:(box['pagina'], box['ordem_extra'], box['box_xyla'][1], box['box_xyla'][0]))
self.__dados__.sort(key = lambda box:(box['pagina'], box['ordem_extra'], box['ordem_y']))

# análise do tipo e ajustes do número do box dentro da página pela ordenação
nbox = 0
for i, box in enumerate(self.__dados__):
Expand All @@ -265,7 +275,20 @@ def __enriquecer_dados__(self):
# análise dos tipos - leva em consideração a posição do box
# - precisa ocorrer depois da ordenação
self.__analisar_tipos__(box, margens)
# análise de petição de textos para identificação de cabeçalhos e rodapés

# finaliza a correção da ordenação pelos tipos encontrados para encontrar ordem em colunas
# precisa dos tipos para corrigir - se houve correção, ajusta os ids
if self.corrige_posicionamento_colunas():
nbox = 0
for i, box in enumerate(self.__dados__):
if pagina != box['pagina']:
nbox = 0
pagina = box['pagina']
box['box'] = nbox
box['id'] = i
nbox += 1

# análise de repetição de textos para identificação de cabeçalhos e rodapés
# leva em consideração os tipos já encontrados
self.__analisar_repeticoes__()
# ajuste final - limpeza do que não é necessário
Expand All @@ -278,6 +301,7 @@ def __enriquecer_dados__(self):

self.__paginas__ = pagina + 1
self.__pagina__ = pagina

self.__enriquecidos__ = True
#print('Enriquecimento concluído...')

Expand Down Expand Up @@ -382,9 +406,11 @@ def __titulo_citacao__(self, box, margens):
box['tipo_sugerido'] = 'Proporção e margem'
# o box tem margem de citação e a margem direita é menor que a esquerda
# pois pode ser um título centralizado
# e não tem um box do lado esquerdo
elif (x / pw >= margens.MARGEM_CITACAO) \
and (pw - x - w < x * 0.8) \
and box['qtd_linhas'] >= 1:
and box['qtd_linhas'] >= 1 \
and not self.__existe_box_esquerda__(box):
box['tipo'] = 'CT'
box['tipo_sugerido'] = 'Margem'
# o box tem uma linha e a fonte é maior que a média da linhas * 1.15
Expand Down Expand Up @@ -465,6 +491,26 @@ def __box_proximo__(self, box1, box2, distancia):
return False
return True

def __existe_box_esquerda__(self, box):
''' verifica se tem um box do lado esquerdo do box informado que não seja estampa para não indicá-lo como citação '''
x1, y1, w1, h1 = box['box_xyla']
for outro in self.__dados__:
# se o outro estiver na borda, ignora
if outro['box'] == box['box'] or any(outro['bordas']):
continue
x2, y2, w2, h2 = outro['box_xyla']
# caixa 2 não está à esquerda
if x2 + w2 > x1:
continue
# a caixa dois está acima
if y2 + h2 < y1:
continue
# a caixa dois está abaixo
if y2 > y1 + h1:
continue
return outro
return None

def __box_diferenca_termos__(self, box1, box2, diferenca):
''' diferença de até n termos '''
if len(box1['palavras'] ^ box2['palavras']) > diferenca:
Expand All @@ -478,6 +524,97 @@ def __box_diferenca_termos__(self, box1, box2, diferenca):
return (len(d) / len(d1+d2+d)) <= distancia
'''

def corrige_posicionamento_colunas(self):
if len(self.__dados__) <= 2:
return False
# reordenação para ajuste de colunas
# a ideia é percorrer na ordem do último sort e verificar se vai para o próximo pela ordem posicional
# ou se quebra a ordem indo para o que está abaixo (a ordem posicional é x e depois y)
alterado = False
novos = []
disponiveis = self.__dados__
novos.append(disponiveis.pop(0))
while len(disponiveis) > 0:
# a próxima caixa é a próxima mesmo (anteriores[0])? ou busca a de baixo pelo eixo y na mesma página?
anterior = novos[-1]
#print(f'Próximo para: {anterior["texto"][:30]}')
abaixo = self.__buscar_i_box_coluna_abaixo__(anterior, disponiveis)
if abaixo >= 0:
novos.append(disponiveis.pop(abaixo))
#print(f'- sugerido: {novos[-1]["texto"][:30]}')
alterado = True
else:
novos.append(disponiveis.pop(0))
#print(f'- natural: {novos[-1]["texto"][:30]}')
self.__dados__ = novos
return alterado

def __buscar_i_box_coluna_abaixo__(self, atual, disponiveis):
if len(disponiveis) <= 1:
return -1
x1, y1, w1, h1 = atual['box_xyla']
# se a próxima não estiver ao lado direito, não tem o que analisar
# a ideia é descobrir se a próxima sendo ao lado direito a de baixo é a melhor opção
# por ser uma continuação da coluna
# se a próxima for cabeçalho, rodapé, estampa, etc, não tem o que analisar também pois a ordenação
# principal já cuidou disso
proxima = disponiveis[0]
if (proxima['pagina'] != atual['pagina']) or \
proxima['tipo'] in self.TIPOS_NAO_MIOLO or \
atual['tipo'] in self.TIPOS_NAO_MIOLO:
# a próxima está em outra página ou não faz parte do miolo de texto, segue a ordenação normal
#print('Saiu a', proxima['id'])
return -1
x2, y2, w2, h2 = proxima['box_xyla']
if x2 < (x1 + w1) * 1.05:
# a próxima não está ao lado, então segue o fluxo da ordenação
#print('Saiu b', proxima['id'])
return -1
# a próxima está no lado direito? Identifica a margem máxima para a de baixo ser aceita
# como próxima no lugar dela
margem = x2 * .95
# busca se tem outro box na página abaixo do atual para continuar a coluna
# considera na margem se começar perto do x1 (5%) e terminar antes do box lateral (5%)
# se encontrar um box atravessado (maior que a largura ou além da largura, interrompe a busca)
# se encontrar um dentro da largura, é ele
# se encontrar nos lados, ignora pois são outras colunas ou estampas
for i, box in enumerate(disponiveis):
if i ==0:
# leva em conta o i, mas ignora pois já foi tratado como próximo
continue
if box['pagina'] != atual['pagina']:
#print('Saiu c', box['id'])
return -1
if box['bordas'] in self.TIPOS_NAO_MIOLO:
continue
x2, y2, w2, h2 = box['box_xyla']
# a caixa está além da margem analisada
if x2 > margem:
continue
# a caixa está aquém do início x da atual (pode ser um título indicando fim dessa coluna,
# prioriza terminar na coluna ao lado)
if x2 + w2 < x1 or x2 < x1 * .95:
# vai usar o fluxo normal que é a lateral
#print('Saiu 1', box['id'])
return -1
# a caixa 2 está atravessada antes
if x2 < x1 and x2 + w2 > margem:
# vai usar o fluxo normal que é a lateral
#print('Saiu 2', box['id'])
return -1
# a caixa 2 está atravessada depois
if x1 < margem and x2 + w2 > margem:
# vai usar o fluxo normal que é a lateral
#print('Saiu 3', box['id'])
return -1
# a caixa 2 está na margem da caixa 1
# indica que é continuação da caixa anterior como coluna
if x2 >= x1 * .95 and x2 + w2 <= margem:
return i
return -1



''' Recebe as dimensões da página e
busca o melhor padrão de página para as margens
'''
Expand Down Expand Up @@ -545,8 +682,12 @@ def __init__(self, pagina_w, pagina_h):
import os
import json

DPIs = 400

arquivo = ''
arquivo_padrao = './exemplos/testes-extração.png'
arquivo_padrao = './exemplos/teste12345.pdf'
arquivo_padrao = './exemplos/Exemplo texto colunas.pdf'
for i, arg in enumerate(sys.argv[1:]):
print(f"- {arg}")
if os.path.isfile(arg):
Expand All @@ -561,22 +702,25 @@ def __init__(self, pagina_w, pagina_h):
print(f'Arquivo não encontrado: {arquivo}')
exit()


if str(arquivo).lower().endswith('.pdf'):
print('Não implementado')
exit()
#imagem = cv2.imread(arq)
aimg = AnaliseImagensOCR(arquivo, file_2_grayscale = True)
from util_pdf_ocr import imagens_pdf
entrada = imagens_pdf(arquivo, dpi = DPIs )
aimg = AnaliseImagensOCR(entrada, file_2_grayscale = True)
else:
aimg = AnaliseImagensOCR(arquivo, file_2_grayscale = True)
pasta, arquivo_nm = os.path.split(arquivo)
arquivo_nm, _ = os.path.splitext(arquivo_nm)
pasta = './temp/'
os.makedirs(pasta, exist_ok=True)

arquivo_json = os.path.join(pasta, f'{arquivo_nm}.json')
arquivo_imagem = os.path.join(pasta, f'{arquivo_nm}_ocr_?.png')

with open(arquivo_json, 'w', encoding='utf8') as f:
f.write(json.dumps(aimg.dados(), indent=2, ensure_ascii = True))

print('Lista de páginas: ', aimg.paginas())
#print('Lista de páginas: ', aimg.paginas())
for p in aimg.paginas():
print(f'Analisando página {p}', end='')
img = aimg.imagem_pagina(p, True, True)
Expand Down
2 changes: 1 addition & 1 deletion app/util_pdf_ocr.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from util import Util
import numpy as np

def imagens_pdf(arquivo_entrada='', dpi = 300, np_array = True):
def imagens_pdf(arquivo_entrada='', dpi = 400, np_array = True):
# extrair as imagens do PDF
imagens = convert_from_path(arquivo_entrada, dpi=dpi)
if np_array:
Expand Down
2 changes: 1 addition & 1 deletion app/util_processar_pasta.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def __init__(self, iniciar = True):
# configurações básicas do serviço
# o arquivo é criado se não existir
CONFIG_PADRAO = {"gerar_md" : True, "gerar_html" : True,
"resolucao_img" : 300,
"resolucao_img" : 400,
"resolucao_pdf" : 300,
"n_workers" : -1, "max_workers" : 0,
"entrada" : "./ocr_entrada",
Expand Down
12 changes: 12 additions & 0 deletions dicas_tesseract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Algumas dicas para otimização do tesseract

- As dicas ainda estão em teste para posteriormente serem incorporadas aos exemplos e/ou serviço.

- Incluindo termos da sua área de domínio para melhorar o reconhecimento de palavras próprias dessa área:
- tesseract --user-words FILE

### Referências
- [Arquivos de configuração](https://github.com/tesseract-ocr/tesseract/blob/main/doc/tesseract.1.asc#config-files-and-augmenting-with-user-data)
- [pytesseract config](https://stackoverflow.com/questions/44619077/pytesseract-ocr-multiple-config-options)
- [Tratamento da imagem - Placa de carro](https://www.geeksforgeeks.org/license-plate-recognition-with-opencv-and-tesseract-ocr/)

0 comments on commit ba8de3e

Please sign in to comment.