@tacman1123/tiny-ocr 0.0.0-stage → 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 +65 -2
- package/models/SHA256SUMS +3 -0
- package/package.json +15 -4
- package/src/index.js +224 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tac Tacelosky
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,66 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tacman1123/tiny-ocr
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
In-browser OCR using PP-OCRv6 **Tiny** on [onnxruntime-web](https://www.npmjs.com/package/onnxruntime-web). The same
|
|
4
|
+
`det.onnx` / `rec.onnx` / `dict.txt` that imagor's optional `ocr()` filter (rusto) uses on the server, so a browser
|
|
5
|
+
result and a server result have the same shape and can be compared directly.
|
|
6
|
+
|
|
7
|
+
Two calls:
|
|
8
|
+
|
|
9
|
+
- `probe(frame)` is detection only. It is cheap enough for a live camera loop and answers "does this look like it has readable text?"
|
|
10
|
+
- `read(frame)` is detection + recognition. It returns an object shaped like imagor's `ocr` metadata.
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
import { createTinyOcr } from '@tacman1123/tiny-ocr';
|
|
14
|
+
|
|
15
|
+
const ocr = await createTinyOcr({ modelBase: '/models/' }); // where det.onnx, rec.onnx, dict.txt are served
|
|
16
|
+
|
|
17
|
+
const p = await ocr.probe(videoElement); // { hasText, count, coverage, score, sharpness, boxes, ms }
|
|
18
|
+
if (p.hasText) {
|
|
19
|
+
const r = await ocr.read(photoBlob); // { text, text_score, char_count, regions: [{ text, score, box }], timing }
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Inputs can be a `<video>`, `<canvas>`, `OffscreenCanvas`, `<img>`, `ImageBitmap`, or a `Blob`/`File` (EXIF orientation is applied).
|
|
24
|
+
Box coordinates are in the source image's pixels.
|
|
25
|
+
|
|
26
|
+
## Models
|
|
27
|
+
|
|
28
|
+
The weights are not in this repo or the package (6.3 MB). Serve them yourself with long cache headers. Get them from
|
|
29
|
+
[byrizki/rusto-rs](https://github.com/byrizki/rusto-rs):
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
bash scripts/download_models.sh --model ppocrv6 --tier tiny --output-dir models
|
|
33
|
+
cd models && cp tiny/* . && shasum -a 256 -c SHA256SUMS
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`models/SHA256SUMS` lists the checksums of the Tiny files this was built and tested against.
|
|
37
|
+
|
|
38
|
+
## The text gate
|
|
39
|
+
|
|
40
|
+
`probe()` returns `hasText` from two thresholds, `minBoxes` (default 2) and `minCoverage` (default 0.01 of the frame).
|
|
41
|
+
Those defaults are starting points, not a validated classifier. Tune them on your own photos (product boxes, envelopes,
|
|
42
|
+
no-text shots) before relying on them. `sharpness` (Laplacian variance) is returned for a blur check but is not part of the decision.
|
|
43
|
+
|
|
44
|
+
## Options
|
|
45
|
+
|
|
46
|
+
`createTinyOcr({ modelBase, ort, canvasFactory, numThreads, executionProviders })`. `ort` defaults to a global `ort`
|
|
47
|
+
(script tag) or `import('onnxruntime-web')`. Threaded wasm needs COOP/COEP headers; without them it runs single-threaded.
|
|
48
|
+
`probe()`/`read()` take `{ maxSide, thresh, boxThresh, unclip, dilate, minTextScore, minBoxes, minCoverage }`.
|
|
49
|
+
|
|
50
|
+
## Known limits
|
|
51
|
+
|
|
52
|
+
- Boxes are axis-aligned. Tilted text gets looser crops than rusto's rotated boxes.
|
|
53
|
+
- Tall boxes (height/width ≥ 1.5) are rotated 90° counter-clockwise before recognition. Upside-down text is not corrected.
|
|
54
|
+
- No orientation detection, no multi-page handling, no table or layout.
|
|
55
|
+
- Tiny's text-presence recall has not been validated on dense or unusual scripts.
|
|
56
|
+
|
|
57
|
+
## Develop
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
npm i && npm test # Node: onnxruntime-node + canvas shim, needs models/ populated
|
|
61
|
+
python3 -m http.server # then open /demo/ (camera needs HTTPS or localhost)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
MIT. Model weights are PaddleOCR's; see their license.
|
|
65
|
+
|
|
66
|
+
`test/sample.png` is from [byrizki/rusto-rs](https://github.com/byrizki/rusto-rs) (MIT); the pre/post-processing here follows its implementation.
|
package/package.json
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tacman1123/tiny-ocr",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "In-browser OCR with PP-OCRv6 Tiny via onnxruntime-web: a cheap probe() to ask 'is there readable text?' and a read() for the full result, shaped like imagor's ocr metadata.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"module": "src/index.js",
|
|
8
|
+
"exports": { ".": "./src/index.js" },
|
|
9
|
+
"files": ["src", "models/SHA256SUMS", "README.md", "LICENSE"],
|
|
10
|
+
"sideEffects": false,
|
|
11
|
+
"scripts": { "test": "node test/pipeline.test.mjs" },
|
|
12
|
+
"peerDependencies": { "onnxruntime-web": "^1.22.0" },
|
|
13
|
+
"devDependencies": { "@napi-rs/canvas": "^0.1.0", "onnxruntime-node": "1.22.0" },
|
|
14
|
+
"repository": { "type": "git", "url": "git+https://github.com/tacman/tiny-ocr.git" },
|
|
15
|
+
"keywords": ["ocr", "onnx", "paddleocr", "browser", "camera"],
|
|
16
|
+
"license": "MIT"
|
|
17
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// In-browser PP-OCRv6 Tiny (det.onnx + rec.onnx + dict.txt) on onnxruntime-web.
|
|
2
|
+
// Pre/post-processing follows byrizki/rusto-rs, the engine behind imagor's ocr() metadata filter.
|
|
3
|
+
|
|
4
|
+
const DET_MEAN = [0.485, 0.456, 0.406]; // applied to B,G,R in that order, as rusto does
|
|
5
|
+
const DET_STD = [0.229, 0.224, 0.225];
|
|
6
|
+
const REC_H = 48;
|
|
7
|
+
const REC_MIN_W = 320;
|
|
8
|
+
|
|
9
|
+
const DEFAULTS = {
|
|
10
|
+
maxSide: 960, // detection long-side cap; probe() callers usually pass ~480
|
|
11
|
+
thresh: 0.3, // DB binarisation threshold
|
|
12
|
+
boxThresh: 0.6, // minimum mean probability inside a box
|
|
13
|
+
unclip: 1.8, // box expansion (rusto v6 preset: 2.0)
|
|
14
|
+
dilate: true,
|
|
15
|
+
minTextScore: 0.5, // read(): drop recognised regions below this
|
|
16
|
+
// probe() decision. These are starting points; tune them on your own photos.
|
|
17
|
+
minBoxes: 2,
|
|
18
|
+
minCoverage: 0.01, // fraction of the frame covered by text boxes
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const sizeOf = s => [s.videoWidth || s.naturalWidth || s.displayWidth || s.width, s.videoHeight || s.naturalHeight || s.displayHeight || s.height];
|
|
22
|
+
|
|
23
|
+
function defaultCanvas(w, h) {
|
|
24
|
+
if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h);
|
|
25
|
+
const c = document.createElement('canvas'); c.width = w; c.height = h; return c;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function createTinyOcr({ modelBase = './models/', ort, canvasFactory = defaultCanvas, numThreads, executionProviders = ['wasm'] } = {}) {
|
|
29
|
+
ort ??= globalThis.ort ?? await import('onnxruntime-web');
|
|
30
|
+
if (numThreads) ort.env.wasm.numThreads = numThreads;
|
|
31
|
+
else if (ort.env?.wasm && !globalThis.crossOriginIsolated) ort.env.wasm.numThreads = 1;
|
|
32
|
+
const base = modelBase.endsWith('/') ? modelBase : modelBase + '/';
|
|
33
|
+
const sessionOpts = { executionProviders, graphOptimizationLevel: 'all' };
|
|
34
|
+
|
|
35
|
+
const [det, rec, dictText] = await Promise.all([
|
|
36
|
+
ort.InferenceSession.create(base + 'det.onnx', sessionOpts),
|
|
37
|
+
ort.InferenceSession.create(base + 'rec.onnx', sessionOpts),
|
|
38
|
+
fetchText(base + 'dict.txt'),
|
|
39
|
+
]);
|
|
40
|
+
const lines = dictText.split('\n').map(l => l.replace(/\r$/, ''));
|
|
41
|
+
if (lines[lines.length - 1] === '') lines.pop();
|
|
42
|
+
const chars = ['blank', ...lines, ' ']; // CTC: blank first, space appended
|
|
43
|
+
|
|
44
|
+
const ctx2d = c => c.getContext('2d', { willReadFrequently: true });
|
|
45
|
+
|
|
46
|
+
// ort sessions are not re-entrant across overlapping calls; serialise them.
|
|
47
|
+
let chain = Promise.resolve();
|
|
48
|
+
let pending = 0;
|
|
49
|
+
const serial = fn => { pending++; const p = chain.then(fn).finally(() => pending--); chain = p.catch(() => {}); return p; };
|
|
50
|
+
|
|
51
|
+
async function toSource(input) {
|
|
52
|
+
if (typeof Blob !== 'undefined' && input instanceof Blob) return createImageBitmap(input, { imageOrientation: 'from-image' });
|
|
53
|
+
return input;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function detect(src, o) {
|
|
57
|
+
const [w, h] = sizeOf(src);
|
|
58
|
+
const ratio = Math.min(1, o.maxSide / Math.max(w, h));
|
|
59
|
+
const rh = Math.max(32, Math.round(h * ratio / 32) * 32), rw = Math.max(32, Math.round(w * ratio / 32) * 32);
|
|
60
|
+
const t = canvasFactory(rw, rh), tg = ctx2d(t);
|
|
61
|
+
tg.fillStyle = '#fff'; tg.fillRect(0, 0, rw, rh);
|
|
62
|
+
tg.imageSmoothingQuality = 'high'; tg.drawImage(src, 0, 0, rw, rh);
|
|
63
|
+
const px = tg.getImageData(0, 0, rw, rh).data, plane = rw * rh;
|
|
64
|
+
const input = new Float32Array(3 * plane);
|
|
65
|
+
let lapSum = 0, lapSq = 0, lapN = 0;
|
|
66
|
+
const lum = new Float32Array(plane);
|
|
67
|
+
for (let i = 0; i < plane; i++) {
|
|
68
|
+
const r = px[4 * i], g = px[4 * i + 1], b = px[4 * i + 2];
|
|
69
|
+
lum[i] = 0.299 * r + 0.587 * g + 0.114 * b;
|
|
70
|
+
input[i] = (b / 255 - DET_MEAN[0]) / DET_STD[0];
|
|
71
|
+
input[plane + i] = (g / 255 - DET_MEAN[1]) / DET_STD[1];
|
|
72
|
+
input[2 * plane + i] = (r / 255 - DET_MEAN[2]) / DET_STD[2];
|
|
73
|
+
}
|
|
74
|
+
for (let y = 1; y < rh - 1; y++) for (let x = 1; x < rw - 1; x++) { // Laplacian variance = cheap blur measure
|
|
75
|
+
const i = y * rw + x, l = lum[i - 1] + lum[i + 1] + lum[i - rw] + lum[i + rw] - 4 * lum[i];
|
|
76
|
+
lapSum += l; lapSq += l * l; lapN++;
|
|
77
|
+
}
|
|
78
|
+
const sharpness = lapN ? lapSq / lapN - (lapSum / lapN) ** 2 : 0;
|
|
79
|
+
|
|
80
|
+
const out = await det.run({ [det.inputNames[0]]: new ort.Tensor('float32', input, [1, 3, rh, rw]) });
|
|
81
|
+
const od = out[det.outputNames[0]], prob = od.data, H = od.dims[od.dims.length - 2], W = od.dims[od.dims.length - 1];
|
|
82
|
+
|
|
83
|
+
let mask = new Uint8Array(W * H);
|
|
84
|
+
for (let i = 0; i < mask.length; i++) mask[i] = prob[i] > o.thresh ? 1 : 0;
|
|
85
|
+
if (o.dilate) { // 2x2 kernel
|
|
86
|
+
const d = new Uint8Array(mask);
|
|
87
|
+
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
|
|
88
|
+
const i = y * W + x;
|
|
89
|
+
if (mask[i] || (x + 1 < W && mask[i + 1]) || (y + 1 < H && mask[i + W]) || (x + 1 < W && y + 1 < H && mask[i + W + 1])) d[i] = 1;
|
|
90
|
+
}
|
|
91
|
+
mask = d;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// connected components (8-neighbour) -> axis-aligned boxes
|
|
95
|
+
const seen = new Uint8Array(W * H), stack = new Int32Array(W * H), boxes = [];
|
|
96
|
+
for (let s = 0; s < mask.length; s++) {
|
|
97
|
+
if (!mask[s] || seen[s]) continue;
|
|
98
|
+
let sp = 0, x0 = W, y0 = H, x1 = 0, y1 = 0;
|
|
99
|
+
stack[sp++] = s; seen[s] = 1;
|
|
100
|
+
while (sp) {
|
|
101
|
+
const i = stack[--sp], x = i % W, y = (i - x) / W;
|
|
102
|
+
if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y;
|
|
103
|
+
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
|
104
|
+
const nx = x + dx, ny = y + dy;
|
|
105
|
+
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
|
|
106
|
+
const j = ny * W + nx;
|
|
107
|
+
if (mask[j] && !seen[j]) { seen[j] = 1; stack[sp++] = j; }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const bw = x1 - x0 + 1, bh = y1 - y0 + 1;
|
|
111
|
+
if (Math.min(bw, bh) < 3) continue;
|
|
112
|
+
let sum = 0;
|
|
113
|
+
for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) sum += prob[y * W + x];
|
|
114
|
+
const score = sum / (bw * bh);
|
|
115
|
+
if (score < o.boxThresh) continue;
|
|
116
|
+
const dist = (bw * bh * o.unclip) / (2 * (bw + bh)); // DB unclip, rectangle case
|
|
117
|
+
boxes.push({
|
|
118
|
+
x0: Math.max(0, (x0 - dist) * w / W), y0: Math.max(0, (y0 - dist) * h / H),
|
|
119
|
+
x1: Math.min(w, (x1 + 1 + dist) * w / W), y1: Math.min(h, (y1 + 1 + dist) * h / H), score,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return { boxes, width: w, height: h, sharpness };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function crop(full, b) {
|
|
126
|
+
const x = Math.floor(b.x0), y = Math.floor(b.y0), cw = Math.max(1, Math.ceil(b.x1) - x), ch = Math.max(1, Math.ceil(b.y1) - y);
|
|
127
|
+
let c = canvasFactory(cw, ch);
|
|
128
|
+
ctx2d(c).drawImage(full, x, y, cw, ch, 0, 0, cw, ch);
|
|
129
|
+
if (ch / cw >= 1.5) { // sideways text: rotate 90° counter-clockwise
|
|
130
|
+
const r = canvasFactory(ch, cw), g = ctx2d(r);
|
|
131
|
+
g.translate(0, cw); g.rotate(-Math.PI / 2); g.drawImage(c, 0, 0);
|
|
132
|
+
c = r;
|
|
133
|
+
}
|
|
134
|
+
return c;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function recognize(c) {
|
|
138
|
+
const ratio = c.width / c.height;
|
|
139
|
+
const imgW = Math.round(REC_H * Math.max(REC_MIN_W / REC_H, ratio));
|
|
140
|
+
const rw = Math.min(imgW, Math.ceil(REC_H * ratio));
|
|
141
|
+
const t = canvasFactory(rw, REC_H), tg = ctx2d(t);
|
|
142
|
+
tg.imageSmoothingQuality = 'high'; tg.drawImage(c, 0, 0, rw, REC_H);
|
|
143
|
+
const px = tg.getImageData(0, 0, rw, REC_H).data, plane = REC_H * imgW;
|
|
144
|
+
const input = new Float32Array(3 * plane); // zero padding on the right
|
|
145
|
+
for (let y = 0; y < REC_H; y++) for (let x = 0; x < rw; x++) {
|
|
146
|
+
const s = 4 * (y * rw + x), d = y * imgW + x;
|
|
147
|
+
input[d] = (px[s + 2] / 255 - 0.5) / 0.5; input[plane + d] = (px[s + 1] / 255 - 0.5) / 0.5; input[2 * plane + d] = (px[s] / 255 - 0.5) / 0.5;
|
|
148
|
+
}
|
|
149
|
+
const out = await rec.run({ [rec.inputNames[0]]: new ort.Tensor('float32', input, [1, 3, REC_H, imgW]) });
|
|
150
|
+
const od = out[rec.outputNames[0]], T = od.dims[1], C = od.dims[2], d = od.data;
|
|
151
|
+
let text = '', sum = 0, n = 0, last = -1;
|
|
152
|
+
for (let t2 = 0; t2 < T; t2++) {
|
|
153
|
+
let best = 0, bv = -Infinity;
|
|
154
|
+
for (let k = 0; k < C; k++) { const v = d[t2 * C + k]; if (v > bv) { bv = v; best = k; } }
|
|
155
|
+
if (best !== 0 && best !== last) { text += chars[best] ?? ''; sum += bv; n++; }
|
|
156
|
+
last = best;
|
|
157
|
+
}
|
|
158
|
+
return { text, score: n ? sum / n : 0 };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Detection only. Cheap enough for a live camera loop. Accepts video, canvas, image, ImageBitmap or Blob. */
|
|
162
|
+
function probe(input, opts = {}) {
|
|
163
|
+
const o = { ...DEFAULTS, maxSide: 480, ...opts };
|
|
164
|
+
return serial(async () => {
|
|
165
|
+
const t0 = performance.now(), src = await toSource(input);
|
|
166
|
+
const { boxes, width, height, sharpness } = await detect(src, o);
|
|
167
|
+
if (src !== input && src.close) src.close();
|
|
168
|
+
const area = boxes.reduce((a, b) => a + (b.x1 - b.x0) * (b.y1 - b.y0), 0);
|
|
169
|
+
const coverage = Math.min(1, area / (width * height));
|
|
170
|
+
const score = boxes.length ? boxes.reduce((a, b) => a + b.score, 0) / boxes.length : 0;
|
|
171
|
+
return {
|
|
172
|
+
hasText: boxes.length >= o.minBoxes && coverage >= o.minCoverage,
|
|
173
|
+
count: boxes.length, coverage, score, sharpness, width, height, boxes, ms: performance.now() - t0,
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Detection + recognition. Returns an object shaped like imagor's `ocr` metadata. */
|
|
179
|
+
function read(input, opts = {}) {
|
|
180
|
+
const o = { ...DEFAULTS, ...opts };
|
|
181
|
+
return serial(async () => {
|
|
182
|
+
const t0 = performance.now(), src = await toSource(input);
|
|
183
|
+
const [w, h] = sizeOf(src);
|
|
184
|
+
let full = src;
|
|
185
|
+
const isCanvas = (typeof OffscreenCanvas !== 'undefined' && src instanceof OffscreenCanvas) || (typeof HTMLCanvasElement !== 'undefined' && src instanceof HTMLCanvasElement);
|
|
186
|
+
if (!isCanvas) { full = canvasFactory(w, h); const g = ctx2d(full); g.fillStyle = '#fff'; g.fillRect(0, 0, w, h); g.drawImage(src, 0, 0, w, h); }
|
|
187
|
+
const { boxes } = await detect(full, o), t1 = performance.now();
|
|
188
|
+
const regions = [];
|
|
189
|
+
for (const b of boxes) {
|
|
190
|
+
const r = await recognize(crop(full, b));
|
|
191
|
+
if (r.text.trim() && r.score >= o.minTextScore) regions.push({ ...b, ...r });
|
|
192
|
+
}
|
|
193
|
+
if (src !== input && src.close) src.close();
|
|
194
|
+
|
|
195
|
+
regions.sort((a, b) => a.y0 - b.y0); // reading order: group by vertical overlap
|
|
196
|
+
const rows = [];
|
|
197
|
+
for (const r of regions) {
|
|
198
|
+
const cy = (r.y0 + r.y1) / 2, row = rows.find(l => cy > l.y0 && cy < l.y1);
|
|
199
|
+
if (row) { row.items.push(r); row.y0 = Math.min(row.y0, r.y0); row.y1 = Math.max(row.y1, r.y1); } else rows.push({ y0: r.y0, y1: r.y1, items: [r] });
|
|
200
|
+
}
|
|
201
|
+
rows.forEach(l => l.items.sort((a, b) => a.x0 - b.x0));
|
|
202
|
+
const ordered = rows.flatMap(l => l.items);
|
|
203
|
+
const charCount = ordered.reduce((a, r) => a + r.text.length, 0);
|
|
204
|
+
return {
|
|
205
|
+
engine: 'ppocrv6-tiny-onnxruntime-web', model: 'tiny', coordinate_space: 'source_image',
|
|
206
|
+
width: w, height: h,
|
|
207
|
+
text: rows.map(l => l.items.map(r => r.text).join(' ')).join('\n'),
|
|
208
|
+
text_score: charCount ? ordered.reduce((a, r) => a + r.score * r.text.length, 0) / charCount : 0,
|
|
209
|
+
char_count: charCount,
|
|
210
|
+
regions: ordered.map(r => ({ text: r.text, score: +r.score.toFixed(3), box: [[r.x0, r.y0], [r.x1, r.y0], [r.x1, r.y1], [r.x0, r.y1]].map(q => q.map(Math.round)) })),
|
|
211
|
+
timing: { detect_ms: t1 - t0, total_ms: performance.now() - t0 },
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { probe, read, get busy() { return pending > 0; }, dispose: async () => { await det.release?.(); await rec.release?.(); } };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function fetchText(url) {
|
|
220
|
+
if (typeof process !== 'undefined' && process.versions?.node && !/^https?:/.test(url)) return (await import('node:fs/promises')).readFile(url, 'utf8');
|
|
221
|
+
const r = await fetch(url);
|
|
222
|
+
if (!r.ok) throw new Error(`tiny-ocr: ${url} → ${r.status}`);
|
|
223
|
+
return r.text();
|
|
224
|
+
}
|