Skip to content

Commit afd7291

Browse files
artizclaude
andcommitted
wasm: lite-profile scanned pipeline in the browser (#157 stage 2)
Scanned PDFs and images now convert fully client-side through the real pipeline shape: RT-DETR layout detection + PP-OCRv3 recognition via ONNX Runtime Web, everything else in Rust — the native pipeline's own functions, no second implementation. docling-pdf (all under the existing ocr-prep feature, wasm-safe): - layout.rs: input packing (640x640 CHW) and RT-DETR decoding (sigmoid top-q, center->corners, per-label thresholds downstream) extracted as layout_input/decode_layout; the native batch path now decodes through the same function per page. - ocr_prep: prep_region_lines — the exact region-crop/line-split/prep gathering native ocr_page does — moved out and shared; ocr.rs consumes it. - new scanned module: the Worker::process refinement chain (label thresholds -> resolve -> orphans -> false-picture drops -> contained drops), page assembly with the geometric table fallback (the lite profile: TableFormer is stage 3), and cross-page continuation merging, re-exposed without the ml feature. Native path byte-identical after the extraction — the scanned-tiff conversion diffs empty against the pre-refactor output (rebuilt and re-verified after a disk-full broke the first attempt), full docling-pdf/docling suites green. docling-wasm: - ScannedConverter: feed pdf.js-rendered RGBA pages ({scale: 2} — the native RENDER_SCALE) one by one, finish() assembles the document with the same continuation merging as native; page_count() for progress. - convert_scanned_image: one-shot image path (its own page at scale 1, like native). - LayoutSession interop mirrors RecSession: run(data) -> {logits: {data, dims}, boxes: {data, dims}}. - www/scan.html: complete demo — pdf.js from CDN, layout int8 (~165 MB, models-v1 release, browser-cached) + rec model + dict, per-page progress, timing. Stage 3 (TableFormer + enrichment in the browser) remains: the autoregressive decoder loop is deeply ort-typed and needs its own careful extraction pass; table regions meanwhile take the geometric reconstruction, matching --no-table-former. 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 27ff3a8 commit afd7291

10 files changed

Lines changed: 572 additions & 99 deletions

File tree

crates/docling-pdf/src/layout.rs

Lines changed: 72 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,12 @@ pub struct Region {
4747
pub b: f32,
4848
}
4949

50-
#[cfg(feature = "ml")]
5150
/// Base confidence threshold (docling-ibm-models `base_threshold`): the raw
5251
/// RT-DETR floor before docling's `LayoutPostprocessor` applies its stricter
5352
/// per-label thresholds ([`label_threshold`]).
5453
const THRESHOLD: f32 = 0.3;
55-
#[cfg(feature = "ml")]
56-
const SIDE: u32 = 640;
54+
/// RT-DETR's fixed square input side.
55+
pub const SIDE: u32 = 640;
5756

5857
/// Per-label confidence threshold, ported from docling's
5958
/// `LayoutPostprocessor.CONFIDENCE_THRESHOLDS`. The raw predictor keeps every
@@ -222,46 +221,81 @@ impl LayoutModel {
222221
let logits =
223222
&logits[p * num_queries * num_classes..(p + 1) * num_queries * num_classes];
224223
let boxes = &boxes[p * num_queries * 4..(p + 1) * num_queries * 4];
225-
226-
// sigmoid over every (query, class); take the top `num_queries` scores.
227-
let mut scored: Vec<(f32, usize)> = (0..num_queries * num_classes)
228-
.map(|idx| (sigmoid(logits[idx]), idx))
229-
.collect();
230-
scored.sort_unstable_by(|a, b| b.0.total_cmp(&a.0));
231-
scored.truncate(num_queries);
232-
233-
let mut regions = Vec::new();
234-
for (score, idx) in scored {
235-
if score <= THRESHOLD {
236-
continue;
237-
}
238-
let label_id = idx % num_classes;
239-
let q = idx / num_classes;
240-
let cx = boxes[q * 4];
241-
let cy = boxes[q * 4 + 1];
242-
let w = boxes[q * 4 + 2];
243-
let h = boxes[q * 4 + 3];
244-
// center_to_corners, then scale normalized coords to page points.
245-
let l = (cx - w / 2.0) * page_w;
246-
let t = (cy - h / 2.0) * page_h;
247-
let r = (cx + w / 2.0) * page_w;
248-
let b = (cy + h / 2.0) * page_h;
249-
regions.push(Region {
250-
label: LABELS.get(label_id).copied().unwrap_or("text"),
251-
score,
252-
l,
253-
t,
254-
r,
255-
b,
256-
});
257-
}
258-
all.push(regions);
224+
all.push(decode_layout(
225+
logits,
226+
boxes,
227+
num_queries,
228+
num_classes,
229+
*page_w,
230+
*page_h,
231+
));
259232
}
260233
Ok(all)
261234
}
262235
}
263236

