feat: Agregar soporte OCR con Tesseract para PDFs escaneados

- Integrar Tesseract OCR para leer PDFs escaneados automáticamente
- Detectar automáticamente si el PDF tiene texto o requiere OCR
- Agregar servicio ocr_service.py con funciones de OCR
- Actualizar Dockerfile con tesseract-ocr, tesseract-ocr-spa y poppler-utils
- Agregar variables de configuración OCR (OCR_ENABLED, OCR_LANGUAGE, OCR_DPI, OCR_TIMEOUT)
- Crear endpoint de debug para ver texto extraído (/api/v1/debug/extract-text)
- Agregar scripts de instalación y prueba (install_ocr.ps1, test_ocr.py, debug_pdf.ps1)
- Documentación completa (OCR_SETUP.md, DOCKER_OCR.md, COMO_PROBAR.md)
- Actualizar docker-compose.yml con variables de entorno OCR
- Modificar pdf_text.py para usar OCR cuando sea necesario
- Actualizar requirements.txt con pytesseract, Pillow, pdf2image
This commit is contained in:
Ernesto Herrera
2026-03-04 08:21:41 -07:00
parent 068d859f42
commit fcc516c9b3
18 changed files with 1694 additions and 14 deletions

197
app/services/ocr_service.py Normal file
View File

@@ -0,0 +1,197 @@
"""OCR service using Tesseract for scanned PDFs."""
import pytesseract
from pdf2image import convert_from_bytes
from PIL import Image
import logging
from typing import Tuple, List, Optional
from io import BytesIO
logger = logging.getLogger(__name__)
class OCRError(Exception):
"""Custom exception for OCR processing errors."""
pass
def is_pdf_scanned(text: str, min_char_threshold: int = 50) -> bool:
"""
Determine if a PDF is scanned (no selectable text) or has text.
Args:
text: Extracted text from PDF
min_char_threshold: Minimum characters to consider as "has text"
Returns:
True if PDF appears to be scanned (no text), False otherwise
"""
# Remove whitespace and count actual characters
cleaned_text = text.replace(" ", "").replace("\n", "").replace("\t", "")
if len(cleaned_text) < min_char_threshold:
logger.info(f"PDF appears to be scanned (only {len(cleaned_text)} characters found)")
return True
logger.info(f"PDF has selectable text ({len(cleaned_text)} characters)")
return False
def extract_text_with_ocr(
pdf_bytes: bytes,
lang: str = "spa",
dpi: int = 300,
timeout: int = 300
) -> Tuple[str, int]:
"""
Extract text from scanned PDF using Tesseract OCR.
Args:
pdf_bytes: PDF file content as bytes
lang: Language for OCR (default: "spa" for Spanish, use "eng" for English)
dpi: DPI for image conversion (higher = better quality but slower)
timeout: Maximum time in seconds for OCR processing
Returns:
Tuple of (extracted_text, page_count)
Raises:
OCRError: If OCR processing fails
"""
try:
logger.info(f"Starting OCR extraction with language={lang}, dpi={dpi}")
# Convert PDF to images
images = convert_from_bytes(
pdf_bytes,
dpi=dpi,
fmt='png',
thread_count=2,
timeout=timeout
)
page_count = len(images)
logger.info(f"Converted PDF to {page_count} images")
# Extract text from each page
text_parts = []
for i, image in enumerate(images, 1):
try:
# Perform OCR on the image
page_text = pytesseract.image_to_string(
image,
lang=lang,
timeout=timeout // page_count # Distribute timeout across pages
)
if page_text.strip():
text_parts.append(f"--- Página {i} ---\n{page_text}")
logger.debug(f"Page {i}: Extracted {len(page_text)} characters")
else:
logger.warning(f"Page {i}: No text extracted")
text_parts.append(f"--- Página {i} ---\n[Sin texto detectado]\n")
except Exception as e:
logger.error(f"OCR failed on page {i}: {str(e)}")
text_parts.append(f"--- Página {i} ---\n[Error en OCR: {str(e)}]\n")
full_text = "\n\n".join(text_parts)
logger.info(f"OCR extraction completed: {len(full_text)} characters from {page_count} pages")
return full_text, page_count
except Exception as e:
logger.error(f"OCR extraction failed: {str(e)}")
raise OCRError(f"OCR extraction failed: {str(e)}")
def optimize_image_for_ocr(image: Image.Image) -> Image.Image:
"""
Optimize image for better OCR results.
Args:
image: PIL Image object
Returns:
Optimized PIL Image
"""
# Convert to grayscale
image = image.convert('L')
# Optional: Apply threshold to make text clearer
# This can be adjusted based on your PDFs
# from PIL import ImageEnhance
# enhancer = ImageEnhance.Contrast(image)
# image = enhancer.enhance(2)
return image
def extract_text_with_ocr_optimized(
pdf_bytes: bytes,
lang: str = "spa",
dpi: int = 300,
timeout: int = 300,
optimize: bool = True
) -> Tuple[str, int]:
"""
Extract text from scanned PDF with image optimization.
Args:
pdf_bytes: PDF file content as bytes
lang: Language for OCR
dpi: DPI for image conversion
timeout: Maximum time in seconds
optimize: Whether to optimize images before OCR
Returns:
Tuple of (extracted_text, page_count)
Raises:
OCRError: If OCR processing fails
"""
try:
logger.info(f"Starting optimized OCR extraction")
# Convert PDF to images
images = convert_from_bytes(
pdf_bytes,
dpi=dpi,
fmt='png',
thread_count=2,
timeout=timeout
)
page_count = len(images)
text_parts = []
for i, image in enumerate(images, 1):
try:
# Optimize image if requested
if optimize:
image = optimize_image_for_ocr(image)
# Perform OCR
page_text = pytesseract.image_to_string(
image,
lang=lang,
config='--psm 1', # Automatic page segmentation with OSD
timeout=timeout // page_count
)
if page_text.strip():
text_parts.append(f"--- Página {i} ---\n{page_text}")
else:
text_parts.append(f"--- Página {i} ---\n[Sin texto detectado]\n")
except Exception as e:
logger.error(f"OCR failed on page {i}: {str(e)}")
text_parts.append(f"--- Página {i} ---\n[Error en OCR]\n")
full_text = "\n\n".join(text_parts)
logger.info(f"Optimized OCR completed: {len(full_text)} characters")
return full_text, page_count
except Exception as e:
logger.error(f"Optimized OCR failed: {str(e)}")
raise OCRError(f"Optimized OCR failed: {str(e)}")

