Skip to main content

pagesift_ocr/
pipeline.rs

1//! 整页流程:检测文字行 → 逐行裁出拉正 → 识别。裁行照 PaddleOCR 的 `get_rotate_crop_image`:
2//! 四边形按两组对边的长度定输出宽高,透视(这里用双线性四边形映射)拉成矩形;
3//! 高是宽的 1.5 倍以上时当作竖排,转 90°。
4
5use std::panic::{AssertUnwindSafe, catch_unwind};
6use std::path::Path;
7use std::sync::{Arc, Mutex, OnceLock};
8use std::time::Instant;
9
10use pagesift_gpu::{GpuBackend, is_oom};
11use pagesift_infer::{Tensor, backend};
12
13use crate::det::Det;
14use crate::device::{Device, DeviceStatus};
15use crate::gpu_run::{GpuRunner, GpuStats, GpuWait};
16use crate::layout::Region;
17use crate::math::{Math, inside_any};
18use crate::rec::Rec;
19use crate::{Image, Line, Quad};
20
21/// [协议] PaddleOCR `get_rotate_crop_image`:裁出的图高宽比 ≥ 1.5 时转 90°
22const ROTATE_RATIO: f32 = 1.5;
23/// [策略] 识别置信度低于它的行丢掉(PaddleOCR 的 `text_rec_score_thresh` 缺省 0.0,
24/// 这里取 0.5:扫描页上的污点、装饰线常被识别成一两个低置信度的字)
25const MIN_SCORE: f32 = 0.5;
26
27/// [`Engine::run_until`] 的错误:请求的截止时间到了。
28const DEADLINE_PASSED: &str = "the OCR deadline passed";
29
30/// [`Engine::run_until`] 的结果。
31#[derive(Debug, Clone)]
32pub struct PageRun {
33    /// 按检测给的顺序(从上到下、从左到右)的各行;有公式模型时公式排在最后。
34    pub lines: Vec<Line>,
35    /// 这一页的文字实际在哪算的。
36    pub device: Device,
37    /// 想用 GPU 却(全部或部分)在 CPU 上算了的原因:GPU 已永久关闭,或这一页有页、行显存不够。
38    pub fallback: Option<String>,
39    /// 部分失败(版面检测、个别公式)的原因:`lines` 仍然可用,只是少了公式。
40    pub partial_error: Option<String>,
41}
42
43fn passed(deadline: Option<Instant>) -> bool {
44    deadline.is_some_and(|d| Instant::now() >= d)
45}
46
47/// 逐个认公式框。认错的框跳过(原因留第一条),文字已经识别完了,不能因为一个公式作废;
48/// 到截止时间就不再往下认。
49fn recognize_formulas(
50    formulas: &[Region],
51    deadline: Option<Instant>,
52    mut recognize: impl FnMut(&Region) -> Result<Option<Line>, String>,
53) -> (Vec<Line>, Option<String>) {
54    let mut lines = Vec::new();
55    let mut error = None;
56    for r in formulas {
57        if passed(deadline) {
58            error.get_or_insert_with(|| DEADLINE_PASSED.to_owned());
59            break;
60        }
61        let one = catch_unwind(AssertUnwindSafe(|| recognize(r)))
62            .unwrap_or_else(|_| Err("the formula model panicked".into()));
63        match one {
64            Ok(line) => lines.extend(line),
65            Err(e) => {
66                error.get_or_insert_with(|| format!("a formula was skipped: {e}"));
67            }
68        }
69    }
70    (lines, error)
71}
72
73/// 检测加识别。
74pub struct Engine {
75    det: Arc<Det>,
76    rec: Arc<Rec>,
77    /// GPU 打不开或出过无法恢复的错时为 None(丢掉工人,显存还给系统)。
78    gpu: Mutex<Option<Arc<GpuRunner>>>,
79    status: Mutex<DeviceStatus>,
80    /// 透明分流的后端,第一次要用时才校准。
81    backend: OnceLock<Option<GpuBackend>>,
82    /// 行间公式(可选,模型大、每个公式要几秒)。
83    math: Option<Math>,
84}
85
86impl Engine {
87    /// 在 CPU 上跑,同 `load_with(det_dir, rec_dir, Device::Cpu)`。
88    pub fn load(det_dir: &Path, rec_dir: &Path) -> Result<Engine, String> {
89        Engine::load_with(det_dir, rec_dir, Device::Cpu)
90    }
91
92    /// 载入模型并打开 `device`。GPU 打不开(没有驱动、PTX 编译失败、`PAGESIFT_GPU=off` 等)不算
93    /// 错误:退回 CPU,原因见 [`Engine::device_status`]。只有模型文件的问题才返回 `Err`。
94    pub fn load_with(det_dir: &Path, rec_dir: &Path, device: Device) -> Result<Engine, String> {
95        let det = Arc::new(Det::load(det_dir)?);
96        let rec = Arc::new(Rec::load(rec_dir)?);
97        let (gpu, status) = match device {
98            Device::Cpu => (None, DeviceStatus::cpu()),
99            Device::Gpu { index } => match GpuRunner::start(index, det.clone(), rec.clone()) {
100                Ok(g) => {
101                    let info = g.worker().info().clone();
102                    (Some(g), DeviceStatus::on_gpu(index, info))
103                }
104                Err(why) => (None, DeviceStatus::fallback(index, why)),
105            },
106        };
107        Ok(Engine {
108            det,
109            rec,
110            gpu: Mutex::new(gpu.map(Arc::new)),
111            status: Mutex::new(status),
112            backend: OnceLock::new(),
113            math: None,
114        })
115    }
116
117    /// 同时识别行间公式,输出 `$$…$$`。
118    pub fn with_math(mut self, math: Math) -> Engine {
119        self.math = Some(math);
120        self
121    }
122
123    /// 实际用的设备、是否退回了 CPU、退回原因。
124    pub fn device_status(&self) -> DeviceStatus {
125        self.status
126            .lock()
127            .unwrap_or_else(|e| e.into_inner())
128            .clone()
129    }
130
131    /// GPU 的显存和批次统计;没在用 GPU 时为 None。
132    pub fn gpu_stats(&self) -> Option<GpuStats> {
133        let mut s = self.gpu_active()?.stats();
134        if let Some(Some(b)) = self.backend.get() {
135            s.offloaded_ops = b.offloaded();
136        }
137        Some(s)
138    }
139
140    /// 在 `f` 里,当前线程直接调 `pagesift_infer::ops` 和 `nn` 层的模型(公式、版面)按实测开销把大算子
141    /// 分流到 GPU。没有 GPU 时就是直接调 `f`。第一次调用会校准几十毫秒。
142    /// 流水线目前没有用它:真实的公式模型上结果和 CPU 有差异(见 `Engine::run`)。
143    pub fn with_gpu_ops<R>(&self, f: impl FnOnce() -> R) -> R {
144        let b = self.backend.get_or_init(|| {
145            let g = self.gpu_active()?;
146            match GpuBackend::new(g.worker().clone()) {
147                Ok(b) => Some(b),
148                Err(e) => {
149                    self.note_fallback(&format!("GPU calibration failed: {e}"), false);
150                    None
151                }
152            }
153        });
154        match (b, self.gpu_active()) {
155            (Some(b), Some(_)) => backend::scoped(b, f),
156            _ => f(),
157        }
158    }
159
160    fn gpu_active(&self) -> Option<Arc<GpuRunner>> {
161        self.gpu.lock().unwrap_or_else(|e| e.into_inner()).clone()
162    }
163
164    /// 记下退回的原因;`permanent` 时之后都不再用 GPU,工人(和它占的显存)等最后一页用完就放掉。
165    fn note_fallback(&self, why: &str, permanent: bool) {
166        let mut s = self.status.lock().unwrap_or_else(|e| e.into_inner());
167        s.fallback = Some(why.to_owned());
168        if permanent {
169            s.actual = Device::Cpu;
170            drop(s);
171            let gpu = self.gpu.lock().unwrap_or_else(|e| e.into_inner()).take();
172            drop(gpu);
173        }
174    }
175
176    /// 识别整页图,按检测给的顺序(从上到下、从左到右)返回各行;有公式模型时公式排在最后。
177    pub fn run(&self, page: &Image) -> Result<Vec<Line>, String> {
178        Ok(self.run_until(page, None)?.lines)
179    }
180
181    /// 同 [`Engine::run`],另外报告这一页用的设备和退回、部分失败的情况,到 `deadline` 就尽快返回。
182    /// 版面检测失败时退回没有公式的文字 OCR,个别公式失败时跳过(原因见 [`PageRun::partial_error`])。
183    /// 版面和公式模型留在 CPU:B5 上经 [`Engine::with_gpu_ops`] 分流后更慢、分数更低,原因还没查明。
184    pub fn run_until(&self, page: &Image, deadline: Option<Instant>) -> Result<PageRun, String> {
185        let mut partial_error = None;
186        let formulas = match &self.math {
187            Some(m) => m.formula_boxes(page).unwrap_or_else(|e| {
188                partial_error = Some(format!("layout detection failed: {e}"));
189                Vec::new()
190            }),
191            None => Vec::new(),
192        };
193        if passed(deadline) {
194            return Err(DEADLINE_PASSED.into());
195        }
196        let (mut lines, device, fallback) = self.run_text(page, &formulas, deadline)?;
197        crate::script::drop_stray_east_asian(&mut lines);
198        if let Some(m) = &self.math {
199            let (found, error) = recognize_formulas(&formulas, deadline, |r| m.recognize(page, r));
200            lines.extend(found);
201            partial_error = partial_error.or(error);
202        }
203        Ok(PageRun {
204            lines,
205            device,
206            fallback,
207            partial_error,
208        })
209    }
210
211    /// 文字行:优先 GPU,GPU 出了显存不足以外的错就从此退回 CPU。还返回实际用的设备和退回的原因。
212    fn run_text(
213        &self,
214        page: &Image,
215        formulas: &[Region],
216        deadline: Option<Instant>,
217    ) -> Result<(Vec<Line>, Device, Option<String>), String> {
218        if let Some(g) = self.gpu_active() {
219            let mut oom = None;
220            match self.run_gpu(&g, page, formulas, deadline, &mut oom) {
221                Ok(lines) => return Ok((lines, self.device_status().requested, oom)),
222                // 模型输入本身的问题(空图等)换设备也没用,别的都是 GPU 的问题
223                Err(RunError::Input(e)) => return Err(e),
224                Err(RunError::Gpu(e)) => self.note_fallback(&e, true),
225            }
226        }
227        let lines = self.run_cpu(page, formulas, deadline)?;
228        Ok((lines, Device::Cpu, self.device_status().fallback))
229    }
230
231    fn run_cpu(
232        &self,
233        page: &Image,
234        formulas: &[Region],
235        deadline: Option<Instant>,
236    ) -> Result<Vec<Line>, String> {
237        let mut out = Vec::new();
238        for quad in self.det.detect(page)? {
239            if passed(deadline) {
240                return Err(DEADLINE_PASSED.into());
241            }
242            if inside_any(&quad, formulas) {
243                continue;
244            }
245            let crop = crop(page, &quad);
246            if crop.width == 0 || crop.height == 0 {
247                continue;
248            }
249            let (text, score) = self.rec.recognize(&crop)?;
250            if score >= MIN_SCORE && !text.trim().is_empty() {
251                out.push(Line {
252                    quad,
253                    text,
254                    score,
255                    formula: false,
256                });
257            }
258        }
259        Ok(out)
260    }
261
262    /// 流程和 [`Engine::run_cpu`] 一样,只是检测网络在工人上跑、各行拼批。显存不够的页、行单独退回
263    /// CPU,原因记在 `oom`:只算这一页的事,不粘在引擎上。
264    fn run_gpu(
265        &self,
266        g: &GpuRunner,
267        page: &Image,
268        formulas: &[Region],
269        deadline: Option<Instant>,
270        oom: &mut Option<String>,
271    ) -> Result<Vec<Line>, RunError> {
272        let _page = g.page();
273        let x = self.det.preprocess(page).map_err(RunError::Input)?;
274        let (prob, w, h) = match g.prob_map(x, deadline) {
275            Ok(r) => r,
276            Err(GpuWait::Failed(e)) if is_oom(&e) => {
277                oom.get_or_insert(e);
278                self.det.prob_map(page).map_err(RunError::Input)?
279            }
280            Err(e) => return Err(e.into()),
281        };
282        let quads: Vec<Quad> = self
283            .det
284            .boxes_from_prob(&prob, w, h, page.width, page.height)
285            .into_iter()
286            .map(|(q, _)| q)
287            .filter(|q| !inside_any(q, formulas))
288            .collect();
289        drop(prob);
290        let inputs = self.crop_inputs(page, &quads).map_err(RunError::Input)?;
291        let (kept, xs): (Vec<usize>, Vec<Tensor>) = inputs.into_iter().unzip();
292        let digests = g.recognize_many(xs, deadline)?;
293        let mut out = Vec::new();
294        for (i, r) in kept.into_iter().zip(digests) {
295            let quad = quads[i];
296            let (text, score) = match r {
297                Ok(rows) => self.rec.decode(&rows),
298                Err(e) if is_oom(&e) => {
299                    oom.get_or_insert(e);
300                    self.rec
301                        .recognize(&crop(page, &quad))
302                        .map_err(RunError::Input)?
303                }
304                Err(e) => return Err(RunError::Gpu(e)),
305            };
306            if score >= MIN_SCORE && !text.trim().is_empty() {
307                out.push(Line {
308                    quad,
309                    text,
310                    score,
311                    formula: false,
312                });
313            }
314        }
315        Ok(out)
316    }
317
318    /// 裁出各行并预处理成网络输入,多线程。返回 (四边形下标, 输入),跳过空的裁剪。
319    fn crop_inputs(&self, page: &Image, quads: &[Quad]) -> Result<Vec<(usize, Tensor)>, String> {
320        let threads = std::thread::available_parallelism()
321            .map_or(1, |n| n.get())
322            .min(quads.len().max(1));
323        let chunk = quads.len().div_ceil(threads).max(1);
324        let one = |base: usize, part: &[Quad]| -> Result<Vec<(usize, Tensor)>, String> {
325            let mut v = Vec::with_capacity(part.len());
326            for (i, q) in part.iter().enumerate() {
327                let c = crop(page, q);
328                if c.width > 0 && c.height > 0 {
329                    v.push((base + i, self.rec.preprocess(&c)?));
330                }
331            }
332            Ok(v)
333        };
334        std::thread::scope(|s| {
335            let hs: Vec<_> = quads
336                .chunks(chunk)
337                .enumerate()
338                .map(|(i, part)| s.spawn(move || one(i * chunk, part)))
339                .collect();
340            let mut out = Vec::with_capacity(quads.len());
341            for h in hs {
342                out.extend(
343                    h.join()
344                        .map_err(|_| "a crop thread panicked".to_owned())??,
345                );
346            }
347            Ok(out)
348        })
349    }
350}
351
352/// 整页跑 GPU 时的错误:分清是输入的问题还是 GPU 的问题。
353enum RunError {
354    Input(String),
355    Gpu(String),
356}
357
358impl From<GpuWait> for RunError {
359    fn from(w: GpuWait) -> RunError {
360        match w {
361            GpuWait::Failed(e) => RunError::Gpu(e),
362            GpuWait::Stalled => RunError::Gpu("the GPU stopped answering".into()),
363            // 截止时间到了不是 GPU 的问题:只放弃这一页,GPU 上别的页还在正常跑
364            GpuWait::Deadline => RunError::Input(DEADLINE_PASSED.into()),
365        }
366    }
367}
368
369fn dist(a: (f32, f32), b: (f32, f32)) -> f32 {
370    ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
371}
372
373/// 按四边形裁出一行并拉正;输出与输入同样的通道数。
374pub fn crop(img: &Image, q: &Quad) -> Image {
375    let [tl, tr, br, bl] = *q;
376    let w = dist(tl, tr).max(dist(bl, br)).round().max(1.0) as usize;
377    let h = dist(tl, bl).max(dist(tr, br)).round().max(1.0) as usize;
378    let c = img.channels;
379    let mut data = vec![0u8; w * h * c];
380    for y in 0..h {
381        let v = (y as f32 + 0.5) / h as f32;
382        for x in 0..w {
383            let u = (x as f32 + 0.5) / w as f32;
384            // 双线性四边形映射:上边、下边各按 u 插值,再按 v 插值
385            let top = (tl.0 + (tr.0 - tl.0) * u, tl.1 + (tr.1 - tl.1) * u);
386            let bot = (bl.0 + (br.0 - bl.0) * u, bl.1 + (br.1 - bl.1) * u);
387            let sx = top.0 + (bot.0 - top.0) * v - 0.5;
388            let sy = top.1 + (bot.1 - top.1) * v - 0.5;
389            for ch in 0..c {
390                data[(y * w + x) * c + ch] = bilinear(img, sx, sy, ch);
391            }
392        }
393    }
394    let out = Image {
395        width: w,
396        height: h,
397        channels: c,
398        data,
399    };
400    if h as f32 >= w as f32 * ROTATE_RATIO {
401        rotate90(&out)
402    } else {
403        out
404    }
405}
406
407/// 双线性取样,图外按边缘像素延伸(OpenCV 的 BORDER_REPLICATE)。
408fn bilinear(img: &Image, x: f32, y: f32, c: usize) -> u8 {
409    let xm = img.width as f32 - 1.0;
410    let ym = img.height as f32 - 1.0;
411    let x = x.clamp(0.0, xm);
412    let y = y.clamp(0.0, ym);
413    let (x0, y0) = (x.floor() as usize, y.floor() as usize);
414    let (x1, y1) = ((x0 + 1).min(img.width - 1), (y0 + 1).min(img.height - 1));
415    let (fx, fy) = (x - x0 as f32, y - y0 as f32);
416    let p = |x: usize, y: usize| f32::from(img.at(x, y, c));
417    let top = p(x0, y0) * (1.0 - fx) + p(x1, y0) * fx;
418    let bot = p(x0, y1) * (1.0 - fx) + p(x1, y1) * fx;
419    (top * (1.0 - fy) + bot * fy).round().clamp(0.0, 255.0) as u8 // [协议] 8 位样本取值 0–255
420}
421
422/// 逆时针转 90°(PaddleOCR 用 `np.rot90`)。
423fn rotate90(img: &Image) -> Image {
424    let (w, h, c) = (img.width, img.height, img.channels);
425    let mut data = vec![0u8; w * h * c];
426    for y in 0..h {
427        for x in 0..w {
428            // 原 (x, y) → 新 (y, w − 1 − x),新图宽 h、高 w
429            let (nx, ny) = (y, w - 1 - x);
430            for ch in 0..c {
431                data[(ny * h + nx) * c + ch] = img.data[(y * w + x) * c + ch];
432            }
433        }
434    }
435    Image {
436        width: h,
437        height: w,
438        channels: c,
439        data,
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    fn region() -> Region {
448        Region {
449            x0: 0.0,
450            y0: 0.0,
451            x1: 1.0,
452            y1: 1.0,
453            label: String::new(),
454            kind: String::new(),
455            score: 1.0,
456        }
457    }
458
459    fn line(text: &str) -> Line {
460        Line {
461            quad: [(0.0, 0.0); 4],
462            text: text.into(),
463            score: 1.0,
464            formula: true,
465        }
466    }
467
468    /// 一个公式出错(或 panic)只跳过它,别的公式照常,原因留第一条。
469    #[test]
470    fn a_failing_formula_is_skipped() {
471        let boxes = vec![region(); 4];
472        let mut n = 0;
473        let (lines, error) = recognize_formulas(&boxes, None, |_| {
474            n += 1;
475            match n {
476                1 => Err("NaN in logits".into()),
477                2 => panic!("模型 panic"),
478                3 => Ok(None),
479                _ => Ok(Some(line("$$x$$"))),
480            }
481        });
482        assert_eq!(lines.len(), 1);
483        assert!(error.unwrap().contains("NaN in logits"));
484    }
485
486    /// 到了截止时间不再认下一个公式,已经认出的留着。
487    #[test]
488    fn formulas_stop_at_the_deadline() {
489        let boxes = vec![region(); 3];
490        let mut n = 0;
491        // wall-clock: 截止时间已经过了,什么机器上都一样
492        let (lines, error) = recognize_formulas(&boxes, Some(Instant::now()), |_| {
493            n += 1;
494            Ok(Some(line("$$x$$")))
495        });
496        assert_eq!(n, 0);
497        assert!(lines.is_empty());
498        assert_eq!(error.as_deref(), Some(DEADLINE_PASSED));
499    }
500
501    /// 永久退回后放掉工人(显存还给系统),之后走 CPU 并如实报告。缓存里没有权重或没有显卡时跳过。
502    #[test]
503    fn a_permanent_fallback_releases_the_gpu() {
504        let Some(root) = crate::models::cache_root() else {
505            return;
506        };
507        let (det, rec) = (
508            root.join("PP-OCRv6_small_det_safetensors"),
509            root.join("PP-OCRv6_small_rec_safetensors"),
510        );
511        let Ok(e) = Engine::load_with(&det, &rec, Device::Gpu { index: 0 }) else {
512            eprintln!("缓存里没有 small 权重,跳过");
513            return;
514        };
515        if e.gpu_active().is_none() {
516            eprintln!("没有能用的 N 卡,跳过");
517            return;
518        }
519        e.note_fallback("device lost", true);
520        assert!(e.gpu_active().is_none());
521        assert!(e.gpu_stats().is_none());
522        let s = e.device_status();
523        assert_eq!(s.actual, Device::Cpu);
524        assert_eq!(s.fallback.as_deref(), Some("device lost"));
525        let page = Image {
526            width: 64,
527            height: 64,
528            channels: 1,
529            data: vec![255; 64 * 64],
530        };
531        let r = e.run_until(&page, None).unwrap();
532        assert_eq!(r.device, Device::Cpu);
533        assert_eq!(r.fallback.as_deref(), Some("device lost"));
534    }
535}