use ab_glyph::{Font, FontVec, ScaleFont};
use crate::ocr_onnx::manifest::{METRICS_FONT, ModelManifest, default_models_dir};
use crate::pixmap::Pixmap;
use crate::text::Rect;
const FONT_PX: f32 = 40.0;
const MARGIN_X: u32 = 60;
const FIRST_BASELINE: f32 = 90.0;
const LINE_STEP: f32 = 90.0;
const PAGE_W: u32 = 1000;
pub struct CorpusPage {
pub pixmap: Pixmap,
pub lines: Vec<(Rect, String)>,
}
pub const CYRILLIC_LINES: [&str; 4] = [
"Съешь же ещё этих мягких",
"французских булок, да выпей чаю.",
"Широкая электрификация южных",
"губерний даст мощный толчок.",
];
pub const LATIN_LINES: [&str; 4] = [
"The quick brown fox jumps",
"over the lazy dog. 0123456789",
"Sphinx of black quartz,",
"judge my vow.",
];
pub fn font_if_present() -> Option<FontVec> {
let dir = default_models_dir();
let manifest = ModelManifest::builtin().unwrap();
let entry = manifest.entry(METRICS_FONT).unwrap();
let path = entry.path_in(&dir);
if !path.exists() {
return None;
}
let bytes = entry.load_verified(&path).expect("pinned font must verify");
Some(FontVec::try_from_vec(bytes).expect("pinned font must parse"))
}
pub fn render_page(font: &FontVec, lines: &[&str]) -> CorpusPage {
let scaled = font.as_scaled(FONT_PX);
let page_h = (FIRST_BASELINE + LINE_STEP * lines.len() as f32) as u32;
let mut page = Pixmap::white(PAGE_W, page_h);
let mut truth = Vec::with_capacity(lines.len());
for (li, text) in lines.iter().enumerate() {
let baseline = FIRST_BASELINE + LINE_STEP * li as f32;
let mut pen_x = MARGIN_X as f32;
let mut prev_glyph = None;
let (mut min_x, mut min_y) = (u32::MAX, u32::MAX);
let (mut max_x, mut max_y) = (0u32, 0u32);
for c in text.chars() {
let id = scaled.glyph_id(c);
if let Some(prev) = prev_glyph {
pen_x += scaled.kern(prev, id);
}
prev_glyph = Some(id);
let glyph = id.with_scale_and_position(FONT_PX, ab_glyph::point(pen_x, baseline));
pen_x += scaled.h_advance(id);
let Some(outlined) = font.outline_glyph(glyph) else {
continue; };
let bounds = outlined.px_bounds();
outlined.draw(|dx, dy, cov| {
let x = bounds.min.x as i32 + dx as i32;
let y = bounds.min.y as i32 + dy as i32;
if x < 0 || y < 0 || x >= PAGE_W as i32 || y >= page_h as i32 {
return;
}
let v = 255 - (cov.clamp(0.0, 1.0) * 255.0).round() as u8;
let (x, y) = (x as u32, y as u32);
let i = ((y * PAGE_W + x) * 4) as usize;
let dark = page.data[i].min(v);
page.data[i..i + 3].fill(dark);
if v < 255 {
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
}
});
}
assert!(min_x < u32::MAX, "corpus line must leave ink: {text}");
truth.push((
Rect {
x: min_x,
y: min_y,
width: max_x - min_x + 1,
height: max_y - min_y + 1,
},
(*text).to_string(),
));
}
CorpusPage {
pixmap: page,
lines: truth,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ocr::{OcrBackend, OcrOptions};
use crate::ocr_onnx::metrics::{cer, mean_line_iou, wer};
use crate::ocr_onnx::pipeline::NeuralOcrBackend;
use crate::text::TextZoneKind;
#[test]
fn corpus_pages_render_deterministically() {
let Some(font) = font_if_present() else {
return; };
let a = render_page(&font, &CYRILLIC_LINES);
let b = render_page(&font, &CYRILLIC_LINES);
assert_eq!(a.pixmap.data, b.pixmap.data);
assert_eq!(a.lines.len(), CYRILLIC_LINES.len());
for pair in a.lines.windows(2) {
assert!(pair[0].0.y < pair[1].0.y);
}
for (rect, _) in &a.lines {
assert!(rect.x >= MARGIN_X - 5 && rect.height > 20 && rect.height < 60);
}
}
fn evaluate(backend: &NeuralOcrBackend, page: &CorpusPage) -> (f64, f64, f64) {
let layer = backend
.recognize(&page.pixmap, &OcrOptions::default())
.expect("corpus page must recognize");
let reference: Vec<&str> = page.lines.iter().map(|(_, t)| t.as_str()).collect();
let reference = reference.join("\n");
let truth_rects: Vec<_> = page.lines.iter().map(|(r, _)| r.clone()).collect();
let detected: Vec<_> = layer.zones[0]
.children
.iter()
.filter(|z| z.kind == TextZoneKind::Line)
.map(|z| z.rect.clone())
.collect();
(
cer(&reference, &layer.text),
wer(&reference, &layer.text),
mean_line_iou(&truth_rects, &detected),
)
}
#[test]
fn corpus_baseline_holds_for_pinned_models() {
let Some(font) = font_if_present() else {
return; };
let dir = default_models_dir();
let backend = match NeuralOcrBackend::load(&dir) {
Ok(b) => b,
Err(_) => return, };
let cyr = evaluate(&backend, &render_page(&font, &CYRILLIC_LINES));
let lat = evaluate(&backend, &render_page(&font, &LATIN_LINES));
eprintln!("corpus metrics cyr (cer, wer, iou): {cyr:?}");
eprintln!("corpus metrics lat (cer, wer, iou): {lat:?}");
let (cer_cyr, wer_cyr, iou_cyr) = cyr;
let (cer_lat, wer_lat, iou_lat) = lat;
assert!(cer_cyr <= 0.05, "Cyrillic CER regressed: {cer_cyr}");
assert!(wer_cyr <= 0.10, "Cyrillic WER regressed: {wer_cyr}");
assert!(iou_cyr >= 0.80, "Cyrillic line IoU regressed: {iou_cyr}");
assert!(cer_lat <= 0.05, "Latin CER regressed: {cer_lat}");
assert!(wer_lat <= 0.10, "Latin WER regressed: {wer_lat}");
assert!(iou_lat >= 0.70, "Latin line IoU regressed: {iou_lat}");
}
}