File size: 12,856 Bytes
b9d34d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
"""Local handwriting OCR using HuggingFace VisionEncoderDecoder models (offline, free).

PaddleOCR's printed-text recogniser is weak on handwriting. This module pairs
PaddleOCR's *detector* (to find text-line boxes) with a TrOCR-family handwriting
*recogniser* (a local HuggingFace transformers model) to read each line.

The recogniser is selected by language + an optional 'large' quality flag:

  en  (default) -> microsoft/trocr-base-handwritten     (English, fast-ish)
  en  + large   -> microsoft/trocr-large-handwritten    (English, slower, more accurate)
  hi  (Hindi)   -> sabaridsnfuji/Hindi_Offline_Handwritten_OCR (Hindi, EXPERIMENTAL)
  ne  (Nepali / generic Devanagari) -> aayushpuri01/TrOCR-Devanagari

It is OPTIONAL and heavy: it needs ``transformers`` + ``torch`` installed and a
one-time model download (hundreds of MB). It is slow on CPU and opt-in per
document. If the deps/model are missing it raises a clear, actionable error
instead of crashing, and unknown languages fall back to the English model.

Backward compatibility: ``lang='en'`` + ``large=False`` (the defaults, and the
module-level ``hw_engine`` singleton) behave byte-for-byte like the original
single-model implementation.
"""

import os
import threading

import cv2
import numpy as np

# ----------------------------------------------------------------------------
# Model registry. Each entry describes one recogniser.
#
#  id        : HuggingFace repo for the VisionEncoderDecoderModel weights.
#  processor : how to build the TrOCRProcessor:
#                "bundled"  -> TrOCRProcessor.from_pretrained(id)  (id ships the
#                              preprocessor_config + tokenizer)
#                ("manual", feat_repo, tok_repo) -> build from a ViT feature
#                              extractor repo + a decoder tokenizer repo, because
#                              the weights repo ships ONLY config + safetensors.
#  max_new_tokens : decode cap (the Hindi model is trained for <=64 chars/crop).
#  experimental   : True for models whose quality on this task is unvalidated.
# ----------------------------------------------------------------------------
_MODELS = {
    "en": {
        "id": "microsoft/trocr-base-handwritten",
        "processor": "bundled",
        "max_new_tokens": 96,
        "experimental": False,
    },
    "en_large": {
        "id": "microsoft/trocr-large-handwritten",
        "processor": "bundled",
        "max_new_tokens": 96,
        "experimental": False,
    },
    "hi": {
        # Only genuinely Hindi-trained option found. Repo ships weights only, so
        # the processor must be assembled from the encoder + decoder base repos.
        # Quality is modest and UNVALIDATED here — treat as experimental.
        "id": "sabaridsnfuji/Hindi_Offline_Handwritten_OCR",
        "processor": ("manual",
                      "google/vit-base-patch16-224-in21k",
                      "surajp/RoBERTa-hindi-guj-san"),
        "max_new_tokens": 64,  # model is trained for <=64 chars per crop
        "experimental": True,
    },
    "ne": {
        # Nepali Devanagari; bundled processor, clean drop-in. Usable as a
        # generic Devanagari fallback (reads many Hindi glyphs, Nepali-biased).
        "id": "aayushpuri01/TrOCR-Devanagari",
        "processor": "bundled",
        "max_new_tokens": 64,
        "experimental": True,
    },
}

# Default fallback when a requested language has no handwriting model.
_DEFAULT_KEY = "en"

# Backwards-compat constant some callers/tests may import.
MODEL_NAME = _MODELS[_DEFAULT_KEY]["id"]


def _resolve_key(lang: str, large: bool) -> str:
    """Map a (lang, large) request to a registry key, falling back to English.

    Devanagari-family languages share script; 'hi'/'ne'/'devanagari' all route
    to a Devanagari model. 'large' currently only applies to English (no large
    Devanagari model exists), so it is ignored for non-English.
    """
    lang = (lang or "en").strip().lower()
    if lang in ("en", "english"):
        return "en_large" if large else "en"
    if lang in ("hi", "hin", "hindi", "mr", "marathi", "sa"):
        return "hi"
    if lang in ("ne", "nep", "nepali", "devanagari"):
        return "ne"
    return _DEFAULT_KEY


