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/// [`prep_region_lines`] with the text detector's boxes as the line source
258/// (#570): inside each text region, the detected lines whose center falls in
259/// the region are the crops (whole, in the detector's reading order — see
260/// `det_boxes_inside`); a region the detector found nothing in falls back to
261/// the projection segmentation. `detected` is in image pixels (the detector ran
262/// on `img`).
263///
264/// Why: the projection profile cuts a region into full-width strips, so on a
265/// form — several fields on one baseline, separated by gaps the strip spans —
266/// one crop carries `FROM: J.Smith   DATE: 3/4/92   REF: 1138` and the
267/// recognizer glues or drops words (`OLDCOLDMENTHOLUIGHTS&ULTRA`). RapidOCR,
268/// docling's engine, recognizes the detector's own boxes, each a single text
269/// run with the detector's margin around it. On FUNSD this is the difference
270/// between 0.61 and 0.85 word recall (see the issue's measurements).
271pub fn prep_region_lines_det(
272    img: &RgbImage,
273    regions: &[crate::layout::Region],
274    scale: f32,
275    detected: &[crate::ocr_det::DetBox],
276) -> (Vec<LineBox>, Vec<PrepLine>) {
277    let (iw, ih) = img.dimensions();
278    let mut bboxes = Vec::new();
279    let mut lines = Vec::new();
280    for region in regions {
281        if !is_text_label(region.label) {
282            continue;
283        }
284        let l = (region.l * scale).max(0.0) as u32;
285        let t = (region.t * scale).max(0.0) as u32;
286        let r = ((region.r * scale).max(0.0) as u32).min(iw);
287        let b = ((region.b * scale).max(0.0) as u32).min(ih);
288        if r <= l || b <= t {
289            continue;
290        }
291        let inside = det_boxes_inside(detected, l, t, r, b, iw, ih);
292        if inside.is_empty() {
293            let (bb, pl) = prep_region_lines(img, std::slice::from_ref(region), scale);
294            bboxes.extend(bb);
295            lines.extend(pl);
296            continue;
297        }
298        for ((dl, dt, dr, db), _) in inside {
299            let line = imageops::crop_imm(img, dl, dt, dr - dl, db - dt).to_image();
300            let Some(pl) = prep_line(&line) else {
301                continue;
302            };
303            bboxes.push((
304                dl as f32 / scale,
305                dt as f32 / scale,
306                dr as f32 / scale,
307                db as f32 / scale,
308            ));
309            lines.push(pl);
310        }
311    }
312    (bboxes, lines)
313}
314
315/// Split a text line into word tokens by a vertical ink-projection profile:
316/// runs of ink columns separated by whitespace wider than ~0.6× the line
317/// height (an inter-word/-column gap, not an inter-character one). Returns tight
318/// `(l, 0, r, h)` boxes in line-crop pixels. The browser table path recognizes
319/// these individually so each word carries its own box for column matching —
320/// the native pipeline gets word boxes from the pdfium text layer instead.
321pub fn segment_words(line: &RgbImage) -> Vec<(u32, u32, u32, u32)> {
322    let (w, h) = line.dimensions();
323    if w == 0 || h == 0 {
324        return Vec::new();
325    }
326    let mean: f32 = line.pixels().map(luma).sum::<f32>() / (w * h) as f32;
327    let thresh = mean * 0.7;
328    let mut col_ink = vec![0u32; w as usize];
329    for y in 0..h {
330        for x in 0..w {
331            if luma(line.get_pixel(x, y)) < thresh {
332                col_ink[x as usize] += 1;
333            }
334        }
335    }
336    let min_gap = ((h as f32) * 0.6).max(4.0) as u32;
337    let mut words = Vec::new();
338    let mut start: Option<u32> = None;
339    let mut last_ink = 0u32;
340    let mut gap = 0u32;
341    for x in 0..w {
342        if col_ink[x as usize] > 0 {
343            if start.is_none() {
344                start = Some(x);
345            }
346            last_ink = x;
347            gap = 0;
348        } else if let Some(s) = start {
349            gap += 1;
350            if gap >= min_gap {
351                words.push((s, 0, last_ink + 1, h));
352                start = None;
353            }
354        }
355    }
356    if let Some(s) = start {
357        words.push((s, 0, last_ink + 1, h));
358    }
359    words
360}
361
362/// Gather word crops from a page's *table* regions (browser table path, #157
363/// stage 3): crop each table region, split it into lines, split each line into
364/// words ([`segment_words`]), prep each word for recognition, and keep the
365/// word's page-point bbox. Recognizing table interiors is what gives the cell
366/// matcher the word boxes it needs — the native pipeline reads those from
367/// pdfium's text layer on digital pages, and calls this on scanned ones
368/// (`OcrModel::ocr_table_words`, #173), same as the browser path.
369pub fn prep_table_words(
370    img: &RgbImage,
371    regions: &[crate::layout::Region],
372    scale: f32,
373) -> (Vec<LineBox>, Vec<PrepLine>) {
374    let (iw, ih) = img.dimensions();
375    let mut bboxes = Vec::new();
376    let mut lines = Vec::new();
377    for region in regions {
378        if !crate::assemble::is_table_like(region.label) {
379            continue;
380        }
381        let l = (region.l * scale).max(0.0) as u32;
382        let t = (region.t * scale).max(0.0) as u32;
383        let r = ((region.r * scale).max(0.0) as u32).min(iw);
384        let b = ((region.b * scale).max(0.0) as u32).min(ih);
385        if r <= l || b <= t {
386            continue;
387        }
388        let crop = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
389        for (lx, ly, rx, ry) in segment_lines(&crop) {
390            let line = imageops::crop_imm(&crop, lx, ly, rx - lx, ry - ly).to_image();
391            for (wx0, _, wx1, _) in segment_words(&line) {
392                let word = imageops::crop_imm(&line, wx0, 0, wx1 - wx0, ry - ly).to_image();
393                let Some(pl) = prep_line(&word) else {
394                    continue;
395                };
396                bboxes.push((
397                    (l + lx + wx0) as f32 / scale,
398                    (t + ly) as f32 / scale,
399                    (l + lx + wx1) as f32 / scale,
400                    (t + ry) as f32 / scale,
401                ));
402                lines.push(pl);
403            }
404        }
405    }
406    (bboxes, lines)
407}
408
409/// [`prep_table_words`] with the text detector's boxes as the word source
410/// (#570): inside each table region the detected boxes whose center falls in
411/// it are the crops (whole) — RapidOCR's cells, which is what
412/// docling's TableFormer matcher receives on a scanned table; a table the
413/// detector found nothing in falls back to the projection line/word split.
414pub fn prep_table_words_det(
415    img: &RgbImage,
416    regions: &[crate::layout::Region],
417    scale: f32,
418    detected: &[crate::ocr_det::DetBox],
419) -> (Vec<LineBox>, Vec<PrepLine>) {
420    let (iw, ih) = img.dimensions();
421    let mut bboxes = Vec::new();
422    let mut lines = Vec::new();
423    for region in regions {
424        if !crate::assemble::is_table_like(region.label) {
425            continue;
426        }
427        let l = (region.l * scale).max(0.0) as u32;
428        let t = (region.t * scale).max(0.0) as u32;
429        let r = ((region.r * scale).max(0.0) as u32).min(iw);
430        let b = ((region.b * scale).max(0.0) as u32).min(ih);
431        if r <= l || b <= t {
432            continue;
433        }
434        let inside: Vec<_> = det_boxes_inside(detected, l, t, r, b, iw, ih)
435            .into_iter()
436            .map(|(rect, _)| rect)
437            .collect();
438        if inside.is_empty() {
439            let (bb, pl) = prep_table_words(img, std::slice::from_ref(region), scale);
440            bboxes.extend(bb);
441            lines.extend(pl);
442            continue;
443        }
444        for (dl, dt, dr, db) in inside {
445            let word = imageops::crop_imm(img, dl, dt, dr - dl, db - dt).to_image();
446            let Some(pl) = prep_line(&word) else {
447                continue;
448            };
449            bboxes.push((
450                dl as f32 / scale,
451                dt as f32 / scale,
452                dr as f32 / scale,
453                db as f32 / scale,
454            ));
455            lines.push(pl);
456        }
457    }
458    (bboxes, lines)
459}
460
461/// The detected boxes whose center lies in the `l..r × t..b` image-pixel
462/// rectangle, in the detector's reading order. A box is taken whole (clipped
463/// to the image, not to the region): the layout box is often tight on the
464/// ink while the detector's unclip margin reaches past it, and cutting that
465/// margin off clips the first and last glyphs — RapidOCR recognizes the
466/// detector's box as drawn. The crop may thus poke a little outside the
467/// region; the cell keeps the box geometry and assembly assigns it by
468/// overlap, as for any cell.
469/// A detected line's box clamped to the image, in whole pixels.
470type PxRect = (u32, u32, u32, u32);
471
472fn det_boxes_inside(
473    detected: &[crate::ocr_det::DetBox],
474    l: u32,
475    t: u32,
476    r: u32,
477    b: u32,
478    iw: u32,
479    ih: u32,
480) -> Vec<(PxRect, &crate::ocr_det::DetBox)> {
481    detected
482        .iter()
483        .filter(|d| {
484            let (cx, cy) = ((d.l + d.r) / 2.0, (d.t + d.b) / 2.0);
485            cx >= l as f32 && cx < r as f32 && cy >= t as f32 && cy < b as f32
486        })
487        .filter_map(|d| {
488            let dl = d.l.max(0.0) as u32;
489            let dt = d.t.max(0.0) as u32;
490            let dr = (d.r.max(0.0) as u32).min(iw);
491            let db = (d.b.max(0.0) as u32).min(ih);
492            (dr > dl && db > dt).then_some(((dl, dt, dr, db), d))
493        })
494        .collect()
495}
496
497/// A text line's pixels before recognition prep: an oriented crop still to
498/// classify (#666), or a projection strip already prepared.
499pub enum LineSrc {
500    Crop(RgbImage),
501    Prepared(PrepLine),
502}
503
504/// [`prep_region_lines_det`] for `ocr_textline_orientation` (#666):
505/// RapidOCR's crops — each detected line warped upright out of its
506/// minimum-area rectangle and turned when tall ([`rotate_crop`]) — left
507/// unprepared so the orientation classifier can see them first. The boxes
508/// are the same axis-aligned ones, so reading order and geometry do not
509/// move; a region without detected lines falls back to the projection split
510/// as there.
511pub fn region_line_crops_det(
512    img: &RgbImage,
513    regions: &[crate::layout::Region],
514    scale: f32,
515    detected: &[crate::ocr_det::DetBox],
516) -> (Vec<LineBox>, Vec<LineSrc>) {
517    let (iw, ih) = img.dimensions();
518    let mut bboxes = Vec::new();
519    let mut lines = Vec::new();
520    for region in regions {
521        if !is_text_label(region.label) {
522            continue;
523        }
524        let l = (region.l * scale).max(0.0) as u32;
525        let t = (region.t * scale).max(0.0) as u32;
526        let r = ((region.r * scale).max(0.0) as u32).min(iw);
527        let b = ((region.b * scale).max(0.0) as u32).min(ih);
528        if r <= l || b <= t {
529            continue;
530        }
531        let inside = det_boxes_inside(detected, l, t, r, b, iw, ih);
532        if inside.is_empty() {
533            let (bb, pl) = prep_region_lines(img, std::slice::from_ref(region), scale);
534            bboxes.extend(bb);
535            lines.extend(pl.into_iter().map(LineSrc::Prepared));
536            continue;
537        }
538        for ((dl, dt, dr, db), d) in inside {
539            let Some(crop) = rotate_crop(img, &d.quad) else {
540                continue;
541            };
542            bboxes.push((
543                dl as f32 / scale,
544                dt as f32 / scale,
545                dr as f32 / scale,
546                db as f32 / scale,
547            ));
548            lines.push(LineSrc::Crop(crop));
549        }
550    }
551    (bboxes, lines)
552}
553
554/// RapidOCR's `get_rotate_crop_image` (#666): the quad `[tl, tr, br, bl]`
555/// warped onto an upright `w × h` rectangle — `w`, `h` the longer of each
556/// pair of opposite edges, truncated — with bicubic sampling (OpenCV's
557/// `INTER_CUBIC`, a = −0.75) and replicated borders; then, when the crop is
558/// at least 1.5× taller than wide, turned 90° counter-clockwise (numpy's
559/// `rot90`), so vertical text reaches the recognizer lying down. An
560/// axis-aligned integer quad samples exactly its pixels — the plain crop.
561/// `None` for a degenerate quad.
562pub fn rotate_crop(img: &RgbImage, quad: &[(f32, f32); 4]) -> Option<RgbImage> {
563    let dist = |a: (f32, f32), b: (f32, f32)| ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt();
564    let w = dist(quad[0], quad[1]).max(dist(quad[2], quad[3])) as u32;
565    let h = dist(quad[0], quad[3]).max(dist(quad[1], quad[2])) as u32;
566    if w == 0 || h == 0 || img.width() == 0 || img.height() == 0 {
567        return None;
568    }
569    let (wf, hf) = (w as f64, h as f64);
570    let map = perspective(
571        [(0.0, 0.0), (wf, 0.0), (wf, hf), (0.0, hf)],
572        quad.map(|(x, y)| (x as f64, y as f64)),
573    )?;
574    let mut out = RgbImage::new(w, h);
575    for (x, y, px) in out.enumerate_pixels_mut() {
576        let (x, y) = (x as f64, y as f64);
577        let d = map[6] * x + map[7] * y + 1.0;
578        if d.abs() < 1e-12 {
579            continue;
580        }
581        let sx = (map[0] * x + map[1] * y + map[2]) / d;
582        let sy = (map[3] * x + map[4] * y + map[5]) / d;
583        *px = bicubic_replicate(img, sx, sy);
584    }
585    Some(if h as f32 / w as f32 >= 1.5 {
586        imageops::rotate270(&out)
587    } else {
588        out
589    })
590}
591
592/// The homography taking each `from` point to its `to` point (OpenCV's
593/// `getPerspectiveTransform`): `[a, b, c, d, e, f, g, h]` with
594/// `x' = (ax + by + c) / (gx + hy + 1)`, `y' = (dx + ey + f) / (gx + hy + 1)`.
595/// `None` for a degenerate (collinear) quad.
596fn perspective(from: [(f64, f64); 4], to: [(f64, f64); 4]) -> Option<[f64; 8]> {
597    let mut m = [[0f64; 9]; 8];
598    for k in 0..4 {
599        let ((x, y), (u, v)) = (from[k], to[k]);
600        m[2 * k] = [x, y, 1.0, 0.0, 0.0, 0.0, -x * u, -y * u, u];
601        m[2 * k + 1] = [0.0, 0.0, 0.0, x, y, 1.0, -x * v, -y * v, v];
602    }
603    // Gaussian elimination with partial pivoting.
604    for col in 0..8 {
605        let pivot = (col..8).max_by(|&a, &b| m[a][col].abs().total_cmp(&m[b][col].abs()))?;
606        if m[pivot][col].abs() < 1e-12 {
607            return None;
608        }
609        m.swap(col, pivot);
610        let pivot_row = m[col];
611        for (row, r) in m.iter_mut().enumerate() {
612            let f = r[col] / pivot_row[col];
613            if row != col && f != 0.0 {
614                for (v, p) in r[col..].iter_mut().zip(&pivot_row[col..]) {
615                    *v -= f * p;
616                }
617            }
618        }
619    }
620    Some(std::array::from_fn(|i| m[i][8] / m[i][i]))
621}
622
623/// One bicubic sample at `(x, y)` (OpenCV `INTER_CUBIC`, a = −0.75),
624/// coordinates outside the image clamped to its edge (`BORDER_REPLICATE`).
625fn bicubic_replicate(img: &RgbImage, x: f64, y: f64) -> Rgb<u8> {
626    fn weights(t: f64) -> [f64; 4] {
627        const A: f64 = -0.75;
628        let w0 = ((A * (t + 1.0) - 5.0 * A) * (t + 1.0) + 8.0 * A) * (t + 1.0) - 4.0 * A;
629        let w1 = ((A + 2.0) * t - (A + 3.0)) * t * t + 1.0;
630        let w2 = ((A + 2.0) * (1.0 - t) - (A + 3.0)) * (1.0 - t) * (1.0 - t) + 1.0;
631        [w0, w1, w2, 1.0 - w0 - w1 - w2]
632    }
633    let (x0, y0) = (x.floor(), y.floor());
634    let (wx, wy) = (weights(x - x0), weights(y - y0));
635    let (mw, mh) = (img.width() as i64 - 1, img.height() as i64 - 1);
636    let mut acc = [0f64; 3];
637    for (j, wyj) in wy.iter().enumerate() {
638        let yy = (y0 as i64 - 1 + j as i64).clamp(0, mh) as u32;
639        for (i, wxi) in wx.iter().enumerate() {
640            let xx = (x0 as i64 - 1 + i as i64).clamp(0, mw) as u32;
641            let p = img.get_pixel(xx, yy);
642            let w = wxi * wyj;
643            for c in 0..3 {
644                acc[c] += w * p[c] as f64;
645            }
646        }
647    }
648    Rgb(acc.map(|v| v.round().clamp(0.0, 255.0) as u8))
649}
650
651/// Normalize an image to the scan polarity every stage assumes — dark ink
652/// on light paper (the segmentation threshold and the recognition model's
653/// training data both bake it in): a predominantly dark page (mean luma
654/// below mid-gray — a dark-mode screenshot, an inverted scan) is inverted.
655/// Browser-path helper; the native pipeline never calls it (its input is
656/// scanned paper, and the conformance baseline stays untouched).
657pub fn normalize_polarity(mut img: RgbImage) -> RgbImage {
658    let (w, h) = img.dimensions();
659    if w == 0 || h == 0 {
660        return img;
661    }
662    let mean: f32 = img.pixels().map(luma).sum::<f32>() / (w * h) as f32;
663    if mean < 128.0 {
664        for px in img.pixels_mut() {
665            px.0 = [255 - px.0[0], 255 - px.0[1], 255 - px.0[2]];
666        }
667    }
668    img
669}
670
671/// Whole-image line preparation for the browser OCR path (no layout model:
672/// the page itself is the single text region). Returns page-order prepared
673/// lines; callers that need geometry use [`segment_lines`] directly.
674pub fn prep_page_lines(img: &RgbImage) -> Vec<PrepLine> {
675    segment_lines(img)
676        .into_iter()
677        .filter_map(|(l, t, r, b)| {
678            let line = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
679            prep_line(&line)
680        })
681        .collect()
682}
683
684/// RapidOCR's `text_score` (docling's `RapidOcrOptions.text_score`, 0.5): a
685/// recognized line whose mean emitted-character confidence falls below it is
686/// dropped — a shaded bar, a halftone, a rule read as a run of letters
687/// (`TUANEURHENSTGOFUPHYCLTOSHSWOYT` for a form's grey `GEOGRAPHY` band)
688/// would otherwise land in the document as a heading. `DOCLING_RS_OCR_TEXT_SCORE`
689/// overrides it (`0` keeps every line, the pre-#570 behavior). Cached.
690pub fn text_score() -> f32 {
691    static SCORE: std::sync::OnceLock<f32> = std::sync::OnceLock::new();
692    *SCORE.get_or_init(|| {
693        text_score_from(docling_core::env::nonempty("DOCLING_RS_OCR_TEXT_SCORE").as_deref())
694    })
695}
696
697/// [`text_score`] for one raw `DOCLING_RS_OCR_TEXT_SCORE` value: a score in
698/// 0–1, else RapidOCR's 0.5.
699fn text_score_from(raw: Option<&str>) -> f32 {
700    raw.and_then(|r| r.trim().parse::<f32>().ok())
701        .filter(|s| s.is_finite() && (0.0..=1.0).contains(s))
702        .unwrap_or(0.5)
703}
704
705/// Deterministic recognition batching: page-order line indices grouped by
706/// exact width (equal widths share a run — bit-identical to one-at-a-time
707/// recognition, see `ocr.rs`), each group split into [`REC_BATCH`] chunks.
708pub fn width_batches(lines: &[PrepLine]) -> Vec<(usize, Vec<usize>)> {
709    let mut by_width: std::collections::BTreeMap<usize, Vec<usize>> =
710        std::collections::BTreeMap::new();
711    for (ix, pl) in lines.iter().enumerate() {
712        by_width.entry(pl.w).or_default().push(ix);
713    }
714    let mut out = Vec::new();
715    for (w, ixs) in by_width {
716        for chunk in ixs.chunks(REC_BATCH) {
717            out.push((w, chunk.to_vec()));
718        }
719    }
720    out
721}
722
723/// Pack one width-batch into the model's `(N, 3, H, W)` input buffer.
724pub fn batch_input(w: usize, chunk: &[usize], lines: &[PrepLine]) -> Vec<f32> {
725    let hw = REC_HEIGHT as usize * w;
726    let mut data = vec![0f32; chunk.len() * 3 * hw];
727    for (i, &ix) in chunk.iter().enumerate() {
728        data[i * 3 * hw..(i + 1) * 3 * hw].copy_from_slice(&lines[ix].data);
729    }
730    data
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736
737    /// A synthetic "page": white background with two black text bars.
738    fn page() -> RgbImage {
739        let mut img = RgbImage::from_pixel(200, 100, Rgb([255, 255, 255]));
740        for y in 20..30 {
741            for x in 10..190 {
742                img.put_pixel(x, y, Rgb([0, 0, 0]));
743            }
744        }
745        for y in 60..72 {
746            for x in 10..120 {
747                img.put_pixel(x, y, Rgb([0, 0, 0]));
748            }
749        }
750        img
751    }
752
753    #[test]
754    fn segments_and_preps_page_lines() {
755        let lines = prep_page_lines(&page());
756        assert_eq!(lines.len(), 2);
757        for pl in &lines {
758            assert_eq!(pl.data.len(), 3 * REC_HEIGHT as usize * pl.w);
759        }
760        // Different aspect ratios → different widths → separate batches.
761        let batches = width_batches(&lines);
762        assert_eq!(batches.len(), 2);
763        let (w0, chunk0) = &batches[0];
764        assert_eq!(
765            batch_input(*w0, chunk0, &lines).len(),
766            3 * REC_HEIGHT as usize * w0
767        );
768    }
769
770    /// #570: inside a text region the detector's boxes are the crops — one
771    /// per box, clipped to the region, in the detector's order — and a region
772    /// the detector saw nothing in falls back to the projection strips. The
773    /// two-bar page as one region: two detected boxes → two det-shaped lines;
774    /// the region's second bar split by the detector into two words → three
775    /// lines where the projection found two; no boxes → the projection's two.
776    #[test]
777    fn detector_boxes_are_the_region_lines_with_projection_fallback() {
778        let img = page();
779        let region = crate::layout::Region {
780            label: "text",
781            score: 0.9,
782            l: 0.0,
783            t: 0.0,
784            r: 200.0,
785            b: 100.0,
786        };
787        let bx = |l: f32, t: f32, r: f32, b: f32| crate::ocr_det::DetBox::rect(l, t, r, b, 0.9);
788        let det = [bx(8.0, 18.0, 192.0, 32.0), bx(8.0, 58.0, 122.0, 74.0)];
789        let (boxes, lines) = prep_region_lines_det(&img, std::slice::from_ref(&region), 1.0, &det);
790        assert_eq!(
791            boxes,
792            vec![(8.0, 18.0, 192.0, 32.0), (8.0, 58.0, 122.0, 74.0)]
793        );
794        assert_eq!(lines.len(), 2);
795        // A box reaching past the region is taken whole (clipped only to the
796        // image); one centered outside the region is not its line.
797        let narrow = crate::layout::Region {
798            r: 150.0,
799            ..region.clone()
800        };
801        let det = [bx(-10.0, 18.0, 250.0, 32.0), bx(300.0, 60.0, 400.0, 70.0)];
802        let (boxes, _) = prep_region_lines_det(&img, std::slice::from_ref(&narrow), 1.0, &det);
803        assert_eq!(boxes, vec![(0.0, 18.0, 200.0, 32.0)]);
804        // The detector split the second bar into two words.
805        let det = [
806            bx(8.0, 18.0, 192.0, 32.0),
807            bx(8.0, 58.0, 60.0, 74.0),
808            bx(70.0, 58.0, 122.0, 74.0),
809        ];
810        let (boxes, _) = prep_region_lines_det(&img, std::slice::from_ref(&region), 1.0, &det);
811        assert_eq!(boxes.len(), 3);
812        // No detected box in the region → the projection segmentation.
813        let (fallback, _) = prep_region_lines_det(&img, std::slice::from_ref(&region), 1.0, &[]);
814        let (projection, _) = prep_region_lines(&img, std::slice::from_ref(&region), 1.0);
815        assert_eq!(fallback, projection);
816        assert_eq!(projection.len(), 2);
817        // Table regions take the same boxes as their word crops.
818        let table = crate::layout::Region {
819            label: "table",
820            ..region.clone()
821        };
822        let det = [bx(8.0, 18.0, 92.0, 32.0), bx(108.0, 18.0, 192.0, 32.0)];
823        let (words, _) = prep_table_words_det(&img, std::slice::from_ref(&table), 1.0, &det);
824        assert_eq!(
825            words,
826            vec![(8.0, 18.0, 92.0, 32.0), (108.0, 18.0, 192.0, 32.0)]
827        );
828        let (fallback, _) = prep_table_words_det(&img, std::slice::from_ref(&table), 1.0, &[]);
829        assert_eq!(
830            fallback,
831            prep_table_words(&img, std::slice::from_ref(&table), 1.0).0
832        );
833    }
834
835    /// RapidOCR's `text_score` default, the pre-#570 "keep everything" with
836    /// `0`, and garbage values ignored.
837    #[test]
838    fn text_score_default_is_rapidocrs() {
839        assert_eq!(text_score_from(None), 0.5);
840        assert_eq!(text_score_from(Some("0")), 0.0);
841        assert_eq!(text_score_from(Some("0.7")), 0.7);
842        for garbage in ["1.5", "-0.1", "NaN", "high", ""] {
843            assert_eq!(text_score_from(Some(garbage)), 0.5, "{garbage:?}");
844        }
845    }
846
847    #[test]
848    fn dark_mode_pages_normalize_to_scan_polarity() {
849        // The same two-bar page, inverted (light text on dark) — the raw
850        // segmentation misfires (it thresholds the dark *background* as ink,
851        // so the "lines" it finds are the inter-bar gaps, not the bars);
852        // polarity normalization recovers the true structure.
853        let mut dark = page();
854        for px in dark.pixels_mut() {
855            px.0 = [255 - px.0[0], 255 - px.0[1], 255 - px.0[2]];
856        }
857        assert_ne!(segment_lines(&dark), segment_lines(&page()));
858        let fixed = normalize_polarity(dark);
859        assert_eq!(segment_lines(&fixed), segment_lines(&page()));
860        assert_eq!(prep_page_lines(&fixed).len(), 2);
861        // A light page passes through untouched.
862        let light = page();
863        assert_eq!(normalize_polarity(light.clone()), light);
864    }
865
866    #[test]
867    fn ctc_decode_collapses_repeats_and_blanks() {
868        // 3 classes: blank, "a", "b"; timesteps a a blank b b → "ab".
869        let chars = dict_chars("a\nb");
870        assert_eq!(chars.len(), 4); // blank, a, b, space
871        let probs = [
872            0.1, 0.8, 0.1, 0.0, // a
873            0.1, 0.8, 0.1, 0.0, // a (repeat collapses)
874            0.9, 0.05, 0.05, 0.0, // blank
875            0.1, 0.1, 0.8, 0.0, // b
876            0.1, 0.1, 0.8, 0.0, // b (repeat collapses)
877        ];
878        assert_eq!(decode_row(&chars, &probs, 4), "ab");
879    }
880}
881
882#[cfg(test)]
883mod word_segmentation {
884    use image::{Rgb, RgbImage};
885
886    /// A white line carrying two ink blocks separated by `gap` pixels.
887    fn line_with_gap(h: u32, gap: u32) -> RgbImage {
888        let w = 30 + gap + 30 + 10;
889        let mut img = RgbImage::from_pixel(w, h, Rgb([255, 255, 255]));
890        for (x0, x1) in [(5u32, 35u32), (35 + gap, 65 + gap)] {
891            for x in x0..x1.min(w) {
892                for y in h / 4..(3 * h / 4) {
893                    img.put_pixel(x, y, Rgb([0, 0, 0]));
894                }
895            }
896        }
897        img
898    }
899
900    /// [`segment_words`] splits on a gap of `0.6 x line height`, which is the
901    /// property the rest of the browser table path inherits: an inter-word
902    /// space never splits, but a column gap wider than that does. Anything
903    /// narrower stays a single box — and a single box can only ever land in one
904    /// TableFormer cell, so this threshold is the floor on how tight a table's
905    /// columns may be before its cells merge.
906    #[test]
907    fn words_split_only_on_gaps_above_six_tenths_of_the_line_height() {
908        for h in [16u32, 24, 32, 40] {
909            let split_at = (1..=40u32)
910                .find(|&gap| super::segment_words(&line_with_gap(h, gap)).len() >= 2)
911                .expect("some gap splits");
912            let ratio = split_at as f32 / h as f32;
913            assert!(
914                (0.5..=0.65).contains(&ratio),
915                "h={h}: split at {split_at}px ({ratio:.2} x height)"
916            );
917            // Just below the threshold the two blocks are one word.
918            assert_eq!(
919                super::segment_words(&line_with_gap(h, split_at - 1)).len(),
920                1,
921                "h={h}: a narrower gap must not split"
922            );
923        }
924    }
925
926    /// #666: an axis-aligned integer quad warps to exactly its pixels — the
927    /// plain crop, so upright lines prepare as before — and a crop at least
928    /// 1.5× taller than wide is turned 90° counter-clockwise.
929    #[test]
930    fn rotate_crop_is_the_plain_crop_for_an_upright_box_and_turns_tall_ones() {
931        let img = RgbImage::from_fn(40, 30, |x, y| Rgb([(x * 6) as u8, (y * 8) as u8, 77]));
932        let quad = [(5.0, 4.0), (25.0, 4.0), (25.0, 14.0), (5.0, 14.0)];
933        let warped = super::rotate_crop(&img, &quad).unwrap();
934        let plain = image::imageops::crop_imm(&img, 5, 4, 20, 10).to_image();
935        assert_eq!(warped, plain);
936        // 6 wide, 12 tall: turned to 12 × 6, the top row now the left column.
937        let tall = [(10.0, 2.0), (16.0, 2.0), (16.0, 14.0), (10.0, 14.0)];
938        let turned = super::rotate_crop(&img, &tall).unwrap();
939        assert_eq!(turned.dimensions(), (12, 6));
940        assert_eq!(turned.get_pixel(0, 5), img.get_pixel(10, 2));
941        assert!(super::rotate_crop(&img, &[(1.0, 1.0); 4]).is_none());
942    }
943
944    /// A rectangle drawn at 30° comes out upright: its warp is a uniform
945    /// dark bar, whatever the angle.
946    #[test]
947    fn rotate_crop_straightens_a_rotated_rectangle() {
948        let (cx, cy, a) = (50.0f32, 40.0f32, 30f32.to_radians());
949        let (hw, hh) = (30.0f32, 6.0f32);
950        let inside = |x: f32, y: f32| {
951            let (dx, dy) = (x - cx, y - cy);
952            let u = dx * a.cos() + dy * a.sin();
953            let v = -dx * a.sin() + dy * a.cos();
954            u.abs() <= hw && v.abs() <= hh
955        };
956        let img = RgbImage::from_fn(100, 80, |x, y| {
957            if inside(x as f32 + 0.5, y as f32 + 0.5) {
958                Rgb([0, 0, 0])
959            } else {
960                Rgb([255, 255, 255])
961            }
962        });
963        let corner = |su: f32, sv: f32| {
964            (
965                cx + su * hw * a.cos() - sv * hh * a.sin(),
966                cy + su * hw * a.sin() + sv * hh * a.cos(),
967            )
968        };
969        let quad = crate::ocr_det::order_corners([
970            corner(-1.0, -1.0),
971            corner(1.0, -1.0),
972            corner(1.0, 1.0),
973            corner(-1.0, 1.0),
974        ]);
975        let bar = super::rotate_crop(&img, &quad).unwrap();
976        // 60 × 12, less what float edge lengths truncate.
977        let (w, h) = bar.dimensions();
978        assert!(
979            (59..=60).contains(&w) && (11..=12).contains(&h),
980            "{w} × {h}"
981        );
982        // The interior (away from the anti-aliased edge) is all ink.
983        for y in 2..h - 2 {
984            for x in 2..w - 2 {
985                assert!(
986                    bar.get_pixel(x, y)[0] < 60,
987                    "({x}, {y}) {:?}",
988                    bar.get_pixel(x, y)
989                );
990            }
991        }
992    }
993}