use std::path::Path;
use crate::ocr::OcrError;
use crate::pixmap::Pixmap;
use crate::text::Rect;
use super::manifest::{DET_MODEL, ModelManifest, default_models_dir};
use super::preprocess;
type OnnxPlan = tract_onnx::prelude::SimplePlan<
tract_onnx::prelude::TypedFact,
Box<dyn tract_onnx::prelude::TypedOp>,
tract_onnx::prelude::Graph<
tract_onnx::prelude::TypedFact,
Box<dyn tract_onnx::prelude::TypedOp>,
>,
>;
const PLAN_CACHE_CAPACITY: usize = 4;
#[derive(Debug, Clone)]
pub struct DetectorOptions {
pub binarize_threshold: f32,
pub min_box_score: f32,
pub min_box_size: u32,
pub unclip_ratio: f32,
}
impl Default for DetectorOptions {
fn default() -> Self {
Self {
binarize_threshold: 0.3,
min_box_score: 0.5,
min_box_size: 3,
unclip_ratio: 1.5,
}
}
}
pub struct TextDetector {
model_bytes: Vec<u8>,
options: DetectorOptions,
plans: Vec<((u32, u32), OnnxPlan)>,
}
impl TextDetector {
pub fn load(models_dir: &Path) -> Result<Self, OcrError> {
Self::load_with_options(models_dir, DetectorOptions::default())
}
pub fn load_default() -> Result<Self, OcrError> {
Self::load(&default_models_dir())
}
pub fn load_with_options(
models_dir: &Path,
options: DetectorOptions,
) -> Result<Self, OcrError> {
let manifest = ModelManifest::builtin()?;
let entry = manifest.entry(DET_MODEL)?;
let model_bytes = entry.load_verified(&entry.path_in(models_dir))?;
Ok(Self {
model_bytes,
options,
plans: Vec::new(),
})
}
pub fn detect(&mut self, page: &Pixmap) -> Result<Vec<Rect>, OcrError> {
use tract_onnx::prelude::*;
let (in_w, in_h) = preprocess::det_input_size(page.width, page.height);
let data = preprocess::det_tensor(page, in_w, in_h);
let tensor =
tract_ndarray::Array4::from_shape_vec((1, 3, in_h as usize, in_w as usize), data)
.map_err(|e| OcrError::RecognitionFailed(format!("input tensor shape error: {e}")))?
.into_tensor();
let plan = self.plan_for(in_w, in_h)?;
let result = plan
.run(tvec![tensor.into()])
.map_err(|e| OcrError::RecognitionFailed(format!("detector inference failed: {e}")))?;
let output = result[0]
.to_array_view::<f32>()
.map_err(|e| OcrError::RecognitionFailed(format!("detector output error: {e}")))?;
let expected: &[usize] = &[1, 1, in_h as usize, in_w as usize];
if output.shape() != expected {
return Err(OcrError::RecognitionFailed(format!(
"detector output shape {:?}, expected {expected:?}",
output.shape()
)));
}
let prob = output.as_slice().ok_or_else(|| {
OcrError::RecognitionFailed("detector output is not contiguous".into())
})?;
Ok(boxes_from_prob_map(
prob,
in_w,
in_h,
page.width,
page.height,
&self.options,
))
}
fn plan_for(&mut self, w: u32, h: u32) -> Result<&OnnxPlan, OcrError> {
use tract_onnx::prelude::*;
if let Some(pos) = self.plans.iter().position(|(k, _)| *k == (w, h)) {
let hit = self.plans.remove(pos);
self.plans.insert(0, hit);
} else {
let plan = tract_onnx::onnx()
.model_for_read(&mut &self.model_bytes[..])
.map_err(|e| OcrError::RecognitionFailed(format!("model parse failed: {e}")))?
.with_input_fact(
0,
InferenceFact::dt_shape(f32::datum_type(), tvec!(1, 3, h as usize, w as usize)),
)
.map_err(|e| OcrError::RecognitionFailed(format!("input fact failed: {e}")))?
.into_optimized()
.map_err(|e| OcrError::RecognitionFailed(format!("plan optimize failed: {e}")))?
.into_runnable()
.map_err(|e| OcrError::RecognitionFailed(format!("plan build failed: {e}")))?;
self.plans.insert(0, ((w, h), plan));
self.plans.truncate(PLAN_CACHE_CAPACITY);
}
Ok(&self.plans[0].1)
}
}
pub fn boxes_from_prob_map(
prob: &[f32],
map_w: u32,
map_h: u32,
page_w: u32,
page_h: u32,
options: &DetectorOptions,
) -> Vec<Rect> {
let w = map_w as usize;
let h = map_h as usize;
debug_assert_eq!(prob.len(), w * h);
if prob.len() != w * h || w == 0 || h == 0 {
return Vec::new();
}
let mut visited = vec![false; w * h];
let mut boxes = Vec::new();
let mut stack = Vec::new();
for start in 0..w * h {
if visited[start] || prob[start] <= options.binarize_threshold {
continue;
}
let (mut min_x, mut max_x) = (start % w, start % w);
let (mut min_y, mut max_y) = (start / w, start / w);
let mut sum = 0.0f64;
let mut count = 0u64;
visited[start] = true;
stack.push(start);
while let Some(idx) = stack.pop() {
let (x, y) = (idx % w, idx / w);
min_x = min_x.min(x);
max_x = max_x.max(x);
min_y = min_y.min(y);
max_y = max_y.max(y);
sum += f64::from(prob[idx]);
count += 1;
let mut push = |n: usize| {
if !visited[n] && prob[n] > options.binarize_threshold {
visited[n] = true;
stack.push(n);
}
};
if x > 0 {
push(idx - 1);
}
if x + 1 < w {
push(idx + 1);
}
if y > 0 {
push(idx - w);
}
if y + 1 < h {
push(idx + w);
}
}
let mean = sum / count as f64;
if (mean as f32) < options.min_box_score {
continue;
}
let box_w = (max_x - min_x + 1) as u32;
let box_h = (max_y - min_y + 1) as u32;
if box_w < options.min_box_size || box_h < options.min_box_size {
continue;
}
let area = box_w as f32 * box_h as f32;
let perimeter = 2.0 * (box_w as f32 + box_h as f32);
let offset = (area * options.unclip_ratio / perimeter).round() as usize;
let x0 = min_x.saturating_sub(offset);
let y0 = min_y.saturating_sub(offset);
let x1 = (max_x + offset).min(w - 1);
let y1 = (max_y + offset).min(h - 1);
let scale_x = |v: usize| (v as u64 * u64::from(page_w) / w as u64) as u32;
let scale_y = |v: usize| (v as u64 * u64::from(page_h) / h as u64) as u32;
let px0 = scale_x(x0);
let py0 = scale_y(y0);
let px1 = scale_x(x1 + 1).min(page_w);
let py1 = scale_y(y1 + 1).min(page_h);
boxes.push(Rect {
x: px0,
y: py0,
width: (px1 - px0).max(1),
height: (py1 - py0).max(1),
});
}
boxes.sort_by_key(|r| (r.y, r.x));
boxes
}
#[cfg(test)]
mod tests {
use super::*;
fn synthetic_map(
map_w: usize,
map_h: usize,
blobs: &[(usize, usize, usize, usize, f32)],
) -> Vec<f32> {
let mut map = vec![0.0f32; map_w * map_h];
for &(x0, x1, y0, y1, p) in blobs {
for y in y0..y1 {
for x in x0..x1 {
map[y * map_w + x] = p;
}
}
}
map
}
#[test]
fn empty_map_yields_no_boxes() {
let map = synthetic_map(64, 32, &[]);
let boxes = boxes_from_prob_map(&map, 64, 32, 640, 320, &DetectorOptions::default());
assert!(boxes.is_empty());
}
#[test]
fn two_blobs_become_two_sorted_page_boxes() {
let map = synthetic_map(100, 50, &[(60, 90, 30, 40, 0.9), (10, 30, 5, 15, 0.9)]);
let opts = DetectorOptions::default();
let boxes = boxes_from_prob_map(&map, 100, 50, 1000, 500, &opts);
assert_eq!(boxes.len(), 2);
assert!(boxes[0].y < boxes[1].y);
let core0 = (100, 300, 50, 150); assert!(boxes[0].x < core0.0 && boxes[0].y < core0.2);
assert!(boxes[0].x + boxes[0].width > core0.1);
assert!(boxes[0].y + boxes[0].height > core0.3);
for b in &boxes {
assert!(b.x + b.width <= 1000 && b.y + b.height <= 500);
}
}
#[test]
fn low_score_blob_is_dropped() {
let map = synthetic_map(64, 64, &[(10, 30, 10, 20, 0.4)]);
let boxes = boxes_from_prob_map(&map, 64, 64, 64, 64, &DetectorOptions::default());
assert!(boxes.is_empty());
}
#[test]
fn tiny_blob_is_dropped_as_noise() {
let map = synthetic_map(64, 64, &[(10, 12, 10, 12, 0.9)]);
let boxes = boxes_from_prob_map(&map, 64, 64, 64, 64, &DetectorOptions::default());
assert!(boxes.is_empty());
}
#[test]
fn touching_pixels_merge_into_one_component() {
let map = synthetic_map(32, 32, &[(5, 15, 5, 8, 0.9), (5, 8, 8, 15, 0.9)]);
let boxes = boxes_from_prob_map(&map, 32, 32, 32, 32, &DetectorOptions::default());
assert_eq!(boxes.len(), 1);
}
#[test]
fn diagonal_blobs_stay_separate() {
let map = synthetic_map(32, 32, &[(4, 10, 4, 10, 0.9), (10, 16, 10, 16, 0.9)]);
let boxes = boxes_from_prob_map(&map, 32, 32, 32, 32, &DetectorOptions::default());
assert_eq!(boxes.len(), 2);
}
fn detector_if_models_present() -> Option<TextDetector> {
let dir = default_models_dir();
let manifest = ModelManifest::builtin().unwrap();
let entry = manifest.entry(DET_MODEL).unwrap();
if !entry.path_in(&dir).exists() {
return None; }
Some(TextDetector::load(&dir).expect("pinned weights must verify and load"))
}
#[test]
fn blank_page_detects_nothing() {
let Some(mut det) = detector_if_models_present() else {
return;
};
let page = Pixmap::white(400, 300);
let boxes = det.detect(&page).expect("inference on a blank page");
assert!(boxes.is_empty(), "blank page must yield no text boxes");
}
#[test]
fn text_like_page_runs_and_boxes_stay_in_bounds() {
let Some(mut det) = detector_if_models_present() else {
return;
};
let mut page = Pixmap::white(640, 480);
for row in 0..8 {
let y0 = 40 + row * 50;
for seg in 0..12 {
let x0 = 30 + seg * 48;
for y in y0..y0 + 14 {
for x in x0..x0 + 34 {
let i = (y * 640 + x) * 4;
page.data[i..i + 3].fill(20);
}
}
}
}
let boxes = det.detect(&page).expect("inference on a synthetic page");
for b in &boxes {
assert!(b.x + b.width <= 640 && b.y + b.height <= 480);
}
let small = Pixmap::white(200, 100);
det.detect(&small).expect("second input size");
det.detect(&page).expect("cached plan reuse");
}
}