1use 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;
20const 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 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 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 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 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 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 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 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 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 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 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>()?; (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 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
189fn 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
239pub 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}