""" OCR utilities for the MarkItDown API. Two engines are provided: ocr_image(source) RapidOCR singleton for raster images (JPEG, PNG, WEBP, etc.). Accepts bytes, a local file path string, an HTTP/HTTPS URL string, a numpy.ndarray, or a PIL.Image instance. ocr_pdf(source, dpi=150) Scanned-PDF fallback. Renders each page with pypdfium2, then feeds the rendered PIL image through ocr_image. Returns all pages joined with double newlines. Both functions return a plain string and never raise; errors are logged and an empty string is returned on failure. """ from __future__ import annotations import io import threading from typing import Union from urllib.parse import urlparse import numpy as np from logger import get_logger logger = get_logger(__name__) # --------------------------------------------------------------------------- # RapidOCR singleton # --------------------------------------------------------------------------- _lock = threading.Lock() _engine = None def _get_engine(): """Return the shared RapidOCR instance, initialising it on first call.""" global _engine if _engine is None: with _lock: if _engine is None: from rapidocr_onnxruntime import RapidOCR _engine = RapidOCR( Det={"use_cuda": False, "use_dml": False}, Cls={"use_cuda": False, "use_dml": False}, Rec={"use_cuda": False, "use_dml": False}, print_verbose=False, ) logger.info("RapidOCR engine initialised") return _engine # --------------------------------------------------------------------------- # Input normalisation # --------------------------------------------------------------------------- def _to_numpy(source) -> Union[np.ndarray, str]: """Normalise *source* to a numpy array or a local file path string. Accepted input types: PIL.Image — converted directly to ndarray. bytes — decoded via PIL then converted to ndarray. str — HTTP/HTTPS URL fetched then decoded; local paths returned as-is. np.ndarray — returned unchanged. """ from PIL import Image def _pil_to_array(img: Image.Image) -> np.ndarray: if img.mode not in ("RGB", "L", "RGBA"): img = img.convert("RGB") return np.array(img) if isinstance(source, np.ndarray): return source if isinstance(source, Image.Image): return _pil_to_array(source) if isinstance(source, (bytes, bytearray, memoryview)): return _pil_to_array(Image.open(io.BytesIO(bytes(source)))) if isinstance(source, str): parsed = urlparse(source) if parsed.scheme in {"http", "https"}: import httpx resp = httpx.get(source, follow_redirects=True, timeout=30) resp.raise_for_status() return _pil_to_array(Image.open(io.BytesIO(resp.content))) # Local file path — RapidOCR accepts it directly. return source raise TypeError( f"ocr_image expects bytes, str (URL or path), numpy.ndarray, or PIL.Image; " f"received {type(source).__name__!r}" ) # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def ocr_image( source, *, use_det: bool = True, use_cls: bool = True, use_rec: bool = True, text_score: float = 0.5, ) -> str: """Extract text from an image using RapidOCR. Parameters ---------- source: Input image — bytes, URL string, local path string, numpy.ndarray, or PIL.Image. use_det, use_cls, use_rec: RapidOCR pipeline stages (detection, classification, recognition). text_score: Minimum confidence threshold for accepted text lines. Returns ------- str Recognised text lines joined by newlines, or an empty string when no text is detected. """ engine = _get_engine() img = _to_numpy(source) result, _ = engine( img, use_det=use_det, use_cls=use_cls, use_rec=use_rec, text_score=text_score, ) if not result: return "" return "\n".join(item[1] for item in result if len(item) > 1 and item[1]) def ocr_pdf(source: Union[str, bytes], *, dpi: int = 150) -> str: """Extract text from a scanned (image-only) PDF using pypdfium2 and RapidOCR. Each page is rendered to a PIL image in memory (no temporary files are written), then passed through ocr_image. All page outputs are joined with double newlines. Parameters ---------- source: Local file path (str) or raw PDF bytes. dpi: Rendering resolution. 150 balances speed and OCR quality for most document types. Increase to 200-300 for small or dense text. Returns ------- str Concatenated OCR text from all pages, or an empty string on failure. """ try: import pypdfium2 as pdfium except ImportError: logger.error("ocr_pdf | pypdfium2 not installed; run: pip install pypdfium2") return "" try: pdf = pdfium.PdfDocument(source) scale = dpi / 72.0 # pypdfium2 native resolution is 72 dpi page_texts: list[str] = [] for page_index in range(len(pdf)): page = pdf[page_index] bitmap = page.render(scale=scale, rotation=0) pil_image = bitmap.to_pil() logger.debug("ocr_pdf | processing page %d/%d", page_index + 1, len(pdf)) page_text = ocr_image(pil_image) if page_text: page_texts.append(page_text) pdf.close() return "\n\n".join(page_texts) except Exception as exc: logger.error("ocr_pdf | failed | error=%s", exc, exc_info=True) return ""