def is_available() -> bool:
    """True if the optional handwriting dependencies are importable."""
    try:
        import torch  # noqa: F401
        import transformers  # noqa: F401
        return True
    except Exception:
        return False


def _dist(a, b) -> float:
    return float(np.hypot(a[0] - b[0], a[1] - b[1]))


def _crop_quad(img: np.ndarray, box) -> np.ndarray:
    """Perspective-warp a 4-point text box to an upright rectangle crop."""
    pts = np.array(box, dtype="float32")
    w = int(max(_dist(pts[0], pts[1]), _dist(pts[2], pts[3])))
    h = int(max(_dist(pts[1], pts[2]), _dist(pts[3], pts[0])))
    w = max(w, 1)
    h = max(h, 1)
    dst = np.array([[0, 0], [w, 0], [w, h], [0, h]], dtype="float32")
    matrix = cv2.getPerspectiveTransform(pts, dst)
    return cv2.warpPerspective(img, matrix, (w, h))


class HandwritingEngine:
    """Lazy-loaded TrOCR-family wrapper for ONE registry entry.

    Thread-safe; one model instance reused. Construct via the module-level
    ``get_engine(lang, large)`` factory so identical models are shared.
    """

    def __init__(self, key: str = _DEFAULT_KEY) -> None:
        self._key = key if key in _MODELS else _DEFAULT_KEY
        self._cfg = _MODELS[self._key]
        self._lock = threading.Lock()
        self._model = None
        self._processor = None
        self._loaded = False
        self._error = None

    @property
    def model_id(self) -> str:
        return self._cfg["id"]

    @property
    def experimental(self) -> bool:
        return bool(self._cfg.get("experimental"))

    def _build_processor(self):
        from transformers import TrOCRProcessor

        proc = self._cfg["processor"]
        if proc == "bundled":
            return TrOCRProcessor.from_pretrained(self._cfg["id"])
        # ("manual", feature_extractor_repo, tokenizer_repo)
        _, feat_repo, tok_repo = proc
        from transformers import AutoTokenizer

        # AutoImageProcessor is the modern name; in transformers 4.40.2
        # AutoFeatureExtractor still resolves it fine as a fallback.
        try:
            from transformers import AutoImageProcessor as _FeatLoader
        except Exception:  # very old fallback
            from transformers import AutoFeatureExtractor as _FeatLoader

        feature_extractor = _FeatLoader.from_pretrained(feat_repo)
        tokenizer = AutoTokenizer.from_pretrained(tok_repo)
        # TrOCRProcessor accepts an image processor as `image_processor=`; older
        # transformers used `feature_extractor=`. 4.40.2 accepts both kwargs.
        try:
            return TrOCRProcessor(image_processor=feature_extractor,
                                  tokenizer=tokenizer)
        except TypeError:
            return TrOCRProcessor(feature_extractor=feature_extractor,
                                  tokenizer=tokenizer)

    def _ensure(self) -> None:
        if self._loaded:
            if self._error:
                raise RuntimeError(self._error)
            return
        with self._lock:
            if self._loaded:
                if self._error:
                    raise RuntimeError(self._error)
                return
            try:
                import torch
                from transformers import VisionEncoderDecoderModel

                torch.set_num_threads(max(1, os.cpu_count() or 1))
                self._processor = self._build_processor()
                self._model = VisionEncoderDecoderModel.from_pretrained(
                    self._cfg["id"]
                )
                self._model.eval()
                self._loaded = True
            except (ImportError, ModuleNotFoundError) as exc:
                # Missing deps are DETERMINISTIC — they won't fix themselves at
                # runtime, so cache the failure permanently (fail fast).
                self._error = (
                    "Handwriting model '{}' unavailable ({}). Enable it with: "
                    "pip install transformers torch  (first use downloads the "
                    "model, a few hundred MB; non-English models also fetch a "
                    "tokenizer/feature-extractor repo).".format(
                        self._cfg["id"], exc
                    )
                )
                self._loaded = True
                raise RuntimeError(self._error)
            except Exception as exc:
                # TRANSIENT failure (network drop / partial HF download / HF
                # outage / disk full). Do NOT poison the engine: leave _loaded
                # False so the next request retries from_pretrained (which can
                # resume the partial download). Otherwise the engine would be
                # dead for the whole process until a server restart.
                raise RuntimeError(
                    "Handwriting model '{}' could not be loaded ({}). This is "
                    "often a transient network/download issue — try again; a "
                    "later request will retry the download.".format(
                        self._cfg["id"], exc
                    )
                ) from exc

    def _recognize(self, crop_bgr: np.ndarray) -> str:
        import torch
        from PIL import Image

        rgb = cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2RGB)
        pil = Image.fromarray(rgb)
        pixel_values = self._processor(
            images=pil, return_tensors="pt"
        ).pixel_values
        with torch.no_grad():
            generated = self._model.generate(
                pixel_values, max_new_tokens=self._cfg["max_new_tokens"]
            )
        return self._processor.batch_decode(
            generated, skip_special_tokens=True
        )[0]

    def ocr_text(self, img_bgr: np.ndarray, boxes: list) -> str:
        """Recognize handwriting in ``img_bgr`` given detected line ``boxes``.

        Boxes are ordered into reading order (top-to-bottom, left-to-right),
        each cropped and passed through the recogniser, joined by newlines.
        """
        self._ensure()
        if not boxes:
            return ""

        items = []
        for box in boxes:
            # Skip any malformed box (not a 4-point quad) so a single bad
            # detection can't abort the whole page's handwriting OCR.
            if not box or len(box) != 4:
                continue
            try:
                ys = [p[1] for p in box]
                xs = [p[0] for p in box]
            except (TypeError, IndexError):
                continue
            items.append({
                "box": box,
                "cy": sum(ys) / len(ys),
                "cx": sum(xs) / len(xs),
                "h": max(ys) - min(ys),
            })
        if not items:
            return ""
        heights = [it["h"] for it in items if it["h"] > 0]
        tol = max((float(np.median(heights)) if heights else 12.0) * 0.6, 8.0)
        items.sort(key=lambda it: (it["cy"], it["cx"]))

        rows = [[items[0]]]
        cy = items[0]["cy"]
        for it in items[1:]:
            if abs(it["cy"] - cy) <= tol:
                rows[-1].append(it)
            else:
                rows.append([it])
                cy = it["cy"]

        lines = []
        for row in rows:
            for it in sorted(row, key=lambda x: x["cx"]):
                try:
                    crop = _crop_quad(img_bgr, it["box"])
                    if crop.size == 0:
                        continue
                    text = self._recognize(crop).strip()
                except Exception:
                    text = ""
                if text:
                    lines.append(text)
        return "\n".join(lines)


# ----------------------------------------------------------------------------
# Factory: one cached engine per registry key (so 'en', 'en_large' and a
# Devanagari model can coexist without reloading). Thread-safe.
# ----------------------------------------------------------------------------
_engines: dict = {}
_engines_lock = threading.Lock()


def get_engine(lang: str = "en", large: bool = False) -> "HandwritingEngine":
    """Return a cached handwriting engine for the requested (lang, large)."""
    key = _resolve_key(lang, large)
    eng = _engines.get(key)
    if eng is not None:
        return eng
    with _engines_lock:
        eng = _engines.get(key)
        if eng is None:
            eng = HandwritingEngine(key)
            _engines[key] = eng
        return eng


# Backwards-compatible module-level singleton (English base), so existing
# `from pipeline.handwriting import hw_engine` callers keep working unchanged.
hw_engine = get_engine("en", large=False)