Spaces:
Running
Running
| """ | |
| InvoiceForge AI β ocr/easyocr_engine.py | |
| EasyOCR fallback engine wrapper. | |
| Used as a confidence-weighted secondary engine when PaddleOCR confidence | |
| is below threshold. Runs CPU-only by default. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| from typing import Optional | |
| import cv2 | |
| import numpy as np | |
| logger = logging.getLogger(__name__) | |
| EASYOCR_LANGS = ["en"] | |
| CONFIDENCE_THRESHOLD = 0.40 # tokens below this are not trusted | |
| class EasyOCREngine: | |
| """ | |
| Singleton wrapper around EasyOCR Reader. | |
| EasyOCR is initialised lazily on first use. The model weights are | |
| cached in ~/.EasyOCR/ by default. | |
| Usage: | |
| engine = EasyOCREngine.instance() | |
| tokens = engine.run_ocr(img) | |
| """ | |
| _reader: Optional[object] = None | |
| def instance(cls) -> "EasyOCREngine": | |
| obj = cls.__new__(cls) | |
| return obj | |
| def _get_reader(self) -> object: | |
| """Lazily initialise EasyOCR Reader.""" | |
| if EasyOCREngine._reader is None: | |
| logger.info("Initialising EasyOCR reader (langs=%s)β¦", EASYOCR_LANGS) | |
| try: | |
| import easyocr # type: ignore | |
| EasyOCREngine._reader = easyocr.Reader( | |
| EASYOCR_LANGS, | |
| gpu=False, | |
| verbose=False, | |
| ) | |
| logger.info("EasyOCR reader initialised.") | |
| except Exception as exc: | |
| logger.error("EasyOCR init failed: %s", exc) | |
| raise | |
| return EasyOCREngine._reader | |
| def run_ocr(self, img: np.ndarray) -> list[dict]: | |
| """ | |
| Run EasyOCR on the given image. | |
| Args: | |
| img: BGR or grayscale numpy array. | |
| Returns: | |
| List of dicts: {text, confidence, bbox, x, y, engine} | |
| """ | |
| reader = self._get_reader() | |
| # EasyOCR accepts BGR; ensure correct dtype | |
| if img.dtype != np.uint8: | |
| img = img.astype(np.uint8) | |
| try: | |
| raw_result = reader.readtext(img, detail=1) # type: ignore[union-attr] | |
| except Exception as exc: | |
| logger.warning("EasyOCR inference failed: %s", exc) | |
| return [] | |
| tokens: list[dict] = [] | |
| for detection in raw_result: | |
| bbox_raw, text, conf = detection | |
| if conf < CONFIDENCE_THRESHOLD: | |
| continue | |
| # bbox_raw: list of [x, y] corners | |
| xs = [p[0] for p in bbox_raw] | |
| ys = [p[1] for p in bbox_raw] | |
| tokens.append( | |
| { | |
| "text": text.strip(), | |
| "confidence": float(conf), | |
| "bbox": bbox_raw, | |
| "x": float(np.mean(xs)), | |
| "y": float(np.mean(ys)), | |
| "engine": "easyocr", | |
| } | |
| ) | |
| tokens.sort(key=lambda t: (t["y"], t["x"])) | |
| logger.debug("EasyOCR: %d tokens extracted.", len(tokens)) | |
| return tokens | |
| def run_ocr_on_region( | |
| self, | |
| img: np.ndarray, | |
| x1: int, | |
| y1: int, | |
| x2: int, | |
| y2: int, | |
| ) -> list[dict]: | |
| """ | |
| Run EasyOCR on a specific rectangular region of the image. | |
| Coordinates are adjusted back to full-image space. | |
| Args: | |
| img: Full image BGR array. | |
| x1, y1: Top-left of region. | |
| x2, y2: Bottom-right of region. | |
| Returns: | |
| List of token dicts with full-image coordinates. | |
| """ | |
| h, w = img.shape[:2] | |
| x1 = max(0, x1) | |
| y1 = max(0, y1) | |
| x2 = min(w, x2) | |
| y2 = min(h, y2) | |
| crop = img[y1:y2, x1:x2] | |
| if crop.size == 0: | |
| return [] | |
| tokens = self.run_ocr(crop) | |
| # Offset coordinates to full-image space | |
| for tok in tokens: | |
| tok["x"] += x1 | |
| tok["y"] += y1 | |
| tok["bbox"] = [ | |
| [p[0] + x1, p[1] + y1] for p in tok["bbox"] | |
| ] | |
| return tokens | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MODULE-LEVEL SINGLETON | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _engine: EasyOCREngine | None = None | |
| def get_easyocr_engine() -> EasyOCREngine: | |
| """Return the module-level EasyOCREngine singleton.""" | |
| global _engine | |
| if _engine is None: | |
| _engine = EasyOCREngine.instance() | |
| return _engine | |