View File

@@ -1,9 +1,10 @@
"""PDF text extraction service using PyMuPDF and pdfplumber."""
"""PDF text extraction service using PyMuPDF, pdfplumber, and OCR."""
import fitz # PyMuPDF
import pdfplumber
import logging
from typing import Tuple, Optional
from io import BytesIO
from .ocr_service import extract_text_with_ocr, is_pdf_scanned, OCRError
logger = logging.getLogger(__name__)
@@ -82,16 +83,19 @@ def extract_text_with_pdfplumber(pdf_bytes: bytes) -> Tuple[str, int]:
raise PDFExtractionError(f"pdfplumber extraction failed: {str(e)}")
def extract_text_from_pdf(pdf_bytes: bytes) -> Tuple[str, int, str]:
def extract_text_from_pdf(pdf_bytes: bytes, enable_ocr: bool = True, ocr_lang: str = "spa") -> Tuple[str, int, str]:
"""
Extract text from PDF using available methods.
Tries PyMuPDF first, falls back to pdfplumber.
Tries PyMuPDF first, falls back to pdfplumber, and uses OCR for scanned PDFs.
Args:
pdf_bytes: PDF file content as bytes
enable_ocr: Whether to use OCR for scanned PDFs (default: True)
ocr_lang: Language for OCR - "spa" for Spanish, "eng" for English (default: "spa")
Returns:
Tuple of (extracted_text, page_count, method_used)
method_used can be: "pymupdf", "pdfplumber", "ocr"
Raises:
PDFExtractionError: If all extraction methods fail
@@ -99,14 +103,41 @@ def extract_text_from_pdf(pdf_bytes: bytes) -> Tuple[str, int, str]:
# Try PyMuPDF first
try:
text, pages = extract_text_with_pymupdf(pdf_bytes)
return text, pages, "text"
# Check if the PDF is scanned (no text content)
if enable_ocr and is_pdf_scanned(text):
logger.info("PDF appears to be scanned, attempting OCR extraction")
try:
ocr_text, ocr_pages = extract_text_with_ocr(pdf_bytes, lang=ocr_lang)
return ocr_text, ocr_pages, "ocr"
except OCRError as ocr_e:
logger.error(f"OCR failed: {str(ocr_e)}")
# Return the minimal text we got, if any
if text.strip():
return text, pages, "pymupdf"
raise PDFExtractionError(f"PDF appears to be scanned and OCR failed: {str(ocr_e)}")
return text, pages, "pymupdf"
except PDFExtractionError as e:
logger.warning(f"PyMuPDF failed, trying pdfplumber: {str(e)}")
# Fallback to pdfplumber
try:
text, pages = extract_text_with_pdfplumber(pdf_bytes)
return text, pages, "text"
# Check if the PDF is scanned
if enable_ocr and is_pdf_scanned(text):
logger.info("PDF appears to be scanned (pdfplumber), attempting OCR extraction")
try:
ocr_text, ocr_pages = extract_text_with_ocr(pdf_bytes, lang=ocr_lang)
return ocr_text, ocr_pages, "ocr"
except OCRError as ocr_e:
logger.error(f"OCR failed: {str(ocr_e)}")
if text.strip():
return text, pages, "pdfplumber"
raise PDFExtractionError(f"PDF appears to be scanned and OCR failed: {str(ocr_e)}")
return text, pages, "pdfplumber"
except PDFExtractionError as e:
logger.error(f"All extraction methods failed: {str(e)}")
raise PDFExtractionError("Failed to extract text from PDF using all available methods")