File size: 9,875 Bytes
b0414d1
82d15c0
b0414d1
 
 
 
 
 
 
 
 
 
82d15c0
a1608c4
b0414d1
 
a1608c4
b0414d1
 
 
a1608c4
 
 
 
 
b0414d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1608c4
 
b0414d1
 
 
 
 
 
a1608c4
b0414d1
 
 
 
 
 
 
 
a1608c4
b0414d1
 
 
 
 
 
 
 
a1608c4
 
b0414d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82d15c0
 
 
b0414d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1608c4
b0414d1
 
 
 
 
 
 
 
 
 
 
 
 
a1608c4
 
b0414d1
 
 
 
a1608c4
82d15c0
b0414d1
 
 
 
 
 
 
 
 
 
82d15c0
 
b0414d1
 
 
 
 
 
 
 
 
 
 
 
 
 
a1608c4
b0414d1
 
 
 
 
 
 
 
a1608c4
 
 
b0414d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d19e69
b0414d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1608c4
 
82d15c0
 
 
b0414d1
 
82d15c0
 
a1608c4
 
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
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
"""OCR engine abstraction with local PaddleOCR/EasyOCR backends.

Configuration is controlled by environment variables:

- ``SHELF_OCR_ENGINE=auto|paddle_v4|paddle_v5|easyocr|none``
- ``SHELF_OCR_AUTO_ORDER=paddle_v4,paddle_v5,easyocr``
- ``SHELF_PADDLE_DET_MODEL_DIR`` / ``SHELF_PADDLE_REC_MODEL_DIR`` /
  ``SHELF_PADDLE_CLS_MODEL_DIR`` for fully local Paddle model placement.

No cloud OCR API is used.  PP-OCRv5 is optional and selected only when available
or explicitly requested; stable PP-OCRv4 remains in the default auto order until
local challenge metrics prove v5 is better on the five labeled videos.
"""

from __future__ import annotations

import logging
import os
from threading import Lock
from typing import Any

import numpy as np

logger = logging.getLogger(__name__)

_VALID_ENGINES = {"auto", "paddle_v4", "paddle_v5", "easyocr", "none"}
_DEFAULT_AUTO_ORDER = ("paddle_v4", "paddle_v5", "easyocr")


def normalize_ocr_engine_name(value: str | None) -> str:
    """Normalize OCR engine selection from config/env."""
    name = (
        (value or os.getenv("SHELF_OCR_ENGINE", "auto"))
        .strip()
        .lower()
        .replace("-", "_")
    )
    aliases = {
        "paddle": "paddle_v4",
        "paddle4": "paddle_v4",
        "ppocr4": "paddle_v4",
        "pp_ocr_v4": "paddle_v4",
        "paddle5": "paddle_v5",
        "ppocr5": "paddle_v5",
        "pp_ocr_v5": "paddle_v5",
        "off": "none",
        "disabled": "none",
    }
    name = aliases.get(name, name)
    return name if name in _VALID_ENGINES else "auto"


def _auto_order() -> list[str]:
    raw = os.getenv("SHELF_OCR_AUTO_ORDER", ",".join(_DEFAULT_AUTO_ORDER))
    order = [normalize_ocr_engine_name(x) for x in raw.split(",")]
    return [
        x for x in order if x in {"paddle_v4", "paddle_v5", "easyocr"}
    ] or list(_DEFAULT_AUTO_ORDER)


def _paddle_model_dir_kwargs() -> dict[str, str]:
    """Collect optional local Paddle model directories from env."""
    mapping = {
        "det_model_dir": os.getenv("SHELF_PADDLE_DET_MODEL_DIR"),
        "rec_model_dir": os.getenv("SHELF_PADDLE_REC_MODEL_DIR"),
        "cls_model_dir": os.getenv("SHELF_PADDLE_CLS_MODEL_DIR"),
    }
    return {k: v for k, v in mapping.items() if v}