264-
#[cfg(feature = "ml")]
265237
fn sigmoid(x: f32) -> f32 {
266238
1.0 / (1.0 + (-x).exp())
267239
}
240+
241+
/// Pack one page image into the model's `(1, 3, SIDE, SIDE)` input: resize
242+
/// (aspect ignored, RT-DETR convention), rescale to `[0,1]`, CHW. Shared
243+
/// with the browser build (#157), which delegates only the session call.
244+
#[cfg(feature = "ocr-prep")]
245+
pub fn layout_input(img: &image::RgbImage) -> Vec<f32> {
246+
let n = (SIDE * SIDE) as usize;
247+
let mut data = vec![0f32; 3 * n];
248+
let resized = image::imageops::resize(img, SIDE, SIDE, image::imageops::FilterType::Triangle);
249+
for (i, px) in resized.pixels().enumerate() {
250+
data[i] = px[0] as f32 / 255.0;
251+
data[n + i] = px[1] as f32 / 255.0;
252+
data[2 * n + i] = px[2] as f32 / 255.0;
253+
}
254+
data
255+
}
256+
257+
/// Decode one page's raw RT-DETR outputs into scored [`Region`]s in page
258+
/// points — sigmoid over every (query, class), top-`num_queries` kept, boxes
259+
/// converted center→corners and scaled. Shared with the browser build; the
260+
/// native batch path calls it per page, so both decode identically.
261+
pub fn decode_layout(
262+
logits: &[f32],
263+
boxes: &[f32],
264+
num_queries: usize,
265+
num_classes: usize,
266+
page_w: f32,
267+
page_h: f32,
268+
) -> Vec<Region> {
269+
let mut scored: Vec<(f32, usize)> = (0..num_queries * num_classes)
270+
.map(|idx| (sigmoid(logits[idx]), idx))
271+
.collect();
272+
scored.sort_unstable_by(|a, b| b.0.total_cmp(&a.0));
273+
scored.truncate(num_queries);
274+
275+
let mut regions = Vec::new();
276+
for (score, idx) in scored {
277+
if score <= THRESHOLD {
278+
continue;
279+
}
280+
let label_id = idx % num_classes;
281+
let q = idx / num_classes;
282+
let cx = boxes[q * 4];
283+
let cy = boxes[q * 4 + 1];
284+
let w = boxes[q * 4 + 2];
285+
let h = boxes[q * 4 + 3];
286+
// center_to_corners, then scale normalized coords to page points.
287+
let l = (cx - w / 2.0) * page_w;
288+
let t = (cy - h / 2.0) * page_h;
289+
let r = (cx + w / 2.0) * page_w;
290+
let b = (cy + h / 2.0) * page_h;
291+
regions.push(Region {
292+
label: LABELS.get(label_id).copied().unwrap_or("text"),
293+
score,
294+
l,
295+
t,
296+
r,
297+
b,
298+
});
299+
}
300+
regions
301+
}

