dsh-ab-ocr 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +378 -0
- package/cordis.patch.yml +7 -0
- package/lib/artifacts.d.ts +100 -0
- package/lib/artifacts.d.ts.map +1 -0
- package/lib/artifacts.js +97 -0
- package/lib/artifacts.js.map +1 -0
- package/lib/config.d.ts +77 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +51 -0
- package/lib/config.js.map +1 -0
- package/lib/documents.d.ts +62 -0
- package/lib/documents.d.ts.map +1 -0
- package/lib/documents.js +173 -0
- package/lib/documents.js.map +1 -0
- package/lib/events.d.ts +161 -0
- package/lib/events.d.ts.map +1 -0
- package/lib/events.js +158 -0
- package/lib/events.js.map +1 -0
- package/lib/filename.d.ts +47 -0
- package/lib/filename.d.ts.map +1 -0
- package/lib/filename.js +77 -0
- package/lib/filename.js.map +1 -0
- package/lib/index.d.ts +85 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +1761 -0
- package/lib/index.js.map +1 -0
- package/lib/levels.d.ts +24 -0
- package/lib/levels.d.ts.map +1 -0
- package/lib/levels.js +52 -0
- package/lib/levels.js.map +1 -0
- package/lib/plan.d.ts +103 -0
- package/lib/plan.d.ts.map +1 -0
- package/lib/plan.js +210 -0
- package/lib/plan.js.map +1 -0
- package/lib/recognize.d.ts +36 -0
- package/lib/recognize.d.ts.map +1 -0
- package/lib/recognize.js +390 -0
- package/lib/recognize.js.map +1 -0
- package/lib/records.d.ts +91 -0
- package/lib/records.d.ts.map +1 -0
- package/lib/records.js +130 -0
- package/lib/records.js.map +1 -0
- package/lib/render.d.ts +19 -0
- package/lib/render.d.ts.map +1 -0
- package/lib/render.js +45 -0
- package/lib/render.js.map +1 -0
- package/lib/sandbox.d.ts +54 -0
- package/lib/sandbox.d.ts.map +1 -0
- package/lib/sandbox.js +101 -0
- package/lib/sandbox.js.map +1 -0
- package/lib/types.d.ts +147 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +7 -0
- package/lib/types.js.map +1 -0
- package/lib/worker.d.ts +107 -0
- package/lib/worker.d.ts.map +1 -0
- package/lib/worker.js +143 -0
- package/lib/worker.js.map +1 -0
- package/package.json +98 -0
- package/python/README.md +125 -0
- package/python/assemble.py +358 -0
- package/python/clean.py +197 -0
- package/python/layout.py +403 -0
- package/python/ocr_worker.py +516 -0
- package/python/requirements.txt +16 -0
- package/python/source.py +182 -0
- package/scripts/setup.mjs +251 -0
- package/tsconfig.json +30 -0
- package/tsdown.config.ts +18 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""OCR worker: one page at a time in, one Markdown document out.
|
|
3
|
+
|
|
4
|
+
The worker reads a JSON job spec from stdin and writes newline-delimited JSON
|
|
5
|
+
events to stdout, so the caller can report progress while a long document is
|
|
6
|
+
still being recognized. Diagnostics the caller cannot act on go to stderr.
|
|
7
|
+
|
|
8
|
+
The worker touches no file the caller did not name as input. It renders a page,
|
|
9
|
+
recognizes it, emits that page's text and geometry so the caller can persist
|
|
10
|
+
them immediately, and drops the pixels before rendering the next page. The page
|
|
11
|
+
records stay in memory until the document ends, when the merge pass turns them
|
|
12
|
+
into the one Markdown string and emits it on the closing event. The OCR engine
|
|
13
|
+
is created for the document and released in a finally block, so a batch of
|
|
14
|
+
documents never holds more than one engine.
|
|
15
|
+
|
|
16
|
+
A job may instead carry a page record the caller kept from an earlier call. That
|
|
17
|
+
assemble job rebuilds the pages from the record and runs the merge alone, so a
|
|
18
|
+
second look at the outline costs no rendering and no recognition.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import gc
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
import traceback
|
|
30
|
+
from dataclasses import asdict
|
|
31
|
+
from typing import Any, Sequence
|
|
32
|
+
|
|
33
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
34
|
+
|
|
35
|
+
from assemble import MergeOptions, MergeStats, OutlineCandidate, build_markdown, page_text # noqa: E402
|
|
36
|
+
from clean import Page # noqa: E402
|
|
37
|
+
from layout import Line, assemble_lines # noqa: E402
|
|
38
|
+
from source import UnsupportedDocument, open_document, source_digest # noqa: E402
|
|
39
|
+
|
|
40
|
+
#: Import name and distribution name of every package the worker needs. Pillow's
|
|
41
|
+
#: are different, which is why the pair is stated rather than derived.
|
|
42
|
+
PACKAGES = (
|
|
43
|
+
('rapidocr', 'rapidocr'),
|
|
44
|
+
('onnxruntime', 'onnxruntime'),
|
|
45
|
+
('pypdfium2', 'pypdfium2'),
|
|
46
|
+
('PIL', 'pillow'),
|
|
47
|
+
('numpy', 'numpy'),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def emit(event: dict[str, Any]) -> None:
|
|
52
|
+
"""Write one event line to stdout as UTF-8 and flush it, so the caller sees it live.
|
|
53
|
+
|
|
54
|
+
The bytes are written to the binary stream rather than through the text
|
|
55
|
+
wrapper: a Windows console defaults to a legacy code page that cannot carry
|
|
56
|
+
the file names this tool works with.
|
|
57
|
+
"""
|
|
58
|
+
sys.stdout.buffer.write((json.dumps(event, ensure_ascii=False) + '\n').encode('utf-8'))
|
|
59
|
+
sys.stdout.buffer.flush()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def configure_stderr() -> None:
|
|
63
|
+
"""Make diagnostics UTF-8, so a path in an error message is never mangled."""
|
|
64
|
+
try:
|
|
65
|
+
sys.stderr.reconfigure(encoding='utf-8', errors='backslashreplace')
|
|
66
|
+
except (AttributeError, ValueError):
|
|
67
|
+
# A stream that is not a text wrapper keeps its own settings; nothing
|
|
68
|
+
# else can reach this, because stderr is a text wrapper under CPython.
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def read_spec(path: str | None) -> dict[str, Any]:
|
|
73
|
+
"""Read the job spec the caller supplied on stdin or named with --job.
|
|
74
|
+
|
|
75
|
+
@param path: a spec file, or None to read stdin.
|
|
76
|
+
@returns: the parsed spec.
|
|
77
|
+
"""
|
|
78
|
+
if path is None:
|
|
79
|
+
raw = sys.stdin.buffer.read().decode('utf-8')
|
|
80
|
+
else:
|
|
81
|
+
with open(path, 'r', encoding='utf-8') as handle:
|
|
82
|
+
raw = handle.read()
|
|
83
|
+
if raw.strip() == '':
|
|
84
|
+
raise ValueError('ocr worker: the spec carried no jobs')
|
|
85
|
+
return json.loads(raw)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_pages(spec: str | None, total: int) -> list[int]:
|
|
89
|
+
"""Resolve a page selection against a document's length.
|
|
90
|
+
|
|
91
|
+
A selection is a comma-separated list of single pages and inclusive ranges,
|
|
92
|
+
counted from one, as a person would write them in a print dialog. A range
|
|
93
|
+
left open after its dash runs to the end of the document.
|
|
94
|
+
@param spec: the selection text, or None for the whole document.
|
|
95
|
+
@param total: the document's page count.
|
|
96
|
+
@returns: every selected page number, ascending and without repeats.
|
|
97
|
+
"""
|
|
98
|
+
if spec is None or spec.strip() == '':
|
|
99
|
+
return list(range(1, total + 1))
|
|
100
|
+
wanted: set[int] = set()
|
|
101
|
+
for part in spec.split(','):
|
|
102
|
+
piece = part.strip()
|
|
103
|
+
if piece == '':
|
|
104
|
+
continue
|
|
105
|
+
start, end = _page_range(piece, total)
|
|
106
|
+
if start < 1 or end < start:
|
|
107
|
+
raise ValueError('invalid page selection: ' + piece)
|
|
108
|
+
wanted.update(range(start, min(end, total) + 1))
|
|
109
|
+
kept = sorted(page for page in wanted if page <= total)
|
|
110
|
+
if not kept:
|
|
111
|
+
raise ValueError('page selection names no page of a ' + str(total) + '-page document')
|
|
112
|
+
return kept
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _page_range(piece: str, total: int) -> tuple[int, int]:
|
|
116
|
+
"""Read one comma-free piece of a selection as an inclusive page range."""
|
|
117
|
+
start_text, separator, end_text = piece.partition('-')
|
|
118
|
+
start_text = start_text.strip()
|
|
119
|
+
end_text = end_text.strip()
|
|
120
|
+
if separator == '':
|
|
121
|
+
number = _page_number(piece, piece)
|
|
122
|
+
return number, number
|
|
123
|
+
start = _page_number(start_text, piece)
|
|
124
|
+
end = total if end_text == '' else _page_number(end_text, piece)
|
|
125
|
+
return start, end
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _page_number(text: str, piece: str) -> int:
|
|
129
|
+
"""Read one page number, reporting a malformed piece as a selection error."""
|
|
130
|
+
try:
|
|
131
|
+
return int(text)
|
|
132
|
+
except ValueError:
|
|
133
|
+
raise ValueError('invalid page selection: ' + piece) from None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Engine:
|
|
137
|
+
"""The OCR engine and the recognizer call the worker makes through it."""
|
|
138
|
+
|
|
139
|
+
def __init__(self) -> None:
|
|
140
|
+
import logging
|
|
141
|
+
|
|
142
|
+
from rapidocr import RapidOCR
|
|
143
|
+
|
|
144
|
+
# The engine narrates model selection on stderr; the caller only wants
|
|
145
|
+
# failures there, and its own events travel on stdout.
|
|
146
|
+
logging.getLogger('RapidOCR').setLevel(logging.ERROR)
|
|
147
|
+
self._engine: Any = RapidOCR()
|
|
148
|
+
logging.getLogger('RapidOCR').setLevel(logging.ERROR)
|
|
149
|
+
|
|
150
|
+
def recognize(self, image: Any) -> tuple[list[Any], list[str], list[float]]:
|
|
151
|
+
"""Recognize one page and return its boxes, texts, and confidences."""
|
|
152
|
+
result = self._engine(image)
|
|
153
|
+
boxes = getattr(result, 'boxes', None)
|
|
154
|
+
if boxes is None:
|
|
155
|
+
return [], [], []
|
|
156
|
+
texts = list(getattr(result, 'txts', None) or [])
|
|
157
|
+
scores = [float(score) for score in (getattr(result, 'scores', None) or [])]
|
|
158
|
+
return list(boxes), texts, scores
|
|
159
|
+
|
|
160
|
+
def release(self) -> None:
|
|
161
|
+
"""Drop the engine so its ONNX sessions and model buffers are freed."""
|
|
162
|
+
self._engine = None
|
|
163
|
+
gc.collect()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def merge_options(job: dict[str, Any]) -> MergeOptions:
|
|
167
|
+
"""Build the merge pass's switches from one job's spec."""
|
|
168
|
+
merge = job.get('merge') or {}
|
|
169
|
+
return MergeOptions(
|
|
170
|
+
remove_page_numbers=bool(merge.get('removePageNumbers', True)),
|
|
171
|
+
remove_running_heads=bool(merge.get('removeRunningHeads', True)),
|
|
172
|
+
running_head_ratio=float(merge.get('runningHeadRatio', 0.6)),
|
|
173
|
+
running_head_min_pages=int(merge.get('runningHeadMinPages', 3)),
|
|
174
|
+
detect_headings=bool(merge.get('detectHeadings', True)),
|
|
175
|
+
heading_min_ratio=float(merge.get('headingMinRatio', 1.18)),
|
|
176
|
+
indent_ratio=float(merge.get('indentRatio', 1.0)),
|
|
177
|
+
paragraph_gap_ratio=float(merge.get('paragraphGapRatio', 0.85)),
|
|
178
|
+
outline_candidate_ratio=float(merge.get('outlineCandidateRatio', 1.05)),
|
|
179
|
+
max_outline_candidates=int(merge.get('maxOutlineCandidates', 400)),
|
|
180
|
+
level_overrides=level_overrides(merge.get('levelOverrides')),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def level_overrides(value: Any) -> dict[str, Any]:
|
|
185
|
+
"""Read the levels a model-assisted outline pass assigned.
|
|
186
|
+
|
|
187
|
+
@param value: the job's levelOverrides value, absent for a merge the model
|
|
188
|
+
pass did not touch.
|
|
189
|
+
@returns: the candidate id to level mapping, empty when none was sent.
|
|
190
|
+
"""
|
|
191
|
+
if value is None:
|
|
192
|
+
return {}
|
|
193
|
+
if not isinstance(value, dict):
|
|
194
|
+
raise ValueError('levelOverrides is not an object')
|
|
195
|
+
return dict(value)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def page_geometry(page: Page) -> dict[str, Any]:
|
|
199
|
+
"""Describe one page so an assemble job can rebuild it without recognizing it.
|
|
200
|
+
|
|
201
|
+
@param page: the page just recognized.
|
|
202
|
+
@returns: the page's number, size, and the geometry of each of its lines.
|
|
203
|
+
"""
|
|
204
|
+
return {
|
|
205
|
+
'index': page.index,
|
|
206
|
+
'width': page.width,
|
|
207
|
+
'height': page.height,
|
|
208
|
+
'lines': [
|
|
209
|
+
{
|
|
210
|
+
'text': line.text,
|
|
211
|
+
'x0': line.x0,
|
|
212
|
+
'y0': line.y0,
|
|
213
|
+
'x1': line.x1,
|
|
214
|
+
'y1': line.y1,
|
|
215
|
+
'height': line.height,
|
|
216
|
+
}
|
|
217
|
+
for line in page.lines
|
|
218
|
+
],
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def record_pages(record: Any) -> list[Page]:
|
|
223
|
+
"""Rebuild the pages an assemble job carries, in the shape the worker emits.
|
|
224
|
+
|
|
225
|
+
@param record: the job's record object.
|
|
226
|
+
@returns: the pages it lists, in its order.
|
|
227
|
+
"""
|
|
228
|
+
if not isinstance(record, dict):
|
|
229
|
+
raise ValueError('assemble job carries no record object')
|
|
230
|
+
entries = record.get('pages')
|
|
231
|
+
if not isinstance(entries, list):
|
|
232
|
+
raise ValueError('assemble record carries no pages list')
|
|
233
|
+
pages: list[Page] = []
|
|
234
|
+
for entry in entries:
|
|
235
|
+
if not isinstance(entry, dict):
|
|
236
|
+
raise ValueError('assemble record carries a page that is not an object')
|
|
237
|
+
lines = entry.get('lines') or []
|
|
238
|
+
if not isinstance(lines, list):
|
|
239
|
+
raise ValueError('assemble record carries a page whose lines are not a list')
|
|
240
|
+
pages.append(Page(
|
|
241
|
+
index=int(entry['index']),
|
|
242
|
+
width=float(entry.get('width', 0.0)),
|
|
243
|
+
height=float(entry.get('height', 0.0)),
|
|
244
|
+
lines=[record_line(item) for item in lines],
|
|
245
|
+
))
|
|
246
|
+
return pages
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def record_line(item: Any) -> Line:
|
|
250
|
+
"""Rebuild one line of an assemble record from the geometry it carries.
|
|
251
|
+
|
|
252
|
+
@param item: one line object of a record page.
|
|
253
|
+
@returns: the line, with its glyph height restored from the record.
|
|
254
|
+
"""
|
|
255
|
+
if not isinstance(item, dict):
|
|
256
|
+
raise ValueError('assemble record carries a line that is not an object')
|
|
257
|
+
text = item.get('text', '')
|
|
258
|
+
if not isinstance(text, str):
|
|
259
|
+
raise ValueError('assemble record carries a line whose text is not a string')
|
|
260
|
+
y0 = float(item['y0'])
|
|
261
|
+
height = float(item.get('height', float(item['y1']) - y0))
|
|
262
|
+
# A record carries geometry only: the merge reads no confidence from it.
|
|
263
|
+
return Line(
|
|
264
|
+
text=text,
|
|
265
|
+
score=1.0,
|
|
266
|
+
x0=float(item['x0']),
|
|
267
|
+
y0=y0,
|
|
268
|
+
x1=float(item['x1']),
|
|
269
|
+
y1=y0 + height,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def merge_result(
|
|
274
|
+
job: dict[str, Any],
|
|
275
|
+
total: int,
|
|
276
|
+
recognized: int,
|
|
277
|
+
started: float,
|
|
278
|
+
markdown: str,
|
|
279
|
+
stats: MergeStats,
|
|
280
|
+
outline: Sequence[OutlineCandidate],
|
|
281
|
+
truncated: bool,
|
|
282
|
+
) -> dict[str, Any]:
|
|
283
|
+
"""Build the closing event's fields for one merged document.
|
|
284
|
+
|
|
285
|
+
@param job: the job the result belongs to.
|
|
286
|
+
@param total: the document's page count.
|
|
287
|
+
@param recognized: how many pages the job recognized or carried.
|
|
288
|
+
@param started: when the job began, so the result reports its own cost.
|
|
289
|
+
@param markdown: the merged document.
|
|
290
|
+
@param stats: the merge pass's counts.
|
|
291
|
+
@param outline: the candidates a model-assisted pass may re-level.
|
|
292
|
+
@param truncated: whether the candidate list hit its cap.
|
|
293
|
+
@returns: the fields of the job's done event.
|
|
294
|
+
"""
|
|
295
|
+
return {
|
|
296
|
+
'id': job.get('id', ''),
|
|
297
|
+
'input': job.get('input', ''),
|
|
298
|
+
'pages': stats.pages,
|
|
299
|
+
'totalPages': total,
|
|
300
|
+
'lines': stats.lines,
|
|
301
|
+
'headings': stats.headings,
|
|
302
|
+
'chars': len(markdown),
|
|
303
|
+
'droppedPageNumbers': stats.dropped_page_numbers,
|
|
304
|
+
'droppedRunningHeads': stats.dropped_running_heads,
|
|
305
|
+
'joinedAcrossPages': stats.joined_across_pages,
|
|
306
|
+
'recognized': recognized,
|
|
307
|
+
'seconds': round(time.time() - started, 2),
|
|
308
|
+
'markdown': markdown,
|
|
309
|
+
'outline': [asdict(candidate) for candidate in outline],
|
|
310
|
+
'outlineTruncated': truncated,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def failure(job: dict[str, Any], error: BaseException) -> dict[str, Any]:
|
|
315
|
+
"""One failed document, in the terms the caller reports it with."""
|
|
316
|
+
return {'id': job.get('id', ''), 'input': job.get('input', ''), 'error': type(error).__name__ + ': ' + str(error)}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def run_job(
|
|
320
|
+
job: dict[str, Any],
|
|
321
|
+
engine_lifetime: str,
|
|
322
|
+
shared: Engine | None,
|
|
323
|
+
) -> tuple[dict[str, Any] | None, dict[str, Any] | None, Engine | None]:
|
|
324
|
+
"""Recognize and merge one document, or merge one the caller already read.
|
|
325
|
+
|
|
326
|
+
@param job: one entry of the spec's job list.
|
|
327
|
+
@param engine_lifetime: 'perDocument' or 'shared'.
|
|
328
|
+
@param shared: the engine a shared lifetime carries between documents.
|
|
329
|
+
@returns: the document result or the failure, and the engine still in use.
|
|
330
|
+
"""
|
|
331
|
+
if str(job.get('mode', 'recognize')) == 'assemble':
|
|
332
|
+
try:
|
|
333
|
+
return assemble_job(job), None, shared
|
|
334
|
+
except Exception as error: # noqa: BLE001 - one unusable record must not end the batch
|
|
335
|
+
traceback.print_exc(file=sys.stderr)
|
|
336
|
+
return None, failure(job, error), shared
|
|
337
|
+
|
|
338
|
+
path = job['input']
|
|
339
|
+
started = time.time()
|
|
340
|
+
try:
|
|
341
|
+
document = open_document(path, int(job.get('dpi', 200)), int(job.get('maxPixels', 0)))
|
|
342
|
+
except UnsupportedDocument as error:
|
|
343
|
+
return None, failure(job, error), shared
|
|
344
|
+
except Exception as error: # noqa: BLE001 - the caller reports any open failure
|
|
345
|
+
return None, failure(job, error), shared
|
|
346
|
+
|
|
347
|
+
total = 0
|
|
348
|
+
engine = shared
|
|
349
|
+
recognized = 0
|
|
350
|
+
pages: list[Page] = []
|
|
351
|
+
try:
|
|
352
|
+
total = document.page_count()
|
|
353
|
+
max_pages = int(job.get('maxPages', 0))
|
|
354
|
+
if max_pages > 0 and total > max_pages and not str(job.get('pages') or '').strip():
|
|
355
|
+
raise ValueError(
|
|
356
|
+
'document carries ' + str(total) + ' pages, above the ' + str(max_pages)
|
|
357
|
+
+ '-page ceiling; select pages or raise maxPages'
|
|
358
|
+
)
|
|
359
|
+
selection = parse_pages(job.get('pages'), total)
|
|
360
|
+
# The caller names a recognition after this digest, so it has to describe
|
|
361
|
+
# the document's content rather than its length: a source edited without
|
|
362
|
+
# changing size is a different recognition and must not reuse the name.
|
|
363
|
+
emit({
|
|
364
|
+
'event': 'start',
|
|
365
|
+
'id': job.get('id', ''),
|
|
366
|
+
'input': path,
|
|
367
|
+
'pages': selection,
|
|
368
|
+
'totalPages': total,
|
|
369
|
+
'sourceDigest': source_digest(path),
|
|
370
|
+
})
|
|
371
|
+
if engine is None:
|
|
372
|
+
engine = Engine()
|
|
373
|
+
for number in selection:
|
|
374
|
+
rendered = document.render(number - 1)
|
|
375
|
+
boxes, texts, scores = engine.recognize(rendered.image)
|
|
376
|
+
lines = assemble_lines(
|
|
377
|
+
boxes,
|
|
378
|
+
texts,
|
|
379
|
+
scores,
|
|
380
|
+
float(rendered.width),
|
|
381
|
+
float(job.get('textScore', 0.5)),
|
|
382
|
+
bool(job.get('detectColumns', True)),
|
|
383
|
+
)
|
|
384
|
+
page = Page(index=number, width=float(rendered.width), height=float(rendered.height), lines=lines)
|
|
385
|
+
pages.append(page)
|
|
386
|
+
recognized += 1
|
|
387
|
+
emit({
|
|
388
|
+
'event': 'page',
|
|
389
|
+
'id': job.get('id', ''),
|
|
390
|
+
'page': page.index,
|
|
391
|
+
'totalPages': total,
|
|
392
|
+
'lines': len(lines),
|
|
393
|
+
'chars': sum(len(line.text) for line in lines),
|
|
394
|
+
'text': page_text(lines),
|
|
395
|
+
'geometry': page_geometry(page),
|
|
396
|
+
})
|
|
397
|
+
# Dropping the page before the next render is what bounds memory.
|
|
398
|
+
del rendered, boxes, texts, scores, lines, page
|
|
399
|
+
except Exception as error: # noqa: BLE001 - one bad document must not end the batch
|
|
400
|
+
traceback.print_exc(file=sys.stderr)
|
|
401
|
+
if engine_lifetime == 'perDocument' and engine is not None:
|
|
402
|
+
engine.release()
|
|
403
|
+
return None, failure(job, error), None if engine_lifetime == 'perDocument' else shared
|
|
404
|
+
finally:
|
|
405
|
+
document.close()
|
|
406
|
+
|
|
407
|
+
if engine_lifetime == 'perDocument' and engine is not None:
|
|
408
|
+
engine.release()
|
|
409
|
+
engine = None
|
|
410
|
+
emit({'event': 'released', 'id': job.get('id', ''), 'reason': 'document-finished'})
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
markdown, stats, outline, truncated = build_markdown(pages, merge_options(job))
|
|
414
|
+
except Exception as error: # noqa: BLE001 - a bad merge spec is one failed document
|
|
415
|
+
traceback.print_exc(file=sys.stderr)
|
|
416
|
+
return None, failure(job, error), None if engine_lifetime == 'perDocument' else shared
|
|
417
|
+
pages.clear()
|
|
418
|
+
result = merge_result(job, total, recognized, started, markdown, stats, outline, truncated)
|
|
419
|
+
return result, None, shared if engine_lifetime == 'shared' else engine
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def assemble_job(job: dict[str, Any]) -> dict[str, Any]:
|
|
423
|
+
"""Merge one document whose pages the caller already recognized.
|
|
424
|
+
|
|
425
|
+
The caller keeps the page records a recognize job emitted, so a second look
|
|
426
|
+
at the outline costs the merge and nothing else: no document is opened and
|
|
427
|
+
no engine is built.
|
|
428
|
+
@param job: one entry of the spec's job list, carrying a page record.
|
|
429
|
+
@returns: the fields of the job's done event.
|
|
430
|
+
"""
|
|
431
|
+
started = time.time()
|
|
432
|
+
pages = record_pages(job.get('record'))
|
|
433
|
+
markdown, stats, outline, truncated = build_markdown(pages, merge_options(job))
|
|
434
|
+
total = len(pages)
|
|
435
|
+
pages.clear()
|
|
436
|
+
return merge_result(job, total, total, started, markdown, stats, outline, truncated)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def self_test() -> int:
|
|
440
|
+
"""Report the worker's environment as one event, for the caller's check.
|
|
441
|
+
|
|
442
|
+
Importing the packages is the check: a missing or mismatched dependency is
|
|
443
|
+
what a fresh installation gets wrong, and it costs a second to detect. The
|
|
444
|
+
engine itself is built per document, where a failure names the document.
|
|
445
|
+
"""
|
|
446
|
+
report: dict[str, Any] = {'event': 'ready', 'python': sys.version.split()[0], 'modules': {}}
|
|
447
|
+
missing: list[str] = []
|
|
448
|
+
for module, distribution in PACKAGES:
|
|
449
|
+
try:
|
|
450
|
+
__import__(module)
|
|
451
|
+
except Exception as error: # noqa: BLE001 - a missing package is the answer
|
|
452
|
+
report['modules'][module] = None
|
|
453
|
+
report['modules'][module + 'Error'] = type(error).__name__ + ': ' + str(error)
|
|
454
|
+
missing.append(module)
|
|
455
|
+
continue
|
|
456
|
+
try:
|
|
457
|
+
from importlib.metadata import version
|
|
458
|
+
|
|
459
|
+
report['modules'][module] = version(distribution)
|
|
460
|
+
except Exception: # noqa: BLE001 - an importable package with no metadata is still present
|
|
461
|
+
report['modules'][module] = 'unknown'
|
|
462
|
+
report['ready'] = not missing
|
|
463
|
+
emit(report)
|
|
464
|
+
return 0 if not missing else 3
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def main(argv: Sequence[str]) -> int:
|
|
468
|
+
"""Read the spec, process every job, and report the batch outcome."""
|
|
469
|
+
parser = argparse.ArgumentParser(description='OCR a PDF or image into Markdown.')
|
|
470
|
+
parser.add_argument('--job', help='path to a JSON spec instead of stdin')
|
|
471
|
+
parser.add_argument('--self-test', action='store_true', help='report the environment and exit')
|
|
472
|
+
args = parser.parse_args(argv)
|
|
473
|
+
configure_stderr()
|
|
474
|
+
|
|
475
|
+
if args.self_test:
|
|
476
|
+
return self_test()
|
|
477
|
+
|
|
478
|
+
try:
|
|
479
|
+
spec = read_spec(args.job)
|
|
480
|
+
except Exception as error: # noqa: BLE001 - a bad spec is reported, not raised
|
|
481
|
+
emit({'event': 'fatal', 'error': type(error).__name__ + ': ' + str(error)})
|
|
482
|
+
return 2
|
|
483
|
+
|
|
484
|
+
jobs = spec.get('jobs', [])
|
|
485
|
+
lifetime = str(spec.get('engineLifetime', 'perDocument'))
|
|
486
|
+
shared: Engine | None = None
|
|
487
|
+
# A batch of assemble jobs merges records the caller already has, so a
|
|
488
|
+
# shared lifetime costs no engine when no job recognizes.
|
|
489
|
+
if lifetime == 'shared' and any(str(job.get('mode', 'recognize')) != 'assemble' for job in jobs):
|
|
490
|
+
try:
|
|
491
|
+
shared = Engine()
|
|
492
|
+
except Exception as error: # noqa: BLE001 - report an unusable engine once
|
|
493
|
+
emit({'event': 'fatal', 'error': type(error).__name__ + ': ' + str(error)})
|
|
494
|
+
return 3
|
|
495
|
+
|
|
496
|
+
finished = 0
|
|
497
|
+
failures: list[dict[str, Any]] = []
|
|
498
|
+
for job in jobs:
|
|
499
|
+
result, problem, shared = run_job(job, lifetime, shared)
|
|
500
|
+
if result is not None:
|
|
501
|
+
finished += 1
|
|
502
|
+
emit({'event': 'done', **result})
|
|
503
|
+
if problem is not None:
|
|
504
|
+
failures.append(problem)
|
|
505
|
+
emit({'event': 'error', 'id': problem['id'], 'input': problem['input'], 'message': problem['error']})
|
|
506
|
+
|
|
507
|
+
if shared is not None:
|
|
508
|
+
shared.release()
|
|
509
|
+
emit({'event': 'released', 'id': '', 'reason': 'batch-finished'})
|
|
510
|
+
|
|
511
|
+
emit({'event': 'end', 'documents': finished, 'failures': failures})
|
|
512
|
+
return 0 if finished > 0 else 1
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
if __name__ == '__main__':
|
|
516
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Runtime dependencies of the OCR worker.
|
|
2
|
+
#
|
|
3
|
+
# rapidocr ships the PP-OCR detection, classification, and recognition models
|
|
4
|
+
# inside the wheel, so recognition works offline once these are installed and
|
|
5
|
+
# "installing the model" is the same act as installing the package.
|
|
6
|
+
# onnxruntime is the inference engine rapidocr selects by default; rapidocr
|
|
7
|
+
# imports numpy directly, so it is stated here rather than relied on as a
|
|
8
|
+
# transitive dependency of either.
|
|
9
|
+
# pypdfium2 renders PDF pages and carries its own PDFium build, so no system
|
|
10
|
+
# PDF library is needed.
|
|
11
|
+
# pillow decodes still images, including every frame of a multi-page one.
|
|
12
|
+
rapidocr>=3.0,<4
|
|
13
|
+
onnxruntime>=1.17
|
|
14
|
+
numpy>=1.26
|
|
15
|
+
pypdfium2>=4.0
|
|
16
|
+
pillow>=10.0
|