class OCREngine:
    """Lazy OCR backend wrapper.

    PaddleOCR is preferred for digits/prices.  EasyOCR is a local fallback and is
    still useful for Russian product names.  The wrapper avoids importing heavy
    OCR packages until the first inference call.
    """

    _easyocr_lock = Lock()

    def __init__(
        self,
        lang: str = "en",
        force_easyocr: bool = False,
        engine: str | None = None,
    ) -> None:
        self.lang = lang
        self.force_easyocr = force_easyocr
        self.engine_name = (
            "easyocr" if force_easyocr else normalize_ocr_engine_name(engine)
        )
        self._ocr: Any | None = None
        self._backend: str | None = (
            None  # paddle_v4 | paddle_v5 | easyocr | none
        )

    def _load(self) -> None:
        if self.engine_name == "none":
            self._backend = "none"
            self._ocr = None
            logger.info("OCR backend disabled by SHELF_OCR_ENGINE=none")
            return

        order = (
            [self.engine_name] if self.engine_name != "auto" else _auto_order()
        )
        for backend in order:
            if backend.startswith("paddle"):
                if self._try_load_paddle(backend):
                    return
            elif backend == "easyocr":
                if self._try_load_easyocr():
                    return

        self._ocr = None
        self._backend = "none"
        logger.warning("No local OCR backend could be loaded; OCR disabled")

    def _try_load_paddle(self, backend: str) -> bool:
        try:
            from paddleocr import PaddleOCR
        except Exception as exc:
            logger.info("PaddleOCR import failed for %s: %s", backend, exc)
            return False

        kwargs = _paddle_model_dir_kwargs()
        attempts = self._paddle_constructor_attempts(backend, kwargs)
        for attempt in attempts:
            try:
                self._ocr = PaddleOCR(**attempt)
                self._backend = backend
                logger.info(
                    "OCR backend: PaddleOCR %s (lang=%s)", backend, self.lang
                )
                return True
            except TypeError as exc:
                logger.debug(
                    "PaddleOCR constructor rejected %s: %s", attempt, exc
                )
            except Exception as exc:
                logger.info(
                    "PaddleOCR %s unavailable with %s: %s",
                    backend,
                    attempt,
                    exc,
                )
        return False

    def _paddle_constructor_attempts(
        self, backend: str, model_dirs: dict[str, str]
    ) -> list[dict[str, Any]]:
        """Return constructor kwargs from modern to legacy PaddleOCR APIs."""
        version = "PP-OCRv5" if backend == "paddle_v5" else "PP-OCRv4"
        common: dict[str, Any] = {"lang": self.lang, **model_dirs}
        attempts: list[dict[str, Any]] = [
            {
                **common,
                "ocr_version": version,
                "use_textline_orientation": True,
            },
            {
                **common,
                "ocr_version": version,
                "use_angle_cls": True,
                "show_log": False,
            },
            {**common, "use_textline_orientation": True},
            {**common, "use_angle_cls": True, "show_log": False},
        ]
        if backend == "paddle_v5":
            attempts.insert(
                0,
                {
                    **common,
                    "ocr_version": "PP-OCRv5",
                    "text_detection_model_name": "PP-OCRv5_mobile_det",
                    "text_recognition_model_name": "PP-OCRv5_mobile_rec",
                    "use_textline_orientation": True,
                },
            )
        return attempts

    def _try_load_easyocr(self) -> bool:
        try:
            import easyocr

            langs = ["ru", "en"] if self.lang == "ru" else ["en"]
            with self._easyocr_lock:
                self._ocr = easyocr.Reader(langs, verbose=False)
            self._backend = "easyocr"
            logger.info("OCR backend: EasyOCR (langs=%s)", langs)
            return True
        except Exception as exc:
            logger.info("EasyOCR unavailable: %s", exc)
            return False

    def run(self, image: np.ndarray) -> list[tuple[list, str, float]]:
        """Return a list of ``(box, text, confidence)`` tuples."""
        if image is None or image.size == 0:
            return []
        if self._backend is None:
            self._load()

        if self._backend == "none" or self._ocr is None:
            return []

        try:
            if str(self._backend).startswith("paddle"):
                return self._run_paddle(image)
            return self._run_easyocr(image)
        except Exception as exc:
            logger.warning("OCR inference failed (%s)", exc)
            return []

    def _run_paddle(self, image: np.ndarray) -> list[tuple[list, str, float]]:
        if hasattr(self._ocr, "ocr"):
            try:
                results = self._ocr.ocr(image, cls=True)
            except TypeError:
                results = self._ocr.ocr(image)
        elif hasattr(self._ocr, "predict"):
            results = self._ocr.predict(image)
        else:
            return []
        return self._parse_paddle_results(results)

    def _parse_paddle_results(
        self, results: Any
    ) -> list[tuple[list, str, float]]:
        out: list[tuple[list, str, float]] = []
        if not results:
            return out
        for page in results if isinstance(results, list) else [results]:
            if page is None:
                continue
            if isinstance(page, dict):
                out.extend(self._parse_paddle_dict(page))
                continue
            for item in page or []:
                if item is None:
                    continue
                if isinstance(item, dict):
                    out.extend(self._parse_paddle_dict(item))
                    continue
                try:
                    box, payload = item
                    if not payload:
                        continue
                    text, conf = payload
                    out.append((box, str(text), float(conf)))
                except Exception:
                    continue
        return out

    @staticmethod
    def _parse_paddle_dict(
        page: dict[str, Any],
    ) -> list[tuple[list, str, float]]:
        texts = page.get("rec_texts") or page.get("texts") or []
        scores = page.get("rec_scores") or page.get("scores") or []
        boxes = (
            page.get("dt_polys")
            or page.get("rec_polys")
            or page.get("boxes")
            or []
        )
        out: list[tuple[list, str, float]] = []
        for idx, text in enumerate(texts):
            box = (
                boxes[idx].tolist()
                if idx < len(boxes) and hasattr(boxes[idx], "tolist")
                else (boxes[idx] if idx < len(boxes) else [])
            )
            conf = float(scores[idx]) if idx < len(scores) else 0.0
            out.append((box, str(text), conf))
        return out

    def _run_easyocr(self, image: np.ndarray) -> list[tuple[list, str, float]]:
        results = self._ocr.readtext(image, detail=1)
        out: list[tuple[list, str, float]] = []
        for box_pts, text, conf in results:
            out.append((box_pts, str(text), float(conf)))
        return out

    def run_texts(self, image: np.ndarray) -> list[str]:
        return [text for _, text, _ in self.run(image)]