← Files VeraARCHIVED FILE

vendor/modules/vera_ocr/__init__.py

18.1 KB · Oct 2, 2026 · 00:29 UTC

↓ Download file

"""Local, page-oriented OCR adapter shared by Vera plugin components.

The adapter never lets PaddleOCR resolve models itself. Both model directories
must come from explicit arguments or, when implicit paths are allowed, the
``VERA_OCR_*_MODEL_DIR`` environment variables, an existing PaddleX
``official_models`` cache, or a Hugging Face snapshot resolved here. Hugging
Face lookup is local-only unless ``allow_model_download=True`` is passed by the
caller.
"""

from __future__ import annotations

import io
import os
from collections.abc import Iterator, Mapping, Sequence
from dataclasses import dataclass
from functools import lru_cache
from importlib.metadata import PackageNotFoundError, version
from importlib.util import find_spec
from pathlib import Path

__all__ = ["OcrResult", "extract_text_from_image_bytes", "ocr_available"]

_ENGINE_NAME = "paddleocr"
_DETECTION_MODEL_NAME = "PP-OCRv5_server_det"
_DEFAULT_LANGUAGE = "it"
_DETECTION_MODEL_ENV = "VERA_OCR_DETECTION_MODEL_DIR"
_RECOGNITION_MODEL_ENV = "VERA_OCR_RECOGNITION_MODEL_DIR"
_LANGUAGE_ALIASES = {
    "deu": "de",
    "eng": "en",
    "esp": "es",
    "espanol": "es",
    "español": "es",
    "fra": "fr",
    "fre": "fr",
    "ger": "de",
    "ita": "it",
    "spa": "es",
    "spanish": "es",
}
_RECOGNITION_MODEL_BY_LANGUAGE = {
    "de": "latin_PP-OCRv5_mobile_rec",
    "en": "en_PP-OCRv5_mobile_rec",
    "fr": "latin_PP-OCRv5_mobile_rec",
    "it": "latin_PP-OCRv5_mobile_rec",
    "es": "latin_PP-OCRv5_mobile_rec",
}
_MODEL_REVISION_BY_NAME = {
    "PP-OCRv5_server_det": "ca867c897ecbca8873081573a802ad70d499cb94",
    "en_PP-OCRv5_mobile_rec": "267c36e24c331595590fe7bd72bde2436fd286f2",
    "latin_PP-OCRv5_mobile_rec": "ab2cd5cc5fa6309be2e5acdfe66eca2c2c127d57",
}


@dataclass(frozen=True)
class OcrResult:
    """Structured outcome from one local OCR inference request."""

    text: str
    status: str
    engine: str
    language: str
    line_count: int
    warnings: tuple[str, ...]
    model_source: str
    network_used: bool
    runtime_versions: tuple[str, ...]
    model_names: tuple[str, ...]
    model_revisions: tuple[str, ...]


@dataclass(frozen=True)
class _ResolvedModel:
    path: Path | None
    source: str
    network_used: bool
    warnings: tuple[str, ...]


@dataclass(frozen=True)
class _ResolvedModels:
    detection_path: Path | None
    recognition_path: Path | None
    source: str
    network_used: bool
    warnings: tuple[str, ...]


def ocr_available() -> bool:
    """Return whether the optional local OCR runtime packages are importable."""

    for module_name in ("numpy", "paddle", "paddleocr", "PIL"):
        try:
            if find_spec(module_name) is None:
                return False
        except (ImportError, AttributeError, ValueError):
            return False
    return True


def _normalize_language(language: str) -> tuple[str, tuple[str, ...]]:
    normalized = str(language or "").strip().lower().replace("_", "-")
    base = normalized.split("-", 1)[0]
    base = _LANGUAGE_ALIASES.get(base, base)
    if base in _RECOGNITION_MODEL_BY_LANGUAGE:
        return base, ()
    return _DEFAULT_LANGUAGE, (f"unsupported_language_fallback:{_DEFAULT_LANGUAGE}",)


def _is_model_directory(path: Path) -> bool:
    try:
        return path.is_dir()
    except OSError:
        return False


def _snapshot_download(
    *, repo_id: str, cache_dir: Path | None, local_files_only: bool
) -> Path:
    from huggingface_hub import snapshot_download  # type: ignore

    model_name = repo_id.rsplit("/", 1)[-1]
    return Path(
        snapshot_download(
            repo_id=repo_id,
            revision=_MODEL_REVISION_BY_NAME[model_name],
            cache_dir=str(cache_dir) if cache_dir is not None else None,
            local_files_only=local_files_only,
        )
    )


