Thank you. Your work too.
In fact, I am just experimenting (pseudo debug stage) some OCR tools, most part for handwritten text recognition (HTR).
I am experimenting too. I am working with obituaries in A4 format like this, so my work seems easier than yours with handwritten text. But this only an starting point. My intention is to be able to extract names from documents and to compare them with database in Gramps.
I am using AI as an assistant. I want to learn Python. Sometimes I use vibe coding, it will be useless to me if I can not understand the code. I used it too to discuss the requirements of the project and maybe the AI was too optimistic about the future.
I was able to give a try on your Fios project
Fios is not my first project for scanning obituaries. I had another private project that I have not published yet. So tesseract and pytesseract were legacy dependencies and the first options recommended by ChatGPT, when I used it
This is ocr_renamer. And sorry, I also code in my mom’s language. I my case, it is a mixture of Galician and Spanish. It is the vice of being born this way 
#!/usr/bin/env python3
import argparse
import subprocess
import os
import sys
import re
import curses
import glob
from curses import textpad
# Intenta importar PIL
try:
from PIL import Image
except ImportError:
print("""
PIL non está instalado. Necesitarás instalalo para poder procesar as imaxes.
Instálao en Ubuntu:
sudo apt install python3-PIL
""")
sys.exit(1)
# Definición da función de reconocemento de imaxes en función do módulo dispoñible
def extraer_texto(ruta_imaxe, porcentaxe = 100, escaneo_vertical = False):
global recordatorio
imaxe = Image.open(ruta_imaxe)
ancho, alto = imaxe.size
# valorando se a imaxe é un recordatorio polo seu tamaño
if ancho <= max_ancho and alto <= max_alto:
recordatorio = ' recordatorio'
else :
recordatorio = ''
# calculando area a procesar para reducir:
# - tempos de procesado
# - xeración de opcións innecesarias
if porcentaxe == 100 or porcentaxe == -100:
area = False
elif escaneo_vertical and porcentaxe > 0:
area = (0, 0, int(ancho * porcentaxe / 100), alto)
elif escaneo_vertical and porcentaxe < 0:
area = (int(ancho * porcentaxe / 100), 0, ancho, alto)
elif porcentaxe > 0:
area = (0, 0, ancho, int(alto * porcentaxe / 100))
elif porcentaxe < 0:
area = (0, int(alto * porcentaxe / 100), ancho, alto)
else:
print('Valor de porcentaxe de imaxe non válido. Ignorado.\n')
area = False
if area:
imaxe = imaxe.crop(area)
# Converter a imaxe a escala de grises
# Mellora a lexibilidade do texto
imaxe = imaxe.convert('L')
# OCR
# Selección da fonte de datos do motor
#'eng', 'spa', 'glg', 'glg+spa'
idioma = lingua
# selección do motor
if OCR_ENGINE == 'tesserocr':
texto = tesserocr.image_to_text(imaxe, lang=idioma)
else:
texto = pytesseract.image_to_string(imaxe, lang=idioma)
return texto.strip()
def extraer_nome_e_apelidos(texto, ignorar_maiusculas = False):
# Lista de prefixos a ignorar (Don, Dona, D., Dª., etc.)
prefixos_excluidos = ['Don', 'Dona', 'Doña', 'D\\.', 'Dª', 'Dª\\.', 'Da', 'Do', 'Sr', 'Sr.', 'Sra', 'Sra.']
# Expresión regular para nomes compostos, evitando prefixos
patron_nome = r"\b(?!(?:" + "|".join(prefixos_excluidos) + r")\b)([A-ZÁÉÍÓÚÑ][a-záéíóúñ]+(?: [A-ZÁÉÍÓÚÑ][a-záéíóúñ]+)+)"
# Expresión regular para buscar un patrón de "Nome Apelido"
# patron = r"([A-ZÁÉÍÓÚÑ][a-záéíóúñ]+(?: [A-ZÁÉÍÓÚÑ][a-záéíóúñ]+)+)"
flags = re.IGNORECASE if ignorar_maiusculas else 0
# Buscar todos os posibles nomes e apelidos no texto
coincidencias = re.findall(patron_nome, texto, flags)
# Normalizar: primeira letra en maiúscula, resto en minúscula
coincidencias_normalizadas = [
" ".join([p[0].upper() + p[1:].lower() if len(p) > 1 else p.upper()
for p in nome.split()])
for nome in coincidencias
]
# Detectar a posible aparición das palabras clave para un cabodano
patron_cabodano = r"((?:\bprimeiro\b\s*)cabodano|(?:\bprimer\b\s*)aniversario)"
cabodano = " cabodano" if re.search(patron_cabodano, texto, re.IGNORECASE) else ""
# Devolvemos as coincidencias e se se detectou que é un cabodano
return coincidencias_normalizadas, cabodano
def mostrar_menu(stdscr):
global recordatorio
# Configurar codificación Unicode para evitar problemas con acentos
curses.curs_set(1)
stdscr.encoding = "utf-8" # Configurar UTF-8 en `curses`
texto_extraido = extraer_texto(camiño_completo, 50)
if not texto_extraido:
texto_extraido = extraer_texto(camiño_completo, -50)
coincidencias, cabodano = extraer_nome_e_apelidos(texto_extraido)
prefixo = 'ESQ '
cadea = False
while True:
# Limpar a pantalla
stdscr.clear()
stdscr.addstr(f"Texto extraído de\n{camiño_completo}\n")
# Mostrar menú
stdscr.addstr("\nPulsa unha tecla e selecciona o nome de arquivo a usar:\n")
opcions = []
if not coincidencias:
stdscr.addstr("\n\tSEN COINCIDENCIAS DETECTADAS\n\n")
else:
# Mostra as 3 primeiras coincidencias
# pode haber máis, pero o nome do defunto soe estar arriba
for i, coincidencia in enumerate(coincidencias[:3]):
#coincidencia = coincidencia.title()
stdscr.addstr(f"\t{i + 1}. '{prefixo}{coincidencia}{cabodano}{recordatorio}{extension}'\n")
# Gardamos os códigos ASCII das opcións
opcions.append(i + ord('1'))
stdscr.addstr("\n\tm. Escoller e editar a opción máis aproximada.\n")
stdscr.addstr("\to. Abrir o arquivo no visor predeterminado.\n")
stdscr.addstr("\tv. Escanear de novo o arquivo, primeiro a metade esquerda.\n")
stdscr.addstr("\tr. Reintentar ignorando maiúsculas\n")
stdscr.addstr("\tn. Omitir e continuar.\n")
stdscr.addstr("\te. Saír\n")
stdscr.refresh()
# Esperar a pulsación dunha tecla
tecla = stdscr.getch()
if tecla == ord('m'):
stdscr.addstr("Escolle a opción máis aproximada\n")
stdscr.addstr("ou pulsa 'b' para voltar ao menú anterior:\n")
stdscr.refresh()
tecla = stdscr.getch()
if tecla in opcions:
index = tecla - ord('1')
cadea = f'{prefixo}{coincidencias[index]}{cabodano}{recordatorio}{extension}'
cadea = editar_texto(stdscr, cadea) # Chamar á edición de texto
elif tecla == ord('b'):
continue
elif tecla in opcions:
index = tecla - ord('1')
cadea = f'{prefixo}{coincidencias[index]}{cabodano}{recordatorio}{extension}'
elif tecla == ord('r'):
coincidencias, cabodano = extraer_nome_e_apelidos(texto_extraido, ignorar_maiusculas = True)
elif tecla == ord('v'):
texto_extraido = extraer_texto(camiño_completo, 50, escaneo_vertical = True)
if not texto_extraido:
texto_extraido = extraer_texto(camiño_completo, -50, escaneo_vertical = True)
coincidencias, cabodano = extraer_nome_e_apelidos(texto_extraido)
continue
elif tecla == ord('o'):
stdscr.addstr(f'Abrindo ruta {camiño_completo}\n')
abrir_imaxe(camiño_completo)
continue
elif tecla == ord('n'):
stdscr.clear()
stdscr.addstr('Procesando imaxe...')
return
elif tecla == ord('e'):
# Acción para saír
sys.exit(0)
else:
# Mensaxe de erro se a tecla non é válida
stdscr.clear()
stdscr.addstr("Tecla non válida, por favor, selecciona unha opción válida.\n")
stdscr.refresh()
stdscr.getch() # Esperar á entrada dunha tecla
# validamos cadea en cada iteración
if cadea:
if renomear_arquivo(stdscr, cadea):
break
else:
continue
def editar_texto(stdscr, cadea):
# función para modificar o texto coa opción m
stdscr.clear()
stdscr.addstr("Modifica o nome a continuación (incluída a extensión) e preme ENTER:\n")
stdscr.addstr("(Se queres cancelar, preme ESC)\n")
stdscr.refresh()
alto, ancho = 1, max(len(cadea) + 10, 70)
win = curses.newwin(alto, ancho, 2, 2)
win.addstr(0, 0, cadea)
stdscr.refresh()
box = textpad.Textbox(win)
def validar_tecla(ch):
if ch == 27: # ESC
raise KeyboardInterrupt
return ch
try:
novo_texto = box.edit(validate=validar_tecla).strip()
return novo_texto if novo_texto else cadea
except KeyboardInterrupt:
return False # Se ESC, devolvemos o nome orixinal
def renomear_arquivo(stdscr, novo_nome):
# Recibe o nome que se lle quere poñer e modifica o arquivo
stdscr.clear()
#novo_camiño = os.path.join(os.path.dirname(camiño_completo), novo_nome)
# veriiica se existe un arquivo co mesmo nome
contador = 1
nome_base, ext = os.path.splitext(novo_nome)
if os.path.exists(novo_nome):
stdscr.addstr("\nO ARQUIVO XA EXISTE!\n\nModificouse a cadea.\n")
while os.path.exists(novo_nome):
novo_nome = f"{nome_base}_{contador}{ext}"
#novo_camiño = os.path.join(camiño_completo, novo_nome)
contador += 1
stdscr.addstr(f"Vaise renomear {camiño_completo}\n")
stdscr.addstr(f"por {novo_nome}\n")
stdscr.addstr("\nPreme ENTER para confirmar a acción.\n")
stdscr.addstr("(Se queres cancelar, preme ESC)\n")
stdscr.refresh()
while True:
tecla = stdscr.getch()
if tecla == 10: # tecla ENTER
try:
os.rename(camiño_completo, novo_nome)
stdscr.addstr(f"\nFicheiro renomeado a: {novo_nome}\n")
return True
except Exception as e:
stdscr.addstr(f"\nErro ao renomear o ficheiro: {e}\n")
elif tecla == 27: # tecla ESC
return False
def abrir_imaxe(ruta_imaxe):
try:
if sys.platform.startswith('linux'):
subprocess.run(['xdg-open', ruta_imaxe], check=True)
elif sys.platform.startswith('darwin'): # macOS
subprocess.run(['open', ruta_imaxe], check=True)
elif sys.platform.startswith('win'):
os.startfile(ruta_imaxe) # Windows
else:
print("Sistema operativo non soportado para abrir imaxes.")
except Exception as e:
print(f"Erro ao abrir a imaxe: {e}")
def procesar_ruta(ruta):
# python pasa como parámetro o nome do executable
global camiño_completo, nome_arquivo, extension
if len(ruta) > 0:
for arquivo in ruta[0:]:
print(arquivo)
if os.path.isfile(arquivo):
camiño_completo = arquivo
nome_arquivo, extension = os.path.splitext(camiño_completo)
procesar_arquivo()
else:
print(f"{ruta}\nA ruta non é válida.")
def procesar_arquivo():
curses.wrapper(mostrar_menu)
###
camiño_completo = nome_arquivo = extension = recordatorio = ''
escaneo_vertical = False
max_ancho = 2100
max_alto = 2000
lingua = 'spa+glg'
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Uso: python script.py <ruta_do_arquivo_ou_directorio>")
sys.exit(1)
# --- Aquí vai o argparse ---
parser = argparse.ArgumentParser(
description="Script para extraer texto de imaxes e renomear ficheiros baseado no contido OCR."
)
parser.add_argument("ruta", nargs="+", help="Ruta do ficheiro ou directorio a procesar.")
parser.add_argument("-p", "--porcentaxe", type=int, default=100,
help="Porcentaxe da imaxe a analizar (positivo: parte superior, negativo: parte inferior).")
parser.add_argument("-l", "--lingua", type=str, default="spa+glg",
help="Lingua usada para o motor OCR (ex.: eng, spa, glg, spa+glg). Debe estar instalada.")
parser.add_argument("-v", "--ver", action="store_true",
help="Abrir a imaxe co visor predeterminado.")
parser.add_argument("--engine", choices=["tesserocr", "pytesseract", "auto"],
default="auto", help="Escolle o motor OCR a empregar.")
args = parser.parse_args()
# --- Fin argparse ---
# Selección do motor OCR segundo argumento
if args.engine == "tesserocr":
try:
import tesserocr
OCR_ENGINE = 'tesserocr'
except ImportError:
print("Erro: non está dispoñible tesserocr.")
sys.exit(1)
elif args.engine == "pytesseract":
try:
import pytesseract
OCR_ENGINE = 'pytesseract'
except ImportError:
print("Erro: non está dispoñible pytesseract.")
sys.exit(1)
else: # auto
try:
import tesserocr
OCR_ENGINE = 'tesserocr'
except ImportError:
try:
import pytesseract
OCR_ENGINE = 'pytesseract'
except ImportError:
print("""
Non se atopou un motor de recoñecemento.
Instálao en Ubuntu con:
sudo apt install python3-tesserocr
ou:
sudo apt install python3-pytesseract
""")
sys.exit(1)
print(f"Usando motor OCR: {OCR_ENGINE}")
# Gardar lingua seleccionada en variable global
lingua = args.lingua
# Pasar argumentos á función de procesado
procesar_ruta(args.ruta)
I used it to scan obituary documents and suggest and confirm name changes for jpg files. It saves me time when I do batch scanning of obituaries from physical copies.
$ python3 ocr-renamer <path to file, use with wildcard>
ocr_renamer can also suggest and add tags to several types of obituaries:
ESQ José Deogracias Franco Vidal [cabodano] [recordatorio].jpg
Where “cabodano” is for anniversary obituaries and “recordatorio” is for small cards.
And here is why I need Fios and ocr_renamer. +1000 files only with obituaries.