use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Instant;
use pagesift_gpu::{GpuBackend, is_oom};
use pagesift_infer::{Tensor, backend};
use crate::det::Det;
use crate::device::{Device, DeviceStatus};
use crate::gpu_run::{GpuRunner, GpuStats, GpuWait};
use crate::layout::Region;
use crate::math::{Math, inside_any};
use crate::rec::Rec;
use crate::{Image, Line, Quad};
const ROTATE_RATIO: f32 = 1.5;
const MIN_SCORE: f32 = 0.5;
const DEADLINE_PASSED: &str = "the OCR deadline passed";
#[derive(Debug, Clone)]
pub struct PageRun {
pub lines: Vec<Line>,
pub device: Device,
pub fallback: Option<String>,
pub partial_error: Option<String>,
}
fn passed(deadline: Option<Instant>) -> bool {
deadline.is_some_and(|d| Instant::now() >= d)
}
fn recognize_formulas(
formulas: &[Region],
deadline: Option<Instant>,
mut recognize: impl FnMut(&Region) -> Result<Option<Line>, String>,
) -> (Vec<Line>, Option<String>) {
let mut lines = Vec::new();
let mut error = None;
for r in formulas {
if passed(deadline) {
error.get_or_insert_with(|| DEADLINE_PASSED.to_owned());
break;
}
let one = catch_unwind(AssertUnwindSafe(|| recognize(r)))
.unwrap_or_else(|_| Err("the formula model panicked".into()));
match one {
Ok(line) => lines.extend(line),
Err(e) => {
error.get_or_insert_with(|| format!("a formula was skipped: {e}"));
}
}
}
(lines, error)
}
pub struct Engine {
det: Arc<Det>,
rec: Arc<Rec>,
gpu: Mutex<Option<Arc<GpuRunner>>>,
status: Mutex<DeviceStatus>,
backend: OnceLock<Option<GpuBackend>>,
math: Option<Math>,
}
impl Engine {
pub fn load(det_dir: &Path, rec_dir: &Path) -> Result<Engine, String> {
Engine::load_with(det_dir, rec_dir, Device::Cpu)
}
pub fn load_with(det_dir: &Path, rec_dir: &Path, device: Device) -> Result<Engine, String> {
let det = Arc::new(Det::load(det_dir)?);
let rec = Arc::new(Rec::load(rec_dir)?);
let (gpu, status) = match device {
Device::Cpu => (None, DeviceStatus::cpu()),
Device::Gpu { index } => match GpuRunner::start(index, det.clone(), rec.clone()) {
Ok(g) => {
let info = g.worker().info().clone();
(Some(g), DeviceStatus::on_gpu(index, info))
}
Err(why) => (None, DeviceStatus::fallback(index, why)),
},
};
Ok(Engine {
det,
rec,
gpu: Mutex::new(gpu.map(Arc::new)),
status: Mutex::new(status),
backend: OnceLock::new(),
math: None,
})
}
pub fn with_math(mut self, math: Math) -> Engine {
self.math = Some(math);
self
}
pub fn device_status(&self) -> DeviceStatus {
self.status
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
pub fn gpu_stats(&self) -> Option<GpuStats> {
let mut s = self.gpu_active()?.stats();
if let Some(Some(b)) = self.backend.get() {
s.offloaded_ops = b.offloaded();
}
Some(s)
}
pub fn with_gpu_ops<R>(&self, f: impl FnOnce() -> R) -> R {
let b = self.backend.get_or_init(|| {
let g = self.gpu_active()?;
match GpuBackend::new(g.worker().clone()) {
Ok(b) => Some(b),
Err(e) => {
self.note_fallback(&format!("GPU calibration failed: {e}"), false);
None
}
}
});
match (b, self.gpu_active()) {
(Some(b), Some(_)) => backend::scoped(b, f),
_ => f(),
}
}
fn gpu_active(&self) -> Option<Arc<GpuRunner>> {
self.gpu.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
fn note_fallback(&self, why: &str, permanent: bool) {
let mut s = self.status.lock().unwrap_or_else(|e| e.into_inner());
s.fallback = Some(why.to_owned());
if permanent {
s.actual = Device::Cpu;
drop(s);
let gpu = self.gpu.lock().unwrap_or_else(|e| e.into_inner()).take();
drop(gpu);
}
}
pub fn run(&self, page: &Image) -> Result<Vec<Line>, String> {
Ok(self.run_until(page, None)?.lines)
}
pub fn run_until(&self, page: &Image, deadline: Option<Instant>) -> Result<PageRun, String> {
let mut partial_error = None;
let formulas = match &self.math {
Some(m) => m.formula_boxes(page).unwrap_or_else(|e| {
partial_error = Some(format!("layout detection failed: {e}"));
Vec::new()
}),
None => Vec::new(),
};
if passed(deadline) {
return Err(DEADLINE_PASSED.into());
}
let (mut lines, device, fallback) = self.run_text(page, &formulas, deadline)?;
crate::script::drop_stray_east_asian(&mut lines);
if let Some(m) = &self.math {
let (found, error) = recognize_formulas(&formulas, deadline, |r| m.recognize(page, r));
lines.extend(found);
partial_error = partial_error.or(error);
}
Ok(PageRun {
lines,
device,
fallback,
partial_error,
})
}
fn run_text(
&self,
page: &Image,
formulas: &[Region],
deadline: Option<Instant>,
) -> Result<(Vec<Line>, Device, Option<String>), String> {
if let Some(g) = self.gpu_active() {
let mut oom = None;
match self.run_gpu(&g, page, formulas, deadline, &mut oom) {
Ok(lines) => return Ok((lines, self.device_status().requested, oom)),
Err(RunError::Input(e)) => return Err(e),
Err(RunError::Gpu(e)) => self.note_fallback(&e, true),
}
}
let lines = self.run_cpu(page, formulas, deadline)?;
Ok((lines, Device::Cpu, self.device_status().fallback))
}
fn run_cpu(
&self,
page: &Image,
formulas: &[Region],
deadline: Option<Instant>,
) -> Result<Vec<Line>, String> {
let mut out = Vec::new();
for quad in self.det.detect(page)? {
if passed(deadline) {
return Err(DEADLINE_PASSED.into());
}
if inside_any(&quad, formulas) {
continue;
}
let crop = crop(page, &quad);
if crop.width == 0 || crop.height == 0 {
continue;
}
let (text, score) = self.rec.recognize(&crop)?;
if score >= MIN_SCORE && !text.trim().is_empty() {
out.push(Line {
quad,
text,
score,
formula: false,
});
}
}
Ok(out)
}
fn run_gpu(
&self,
g: &GpuRunner,
page: &Image,
formulas: &[Region],
deadline: Option<Instant>,
oom: &mut Option<String>,
) -> Result<Vec<Line>, RunError> {
let _page = g.page();
let x = self.det.preprocess(page).map_err(RunError::Input)?;
let (prob, w, h) = match g.prob_map(x, deadline) {
Ok(r) => r,
Err(GpuWait::Failed(e)) if is_oom(&e) => {
oom.get_or_insert(e);
self.det.prob_map(page).map_err(RunError::Input)?
}
Err(e) => return Err(e.into()),
};
let quads: Vec<Quad> = self
.det
.boxes_from_prob(&prob, w, h, page.width, page.height)
.into_iter()
.map(|(q, _)| q)
.filter(|q| !inside_any(q, formulas))
.collect();
drop(prob);
let inputs = self.crop_inputs(page, &quads).map_err(RunError::Input)?;
let (kept, xs): (Vec<usize>, Vec<Tensor>) = inputs.into_iter().unzip();
let digests = g.recognize_many(xs, deadline)?;
let mut out = Vec::new();
for (i, r) in kept.into_iter().zip(digests) {
let quad = quads[i];
let (text, score) = match r {
Ok(rows) => self.rec.decode(&rows),
Err(e) if is_oom(&e) => {
oom.get_or_insert(e);
self.rec
.recognize(&crop(page, &quad))
.map_err(RunError::Input)?
}
Err(e) => return Err(RunError::Gpu(e)),
};
if score >= MIN_SCORE && !text.trim().is_empty() {
out.push(Line {
quad,
text,
score,
formula: false,
});
}
}
Ok(out)
}
fn crop_inputs(&self, page: &Image, quads: &[Quad]) -> Result<Vec<(usize, Tensor)>, String> {
let threads = std::thread::available_parallelism()
.map_or(1, |n| n.get())
.min(quads.len().max(1));
let chunk = quads.len().div_ceil(threads).max(1);
let one = |base: usize, part: &[Quad]| -> Result<Vec<(usize, Tensor)>, String> {
let mut v = Vec::with_capacity(part.len());
for (i, q) in part.iter().enumerate() {
let c = crop(page, q);
if c.width > 0 && c.height > 0 {
v.push((base + i, self.rec.preprocess(&c)?));
}
}
Ok(v)
};
std::thread::scope(|s| {
let hs: Vec<_> = quads
.chunks(chunk)
.enumerate()
.map(|(i, part)| s.spawn(move || one(i * chunk, part)))
.collect();
let mut out = Vec::with_capacity(quads.len());
for h in hs {
out.extend(
h.join()
.map_err(|_| "a crop thread panicked".to_owned())??,
);
}
Ok(out)
})
}
}
enum RunError {
Input(String),
Gpu(String),
}
impl From<GpuWait> for RunError {
fn from(w: GpuWait) -> RunError {
match w {
GpuWait::Failed(e) => RunError::Gpu(e),
GpuWait::Stalled => RunError::Gpu("the GPU stopped answering".into()),
GpuWait::Deadline => RunError::Input(DEADLINE_PASSED.into()),
}
}
}
fn dist(a: (f32, f32), b: (f32, f32)) -> f32 {
((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
}
pub fn crop(img: &Image, q: &Quad) -> Image {
let [tl, tr, br, bl] = *q;
let w = dist(tl, tr).max(dist(bl, br)).round().max(1.0) as usize;
let h = dist(tl, bl).max(dist(tr, br)).round().max(1.0) as usize;
let c = img.channels;
let mut data = vec![0u8; w * h * c];
for y in 0..h {
let v = (y as f32 + 0.5) / h as f32;
for x in 0..w {
let u = (x as f32 + 0.5) / w as f32;
let top = (tl.0 + (tr.0 - tl.0) * u, tl.1 + (tr.1 - tl.1) * u);
let bot = (bl.0 + (br.0 - bl.0) * u, bl.1 + (br.1 - bl.1) * u);
let sx = top.0 + (bot.0 - top.0) * v - 0.5;
let sy = top.1 + (bot.1 - top.1) * v - 0.5;
for ch in 0..c {
data[(y * w + x) * c + ch] = bilinear(img, sx, sy, ch);
}
}
}
let out = Image {
width: w,
height: h,
channels: c,
data,
};
if h as f32 >= w as f32 * ROTATE_RATIO {
rotate90(&out)
} else {
out
}
}
fn bilinear(img: &Image, x: f32, y: f32, c: usize) -> u8 {
let xm = img.width as f32 - 1.0;
let ym = img.height as f32 - 1.0;
let x = x.clamp(0.0, xm);
let y = y.clamp(0.0, ym);
let (x0, y0) = (x.floor() as usize, y.floor() as usize);
let (x1, y1) = ((x0 + 1).min(img.width - 1), (y0 + 1).min(img.height - 1));
let (fx, fy) = (x - x0 as f32, y - y0 as f32);
let p = |x: usize, y: usize| f32::from(img.at(x, y, c));
let top = p(x0, y0) * (1.0 - fx) + p(x1, y0) * fx;
let bot = p(x0, y1) * (1.0 - fx) + p(x1, y1) * fx;
(top * (1.0 - fy) + bot * fy).round().clamp(0.0, 255.0) as u8 }
fn rotate90(img: &Image) -> Image {
let (w, h, c) = (img.width, img.height, img.channels);
let mut data = vec![0u8; w * h * c];
for y in 0..h {
for x in 0..w {
let (nx, ny) = (y, w - 1 - x);
for ch in 0..c {
data[(ny * h + nx) * c + ch] = img.data[(y * w + x) * c + ch];
}
}
}
Image {
width: h,
height: w,
channels: c,
data,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn region() -> Region {
Region {
x0: 0.0,
y0: 0.0,
x1: 1.0,
y1: 1.0,
label: String::new(),
kind: String::new(),
score: 1.0,
}
}
fn line(text: &str) -> Line {
Line {
quad: [(0.0, 0.0); 4],
text: text.into(),
score: 1.0,
formula: true,
}
}
#[test]
fn a_failing_formula_is_skipped() {
let boxes = vec![region(); 4];
let mut n = 0;
let (lines, error) = recognize_formulas(&boxes, None, |_| {
n += 1;
match n {
1 => Err("NaN in logits".into()),
2 => panic!("模型 panic"),
3 => Ok(None),
_ => Ok(Some(line("$$x$$"))),
}
});
assert_eq!(lines.len(), 1);
assert!(error.unwrap().contains("NaN in logits"));
}
#[test]
fn formulas_stop_at_the_deadline() {
let boxes = vec![region(); 3];
let mut n = 0;
let (lines, error) = recognize_formulas(&boxes, Some(Instant::now()), |_| {
n += 1;
Ok(Some(line("$$x$$")))
});
assert_eq!(n, 0);
assert!(lines.is_empty());
assert_eq!(error.as_deref(), Some(DEADLINE_PASSED));
}
#[test]
fn a_permanent_fallback_releases_the_gpu() {
let Some(root) = crate::models::cache_root() else {
return;
};
let (det, rec) = (
root.join("PP-OCRv6_small_det_safetensors"),
root.join("PP-OCRv6_small_rec_safetensors"),
);
let Ok(e) = Engine::load_with(&det, &rec, Device::Gpu { index: 0 }) else {
eprintln!("缓存里没有 small 权重,跳过");
return;
};
if e.gpu_active().is_none() {
eprintln!("没有能用的 N 卡,跳过");
return;
}
e.note_fallback("device lost", true);
assert!(e.gpu_active().is_none());
assert!(e.gpu_stats().is_none());
let s = e.device_status();
assert_eq!(s.actual, Device::Cpu);
assert_eq!(s.fallback.as_deref(), Some("device lost"));
let page = Image {
width: 64,
height: 64,
channels: 1,
data: vec![255; 64 * 64],
};
let r = e.run_until(&page, None).unwrap();
assert_eq!(r.device, Device::Cpu);
assert_eq!(r.fallback.as_deref(), Some("device lost"));
}
}