def _resolve_huggingface_model(
    model_name: str,
    *,
    label: str,
    cache_dir: Path | None,
    allow_model_download: bool,
) -> _ResolvedModel:
    repo_id = f"PaddlePaddle/{model_name}"
    try:
        local_path = _snapshot_download(
            repo_id=repo_id,
            cache_dir=cache_dir,
            local_files_only=True,
        )
        if not _is_model_directory(local_path):
            raise FileNotFoundError(local_path)
        return _ResolvedModel(local_path, "huggingface_cache", False, ())
    except (ImportError, ModuleNotFoundError):
        return _ResolvedModel(
            None,
            "unavailable",
            False,
            ("model_resolver_unavailable:huggingface_hub",),
        )
    except (OSError, RuntimeError, TypeError, ValueError):
        if not allow_model_download:
            return _ResolvedModel(
                None,
                "unavailable",
                False,
                (f"{label}_model_not_found_in_local_cache",),
            )

    try:
        downloaded_path = _snapshot_download(
            repo_id=repo_id,
            cache_dir=cache_dir,
            local_files_only=False,
        )
        if not _is_model_directory(downloaded_path):
            raise FileNotFoundError(downloaded_path)
        return _ResolvedModel(
            downloaded_path,
            "huggingface_download",
            True,
            (f"{label}_model_downloaded",),
        )
    except (ImportError, ModuleNotFoundError):
        return _ResolvedModel(
            None,
            "unavailable",
            False,
            ("model_resolver_unavailable:huggingface_hub",),
        )
    except (OSError, RuntimeError, TypeError, ValueError):
        return _ResolvedModel(
            None,
            "unavailable",
            True,
            (f"{label}_model_download_failed",),
        )


def _resolve_one_model(
    explicit_path: Path | None,
    *,
    environment_variable: str,
    model_name: str,
    label: str,
    cache_dir: Path | None,
    allow_model_download: bool,
    allow_implicit_model_paths: bool,
) -> _ResolvedModel:
    source = "explicit"
    selected_path = explicit_path
    if selected_path is None and allow_implicit_model_paths:
        environment_value = os.environ.get(environment_variable, "").strip()
        if environment_value:
            selected_path = Path(environment_value).expanduser()
            source = "environment"
    if selected_path is not None:
        selected_path = Path(selected_path).expanduser()
        if _is_model_directory(selected_path):
            return _ResolvedModel(selected_path, source, False, ())
        return _ResolvedModel(
            None,
            "unavailable",
            False,
            (f"{label}_model_dir_unavailable:{source}",),
        )

    if allow_implicit_model_paths:
        paddlex_cache_value = os.environ.get("PADDLE_PDX_CACHE_HOME", "").strip()
        paddlex_cache_home = (
            Path(paddlex_cache_value).expanduser()
            if paddlex_cache_value
            else Path.home() / ".paddlex"
        )
        paddlex_model_path = paddlex_cache_home / "official_models" / model_name
        if _is_model_directory(paddlex_model_path):
            return _ResolvedModel(paddlex_model_path, "paddlex_cache", False, ())

    if cache_dir is None:
        # Managed callers disable implicit locations so model reads and writes
        # remain inside exact receipts or their authorized run output.
        return _ResolvedModel(
            None,
            "unavailable",
            False,
            (f"{label}_managed_model_location_required",),
        )

    return _resolve_huggingface_model(
        model_name,
        label=label,
        cache_dir=cache_dir,
        allow_model_download=allow_model_download,
    )


def _combined_model_source(detection_source: str, recognition_source: str) -> str:
    if detection_source == "unavailable" or recognition_source == "unavailable":
        return "unavailable"
    if detection_source == recognition_source:
        return detection_source
    return "mixed"


