Spaces:
Runtime error
Runtime error
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)
|