crates/docling-pdf/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ pub mod pdfium_backend;
3333
mod reading_order;
3434
#[cfg(feature = "ml")]
3535
pub mod resample;
36+
#[cfg(feature = "ocr-prep")]
37+
pub mod scanned;
3638
#[cfg(feature = "ml")]
3739
pub mod tableformer;
3840
pub mod textparse;

crates/docling-pdf/src/ocr.rs

Lines changed: 6 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,15 @@
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 image::{imageops, RgbImage};
8+
use image::RgbImage;
99
use ort::session::Session;
1010
use ort::value::Tensor;
1111

1212
use crate::layout::Region;
1313
// The ONNX-free half (line prep, batching, CTC decode) lives in `ocr_prep`
1414
// so the wasm build shares it verbatim (#79 phase 2).
1515
use crate::ocr_prep::{
16-
batch_input, decode_row, dict_chars, prep_line, segment_lines, width_batches, PrepLine,
17-
REC_HEIGHT,
16+
batch_input, decode_row, dict_chars, prep_region_lines, width_batches, PrepLine, REC_HEIGHT,
1817
};
1918
use crate::pdfium_backend::TextCell;
2019

@@ -24,21 +23,6 @@ pub struct OcrModel {
2423
chars: Vec<String>,
2524
}
2625

