@stabrise/scaledp 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 +661 -0
- package/README.md +218 -0
- package/dist/box-DAfzwfhA.d.ts +119 -0
- package/dist/config-g6IrKlDC.d.ts +80 -0
- package/dist/data-to-image-DoZ4jQ3R.js +54 -0
- package/dist/data-to-image-DoZ4jQ3R.js.map +1 -0
- package/dist/detect/index.d.ts +71 -0
- package/dist/detect/index.js +2 -0
- package/dist/detect-q8AI_Jdj.js +274 -0
- package/dist/detect-q8AI_Jdj.js.map +1 -0
- package/dist/detector-output-C0Qt-jEq.d.ts +13 -0
- package/dist/detector-output-lyF1Mqb8.js +13 -0
- package/dist/detector-output-lyF1Mqb8.js.map +1 -0
- package/dist/display/index.d.ts +66 -0
- package/dist/display/index.js +237 -0
- package/dist/display/index.js.map +1 -0
- package/dist/document-B8I61TiY.d.ts +16 -0
- package/dist/entity-CedtRhU1.d.ts +22 -0
- package/dist/entity-D6Hxaugj.js +13 -0
- package/dist/entity-D6Hxaugj.js.map +1 -0
- package/dist/image-CAH2rLv9.js +511 -0
- package/dist/image-CAH2rLv9.js.map +1 -0
- package/dist/image-Dc5TSg46.d.ts +18 -0
- package/dist/image-DoZDJkcR.js +37 -0
- package/dist/image-DoZDJkcR.js.map +1 -0
- package/dist/image-draw-boxes-De0QbFv9.js +285 -0
- package/dist/image-draw-boxes-De0QbFv9.js.map +1 -0
- package/dist/index.d.ts +269 -0
- package/dist/index.js +11 -0
- package/dist/model-cache-BEaqqRZ9.js +182 -0
- package/dist/model-cache-BEaqqRZ9.js.map +1 -0
- package/dist/model-cache-BhFYpfZz.d.ts +36 -0
- package/dist/ner/index.d.ts +293 -0
- package/dist/ner/index.js +2 -0
- package/dist/ner-SsZLZ6ed.js +1028 -0
- package/dist/ner-SsZLZ6ed.js.map +1 -0
- package/dist/ocr/index.d.ts +440 -0
- package/dist/ocr/index.js +3 -0
- package/dist/ocr-OHX2WM3e.js +1294 -0
- package/dist/ocr-OHX2WM3e.js.map +1 -0
- package/dist/ort-CXDoPrtw.js +73 -0
- package/dist/ort-CXDoPrtw.js.map +1 -0
- package/dist/params-DapwK9Ns.js +37 -0
- package/dist/params-DapwK9Ns.js.map +1 -0
- package/dist/pdf/index.d.ts +123 -0
- package/dist/pdf/index.js +2 -0
- package/dist/pdf-BQl0dneD.js +417 -0
- package/dist/pdf-BQl0dneD.js.map +1 -0
- package/dist/pipeline-DACqGkpN.js +240 -0
- package/dist/pipeline-DACqGkpN.js.map +1 -0
- package/dist/pipeline-DeLO-OCE.d.ts +139 -0
- package/dist/registry/index.d.ts +169 -0
- package/dist/registry/index.js +1061 -0
- package/dist/registry/index.js.map +1 -0
- package/dist/text-ahMLpxN9.js +109 -0
- package/dist/text-ahMLpxN9.js.map +1 -0
- package/dist/worker/index.d.ts +105 -0
- package/dist/worker/index.js +180 -0
- package/dist/worker/index.js.map +1 -0
- package/package.json +135 -0
|
@@ -0,0 +1,1294 @@
|
|
|
1
|
+
import { h as getConfig, i as Stage, s as DetectionError, u as OcrError } from "./pipeline-DACqGkpN.js";
|
|
2
|
+
import { A as polygonArea, D as boxPoints, S as isRotated, _ as boxFromBBox, a as cropBox, c as encodeImage, i as createCanvas, j as polygonPerimeter, k as minAreaRect, l as imageDataToCanvas, m as toNchwFloat32, n as IMAGENET_STD, o as cropGeometry, p as toImageData, r as context2d, s as decodeImage, t as IMAGENET_MEAN, u as letterbox, v as boxFromPolygon, w as mergeOverlappingBoxes } from "./image-CAH2rLv9.js";
|
|
3
|
+
import { a as isCached, n as ensureModelFiles, r as evict } from "./model-cache-BEaqqRZ9.js";
|
|
4
|
+
import { i as resolveParams, t as BASE_STAGE_DEFAULTS } from "./params-DapwK9Ns.js";
|
|
5
|
+
import { n as boxesToText, t as boxesToFormattedText } from "./text-ahMLpxN9.js";
|
|
6
|
+
import { t as createDetectorOutput } from "./detector-output-lyF1Mqb8.js";
|
|
7
|
+
import { n as createDocument, t as createImage } from "./image-DoZDJkcR.js";
|
|
8
|
+
import { i as loadOrt, t as createSession } from "./ort-CXDoPrtw.js";
|
|
9
|
+
//#region src/ocr/db-postprocess.ts
|
|
10
|
+
/**
|
|
11
|
+
* Differentiable Binarization post-processing.
|
|
12
|
+
*
|
|
13
|
+
* Port of `scaledp/models/detectors/paddle_onnx/db_postprocess.py`. Takes the
|
|
14
|
+
* model's probability map and turns it into text quads, with the same
|
|
15
|
+
* thresholds, ordering and filtering as Python so box output matches.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* ScaleDP's own values, from `predict_det.py`. Note `boxThresh` is 0.3 there
|
|
19
|
+
* and the stage-level `scoreThreshold` never reaches this code -- a Python bug
|
|
20
|
+
* that is not reproduced: here `boxThresh` is what the stage actually sets.
|
|
21
|
+
*/
|
|
22
|
+
const DB_POSTPROCESS_DEFAULTS = Object.freeze({
|
|
23
|
+
thresh: .5,
|
|
24
|
+
boxThresh: .3,
|
|
25
|
+
maxCandidates: 1e3,
|
|
26
|
+
unclipRatio: 2.5,
|
|
27
|
+
minSize: 3
|
|
28
|
+
});
|
|
29
|
+
/**
|
|
30
|
+
* Boundary pixels of each 8-connected foreground component.
|
|
31
|
+
*
|
|
32
|
+
* cv2.findContours traces outlines; we collect boundary pixels instead. For the
|
|
33
|
+
* only consumers here -- minAreaRect and its convex hull -- the two are
|
|
34
|
+
* equivalent, and this avoids porting Suzuki-Abe border following.
|
|
35
|
+
*/
|
|
36
|
+
function findComponentBoundaries(map, thresh, maxCandidates) {
|
|
37
|
+
const { data, width, height } = map;
|
|
38
|
+
const visited = new Uint8Array(width * height);
|
|
39
|
+
const components = [];
|
|
40
|
+
const isForeground = (x, y) => x >= 0 && y >= 0 && x < width && y < height && data[y * width + x] > thresh;
|
|
41
|
+
const stack = [];
|
|
42
|
+
for (let start = 0; start < visited.length && components.length < maxCandidates; start++) {
|
|
43
|
+
if (visited[start] || !(data[start] > thresh)) continue;
|
|
44
|
+
const boundary = [];
|
|
45
|
+
stack.length = 0;
|
|
46
|
+
stack.push(start);
|
|
47
|
+
visited[start] = 1;
|
|
48
|
+
while (stack.length > 0) {
|
|
49
|
+
const index = stack.pop();
|
|
50
|
+
const x = index % width;
|
|
51
|
+
const y = (index - x) / width;
|
|
52
|
+
let onBoundary = false;
|
|
53
|
+
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
|
54
|
+
if (dx === 0 && dy === 0) continue;
|
|
55
|
+
const nx = x + dx;
|
|
56
|
+
const ny = y + dy;
|
|
57
|
+
if (!isForeground(nx, ny)) {
|
|
58
|
+
onBoundary = true;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const neighbour = ny * width + nx;
|
|
62
|
+
if (!visited[neighbour]) {
|
|
63
|
+
visited[neighbour] = 1;
|
|
64
|
+
stack.push(neighbour);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (onBoundary) boundary.push([x, y]);
|
|
68
|
+
}
|
|
69
|
+
if (boundary.length >= 3) components.push(boundary);
|
|
70
|
+
}
|
|
71
|
+
return components;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Corners of the minimum-area rect, ordered top-left, top-right, bottom-right,
|
|
75
|
+
* bottom-left, together with the rect's shorter side.
|
|
76
|
+
*
|
|
77
|
+
* Port of `get_mini_boxes`: sort the four corners by x, then decide within each
|
|
78
|
+
* pair which is the upper one.
|
|
79
|
+
*/
|
|
80
|
+
function miniBox(points) {
|
|
81
|
+
const rect = minAreaRect(points);
|
|
82
|
+
const [p0, p1, p2, p3] = [...boxPoints(rect)].sort((a, b) => a[0] - b[0]);
|
|
83
|
+
const [topLeft, bottomLeft] = p0[1] <= p1[1] ? [p0, p1] : [p1, p0];
|
|
84
|
+
const [topRight, bottomRight] = p2[1] <= p3[1] ? [p2, p3] : [p3, p2];
|
|
85
|
+
return {
|
|
86
|
+
points: [
|
|
87
|
+
topLeft,
|
|
88
|
+
topRight,
|
|
89
|
+
bottomRight,
|
|
90
|
+
bottomLeft
|
|
91
|
+
],
|
|
92
|
+
sside: Math.min(rect.size[0], rect.size[1])
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Mean probability inside a quad -- port of `box_score_fast`.
|
|
97
|
+
*
|
|
98
|
+
* Python builds an integer mask with `cv2.fillPoly` and averages the
|
|
99
|
+
* probability map under it, so this reproduces OpenCV's fill convention:
|
|
100
|
+
* vertices are truncated to integers and treated as pixel *centres*, scanlines
|
|
101
|
+
* run at integer y, and both ends of each span are inclusive. Sampling at
|
|
102
|
+
* pixel centres instead (the more usual rasterisation rule) drops the boundary
|
|
103
|
+
* row and column, which shifts the score by around 1% and changes which
|
|
104
|
+
* candidates clear `boxThresh`.
|
|
105
|
+
*/
|
|
106
|
+
function boxScore(map, points) {
|
|
107
|
+
const { data, width, height } = map;
|
|
108
|
+
const xs = points.map((p) => p[0]);
|
|
109
|
+
const ys = points.map((p) => p[1]);
|
|
110
|
+
const clamp = (value, max) => Math.min(Math.max(value, 0), max);
|
|
111
|
+
const xmin = clamp(Math.floor(Math.min(...xs)), width - 1);
|
|
112
|
+
const xmax = clamp(Math.ceil(Math.max(...xs)), width - 1);
|
|
113
|
+
const ymin = clamp(Math.floor(Math.min(...ys)), height - 1);
|
|
114
|
+
const ymax = clamp(Math.ceil(Math.max(...ys)), height - 1);
|
|
115
|
+
if (xmax < xmin || ymax < ymin) return 0;
|
|
116
|
+
const local = points.map(([x, y]) => [Math.trunc(x - xmin), Math.trunc(y - ymin)]);
|
|
117
|
+
const maskWidth = xmax - xmin;
|
|
118
|
+
const maskHeight = ymax - ymin;
|
|
119
|
+
let sum = 0;
|
|
120
|
+
let count = 0;
|
|
121
|
+
for (let y = 0; y <= maskHeight; y++) {
|
|
122
|
+
let left = Number.POSITIVE_INFINITY;
|
|
123
|
+
let right = Number.NEGATIVE_INFINITY;
|
|
124
|
+
for (let i = 0; i < local.length; i++) {
|
|
125
|
+
const a = local[i];
|
|
126
|
+
const b = local[(i + 1) % local.length];
|
|
127
|
+
if (a[1] === b[1]) {
|
|
128
|
+
if (a[1] !== y) continue;
|
|
129
|
+
left = Math.min(left, a[0], b[0]);
|
|
130
|
+
right = Math.max(right, a[0], b[0]);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (y < Math.min(a[1], b[1]) || y > Math.max(a[1], b[1])) continue;
|
|
134
|
+
const x = a[0] + (y - a[1]) / (b[1] - a[1]) * (b[0] - a[0]);
|
|
135
|
+
left = Math.min(left, x);
|
|
136
|
+
right = Math.max(right, x);
|
|
137
|
+
}
|
|
138
|
+
if (left > right) continue;
|
|
139
|
+
const from = Math.max(0, Math.ceil(left));
|
|
140
|
+
const to = Math.min(maskWidth, Math.floor(right));
|
|
141
|
+
const rowOffset = (y + ymin) * width + xmin;
|
|
142
|
+
for (let x = from; x <= to; x++) {
|
|
143
|
+
sum += data[rowOffset + x];
|
|
144
|
+
count++;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return count === 0 ? 0 : sum / count;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Grow a detected rect outward -- port of `unclip`.
|
|
151
|
+
*
|
|
152
|
+
* Python offsets the polygon with a Clipper round join and then takes the
|
|
153
|
+
* minimum-area rect of the result. For a rectangle those two steps compose
|
|
154
|
+
* exactly: offsetting outward by `d` produces a rounded rectangle whose
|
|
155
|
+
* min-area rect is the original grown by `d` on each of the four sides. That
|
|
156
|
+
* identity is what lets this avoid a Clipper dependency entirely.
|
|
157
|
+
*/
|
|
158
|
+
function unclipRect(points, unclipRatio) {
|
|
159
|
+
const rect = minAreaRect(points);
|
|
160
|
+
const perimeter = polygonPerimeter(points);
|
|
161
|
+
const distance = perimeter === 0 ? 0 : polygonArea(points) * unclipRatio / perimeter;
|
|
162
|
+
return boxPoints({
|
|
163
|
+
center: rect.center,
|
|
164
|
+
size: [rect.size[0] + distance * 2, rect.size[1] + distance * 2],
|
|
165
|
+
angle: rect.angle
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Reorder four points clockwise from the top-left -- port of
|
|
170
|
+
* `order_points_clockwise`. Top-left has the smallest x+y, bottom-right the
|
|
171
|
+
* largest; the remaining two are separated by y-x.
|
|
172
|
+
*/
|
|
173
|
+
function orderPointsClockwise(points) {
|
|
174
|
+
const bySum = [...points].sort((a, b) => a[0] + a[1] - (b[0] + b[1]));
|
|
175
|
+
const topLeft = bySum[0];
|
|
176
|
+
const bottomRight = bySum[bySum.length - 1];
|
|
177
|
+
const rest = bySum.slice(1, -1).sort((a, b) => a[1] - a[0] - (b[1] - b[0]));
|
|
178
|
+
return [
|
|
179
|
+
topLeft,
|
|
180
|
+
rest[0],
|
|
181
|
+
bottomRight,
|
|
182
|
+
rest[1]
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Probability map -> text quads in source-image coordinates.
|
|
187
|
+
*
|
|
188
|
+
* `scale` is the uniform factor the source was resized by. Coordinates restore
|
|
189
|
+
* by *dividing* by it, with no offset to subtract, because the letterbox pads
|
|
190
|
+
* bottom and right only.
|
|
191
|
+
*/
|
|
192
|
+
function quadsFromProbabilityMap(map, source, scale, options = {}) {
|
|
193
|
+
const opts = {
|
|
194
|
+
...DB_POSTPROCESS_DEFAULTS,
|
|
195
|
+
...options
|
|
196
|
+
};
|
|
197
|
+
const quads = [];
|
|
198
|
+
for (const boundary of findComponentBoundaries(map, opts.thresh, opts.maxCandidates)) {
|
|
199
|
+
const candidate = miniBox(boundary);
|
|
200
|
+
if (candidate.sside < opts.minSize) continue;
|
|
201
|
+
const score = boxScore(map, candidate.points);
|
|
202
|
+
if (score < opts.boxThresh) continue;
|
|
203
|
+
const expanded = miniBox(unclipRect(candidate.points, opts.unclipRatio));
|
|
204
|
+
if (expanded.sside < opts.minSize + 2) continue;
|
|
205
|
+
const ordered = orderPointsClockwise(expanded.points.map(([x, y]) => [Math.min(Math.max(Math.round(x / scale), 0), source.width), Math.min(Math.max(Math.round(y / scale), 0), source.height)]));
|
|
206
|
+
const width = Math.hypot(ordered[0][0] - ordered[1][0], ordered[0][1] - ordered[1][1]);
|
|
207
|
+
const height = Math.hypot(ordered[0][0] - ordered[3][0], ordered[0][1] - ordered[3][1]);
|
|
208
|
+
if (width <= 3 || height <= 3) continue;
|
|
209
|
+
quads.push({
|
|
210
|
+
points: ordered,
|
|
211
|
+
score
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return quads;
|
|
215
|
+
}
|
|
216
|
+
//#endregion
|
|
217
|
+
//#region src/ocr/dbnet-onnx.ts
|
|
218
|
+
/**
|
|
219
|
+
* DBNet ONNX text detection -- the direct mirror of
|
|
220
|
+
* `scaledp/models/detectors/DBNetOnnxDetector.py`, so ScaleDP's own detection
|
|
221
|
+
* model runs unchanged in the browser.
|
|
222
|
+
*
|
|
223
|
+
* Preprocessing reproduces `paddle_onnx/operators.py` exactly, including one
|
|
224
|
+
* quirk that matters: the Python path converts RGB to BGR and never converts
|
|
225
|
+
* back, so the model is fed BGR channels normalised against *RGB* ImageNet
|
|
226
|
+
* statistics. Feeding true RGB instead shifts the boxes.
|
|
227
|
+
*
|
|
228
|
+
* The model detects text *lines*, not words -- one region per line, in Python
|
|
229
|
+
* as here. Nothing downstream subdivides them either: `TesseractRecognizer`
|
|
230
|
+
* reads each region with PSM.SINGLE_WORD and returns one box per region, which
|
|
231
|
+
* is what ScaleDP does too. `TesseractOcr`, which runs tesseract's own layout
|
|
232
|
+
* analysis over the whole page, is the stage that yields word boxes.
|
|
233
|
+
*/
|
|
234
|
+
/** Fixed input size from ScaleDP's `DetResizeForTest` config. */
|
|
235
|
+
const DBNET_INPUT_SIZE = 1280;
|
|
236
|
+
/** Model ScaleDP's DBNetOnnxDetector documents and its tests use. */
|
|
237
|
+
const DEFAULT_DBNET_MODEL = "StabRise/text_detection_dbnet_ml_v0.2";
|
|
238
|
+
const DBNET_DETECTOR_DEFAULTS = Object.freeze({
|
|
239
|
+
...BASE_STAGE_DEFAULTS,
|
|
240
|
+
inputCol: "image",
|
|
241
|
+
outputCol: "boxes",
|
|
242
|
+
keepInputData: true,
|
|
243
|
+
model: DEFAULT_DBNET_MODEL,
|
|
244
|
+
scoreThreshold: DB_POSTPROCESS_DEFAULTS.boxThresh,
|
|
245
|
+
binaryThreshold: DB_POSTPROCESS_DEFAULTS.thresh,
|
|
246
|
+
unclipRatio: DB_POSTPROCESS_DEFAULTS.unclipRatio,
|
|
247
|
+
mergeBoxes: true
|
|
248
|
+
});
|
|
249
|
+
function modelSpec(model) {
|
|
250
|
+
return /^https?:\/\//.test(model) ? {
|
|
251
|
+
repo: "dbnet",
|
|
252
|
+
files: [{ path: model }]
|
|
253
|
+
} : {
|
|
254
|
+
repo: model,
|
|
255
|
+
files: [{ path: "model.onnx" }]
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
var DbnetOnnxDetector = class extends Stage {
|
|
259
|
+
name = "DbnetOnnxDetector";
|
|
260
|
+
session = null;
|
|
261
|
+
loading = null;
|
|
262
|
+
constructor(options = {}) {
|
|
263
|
+
super(resolveParams(DBNET_DETECTOR_DEFAULTS, options));
|
|
264
|
+
}
|
|
265
|
+
async init() {
|
|
266
|
+
await this.getSession();
|
|
267
|
+
}
|
|
268
|
+
getSession() {
|
|
269
|
+
if (this.session) return Promise.resolve(this.session);
|
|
270
|
+
if (this.loading) return this.loading;
|
|
271
|
+
this.loading = (async () => {
|
|
272
|
+
const spec = modelSpec(this.params.model);
|
|
273
|
+
const bytes = (await ensureModelFiles(spec))[spec.files[0]?.path ?? ""];
|
|
274
|
+
if (!bytes) throw new DetectionError(`Model ${this.params.model} not found`, this.name);
|
|
275
|
+
const session = await createSession(bytes, { executionProviders: getConfig().executionProviders });
|
|
276
|
+
this.session = session;
|
|
277
|
+
return session;
|
|
278
|
+
})();
|
|
279
|
+
this.loading.catch(() => {
|
|
280
|
+
this.loading = null;
|
|
281
|
+
});
|
|
282
|
+
return this.loading;
|
|
283
|
+
}
|
|
284
|
+
async dispose() {
|
|
285
|
+
await this.session?.release();
|
|
286
|
+
this.session = null;
|
|
287
|
+
this.loading = null;
|
|
288
|
+
}
|
|
289
|
+
async apply(input, row) {
|
|
290
|
+
const image = input;
|
|
291
|
+
if (image?.exception) throw new DetectionError(`Upstream stage failed: ${image.exception}`, this.name);
|
|
292
|
+
if (!image || !(image.data instanceof Uint8Array) || image.data.byteLength === 0) throw new DetectionError("Expected an Image with decoded bytes", this.name);
|
|
293
|
+
const bitmap = await decodeImage(image.data);
|
|
294
|
+
let boxes;
|
|
295
|
+
try {
|
|
296
|
+
boxes = await this.detect(bitmap);
|
|
297
|
+
} finally {
|
|
298
|
+
bitmap.close();
|
|
299
|
+
}
|
|
300
|
+
return createDetectorOutput({
|
|
301
|
+
path: String(row[this.params.pathCol] ?? "memory"),
|
|
302
|
+
type: "dbnet-onnx",
|
|
303
|
+
bboxes: boxes
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
/** Run the model over one decoded image and return boxes in its coordinates. */
|
|
307
|
+
async detect(source) {
|
|
308
|
+
const session = await this.getSession();
|
|
309
|
+
const fitted = letterbox(source, {
|
|
310
|
+
width: DBNET_INPUT_SIZE,
|
|
311
|
+
height: DBNET_INPUT_SIZE
|
|
312
|
+
}, {
|
|
313
|
+
padding: "end",
|
|
314
|
+
fill: "#ffffff"
|
|
315
|
+
});
|
|
316
|
+
const tensorData = toNchwFloat32(toImageData(fitted.canvas), {
|
|
317
|
+
mean: IMAGENET_MEAN,
|
|
318
|
+
std: IMAGENET_STD,
|
|
319
|
+
bgr: true
|
|
320
|
+
});
|
|
321
|
+
const { Tensor } = await import("onnxruntime-web");
|
|
322
|
+
const inputName = session.inputNames[0];
|
|
323
|
+
const outputName = session.outputNames[0];
|
|
324
|
+
if (!inputName || !outputName) throw new DetectionError("Model exposes no input or output", this.name);
|
|
325
|
+
const output = (await session.run({ [inputName]: new Tensor("float32", tensorData, [
|
|
326
|
+
1,
|
|
327
|
+
3,
|
|
328
|
+
DBNET_INPUT_SIZE,
|
|
329
|
+
DBNET_INPUT_SIZE
|
|
330
|
+
]) }))[outputName];
|
|
331
|
+
if (!output) throw new DetectionError(`Model produced no "${outputName}" output`, this.name);
|
|
332
|
+
const [, , height = DBNET_INPUT_SIZE, width = DBNET_INPUT_SIZE] = output.dims;
|
|
333
|
+
const boxes = quadsFromProbabilityMap({
|
|
334
|
+
data: output.data,
|
|
335
|
+
width,
|
|
336
|
+
height
|
|
337
|
+
}, {
|
|
338
|
+
width: source.width,
|
|
339
|
+
height: source.height
|
|
340
|
+
}, fitted.scale, {
|
|
341
|
+
thresh: this.params.binaryThreshold,
|
|
342
|
+
boxThresh: this.params.scoreThreshold,
|
|
343
|
+
unclipRatio: this.params.unclipRatio
|
|
344
|
+
}).map((quad) => boxFromPolygon(quad.points, { score: quad.score }));
|
|
345
|
+
return this.params.mergeBoxes ? mergeOverlappingBoxes(boxes, .02, 10, .3) : boxes;
|
|
346
|
+
}
|
|
347
|
+
onError(message, row) {
|
|
348
|
+
return createDetectorOutput({
|
|
349
|
+
path: String(row[this.params.pathCol] ?? "memory"),
|
|
350
|
+
type: "dbnet-onnx",
|
|
351
|
+
exception: message
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
//#endregion
|
|
356
|
+
//#region src/ocr/detector-registry.ts
|
|
357
|
+
const DETECTOR_MODELS = Object.freeze([
|
|
358
|
+
{
|
|
359
|
+
id: "paddle",
|
|
360
|
+
name: "PaddleOCR DB (follows the OCR preset)",
|
|
361
|
+
kind: "paddle",
|
|
362
|
+
notes: "Shares the detection model already downloaded for the selected OCR preset."
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
id: "dbnet-v0.2",
|
|
366
|
+
name: "StabRise DBNet ONNX v0.2 (~5 MB)",
|
|
367
|
+
kind: "dbnet-onnx",
|
|
368
|
+
repo: "StabRise/text_detection_dbnet_ml_v0.2",
|
|
369
|
+
approxBytes: 48e5,
|
|
370
|
+
notes: "The same detection model ScaleDP uses server-side."
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
id: "dbnet-v0.1",
|
|
374
|
+
name: "StabRise DBNet ONNX v0.1 (~5 MB)",
|
|
375
|
+
kind: "dbnet-onnx",
|
|
376
|
+
repo: "StabRise/text_detection_dbnet_ml_v0.1",
|
|
377
|
+
approxBytes: 48e5,
|
|
378
|
+
notes: "The earlier revision, kept for comparison."
|
|
379
|
+
}
|
|
380
|
+
]);
|
|
381
|
+
const DEFAULT_DETECTOR_ID = "paddle";
|
|
382
|
+
function getDetectorModel(id) {
|
|
383
|
+
return DETECTOR_MODELS.find((m) => m.id === id);
|
|
384
|
+
}
|
|
385
|
+
//#endregion
|
|
386
|
+
//#region src/ocr/line-orientation.ts
|
|
387
|
+
/**
|
|
388
|
+
* 0 / 180 degree line-orientation classifier.
|
|
389
|
+
*
|
|
390
|
+
* Port of `scaledp/models/detectors/HasDetectLineOrientation.py`. Tiny, and it
|
|
391
|
+
* meaningfully improves recognition on rotated crops -- an upside-down line
|
|
392
|
+
* otherwise recognises as noise.
|
|
393
|
+
*
|
|
394
|
+
* Reproduces the same BGR-with-RGB-statistics quirk as the DBNet path: the
|
|
395
|
+
* Python code converts to BGR and normalises with RGB ImageNet constants.
|
|
396
|
+
*/
|
|
397
|
+
const DEFAULT_ORIENTATION_MODEL = "StabRise/line_orientation_detection_v0.1";
|
|
398
|
+
/** Model input, width x height. */
|
|
399
|
+
const ORIENTATION_INPUT = {
|
|
400
|
+
width: 160,
|
|
401
|
+
height: 80
|
|
402
|
+
};
|
|
403
|
+
const LABELS = ["0_degree", "180_degree"];
|
|
404
|
+
var LineOrientationClassifier = class {
|
|
405
|
+
model;
|
|
406
|
+
session = null;
|
|
407
|
+
loading = null;
|
|
408
|
+
constructor(model = DEFAULT_ORIENTATION_MODEL) {
|
|
409
|
+
this.model = model;
|
|
410
|
+
}
|
|
411
|
+
getSession() {
|
|
412
|
+
if (this.session) return Promise.resolve(this.session);
|
|
413
|
+
if (this.loading) return this.loading;
|
|
414
|
+
this.loading = (async () => {
|
|
415
|
+
const bytes = (await ensureModelFiles({
|
|
416
|
+
repo: this.model,
|
|
417
|
+
files: [{ path: "model.onnx" }]
|
|
418
|
+
}))["model.onnx"];
|
|
419
|
+
if (!bytes) throw new DetectionError(`Model ${this.model} has no model.onnx`, "LineOrientation");
|
|
420
|
+
const session = await createSession(bytes);
|
|
421
|
+
this.session = session;
|
|
422
|
+
return session;
|
|
423
|
+
})();
|
|
424
|
+
this.loading.catch(() => {
|
|
425
|
+
this.loading = null;
|
|
426
|
+
});
|
|
427
|
+
return this.loading;
|
|
428
|
+
}
|
|
429
|
+
async classify(source) {
|
|
430
|
+
const session = await this.getSession();
|
|
431
|
+
const canvas = createCanvas(ORIENTATION_INPUT.width, ORIENTATION_INPUT.height);
|
|
432
|
+
context2d(canvas).drawImage(source, 0, 0, canvas.width, canvas.height);
|
|
433
|
+
const data = toNchwFloat32(toImageData(canvas), {
|
|
434
|
+
mean: IMAGENET_MEAN,
|
|
435
|
+
std: IMAGENET_STD,
|
|
436
|
+
bgr: true
|
|
437
|
+
});
|
|
438
|
+
const { Tensor } = await import("onnxruntime-web");
|
|
439
|
+
const inputName = session.inputNames[0];
|
|
440
|
+
const outputName = session.outputNames[0];
|
|
441
|
+
if (!inputName || !outputName) throw new DetectionError("Model exposes no input or output", "LineOrientation");
|
|
442
|
+
const logits = (await session.run({ [inputName]: new Tensor("float32", data, [
|
|
443
|
+
1,
|
|
444
|
+
3,
|
|
445
|
+
ORIENTATION_INPUT.height,
|
|
446
|
+
ORIENTATION_INPUT.width
|
|
447
|
+
]) }))[outputName]?.data;
|
|
448
|
+
if (!logits || logits.length < 2) throw new DetectionError("Classifier produced no logits", "LineOrientation");
|
|
449
|
+
return logits[1] > logits[0] ? LABELS[1] : LABELS[0];
|
|
450
|
+
}
|
|
451
|
+
async dispose() {
|
|
452
|
+
await this.session?.release();
|
|
453
|
+
this.session = null;
|
|
454
|
+
this.loading = null;
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
//#endregion
|
|
458
|
+
//#region src/ocr/line-orientation-stage.ts
|
|
459
|
+
/**
|
|
460
|
+
* Line-orientation detection and correction.
|
|
461
|
+
*
|
|
462
|
+
* Port of ScaleDP's `HasDetectLineOrientation`, which `TesseractRecognizer`
|
|
463
|
+
* mixes in: each detected box is cropped, classified 0 or 180 degrees, and the
|
|
464
|
+
* upside-down ones are turned before recognition. An inverted line otherwise
|
|
465
|
+
* recognises as noise.
|
|
466
|
+
*
|
|
467
|
+
* Python does this per crop inside the recognizer. Here it is a stage of its
|
|
468
|
+
* own, because `PaddleTextRecognizer` detects and recognises in a single pass
|
|
469
|
+
* and has no seam to hook into. Flipping each inverted region in place on a
|
|
470
|
+
* copy of the page gets the same result and costs one recognition pass rather
|
|
471
|
+
* than one per box; the regions are rectangles, so a 180-degree turn leaves
|
|
472
|
+
* every box's coordinates untouched and downstream stages need no adjustment.
|
|
473
|
+
*/
|
|
474
|
+
const LINE_ORIENTATION_DEFAULTS = Object.freeze({
|
|
475
|
+
...BASE_STAGE_DEFAULTS,
|
|
476
|
+
inputCol: "image",
|
|
477
|
+
inputCols: ["image", "boxes"],
|
|
478
|
+
outputCol: "oriented",
|
|
479
|
+
orientationCol: "orientations",
|
|
480
|
+
keepInputData: true,
|
|
481
|
+
model: DEFAULT_ORIENTATION_MODEL,
|
|
482
|
+
correct: true,
|
|
483
|
+
onlyRotated: true,
|
|
484
|
+
padding: 2,
|
|
485
|
+
imageType: "png"
|
|
486
|
+
});
|
|
487
|
+
function boxesOf$1(source) {
|
|
488
|
+
if (typeof source !== "object" || source === null) return [];
|
|
489
|
+
return source.bboxes ?? [];
|
|
490
|
+
}
|
|
491
|
+
var LineOrientationDetector = class extends Stage {
|
|
492
|
+
name = "LineOrientationDetector";
|
|
493
|
+
classifier = null;
|
|
494
|
+
constructor(options = {}) {
|
|
495
|
+
super(resolveParams(LINE_ORIENTATION_DEFAULTS, options, { inputCols: (value) => {
|
|
496
|
+
if (value.length !== 2) throw new RangeError("inputCols must be [imageColumn, boxColumn]");
|
|
497
|
+
} }));
|
|
498
|
+
}
|
|
499
|
+
async init() {
|
|
500
|
+
this.classifier ??= new LineOrientationClassifier(this.params.model);
|
|
501
|
+
}
|
|
502
|
+
/** One row in, one row out -- but two columns written, so expand not apply. */
|
|
503
|
+
async expand(_input, row, ctx) {
|
|
504
|
+
const { inputCols, outputCol, orientationCol, correct, onlyRotated, padding } = this.params;
|
|
505
|
+
const [imageCol, boxCol] = inputCols;
|
|
506
|
+
const image = row[imageCol];
|
|
507
|
+
if (image?.exception) throw new DetectionError(`Upstream stage failed: ${image.exception}`, this.name);
|
|
508
|
+
if (!image || !(image.data instanceof Uint8Array) || image.data.byteLength === 0) throw new DetectionError("Expected an Image with decoded bytes", this.name);
|
|
509
|
+
await this.init();
|
|
510
|
+
const classifier = this.classifier;
|
|
511
|
+
const boxes = boxesOf$1(row[boxCol]);
|
|
512
|
+
const bitmap = await decodeImage(image.data);
|
|
513
|
+
try {
|
|
514
|
+
const canvas = createCanvas(bitmap.width, bitmap.height);
|
|
515
|
+
const ctx2d = context2d(canvas);
|
|
516
|
+
ctx2d.drawImage(bitmap, 0, 0);
|
|
517
|
+
const orientations = [];
|
|
518
|
+
let flipped = 0;
|
|
519
|
+
for (const box of boxes) {
|
|
520
|
+
ctx.signal?.throwIfAborted();
|
|
521
|
+
if (onlyRotated && !isRotated(box)) {
|
|
522
|
+
orientations.push("0_degree");
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
const crop = cropBox(bitmap, box, { padding });
|
|
526
|
+
const orientation = await classifier.classify(crop);
|
|
527
|
+
orientations.push(orientation);
|
|
528
|
+
if (orientation === "180_degree" && correct) {
|
|
529
|
+
flipRegion(ctx2d, canvas, box);
|
|
530
|
+
flipped++;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
const corrected = flipped > 0 ? createImage({
|
|
534
|
+
path: image.path,
|
|
535
|
+
resolution: image.resolution,
|
|
536
|
+
data: await encodeImage(canvas, `image/${this.params.imageType}`),
|
|
537
|
+
imageType: this.params.imageType,
|
|
538
|
+
width: canvas.width,
|
|
539
|
+
height: canvas.height
|
|
540
|
+
}) : image;
|
|
541
|
+
return [{
|
|
542
|
+
...row,
|
|
543
|
+
[outputCol]: corrected,
|
|
544
|
+
[orientationCol]: orientations
|
|
545
|
+
}];
|
|
546
|
+
} finally {
|
|
547
|
+
bitmap.close();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
async apply() {
|
|
551
|
+
throw new DetectionError("unreachable: expand handles every row", this.name);
|
|
552
|
+
}
|
|
553
|
+
onError(message, row) {
|
|
554
|
+
return createImage({
|
|
555
|
+
path: String(row[this.params.pathCol] ?? "memory"),
|
|
556
|
+
exception: message
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
async dispose() {
|
|
560
|
+
await this.classifier?.dispose();
|
|
561
|
+
this.classifier = null;
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
/**
|
|
565
|
+
* Turn one box's region 180 degrees in place.
|
|
566
|
+
*
|
|
567
|
+
* A rectangle maps onto itself under a 180-degree rotation about its own
|
|
568
|
+
* centre, whatever its angle, so the box's coordinates stay valid and
|
|
569
|
+
* downstream stages need no adjustment.
|
|
570
|
+
*
|
|
571
|
+
* The draw is clipped to the box's own rotated outline. Without that a rotated
|
|
572
|
+
* box turns its whole axis-aligned envelope, dragging neighbouring text through
|
|
573
|
+
* the rotation with it -- and the envelope of a skewed box is meaningfully
|
|
574
|
+
* larger than the box.
|
|
575
|
+
*/
|
|
576
|
+
function flipRegion(ctx, canvas, box) {
|
|
577
|
+
const pad = 1;
|
|
578
|
+
const x = Math.max(0, Math.min(Math.round(box.x) - pad, canvas.width - 1));
|
|
579
|
+
const y = Math.max(0, Math.min(Math.round(box.y) - pad, canvas.height - 1));
|
|
580
|
+
const width = Math.min(Math.round(box.width) + 2, canvas.width - x);
|
|
581
|
+
const height = Math.min(Math.round(box.height) + 2, canvas.height - y);
|
|
582
|
+
if (width < 1 || height < 1) return;
|
|
583
|
+
const region = createCanvas(width, height);
|
|
584
|
+
context2d(region).drawImage(canvas, x, y, width, height, 0, 0, width, height);
|
|
585
|
+
const cx = x + width / 2;
|
|
586
|
+
const cy = y + height / 2;
|
|
587
|
+
ctx.save();
|
|
588
|
+
ctx.beginPath();
|
|
589
|
+
if (Math.abs(box.angle) < 3) ctx.rect(x, y, width, height);
|
|
590
|
+
else {
|
|
591
|
+
const rad = box.angle * Math.PI / 180;
|
|
592
|
+
const cos = Math.cos(rad);
|
|
593
|
+
const sin = Math.sin(rad);
|
|
594
|
+
[
|
|
595
|
+
[-box.width / 2, -box.height / 2],
|
|
596
|
+
[box.width / 2, -box.height / 2],
|
|
597
|
+
[box.width / 2, box.height / 2],
|
|
598
|
+
[-box.width / 2, box.height / 2]
|
|
599
|
+
].forEach(([px, py], index) => {
|
|
600
|
+
const rx = px * cos - py * sin + cx;
|
|
601
|
+
const ry = px * sin + py * cos + cy;
|
|
602
|
+
if (index === 0) ctx.moveTo(rx, ry);
|
|
603
|
+
else ctx.lineTo(rx, ry);
|
|
604
|
+
});
|
|
605
|
+
ctx.closePath();
|
|
606
|
+
}
|
|
607
|
+
ctx.clip();
|
|
608
|
+
ctx.translate(cx, cy);
|
|
609
|
+
ctx.rotate(Math.PI);
|
|
610
|
+
ctx.drawImage(region, -width / 2, -height / 2);
|
|
611
|
+
ctx.restore();
|
|
612
|
+
}
|
|
613
|
+
//#endregion
|
|
614
|
+
//#region src/ocr/presets.ts
|
|
615
|
+
const PADDLE_OCR_PRESETS = Object.freeze([
|
|
616
|
+
{
|
|
617
|
+
value: "v6-small",
|
|
618
|
+
label: "Latin / CJK (default)",
|
|
619
|
+
scripts: [
|
|
620
|
+
"Latin",
|
|
621
|
+
"Han",
|
|
622
|
+
"Hiragana",
|
|
623
|
+
"Katakana"
|
|
624
|
+
]
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
value: "v6-medium",
|
|
628
|
+
label: "Latin / CJK (medium, more accurate)",
|
|
629
|
+
scripts: [
|
|
630
|
+
"Latin",
|
|
631
|
+
"Han",
|
|
632
|
+
"Hiragana",
|
|
633
|
+
"Katakana"
|
|
634
|
+
]
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
value: "v6-tiny",
|
|
638
|
+
label: "Latin / CJK (tiny, fastest)",
|
|
639
|
+
scripts: ["Latin", "Han"]
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
value: "v5-latin-mobile",
|
|
643
|
+
label: "Latin (French, German, Spanish, ...)",
|
|
644
|
+
scripts: ["Latin"]
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
value: "v5-eslav-mobile",
|
|
648
|
+
label: "Latin + Cyrillic (Russian, Ukrainian, ...)",
|
|
649
|
+
scripts: ["Latin", "Cyrillic"]
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
value: "v5-cyrillic-mobile",
|
|
653
|
+
label: "Cyrillic only",
|
|
654
|
+
scripts: ["Cyrillic"]
|
|
655
|
+
},
|
|
656
|
+
{
|
|
657
|
+
value: "v5-devanagari-mobile",
|
|
658
|
+
label: "Latin + Hindi (Devanagari)",
|
|
659
|
+
scripts: ["Latin", "Devanagari"]
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
value: "v5-arabic-mobile",
|
|
663
|
+
label: "Latin + Arabic",
|
|
664
|
+
scripts: ["Latin", "Arabic"]
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
value: "v5-greek-mobile",
|
|
668
|
+
label: "Latin + Greek",
|
|
669
|
+
scripts: ["Latin", "Greek"]
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
value: "v5-korean-mobile",
|
|
673
|
+
label: "Latin + Korean",
|
|
674
|
+
scripts: ["Latin", "Hangul"]
|
|
675
|
+
},
|
|
676
|
+
{
|
|
677
|
+
value: "v5-thai-mobile",
|
|
678
|
+
label: "Latin + Thai",
|
|
679
|
+
scripts: ["Latin", "Thai"]
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
value: "v5-tamil-mobile",
|
|
683
|
+
label: "Latin + Tamil",
|
|
684
|
+
scripts: ["Latin", "Tamil"]
|
|
685
|
+
},
|
|
686
|
+
{
|
|
687
|
+
value: "v5-telugu-mobile",
|
|
688
|
+
label: "Latin + Telugu",
|
|
689
|
+
scripts: ["Latin", "Telugu"]
|
|
690
|
+
},
|
|
691
|
+
{
|
|
692
|
+
value: "v5-en-mobile",
|
|
693
|
+
label: "English only (fastest)",
|
|
694
|
+
scripts: ["Latin"]
|
|
695
|
+
}
|
|
696
|
+
]);
|
|
697
|
+
const DEFAULT_OCR_PRESET = "v6-small";
|
|
698
|
+
function isKnownPreset(value) {
|
|
699
|
+
return PADDLE_OCR_PRESETS.some((p) => p.value === value);
|
|
700
|
+
}
|
|
701
|
+
/** Presets able to read a script name as reported by OSD script detection. */
|
|
702
|
+
function presetsForScript(script) {
|
|
703
|
+
return PADDLE_OCR_PRESETS.filter((p) => p.scripts.includes(script));
|
|
704
|
+
}
|
|
705
|
+
//#endregion
|
|
706
|
+
//#region src/ocr/paddle-service.ts
|
|
707
|
+
/**
|
|
708
|
+
* PaddleOcrService lifecycle: model fetching, caching and per-preset reuse.
|
|
709
|
+
*
|
|
710
|
+
* Models are pulled through our own ModelCache and handed to the service as
|
|
711
|
+
* ArrayBuffers, rather than letting ppu-paddle-ocr fetch them itself. Its
|
|
712
|
+
* browser build re-downloads ~6 MB on every page load, relying only on the HTTP
|
|
713
|
+
* cache; routing through IndexedDB makes a repeat visit instant and offline-safe.
|
|
714
|
+
*/
|
|
715
|
+
let modulePromise = null;
|
|
716
|
+
async function loadPpu() {
|
|
717
|
+
if (modulePromise) return modulePromise;
|
|
718
|
+
modulePromise = (async () => {
|
|
719
|
+
await loadOrt();
|
|
720
|
+
try {
|
|
721
|
+
return await import("ppu-paddle-ocr/web");
|
|
722
|
+
} catch (cause) {
|
|
723
|
+
throw new Error("ppu-paddle-ocr is required for PaddleOCR stages. Install it: npm i ppu-paddle-ocr", { cause });
|
|
724
|
+
}
|
|
725
|
+
})();
|
|
726
|
+
return modulePromise;
|
|
727
|
+
}
|
|
728
|
+
/** The three files a preset needs, as a ModelSpec our cache understands. */
|
|
729
|
+
async function specForPreset(preset) {
|
|
730
|
+
const ppu = await loadPpu();
|
|
731
|
+
const urls = preset in ppu.MODEL_PRESETS ? ppu.MODEL_PRESETS[preset] : ppu.DEFAULT_MODEL;
|
|
732
|
+
const roles = [
|
|
733
|
+
"detection",
|
|
734
|
+
"recognition",
|
|
735
|
+
"charactersDictionary"
|
|
736
|
+
];
|
|
737
|
+
return {
|
|
738
|
+
spec: {
|
|
739
|
+
repo: `ppu-paddle-ocr/${preset}`,
|
|
740
|
+
files: roles.map((r) => ({ path: urls[r] }))
|
|
741
|
+
},
|
|
742
|
+
roles: [...roles]
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Absolute model URLs bypass `modelHost`: ppu-paddle-ocr publishes its own
|
|
747
|
+
* catalogue and the paths are meaningful only against that host.
|
|
748
|
+
*/
|
|
749
|
+
async function fetchPresetFiles(preset) {
|
|
750
|
+
const { spec, roles } = await specForPreset(preset);
|
|
751
|
+
const files = await ensureModelFiles({
|
|
752
|
+
...spec,
|
|
753
|
+
files: spec.files.map((f) => ({ ...f }))
|
|
754
|
+
});
|
|
755
|
+
const out = {};
|
|
756
|
+
for (const [i, role] of roles.entries()) {
|
|
757
|
+
const path = spec.files[i]?.path;
|
|
758
|
+
if (path && files[path]) out[role] = files[path];
|
|
759
|
+
}
|
|
760
|
+
return out;
|
|
761
|
+
}
|
|
762
|
+
const services = /* @__PURE__ */ new Map();
|
|
763
|
+
async function getPaddleService(preset = DEFAULT_OCR_PRESET) {
|
|
764
|
+
const existing = services.get(preset);
|
|
765
|
+
if (existing) return existing;
|
|
766
|
+
const promise = (async () => {
|
|
767
|
+
const ppu = await loadPpu();
|
|
768
|
+
const model = await fetchPresetFiles(preset);
|
|
769
|
+
const service = new ppu.PaddleOcrService({
|
|
770
|
+
model,
|
|
771
|
+
session: {
|
|
772
|
+
executionProviders: [...getConfig().executionProviders],
|
|
773
|
+
graphOptimizationLevel: "all"
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
await service.initialize();
|
|
777
|
+
return service;
|
|
778
|
+
})();
|
|
779
|
+
promise.catch(() => services.delete(preset));
|
|
780
|
+
services.set(preset, promise);
|
|
781
|
+
return promise;
|
|
782
|
+
}
|
|
783
|
+
async function isPresetCached(preset) {
|
|
784
|
+
const { spec } = await specForPreset(preset);
|
|
785
|
+
return isCached(spec);
|
|
786
|
+
}
|
|
787
|
+
/** Pre-warm a preset so the first OCR call does not pay the download. */
|
|
788
|
+
async function loadPreset(preset) {
|
|
789
|
+
await getPaddleService(preset);
|
|
790
|
+
}
|
|
791
|
+
async function removePreset(preset) {
|
|
792
|
+
const { spec } = await specForPreset(preset);
|
|
793
|
+
const service = services.get(preset);
|
|
794
|
+
services.delete(preset);
|
|
795
|
+
await service?.then((s) => s.destroy()).catch(() => void 0);
|
|
796
|
+
await evict(spec);
|
|
797
|
+
}
|
|
798
|
+
/** Tear down every cached service. */
|
|
799
|
+
async function disposePaddleServices() {
|
|
800
|
+
const pending = [...services.values()];
|
|
801
|
+
services.clear();
|
|
802
|
+
await Promise.all(pending.map((p) => p.then((s) => s.destroy()).catch(() => void 0)));
|
|
803
|
+
}
|
|
804
|
+
//#endregion
|
|
805
|
+
//#region src/ocr/paddle.ts
|
|
806
|
+
/**
|
|
807
|
+
* PaddleOCR text detection and recognition -- the default OCR engines.
|
|
808
|
+
*
|
|
809
|
+
* `PaddleTextDetector` mirrors ScaleDP's detector stages (image -> boxes) and
|
|
810
|
+
* `PaddleTextRecognizer` mirrors its OCR stages (image -> Document with text
|
|
811
|
+
* and boxes). Both run PP-OCR models through ppu-paddle-ocr on onnxruntime-web.
|
|
812
|
+
*/
|
|
813
|
+
const PADDLE_DETECTOR_DEFAULTS = Object.freeze({
|
|
814
|
+
...BASE_STAGE_DEFAULTS,
|
|
815
|
+
inputCol: "image",
|
|
816
|
+
outputCol: "boxes",
|
|
817
|
+
keepInputData: true,
|
|
818
|
+
preset: DEFAULT_OCR_PRESET,
|
|
819
|
+
scoreThreshold: 0
|
|
820
|
+
});
|
|
821
|
+
function validatePreset(value) {
|
|
822
|
+
if (!isKnownPreset(value)) throw new RangeError(`Unknown OCR preset "${value}". See PADDLE_OCR_PRESETS for valid values.`);
|
|
823
|
+
}
|
|
824
|
+
/** Decode a stage input into something ppu-paddle-ocr accepts. */
|
|
825
|
+
async function toCanvas(input) {
|
|
826
|
+
if (typeof OffscreenCanvas !== "undefined" && input instanceof OffscreenCanvas) return input;
|
|
827
|
+
if (typeof ImageData !== "undefined" && input instanceof ImageData) return imageDataToCanvas(input);
|
|
828
|
+
const image = input;
|
|
829
|
+
if (image?.exception) throw new OcrError(`Upstream stage failed: ${image.exception}`, "toCanvas");
|
|
830
|
+
if (!image || !(image.data instanceof Uint8Array) || image.data.byteLength === 0) throw new OcrError("Expected an Image with decoded bytes", "toCanvas");
|
|
831
|
+
const bitmap = await decodeImage(image.data);
|
|
832
|
+
try {
|
|
833
|
+
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
|
834
|
+
const ctx = canvas.getContext("2d");
|
|
835
|
+
if (!ctx) throw new OcrError("Failed to acquire a 2D context", "toCanvas");
|
|
836
|
+
ctx.drawImage(bitmap, 0, 0);
|
|
837
|
+
return canvas;
|
|
838
|
+
} finally {
|
|
839
|
+
bitmap.close();
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
/** Text detection only: image -> word boxes, no recognition. */
|
|
843
|
+
var PaddleTextDetector = class extends Stage {
|
|
844
|
+
name = "PaddleTextDetector";
|
|
845
|
+
constructor(options = {}) {
|
|
846
|
+
super(resolveParams(PADDLE_DETECTOR_DEFAULTS, options, { preset: validatePreset }));
|
|
847
|
+
}
|
|
848
|
+
async init() {
|
|
849
|
+
await getPaddleService(this.params.preset);
|
|
850
|
+
}
|
|
851
|
+
async apply(input, row) {
|
|
852
|
+
const service = await getPaddleService(this.params.preset);
|
|
853
|
+
const canvas = await toCanvas(input);
|
|
854
|
+
const { boxes } = await service.detect(canvas);
|
|
855
|
+
return createDetectorOutput({
|
|
856
|
+
path: String(row[this.params.pathCol] ?? "memory"),
|
|
857
|
+
type: "paddle",
|
|
858
|
+
bboxes: boxes.map((b) => boxFromBBox([
|
|
859
|
+
b.x,
|
|
860
|
+
b.y,
|
|
861
|
+
b.x + b.width,
|
|
862
|
+
b.y + b.height
|
|
863
|
+
], { score: 1 }))
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
onError(message, row) {
|
|
867
|
+
return createDetectorOutput({
|
|
868
|
+
path: String(row[this.params.pathCol] ?? "memory"),
|
|
869
|
+
type: "paddle",
|
|
870
|
+
exception: message
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
const PADDLE_RECOGNIZER_DEFAULTS = Object.freeze({
|
|
875
|
+
...BASE_STAGE_DEFAULTS,
|
|
876
|
+
inputCol: "image",
|
|
877
|
+
outputCol: "text",
|
|
878
|
+
keepInputData: true,
|
|
879
|
+
preset: DEFAULT_OCR_PRESET,
|
|
880
|
+
scoreThreshold: .5,
|
|
881
|
+
strategy: "per-box",
|
|
882
|
+
keepFormatting: false,
|
|
883
|
+
lineTolerance: 0
|
|
884
|
+
});
|
|
885
|
+
/** Full OCR: image -> Document with text and word-level boxes. */
|
|
886
|
+
var PaddleTextRecognizer = class extends Stage {
|
|
887
|
+
name = "PaddleTextRecognizer";
|
|
888
|
+
constructor(options = {}) {
|
|
889
|
+
super(resolveParams(PADDLE_RECOGNIZER_DEFAULTS, options, { preset: validatePreset }));
|
|
890
|
+
}
|
|
891
|
+
async init() {
|
|
892
|
+
await getPaddleService(this.params.preset);
|
|
893
|
+
}
|
|
894
|
+
async apply(input, row) {
|
|
895
|
+
const { preset, strategy, scoreThreshold, keepFormatting, lineTolerance } = this.params;
|
|
896
|
+
const service = await getPaddleService(preset);
|
|
897
|
+
const canvas = await toCanvas(input);
|
|
898
|
+
const result = await service.recognize(canvas, {
|
|
899
|
+
flatten: true,
|
|
900
|
+
strategy,
|
|
901
|
+
noCache: true
|
|
902
|
+
});
|
|
903
|
+
const bboxes = ("results" in result ? result.results : []).filter((item) => item.confidence >= scoreThreshold).map((item) => boxFromBBox([
|
|
904
|
+
item.box.x,
|
|
905
|
+
item.box.y,
|
|
906
|
+
item.box.x + item.box.width,
|
|
907
|
+
item.box.y + item.box.height
|
|
908
|
+
], {
|
|
909
|
+
text: item.text,
|
|
910
|
+
score: item.confidence
|
|
911
|
+
}));
|
|
912
|
+
return createDocument({
|
|
913
|
+
path: String(row[this.params.pathCol] ?? "memory"),
|
|
914
|
+
type: "ocr",
|
|
915
|
+
text: keepFormatting ? boxesToFormattedText(bboxes, lineTolerance) : boxesToText(bboxes),
|
|
916
|
+
bboxes
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
onError(message, row) {
|
|
920
|
+
return createDocument({
|
|
921
|
+
path: String(row[this.params.pathCol] ?? "memory"),
|
|
922
|
+
type: "ocr",
|
|
923
|
+
exception: message
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
//#endregion
|
|
928
|
+
//#region src/ocr/script-detect.ts
|
|
929
|
+
/**
|
|
930
|
+
* Writing-script detection via Tesseract's OSD model.
|
|
931
|
+
*
|
|
932
|
+
* Kept separate from recognition on purpose: when the recognition model is
|
|
933
|
+
* wrong for the page the text comes back garbled, and that is precisely when
|
|
934
|
+
* knowing the script is most useful. Running OSD independently means the
|
|
935
|
+
* answer stays trustworthy.
|
|
936
|
+
*
|
|
937
|
+
* Feeds the preset picker -- see `presetsForScript`.
|
|
938
|
+
*/
|
|
939
|
+
let workerPromise = null;
|
|
940
|
+
async function getOsdWorker() {
|
|
941
|
+
if (workerPromise) return workerPromise;
|
|
942
|
+
workerPromise = (async () => {
|
|
943
|
+
let mod;
|
|
944
|
+
try {
|
|
945
|
+
mod = await import("tesseract.js");
|
|
946
|
+
} catch (cause) {
|
|
947
|
+
throw new Error("tesseract.js is required for script detection. Install it: npm i tesseract.js", { cause });
|
|
948
|
+
}
|
|
949
|
+
return mod.createWorker("osd", mod.OEM.TESSERACT_ONLY);
|
|
950
|
+
})();
|
|
951
|
+
workerPromise.catch(() => {
|
|
952
|
+
workerPromise = null;
|
|
953
|
+
});
|
|
954
|
+
return workerPromise;
|
|
955
|
+
}
|
|
956
|
+
/** Detect the dominant writing script on a page. Returns null when unsure. */
|
|
957
|
+
async function detectScript(source) {
|
|
958
|
+
const worker = await getOsdWorker();
|
|
959
|
+
let canvas;
|
|
960
|
+
if (typeof OffscreenCanvas !== "undefined" && source instanceof OffscreenCanvas) canvas = source;
|
|
961
|
+
else if (typeof ImageData !== "undefined" && source instanceof ImageData) canvas = imageDataToCanvas(source);
|
|
962
|
+
else if (typeof ImageBitmap !== "undefined" && source instanceof ImageBitmap) canvas = imageDataToCanvas(toImageData(source));
|
|
963
|
+
else {
|
|
964
|
+
const image = source;
|
|
965
|
+
if (!(image.data instanceof Uint8Array) || image.data.byteLength === 0) throw new OcrError("Expected an Image with decoded bytes", "detectScript");
|
|
966
|
+
const bitmap = await decodeImage(image.data);
|
|
967
|
+
try {
|
|
968
|
+
canvas = imageDataToCanvas(toImageData(bitmap));
|
|
969
|
+
} finally {
|
|
970
|
+
bitmap.close();
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
const blob = await canvas.convertToBlob({ type: "image/png" });
|
|
974
|
+
const { data } = await worker.detect(blob);
|
|
975
|
+
if (!data?.script) return null;
|
|
976
|
+
return {
|
|
977
|
+
script: data.script,
|
|
978
|
+
confidence: data.script_confidence ?? 0
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
/** Presets able to read the detected script, best-first. */
|
|
982
|
+
async function suggestPresets(source) {
|
|
983
|
+
const detected = await detectScript(source);
|
|
984
|
+
return detected ? presetsForScript(detected.script) : [];
|
|
985
|
+
}
|
|
986
|
+
/** Tear down the shared OSD worker. */
|
|
987
|
+
async function disposeScriptDetection() {
|
|
988
|
+
const pending = workerPromise;
|
|
989
|
+
workerPromise = null;
|
|
990
|
+
await pending?.then((w) => w.terminate()).catch(() => void 0);
|
|
991
|
+
}
|
|
992
|
+
//#endregion
|
|
993
|
+
//#region src/ocr/tesseract.ts
|
|
994
|
+
/**
|
|
995
|
+
* Tesseract OCR, mirroring ScaleDP's `TesseractOcr` stage.
|
|
996
|
+
*
|
|
997
|
+
* Recognition runs on tesseract-wasm; script detection uses tesseract.js's OSD
|
|
998
|
+
* model. The two are deliberately independent -- script detection still works
|
|
999
|
+
* when the recognition model is wrong for the page and garbles the text, which
|
|
1000
|
+
* is exactly when you most want to know the script.
|
|
1001
|
+
*
|
|
1002
|
+
* Both the worker URL and the traineddata location come from `configure()`.
|
|
1003
|
+
* The pdftools prototype hardcoded `/tesseract-worker.js` and a raw GitHub URL,
|
|
1004
|
+
* neither of which a library can assume.
|
|
1005
|
+
*/
|
|
1006
|
+
/** tessdata_fast is a good default: far smaller than tessdata, barely less accurate. */
|
|
1007
|
+
const DEFAULT_TESSDATA_URL = "https://raw.githubusercontent.com/tesseract-ocr/tessdata_fast/main/";
|
|
1008
|
+
const TESSERACT_OCR_DEFAULTS = Object.freeze({
|
|
1009
|
+
...BASE_STAGE_DEFAULTS,
|
|
1010
|
+
inputCol: "image",
|
|
1011
|
+
outputCol: "text",
|
|
1012
|
+
keepInputData: true,
|
|
1013
|
+
lang: ["eng"],
|
|
1014
|
+
scoreThreshold: .5,
|
|
1015
|
+
keepFormatting: false,
|
|
1016
|
+
lineTolerance: 0
|
|
1017
|
+
});
|
|
1018
|
+
let clientPromise = null;
|
|
1019
|
+
let loadedLanguage = null;
|
|
1020
|
+
function trainedDataUrl(lang) {
|
|
1021
|
+
return `${(getConfig().tesseract.dataUrl ?? "https://raw.githubusercontent.com/tesseract-ocr/tessdata_fast/main/").replace(/\/?$/, "/")}${lang}.traineddata`;
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* The shared Tesseract client, loading the model for `lang` if needed.
|
|
1025
|
+
*
|
|
1026
|
+
* Exported so the crop-based recognizer reuses this one client rather than
|
|
1027
|
+
* standing up a second worker and loading the same traineddata twice.
|
|
1028
|
+
*/
|
|
1029
|
+
async function getTesseractClient(lang) {
|
|
1030
|
+
if (clientPromise && loadedLanguage === lang) return clientPromise;
|
|
1031
|
+
if (clientPromise) {
|
|
1032
|
+
const previous = clientPromise;
|
|
1033
|
+
clientPromise = null;
|
|
1034
|
+
await previous.then((c) => c.destroy()).catch(() => void 0);
|
|
1035
|
+
}
|
|
1036
|
+
loadedLanguage = lang;
|
|
1037
|
+
clientPromise = (async () => {
|
|
1038
|
+
let mod;
|
|
1039
|
+
try {
|
|
1040
|
+
mod = await import("tesseract-wasm");
|
|
1041
|
+
} catch (cause) {
|
|
1042
|
+
throw new Error("tesseract-wasm is required for the Tesseract engine. Install it: npm i tesseract-wasm", { cause });
|
|
1043
|
+
}
|
|
1044
|
+
const { workerUrl } = getConfig().tesseract;
|
|
1045
|
+
const client = new mod.OCRClient(workerUrl ? { workerURL: workerUrl } : {});
|
|
1046
|
+
const response = await fetch(trainedDataUrl(lang));
|
|
1047
|
+
if (!response.ok) throw new OcrError(`Failed to fetch ${lang}.traineddata: ${response.status} ${response.statusText}`, "TesseractOcr");
|
|
1048
|
+
await client.loadModel(new Uint8Array(await response.arrayBuffer()));
|
|
1049
|
+
return client;
|
|
1050
|
+
})();
|
|
1051
|
+
clientPromise.catch(() => {
|
|
1052
|
+
clientPromise = null;
|
|
1053
|
+
loadedLanguage = null;
|
|
1054
|
+
});
|
|
1055
|
+
return clientPromise;
|
|
1056
|
+
}
|
|
1057
|
+
/** Tear down the shared Tesseract client. */
|
|
1058
|
+
async function disposeTesseract() {
|
|
1059
|
+
const pending = clientPromise;
|
|
1060
|
+
clientPromise = null;
|
|
1061
|
+
loadedLanguage = null;
|
|
1062
|
+
await pending?.then((c) => c.destroy()).catch(() => void 0);
|
|
1063
|
+
}
|
|
1064
|
+
var TesseractOcr = class extends Stage {
|
|
1065
|
+
name = "TesseractOcr";
|
|
1066
|
+
constructor(options = {}) {
|
|
1067
|
+
super(resolveParams(TESSERACT_OCR_DEFAULTS, options, { lang: (value) => {
|
|
1068
|
+
if (value.length === 0) throw new RangeError("lang must not be empty");
|
|
1069
|
+
} }));
|
|
1070
|
+
}
|
|
1071
|
+
/** tesseract-wasm loads one model, so multi-language means a joined code. */
|
|
1072
|
+
get language() {
|
|
1073
|
+
return this.params.lang.join("+");
|
|
1074
|
+
}
|
|
1075
|
+
async init() {
|
|
1076
|
+
await getTesseractClient(this.language);
|
|
1077
|
+
}
|
|
1078
|
+
async apply(input, row) {
|
|
1079
|
+
const image = input;
|
|
1080
|
+
if (image?.exception) throw new OcrError(`Upstream stage failed: ${image.exception}`, this.name);
|
|
1081
|
+
if (!image || !(image.data instanceof Uint8Array) || image.data.byteLength === 0) throw new OcrError("Expected an Image with decoded bytes", this.name);
|
|
1082
|
+
const client = await getTesseractClient(this.language);
|
|
1083
|
+
const bitmap = await decodeImage(image.data);
|
|
1084
|
+
let words;
|
|
1085
|
+
try {
|
|
1086
|
+
await client.loadImage(toImageData(bitmap));
|
|
1087
|
+
words = await client.getTextBoxes("word");
|
|
1088
|
+
} finally {
|
|
1089
|
+
bitmap.close();
|
|
1090
|
+
}
|
|
1091
|
+
const { scoreThreshold, keepFormatting, lineTolerance } = this.params;
|
|
1092
|
+
const bboxes = words.filter((word) => word.confidence >= scoreThreshold && word.text.trim().length > 0).map((word) => boxFromBBox([
|
|
1093
|
+
word.rect.left,
|
|
1094
|
+
word.rect.top,
|
|
1095
|
+
word.rect.right,
|
|
1096
|
+
word.rect.bottom
|
|
1097
|
+
], {
|
|
1098
|
+
text: word.text.trim(),
|
|
1099
|
+
score: word.confidence
|
|
1100
|
+
}));
|
|
1101
|
+
return createDocument({
|
|
1102
|
+
path: String(row[this.params.pathCol] ?? "memory"),
|
|
1103
|
+
type: "tesseract",
|
|
1104
|
+
text: keepFormatting ? boxesToFormattedText(bboxes, lineTolerance) : boxesToText(bboxes),
|
|
1105
|
+
bboxes
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
onError(message, row) {
|
|
1109
|
+
return createDocument({
|
|
1110
|
+
path: String(row[this.params.pathCol] ?? "memory"),
|
|
1111
|
+
type: "tesseract",
|
|
1112
|
+
exception: message
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
async dispose() {
|
|
1116
|
+
await disposeTesseract();
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
//#endregion
|
|
1120
|
+
//#region src/ocr/tesseract-recognizer.ts
|
|
1121
|
+
/**
|
|
1122
|
+
* Recognize text inside boxes a detector already found.
|
|
1123
|
+
*
|
|
1124
|
+
* Port of ScaleDP's `TesseractRecognizer` (a `BaseRecognizer`), which is the
|
|
1125
|
+
* half of the OCR story `PaddleTextRecognizer` cannot cover: Paddle detects and
|
|
1126
|
+
* recognises in a single pass over the page, so boxes produced by a *separate*
|
|
1127
|
+
* detector never reach it -- rotated boxes in particular. This stage takes those
|
|
1128
|
+
* boxes, straightens each one, and reads it.
|
|
1129
|
+
*/
|
|
1130
|
+
const TESSERACT_RECOGNIZER_DEFAULTS = Object.freeze({
|
|
1131
|
+
...BASE_STAGE_DEFAULTS,
|
|
1132
|
+
inputCol: "image",
|
|
1133
|
+
inputCols: ["image", "boxes"],
|
|
1134
|
+
outputCol: "text",
|
|
1135
|
+
keepInputData: true,
|
|
1136
|
+
lang: ["eng"],
|
|
1137
|
+
scaleFactor: 1,
|
|
1138
|
+
padding: 5,
|
|
1139
|
+
scoreThreshold: .5,
|
|
1140
|
+
keepFormatting: false,
|
|
1141
|
+
lineTolerance: 0,
|
|
1142
|
+
boxLevel: "region",
|
|
1143
|
+
detectLineOrientation: true,
|
|
1144
|
+
onlyRotated: false,
|
|
1145
|
+
oriModel: DEFAULT_ORIENTATION_MODEL
|
|
1146
|
+
});
|
|
1147
|
+
function boxesOf(source) {
|
|
1148
|
+
if (typeof source !== "object" || source === null) return [];
|
|
1149
|
+
return source.bboxes ?? [];
|
|
1150
|
+
}
|
|
1151
|
+
var TesseractRecognizer = class extends Stage {
|
|
1152
|
+
name = "TesseractRecognizer";
|
|
1153
|
+
orientation = null;
|
|
1154
|
+
constructor(options = {}) {
|
|
1155
|
+
super(resolveParams(TESSERACT_RECOGNIZER_DEFAULTS, options, {
|
|
1156
|
+
inputCols: (value) => {
|
|
1157
|
+
if (value.length !== 2) throw new RangeError("inputCols must be [imageColumn, boxColumn]");
|
|
1158
|
+
},
|
|
1159
|
+
lang: (value) => {
|
|
1160
|
+
if (value.length === 0) throw new RangeError("lang must not be empty");
|
|
1161
|
+
}
|
|
1162
|
+
}));
|
|
1163
|
+
}
|
|
1164
|
+
get language() {
|
|
1165
|
+
return this.params.lang.join("+");
|
|
1166
|
+
}
|
|
1167
|
+
async init() {
|
|
1168
|
+
await getTesseractClient(this.language);
|
|
1169
|
+
if (this.params.detectLineOrientation) this.orientation ??= new LineOrientationClassifier(this.params.oriModel);
|
|
1170
|
+
}
|
|
1171
|
+
async apply(_input, row, ctx) {
|
|
1172
|
+
const { inputCols, scaleFactor, padding, scoreThreshold, keepFormatting, lineTolerance, boxLevel, detectLineOrientation, onlyRotated } = this.params;
|
|
1173
|
+
const [imageCol, boxCol] = inputCols;
|
|
1174
|
+
const image = row[imageCol];
|
|
1175
|
+
if (image?.exception) throw new OcrError(`Upstream stage failed: ${image.exception}`, this.name);
|
|
1176
|
+
if (!image || !(image.data instanceof Uint8Array) || image.data.byteLength === 0) throw new OcrError("Expected an Image with decoded bytes", this.name);
|
|
1177
|
+
const source = row[boxCol];
|
|
1178
|
+
if (source === void 0) throw new OcrError(`No boxes in column "${boxCol}". This stage reads a detector's output; run a text detector before it.`, this.name);
|
|
1179
|
+
await this.init();
|
|
1180
|
+
const client = await getTesseractClient(this.language);
|
|
1181
|
+
const bitmap = await decodeImage(image.data);
|
|
1182
|
+
const recognized = [];
|
|
1183
|
+
try {
|
|
1184
|
+
for (const box of boxesOf(source)) {
|
|
1185
|
+
ctx.signal?.throwIfAborted();
|
|
1186
|
+
const geometry = cropGeometry(box, {
|
|
1187
|
+
scaleFactor,
|
|
1188
|
+
padding
|
|
1189
|
+
});
|
|
1190
|
+
let crop = cropBox(bitmap, box, {
|
|
1191
|
+
scaleFactor,
|
|
1192
|
+
padding
|
|
1193
|
+
});
|
|
1194
|
+
let inverted = false;
|
|
1195
|
+
if (detectLineOrientation && this.orientation) {
|
|
1196
|
+
inverted = await this.orientation.classify(crop) === "180_degree";
|
|
1197
|
+
if (inverted) crop = rotate180(crop);
|
|
1198
|
+
}
|
|
1199
|
+
if (onlyRotated && !isRotated(box) && !inverted) continue;
|
|
1200
|
+
await client.loadImage(toImageData(crop));
|
|
1201
|
+
const items = await client.getTextBoxes("word");
|
|
1202
|
+
const words = items.filter((item) => item.text.trim());
|
|
1203
|
+
if (words.length === 0) continue;
|
|
1204
|
+
const score = items.reduce((sum, item) => sum + item.confidence, 0) / (items.length || 1);
|
|
1205
|
+
if (score < scoreThreshold) continue;
|
|
1206
|
+
if (boxLevel === "word") {
|
|
1207
|
+
for (const item of words) recognized.push(wordBox(item.rect, geometry, inverted, scaleFactor, item.text.trim(), item.confidence));
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
1210
|
+
recognized.push({
|
|
1211
|
+
...box,
|
|
1212
|
+
text: words.map((item) => item.text.trim()).join(" "),
|
|
1213
|
+
score
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
} finally {
|
|
1217
|
+
bitmap.close();
|
|
1218
|
+
}
|
|
1219
|
+
const bboxes = scaleFactor === 1 || boxLevel === "word" ? recognized : recognized.map((box) => ({
|
|
1220
|
+
...box,
|
|
1221
|
+
x: Math.round(box.x / scaleFactor),
|
|
1222
|
+
y: Math.round(box.y / scaleFactor),
|
|
1223
|
+
width: Math.round(box.width / scaleFactor),
|
|
1224
|
+
height: Math.round(box.height / scaleFactor)
|
|
1225
|
+
}));
|
|
1226
|
+
return createDocument({
|
|
1227
|
+
path: String(row[this.params.pathCol] ?? image.path),
|
|
1228
|
+
type: "tesseract-recognizer",
|
|
1229
|
+
text: keepFormatting ? boxesToFormattedText(bboxes, lineTolerance) : boxesToText(bboxes),
|
|
1230
|
+
bboxes
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
onError(message, row) {
|
|
1234
|
+
return createDocument({
|
|
1235
|
+
path: String(row[this.params.pathCol] ?? "memory"),
|
|
1236
|
+
type: "tesseract-recognizer",
|
|
1237
|
+
exception: message
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
async dispose() {
|
|
1241
|
+
await this.orientation?.dispose();
|
|
1242
|
+
this.orientation = null;
|
|
1243
|
+
}
|
|
1244
|
+
};
|
|
1245
|
+
/**
|
|
1246
|
+
* One word's box, in the page's coordinates.
|
|
1247
|
+
*
|
|
1248
|
+
* tesseract reports the rect in the crop's own space: straightened, padded,
|
|
1249
|
+
* scaled, and turned the right way up if the line was upside down. Undo the
|
|
1250
|
+
* turn, then push the four corners back through the crop's own transform, so a
|
|
1251
|
+
* word inside a skewed line comes back skewed the same way.
|
|
1252
|
+
*/
|
|
1253
|
+
function wordBox(rect, geometry, inverted, scaleFactor, text, score) {
|
|
1254
|
+
const { width, height, map } = geometry;
|
|
1255
|
+
const [left, top, right, bottom] = inverted ? [
|
|
1256
|
+
width - rect.right,
|
|
1257
|
+
height - rect.bottom,
|
|
1258
|
+
width - rect.left,
|
|
1259
|
+
height - rect.top
|
|
1260
|
+
] : [
|
|
1261
|
+
rect.left,
|
|
1262
|
+
rect.top,
|
|
1263
|
+
rect.right,
|
|
1264
|
+
rect.bottom
|
|
1265
|
+
];
|
|
1266
|
+
const toPage = (x, y) => {
|
|
1267
|
+
const [px, py] = map(x, y);
|
|
1268
|
+
return [px / scaleFactor, py / scaleFactor];
|
|
1269
|
+
};
|
|
1270
|
+
const corners = [
|
|
1271
|
+
toPage(left, top),
|
|
1272
|
+
toPage(right, top),
|
|
1273
|
+
toPage(right, bottom),
|
|
1274
|
+
toPage(left, bottom)
|
|
1275
|
+
];
|
|
1276
|
+
return boxFromPolygon(corners, {
|
|
1277
|
+
text,
|
|
1278
|
+
score
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
/** Turn a crop 180 degrees, so an inverted line reads the right way up. */
|
|
1282
|
+
function rotate180(source) {
|
|
1283
|
+
const out = new OffscreenCanvas(source.width, source.height);
|
|
1284
|
+
const ctx = out.getContext("2d");
|
|
1285
|
+
if (!ctx) throw new OcrError("Failed to acquire a 2D context", "TesseractRecognizer");
|
|
1286
|
+
ctx.translate(source.width / 2, source.height / 2);
|
|
1287
|
+
ctx.rotate(Math.PI);
|
|
1288
|
+
ctx.drawImage(source, -source.width / 2, -source.height / 2);
|
|
1289
|
+
return out;
|
|
1290
|
+
}
|
|
1291
|
+
//#endregion
|
|
1292
|
+
export { getDetectorModel as A, quadsFromProbabilityMap as B, LINE_ORIENTATION_DEFAULTS as C, ORIENTATION_INPUT as D, LineOrientationClassifier as E, DB_POSTPROCESS_DEFAULTS as F, boxScore as I, findComponentBoundaries as L, DBNET_INPUT_SIZE as M, DEFAULT_DBNET_MODEL as N, DEFAULT_DETECTOR_ID as O, DbnetOnnxDetector as P, miniBox as R, presetsForScript as S, DEFAULT_ORIENTATION_MODEL as T, unclipRect as V, loadPreset as _, TesseractOcr as a, PADDLE_OCR_PRESETS as b, disposeScriptDetection as c, PADDLE_RECOGNIZER_DEFAULTS as d, PaddleTextDetector as f, isPresetCached as g, getPaddleService as h, TESSERACT_OCR_DEFAULTS as i, DBNET_DETECTOR_DEFAULTS as j, DETECTOR_MODELS as k, suggestPresets as l, disposePaddleServices as m, TesseractRecognizer as n, disposeTesseract as o, PaddleTextRecognizer as p, DEFAULT_TESSDATA_URL as r, detectScript as s, TESSERACT_RECOGNIZER_DEFAULTS as t, PADDLE_DETECTOR_DEFAULTS as u, removePreset as v, LineOrientationDetector as w, isKnownPreset as x, DEFAULT_OCR_PRESET as y, orderPointsClockwise as z };
|
|
1293
|
+
|
|
1294
|
+
//# sourceMappingURL=ocr-OHX2WM3e.js.map
|