Skip to content

Commit 27ff3a8

Browse files
artizclaude
andcommitted
wasm: in-browser OCR via ONNX Runtime Web interop (#157 stage 1)
First slice of the browser ML pipeline: scanned images OCR fully client-side. docling-pdf grows an `ocr-prep` feature (pure Rust + the image crate, no ort/pdfium): the ONNX-free half of the PP-OCRv3 recognition pipeline — line segmentation, crop prep, deterministic width-batching, CTC decode, dictionary handling — moved verbatim from ocr.rs into a public ocr_prep module. ocr.rs now consumes it, so there is exactly ONE implementation of the pre/post-processing; the native path is byte-identical after the refactor (verified: the scanned-tiff conversion diffs empty against the pre-refactor output, full docling-pdf suite green). Any wasm-vs-native drift can therefore only come from the runtime's kernels — the #157 conformance story depends on that property. docling-wasm (default `ocr` feature): `ocr_image(bytes, dict, session, to)` — decode the image in Rust, segment/prep/batch via ocr_prep, delegate each width-batch to a JS `RecSession` wrapper around an ort-web InferenceSession (`run(n, h, w, data) -> {data, dims}`), CTC- decode, and export a DoclingDocument as Markdown or docling JSON. No layout model yet (stage 2): the whole image is one text region split by the ink-projection profile — right for single-column scans, degraded on complex layouts, and said so in the docs. www/ocr.html: complete drop-an-image demo — ort-web from CDN, the ~10 MB en_PP-OCRv3 model + dictionary fetched from their public hosting and browser-cached, timing readout. Compiles for wasm32-unknown-unknown and the host (rlib tests); clippy clean; the existing wasm CI check is unaffected. Refs #157 Signed-off-by: artiz <artem.kustikov@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EY5KAiquN4YpVf2PXEQkVT
1 parent e983bd2 commit 27ff3a8

10 files changed

Lines changed: 568 additions & 157 deletions

File tree

Cargo.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/docling-pdf/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ default = ["ml"]
1818
# (`convert_text_layer` — lopdf content-stream parsing + the docling-parse
1919
# line sanitizer, the same extraction `--no-ocr` uses), which compiles for
2020
# wasm32-unknown-unknown.
21+
# The ONNX-free OCR pre/post half (`ocr_prep`) alone — what the wasm build
22+
# (#79 phase 2) pulls: pure Rust + the `image` crate, no ort/pdfium.
23+
ocr-prep = ["dep:image"]
2124
ml = [
25+
"ocr-prep",
2226
"dep:pdfium-render",
2327
"dep:ort",
2428
"dep:image",

crates/docling-pdf/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ pub mod layout;
2727
mod mets;
2828
#[cfg(feature = "ml")]
2929
mod ocr;
30+
#[cfg(feature = "ocr-prep")]
31+
pub mod ocr_prep;
3032
pub mod pdfium_backend;
3133
mod reading_order;
3234
#[cfg(feature = "ml")]

crates/docling-pdf/src/ocr.rs

Lines changed: 23 additions & 157 deletions
Original file line numberDiff line numberDiff line change
@@ -5,88 +5,25 @@
55
//! is recognised and decoded with CTC — producing [`TextCell`]s the normal
66
//! layout assembly then consumes. This avoids a separate text-detection model.
77
8-
use std::collections::BTreeMap;
9-
10-
use image::{imageops, imageops::FilterType, Rgb, RgbImage};
8+
use image::{imageops, RgbImage};
119
use ort::session::Session;
1210
use ort::value::Tensor;
1311

1412
use crate::layout::Region;
13+
// The ONNX-free half (line prep, batching, CTC decode) lives in `ocr_prep`
14+
// so the wasm build shares it verbatim (#79 phase 2).
15+
use crate::ocr_prep::{
16+
batch_input, decode_row, dict_chars, prep_line, segment_lines, width_batches, PrepLine,
17+
REC_HEIGHT,
18+
};
1519
use crate::pdfium_backend::TextCell;
1620

17-
const REC_HEIGHT: u32 = 48;
18-
19-
/// Cap on lines per recognition run: bounds peak input-tensor memory
20-
/// (16 × 3 × 48 × 2400 px ≈ 22 MB f32) without costing measurable batching
21-
/// benefit — same-width groups are rarely larger.
22-
const REC_BATCH: usize = 16;
23-
24-
/// A text-line crop prepared for recognition: resized to the fixed model
25-
/// height, normalised to `[-1, 1]`, laid out CHW.
26-
struct PrepLine {
27-
/// Width after the aspect-preserving resize to `REC_HEIGHT`.
28-
w: usize,
29-
/// `3 * REC_HEIGHT * w` values.
30-
data: Vec<f32>,
31-
}
32-
33-
/// Prepare one line crop, or `None` for a degenerate (zero-sized) crop.
34-
fn prep_line(line: &RgbImage) -> Option<PrepLine> {
35-
let (w, h) = line.dimensions();
36-
if w == 0 || h == 0 {
37-
return None;
38-
}
39-
let new_w = ((w as f32) * REC_HEIGHT as f32 / h as f32)
40-
.round()
41-
.clamp(8.0, 2400.0) as u32;
42-
let resized = imageops::resize(line, new_w, REC_HEIGHT, FilterType::Triangle);
43-
let n = (REC_HEIGHT * new_w) as usize;
44-
// Normalise to [-1, 1]: (x/255 - 0.5) / 0.5.
45-
let mut data = vec![0f32; 3 * n];
46-
for (i, px) in resized.pixels().enumerate() {
47-
data[i] = px[0] as f32 / 127.5 - 1.0;
48-
data[n + i] = px[1] as f32 / 127.5 - 1.0;
49-
data[2 * n + i] = px[2] as f32 / 127.5 - 1.0;
50-
}
51-
Some(PrepLine {
52-
w: new_w as usize,
53-
data,
54-
})
55-
}
56-
5721
pub struct OcrModel {
5822
rec: Session,
5923
/// CTC classes: index 0 = blank, 1..=6623 = dictionary, 6624 = space.
6024
chars: Vec<String>,
6125
}
6226

63-
/// Greedy CTC decode of one row's `(T, C)` probabilities.
64-
fn decode_row(chars: &[String], probs: &[f32], nc: usize) -> String {
65-
let mut out = String::new();
66-
let mut prev = 0usize;
67-
for row in probs.chunks_exact(nc) {
68-
let mut best = 0usize;
69-
let mut bestv = row[0];
70-
for (c, &v) in row.iter().enumerate().skip(1) {
71-
if v > bestv {
72-
bestv = v;
73-
best = c;
74-
}
75-
}
76-
if best != prev && best != 0 {
77-
if let Some(ch) = chars.get(best) {
78-
out.push_str(ch);
79-
}
80-
}
81-
prev = best;
82-
}
83-
out
84-
}
85-
86-
fn luma(p: &Rgb<u8>) -> f32 {
87-
0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32
88-
}
89-
9027
/// Layout labels whose content is recognised as running text.
9128
fn is_text_label(label: &str) -> bool {
9229
matches!(
@@ -194,10 +131,10 @@ impl OcrModel {
194131
.map_err(|e| format!("ocr: load {rec_path}: {e}"))?;
195132
let dict = std::fs::read_to_string(&dict_path)
196133
.map_err(|e| format!("ocr: read dict {dict_path}: {e}"))?;
197-
let mut chars = vec![String::new()]; // blank at 0
198-
chars.extend(dict.lines().map(|s| s.to_string()));
199-
chars.push(" ".to_string());
200-
Ok(Self { rec, chars })
134+
Ok(Self {
135+
rec,
136+
chars: dict_chars(&dict),
137+
})
201138
}
202139

203140
/// Recognise a batch of prepared *same-width* lines in one session run.
@@ -208,13 +145,14 @@ impl OcrModel {
208145
/// the scanned corpus), whereas width-padding leaks into the real
209146
/// timesteps through the model's global-attention blocks and measurably
210147
/// changes low-confidence characters.
211-
fn recognize_batch(&mut self, w: usize, batch: &[&PrepLine]) -> Result<Vec<String>, String> {
212-
let n = batch.len();
213-
let hw = REC_HEIGHT as usize * w;
214-
let mut data = vec![0f32; n * 3 * hw];
215-
for (i, pl) in batch.iter().enumerate() {
216-
data[i * 3 * hw..(i + 1) * 3 * hw].copy_from_slice(&pl.data);
217-
}
148+
fn recognize_batch(
149+
&mut self,
150+
w: usize,
151+
chunk: &[usize],
152+
lines: &[PrepLine],
153+
) -> Result<Vec<String>, String> {
154+
let n = chunk.len();
155+
let data = batch_input(w, chunk, lines);
218156
let input = Tensor::from_array(([n, 3, REC_HEIGHT as usize, w], data))
219157
.map_err(|e| format!("ocr: input tensor: {e}"))?;
220158
let outputs = self
@@ -277,19 +215,11 @@ impl OcrModel {
277215
}
278216
}
279217

280-
// Group page-order line indices by exact width (BTreeMap: run order is
281-
// deterministic) and recognise each group batched.
282-
let mut by_width: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
283-
for (ix, pl) in lines.iter().enumerate() {
284-
by_width.entry(pl.w).or_default().push(ix);
285-
}
218+
// Deterministic width-batching (shared with the wasm path).
286219
let mut texts = vec![String::new(); lines.len()];
287-
for (w, ixs) in by_width {
288-
for chunk in ixs.chunks(REC_BATCH) {
289-
let batch: Vec<&PrepLine> = chunk.iter().map(|&i| &lines[i]).collect();
290-
for (&i, text) in chunk.iter().zip(self.recognize_batch(w, &batch)?) {
291-
texts[i] = text;
292-
}
220+
for (w, chunk) in width_batches(&lines) {
221+
for (&i, text) in chunk.iter().zip(self.recognize_batch(w, &chunk, &lines)?) {
222+
texts[i] = text;
293223
}
294224
}
295225

@@ -305,67 +235,3 @@ impl OcrModel {
305235
Ok(cells)
306236
}
307237
}
308-
309-
/// Split a region crop into text lines via a horizontal ink-projection profile.
310-
/// Returns tight `(l, t, r, b)` boxes in crop pixels.
311-
fn segment_lines(crop: &RgbImage) -> Vec<(u32, u32, u32, u32)> {
312-
let (w, h) = crop.dimensions();
313-
if w == 0 || h == 0 {
314-
return Vec::new();
315-
}
316-
let mean: f32 = crop.pixels().map(luma).sum::<f32>() / (w * h) as f32;
317-
let thresh = mean * 0.7; // ink = noticeably darker than the page average
318-
let min_ink = ((w as f32) * 0.005).max(1.0) as u32;
319-
320-
let mut profile = vec![0u32; h as usize];
321-
for y in 0..h {
322-
let mut row = 0u32;
323-
for x in 0..w {
324-
if luma(crop.get_pixel(x, y)) < thresh {
325-
row += 1;
326-
}
327-
}
328-
profile[y as usize] = row;
329-
}
330-
331-
// Maximal runs of text rows, separated by (near-)blank rows.
332-
let mut runs: Vec<(u32, u32)> = Vec::new();
333-
let mut start: Option<u32> = None;
334-
for y in 0..h {
335-
let text = profile[y as usize] >= min_ink;
336-
if text && start.is_none() {
337-
start = Some(y);
338-
} else if !text {
339-
if let Some(s) = start.take() {
340-
if y - s >= 4 {
341-
runs.push((s, y));
342-
}
343-
}
344-
}
345-
}
346-
if let Some(s) = start {
347-
if h - s >= 4 {
348-
runs.push((s, h));
349-
}
350-
}
351-
352-
// Tighten each line to its horizontal ink bounds.
353-
runs.into_iter()
354-
.map(|(t, b)| {
355-
let (mut l, mut r) = (w, 0u32);
356-
for y in t..b {
357-
for x in 0..w {
358-
if luma(crop.get_pixel(x, y)) < thresh {
359-
l = l.min(x);
360-
r = r.max(x + 1);
361-
}
362-
}
363-
}
364-
if l >= r {
365-
(0, t, w, b)
366-
} else {
367-
(l, t, r, b)
368-
}
369-
})
370-
.collect()
371-
}

0 commit comments

Comments
 (0)