27-
/// Layout labels whose content is recognised as running text.
28-
fn is_text_label(label: &str) -> bool {
29-
matches!(
30-
label,
31-
"text"
32-
| "title"
33-
| "section_header"
34-
| "list_item"
35-
| "caption"
36-
| "footnote"
37-
| "code"
38-
| "formula"
39-
)
40-
}
41-
4226
/// OCR recognition language: which PP-OCRv3 model + dictionary pair runs.
4327
///
4428
/// The default is **English** (`models/ocr_rec_en.onnx` + `models/en_dict.txt`):
@@ -183,37 +167,10 @@ impl OcrModel {
183167
regions: &[Region],
184168
scale: f32,
185169
) -> Result<Vec<TextCell>, String> {
186-
let (iw, ih) = img.dimensions();
187-
// Gather every line crop on the page first, so equal-width lines can
188-
// share a recognition run regardless of which region they came from.
189-
let mut bboxes: Vec<(f32, f32, f32, f32)> = Vec::new();
190-
let mut lines: Vec<PrepLine> = Vec::new();
191-
for region in regions {
192-
if !is_text_label(region.label) {
193-
continue;
194-
}
195-
let l = (region.l * scale).max(0.0) as u32;
196-
let t = (region.t * scale).max(0.0) as u32;
197-
let r = ((region.r * scale).max(0.0) as u32).min(iw);
198-
let b = ((region.b * scale).max(0.0) as u32).min(ih);
199-
if r <= l || b <= t {
200-
continue;
201-
}
202-
let crop = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
203-
for (lx, ly, rx, ry) in segment_lines(&crop) {
204-
let line = imageops::crop_imm(&crop, lx, ly, rx - lx, ry - ly).to_image();
205-
let Some(pl) = prep_line(&line) else {
206-
continue;
207-
};
208-
bboxes.push((
209-
(l + lx) as f32 / scale,
210-
(t + ly) as f32 / scale,
211-
(l + rx) as f32 / scale,
212-
(t + ry) as f32 / scale,
213-
));
214-
lines.push(pl);
215-
}
216-
}
170+
// Gather every line crop on the page first (shared with the browser
171+
// path), so equal-width lines can share a recognition run regardless
172+
// of which region they came from.
173+
let (bboxes, lines) = prep_region_lines(img, regions, scale);
217174

218175
// Deterministic width-batching (shared with the wasm path).
219176
let mut texts = vec![String::new(); lines.len()];

crates/docling-pdf/src/ocr_prep.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,66 @@ pub fn segment_lines(crop: &RgbImage) -> Vec<(u32, u32, u32, u32)> {
152152
.collect()
153153
}
154154

155+
/// Layout labels whose content is recognised as running text.
156+
pub fn is_text_label(label: &str) -> bool {
157+
matches!(
158+
label,
159+
"text"
160+
| "title"
161+
| "section_header"
162+
| "list_item"
163+
| "caption"
164+
| "footnote"
165+
| "code"
166+
| "formula"
167+
)
168+
}
169+
170+
/// A line's page-point bounding box, `(l, t, r, b)`.
171+
pub type LineBox = (f32, f32, f32, f32);
172+
173+
/// Gather every text-region line crop on a page, in page order: crop each
174+
/// text region (page points × `scale` → image px), split it into lines, prep
175+
/// each line, and keep the line's page-point bbox. The exact gathering the
176+
/// native `ocr_page` does — shared so the browser path produces the same
177+
/// cells given the same probabilities.
178+
pub fn prep_region_lines(
179+
img: &RgbImage,
180+
regions: &[crate::layout::Region],
181+
scale: f32,
182+
) -> (Vec<LineBox>, Vec<PrepLine>) {
183+
let (iw, ih) = img.dimensions();
184+
let mut bboxes = Vec::new();
185+
let mut lines = Vec::new();
186+
for region in regions {
187+
if !is_text_label(region.label) {
188+
continue;
189+
}
190+
let l = (region.l * scale).max(0.0) as u32;
191+
let t = (region.t * scale).max(0.0) as u32;
192+
let r = ((region.r * scale).max(0.0) as u32).min(iw);
193+
let b = ((region.b * scale).max(0.0) as u32).min(ih);
194+
if r <= l || b <= t {
195+
continue;
196+
}
197+
let crop = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
198+
for (lx, ly, rx, ry) in segment_lines(&crop) {
199+
let line = imageops::crop_imm(&crop, lx, ly, rx - lx, ry - ly).to_image();
200+
let Some(pl) = prep_line(&line) else {
201+
continue;
202+
};
203+
bboxes.push((
204+
(l + lx) as f32 / scale,
205+
(t + ly) as f32 / scale,
206+
(l + rx) as f32 / scale,
207+
(t + ry) as f32 / scale,
208+
));
209+
lines.push(pl);
210+
}
211+
}
212+
(bboxes, lines)
213+
}
214+
155215
/// Whole-image line preparation for the browser OCR path (no layout model:
156216
/// the page itself is the single text region). Returns page-order prepared
157217
/// lines; callers that need geometry use [`segment_lines`] directly.

crates/docling-pdf/src/scanned.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//! Scanned-page assembly facade for the browser build (#157 stage 2).
2+
//!
3+
//! The native `Worker::process` chain, re-exposed without the `ml` feature so
4+
//! `docling-wasm` can run it around JS-delegated inference: refine the raw
5+
//! layout detections exactly like the native pipeline, then assemble the page
6+
//! with the geometric table fallback (the lite profile — TableFormer is
7+
//! stage 3) and no enrichments. One shared implementation, one behavior.
8+
9+
use docling_core::{DoclingDocument, Node};
10+
11+
/// One assembled page: its nodes and `(anchor, href)` hyperlink pairs.
12+
pub type AssembledPage = (Vec<Node>, Vec<(String, String)>);
13+
14+
use crate::layout::{label_threshold, Region};
15+
use crate::pdfium_backend::{PdfPage, TextCell};
16+
17+
/// The native pipeline's region-refinement chain, in its exact order:
18+
/// per-label score thresholds → overlap resolution → orphan text regions for
19+
/// detector-missed cells → false-picture drops → contained-regular drops.
20+
/// `cells` is the page's (possibly empty, pre-OCR) text-cell set.
21+
pub fn refine_regions(
22+
regions: Vec<Region>,
23+
cells: &[TextCell],
24+
page_w: f32,
25+
page_h: f32,
26+
) -> Vec<Region> {
27+
let mut regions = regions;
28+
regions.retain(|r| r.score >= label_threshold(r.label));
29+
let mut regions = crate::assemble::resolve(regions);
30+
crate::assemble::add_orphan_regions(&mut regions, cells);
31+
crate::assemble::drop_false_pictures(&mut regions, cells, page_w, page_h);
32+
crate::assemble::drop_contained_regulars(&mut regions);
33+
regions
34+
}
35+
36+
/// Assemble one refined page — geometric tables (no TableFormer), no
37+
/// enrichments — into its nodes and hyperlink pairs.
38+
pub fn assemble_page(page: &PdfPage, regions: Vec<Region>) -> AssembledPage {
39+
let table_rows = vec![None; regions.len()];
40+
let enrich = vec![None; regions.len()];
41+
crate::assemble::assemble_page(page, regions, &table_rows, &enrich)
42+
}
43+
44+
/// Stitch per-page results into a document: cross-page paragraph
45+
/// continuations merge exactly like the native pipeline's final pass.
46+
pub fn finish_document(name: &str, pages: Vec<AssembledPage>) -> DoclingDocument {
47+
let mut doc = DoclingDocument::new(name);
48+
for (nodes, links) in pages {
49+
doc.nodes.extend(nodes);
50+
doc.links.extend(links);
51+
}
52+
crate::assemble::merge_continuations(&mut doc.nodes);
53+
doc
54+
}

crates/docling-wasm/README.md

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,32 @@ const rec = {
8383
const markdown = await ocr_image(imageBytes, dictText, rec, "md");
8484
```
8585

86-
[`www/ocr.html`](./www/ocr.html) is the complete demo (model/dict fetched
87-
from their public hosting and browser-cached). Whole-image projection
88-
segmentation only — best on single-column scans; layout detection is
89-
stage 2, scanned PDFs (pdf.js rasterization) arrive with it.
86+
[`www/ocr.html`](./www/ocr.html) is the recognition-only demo (model/dict
87+
fetched from their public hosting and browser-cached).
88+
89+
**Stage 2 — the lite profile** (`ScannedConverter` / `convert_scanned_image`):
90+
RT-DETR layout detection (int8, ~165 MB from the `models-v1` release) +
91+
region-cropped OCR + reading-order assembly, with tables via the geometric
92+
reconstruction (the native `--no-table-former` path — TableFormer is
93+
stage 3). Scanned **PDFs** convert too: the host page rasterizes pages with
94+
pdf.js at `{scale: 2}` (2 px/point — the native pipeline's `RENDER_SCALE`)
95+
and feeds the bitmaps page by page:
96+
97+
```js
98+
const conv = new ScannedConverter(dictText);
99+
for (let p = 1; p <= pdf.numPages; p++) {
100+
const viewport = page.getViewport({ scale: 2 });
101+
// … render to canvas, then:
102+
await conv.add_page(rgba, canvas.width, canvas.height, 2.0, layout, rec);
103+
}
104+
const markdown = conv.finish(file.name, "md");
105+
```
106+
107+
[`www/scan.html`](./www/scan.html) is the complete demo (pdf.js + both
108+
models). Region refinement, OCR gathering/batching/decoding and page
109+
assembly are the native pipeline's own functions
110+
(`docling_pdf::{layout, ocr_prep, scanned}`) — the wasm path adds no second
111+
implementation.
90112

91113
## Host-side tests
92114

crates/docling-wasm/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ use wasm_bindgen::prelude::*;
2222
#[cfg(feature = "ocr")]
2323
mod ocr;
2424
#[cfg(feature = "ocr")]
25+
mod scanned;
26+
#[cfg(feature = "ocr")]
2527
pub use ocr::ocr_image;
28+
#[cfg(feature = "ocr")]
29+
pub use scanned::{convert_scanned_image, ScannedConverter};
2630

2731
#[wasm_bindgen(start)]
2832
fn start() {

0 commit comments

Comments
 (0)