Skip to main content

docling_pdf/
ocr_prep.rs

1//! ONNX-free half of the PP-OCRv3 recognition pipeline: everything before
2//! and after the `session.run` call — line segmentation, crop preparation,
3//! width-batching, CTC decoding, dictionary handling.
4//!
5//! Split out of `ocr.rs` (which keeps the `ort` session) so the browser build
6//! can reuse it (issue #79 phase 2): `docling-wasm` runs these exact
7//! functions and delegates only the inference call to ONNX Runtime Web on
8//! the JS side. Keeping one implementation is what makes the wasm output
9//! byte-comparable to the native CPU path — any drift then comes from the
10//! runtime, not from pre/post-processing.
11
12use image::{imageops, imageops::FilterType, Rgb, RgbImage};
13
14/// PP-OCRv3's fixed input height.
15pub const REC_HEIGHT: u32 = 48;
16
17/// Cap on lines per recognition run: bounds peak input-tensor memory
18/// (16 × 3 × 48 × 2400 px ≈ 22 MB f32) without costing measurable batching
19/// benefit — same-width groups are rarely larger.
20pub const REC_BATCH: usize = 16;
21
22/// A text-line crop prepared for recognition: resized to the fixed model
23/// height, normalised to `[-1, 1]`, laid out CHW.
24pub struct PrepLine {
25    /// Width after the aspect-preserving resize to [`REC_HEIGHT`].
26    pub w: usize,
27    /// `3 * REC_HEIGHT * w` values.
28    pub data: Vec<f32>,
29}
30
31/// Prepare one line crop, or `None` for a degenerate (zero-sized) crop.
32pub fn prep_line(line: &RgbImage) -> Option<PrepLine> {
33    let (w, h) = line.dimensions();
34    if w == 0 || h == 0 {
35        return None;
36    }
37    let new_w = ((w as f32) * REC_HEIGHT as f32 / h as f32)
38        .round()
39        .clamp(8.0, 2400.0) as u32;
40    let resized = imageops::resize(line, new_w, REC_HEIGHT, FilterType::Triangle);
41    let n = (REC_HEIGHT * new_w) as usize;
42    // Normalise to [-1, 1]: (x/255 - 0.5) / 0.5.
43    let mut data = vec![0f32; 3 * n];
44    for (i, px) in resized.pixels().enumerate() {
45        data[i] = px[0] as f32 / 127.5 - 1.0;
46        data[n + i] = px[1] as f32 / 127.5 - 1.0;
47        data[2 * n + i] = px[2] as f32 / 127.5 - 1.0;
48    }
49    Some(PrepLine {
50        w: new_w as usize,
51        data,
52    })
53}
54
55/// The CTC class table for a recognition dictionary file: index 0 = blank,
56/// then one class per dictionary line, then the space class.
57pub fn dict_chars(dict: &str) -> Vec<String> {
58    let mut chars = vec![String::new()]; // blank at 0
59    chars.extend(dict.lines().map(|s| s.to_string()));
60    chars.push(" ".to_string());
61    chars
62}
63
64/// Greedy CTC decode of one row's `(T, C)` probabilities.
65pub fn decode_row(chars: &[String], probs: &[f32], nc: usize) -> String {
66    let mut out = String::new();
67    let mut prev = 0usize;
68    for row in probs.chunks_exact(nc) {
69        let mut best = 0usize;
70        let mut bestv = row[0];
71        for (c, &v) in row.iter().enumerate().skip(1) {
72            if v > bestv {
73                bestv = v;
74                best = c;
75            }
76        }
77        if best != prev && best != 0 {
78            if let Some(ch) = chars.get(best) {
79                out.push_str(ch);
80            }
81        }
82        prev = best;
83    }
84    out
85}
86
87pub(crate) fn luma(p: &Rgb<u8>) -> f32 {
88    0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32
89}
90
91/// Split a region crop into text lines via a horizontal ink-projection profile.
92/// Returns tight `(l, t, r, b)` boxes in crop pixels.
93pub fn segment_lines(crop: &RgbImage) -> Vec<(u32, u32, u32, u32)> {
94    let (w, h) = crop.dimensions();
95    if w == 0 || h == 0 {
96        return Vec::new();
97    }
98    let mean: f32 = crop.pixels().map(luma).sum::<f32>() / (w * h) as f32;
99    let thresh = mean * 0.7; // ink = noticeably darker than the page average
100    let min_ink = ((w as f32) * 0.005).max(1.0) as u32;
101
102    // A column inked in nearly every row is a vertical rule — a panel border, a
103    // table line — not text; left in the profile it bridges every inter-line
104    // gap and the whole crop collapses into one giant "line" (a framed
105    // terms-and-conditions box OCR'd as a single unreadable strip). Mask those
106    // columns out of the row profile; glyph columns never come close (a
107    // descender-to-ascender stack is still far from 90 % of the crop height).
108    let mut col_ink = vec![0u32; w as usize];
109    for y in 0..h {
110        for x in 0..w {
111            if luma(crop.get_pixel(x, y)) < thresh {
112                col_ink[x as usize] += 1;
113            }
114        }
115    }
116    // Borders are *thin*: when "always-inked" columns make up a noticeable
117    // share of the width it is not a frame but a polarity/threshold artifact
118    // (an inverted dark-mode page classifies its whole background as ink) —
119    // leave the profile alone and let polarity normalization handle it.
120    let rule_cols = col_ink
121        .iter()
122        .filter(|&&c| c as f32 > 0.9 * h as f32)
123        .count();
124    let mask_rules = (rule_cols as f32) < 0.15 * w as f32;
125    let rule = |x: u32| mask_rules && col_ink[x as usize] as f32 > 0.9 * h as f32;
126
127    let mut profile = vec![0u32; h as usize];
128    for y in 0..h {
129        let mut row = 0u32;
130        for x in 0..w {
131            if !rule(x) && luma(crop.get_pixel(x, y)) < thresh {
132                row += 1;
133            }
134        }
135        profile[y as usize] = row;
136    }
137
138    // Maximal runs of text rows, separated by (near-)blank rows.
139    let mut runs: Vec<(u32, u32)> = Vec::new();
140    let mut start: Option<u32> = None;
141    for y in 0..h {
142        let text = profile[y as usize] >= min_ink;
143        if text && start.is_none() {
144            start = Some(y);
145        } else if !text {
146            if let Some(s) = start.take() {
147                if y - s >= 4 {
148                    runs.push((s, y));
149                }
150            }
151        }
152    }
153    if let Some(s) = start {
154        if h - s >= 4 {
155            runs.push((s, h));
156        }
157    }
158
159    // Tighten each line to its horizontal ink bounds.
160    runs.into_iter()
161        .map(|(t, b)| {
162            let (mut l, mut r) = (w, 0u32);
163            for y in t..b {
164                for x in 0..w {
165                    if luma(crop.get_pixel(x, y)) < thresh {
166                        l = l.min(x);
167                        r = r.max(x + 1);
168                    }
169                }
170            }
171            if l >= r {
172                (0, t, w, b)
173            } else {
174                (l, t, r, b)
175            }
176        })
177        .collect()
178}
179
180/// Layout labels whose content is recognised as running text.
181pub fn is_text_label(label: &str) -> bool {
182    matches!(
183        label,
184        "text"
185            | "title"
186            | "section_header"
187            | "list_item"
188            | "caption"
189            | "footnote"
190            | "code"
191            | "formula"
192    )
193}
194
195/// A line's page-point bounding box, `(l, t, r, b)`.
196pub type LineBox = (f32, f32, f32, f32);
197
198/// Gather every text-region line crop on a page, in page order: crop each
199/// text region (page points × `scale` → image px), split it into lines, prep
200/// each line, and keep the line's page-point bbox. The exact gathering the
201/// native `ocr_page` does — shared so the browser path produces the same
202/// cells given the same probabilities.
203pub fn prep_region_lines(
204    img: &RgbImage,
205    regions: &[crate::layout::Region],
206    scale: f32,
207) -> (Vec<LineBox>, Vec<PrepLine>) {
208    let (iw, ih) = img.dimensions();
209    let mut bboxes = Vec::new();
210    let mut lines = Vec::new();
211    for region in regions {
212        if !is_text_label(region.label) {
213            continue;
214        }
215        let l = (region.l * scale).max(0.0) as u32;
216        let t = (region.t * scale).max(0.0) as u32;
217        let r = ((region.r * scale).max(0.0) as u32).min(iw);
218        let b = ((region.b * scale).max(0.0) as u32).min(ih);
219        if r <= l || b <= t {
220            continue;
221        }
222        let crop = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
223        for (lx, ly, rx, ry) in segment_lines(&crop) {
224            let line = imageops::crop_imm(&crop, lx, ly, rx - lx, ry - ly).to_image();
225            let Some(pl) = prep_line(&line) else {
226                continue;
227            };
228            bboxes.push((
229                (l + lx) as f32 / scale,
230                (t + ly) as f32 / scale,
231                (l + rx) as f32 / scale,
232                (t + ry) as f32 / scale,
233            ));
234            lines.push(pl);
235        }
236    }
237    (bboxes, lines)
238}
239
240/// Split a text line into word tokens by a vertical ink-projection profile:
241/// runs of ink columns separated by whitespace wider than ~0.6× the line
242/// height (an inter-word/-column gap, not an inter-character one). Returns tight
243/// `(l, 0, r, h)` boxes in line-crop pixels. The browser table path recognizes
244/// these individually so each word carries its own box for column matching —
245/// the native pipeline gets word boxes from the pdfium text layer instead.
246pub fn segment_words(line: &RgbImage) -> Vec<(u32, u32, u32, u32)> {
247    let (w, h) = line.dimensions();
248    if w == 0 || h == 0 {
249        return Vec::new();
250    }
251    let mean: f32 = line.pixels().map(luma).sum::<f32>() / (w * h) as f32;
252    let thresh = mean * 0.7;
253    let mut col_ink = vec![0u32; w as usize];
254    for y in 0..h {
255        for x in 0..w {
256            if luma(line.get_pixel(x, y)) < thresh {
257                col_ink[x as usize] += 1;
258            }
259        }
260    }
261    let min_gap = ((h as f32) * 0.6).max(4.0) as u32;
262    let mut words = Vec::new();
263    let mut start: Option<u32> = None;
264    let mut last_ink = 0u32;
265    let mut gap = 0u32;
266    for x in 0..w {
267        if col_ink[x as usize] > 0 {
268            if start.is_none() {
269                start = Some(x);
270            }
271            last_ink = x;
272            gap = 0;
273        } else if let Some(s) = start {
274            gap += 1;
275            if gap >= min_gap {
276                words.push((s, 0, last_ink + 1, h));
277                start = None;
278            }
279        }
280    }
281    if let Some(s) = start {
282        words.push((s, 0, last_ink + 1, h));
283    }
284    words
285}
286
287/// Gather word crops from a page's *table* regions (browser table path, #157
288/// stage 3): crop each table region, split it into lines, split each line into
289/// words ([`segment_words`]), prep each word for recognition, and keep the
290/// word's page-point bbox. Recognizing table interiors is what gives the cell
291/// matcher the word boxes it needs — the native pipeline reads those from
292/// pdfium's text layer, which the browser doesn't have. NOT used by native.
293pub fn prep_table_words(
294    img: &RgbImage,
295    regions: &[crate::layout::Region],
296    scale: f32,
297) -> (Vec<LineBox>, Vec<PrepLine>) {
298    let (iw, ih) = img.dimensions();
299    let mut bboxes = Vec::new();
300    let mut lines = Vec::new();
301    for region in regions {
302        if !crate::assemble::is_table_like(region.label) {
303            continue;
304        }
305        let l = (region.l * scale).max(0.0) as u32;
306        let t = (region.t * scale).max(0.0) as u32;
307        let r = ((region.r * scale).max(0.0) as u32).min(iw);
308        let b = ((region.b * scale).max(0.0) as u32).min(ih);
309        if r <= l || b <= t {
310            continue;
311        }
312        let crop = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
313        for (lx, ly, rx, ry) in segment_lines(&crop) {
314            let line = imageops::crop_imm(&crop, lx, ly, rx - lx, ry - ly).to_image();
315            for (wx0, _, wx1, _) in segment_words(&line) {
316                let word = imageops::crop_imm(&line, wx0, 0, wx1 - wx0, ry - ly).to_image();
317                let Some(pl) = prep_line(&word) else {
318                    continue;
319                };
320                bboxes.push((
321                    (l + lx + wx0) as f32 / scale,
322                    (t + ly) as f32 / scale,
323                    (l + lx + wx1) as f32 / scale,
324                    (t + ry) as f32 / scale,
325                ));
326                lines.push(pl);
327            }
328        }
329    }
330    (bboxes, lines)
331}
332
333/// Normalize an image to the scan polarity every stage assumes — dark ink
334/// on light paper (the segmentation threshold and the recognition model's
335/// training data both bake it in): a predominantly dark page (mean luma
336/// below mid-gray — a dark-mode screenshot, an inverted scan) is inverted.
337/// Browser-path helper; the native pipeline never calls it (its input is
338/// scanned paper, and the conformance baseline stays untouched).
339pub fn normalize_polarity(mut img: RgbImage) -> RgbImage {
340    let (w, h) = img.dimensions();
341    if w == 0 || h == 0 {
342        return img;
343    }
344    let mean: f32 = img.pixels().map(luma).sum::<f32>() / (w * h) as f32;
345    if mean < 128.0 {
346        for px in img.pixels_mut() {
347            px.0 = [255 - px.0[0], 255 - px.0[1], 255 - px.0[2]];
348        }
349    }
350    img
351}
352
353/// Whole-image line preparation for the browser OCR path (no layout model:
354/// the page itself is the single text region). Returns page-order prepared
355/// lines; callers that need geometry use [`segment_lines`] directly.
356pub fn prep_page_lines(img: &RgbImage) -> Vec<PrepLine> {
357    segment_lines(img)
358        .into_iter()
359        .filter_map(|(l, t, r, b)| {
360            let line = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
361            prep_line(&line)
362        })
363        .collect()
364}
365
366/// Deterministic recognition batching: page-order line indices grouped by
367/// exact width (equal widths share a run — bit-identical to one-at-a-time
368/// recognition, see `ocr.rs`), each group split into [`REC_BATCH`] chunks.
369pub fn width_batches(lines: &[PrepLine]) -> Vec<(usize, Vec<usize>)> {
370    let mut by_width: std::collections::BTreeMap<usize, Vec<usize>> =
371        std::collections::BTreeMap::new();
372    for (ix, pl) in lines.iter().enumerate() {
373        by_width.entry(pl.w).or_default().push(ix);
374    }
375    let mut out = Vec::new();
376    for (w, ixs) in by_width {
377        for chunk in ixs.chunks(REC_BATCH) {
378            out.push((w, chunk.to_vec()));
379        }
380    }
381    out
382}
383
384/// Pack one width-batch into the model's `(N, 3, H, W)` input buffer.
385pub fn batch_input(w: usize, chunk: &[usize], lines: &[PrepLine]) -> Vec<f32> {
386    let hw = REC_HEIGHT as usize * w;
387    let mut data = vec![0f32; chunk.len() * 3 * hw];
388    for (i, &ix) in chunk.iter().enumerate() {
389        data[i * 3 * hw..(i + 1) * 3 * hw].copy_from_slice(&lines[ix].data);
390    }
391    data
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    /// A synthetic "page": white background with two black text bars.
399    fn page() -> RgbImage {
400        let mut img = RgbImage::from_pixel(200, 100, Rgb([255, 255, 255]));
401        for y in 20..30 {
402            for x in 10..190 {
403                img.put_pixel(x, y, Rgb([0, 0, 0]));
404            }
405        }
406        for y in 60..72 {
407            for x in 10..120 {
408                img.put_pixel(x, y, Rgb([0, 0, 0]));
409            }
410        }
411        img
412    }
413
414    #[test]
415    fn segments_and_preps_page_lines() {
416        let lines = prep_page_lines(&page());
417        assert_eq!(lines.len(), 2);
418        for pl in &lines {
419            assert_eq!(pl.data.len(), 3 * REC_HEIGHT as usize * pl.w);
420        }
421        // Different aspect ratios → different widths → separate batches.
422        let batches = width_batches(&lines);
423        assert_eq!(batches.len(), 2);
424        let (w0, chunk0) = &batches[0];
425        assert_eq!(
426            batch_input(*w0, chunk0, &lines).len(),
427            3 * REC_HEIGHT as usize * w0
428        );
429    }
430
431    #[test]
432    fn dark_mode_pages_normalize_to_scan_polarity() {
433        // The same two-bar page, inverted (light text on dark) — the raw
434        // segmentation misfires (it thresholds the dark *background* as ink,
435        // so the "lines" it finds are the inter-bar gaps, not the bars);
436        // polarity normalization recovers the true structure.
437        let mut dark = page();
438        for px in dark.pixels_mut() {
439            px.0 = [255 - px.0[0], 255 - px.0[1], 255 - px.0[2]];
440        }
441        assert_ne!(segment_lines(&dark), segment_lines(&page()));
442        let fixed = normalize_polarity(dark);
443        assert_eq!(segment_lines(&fixed), segment_lines(&page()));
444        assert_eq!(prep_page_lines(&fixed).len(), 2);
445        // A light page passes through untouched.
446        let light = page();
447        assert_eq!(normalize_polarity(light.clone()), light);
448    }
449
450    #[test]
451    fn ctc_decode_collapses_repeats_and_blanks() {
452        // 3 classes: blank, "a", "b"; timesteps a a blank b b → "ab".
453        let chars = dict_chars("a\nb");
454        assert_eq!(chars.len(), 4); // blank, a, b, space
455        let probs = [
456            0.1, 0.8, 0.1, 0.0, // a
457            0.1, 0.8, 0.1, 0.0, // a (repeat collapses)
458            0.9, 0.05, 0.05, 0.0, // blank
459            0.1, 0.1, 0.8, 0.0, // b
460            0.1, 0.1, 0.8, 0.0, // b (repeat collapses)
461        ];
462        assert_eq!(decode_row(&chars, &probs, 4), "ab");
463    }
464}
465
466#[cfg(test)]
467mod word_segmentation {
468    use image::{Rgb, RgbImage};
469
470    /// A white line carrying two ink blocks separated by `gap` pixels.
471    fn line_with_gap(h: u32, gap: u32) -> RgbImage {
472        let w = 30 + gap + 30 + 10;
473        let mut img = RgbImage::from_pixel(w, h, Rgb([255, 255, 255]));
474        for (x0, x1) in [(5u32, 35u32), (35 + gap, 65 + gap)] {
475            for x in x0..x1.min(w) {
476                for y in h / 4..(3 * h / 4) {
477                    img.put_pixel(x, y, Rgb([0, 0, 0]));
478                }
479            }
480        }
481        img
482    }
483
484    /// [`segment_words`] splits on a gap of `0.6 x line height`, which is the
485    /// property the rest of the browser table path inherits: an inter-word
486    /// space never splits, but a column gap wider than that does. Anything
487    /// narrower stays a single box — and a single box can only ever land in one
488    /// TableFormer cell, so this threshold is the floor on how tight a table's
489    /// columns may be before its cells merge.
490    #[test]
491    fn words_split_only_on_gaps_above_six_tenths_of_the_line_height() {
492        for h in [16u32, 24, 32, 40] {
493            let split_at = (1..=40u32)
494                .find(|&gap| super::segment_words(&line_with_gap(h, gap)).len() >= 2)
495                .expect("some gap splits");
496            let ratio = split_at as f32 / h as f32;
497            assert!(
498                (0.5..=0.65).contains(&ratio),
499                "h={h}: split at {split_at}px ({ratio:.2} x height)"
500            );
501            // Just below the threshold the two blocks are one word.
502            assert_eq!(
503                super::segment_words(&line_with_gap(h, split_at - 1)).len(),
504                1,
505                "h={h}: a narrower gap must not split"
506            );
507        }
508    }
509}