File size: 6,300 Bytes
d9106f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e87916b
d9106f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e87916b
d9106f7
 
 
 
e87916b
d9106f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
"""
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

    @property
    def is_available(self) -> bool:
        return _PADDLEOCR_AVAILABLE

    @property
    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

    @staticmethod
    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