Merge pull request #568 from christianlouis/copilot/add-multi-language-ocr-support
feat(ocr): per-pipeline language override for multi-language OCR
This commit is contained in:
+42
-1
@@ -51,7 +51,48 @@ PIPELINE_STEP_TYPES: dict[str, dict[str, Any]] = {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Always use cloud OCR even if the PDF already has embedded text.",
|
||||
}
|
||||
},
|
||||
"ocr_language": {
|
||||
"type": "select",
|
||||
"default": "auto",
|
||||
"description": (
|
||||
"Language(s) used for OCR text extraction. Applies to Tesseract and EasyOCR "
|
||||
"providers; Azure and Mistral perform auto-detection by default. "
|
||||
"Use Tesseract codes such as 'eng', 'deu', or 'eng+deu' for multi-language "
|
||||
"documents. 'auto' falls back to the global system setting."
|
||||
),
|
||||
"options": [
|
||||
{"value": "auto", "label": "Auto (use system default)"},
|
||||
{"value": "ara", "label": "Arabic"},
|
||||
{"value": "chi_sim", "label": "Chinese (Simplified)"},
|
||||
{"value": "chi_tra", "label": "Chinese (Traditional)"},
|
||||
{"value": "ces", "label": "Czech"},
|
||||
{"value": "dan", "label": "Danish"},
|
||||
{"value": "nld", "label": "Dutch"},
|
||||
{"value": "eng", "label": "English"},
|
||||
{"value": "fin", "label": "Finnish"},
|
||||
{"value": "fra", "label": "French"},
|
||||
{"value": "deu", "label": "German"},
|
||||
{"value": "ell", "label": "Greek"},
|
||||
{"value": "heb", "label": "Hebrew"},
|
||||
{"value": "hin", "label": "Hindi"},
|
||||
{"value": "hun", "label": "Hungarian"},
|
||||
{"value": "ita", "label": "Italian"},
|
||||
{"value": "jpn", "label": "Japanese"},
|
||||
{"value": "kor", "label": "Korean"},
|
||||
{"value": "nor", "label": "Norwegian"},
|
||||
{"value": "pol", "label": "Polish"},
|
||||
{"value": "por", "label": "Portuguese"},
|
||||
{"value": "ron", "label": "Romanian"},
|
||||
{"value": "rus", "label": "Russian"},
|
||||
{"value": "spa", "label": "Spanish"},
|
||||
{"value": "swe", "label": "Swedish"},
|
||||
{"value": "tha", "label": "Thai"},
|
||||
{"value": "tur", "label": "Turkish"},
|
||||
{"value": "ukr", "label": "Ukrainian"},
|
||||
{"value": "vie", "label": "Vietnamese"},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"extract_metadata": {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pypdf # Upgraded from PyPDF2 to fix CVE-2023-36464
|
||||
from pypdf.errors import PdfReadError
|
||||
@@ -12,7 +16,7 @@ from pypdf.errors import PdfReadError
|
||||
from app.celery_app import celery
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.models import FileRecord
|
||||
from app.models import FileRecord, Pipeline, PipelineStep
|
||||
from app.tasks.extract_metadata_with_gpt import extract_metadata_with_gpt
|
||||
from app.tasks.process_with_ocr import process_with_ocr
|
||||
from app.tasks.retry_config import BaseTaskWithRetry
|
||||
@@ -20,9 +24,75 @@ from app.utils import get_unique_filepath_with_counter, hash_file, log_task_prog
|
||||
from app.utils.step_manager import initialize_file_steps
|
||||
from app.utils.text_quality import check_text_quality, detect_pdf_text_source
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_pipeline_ocr_language(db: "Session", file_record: FileRecord, owner_id: str | None) -> str | None:
|
||||
"""Look up the OCR language override from the file's pipeline OCR step config.
|
||||
|
||||
Resolution order:
|
||||
1. Explicit pipeline assigned to the file (``file_record.pipeline_id``).
|
||||
2. User's own default pipeline (``owner_id``, ``is_default=True``).
|
||||
3. System default pipeline (``owner_id=NULL``, ``is_default=True``).
|
||||
|
||||
Returns the ``ocr_language`` value from the pipeline's OCR step config, or
|
||||
``None`` when no override is configured.
|
||||
"""
|
||||
pipeline = None
|
||||
|
||||
if file_record.pipeline_id:
|
||||
pipeline = db.query(Pipeline).filter(Pipeline.id == file_record.pipeline_id).first()
|
||||
|
||||
if pipeline is None and owner_id:
|
||||
pipeline = (
|
||||
db.query(Pipeline)
|
||||
.filter(
|
||||
Pipeline.owner_id == owner_id,
|
||||
Pipeline.is_default.is_(True),
|
||||
Pipeline.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if pipeline is None:
|
||||
pipeline = (
|
||||
db.query(Pipeline)
|
||||
.filter(
|
||||
Pipeline.owner_id.is_(None),
|
||||
Pipeline.is_default.is_(True),
|
||||
Pipeline.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if pipeline is None:
|
||||
return None
|
||||
|
||||
ocr_step = (
|
||||
db.query(PipelineStep)
|
||||
.filter(
|
||||
PipelineStep.pipeline_id == pipeline.id,
|
||||
PipelineStep.step_type == "ocr",
|
||||
PipelineStep.enabled.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if ocr_step is None or not ocr_step.config:
|
||||
return None
|
||||
|
||||
try:
|
||||
step_config = json.loads(ocr_step.config)
|
||||
lang = step_config.get("ocr_language")
|
||||
# "auto" is treated as no override
|
||||
return lang if lang and lang != "auto" else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||
def process_document(
|
||||
self,
|
||||
@@ -109,6 +179,7 @@ def process_document(
|
||||
)
|
||||
|
||||
# Acquire DB session in the task
|
||||
ocr_language: str | None = None # Pipeline OCR language override resolved inside DB session
|
||||
with SessionLocal() as db:
|
||||
# When file_id is provided, we are reprocessing an existing file.
|
||||
# Skip the duplicate check and reuse the existing record.
|
||||
@@ -305,6 +376,14 @@ def process_document(
|
||||
new_record.local_filename = new_local_path
|
||||
db.commit()
|
||||
|
||||
# Look up pipeline OCR language override before the session closes.
|
||||
# This reads the OCR step config from the file's assigned pipeline (or
|
||||
# the user/system default pipeline) so the language is available when
|
||||
# dispatching process_with_ocr below.
|
||||
ocr_language = _get_pipeline_ocr_language(db, new_record, owner_id)
|
||||
if ocr_language:
|
||||
logger.info(f"[{task_id}] Pipeline OCR language override: {ocr_language!r}")
|
||||
|
||||
# Store file_id before session closes to avoid DetachedInstanceError
|
||||
file_id = new_record.id
|
||||
|
||||
@@ -334,7 +413,7 @@ def process_document(
|
||||
"Queued for forced OCR processing",
|
||||
file_id=file_id,
|
||||
)
|
||||
process_with_ocr.delay(new_filename, file_id)
|
||||
process_with_ocr.delay(new_filename, file_id, language=ocr_language)
|
||||
return {"file": new_local_path, "status": "Queued for forced OCR", "file_id": file_id}
|
||||
|
||||
# If the file is not a PDF, skip embedded text check and convert to PDF first
|
||||
@@ -491,7 +570,7 @@ def process_document(
|
||||
"Queued for OCR (text quality too low)",
|
||||
file_id=file_id,
|
||||
)
|
||||
process_with_ocr.delay(new_filename, file_id, extracted_text)
|
||||
process_with_ocr.delay(new_filename, file_id, extracted_text, language=ocr_language)
|
||||
return {
|
||||
"file": new_local_path,
|
||||
"status": "Queued for OCR (poor embedded text quality)",
|
||||
@@ -564,5 +643,5 @@ def process_document(
|
||||
"Queued for OCR processing",
|
||||
file_id=file_id,
|
||||
)
|
||||
process_with_ocr.delay(new_filename, file_id)
|
||||
process_with_ocr.delay(new_filename, file_id, language=ocr_language)
|
||||
return {"file": new_local_path, "status": "Queued for OCR", "file_id": file_id}
|
||||
|
||||
@@ -17,7 +17,6 @@ task with a multi-engine OCR pipeline that:
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from app.celery_app import celery
|
||||
from app.config import settings
|
||||
@@ -33,7 +32,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery.task(base=OcrTaskWithRetry, bind=True)
|
||||
def process_with_ocr(self, filename: str, file_id: Optional[int] = None, original_text: Optional[str] = None):
|
||||
def process_with_ocr(
|
||||
self,
|
||||
filename: str,
|
||||
file_id: int | None = None,
|
||||
original_text: str | None = None,
|
||||
language: str | None = None,
|
||||
):
|
||||
"""Run the configured OCR providers on *filename* and continue the pipeline.
|
||||
|
||||
When multiple OCR providers are configured the results are merged using the
|
||||
@@ -47,6 +52,10 @@ def process_with_ocr(self, filename: str, file_id: Optional[int] = None, origina
|
||||
filename: Base name of the file inside ``<workdir>/tmp/``.
|
||||
file_id: Optional database record ID passed through to downstream tasks.
|
||||
original_text: Optional original embedded text for head-to-head comparison.
|
||||
language: Optional Tesseract-style language code(s) (e.g. ``"eng+deu"``)
|
||||
to override the global OCR language settings for this specific run.
|
||||
Pass ``None`` or ``"auto"`` to use the global settings. This
|
||||
enables per-pipeline language configuration.
|
||||
"""
|
||||
task_id = self.request.id
|
||||
log_task_progress(
|
||||
@@ -62,7 +71,7 @@ def process_with_ocr(self, filename: str, file_id: Optional[int] = None, origina
|
||||
if not os.path.exists(tmp_file_path):
|
||||
raise FileNotFoundError(f"Local file not found: {tmp_file_path}")
|
||||
|
||||
providers = get_ocr_providers()
|
||||
providers = get_ocr_providers(language=language)
|
||||
provider_names = [p.name for p in providers]
|
||||
logger.info(f"[{task_id}] Running {len(providers)} OCR provider(s): {provider_names}")
|
||||
|
||||
@@ -122,7 +131,12 @@ def process_with_ocr(self, filename: str, file_id: Optional[int] = None, origina
|
||||
# PDF with ocrmypdf to embed an invisible text layer so the output is
|
||||
# selectable/searchable in PDF viewers.
|
||||
if searchable_pdf_path is None:
|
||||
lang = getattr(settings, "tesseract_language", None) or "eng"
|
||||
# Use the per-call language override; fall back to global setting
|
||||
embed_lang = (
|
||||
language
|
||||
if language and language != "auto"
|
||||
else (getattr(settings, "tesseract_language", None) or "eng")
|
||||
)
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"embed_text_layer",
|
||||
@@ -130,7 +144,7 @@ def process_with_ocr(self, filename: str, file_id: Optional[int] = None, origina
|
||||
"Embedding searchable text layer into PDF",
|
||||
file_id=file_id,
|
||||
)
|
||||
embedded = embed_text_layer(tmp_file_path, tmp_file_path, language=lang)
|
||||
embedded = embed_text_layer(tmp_file_path, tmp_file_path, language=embed_lang)
|
||||
if embedded:
|
||||
searchable_pdf_path = tmp_file_path
|
||||
log_task_progress(
|
||||
|
||||
+141
-5
@@ -192,6 +192,95 @@ class OCRResult:
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-language support
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Canonical list of supported OCR languages for pipeline configuration.
|
||||
#: Keys are display names; values are Tesseract language code(s).
|
||||
#: Tesseract codes are used as the canonical format because they are the most
|
||||
#: widely applicable across self-hosted providers (Tesseract + ocrmypdf).
|
||||
#: "auto" falls back to the global ``tesseract_language`` / ``easyocr_languages``
|
||||
#: settings (i.e. no per-call override).
|
||||
OCR_LANGUAGES: Dict[str, str] = {
|
||||
"Auto (use system default)": "auto",
|
||||
"Arabic": "ara",
|
||||
"Chinese (Simplified)": "chi_sim",
|
||||
"Chinese (Traditional)": "chi_tra",
|
||||
"Czech": "ces",
|
||||
"Danish": "dan",
|
||||
"Dutch": "nld",
|
||||
"English": "eng",
|
||||
"Finnish": "fin",
|
||||
"French": "fra",
|
||||
"German": "deu",
|
||||
"Greek": "ell",
|
||||
"Hebrew": "heb",
|
||||
"Hindi": "hin",
|
||||
"Hungarian": "hun",
|
||||
"Italian": "ita",
|
||||
"Japanese": "jpn",
|
||||
"Korean": "kor",
|
||||
"Norwegian": "nor",
|
||||
"Polish": "pol",
|
||||
"Portuguese": "por",
|
||||
"Romanian": "ron",
|
||||
"Russian": "rus",
|
||||
"Spanish": "spa",
|
||||
"Swedish": "swe",
|
||||
"Thai": "tha",
|
||||
"Turkish": "tur",
|
||||
"Ukrainian": "ukr",
|
||||
"Vietnamese": "vie",
|
||||
}
|
||||
|
||||
#: Mapping from Tesseract language codes to EasyOCR language codes.
|
||||
#: Used when ``TesseractOCRProvider``-style codes are specified but EasyOCR is
|
||||
#: the active provider. Codes not present in this map are passed through as-is
|
||||
#: (EasyOCR accepts its own ISO 639-1 codes such as ``"en"`` or ``"de"``).
|
||||
TESSERACT_TO_EASYOCR: Dict[str, str] = {
|
||||
"ara": "ar",
|
||||
"ces": "cs",
|
||||
"chi_sim": "ch_sim",
|
||||
"chi_tra": "ch_tra",
|
||||
"dan": "da",
|
||||
"deu": "de",
|
||||
"ell": "el",
|
||||
"eng": "en",
|
||||
"fin": "fi",
|
||||
"fra": "fr",
|
||||
"heb": "he",
|
||||
"hin": "hi",
|
||||
"hun": "hu",
|
||||
"ita": "it",
|
||||
"jpn": "ja",
|
||||
"kor": "ko",
|
||||
"nld": "nl",
|
||||
"nor": "no",
|
||||
"pol": "pl",
|
||||
"por": "pt",
|
||||
"ron": "ro",
|
||||
"rus": "ru",
|
||||
"spa": "es",
|
||||
"swe": "sv",
|
||||
"tha": "th",
|
||||
"tur": "tr",
|
||||
"ukr": "uk",
|
||||
"vie": "vi",
|
||||
}
|
||||
|
||||
|
||||
def _tesseract_codes_to_easyocr(tesseract_lang: str) -> List[str]:
|
||||
"""Convert a Tesseract language string (e.g. ``"eng+deu"``) to a list of
|
||||
EasyOCR language codes (e.g. ``["en", "de"]``).
|
||||
|
||||
Unknown codes are passed through unchanged, so native EasyOCR codes such
|
||||
as ``"en"`` also work transparently.
|
||||
"""
|
||||
codes = [part.strip() for part in tesseract_lang.split("+") if part.strip()]
|
||||
return [TESSERACT_TO_EASYOCR.get(code, code) for code in codes]
|
||||
|
||||
|
||||
class OCRProvider(ABC):
|
||||
"""Abstract base class for OCR providers.
|
||||
|
||||
@@ -290,10 +379,24 @@ class TesseractOCRProvider(OCRProvider):
|
||||
- ``tesseract_cmd`` – path to the ``tesseract`` binary (optional).
|
||||
- ``tesseract_language`` – Tesseract language code(s), e.g. ``"eng"`` or
|
||||
``"eng+deu"`` (default: ``"eng"``).
|
||||
|
||||
The optional *language* constructor argument overrides the global
|
||||
``tesseract_language`` setting for this specific provider instance, enabling
|
||||
per-pipeline language configuration.
|
||||
"""
|
||||
|
||||
name = "tesseract"
|
||||
|
||||
def __init__(self, language: Optional[str] = None) -> None:
|
||||
"""Initialise the Tesseract provider.
|
||||
|
||||
Args:
|
||||
language: Optional Tesseract language code(s) to use instead of the
|
||||
global ``tesseract_language`` setting (e.g. ``"eng+deu"``).
|
||||
Pass ``None`` or ``"auto"`` to use the global setting.
|
||||
"""
|
||||
self._language_override: Optional[str] = language if language and language != "auto" else None
|
||||
|
||||
def process(self, file_path: str) -> OCRResult:
|
||||
try:
|
||||
import pytesseract
|
||||
@@ -308,7 +411,7 @@ class TesseractOCRProvider(OCRProvider):
|
||||
if tesseract_cmd:
|
||||
pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
|
||||
|
||||
lang = getattr(settings, "tesseract_language", None) or "eng"
|
||||
lang = self._language_override or getattr(settings, "tesseract_language", None) or "eng"
|
||||
|
||||
# Ensure language data files are present; attempt download if missing.
|
||||
from app.utils.ocr_language_manager import ensure_tesseract_languages # noqa: PLC0415
|
||||
@@ -349,10 +452,26 @@ class EasyOCRProvider(OCRProvider):
|
||||
- ``easyocr_languages`` – comma-separated list of language codes
|
||||
(default: ``"en"``).
|
||||
- ``easyocr_gpu`` – whether to use GPU acceleration (default: ``False``).
|
||||
|
||||
The optional *language* constructor argument accepts a Tesseract-style
|
||||
language string (e.g. ``"eng+deu"``) which is automatically translated to
|
||||
EasyOCR codes (e.g. ``["en", "de"]``), overriding the global
|
||||
``easyocr_languages`` setting for this provider instance.
|
||||
"""
|
||||
|
||||
name = "easyocr"
|
||||
|
||||
def __init__(self, language: Optional[str] = None) -> None:
|
||||
"""Initialise the EasyOCR provider.
|
||||
|
||||
Args:
|
||||
language: Optional Tesseract-style language code(s) (e.g. ``"eng+deu"``)
|
||||
or a comma-separated EasyOCR language list (e.g. ``"en,de"``).
|
||||
Pass ``None`` or ``"auto"`` to use the global ``easyocr_languages``
|
||||
setting.
|
||||
"""
|
||||
self._language_override: Optional[str] = language if language and language != "auto" else None
|
||||
|
||||
def process(self, file_path: str) -> OCRResult:
|
||||
try:
|
||||
import easyocr
|
||||
@@ -363,8 +482,12 @@ class EasyOCRProvider(OCRProvider):
|
||||
"Install them with: pip install easyocr pdf2image"
|
||||
) from exc
|
||||
|
||||
lang_str = getattr(settings, "easyocr_languages", None) or "en"
|
||||
langs = [lang.strip() for lang in lang_str.split(",") if lang.strip()]
|
||||
if self._language_override:
|
||||
# Convert Tesseract-style codes to EasyOCR codes
|
||||
langs = _tesseract_codes_to_easyocr(self._language_override)
|
||||
else:
|
||||
lang_str = getattr(settings, "easyocr_languages", None) or "en"
|
||||
langs = [lang.strip() for lang in lang_str.split(",") if lang.strip()]
|
||||
gpu = getattr(settings, "easyocr_gpu", False)
|
||||
|
||||
logger.info(f"[EasyOCR] Processing {os.path.basename(file_path)} (langs={langs}, gpu={gpu})")
|
||||
@@ -679,23 +802,36 @@ KNOWN_OCR_PROVIDERS: List[str] = sorted(_PROVIDER_MAP.keys())
|
||||
MAX_OCR_TEXT_FOR_AI_MERGE = 4000
|
||||
|
||||
|
||||
def get_ocr_providers() -> List[OCRProvider]:
|
||||
def get_ocr_providers(language: Optional[str] = None) -> List[OCRProvider]:
|
||||
"""Return a list of configured OCR provider instances.
|
||||
|
||||
Reads ``settings.ocr_providers`` (comma-separated provider names) and
|
||||
returns one instantiated provider per entry. Falls back to ``["azure"]``
|
||||
when the setting is absent.
|
||||
|
||||
Args:
|
||||
language: Optional Tesseract-style language code(s) (e.g. ``"eng+deu"``)
|
||||
to override the global language settings for providers that support
|
||||
per-call language configuration (Tesseract and EasyOCR). Pass
|
||||
``None`` or ``"auto"`` to use the global settings.
|
||||
"""
|
||||
raw = getattr(settings, "ocr_providers", None) or "azure"
|
||||
provider_names = [name.strip().lower() for name in raw.split(",") if name.strip()]
|
||||
|
||||
# Normalise "auto" to None so providers fall back to global settings
|
||||
effective_language = language if language and language != "auto" else None
|
||||
|
||||
providers: List[OCRProvider] = []
|
||||
for name in provider_names:
|
||||
cls = _PROVIDER_MAP.get(name)
|
||||
if cls is None:
|
||||
logger.warning(f"Unknown OCR provider '{name}' in OCR_PROVIDERS – skipping.")
|
||||
continue
|
||||
providers.append(cls())
|
||||
# Pass language override to providers that support per-call language config
|
||||
if effective_language is not None and name in ("tesseract", "easyocr"):
|
||||
providers.append(cls(language=effective_language))
|
||||
else:
|
||||
providers.append(cls())
|
||||
logger.debug(f"Registered OCR provider: {name}")
|
||||
|
||||
if not providers:
|
||||
|
||||
Reference in New Issue
Block a user