Spaces:
Running
Running
| """ | |
| InvoiceForge AI β ocr/paddle_engine.py | |
| PaddleOCR 3.x + PPStructureV3 engine wrapper. | |
| Provides: | |
| - PaddleEngine: singleton OCR engine with lazy initialisation | |
| - run_ocr(): returns standardised token list | |
| - run_structure(): PPStructureV3 table + layout analysis | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| from dataclasses import dataclass, field | |
| from functools import lru_cache | |
| from typing import Any | |
| import cv2 | |
| import numpy as np | |
| logger = logging.getLogger(__name__) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # DATA CLASSES | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class OCRToken: | |
| """Single recognised text token from any OCR engine.""" | |
| text: str | |
| confidence: float | |
| bbox: list # [[x1,y1],[x2,y1],[x2,y2],[x1,y2]] | |
| x: float = 0.0 | |
| y: float = 0.0 | |
| engine: str = "paddleocr" | |
| def __post_init__(self) -> None: | |
| if self.bbox: | |
| xs = [p[0] for p in self.bbox] | |
| ys = [p[1] for p in self.bbox] | |
| self.x = float(np.mean(xs)) | |
| self.y = float(np.mean(ys)) | |
| class TableCell: | |
| """Single cell from PPStructureV3 table extraction.""" | |
| row: int | |
| col: int | |
| row_span: int | |
| col_span: int | |
| text: str | |
| bbox: list | |
| class TableResult: | |
| """Full table extracted by PPStructureV3.""" | |
| cells: list[TableCell] = field(default_factory=list) | |
| html: str = "" | |
| confidence: float = 0.0 | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # PADDLE ENGINE SINGLETON | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class PaddleEngine: | |
| """ | |
| Thread-safe singleton wrapper around PaddleOCR and PPStructure. | |
| Initialization is deferred until first use to avoid penalising startup. | |
| Usage: | |
| engine = PaddleEngine.instance() | |
| tokens = engine.run_ocr(img_array) | |
| """ | |
| _ocr_instance: Any = None | |
| _struct_instance: Any = None | |
| def instance(cls) -> "PaddleEngine": | |
| """Return the singleton PaddleEngine.""" | |
| obj = cls.__new__(cls) | |
| return obj | |
| def _get_ocr(self) -> Any: | |
| """Lazily initialise PaddleOCR.""" | |
| if PaddleEngine._ocr_instance is None: | |
| logger.info("Initialising PaddleOCR engine β¦") | |
| try: | |
| from paddleocr import PaddleOCR # type: ignore | |
| PaddleEngine._ocr_instance = PaddleOCR( | |
| use_angle_cls=True, | |
| lang="en", | |
| show_log=False, | |
| use_gpu=False, | |
| # v3 models β highest accuracy for English invoices | |
| det_model_dir=None, | |
| rec_model_dir=None, | |
| cls_model_dir=None, | |
| ) | |
| logger.info("PaddleOCR initialised successfully.") | |
| except Exception as exc: | |
| logger.error("PaddleOCR init failed: %s", exc) | |
| raise | |
| return PaddleEngine._ocr_instance | |
| def _get_structure(self) -> Any: | |
| """Lazily initialise PPStructureV3.""" | |
| if PaddleEngine._struct_instance is None: | |
| logger.info("Initialising PPStructure engine β¦") | |
| try: | |
| from paddleocr import PPStructure # type: ignore | |
| PaddleEngine._struct_instance = PPStructure( | |
| table=True, | |
| ocr=True, | |
| show_log=False, | |
| use_gpu=False, | |
| ) | |
| logger.info("PPStructure initialised successfully.") | |
| except Exception as exc: | |
| logger.error("PPStructure init failed: %s", exc) | |
| raise | |
| return PaddleEngine._struct_instance | |
| # ββ OCR βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def run_ocr(self, img: np.ndarray) -> list[OCRToken]: | |
| """ | |
| Run PaddleOCR on a preprocessed image. | |
| Args: | |
| img: Grayscale or BGR numpy array. | |
| Returns: | |
| List of OCRToken objects sorted by (y, x). | |
| """ | |
| ocr = self._get_ocr() | |
| # PaddleOCR accepts BGR or gray; ensure uint8 | |
| if img.dtype != np.uint8: | |
| img = img.astype(np.uint8) | |
| result = ocr.ocr(img, cls=True) | |
| tokens: list[OCRToken] = [] | |
| if not result or not result[0]: | |
| return tokens | |
| for line in result[0]: | |
| if line is None: | |
| continue | |
| bbox, (text, conf) = line[0], line[1] | |
| if conf < 0.25: # discard near-noise detections | |
| continue | |
| tokens.append( | |
| OCRToken( | |
| text=text.strip(), | |
| confidence=float(conf), | |
| bbox=bbox, | |
| engine="paddleocr", | |
| ) | |
| ) | |
| tokens.sort(key=lambda t: (t.y, t.x)) | |
| logger.debug("PaddleOCR: %d tokens extracted.", len(tokens)) | |
| return tokens | |
| # ββ STRUCTURE / TABLE βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def run_structure(self, img_bgr: np.ndarray) -> list[dict]: | |
| """ | |
| Run PPStructureV3 layout + table analysis. | |
| Args: | |
| img_bgr: BGR numpy array (colour input required by PPStructure). | |
| Returns: | |
| List of region dicts from PPStructure, each containing: | |
| type, bbox, res (OCR or table HTML), and score. | |
| """ | |
| if len(img_bgr.shape) == 2: | |
| img_bgr = cv2.cvtColor(img_bgr, cv2.COLOR_GRAY2BGR) | |
| struct = self._get_structure() | |
| try: | |
| result = struct(img_bgr) | |
| logger.debug("PPStructure: %d regions detected.", len(result)) | |
| return result or [] | |
| except Exception as exc: | |
| logger.warning("PPStructure failed: %s", exc) | |
| return [] | |
| def extract_tables(self, img_bgr: np.ndarray) -> list[TableResult]: | |
| """ | |
| Extract all tables from the image using PPStructureV3. | |
| Parses the HTML output into structured TableCell objects. | |
| Returns: | |
| List of TableResult objects (one per detected table). | |
| """ | |
| regions = self.run_structure(img_bgr) | |
| tables: list[TableResult] = [] | |
| for region in regions: | |
| if region.get("type", "").lower() != "table": | |
| continue | |
| res = region.get("res", {}) | |
| html = res.get("html", "") if isinstance(res, dict) else "" | |
| score = float(region.get("score", 0.0)) | |
| cells = _parse_table_html(html) | |
| tables.append(TableResult(cells=cells, html=html, confidence=score)) | |
| return tables | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # TABLE HTML PARSER | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _parse_table_html(html: str) -> list[TableCell]: | |
| """ | |
| Parse HTML table string from PPStructureV3 into TableCell objects. | |
| Handles rowspan and colspan attributes. | |
| """ | |
| if not html: | |
| return [] | |
| try: | |
| import re | |
| cells: list[TableCell] = [] | |
| row_idx = 0 | |
| # Find all table rows | |
| tr_pattern = re.compile(r"<tr[^>]*>(.*?)</tr>", re.DOTALL | re.IGNORECASE) | |
| td_pattern = re.compile( | |
| r"<td([^>]*)>(.*?)</td>", re.DOTALL | re.IGNORECASE | |
| ) | |
| attr_pattern = re.compile(r'(\w+)=["\'](\d+)["\']') | |
| for tr_match in tr_pattern.finditer(html): | |
| row_html = tr_match.group(1) | |
| col_idx = 0 | |
| for td_match in td_pattern.finditer(row_html): | |
| attrs_str = td_match.group(1) | |
| content = re.sub(r"<[^>]+>", "", td_match.group(2)).strip() | |
| attrs: dict[str, int] = {} | |
| for attr_m in attr_pattern.finditer(attrs_str): | |
| attrs[attr_m.group(1).lower()] = int(attr_m.group(2)) | |
| row_span = attrs.get("rowspan", 1) | |
| col_span = attrs.get("colspan", 1) | |
| cells.append( | |
| TableCell( | |
| row=row_idx, | |
| col=col_idx, | |
| row_span=row_span, | |
| col_span=col_span, | |
| text=content, | |
| bbox=[], | |
| ) | |
| ) | |
| col_idx += col_span | |
| row_idx += 1 | |
| return cells | |
| except Exception as exc: | |
| logger.warning("Table HTML parse failed: %s", exc) | |
| return [] | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MODULE-LEVEL SINGLETON | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _engine: PaddleEngine | None = None | |
| def get_paddle_engine() -> PaddleEngine: | |
| """Return the module-level PaddleEngine singleton.""" | |
| global _engine | |
| if _engine is None: | |
| _engine = PaddleEngine.instance() | |
| return _engine | |