Spaces:
Sleeping
Sleeping
Aaron
Sync backend with GitHub main (f6aa117): OCR word-coherence filter, full_text consistency fix
e87916b Download processors/paddleocr_processor.py from lylron/wonq-backend: direct link, hf CLI and curl.
- Browser
- Download file 6.3 kB
-
https://huggingface.co/spaces/lylron/wonq-backend/resolve/main/processors/paddleocr_processor.py
- Command line
-
hf download hf://spaces/lylron/wonq-backend/processors/paddleocr_processor.py
-
curl -L -o paddleocr_processor.py https://huggingface.co/spaces/lylron/wonq-backend/resolve/main/processors/paddleocr_processor.py
6.3 kB
| """ | |
| PaddleOCR Processor | |
| =================== | |
| Alternative to Tesseract (see ocr_processor.py) backed by PaddleOCR | |
| (Apache 2.0, https://github.com/PaddlePaddle/PaddleOCR). | |
| Why this is a separate, simpler class rather than another Tesseract config: | |
| PaddleOCR's detector locates text at the phrase/line level directly, so none | |
| of ocr_processor.py's word->phrase grouping, gap-splitting, or tick-mark | |
| height filtering is needed here — those all exist specifically to work | |
| around Tesseract returning individual words with no semantic grouping. | |
| Classification (dimensions/room_names/numbers/labels) is shared via | |
| ocr_classification.py so both engines bucket text identically. | |
| Status: NOT YET BENCHMARKED. Model weights could not be downloaded in the | |
| sandboxed dev environment this was written in — see CLAUDE.md for why | |
| (corporate TLS-inspecting proxy whose root CA isn't in Python's default | |
| trust store). The API calls below are verified against the installed | |
| paddleocr==3.7.0 / paddlex package source directly (constructor params, | |
| and the exact result schema: `rec_texts`/`rec_scores`/`rec_boxes`, with | |
| `rec_boxes[i] == [left, top, right, bottom]` — see | |
| venv/lib/python3.12/site-packages/paddlex/inference/pipelines/ocr/pipeline.py | |
| and .../components/common/convert_points_and_boxes.py), but real accuracy | |
| on this project's sample images has not been measured. Run | |
| `benchmark/run_benchmark.py` before relying on this over Tesseract. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| from typing import Any, Dict, List, Optional | |
| import numpy as np | |
| from .ocr_classification import classify, clean_elements, empty_result | |
| logger = logging.getLogger(__name__) | |
| # PP-OCRv6's detection/recognition models crash under paddlex's default CPU | |
| # MKLDNN/PIR execution path on this Windows/CPU combo: | |
| # NotImplementedError: (Unimplemented) ConvertPirAttribute2RuntimeAttribute | |
| # not support [pir::ArrayAttribute<pir::DoubleAttribute>] | |
| # Neither model is in paddlex's MKLDNN_BLOCKLIST (an upstream gap), so the | |
| # workaround is disabling MKLDNN-by-default before paddlex's flags module | |
| # is imported. Must happen before `import paddleocr`, not after. | |
| os.environ.setdefault("PADDLE_PDX_ENABLE_MKLDNN_BYDEFAULT", "False") | |
| try: | |
| from paddleocr import PaddleOCR as _PaddleOCREngine | |
| _PADDLEOCR_AVAILABLE = True | |
| _PADDLEOCR_ERROR: Optional[str] = None | |
| except ImportError as _e: | |
| _PADDLEOCR_AVAILABLE = False | |
| _PADDLEOCR_ERROR = f"paddleocr / paddlepaddle not installed: {_e}" | |
| class PaddleOCRProcessor: | |
| """ | |
| Drop-in alternative to OCRProcessor — same extract_text() contract | |
| (full_text / element_count / elements / classified), so main.py can | |
| swap between engines without any other code changing. | |
| """ | |
| def __init__(self, lang: str = "en") -> None: | |
| # PaddleOCR loads one recognition model per language (unlike | |
| # Tesseract's combined "jpn+eng"); "en" covers this project's | |
| # sample images, pass lang="japan" for Japanese-only drawings. | |
| self._lang = lang | |
| self._engine = None # lazy-loaded on first use — downloads weights | |
| def is_available(self) -> bool: | |
| return _PADDLEOCR_AVAILABLE | |
| def unavailable_reason(self) -> Optional[str]: | |
| return _PADDLEOCR_ERROR | |
| def extract_text(self, image: np.ndarray) -> Dict[str, Any]: | |
| """ | |
| Parameters | |
| ---------- | |
| image : enhanced binary or grayscale (or BGR) image | |
| Returns | |
| ------- | |
| Same schema as OCRProcessor.extract_text() — see that module's | |
| docstring. | |
| """ | |
| if not _PADDLEOCR_AVAILABLE: | |
| logger.warning("paddleocr not installed – OCR skipped") | |
| return empty_result("paddleocr not installed") | |
| try: | |
| engine = self._get_engine() | |
| results = engine.predict(image) | |
| except Exception as exc: | |
| logger.exception("PaddleOCR error") | |
| return empty_result(str(exc)) | |
| if not results: | |
| return empty_result("no text regions detected") | |
| result = results[0] | |
| raw_elements = self._to_elements( | |
| result.get("rec_texts", []), | |
| result.get("rec_scores", []), | |
| result.get("rec_boxes", []), | |
| ) | |
| elements = clean_elements(raw_elements) | |
| classified = classify(elements) | |
| return { | |
| "full_text": "\n".join(e["text"] for e in elements), | |
| "element_count": len(elements), | |
| "elements": elements, | |
| "classified": classified, | |
| } | |
| # ------------------------------------------------------------------ # | |
| # Private helpers # | |
| # ------------------------------------------------------------------ # | |
| def _get_engine(self): | |
| if self._engine is not None: | |
| return self._engine | |
| logger.info("Loading PaddleOCR engine (lang=%s) — downloads weights on first use", self._lang) | |
| self._engine = _PaddleOCREngine( | |
| lang=self._lang, | |
| # Our own enhancement pipeline already deskews/upscales the | |
| # image, so skip PaddleOCR's own document-orientation | |
| # classification and unwarping sub-pipelines — one less model | |
| # to download and one less inference pass per image. | |
| use_doc_orientation_classify=False, | |
| use_doc_unwarping=False, | |
| use_textline_orientation=False, | |
| ) | |
| return self._engine | |
| def _to_elements( | |
| texts: List[str], scores: List[float], boxes: Any | |
| ) -> List[Dict[str, Any]]: | |
| elements: List[Dict[str, Any]] = [] | |
| for text, score, box in zip(texts, scores, boxes): | |
| text = str(text).strip() | |
| if not text: | |
| continue | |
| x1, y1, x2, y2 = (float(v) for v in box) | |
| elements.append({ | |
| "text": text, | |
| "confidence": round(float(score) * 100), # match Tesseract's 0-100 scale | |
| "x": int(round(x1)), | |
| "y": int(round(y1)), | |
| "width": int(round(x2 - x1)), | |
| "height": int(round(y2 - y1)), | |
| }) | |
| return elements | |