Skip to main content

steeldb/
ocr.rs

1//! OCR fallback for scanned/image PDFs with no text layer — the bundled PP-OCRv6 pipeline, natively in
2//! Rust. Two ONNX stages: DB **detection** (image → text-region probability map) and CRNN **recognition**
3//! (each cropped region → text via CTC). Pages are rasterized with poppler `pdftoppm`. Gated behind the
4//! `ocr` feature (implies `docs`/`onnx`).
5//!
6//! Detection post-processing is a pragmatic axis-aligned variant: binarize the prob map, take connected
7//! components' bounding boxes (documents are near-horizontal), unclip slightly, crop, recognize.
8
9use image::{imageops::FilterType, RgbImage};
10use ort::session::Session;
11use ort::value::Tensor;
12use std::path::Path;
13
14type Res<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
15
16const DET_MAX_SIDE: u32 = 960;
17const DET_THRESH: f32 = 0.3;
18const REC_H: u32 = 48;
19const REC_MAX_W: u32 = 640;
20// ImageNet normalization for detection; rec uses (x/255-0.5)/0.5.
21const MEAN: [f32; 3] = [0.485, 0.456, 0.406];
22const STD: [f32; 3] = [0.229, 0.224, 0.225];
23
24pub struct OcrEngine {
25    det: Session,
26    rec: Session,
27    dict: Vec<String>,
28}
29
30impl OcrEngine {
31    /// `dir` holds `pp-ocrv6_small_det.onnx`, `pp-ocrv6_small_rec.onnx`, `ppocrv6_dict.txt`.
32    pub fn load(dir: &Path) -> Res<OcrEngine> {
33        let det = Session::builder()?.commit_from_file(dir.join("pp-ocrv6_small_det.onnx"))?;
34        let rec = Session::builder()?.commit_from_file(dir.join("pp-ocrv6_small_rec.onnx"))?;
35        let dict = std::fs::read_to_string(dir.join("ppocrv6_dict.txt"))?.lines().map(|l| l.to_string()).collect();
36        Ok(OcrEngine { det, rec, dict })
37    }
38
39    /// Resolves via `crate::paths` (env `STEELDB_OCR_DIR` → cache → next-to-exe → `./models/ocr`); None
40    /// if the models aren't present.
41    pub fn default_engine() -> Option<OcrEngine> {
42        let dir = crate::paths::model_dir("ocr", "STEELDB_OCR_DIR", "pp-ocrv6_small_det.onnx")?;
43        OcrEngine::load(&dir).ok()
44    }
45
46    /// Load an image file and OCR it (convenience for callers without the `image` crate in scope).
47    pub fn ocr_path(&mut self, path: &Path) -> Res<String> {
48        let img = image::open(path)?.to_rgb8();
49        self.ocr_image(&img)
50    }
51
52    /// OCR one page image → recognized text (reading order: top-to-bottom, left-to-right).
53    pub fn ocr_image(&mut self, img: &RgbImage) -> Res<String> {
54        let boxes = self.detect(img)?;
55        let mut lines: Vec<(i32, i32, String)> = Vec::new();
56        for (x0, y0, x1, y1) in boxes {
57            let crop = crop_rgb(img, x0, y0, x1, y1);
58            if crop.width() < 4 || crop.height() < 4 {
59                continue;
60            }
61            let text = self.recognize(&crop)?;
62            if !text.trim().is_empty() {
63                lines.push((y0, x0, text));
64            }
65        }
66        // group into reading order: sort by y, then x; newline when the vertical gap is large.
67        lines.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
68        let mut out = String::new();
69        let mut last_y = i32::MIN;
70        for (y, _x, text) in lines {
71            if last_y != i32::MIN && (y - last_y).abs() > 12 {
72                out.push('\n');
73            } else if !out.is_empty() {
74                out.push(' ');
75            }
76            out.push_str(text.trim());
77            last_y = y;
78        }
79        Ok(out)
80    }
81
82    /// DB detection → axis-aligned region boxes in original-image coordinates.
83    fn detect(&mut self, img: &RgbImage) -> Res<Vec<(i32, i32, i32, i32)>> {
84        let (ow, oh) = (img.width(), img.height());
85        let scale = (DET_MAX_SIDE as f32 / ow.max(oh) as f32).min(1.0);
86        let rw = (((ow as f32 * scale) as u32).max(32) + 31) / 32 * 32;
87        let rh = (((oh as f32 * scale) as u32).max(32) + 31) / 32 * 32;
88        let resized = image::imageops::resize(img, rw, rh, FilterType::Triangle);
89
90        let (rhu, rwu) = (rh as usize, rw as usize);
91        let mut data = vec![0f32; 3 * rhu * rwu];
92        for y in 0..rh {
93            for x in 0..rw {
94                let p = resized.get_pixel(x, y).0;
95                for c in 0..3usize {
96                    data[c * rhu * rwu + (y as usize) * rwu + x as usize] = (p[c] as f32 / 255.0 - MEAN[c]) / STD[c];
97                }
98            }
99        }
100        let t = Tensor::from_array(([1usize, 3, rhu, rwu], data))?;
101        let out = self.det.run(ort::inputs!["x" => t])?;
102        let (shape, prob) = out["fetch_name_0"].try_extract_tensor::<f32>()?;
103        let (ph, pw) = (shape[shape.len() - 2] as u32, shape[shape.len() - 1] as u32);
104
105        // binarize → connected components → bounding boxes
106        let bin: Vec<bool> = prob.iter().map(|&v| v > DET_THRESH).collect();
107        let boxes = components_bboxes(&bin, pw, ph);
108        let (sx, sy) = (ow as f32 / pw as f32, oh as f32 / ph as f32);
109        Ok(boxes
110            .into_iter()
111            .filter(|&(x0, y0, x1, y1)| x1 - x0 >= 2 && y1 - y0 >= 2)
112            .map(|(x0, y0, x1, y1)| {
113                // scale to original + unclip (small margin proportional to line height)
114                let bh = (y1 - y0) as f32 * sy;
115                let mx = (0.15 * bh + 1.0) as i32;
116                (
117                    ((x0 as f32 * sx) as i32 - mx).max(0),
118                    ((y0 as f32 * sy) as i32 - mx).max(0),
119                    (((x1 as f32 * sx) as i32 + mx).min(ow as i32)),
120                    (((y1 as f32 * sy) as i32 + mx).min(oh as i32)),
121                )
122            })
123            .filter(|&(x0, y0, x1, y1)| (x1 - x0) as f32 > 3.0 && (y1 - y0) as f32 > 3.0)
124            .collect())
125    }
126
127    /// CRNN recognition of a single text-line crop → CTC-decoded string.
128    fn recognize(&mut self, crop: &RgbImage) -> Res<String> {
129        let ratio = crop.width() as f32 / crop.height() as f32;
130        let w = ((REC_H as f32 * ratio).round() as u32).clamp(16, REC_MAX_W);
131        let resized = image::imageops::resize(crop, w, REC_H, FilterType::Triangle);
132        let (hu, wu) = (REC_H as usize, w as usize);
133        let mut data = vec![0f32; 3 * hu * wu];
134        for y in 0..REC_H {
135            for x in 0..w {
136                let p = resized.get_pixel(x, y).0;
137                for c in 0..3usize {
138                    data[c * hu * wu + (y as usize) * wu + x as usize] = (p[c] as f32 / 255.0 - 0.5) / 0.5;
139                }
140            }
141        }
142        let t = Tensor::from_array(([1usize, 3, hu, wu], data))?;
143        // scope the session output so its &mut self.rec borrow releases before we read self.dict
144        let (steps, classes, logits) = {
145            let out = self.rec.run(ort::inputs!["x" => t])?;
146            let (shape, logits) = out["fetch_name_0"].try_extract_tensor::<f32>()?; // [1, T, C]
147            (shape[shape.len() - 2] as usize, shape[shape.len() - 1] as usize, logits.to_vec())
148        };
149        Ok(self.ctc_decode(&logits, steps, classes))
150    }
151
152    /// Greedy CTC: per-timestep argmax, collapse repeats, drop blanks; map index → dict char.
153    /// PaddleOCR convention: index 0 = blank; 1..=dict.len() → dict[i-1]; dict.len()+1 → space.
154    fn ctc_decode(&self, logits: &[f32], steps: usize, classes: usize) -> String {
155        let mut out = String::new();
156        let mut prev = usize::MAX;
157        for t in 0..steps {
158            let base = t * classes;
159            let mut arg = 0usize;
160            let mut best = f32::NEG_INFINITY;
161            for c in 0..classes {
162                let v = logits[base + c];
163                if v > best {
164                    best = v;
165                    arg = c;
166                }
167            }
168            if arg != prev && arg != 0 {
169                if arg <= self.dict.len() {
170                    out.push_str(&self.dict[arg - 1]);
171                } else {
172                    out.push(' ');
173                }
174            }
175            prev = arg;
176        }
177        out
178    }
179}
180
181fn crop_rgb(img: &RgbImage, x0: i32, y0: i32, x1: i32, y1: i32) -> RgbImage {
182    let x = x0.max(0) as u32;
183    let y = y0.max(0) as u32;
184    let w = (x1 - x0).max(1) as u32;
185    let h = (y1 - y0).max(1) as u32;
186    image::imageops::crop_imm(img, x, y, w.min(img.width().saturating_sub(x)), h.min(img.height().saturating_sub(y))).to_image()
187}
188
189/// 4-connected components of a binary mask → each component's (x0,y0,x1,y1) bounding box (exclusive max).
190fn components_bboxes(bin: &[bool], w: u32, h: u32) -> Vec<(i32, i32, i32, i32)> {
191    let (w, h) = (w as usize, h as usize);
192    let mut seen = vec![false; bin.len()];
193    let mut out = Vec::new();
194    let mut stack: Vec<(usize, usize)> = Vec::new();
195    for sy in 0..h {
196        for sx in 0..w {
197            let idx = sy * w + sx;
198            if !bin[idx] || seen[idx] {
199                continue;
200            }
201            let (mut x0, mut y0, mut x1, mut y1) = (sx, sy, sx, sy);
202            let mut count = 0usize;
203            seen[idx] = true;
204            stack.push((sx, sy));
205            while let Some((x, y)) = stack.pop() {
206                count += 1;
207                x0 = x0.min(x);
208                y0 = y0.min(y);
209                x1 = x1.max(x);
210                y1 = y1.max(y);
211                let push = |nx: usize, ny: usize, stack: &mut Vec<(usize, usize)>, seen: &mut [bool]| {
212                    let ni = ny * w + nx;
213                    if bin[ni] && !seen[ni] {
214                        seen[ni] = true;
215                        stack.push((nx, ny));
216                    }
217                };
218                if x > 0 {
219                    push(x - 1, y, &mut stack, &mut seen);
220                }
221                if x + 1 < w {
222                    push(x + 1, y, &mut stack, &mut seen);
223                }
224                if y > 0 {
225                    push(x, y - 1, &mut stack, &mut seen);
226                }
227                if y + 1 < h {
228                    push(x, y + 1, &mut stack, &mut seen);
229                }
230            }
231            if count >= 6 {
232                out.push((x0 as i32, y0 as i32, x1 as i32 + 1, y1 as i32 + 1));
233            }
234        }
235    }
236    out
237}
238
239/// OCR a PDF by rasterizing its pages (poppler `pdftoppm`, 150 dpi) and running the PP-OCRv6 pipeline.
240/// Returns concatenated page text, or None if rasterization/models are unavailable.
241pub fn ocr_pdf(path: &Path) -> Option<String> {
242    let mut engine = OcrEngine::default_engine()?;
243    let tmp = std::env::temp_dir().join(format!("steeldb_ocr_{}", std::process::id()));
244    let _ = std::fs::create_dir_all(&tmp);
245    let prefix = tmp.join("page");
246    let status = std::process::Command::new("pdftoppm")
247        .args(["-png", "-r", "150"])
248        .arg(path)
249        .arg(&prefix)
250        .status()
251        .ok()?;
252    if !status.success() {
253        return None;
254    }
255    let mut pages: Vec<std::path::PathBuf> = std::fs::read_dir(&tmp).ok()?.flatten().map(|e| e.path()).filter(|p| p.extension().map(|e| e == "png").unwrap_or(false)).collect();
256    pages.sort();
257    let mut out = String::new();
258    for page in &pages {
259        if let Ok(img) = image::open(page) {
260            if let Ok(text) = engine.ocr_image(&img.to_rgb8()) {
261                out.push_str(&text);
262                out.push('\n');
263            }
264        }
265    }
266    let _ = std::fs::remove_dir_all(&tmp);
267    let out = out.trim().to_string();
268    (!out.is_empty()).then_some(out)
269}