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