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 on digital pages, and calls this on scanned ones
293/// (`OcrModel::ocr_table_words`, #173), same as the browser path.
294pub fn prep_table_words(
295    img: &RgbImage,
296    regions: &[crate::layout::Region],
297    scale: f32,
298) -> (Vec<LineBox>, Vec<PrepLine>) {
299    let (iw, ih) = img.dimensions();
300    let mut bboxes = Vec::new();
301    let mut lines = Vec::new();
302    for region in regions {
303        if !crate::assemble::is_table_like(region.label) {
304            continue;
305        }
306        let l = (region.l * scale).max(0.0) as u32;
307        let t = (region.t * scale).max(0.0) as u32;
308        let r = ((region.r * scale).max(0.0) as u32).min(iw);
309        let b = ((region.b * scale).max(0.0) as u32).min(ih);
310        if r <= l || b <= t {
311            continue;
312        }
313        let crop = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
314        for (lx, ly, rx, ry) in segment_lines(&crop) {
315            let line = imageops::crop_imm(&crop, lx, ly, rx - lx, ry - ly).to_image();
316            for (wx0, _, wx1, _) in segment_words(&line) {
317                let word = imageops::crop_imm(&line, wx0, 0, wx1 - wx0, ry - ly).to_image();
318                let Some(pl) = prep_line(&word) else {
319                    continue;
320                };
321                bboxes.push((
322                    (l + lx + wx0) as f32 / scale,
323                    (t + ly) as f32 / scale,
324                    (l + lx + wx1) as f32 / scale,
325                    (t + ry) as f32 / scale,
326                ));
327                lines.push(pl);
328            }
329        }
330    }
331    (bboxes, lines)
332}
333
334/// Normalize an image to the scan polarity every stage assumes — dark ink
335/// on light paper (the segmentation threshold and the recognition model's
336/// training data both bake it in): a predominantly dark page (mean luma
337/// below mid-gray — a dark-mode screenshot, an inverted scan) is inverted.
338/// Browser-path helper; the native pipeline never calls it (its input is
339/// scanned paper, and the conformance baseline stays untouched).
340pub fn normalize_polarity(mut img: RgbImage) -> RgbImage {
341    let (w, h) = img.dimensions();
342    if w == 0 || h == 0 {
343        return img;
344    }
345    let mean: f32 = img.pixels().map(luma).sum::<f32>() / (w * h) as f32;
346    if mean < 128.0 {
347        for px in img.pixels_mut() {
348            px.0 = [255 - px.0[0], 255 - px.0[1], 255 - px.0[2]];
349        }
350    }
351    img
352}
353
354/// Whole-image line preparation for the browser OCR path (no layout model:
355/// the page itself is the single text region). Returns page-order prepared
356/// lines; callers that need geometry use [`segment_lines`] directly.
357pub fn prep_page_lines(img: &RgbImage) -> Vec<PrepLine> {
358    segment_lines(img)
359        .into_iter()
360        .filter_map(|(l, t, r, b)| {
361            let line = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
362            prep_line(&line)
363        })
364        .collect()
365}
366
367/// Deterministic recognition batching: page-order line indices grouped by
368/// exact width (equal widths share a run — bit-identical to one-at-a-time
369/// recognition, see `ocr.rs`), each group split into [`REC_BATCH`] chunks.
370pub fn width_batches(lines: &[PrepLine]) -> Vec<(usize, Vec<usize>)> {
371    let mut by_width: std::collections::BTreeMap<usize, Vec<usize>> =
372        std::collections::BTreeMap::new();
373    for (ix, pl) in lines.iter().enumerate() {
374        by_width.entry(pl.w).or_default().push(ix);
375    }
376    let mut out = Vec::new();
377    for (w, ixs) in by_width {
378        for chunk in ixs.chunks(REC_BATCH) {
379            out.push((w, chunk.to_vec()));
380        }
381    }
382    out
383}
384
385/// Pack one width-batch into the model's `(N, 3, H, W)` input buffer.
386pub fn batch_input(w: usize, chunk: &[usize], lines: &[PrepLine]) -> Vec<f32> {
387    let hw = REC_HEIGHT as usize * w;
388    let mut data = vec![0f32; chunk.len() * 3 * hw];
389    for (i, &ix) in chunk.iter().enumerate() {
390        data[i * 3 * hw..(i + 1) * 3 * hw].copy_from_slice(&lines[ix].data);
391    }
392    data
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    /// A synthetic "page": white background with two black text bars.
400    fn page() -> RgbImage {
401        let mut img = RgbImage::from_pixel(200, 100, Rgb([255, 255, 255]));
402        for y in 20..30 {
403            for x in 10..190 {
404                img.put_pixel(x, y, Rgb([0, 0, 0]));
405            }
406        }
407        for y in 60..72 {
408            for x in 10..120 {
409                img.put_pixel(x, y, Rgb([0, 0, 0]));
410            }
411        }
412        img
413    }
414
415    #[test]
416    fn segments_and_preps_page_lines() {
417        let lines = prep_page_lines(&page());
418        assert_eq!(lines.len(), 2);
419        for pl in &lines {
420            assert_eq!(pl.data.len(), 3 * REC_HEIGHT as usize * pl.w);
421        }
422        // Different aspect ratios → different widths → separate batches.
423        let batches = width_batches(&lines);
424        assert_eq!(batches.len(), 2);
425        let (w0, chunk0) = &batches[0];
426        assert_eq!(
427            batch_input(*w0, chunk0, &lines).len(),
428            3 * REC_HEIGHT as usize * w0
429        );
430    }
431
432    #[test]
433    fn dark_mode_pages_normalize_to_scan_polarity() {
434        // The same two-bar page, inverted (light text on dark) — the raw
435        // segmentation misfires (it thresholds the dark *background* as ink,
436        // so the "lines" it finds are the inter-bar gaps, not the bars);
437        // polarity normalization recovers the true structure.
438        let mut dark = page();
439        for px in dark.pixels_mut() {
440            px.0 = [255 - px.0[0], 255 - px.0[1], 255 - px.0[2]];
441        }
442        assert_ne!(segment_lines(&dark), segment_lines(&page()));
443        let fixed = normalize_polarity(dark);
444        assert_eq!(segment_lines(&fixed), segment_lines(&page()));
445        assert_eq!(prep_page_lines(&fixed).len(), 2);
446        // A light page passes through untouched.
447        let light = page();
448        assert_eq!(normalize_polarity(light.clone()), light);
449    }
450
451    #[test]
452    fn ctc_decode_collapses_repeats_and_blanks() {
453        // 3 classes: blank, "a", "b"; timesteps a a blank b b → "ab".
454        let chars = dict_chars("a\nb");
455        assert_eq!(chars.len(), 4); // blank, a, b, space
456        let probs = [
457            0.1, 0.8, 0.1, 0.0, // a
458            0.1, 0.8, 0.1, 0.0, // a (repeat collapses)
459            0.9, 0.05, 0.05, 0.0, // blank
460            0.1, 0.1, 0.8, 0.0, // b
461            0.1, 0.1, 0.8, 0.0, // b (repeat collapses)
462        ];
463        assert_eq!(decode_row(&chars, &probs, 4), "ab");
464    }
465}
466
467#[cfg(test)]
468mod word_segmentation {
469    use image::{Rgb, RgbImage};
470
471    /// A white line carrying two ink blocks separated by `gap` pixels.
472    fn line_with_gap(h: u32, gap: u32) -> RgbImage {
473        let w = 30 + gap + 30 + 10;
474        let mut img = RgbImage::from_pixel(w, h, Rgb([255, 255, 255]));
475        for (x0, x1) in [(5u32, 35u32), (35 + gap, 65 + gap)] {
476            for x in x0..x1.min(w) {
477                for y in h / 4..(3 * h / 4) {
478                    img.put_pixel(x, y, Rgb([0, 0, 0]));
479                }
480            }
481        }
482        img
483    }
484
485    /// [`segment_words`] splits on a gap of `0.6 x line height`, which is the
486    /// property the rest of the browser table path inherits: an inter-word
487    /// space never splits, but a column gap wider than that does. Anything
488    /// narrower stays a single box — and a single box can only ever land in one
489    /// TableFormer cell, so this threshold is the floor on how tight a table's
490    /// columns may be before its cells merge.
491    #[test]
492    fn words_split_only_on_gaps_above_six_tenths_of_the_line_height() {
493        for h in [16u32, 24, 32, 40] {
494            let split_at = (1..=40u32)
495                .find(|&gap| super::segment_words(&line_with_gap(h, gap)).len() >= 2)
496                .expect("some gap splits");
497            let ratio = split_at as f32 / h as f32;
498            assert!(
499                (0.5..=0.65).contains(&ratio),
500                "h={h}: split at {split_at}px ({ratio:.2} x height)"
501            );
502            // Just below the threshold the two blocks are one word.
503            assert_eq!(
504                super::segment_words(&line_with_gap(h, split_at - 1)).len(),
505                1,
506                "h={h}: a narrower gap must not split"
507            );
508        }
509    }
510}