def _resolve_models(
    language: str,
    *,
    cache_dir: Path | None,
    allow_model_download: bool,
    detection_model_dir: Path | None,
    recognition_model_dir: Path | None,
    allow_implicit_model_paths: bool,
) -> _ResolvedModels:
    detection = _resolve_one_model(
        detection_model_dir,
        environment_variable=_DETECTION_MODEL_ENV,
        model_name=_DETECTION_MODEL_NAME,
        label="detection",
        cache_dir=cache_dir,
        allow_model_download=allow_model_download,
        allow_implicit_model_paths=allow_implicit_model_paths,
    )
    if detection.path is None:
        return _ResolvedModels(
            None,
            None,
            "unavailable",
            detection.network_used,
            detection.warnings,
        )

    recognition_model_name = _RECOGNITION_MODEL_BY_LANGUAGE[language]
    recognition = _resolve_one_model(
        recognition_model_dir,
        environment_variable=_RECOGNITION_MODEL_ENV,
        model_name=recognition_model_name,
        label="recognition",
        cache_dir=cache_dir,
        allow_model_download=allow_model_download,
        allow_implicit_model_paths=allow_implicit_model_paths,
    )
    return _ResolvedModels(
        detection.path,
        recognition.path,
        _combined_model_source(detection.source, recognition.source),
        detection.network_used or recognition.network_used,
        detection.warnings + recognition.warnings,
    )


@lru_cache(maxsize=8)
def _get_engine(
    language: str,
    detection_model_path: str,
    recognition_model_path: str,
    recognition_model_name: str,
) -> object:
    os.environ.setdefault("PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK", "True")
    from paddleocr import PaddleOCR  # type: ignore

    modern_kwargs: dict[str, object] = {
        "lang": language,
        "text_detection_model_name": _DETECTION_MODEL_NAME,
        "text_detection_model_dir": detection_model_path,
        "text_recognition_model_name": recognition_model_name,
        "text_recognition_model_dir": recognition_model_path,
        "use_doc_orientation_classify": False,
        "use_doc_unwarping": False,
        "use_textline_orientation": False,
        "enable_mkldnn": False,
    }
    try:
        return PaddleOCR(**modern_kwargs)
    except TypeError:
        legacy_kwargs: dict[str, object] = {
            "lang": language,
            "det_model_dir": detection_model_path,
            "rec_model_dir": recognition_model_path,
            "show_log": False,
            "use_angle_cls": False,
            "enable_mkldnn": False,
        }
        return PaddleOCR(**legacy_kwargs)


def _decode_image(image_bytes: bytes) -> object:
    import numpy as np  # type: ignore
    from PIL import Image  # type: ignore

    try:
        with Image.open(io.BytesIO(image_bytes)) as image:
            return np.asarray(image.convert("RGB"))
    except (OSError, Image.DecompressionBombError) as exc:
        raise ValueError("Unsupported or invalid OCR image") from exc


def _clean_line(value: object) -> str:
    if not isinstance(value, str):
        return ""
    return " ".join(value.split())


def _raw_ocr_lines(raw: object) -> list[str]:
    lines: list[str] = []

    def collect(value: object) -> None:
        if value is None:
            return
        if isinstance(value, Mapping):
            for key in ("rec_texts", "texts"):
                nested = value.get(key)
                if isinstance(nested, Sequence) and not isinstance(
                    nested, (str, bytes, bytearray)
                ):
                    for item in nested:
                        line = _clean_line(item)
                        if line:
                            lines.append(line)
                    return
            direct_text = _clean_line(value.get("text"))
            if direct_text:
                lines.append(direct_text)
                return
            for nested in value.values():
                collect(nested)
            return
        if isinstance(value, Iterator):
            for nested in value:
                collect(nested)
            return
        if isinstance(value, Sequence) and not isinstance(
            value, (str, bytes, bytearray)
        ):
            if (
                len(value) >= 2
                and isinstance(value[1], Sequence)
                and not isinstance(value[1], (str, bytes, bytearray))
                and value[1]
            ):
                legacy_text = _clean_line(value[1][0])
                if legacy_text:
                    lines.append(legacy_text)
                    return
            for nested in value:
                collect(nested)

    collect(raw)
    return lines


def _run_inference(engine: object, image_array: object) -> object:
    predict = getattr(engine, "predict", None)
    if callable(predict):
        return predict(image_array)
    ocr = getattr(engine, "ocr", None)
    if callable(ocr):
        return ocr(image_array, cls=False)
    raise RuntimeError("No compatible PaddleOCR inference method is available")


@lru_cache(maxsize=1)
def _runtime_versions() -> tuple[str, ...]:
    versions: list[str] = []
    for distribution in ("paddleocr", "paddlepaddle", "paddlex"):
        try:
            installed = version(distribution)
        except PackageNotFoundError:
            installed = "unavailable"
        versions.append(f"{distribution}={installed}")
    return tuple(versions)


