tomngdev commited on
Commit
2ec055d
·
verified ·
1 Parent(s): f276454

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ examples/chinese_essay.png filter=lfs diff=lfs merge=lfs -text
37
+ examples/multi_column.png filter=lfs diff=lfs merge=lfs -text
38
+ examples/rotated_letter.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,10 +1,24 @@
1
  ---
2
- title: Indra Ocr Demo
3
- emoji: 🌍
4
- colorFrom: red
5
- colorTo: yellow
6
  sdk: static
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: indra-ocr (browser)
3
+ emoji: 🔍
4
+ colorFrom: indigo
5
+ colorTo: pink
6
  sdk: static
7
+ short_description: OCR in your browser — ONNX on WebGPU/wasm, no server
8
  pinned: false
9
  ---
10
 
11
+ # indra-ocr in your browser
12
+
13
+ Fully client-side OCR: text detection + CTC recognition + reading order +
14
+ orientation, running on [onnxruntime-web](https://onnxruntime.ai/docs/get-started/web)
15
+ (WebGPU when available, wasm otherwise). No server, no data leaves your machine.
16
+
17
+ - Models: [tomngdev/indra-ocr-onnx](https://huggingface.co/tomngdev/indra-ocr-onnx)
18
+ (ONNX exports of [datalab-to/indra-ocr](https://huggingface.co/datalab-to/indra-ocr) v3.1.1)
19
+ - Library + code: [indra-ocr-web](https://github.com/) — see the model repo for
20
+ `indra-ocr-web.mjs`, the drop-in browser loader.
21
+
22
+ Pre/post-processing is ported from the
23
+ [indra-ocr Python package](https://github.com/datalab-to/indra). Example page
24
+ images are from the same Apache-2.0 repository.
examples/chinese_essay.png ADDED

Git LFS Details

  • SHA256: 45426e6ac7e56581e1135f780d60a11328f96e411568c80544052bf728fdd07b
  • Pointer size: 131 Bytes
  • Size of remote file: 525 kB
examples/multi_column.png ADDED

Git LFS Details

  • SHA256: c9f80ee321b48685e63024ca0649e8977daf93f07bb0fd38818541edf9ab58b3
  • Pointer size: 131 Bytes
  • Size of remote file: 961 kB
examples/rotated_letter.png ADDED

Git LFS Details

  • SHA256: bfb42c69e9950e123aef7bf879ced11d2f6a70d88bfd79e629f9f37b2e6c918f
  • Pointer size: 131 Bytes
  • Size of remote file: 699 kB
index.html CHANGED
@@ -1,19 +1,213 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>indra-ocr — OCR in your browser (ONNX Runtime: WebGPU / wasm)</title>
7
+ <!--
8
+ The importmap pins onnxruntime-web's ESM build WITH WebGPU (the plain
9
+ bundle is wasm-only). The wasm files resolve next to it on the CDN. In a
10
+ bundler/npm app there is no importmap: just `npm i onnxruntime-web`.
11
+ -->
12
+ <script type="importmap">
13
+ {
14
+ "imports": {
15
+ "onnxruntime-web": "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.22.0-dev.20250409-89f8206ba4/dist/ort.webgpu.bundle.min.mjs"
16
+ }
17
+ }
18
+ </script>
19
+ <style>
20
+ body { font-family: system-ui, sans-serif; margin: 0; display: flex; height: 100vh; }
21
+ #side { width: 340px; padding: 16px; box-sizing: border-box; overflow-y: auto;
22
+ border-right: 1px solid #ddd; background: #fafafa; }
23
+ #main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
24
+ #canvasWrap { flex: 1; overflow: auto; background: #333; position: relative; text-align: center; }
25
+ canvas { max-width: 100%; max-height: 100%; margin: auto; display: block; }
26
+ #text { height: 30%; overflow: auto; margin: 0; padding: 12px; white-space: pre-wrap;
27
+ border-top: 1px solid #ddd; font: 13px/1.45 ui-monospace, monospace; background: #fff; }
28
+ label { display: block; margin: 10px 0 3px; font-size: 13px; color: #444; }
29
+ input[type=text], select { width: 100%; box-sizing: border-box; padding: 4px; }
30
+ .row { display: flex; gap: 8px; }
31
+ .row > * { flex: 1; }
32
+ button { padding: 6px 14px; cursor: pointer; }
33
+ #drop { border: 2px dashed #bbb; border-radius: 6px; padding: 18px; text-align: center;
34
+ color: #777; font-size: 13px; margin-top: 8px; }
35
+ #drop.busy { border-color: #4a9; color: #4a9; }
36
+ #examples { display: flex; gap: 6px; margin-top: 6px; }
37
+ #examples button { flex: 1; font-size: 12px; padding: 5px 4px; }
38
+ #status { font-size: 12px; color: #666; margin-top: 10px; min-height: 3em; white-space: pre-wrap; }
39
+ code { background: #eee; padding: 1px 4px; border-radius: 3px; font-size: 11px; }
40
+ details { margin-top: 12px; font-size: 13px; color: #444; }
41
+ details label { margin-top: 8px; }
42
+ #credit { font-size: 11px; color: #999; margin-top: 14px; line-height: 1.5; }
43
+ #credit a { color: #888; }
44
+ </style>
45
+ </head>
46
+ <body>
47
+ <div id="side">
48
+ <h3 style="margin:0 0 4px">🔍 indra-ocr</h3>
49
+ <div style="font-size:12px;color:#666">det + CTC rec + reading order + orientation,
50
+ on onnxruntime-web (WebGPU / wasm) — 100% in your browser, nothing is uploaded.</div>
51
+
52
+ <label>Try an example page</label>
53
+ <div id="examples">
54
+ <button data-ex="multi_column">Multi-column</button>
55
+ <button data-ex="rotated_letter">Rotated</button>
56
+ <button data-ex="chinese_essay">中文 essay</button>
57
+ </div>
58
+
59
+ <label>Page image</label>
60
+ <input id="file" type="file" accept="image/*" />
61
+ <div id="drop">…or drop an image here</div>
62
+
63
+ <details>
64
+ <summary style="cursor:pointer">Options</summary>
65
+ <label for="base">Model base URL</label>
66
+ <input id="base" type="text"
67
+ value="https://huggingface.co/tomngdev/indra-ocr-onnx/resolve/main"
68
+ placeholder="https://huggingface.co/USER/indra-ocr/resolve/main" />
69
+
70
+ <label>Device</label>
71
+ <select id="ep">
72
+ <option value="auto" selected>auto (WebGPU if available)</option>
73
+ <option value="webgpu">webgpu (falls back to wasm)</option>
74
+ <option value="wasm">wasm (CPU)</option>
75
+ </select>
76
+
77
+ <div class="row">
78
+ <label><input type="checkbox" id="cjk" checked /> CJK models</label>
79
+ <label><input type="checkbox" id="orient" checked /> orientation</label>
80
+ </div>
81
+ </details>
82
+
83
+ <p></p>
84
+ <button id="run" disabled style="width:100%">Run OCR</button>
85
+ <div id="status"></div>
86
+
87
+ <div id="credit">
88
+ Models <a href="https://huggingface.co/tomngdev/indra-ocr-onnx" target="_blank" rel="noopener">tomngdev/indra-ocr-onnx</a>
89
+ · ONNX exports of <a href="https://huggingface.co/datalab-to/indra-ocr" target="_blank" rel="noopener">datalab-to/indra-ocr</a>
90
+ · <a href="https://onnxruntime.ai/docs/get-started/web" target="_blank" rel="noopener">onnxruntime-web</a>
91
+ </div>
92
+ </div>
93
+ <div id="main">
94
+ <div id="canvasWrap"></div>
95
+ <pre id="text">lines appear here — pick an example or drop an image, then Run OCR</pre>
96
+ </div>
97
+
98
+ <script type="module">
99
+ import { loadIndra, readGrayBrowser } from "./indra-ocr-web.mjs";
100
+
101
+ const $ = (id) => document.getElementById(id);
102
+ const status = (s) => { $("status").textContent = s; };
103
+ let ocr = null, gray = null, page = null;
104
+
105
+ async function ensureModel() {
106
+ const base = $("base").value.trim();
107
+ const opts = {
108
+ device: $("ep").value,
109
+ cjk: $("cjk").checked,
110
+ orient: $("orient").checked,
111
+ };
112
+ const sig = JSON.stringify([base, opts.device, opts.cjk, opts.orient]);
113
+ if (ensureModel.sig !== sig) {
114
+ status("loading models…");
115
+ const t0 = performance.now();
116
+ ocr = await loadIndra(base, opts);
117
+ ensureModel.sig = sig;
118
+ window.__device = ocr.device;
119
+ status(`models loaded in ${Math.round(performance.now() - t0)} ms (${ocr.device})`);
120
+ }
121
+ return ocr;
122
+ }
123
+
124
+ async function showImage(src) {
125
+ gray = await readGrayBrowser(src);
126
+ page = null;
127
+ $("run").disabled = false;
128
+ const wrap = $("canvasWrap");
129
+ wrap.innerHTML = "";
130
+ const c = document.createElement("canvas");
131
+ c.width = gray.width;
132
+ c.height = gray.height;
133
+ const ctx = c.getContext("2d");
134
+ const img = ctx.createImageData(gray.width, gray.height);
135
+ for (let i = 0, p = 0; i < gray.data.length; i++, p += 4) {
136
+ img.data[p] = img.data[p + 1] = img.data[p + 2] = gray.data[i];
137
+ img.data[p + 3] = 255;
138
+ }
139
+ ctx.putImageData(img, 0, 0);
140
+ wrap.appendChild(c);
141
+ window.__canvas = c;
142
+ }
143
+
144
+ async function run() {
145
+ if (!gray) return;
146
+ $("run").disabled = true;
147
+ $("drop").classList.add("busy");
148
+ status("running…");
149
+ await new Promise((r) => setTimeout(r, 30)); // let the UI paint
150
+ try {
151
+ const t0 = performance.now();
152
+ page = await (await ensureModel()).__call__(gray);
153
+ const ms = Math.round(performance.now() - t0);
154
+ $("text").textContent = page.text;
155
+ status(`${page.lines.length} lines in ${ms} ms (rotation ${page.rotation}°, ${window.__device ?? "?"})`);
156
+ drawBoxes();
157
+ } catch (e) {
158
+ status("failed: " + (e?.message ?? e));
159
+ console.error(e);
160
+ }
161
+ $("run").disabled = false;
162
+ $("drop").classList.remove("busy");
163
+ }
164
+
165
+ function drawBoxes() {
166
+ const c = window.__canvas;
167
+ const ctx = c.getContext("2d");
168
+ const s = c.width / gray.width; // canvas may be drawn at native res; CSS scales it
169
+ ctx.lineWidth = Math.max(1.5, 1.5 / s);
170
+ ctx.strokeStyle = "rgba(30, 170, 90, 0.9)";
171
+ for (const l of page.lines) {
172
+ const [x0, y0, x1, y1] = l.box;
173
+ ctx.strokeRect(x0 * s, y0 * s, (x1 - x0) * s, (y1 - y0) * s);
174
+ }
175
+ }
176
+
177
+ // example pages (Apache-2.0, from datalab-to/indra-ocr)
178
+ for (const b of document.querySelectorAll("#examples button")) {
179
+ b.onclick = async () => {
180
+ try {
181
+ status(`loading example: ${b.textContent}…`);
182
+ await showImage(`./examples/${b.dataset.ex}.png`);
183
+ status("example loaded — press Run OCR");
184
+ } catch (e) {
185
+ status("failed to load example: " + (e?.message ?? e));
186
+ }
187
+ };
188
+ }
189
+
190
+ $("run").onclick = run;
191
+ $("file").onchange = () => $("file").files[0] && showImage($("file").files[0]);
192
+ const drop = $("drop");
193
+ drop.ondragover = (e) => { e.preventDefault(); };
194
+ drop.ondrop = (e) => {
195
+ e.preventDefault();
196
+ const f = e.dataTransfer.files[0];
197
+ if (f) showImage(f);
198
+ };
199
+
200
+ // Hook for headless tests: window.__test(imageUrl, base, device)
201
+ window.__test = async (imageUrl, base, device) => {
202
+ if (base) $("base").value = base;
203
+ if (device) $("ep").value = device;
204
+ await showImage(imageUrl);
205
+ await run();
206
+ return {
207
+ lines: page?.lines ?? [], rotation: page?.rotation, text: page?.text ?? "",
208
+ device: window.__device ?? null,
209
+ };
210
+ };
211
+ </script>
212
+ </body>
213
  </html>
indra-ocr-web.mjs ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // indra-ocr in the browser, on onnxruntime-web (the same ONNX Runtime that
2
+ // transformers.js uses in the browser; here used directly, since raw custom
3
+ // sessions are outside transformers.js's API). Runs on WebGPU or wasm.
4
+ //
5
+ // import { loadIndra } from "./indra-ocr-web.mjs";
6
+ // const ocr = await loadIndra("https://huggingface.co/USER/REPO/resolve/main");
7
+ // const page = await ocr.__call__(gray); // gray: {data, width, height}
8
+ //
9
+ // loadIndra(base, opts):
10
+ // base model file base URL: an HF ".../resolve/main" URL, or a same-origin path
11
+ // opts.device "auto" (default) | "webgpu" | "wasm"
12
+ // opts.cjk / opts.order / opts.orient skip those models (no download)
13
+ // opts.wasmPaths ort.env.wasm.wasmPaths override
14
+ //
15
+ // "auto" picks WebGPU when navigator.gpu reports an adapter, else wasm. A
16
+ // "webgpu" session that fails to initialize or run (e.g. an op the WebGPU EP
17
+ // does not implement without its wasm fallback) is transparently rebuilt on
18
+ // wasm, so WebGPU never breaks a page that wasm can do.
19
+ import { InferenceSession, Tensor, env as ortEnv } from "onnxruntime-web";
20
+ import { IndraOCR } from "./src/pipeline.mjs"; // staged as ./src/ by prepare-repo.mjs
21
+
22
+ async function fetchBuf(url) {
23
+ const res = await fetch(url);
24
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
25
+ return new Uint8Array(await res.arrayBuffer());
26
+ }
27
+
28
+ async function makeSession(base, name, eps) {
29
+ const buf = await fetchBuf(base + name);
30
+ const create = (providers) =>
31
+ InferenceSession.create(buf, { executionProviders: providers, graphOptimizationLevel: "all" });
32
+ let gpu = eps.includes("webgpu");
33
+ let sess;
34
+ try {
35
+ sess = await create(eps);
36
+ } catch (e) {
37
+ if (!gpu) throw e;
38
+ console.warn(`indra-ocr: ${name}: WebGPU init failed (${e?.message ?? e}); using wasm`);
39
+ gpu = false;
40
+ sess = await create(["wasm"]);
41
+ }
42
+ return {
43
+ async run(inputs) {
44
+ try {
45
+ return await sess.run(inputs);
46
+ } catch (e) {
47
+ if (!gpu) throw e;
48
+ console.warn(`indra-ocr: ${name}: WebGPU run failed (${e?.message ?? e}); switching to wasm`);
49
+ gpu = false;
50
+ sess = await create(["wasm"]);
51
+ return await sess.run(inputs);
52
+ }
53
+ },
54
+ };
55
+ }
56
+
57
+ export async function loadIndra(base, opts = {}) {
58
+ if (!/\/$/.test(base)) base += "/";
59
+ if (opts.wasmPaths) ortEnv.wasm.wasmPaths = opts.wasmPaths;
60
+
61
+ // Which device? "auto" probes for a WebGPU adapter first.
62
+ let useGpu = false;
63
+ const want = opts.device ?? "auto";
64
+ if (want === "webgpu") {
65
+ useGpu = true;
66
+ } else if (want === "auto" && typeof navigator !== "undefined" && navigator.gpu) {
67
+ try {
68
+ useGpu = !!(await navigator.gpu.requestAdapter());
69
+ } catch {
70
+ /* no adapter -> wasm */
71
+ }
72
+ }
73
+ const eps = useGpu ? ["webgpu", "wasm"] : ["wasm"]; // wasm kept for op-level fallback
74
+
75
+ const backend = {
76
+ Tensor,
77
+ meta: async () => await (await fetch(base + "meta.json")).json(),
78
+ session: (name) => makeSession(base, name, eps),
79
+ };
80
+ const ocr = await IndraOCR.create(backend, opts);
81
+ ocr.device = useGpu ? "webgpu" : "wasm";
82
+ return ocr;
83
+ }
84
+
85
+ // Decode an image URL/Blob to the grayscale {data, width, height} the pipeline
86
+ // takes, with cv2's Rec.601 luma (see the Rec.601 note in src/imaging.mjs).
87
+ export async function readGrayBrowser(source) {
88
+ const blob = source instanceof Blob ? source : await (await fetch(source)).blob();
89
+ const bmp = await createImageBitmap(blob);
90
+ const c = document.createElement("canvas");
91
+ c.width = bmp.width;
92
+ c.height = bmp.height;
93
+ const ctx = c.getContext("2d", { willReadFrequently: true });
94
+ ctx.drawImage(bmp, 0, 0);
95
+ const { data, width, height } = ctx.getImageData(0, 0, bmp.width, bmp.height);
96
+ const out = new Uint8Array(width * height);
97
+ for (let i = 0, p = 0; i < out.length; i++, p += 4) {
98
+ out[i] = Math.round(0.299 * data[p] + 0.587 * data[p + 1] + 0.114 * data[p + 2]);
99
+ }
100
+ bmp.close();
101
+ return { data: out, width, height };
102
+ }
src/det.mjs ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Line detection pre/post-processing, ported from indra_ocr/det_post.py.
2
+ import { resizeGray, roundPy } from "./imaging.mjs";
3
+
4
+ const INPUT_MULTIPLE = 32;
5
+ const OUT_STRIDE = 2;
6
+ const CORE_X_SHRINK = 0.15;
7
+
8
+ // page_to_input: uint8 page -> float32 ink map (0 = white), long side scaled,
9
+ // padded up to a multiple of 32 with white. Returns the map and the scale.
10
+ export function pageToInput(gray, longSide) {
11
+ const { data, width: w, height: h } = gray;
12
+ const scale = longSide / Math.max(h, w);
13
+ const nh = Math.max(1, roundPy(h * scale));
14
+ const nw = Math.max(1, roundPy(w * scale));
15
+ const small =
16
+ nh === h && nw === w
17
+ ? data
18
+ : resizeGray(data, w, h, nw, nh, scale < 1 ? "area" : "linear");
19
+ const ph = Math.ceil(nh / INPUT_MULTIPLE) * INPUT_MULTIPLE;
20
+ const pw = Math.ceil(nw / INPUT_MULTIPLE) * INPUT_MULTIPLE;
21
+ const x = new Float32Array(ph * pw);
22
+ for (let r = 0; r < nh; r++) {
23
+ for (let c = 0; c < nw; c++) x[r * pw + c] = 1 - small[r * nw + c] / 255;
24
+ }
25
+ return { x, ph, pw, scale };
26
+ }
27
+
28
+ // decode(): out [3, h, w] (core logit, top dist, bottom dist) at half
29
+ // resolution -> boxes [N, 5] (x0, y0, x1, y1, score) in input pixels.
30
+ // Connected components (4-connectivity) of core > thresh; per component the
31
+ // mean of the per-pixel reconstructed line top / bottom and mean probability.
32
+ export function decode(out, h, w, thresh = 0.5, minArea = 2) {
33
+ const n = h * w;
34
+ const core = out.subarray(0, n);
35
+ const dTop = out.subarray(n, 2 * n);
36
+ const dBot = out.subarray(2 * n, 3 * n);
37
+ const labels = new Int32Array(n); // 0 = background, ids in scan order
38
+ const prob = new Float32Array(n);
39
+ for (let i = 0; i < n; i++) prob[i] = 1 / (1 + Math.exp(-core[i]));
40
+ const stats = [{ area: 0 }]; // per label, index 0 unused
41
+ const stack = new Int32Array(n);
42
+ for (let i = 0; i < n; i++) {
43
+ if (labels[i] !== 0 || prob[i] <= thresh) continue;
44
+ const lab = stats.length;
45
+ let sp = 0;
46
+ stack[sp++] = i;
47
+ labels[i] = lab;
48
+ const st = { area: 0, x0: w, x1: -1, sumTop: 0, sumBot: 0, sumSc: 0 };
49
+ while (sp > 0) {
50
+ const j = stack[--sp];
51
+ const r = (j / w) | 0;
52
+ const c = j - r * w;
53
+ st.area++;
54
+ if (c < st.x0) st.x0 = c;
55
+ if (c > st.x1) st.x1 = c;
56
+ const ys = (r + 0.5) * OUT_STRIDE;
57
+ st.sumTop += ys - dTop[j];
58
+ st.sumBot += ys + dBot[j];
59
+ st.sumSc += prob[j];
60
+ if (c > 0 && labels[j - 1] === 0 && prob[j - 1] > thresh) {
61
+ labels[j - 1] = lab;
62
+ stack[sp++] = j - 1;
63
+ }
64
+ if (c < w - 1 && labels[j + 1] === 0 && prob[j + 1] > thresh) {
65
+ labels[j + 1] = lab;
66
+ stack[sp++] = j + 1;
67
+ }
68
+ if (r > 0 && labels[j - w] === 0 && prob[j - w] > thresh) {
69
+ labels[j - w] = lab;
70
+ stack[sp++] = j - w;
71
+ }
72
+ if (r < h - 1 && labels[j + w] === 0 && prob[j + w] > thresh) {
73
+ labels[j + w] = lab;
74
+ stack[sp++] = j + w;
75
+ }
76
+ }
77
+ stats.push(st);
78
+ }
79
+ const boxes = [];
80
+ for (let lab = 1; lab < stats.length; lab++) {
81
+ const st = stats[lab];
82
+ if (st.area < minArea) continue;
83
+ const y0 = st.sumTop / st.area;
84
+ const y1 = st.sumBot / st.area;
85
+ const lh = Math.max(1, y1 - y0);
86
+ const ex = Math.min(CORE_X_SHRINK * lh, 0.5 * lh);
87
+ boxes.push([
88
+ st.x0 * OUT_STRIDE - ex,
89
+ y0,
90
+ (st.x1 + 1) * OUT_STRIDE + ex,
91
+ y1,
92
+ st.sumSc / st.area,
93
+ ]);
94
+ }
95
+ return boxes;
96
+ }
src/imaging.mjs ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Image helpers, ported from the Python code's cv2 usage so the ONNX models
2
+ // see (near-)identical inputs. Only bilinear and box ("area") filtering is
3
+ // needed, both separable, so a plain typed-array implementation is enough —
4
+ // and it keeps the code browser-portable (image decoding lives in
5
+ // node-io.mjs / the app).
6
+
7
+ // Python round(): half to even.
8
+ export function roundPy(x) {
9
+ const f = Math.floor(x);
10
+ const d = x - f;
11
+ if (d > 0.5) return f + 1;
12
+ if (d < 0.5) return f;
13
+ return f % 2 === 0 ? f : f + 1;
14
+ }
15
+
16
+ export const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
17
+ export const median = (xs) => {
18
+ if (!xs.length) return NaN;
19
+ const s = [...xs].sort((a, b) => a - b);
20
+ const m = s.length >> 1;
21
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
22
+ };
23
+
24
+ // Per-axis filter taps, matching cv2's separable resize:
25
+ // - shrinking with mode "area": overlap-weighted box average
26
+ // - anything else: bilinear (INTER_AREA falls back to it, and callers pass
27
+ // "linear" where cv2 passes INTER_LINEAR)
28
+ function taps(srcN, dstN, mode) {
29
+ const out = [];
30
+ if (mode === "area" && dstN < srcN) {
31
+ const inv = srcN / dstN;
32
+ for (let d = 0; d < dstN; d++) {
33
+ const x0 = d * inv;
34
+ const x1 = (d + 1) * inv;
35
+ const t = [];
36
+ for (let i = Math.floor(x0); i < Math.ceil(x1); i++) {
37
+ const w = (Math.min(x1, i + 1) - Math.max(x0, i)) / inv; // normalized
38
+ if (w > 0) t.push([i, w]);
39
+ }
40
+ out.push(t);
41
+ }
42
+ return out;
43
+ }
44
+ const scale = srcN / dstN;
45
+ for (let d = 0; d < dstN; d++) {
46
+ const s = (d + 0.5) * scale - 0.5;
47
+ let i0 = Math.floor(s);
48
+ const f = s - i0;
49
+ let i1 = i0 + 1;
50
+ if (i0 < 0) i0 = 0;
51
+ if (i1 > srcN - 1) i1 = srcN - 1;
52
+ out.push(i0 === i1 || f <= 0 ? [[i0, 1]] : [[i0, 1 - f], [i1, f]]);
53
+ }
54
+ return out;
55
+ }
56
+
57
+ // Resize a uint8 grayscale image (row-major) to [dw, dh].
58
+ export function resizeGray(src, sw, sh, dw, dh, mode = "linear") {
59
+ if (dw === sw && dh === sh) return src;
60
+ const tx = taps(sw, dw, mode);
61
+ const ty = taps(sh, dh, mode);
62
+ const tmp = new Float64Array(dw * sh); // x pass
63
+ for (let y = 0; y < sh; y++) {
64
+ const row = y * sw;
65
+ for (let d = 0; d < dw; d++) {
66
+ let a = 0;
67
+ for (const [i, w] of tx[d]) a += src[row + i] * w;
68
+ tmp[y * dw + d] = a;
69
+ }
70
+ }
71
+ const out = new Uint8Array(dw * dh); // y pass + round
72
+ for (let x = 0; x < dw; x++) {
73
+ for (let d = 0; d < dh; d++) {
74
+ let a = 0;
75
+ for (const [i, w] of ty[d]) a += tmp[i * dw + x] * w;
76
+ out[d * dw + x] = clamp(Math.round(a), 0, 255);
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+
82
+ // np.rot90(m, -k): the image turned k quarter-turns clockwise.
83
+ export function rotQuarterCW(gray, k) {
84
+ let { data, width: w, height: h } = gray;
85
+ for (let n = 0; n < k; n++) {
86
+ const out = new Uint8Array(w * h); // dims swap: [h, w] -> [w, h]
87
+ for (let y = 0; y < w; y++)
88
+ for (let x = 0; x < h; x++) out[y * h + x] = data[(h - 1 - x) * w + y];
89
+ data = out;
90
+ [w, h] = [h, w];
91
+ }
92
+ return { data, width: w, height: h };
93
+ }
src/order.mjs ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Reading order, ported from indra_ocr/order_post.py: line features, the
2
+ // order model's decoding (blocks -> lines inside blocks) and the geometric
3
+ // baselines (XY-cut, top-down rows).
4
+
5
+ import { median, roundPy } from "./imaging.mjs";
6
+
7
+ const N_FEATS = 16;
8
+ // chandra block labels of the layout head ("cls") output
9
+ export const BLOCK_CLASSES = [
10
+ "Text",
11
+ "Section-Header",
12
+ "Page-Header",
13
+ "Page-Footer",
14
+ "List-Group",
15
+ "Table",
16
+ "Caption",
17
+ "Footnote",
18
+ "Table-Of-Contents",
19
+ "Form",
20
+ ];
21
+ const GRID_LABELS = new Set(
22
+ ["Table", "Form", "Table-Of-Contents"].map((c) => BLOCK_CLASSES.indexOf(c))
23
+ );
24
+
25
+ const isDigit = (c) => c >= "0" && c <= "9";
26
+ const isUpper = (c) => c !== c.toLowerCase() && c === c.toUpperCase();
27
+
28
+ // line_features: [N, 4] boxes + texts -> Float32Array [N, 16] features.
29
+ export function lineFeatures(boxes, pageW, pageH, texts) {
30
+ const n = boxes.length;
31
+ const out = new Float32Array(n * N_FEATS);
32
+ if (!n) return out;
33
+ const medH = median(boxes.map((b) => b[3] - b[1]));
34
+ for (let i = 0; i < n; i++) {
35
+ const [x0, y0, x1, y1] = boxes[i];
36
+ const w = Math.max(x1 - x0, 1e-3);
37
+ const h = Math.max(y1 - y0, 1e-3);
38
+ const f = i * N_FEATS;
39
+ out[f + 0] = x0 / pageW;
40
+ out[f + 1] = y0 / pageH;
41
+ out[f + 2] = x1 / pageW;
42
+ out[f + 3] = y1 / pageH;
43
+ out[f + 4] = (x0 + x1) / (2 * pageW);
44
+ out[f + 5] = (y0 + y1) / (2 * pageH);
45
+ out[f + 6] = w / pageW;
46
+ out[f + 7] = Math.log(h / medH);
47
+ out[f + 8] = Math.log(w / h) / 4;
48
+ out[f + 9] = (medH / pageH) * 20;
49
+ const t = (texts?.[i] ?? "").trim();
50
+ if (!t) continue;
51
+ let digits = 0;
52
+ for (const c of t) if (isDigit(c)) digits++;
53
+ let head = t.slice(0, 3);
54
+ while (head.endsWith(".") || head.endsWith(")")) head = head.slice(0, -1);
55
+ out[f + 10] = Math.log1p(t.length) / 5;
56
+ out[f + 11] = digits / t.length;
57
+ out[f + 12] = Number(
58
+ "•-–▪◦*·→".includes(t[0]) || (/^[0-9]+$/.test(head) && t.length > 3)
59
+ );
60
+ out[f + 13] = Number(t.endsWith("-"));
61
+ out[f + 14] = Number(".!?:;".includes(t[t.length - 1]));
62
+ out[f + 15] = Number(isUpper(t[0]));
63
+ }
64
+ return out;
65
+ }
66
+
67
+ // log P(i before j) from a pairwise logit
68
+ const lpOf = (a) => (a >= 0 ? -Math.log1p(Math.exp(-a)) : a - Math.log1p(Math.exp(a)));
69
+
70
+ // decode_order: order from a pairwise logit matrix. Start from the row-sum
71
+ // ranking, then local search with insertion moves (see the Python docstring).
72
+ export function decodeOrder(a, n, sweeps = 4) {
73
+ if (n <= 1) return Array.from({ length: n }, (_, i) => i);
74
+ const score = new Float64Array(n);
75
+ for (let i = 0; i < n; i++)
76
+ for (let j = 0; j < n; j++) score[i] += lpOf(a[j * n + i]) - lpOf(a[i * n + j]);
77
+ let order = Array.from({ length: n }, (_, i) => i).sort((p, q) => score[p] - score[q]);
78
+ for (let s = 0; s < sweeps; s++) {
79
+ let improved = false;
80
+ for (const x of [...order]) {
81
+ const rest = order.filter((v) => v !== x);
82
+ const before = rest.map((r) => lpOf(a[r * n + x])); // x placed after these
83
+ const after = rest.map((r) => lpOf(a[x * n + r])); // x placed before these
84
+ const gains = new Float64Array(n);
85
+ let run = 0;
86
+ for (let k = 1; k < n; k++) gains[k] = run += before[k - 1];
87
+ run = 0;
88
+ for (let k = n - 2; k >= 0; k--) gains[k] += run += after[k];
89
+ let best = 0;
90
+ for (let k = 1; k <= n; k++) if (gains[k] > gains[best]) best = k;
91
+ const cur = order.indexOf(x);
92
+ if (gains[best] > gains[cur] + 1e-6) {
93
+ rest.splice(best, 0, x);
94
+ order = rest;
95
+ improved = true;
96
+ }
97
+ }
98
+ if (!improved) break;
99
+ }
100
+ return order;
101
+ }
102
+
103
+ // _cluster: average-linkage clustering of pairwise "same block" logits
104
+ // (port of the vectorized numpy loop; n^2-ish work per page).
105
+ function cluster(same, n) {
106
+ if (!n) return [];
107
+ const M = new Float64Array(n * n);
108
+ for (let i = 0; i < n * n; i++) M[i] = same[i];
109
+ for (let i = 0; i < n; i++) M[i * n + i] = -Infinity;
110
+ const alive = new Uint8Array(n).fill(1);
111
+ const best = new Int32Array(n);
112
+ const bestV = new Float64Array(n);
113
+ for (let i = 0; i < n; i++) {
114
+ let b = 0;
115
+ for (let j = 1; j < n; j++) if (M[i * n + j] > M[i * n + b]) b = j;
116
+ best[i] = b;
117
+ bestV[i] = M[i * n + b];
118
+ }
119
+ const members = Array.from({ length: n }, (_, i) => [i]);
120
+ const S = new Float64Array(M); // sum of logits between groups
121
+ const size = new Float64Array(n).fill(1);
122
+ for (let it = 0; it < n - 1; it++) {
123
+ let a = 0;
124
+ for (let i = 1; i < n; i++) if (bestV[i] > bestV[a]) a = i;
125
+ let b = best[a];
126
+ if (bestV[a] <= 0) break;
127
+ if (b < a) [a, b] = [b, a];
128
+ for (let j = 0; j < n; j++) S[a * n + j] += S[b * n + j];
129
+ for (let j = 0; j < n; j++) S[j * n + a] += S[j * n + b];
130
+ S[a * n + a] = 0;
131
+ size[a] += size[b];
132
+ alive[b] = 0;
133
+ members[a].push(...members[b]);
134
+ const row = new Float64Array(n);
135
+ for (let j = 0; j < n; j++)
136
+ row[j] = alive[j] && j !== a ? S[a * n + j] / (size[a] * size[j]) : -Infinity;
137
+ for (let j = 0; j < n; j++) {
138
+ M[a * n + j] = M[j * n + a] = row[j];
139
+ M[b * n + j] = M[j * n + b] = -Infinity;
140
+ }
141
+ bestV[b] = -Infinity;
142
+ let rb = 0;
143
+ for (let j = 1; j < n; j++) if (row[j] > row[rb]) rb = j;
144
+ best[a] = rb;
145
+ bestV[a] = row[rb];
146
+ for (let j = 0; j < n; j++) {
147
+ if (!alive[j] || j === a) continue;
148
+ if (best[j] === a || best[j] === b) {
149
+ let nb = 0;
150
+ for (let k = 1; k < n; k++) if (M[j * n + k] > M[j * n + nb]) nb = k;
151
+ best[j] = nb;
152
+ bestV[j] = M[j * n + nb];
153
+ }
154
+ if (row[j] > bestV[j]) {
155
+ best[j] = a;
156
+ bestV[j] = row[j];
157
+ }
158
+ }
159
+ }
160
+ const out = [];
161
+ for (let i = 0; i < n; i++) if (alive[i]) out.push(members[i].sort((p, q) => p - q));
162
+ return out;
163
+ }
164
+
165
+ // order_top_down: rows top to bottom (centres within half a line height share
166
+ // a row), left to right.
167
+ export function topDown(boxes) {
168
+ if (!boxes.length) return [];
169
+ const medH = median(boxes.map((b) => b[3] - b[1]));
170
+ return boxes
171
+ .map((b, i) => [roundPy((b[1] + b[3]) / 2 / Math.max(medH * 0.5, 1e-3)), b[0], i])
172
+ .sort((p, q) => p[0] - q[0] || p[1] - q[1])
173
+ .map((t) => t[2]);
174
+ }
175
+
176
+ // order_xycut: recursive XY-cut, split at the widest whitespace gap.
177
+ export function xyCut(boxes) {
178
+ const bestCut = (b, axis) => {
179
+ const lo = b.map((bb) => (axis ? bb[1] : bb[0]));
180
+ const hi = b.map((bb) => (axis ? bb[3] : bb[2]));
181
+ const o = lo.map((_, i) => i).sort((p, q) => lo[p] - lo[q]);
182
+ let end = hi[o[0]];
183
+ let bestGap = 0;
184
+ let bestK = -1;
185
+ for (let k = 1; k < o.length; k++) {
186
+ const gap = lo[o[k]] - end;
187
+ if (gap > bestGap) {
188
+ bestGap = gap;
189
+ bestK = k;
190
+ }
191
+ end = Math.max(end, hi[o[k]]);
192
+ }
193
+ return [bestGap, bestK, o];
194
+ };
195
+ const rec = (ids) => {
196
+ if (ids.length <= 1) return [...ids];
197
+ const b = ids.map((i) => boxes[i]);
198
+ const [gy, ky, oy] = bestCut(b, 1);
199
+ const [gx, kx, ox] = bestCut(b, 0);
200
+ if (ky < 0 && kx < 0) return topDown(b).map((k) => ids[k]);
201
+ const [k, o] = gy >= gx ? [ky, oy] : [kx, ox];
202
+ return rec(o.slice(0, k).map((i) => ids[i])).concat(rec(o.slice(k).map((i) => ids[i])));
203
+ };
204
+ return rec(boxes.map((_, i) => i));
205
+ }
206
+
207
+ // decode_blocks_order: blocks from the same-block head (average-linkage
208
+ // clustering), lines inside a block by geometry, blocks by the mean pairwise
209
+ // order logit between them. a / same are the order model's [N, N] outputs
210
+ // (row-major Float32Array), cls its [N, 10] layout logits.
211
+ export function decodeBlocksOrder(a, same, boxes, cls) {
212
+ const n = boxes.length;
213
+ if (n <= 1) return Array.from({ length: n }, (_, i) => i);
214
+ const blocks = cluster(same, n);
215
+ const inner = blocks.map((b) => {
216
+ let isGrid = false;
217
+ if (cls) {
218
+ const sum = new Float64Array(BLOCK_CLASSES.length);
219
+ for (const i of b)
220
+ for (let c = 0; c < BLOCK_CLASSES.length; c++) sum[c] += cls[i * BLOCK_CLASSES.length + c];
221
+ let am = 0;
222
+ for (let c = 1; c < BLOCK_CLASSES.length; c++) if (sum[c] > sum[am]) am = c;
223
+ isGrid = GRID_LABELS.has(am);
224
+ }
225
+ const bx = b.map((i) => boxes[i]);
226
+ const local = isGrid ? topDown(bx) : xyCut(bx);
227
+ return local.map((k) => b[k]);
228
+ });
229
+ const m = blocks.length;
230
+ const ab = new Float64Array(m * m);
231
+ for (let p = 0; p < m; p++)
232
+ for (let q = p + 1; q < m; q++) {
233
+ let s = 0;
234
+ for (const i of blocks[p]) for (const j of blocks[q]) s += a[i * n + j];
235
+ const v = s / (blocks[p].length * blocks[q].length);
236
+ ab[p * m + q] = v;
237
+ ab[q * m + p] = -v;
238
+ }
239
+ return decodeOrder(ab, m).flatMap((k) => inner[k]);
240
+ }
src/pipeline.mjs ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The indra-ocr pipeline, ported from indra_ocr/pipeline.py and friends.
2
+ // Pure ESM (no Node or browser APIs): the ONNX Runtime sessions and the
3
+ // Tensor class come from an injected backend — web/indra-ocr-web.mjs builds
4
+ // one on onnxruntime-web (wasm or WebGPU).
5
+ //
6
+ // Not ported here (the models are optional in the Python package too):
7
+ // orient.onnx's deskew step (estimate_skew), mask.onnx (not in this model
8
+ // revision), the math models, and the markdown/block assembly.
9
+ import { pageToInput, decode as decodeDet } from "./det.mjs";
10
+ import { cropLine, padBatch, widthBatches, PAD_X } from "./rec.mjs";
11
+ import { lineFeatures, decodeBlocksOrder, decodeOrder, xyCut, BLOCK_CLASSES } from "./order.mjs";
12
+ import { Vocab } from "./vocab.mjs";
13
+ import { clamp, median, resizeGray, rotQuarterCW, roundPy } from "./imaging.mjs";
14
+
15
+ const ADAPTIVE_MIN_H = 9.0;
16
+ const ADAPTIVE_TARGET_H = 12.0;
17
+ const ADAPTIVE_MAX_SIDE = 2048;
18
+ const ORIENT_SIDE = 768;
19
+ const ORIENT_MIN = 0.95;
20
+ const MIN_CONF = 0.5;
21
+ const SPLIT_GAP = 3.0;
22
+ const GUTTER_GAP = 1.6;
23
+ const GUTTER_EDGES = 3;
24
+
25
+ const round1 = (v) => Math.round(v * 10) / 10;
26
+ const round4 = (v) => Math.round(v * 1e4) / 1e4;
27
+
28
+ export class IndraOCR {
29
+ /** Build the pipeline on a backend: { Tensor, meta: async () => object,
30
+ * session: async (name) => { run } }. opts.{order, orient, cjk} turn off
31
+ * the optional models (a missing file is also fine); opts.adaptive toggles
32
+ * the second, higher-resolution detection pass. */
33
+ static async create(backend, opts = {}) {
34
+ const o = new IndraOCR();
35
+ o.Tensor = backend.Tensor;
36
+ const meta = await backend.meta();
37
+ o.vocab = Vocab.fromMeta(meta.vocab);
38
+ o.longSide = meta.det_long_side;
39
+ o.height = meta.rec_height;
40
+ o.det = await backend.session("det.onnx");
41
+ o.rec = await backend.session("rec.onnx");
42
+ o.order = o.orient = null;
43
+ if (opts.order !== false) {
44
+ try {
45
+ o.order = await backend.session("order.onnx");
46
+ } catch {
47
+ /* geometry-only reading order */
48
+ }
49
+ }
50
+ if (opts.orient !== false) {
51
+ try {
52
+ o.orient = await backend.session("orient.onnx");
53
+ } catch {
54
+ /* no quarter-turn detection */
55
+ }
56
+ }
57
+ // Chinese: a line classifier routes lines with hanzi to their own recognizer.
58
+ o.script = o.recCjk = o.vocabCjk = null;
59
+ if (opts.cjk !== false) {
60
+ try {
61
+ o.script = await backend.session("script.onnx");
62
+ o.recCjk = await backend.session("rec_cjk.onnx");
63
+ o.vocabCjk = Vocab.fromMeta(meta.vocab_cjk);
64
+ } catch {
65
+ /* Latin-only model folder */
66
+ }
67
+ }
68
+ o.adaptive = opts.adaptive ?? true;
69
+ return o;
70
+ }
71
+
72
+ async #detectAt(gray, longSide) {
73
+ const { x, ph, pw, scale } = pageToInput(gray, longSide);
74
+ const { out } = await this.det.run({ x: new this.Tensor("float32", x, [1, 1, ph, pw]) });
75
+ const [, , h, w] = out.dims;
76
+ return { boxes: decodeDet(out.data, h, w), scale };
77
+ }
78
+
79
+ /** Line boxes (x0, y0, x1, y1, score) of a page, in page pixels. */
80
+ async detect(gray) {
81
+ let { boxes, scale } = await this.#detectAt(gray, this.longSide);
82
+ if (this.adaptive && boxes.length >= 8) {
83
+ const medH = median(boxes.map((b) => b[3] - b[1]));
84
+ if (medH < ADAPTIVE_MIN_H) {
85
+ let ls = Math.floor(
86
+ Math.min(ADAPTIVE_MAX_SIDE, (this.longSide * ADAPTIVE_TARGET_H) / Math.max(medH, 1))
87
+ );
88
+ ls = Math.floor(ls / 32) * 32;
89
+ if (ls > this.longSide) ({ boxes, scale } = await this.#detectAt(gray, ls));
90
+ }
91
+ }
92
+ for (const b of boxes) {
93
+ b[0] = clamp(b[0] / scale, 0, gray.width);
94
+ b[2] = clamp(b[2] / scale, 0, gray.width);
95
+ b[1] = clamp(b[1] / scale, 0, gray.height);
96
+ b[3] = clamp(b[3] / scale, 0, gray.height);
97
+ }
98
+ return boxes;
99
+ }
100
+
101
+ /** Per line crop: does it hold Chinese characters (script.onnx)? */
102
+ async #hanziLines(crops) {
103
+ const out = new Array(crops.length).fill(false);
104
+ for (const part of widthBatches(crops.map((c) => c.w))) {
105
+ const { arr, w, widths } = padBatch(part.map((i) => crops[i]));
106
+ const { p } = await this.script.run({
107
+ x: new this.Tensor("float32", arr, [part.length, 1, this.height, w]),
108
+ widths: new this.Tensor("int64", widths, [part.length]),
109
+ });
110
+ part.forEach((i, k) => (out[i] = p.data[k] > 0.5));
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /** Recognize the lines: [(text, conf, tokens)] per box; tokens are
116
+ * [(vocab id | char, page x)] at the CTC peaks. */
117
+ async recognize(gray, boxes, batch = 64) {
118
+ const crops = boxes.map((b) => cropLine(gray, b, this.height));
119
+ const out = crops.map(() => ({ text: "", conf: 0, toks: [] }));
120
+ let groups = [[this.rec, this.vocab, crops.map((_, i) => i), false]];
121
+ if (this.script) {
122
+ const hanzi = await this.#hanziLines(crops);
123
+ groups = [
124
+ [this.rec, this.vocab, hanzi.flatMap((h, i) => (h ? [] : [i])), false],
125
+ [this.recCjk, this.vocabCjk, hanzi.flatMap((h, i) => (h ? [i] : [])), true],
126
+ ];
127
+ }
128
+ for (const [sess, vocab, sel, asChars] of groups) {
129
+ if (!sel.length) continue;
130
+ for (const part of widthBatches(sel.map((j) => crops[j].w), batch)) {
131
+ const { arr, w: Wm, widths } = padBatch(part.map((j) => crops[j]));
132
+ const { ids, conf, lengths } = await sess.run({
133
+ x: new this.Tensor("float32", arr, [part.length, 1, this.height, Wm]),
134
+ widths: new this.Tensor("int64", widths, [part.length]),
135
+ });
136
+ const T = ids.dims[1];
137
+ const stride = Wm / Math.max(T, 1);
138
+ part.forEach((j, k) => {
139
+ const n = Number(lengths.data[k]);
140
+ const seq = Array.from({ length: n }, (_, t) => Number(ids.data[k * T + t]));
141
+ const keep = [];
142
+ for (let t = 0; t < n; t++)
143
+ if (seq[t] !== 0 && (t === 0 || seq[t] !== seq[t - 1])) keep.push(t);
144
+ const [x0, y0, x1, y1] = boxes[j];
145
+ const h = Math.max(1, y1 - y0);
146
+ const ix0 = Math.floor(x0 - PAD_X * h);
147
+ const cw = Math.ceil(x1 + PAD_X * h) - ix0;
148
+ const f = cw / crops[j].w;
149
+ const xs = keep.map((t) => ix0 + (t + 0.5) * stride * f);
150
+ const kept = keep.map((t) => seq[t]);
151
+ const toks = asChars
152
+ ? kept.map((id, i) => [vocab.chars[id - 1], xs[i]])
153
+ : kept.map((id, i) => [id, xs[i]]);
154
+ const c =
155
+ keep.reduce((s, t) => s + conf.data[k * T + t], 0) / Math.max(keep.length, 1);
156
+ out[j] =
157
+ c >= MIN_CONF
158
+ ? { text: vocab.decode(kept), conf: c, toks }
159
+ : { text: "", conf: c, toks: [] };
160
+ });
161
+ }
162
+ }
163
+ return out;
164
+ }
165
+
166
+ /** Reading order of the (split) lines with order.onnx. */
167
+ async readingOrder(boxes, texts, wh) {
168
+ if (boxes.length < 2 || !this.order) {
169
+ return { order: boxes.length ? xyCut(boxes) : [], layout: null };
170
+ }
171
+ const feats = lineFeatures(boxes, wh[0], wh[1], texts);
172
+ const n = boxes.length;
173
+ const outs = await this.order.run({
174
+ feats: new this.Tensor("float32", feats, [1, n, 16]),
175
+ mask: new this.Tensor("bool", new Uint8Array(n).fill(1), [1, n]),
176
+ });
177
+ const a = outs.a.data;
178
+ const cls = outs.cls ? outs.cls.data : null;
179
+ if (outs.same) {
180
+ // Layout heads: blocks from the same-block head, lines inside a block by
181
+ // geometry, blocks by the order head.
182
+ return {
183
+ order: decodeBlocksOrder(a, outs.same.data, boxes, cls),
184
+ layout: { cls, same: outs.same.data, classes: BLOCK_CLASSES },
185
+ };
186
+ }
187
+ return { order: decodeOrder(a, n), layout: null };
188
+ }
189
+
190
+ #isSpace(tok) {
191
+ return tok === " " || tok === this.vocab.index.get(" ");
192
+ }
193
+
194
+ #decodeToks(toks) {
195
+ if (!toks.length) return "";
196
+ if (typeof toks[0][0] === "string")
197
+ return toks.map((tk) => tk[0]).join("").split(/\s+/).filter(Boolean).join(" ");
198
+ return this.vocab.decode(toks.map((tk) => tk[0]));
199
+ }
200
+
201
+ /** Column gutters per line (see pipeline.py gutter_cuts). */
202
+ #gutterCuts(boxes, rec) {
203
+ const lefts = boxes.map((b) => b[0]);
204
+ const rights = boxes.map((b) => b[2]);
205
+ return boxes.map((b, i) => {
206
+ const h = Math.max(1, b[3] - b[1]);
207
+ const xs = rec[i].toks.filter((tk) => !this.#isSpace(tk[0])).map((tk) => tk[1]);
208
+ const mine = [];
209
+ for (let k = 1; k < xs.length; k++) {
210
+ const a = xs[k - 1];
211
+ const c = xs[k];
212
+ if (c - a <= GUTTER_GAP * h) continue;
213
+ const gl = a + 0.35 * h;
214
+ const gr = c - 0.35 * h;
215
+ let near = 0;
216
+ for (let m = 0; m < boxes.length; m++) {
217
+ if (m === i) continue;
218
+ if (Math.abs(rights[m] - gl) < 0.5 * h || Math.abs(lefts[m] - gr) < 0.5 * h) near++;
219
+ }
220
+ if (near >= GUTTER_EDGES) mine.push((a + c) / 2);
221
+ }
222
+ return mine;
223
+ });
224
+ }
225
+
226
+ /** Cut a line where two neighbouring characters are more than `gap` line
227
+ * heights apart (or at a column gutter in `at`). */
228
+ #splitLine(box, toks, gap, at = []) {
229
+ const h = Math.max(1, box[3] - box[1]);
230
+ const segs = [[]];
231
+ let prev = null;
232
+ const ats = [...at].sort((p, q) => p - q);
233
+ for (const tk of toks) {
234
+ const x = tk[1];
235
+ if (!this.#isSpace(tk[0])) {
236
+ const cut =
237
+ prev !== null && (x - prev > gap * h || ats.some((c) => prev < c && c < x));
238
+ if (cut) segs.push([]);
239
+ prev = x;
240
+ }
241
+ segs[segs.length - 1].push(tk);
242
+ }
243
+ if (segs.length === 1) return [[box.slice(0, 4), this.#decodeToks(toks), toks]];
244
+ const out = [];
245
+ for (const sg of segs) {
246
+ const text = this.#decodeToks(sg).trim();
247
+ const xs = sg.filter((tk) => !this.#isSpace(tk[0])).map((tk) => tk[1]);
248
+ if (text && xs.length) {
249
+ out.push([
250
+ [
251
+ Math.max(box[0], Math.min(...xs) - 0.35 * h),
252
+ box[1],
253
+ Math.min(box[2], Math.max(...xs) + 0.35 * h),
254
+ box[3],
255
+ ],
256
+ text,
257
+ sg,
258
+ ]);
259
+ }
260
+ }
261
+ return out;
262
+ }
263
+
264
+ /** Lines -> split at wide gaps -> reading order. */
265
+ async #assemble(wh, boxes, rec) {
266
+ const cuts = this.#gutterCuts(boxes, rec);
267
+ let nb = [];
268
+ let nrec = [];
269
+ boxes.forEach((b, i) => {
270
+ for (const [bx, tx, tk] of this.#splitLine(b, rec[i].toks, SPLIT_GAP, cuts[i])) {
271
+ nb.push(bx);
272
+ nrec.push({ text: tx, conf: rec[i].conf, toks: tk });
273
+ }
274
+ });
275
+ const texts = nrec.map((r) => r.text);
276
+ const { order } = await this.readingOrder(nb, texts, wh);
277
+ return order.map((i) => ({
278
+ box: nb[i].slice(0, 4).map(round1),
279
+ text: nrec[i].text,
280
+ conf: round4(nrec[i].conf),
281
+ }));
282
+ }
283
+
284
+ /** Quarter turns (counter-clockwise) the page content is turned by. */
285
+ async orientation(gray) {
286
+ if (!this.orient) return 0;
287
+ const side = ORIENT_SIDE;
288
+ const h = gray.height;
289
+ const w = gray.width;
290
+ const s = side / Math.max(h, w);
291
+ const nh = Math.max(1, roundPy(h * s));
292
+ const nw = Math.max(1, roundPy(w * s));
293
+ const small =
294
+ nh === h && nw === w ? gray.data : resizeGray(gray.data, w, h, nw, nh, "area");
295
+ const x = new Float32Array(side * side);
296
+ for (let r = 0; r < nh; r++)
297
+ for (let c = 0; c < nw; c++) x[r * side + c] = 1 - small[r * nw + c] / 255;
298
+ const { p } = await this.orient.run({
299
+ x: new this.Tensor("float32", x, [1, 1, side, side]),
300
+ });
301
+ let k = 0;
302
+ for (let i = 1; i < p.data.length; i++) if (p.data[i] > p.data[k]) k = i;
303
+ return p.data[k] > ORIENT_MIN ? k : 0;
304
+ }
305
+
306
+ /** OCR of a page image ({data: Uint8Array, width, height}, grayscale):
307
+ * {lines: [{box, text, conf}] in reading order, rotation, text}. */
308
+ async __call__(gray, turn = true) {
309
+ const k = turn ? await this.orientation(gray) : 0;
310
+ if (k) gray = rotQuarterCW(gray, k);
311
+ const boxes = await this.detect(gray);
312
+ const rec = await this.recognize(gray, boxes);
313
+ const lines = await this.#assemble([gray.width, gray.height], boxes, rec);
314
+ return { lines, rotation: 90 * k, text: lines.map((l) => l.text).join("\n") };
315
+ }
316
+ }
src/rec.mjs ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Line crop rule and batching, ported from indra_ocr/rec_preproc.py.
2
+ import { resizeGray, roundPy } from "./imaging.mjs";
3
+
4
+ export const LINE_HEIGHT = 32;
5
+ export const PAD_Y = 0.2;
6
+ export const PAD_X = 0.3;
7
+ const WIDTH_MULTIPLE = 8;
8
+
9
+ // crop_line: expand the line box by PAD_X / PAD_Y of its height, crop from the
10
+ // page (white outside), resize to `height` px tall keeping the aspect ratio,
11
+ // return a float32 ink map {ink: Float32Array, w}.
12
+ export function cropLine(gray, box, height = LINE_HEIGHT, maxW = 4096) {
13
+ const [x0, y0, x1, y1] = box;
14
+ const h = Math.max(1, y1 - y0);
15
+ const X0 = x0 - PAD_X * h;
16
+ const X1 = x1 + PAD_X * h;
17
+ const Y0 = y0 - PAD_Y * h;
18
+ const Y1 = y1 + PAD_Y * h;
19
+ const H = gray.height;
20
+ const W = gray.width;
21
+ const ix0 = Math.floor(X0);
22
+ const iy0 = Math.floor(Y0);
23
+ const ix1 = Math.ceil(X1);
24
+ const iy1 = Math.ceil(Y1);
25
+ const cw = Math.max(1, ix1 - ix0);
26
+ const ch = Math.max(1, iy1 - iy0);
27
+ const crop = new Uint8Array(ch * cw).fill(255);
28
+ const sx0 = Math.max(0, ix0);
29
+ const sy0 = Math.max(0, iy0);
30
+ const sx1 = Math.min(W, ix1);
31
+ const sy1 = Math.min(H, iy1);
32
+ if (sx1 > sx0 && sy1 > sy0) {
33
+ for (let r = sy0; r < sy1; r++) {
34
+ const srcRow = r * W;
35
+ const dstRow = (r - iy0) * cw;
36
+ for (let c = sx0; c < sx1; c++) crop[dstRow + (c - ix0)] = gray.data[srcRow + c];
37
+ }
38
+ }
39
+ const outW = Math.max(WIDTH_MULTIPLE, Math.min(maxW, roundPy((cw * height) / ch)));
40
+ const resized = resizeGray(crop, cw, ch, outW, height, ch > height ? "area" : "linear");
41
+ const ink = new Float32Array(resized.length);
42
+ for (let i = 0; i < resized.length; i++) ink[i] = 1 - resized[i] / 255;
43
+ return { ink, w: outW };
44
+ }
45
+
46
+ // pad_batch: right-pad ink maps to one width (a multiple of 8).
47
+ // Returns {arr: Float32Array [B,1,H,Wm], w: Wm, widths: BigInt64Array}.
48
+ export function padBatch(lines) {
49
+ const H = lines[0].ink.length / lines[0].w;
50
+ let Wm = Math.max(...lines.map((l) => l.w));
51
+ Wm = Math.ceil(Wm / WIDTH_MULTIPLE) * WIDTH_MULTIPLE;
52
+ const arr = new Float32Array(lines.length * H * Wm);
53
+ const widths = new BigInt64Array(lines.length);
54
+ lines.forEach((l, i) => {
55
+ for (let r = 0; r < H; r++) {
56
+ const src = r * l.w;
57
+ const dst = i * H * Wm + r * Wm;
58
+ for (let c = 0; c < l.w; c++) arr[dst + c] = l.ink[src + c];
59
+ }
60
+ widths[i] = BigInt(l.w);
61
+ });
62
+ return { arr, w: Wm, widths };
63
+ }
64
+
65
+ // width_batches: group line indices into batches of similar width (see the
66
+ // Python docstring: less compute goes to padding, and allocations stay small).
67
+ export function widthBatches(widths, maxBatch = 64, maxRatio = 1.3, minWidth = 64, maxArea = 64 * 640) {
68
+ const order = widths.map((w, i) => i).sort((a, b) => widths[a] - widths[b]);
69
+ const batches = [];
70
+ for (const i of order) {
71
+ const last = batches[batches.length - 1];
72
+ if (
73
+ last &&
74
+ last.length < maxBatch &&
75
+ Math.max(widths[i], minWidth) <= maxRatio * Math.max(widths[last[0]], minWidth) &&
76
+ (last.length + 1) * Math.max(widths[i], minWidth) <= maxArea
77
+ ) {
78
+ last.push(i);
79
+ } else {
80
+ batches.push([i]);
81
+ }
82
+ }
83
+ return batches;
84
+ }
src/vocab.mjs ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // CTC vocabulary (class 0 = blank), from meta.json. Port of indra_ocr/vocab.py
2
+ // decode(): a LaTeX command token ("\frac") gets a space before a following letter.
3
+ export class Vocab {
4
+ constructor(chars) {
5
+ this.chars = chars;
6
+ this.index = new Map(chars.map((c, i) => [c, i + 1]));
7
+ this.hasCommands = chars.some((c) => c.length > 1);
8
+ }
9
+
10
+ static fromMeta(obj) {
11
+ if (typeof obj === "string") obj = JSON.parse(obj); // meta.json stores it double-encoded
12
+ if (obj.blank !== 0) throw new Error("only blank=0 vocabularies are supported");
13
+ return new Vocab(obj.chars);
14
+ }
15
+
16
+ decode(ids) {
17
+ const toks = [];
18
+ for (const i of ids) if (i > 0 && i <= this.chars.length) toks.push(this.chars[i - 1]);
19
+ if (!this.hasCommands) return toks.join("");
20
+ let out = "";
21
+ for (let k = 0; k < toks.length; k++) {
22
+ const t = toks[k];
23
+ const nxt = toks[k + 1] ?? "";
24
+ out += t;
25
+ if (
26
+ t.length > 2 &&
27
+ t[0] === "\\" &&
28
+ /^[A-Za-z]+$/.test(t.slice(1)) &&
29
+ /^[A-Za-z]/.test(nxt)
30
+ )
31
+ out += " ";
32
+ }
33
+ return out;
34
+ }
35
+ }