validops-east-1 commited on
Commit
6a876ee
·
1 Parent(s): a980424

feat: add paddle ocr

Browse files
Files changed (5) hide show
  1. Dockerfile +7 -0
  2. app/api/server.py +13 -0
  3. app/config.py +17 -0
  4. app/services/ocr_service.py +174 -26
  5. requirements.txt +14 -1
Dockerfile CHANGED
@@ -16,6 +16,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
16
  libssl-dev \
17
  nodejs \
18
  zlib1g-dev \
 
 
 
 
 
 
 
19
  && rm -rf /var/lib/apt/lists/*
20
 
21
  RUN groupadd --gid 1000 appuser && \
 
16
  libssl-dev \
17
  nodejs \
18
  zlib1g-dev \
19
+ # Runtime libs for PaddleOCR / opencv-contrib-python (cv2), mirroring the
20
+ # reference reconciliation-file-processing-service Dockerfile.
21
+ libglib2.0-0 \
22
+ libsm6 \
23
+ libxext6 \
24
+ libxrender-dev \
25
+ libgomp1 \
26
  && rm -rf /var/lib/apt/lists/*
27
 
28
  RUN groupadd --gid 1000 appuser && \
app/api/server.py CHANGED
@@ -111,6 +111,19 @@ async def lifespan(app: FastAPI):
111
  _logger.info("Embedding service initialized with dims: %s", _embedding_service.loaded_dimensions)
112
  _logger.info("Vector store service initialized with %d existing stores", len(_vector_store_service.list_stores()))
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  redis = create_redis_client(_settings.redis_url) if _settings.redis_url else None
115
  scripts = await load_scripts(redis) if redis else {}
116
  app.state.redis = redis
 
111
  _logger.info("Embedding service initialized with dims: %s", _embedding_service.loaded_dimensions)
112
  _logger.info("Vector store service initialized with %d existing stores", len(_vector_store_service.list_stores()))
113
 
114
+ # Warm the OCR engine at startup so the PP-OCRv6 models are downloaded and
115
+ # cached before the first request (avoids slow first OCR). Runs on the
116
+ # shared thread pool; failure is non-fatal (engine initializes lazily on
117
+ # first use).
118
+ try:
119
+ from app.core.thread_pool import run_in_executor
120
+ from app.services.ocr_service import warmup as warmup_ocr
121
+
122
+ await run_in_executor(warmup_ocr)
123
+ _logger.info("OCR engine warmed up at startup")
124
+ except Exception as exc:
125
+ _logger.warning("OCR engine warm-up failed (will lazy-init on first use): %s", exc)
126
+
127
  redis = create_redis_client(_settings.redis_url) if _settings.redis_url else None
128
  scripts = await load_scripts(redis) if redis else {}
129
  app.state.redis = redis
app/config.py CHANGED
@@ -31,6 +31,8 @@ class Settings(BaseSettings):
31
  max_batch_files: int = 10
32
  max_batch_urls: int = 20
33
 
 
 
34
  ocr_det_cuda: bool = False
35
  ocr_det_dml: bool = False
36
  ocr_cls_cuda: bool = False
@@ -38,6 +40,21 @@ class Settings(BaseSettings):
38
  ocr_rec_cuda: bool = False
39
  ocr_rec_dml: bool = False
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  self_ping_url: str = "https://aetherbase-llm-ready-data.hf.space/ping"
42
  spacy_model: str = "en_core_web_sm"
43
 
 
31
  max_batch_files: int = 10
32
  max_batch_urls: int = 20
33
 
34
+ # Legacy RapidOCR flags -- retained for the commented-out RapidOCR engine
35
+ # (see app/services/ocr_service.py). No longer used at runtime.
36
  ocr_det_cuda: bool = False
37
  ocr_det_dml: bool = False
38
  ocr_cls_cuda: bool = False
 
40
  ocr_rec_cuda: bool = False
41
  ocr_rec_dml: bool = False
42
 
43
+ # PaddleOCR PP-OCRv6 engine (default: PP-OCRv6 small tier on ONNX Runtime,
44
+ # mirroring the reference reconciliation-file-processing-service setup).
45
+ ocr_engine: str = "onnxruntime" # paddle | paddle_static | paddle_dynamic | onnxruntime | transformers
46
+ ocr_device: str = "cpu"
47
+ ocr_lang: Optional[str] = None # e.g. "en"; None uses the model defaults
48
+ ocr_det_model_name: str = "PP-OCRv6_small_det"
49
+ ocr_rec_model_name: str = "PP-OCRv6_small_rec"
50
+ ocr_use_doc_orientation_classify: bool = False
51
+ ocr_use_doc_unwarping: bool = False
52
+ ocr_use_textline_orientation: bool = True
53
+ # Max concurrent predictions on the shared PaddleOCR engine. PaddleX
54
+ # pipelines are not documented thread-safe, so this serializes inference
55
+ # on the shared instance (not a new worker pool).
56
+ ocr_max_concurrent: int = 1
57
+
58
  self_ping_url: str = "https://aetherbase-llm-ready-data.hf.space/ping"
59
  spacy_model: str = "en_core_web_sm"
60
 
app/services/ocr_service.py CHANGED
@@ -17,34 +17,113 @@ _settings = get_settings()
17
  _lock = threading.Lock()
18
  _engine = None
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  def _get_engine():
 
 
 
 
 
 
 
 
22
  global _engine
23
  if _engine is None:
24
  with _lock:
25
  if _engine is None:
26
- from rapidocr_onnxruntime import RapidOCR
27
- _engine = RapidOCR(
28
- Det={"use_cuda": _settings.ocr_det_cuda, "use_dml": _settings.ocr_det_dml},
29
- Cls={"use_cuda": _settings.ocr_cls_cuda, "use_dml": _settings.ocr_cls_dml},
30
- Rec={"use_cuda": _settings.ocr_rec_cuda, "use_dml": _settings.ocr_rec_dml},
31
- print_verbose=False,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  )
33
  return _engine
34
 
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  def _to_numpy(source) -> Union[np.ndarray, str]:
37
  if isinstance(source, Image.Image):
38
- img = source
39
- if img.mode not in ("RGB", "L", "RGBA"):
40
- img = img.convert("RGB")
41
- return np.array(img)
42
 
43
  if isinstance(source, (bytes, bytearray)):
44
  img = Image.open(io.BytesIO(source))
45
- if img.mode not in ("RGB", "L", "RGBA"):
46
- img = img.convert("RGB")
47
- return np.array(img)
48
 
49
  if isinstance(source, str):
50
  parsed = urlparse(source)
@@ -53,12 +132,14 @@ def _to_numpy(source) -> Union[np.ndarray, str]:
53
  resp = httpx.get(source, follow_redirects=True, timeout=30)
54
  resp.raise_for_status()
55
  img = Image.open(io.BytesIO(resp.content))
56
- if img.mode not in ("RGB", "L", "RGBA"):
57
- img = img.convert("RGB")
58
- return np.array(img)
59
  return source
60
 
61
  if isinstance(source, np.ndarray):
 
 
 
 
62
  return source
63
 
64
  raise TypeError(
@@ -66,6 +147,47 @@ def _to_numpy(source) -> Union[np.ndarray, str]:
66
  )
67
 
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  def ocr_image(
70
  source,
71
  *,
@@ -74,18 +196,32 @@ def ocr_image(
74
  use_rec: bool = True,
75
  text_score: float = 0.5,
76
  ) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  engine = _get_engine()
78
  img = _to_numpy(source)
79
- result, _ = engine(
80
- img,
81
- use_det=use_det,
82
- use_cls=use_cls,
83
- use_rec=use_rec,
84
- text_score=text_score,
85
- )
86
- if not result:
87
- return ""
88
- lines = [item[1] for item in result if len(item) > 1 and item[1]]
89
  return "\n".join(lines)
90
 
91
 
@@ -119,3 +255,15 @@ def ocr_pdf(source: Union[str, bytes], *, dpi: int = 150) -> str:
119
  class OCRService:
120
  def __init__(self) -> None:
121
  self._engine = _get_engine()
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  _lock = threading.Lock()
18
  _engine = None
19
 
20
+ # ---------------------------------------------------------------------------
21
+ # Legacy RapidOCR implementation (RapidOCR) -- commented out, NOT deleted.
22
+ #
23
+ # It used the ONNX Runtime based `rapidocr_onnxruntime` package with per-module
24
+ # CUDA/DML flags. Kept here for reference / easy rollback.
25
+ #
26
+ # def _get_engine():
27
+ # global _engine
28
+ # if _engine is None:
29
+ # with _lock:
30
+ # if _engine is None:
31
+ # from rapidocr_onnxruntime import RapidOCR
32
+ # _engine = RapidOCR(
33
+ # Det={"use_cuda": _settings.ocr_det_cuda, "use_dml": _settings.ocr_det_dml},
34
+ # Cls={"use_cuda": _settings.ocr_cls_cuda, "use_dml": _settings.ocr_cls_dml},
35
+ # Rec={"use_cuda": _settings.ocr_rec_cuda, "use_dml": _settings.ocr_rec_dml},
36
+ # print_verbose=False,
37
+ # )
38
+ # return _engine
39
+ #
40
+ # def ocr_image(source, *, use_det=True, use_cls=True, use_rec=True, text_score=0.5) -> str:
41
+ # engine = _get_engine()
42
+ # img = _to_numpy(source)
43
+ # result, _ = engine(
44
+ # img,
45
+ # use_det=use_det,
46
+ # use_cls=use_cls,
47
+ # use_rec=use_rec,
48
+ # text_score=text_score,
49
+ # )
50
+ # if not result:
51
+ # return ""
52
+ # lines = [item[1] for item in result if len(item) > 1 and item[1]]
53
+ # return "\n".join(lines)
54
+ # ---------------------------------------------------------------------------
55
+
56
+ # Guards the shared PaddleOCR pipeline instance. PaddleOCR/PaddleX pipelines are
57
+ # not documented as thread-safe for concurrent predict() calls, so OCR inference
58
+ # is serialized on the shared engine (CPU-bound anyway). This is NOT a new worker
59
+ # pool -- it merely limits concurrent use of the single shared model instance.
60
+ _infer_semaphore = threading.BoundedSemaphore(
61
+ max(1, _settings.ocr_max_concurrent)
62
+ )
63
+
64
 
65
  def _get_engine():
66
+ """Return the shared PaddleOCR PP-OCRv6 Small engine (lazy singleton).
67
+
68
+ Uses the PP-OCRv6 *small* tier models (PP-OCRv6_small_det /
69
+ PP-OCRv6_small_rec) shipped with PaddleOCR 3.7.0, executed on the ONNX
70
+ Runtime backend (engine="onnxruntime"). This mirrors the OCR benchmark /
71
+ reference reconciliation-file-processing-service, which pairs
72
+ ``paddleocr==3.7.0`` with PP-OCRv6-small models and the ONNX Runtime engine.
73
+ """
74
  global _engine
75
  if _engine is None:
76
  with _lock:
77
  if _engine is None:
78
+ from paddleocr import PaddleOCR
79
+
80
+ kwargs: dict = {
81
+ "text_detection_model_name": _settings.ocr_det_model_name,
82
+ "text_recognition_model_name": _settings.ocr_rec_model_name,
83
+ "use_doc_orientation_classify": _settings.ocr_use_doc_orientation_classify,
84
+ "use_doc_unwarping": _settings.ocr_use_doc_unwarping,
85
+ "use_textline_orientation": _settings.ocr_use_textline_orientation,
86
+ "engine": _settings.ocr_engine,
87
+ "device": _settings.ocr_device,
88
+ }
89
+ if _settings.ocr_lang:
90
+ kwargs["lang"] = _settings.ocr_lang
91
+ _engine = PaddleOCR(**kwargs)
92
+ _logger.info(
93
+ "PaddleOCR engine ready (engine=%s device=%s det=%s rec=%s)",
94
+ _settings.ocr_engine,
95
+ _settings.ocr_device,
96
+ _settings.ocr_det_model_name,
97
+ _settings.ocr_rec_model_name,
98
  )
99
  return _engine
100
 
101
 
102
+ def _to_rgb(img: Image.Image) -> Image.Image:
103
+ """Normalise an image to 3-channel RGB.
104
+
105
+ PaddleOCR's recognition model asserts a 3-channel input (``imgC ==
106
+ img.shape[2]``), so RGBA (4-channel) and palette/grayscale inputs must be
107
+ converted -- unlike RapidOCR, which accepted RGBA arrays as-is.
108
+ """
109
+ if img.mode == "RGBA":
110
+ # Flatten alpha onto a white background: standard for scanned documents
111
+ # and avoids black halos around transparent regions.
112
+ background = Image.new("RGB", img.size, (255, 255, 255))
113
+ background.paste(img, mask=img.split()[-1])
114
+ return background
115
+ if img.mode != "RGB":
116
+ return img.convert("RGB")
117
+ return img
118
+
119
+
120
  def _to_numpy(source) -> Union[np.ndarray, str]:
121
  if isinstance(source, Image.Image):
122
+ return np.array(_to_rgb(source))
 
 
 
123
 
124
  if isinstance(source, (bytes, bytearray)):
125
  img = Image.open(io.BytesIO(source))
126
+ return np.array(_to_rgb(img))
 
 
127
 
128
  if isinstance(source, str):
129
  parsed = urlparse(source)
 
132
  resp = httpx.get(source, follow_redirects=True, timeout=30)
133
  resp.raise_for_status()
134
  img = Image.open(io.BytesIO(resp.content))
135
+ return np.array(_to_rgb(img))
 
 
136
  return source
137
 
138
  if isinstance(source, np.ndarray):
139
+ # Defensive: PaddleOCR needs 3-channel input. If a 4-channel array was
140
+ # passed directly, drop the alpha channel.
141
+ if source.ndim == 3 and source.shape[2] == 4:
142
+ source = source[:, :, :3]
143
  return source
144
 
145
  raise TypeError(
 
147
  )
148
 
149
 
150
+ def _append_rec_texts(item: dict, texts: list[str], text_score: float) -> None:
151
+ """Append recognized lines from one PaddleOCR result dict.
152
+
153
+ ``rec_texts`` holds the recognized strings and ``rec_scores`` the matching
154
+ confidences (same index order). Lines below ``text_score`` are dropped,
155
+ mirroring the old RapidOCR ``text_score`` behaviour.
156
+ """
157
+ rec_texts = item.get("rec_texts") or []
158
+ rec_scores = item.get("rec_scores")
159
+ for idx, text in enumerate(rec_texts):
160
+ if not text:
161
+ continue
162
+ if rec_scores is not None and idx < len(rec_scores):
163
+ try:
164
+ score = float(rec_scores[idx])
165
+ except (TypeError, ValueError):
166
+ score = text_score
167
+ if score < text_score:
168
+ continue
169
+ texts.append(text)
170
+
171
+
172
+ def _flatten_result(result, *, text_score: float = 0.5) -> list[str]:
173
+ """Flatten a PaddleOCR ``predict()`` result into recognized text lines.
174
+
175
+ Handles both the single-dict form and the nested list-of-dicts form the
176
+ pipeline can return (the reference project iterates the same shapes).
177
+ """
178
+ texts: list[str] = []
179
+ if not result:
180
+ return texts
181
+ for item in result:
182
+ if isinstance(item, dict):
183
+ _append_rec_texts(item, texts, text_score)
184
+ elif isinstance(item, (list, tuple)):
185
+ for sub in item:
186
+ if isinstance(sub, dict):
187
+ _append_rec_texts(sub, texts, text_score)
188
+ return texts
189
+
190
+
191
  def ocr_image(
192
  source,
193
  *,
 
196
  use_rec: bool = True,
197
  text_score: float = 0.5,
198
  ) -> str:
199
+ """Run OCR on an image and return the recognized text lines.
200
+
201
+ API contract is unchanged from the RapidOCR implementation. Note: the
202
+ PaddleOCR 3.x pipeline always performs detection + recognition together;
203
+ ``use_det`` / ``use_cls`` / ``use_rec`` are accepted for backward
204
+ compatibility. ``use_cls`` maps to the textline-orientation module, which is
205
+ configured at engine initialisation (``ocr_use_textline_orientation``).
206
+ """
207
+ if not (use_det and use_rec):
208
+ _logger.warning(
209
+ "ocr_image: use_det/use_rec are no-ops with the PaddleOCR pipeline "
210
+ "(detection+recognition always run); got use_det=%s use_rec=%s",
211
+ use_det,
212
+ use_rec,
213
+ )
214
+ if not use_cls:
215
+ _logger.warning(
216
+ "ocr_image: use_cls=%s is ignored; textline orientation is fixed at "
217
+ "engine init via ocr_use_textline_orientation",
218
+ use_cls,
219
+ )
220
  engine = _get_engine()
221
  img = _to_numpy(source)
222
+ with _infer_semaphore:
223
+ result = engine.predict(img)
224
+ lines = _flatten_result(result, text_score=text_score)
 
 
 
 
 
 
 
225
  return "\n".join(lines)
226
 
227
 
 
255
  class OCRService:
256
  def __init__(self) -> None:
257
  self._engine = _get_engine()
258
+
259
+
260
+ def warmup() -> None:
261
+ """Force model download + engine initialisation at startup.
262
+
263
+ PaddleOCR downloads the PP-OCRv6 small ONNX models and caches them under
264
+ ``~/.paddlex/official_models`` on first use. Calling this during the app
265
+ lifespan (in the shared thread pool) means the first OCR request never
266
+ pays the download/init cost.
267
+ """
268
+ _get_engine()
269
+ _logger.info("PaddleOCR engine warmed up (models downloaded and cached)")
requirements.txt CHANGED
@@ -10,12 +10,25 @@ aiohttp==3.11.13
10
  # --- Media / file processing ---
11
  numpy==2.2.6
12
  pillow==10.3.0
13
- opencv-python-headless==4.12.0.88
 
 
 
14
  pypdfium2==4.30.0
 
 
15
  rapidocr-onnxruntime==1.4.4
16
  onnxruntime==1.20.1
17
  markitdown[all]==0.1.5
18
 
 
 
 
 
 
 
 
 
19
  # --- Data / analysis ---
20
  pandas==3.0.1
21
  matplotlib==3.9.2
 
10
  # --- Media / file processing ---
11
  numpy==2.2.6
12
  pillow==10.3.0
13
+ # Aligned with paddlex[ocr-core]'s opencv-contrib-python==4.10.0.84 pin (both
14
+ # provide cv2; same version avoids install-time file conflicts). Mirrors the
15
+ # reference reconciliation-file-processing-service requirements.
16
+ opencv-python-headless==4.10.0.84
17
  pypdfium2==4.30.0
18
+ # Legacy RapidOCR engine -- kept installed for the commented-out implementation
19
+ # in app/services/ocr_service.py (enables easy rollback).
20
  rapidocr-onnxruntime==1.4.4
21
  onnxruntime==1.20.1
22
  markitdown[all]==0.1.5
23
 
24
+ # --- OCR: PaddleOCR PP-OCRv6 Small on ONNX Runtime ---
25
+ # PaddleOCR 3.7.0 ships the PP-OCRv6 model family (tiny/small/medium). The
26
+ # engine runs on ONNX Runtime (engine="onnxruntime"), which does NOT require the
27
+ # PaddlePaddle framework -- PaddleX 3.7 avoids importing `paddle` unless a
28
+ # paddle_static/paddle_dynamic engine is selected. PP-OCRv6-small ONNX models
29
+ # are downloaded on first use to ~/.paddlex/official_models.
30
+ paddleocr==3.7.0
31
+
32
  # --- Data / analysis ---
33
  pandas==3.0.1
34
  matplotlib==3.9.2