Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- .gitattributes +3 -0
- README.md +19 -5
- examples/chinese_essay.png +3 -0
- examples/multi_column.png +3 -0
- examples/rotated_letter.png +3 -0
- index.html +211 -17
- indra-ocr-web.mjs +102 -0
- src/det.mjs +96 -0
- src/imaging.mjs +93 -0
- src/order.mjs +240 -0
- src/pipeline.mjs +316 -0
- src/rec.mjs +84 -0
- src/vocab.mjs +35 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
examples/multi_column.png
ADDED
|
Git LFS Details
|
examples/rotated_letter.png
ADDED
|
Git LFS Details
|
index.html
CHANGED
|
@@ -1,19 +1,213 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|