def _result(
    *,
    status: str,
    language: str,
    warnings: Sequence[str],
    model_source: str,
    network_used: bool,
    lines: Sequence[str] = (),
) -> OcrResult:
    model_names = (
        _DETECTION_MODEL_NAME,
        _RECOGNITION_MODEL_BY_LANGUAGE[language],
    )
    model_revisions = (
        tuple(_MODEL_REVISION_BY_NAME[name] for name in model_names)
        if model_source in {"huggingface_cache", "huggingface_download"}
        else ()
    )
    return OcrResult(
        text="\n".join(lines),
        status=status,
        engine=_ENGINE_NAME,
        language=language,
        line_count=len(lines),
        warnings=tuple(warnings),
        model_source=model_source,
        network_used=network_used,
        runtime_versions=_runtime_versions(),
        model_names=model_names,
        model_revisions=model_revisions,
    )


def extract_text_from_image_bytes(
    image_bytes: bytes,
    *,
    language: str = "it",
    cache_dir: Path | None = None,
    allow_model_download: bool = False,
    detection_model_dir: Path | None = None,
    recognition_model_dir: Path | None = None,
    allow_implicit_model_paths: bool = True,
) -> OcrResult:
    """Extract text locally, returning a structured result instead of raising.

    Network access is possible only when ``allow_model_download`` is true and a
    required model is absent from the local Hugging Face cache. Managed callers
    can disable environment and user-cache discovery by passing
    ``allow_implicit_model_paths=False``.
    """

    normalized_language, language_warnings = _normalize_language(language)
    if not ocr_available():
        return _result(
            status="runtime_unavailable",
            language=normalized_language,
            warnings=language_warnings + ("runtime_dependencies_unavailable",),
            model_source="unresolved",
            network_used=False,
        )

    models = _resolve_models(
        normalized_language,
        cache_dir=Path(cache_dir).expanduser() if cache_dir is not None else None,
        allow_model_download=allow_model_download,
        detection_model_dir=detection_model_dir,
        recognition_model_dir=recognition_model_dir,
        allow_implicit_model_paths=allow_implicit_model_paths,
    )
    warnings = language_warnings + models.warnings
    if models.detection_path is None or models.recognition_path is None:
        return _result(
            status="models_unavailable",
            language=normalized_language,
            warnings=warnings,
            model_source=models.source,
            network_used=models.network_used,
        )

    try:
        image_array = _decode_image(image_bytes)
    except (ImportError, ModuleNotFoundError):
        return _result(
            status="runtime_unavailable",
            language=normalized_language,
            warnings=warnings + ("image_runtime_unavailable",),
            model_source=models.source,
            network_used=models.network_used,
        )
    except (OSError, TypeError, ValueError):
        return _result(
            status="inference_failed",
            language=normalized_language,
            warnings=warnings + ("invalid_image",),
            model_source=models.source,
            network_used=models.network_used,
        )

    recognition_model_name = _RECOGNITION_MODEL_BY_LANGUAGE[normalized_language]
    try:
        engine = _get_engine(
            normalized_language,
            str(models.detection_path),
            str(models.recognition_path),
            recognition_model_name,
        )
    except (ImportError, ModuleNotFoundError):
        return _result(
            status="runtime_unavailable",
            language=normalized_language,
            warnings=warnings + ("paddleocr_import_failed",),
            model_source=models.source,
            network_used=models.network_used,
        )
    except (
        AssertionError,
        AttributeError,
        OSError,
        RuntimeError,
        TypeError,
        ValueError,
    ):
        return _result(
            status="models_unavailable",
            language=normalized_language,
            warnings=warnings + ("model_initialization_failed",),
            model_source=models.source,
            network_used=models.network_used,
        )

    try:
        lines = _raw_ocr_lines(_run_inference(engine, image_array))
    except (
        AssertionError,
        AttributeError,
        OSError,
        RuntimeError,
        TypeError,
        ValueError,
    ):
        return _result(
            status="inference_failed",
            language=normalized_language,
            warnings=warnings + ("paddleocr_inference_failed",),
            model_source=models.source,
            network_used=models.network_used,
        )
    if not lines:
        warnings += ("no_text_detected",)
    return _result(
        status="ok",
        language=normalized_language,
        warnings=warnings,
        model_source=models.source,
        network_used=models.network_used,
        lines=lines,
    )

SHA-256: 205ebcafba5262fb349e63f45781f932a24e3d12971382139c22ba8b1b102742