1use 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
21const ROTATE_RATIO: f32 = 1.5;
23const MIN_SCORE: f32 = 0.5;
26
27const DEADLINE_PASSED: &str = "the OCR deadline passed";
29
30#[derive(Debug, Clone)]
32pub struct PageRun {
33 pub lines: Vec<Line>,
35 pub device: Device,
37 pub fallback: Option<String>,
39 pub partial_error: Option<String>,
41}
42
43fn passed(deadline: Option<Instant>) -> bool {
44 deadline.is_some_and(|d| Instant::now() >= d)
45}
46
47fn 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
73pub struct Engine {
75 det: Arc<Det>,
76 rec: Arc<Rec>,
77 gpu: Mutex<Option<Arc<GpuRunner>>>,
79 status: Mutex<DeviceStatus>,
80 backend: OnceLock<Option<GpuBackend>>,
82 math: Option<Math>,
84}
85
86impl Engine {
87 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 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 pub fn with_math(mut self, math: Math) -> Engine {
119 self.math = Some(math);
120 self
121 }
122
123 pub fn device_status(&self) -> DeviceStatus {
125 self.status
126 .lock()
127 .unwrap_or_else(|e| e.into_inner())
128 .clone()
129 }
130
131 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 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 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 pub fn run(&self, page: &Image) -> Result<Vec<Line>, String> {
178 Ok(self.run_until(page, None)?.lines)
179 }
180
181 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 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 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 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 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
352enum 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 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
373pub 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 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
407fn 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 }
421
422fn 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 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 #[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 #[test]
488 fn formulas_stop_at_the_deadline() {
489 let boxes = vec![region(); 3];
490 let mut n = 0;
491 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 #[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}