use image::{DynamicImage, GrayImage, Luma};
use ocr::core::config::OcrConfig;
use ocr::core::engine::OcrEngine;
use ocr::core::image::OcrImage;
const SUPPORTED_LETTERS: &[char] = &[
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S',
'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
];
const SUPPORTED_DIGITS: &[char] = &['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
const SUPPORTED_SPECIALS: &[char] = &['.', '-', ':', '/', ',', '\'', '!', '?'];
fn glyph_rows(ch: char) -> [&'static str; 7] {
match ch {
'A' => [
"01110", "10001", "10001", "11111", "10001", "10001", "10001",
],
'B' => [
"11110", "10001", "10001", "11110", "10001", "10001", "11110",
],
'C' => [
"01111", "10000", "10000", "10000", "10000", "10000", "01111",
],
'D' => [
"11110", "10001", "10001", "10001", "10001", "10001", "11110",
],
'E' => [
"11111", "10000", "10000", "11110", "10000", "10000", "11111",
],
'F' => [
"11111", "10000", "10000", "11110", "10000", "10000", "10000",
],
'G' => [
"01111", "10000", "10000", "10111", "10001", "10001", "01111",
],
'H' => [
"10001", "10001", "10001", "11111", "10001", "10001", "10001",
],
'I' => [
"11111", "00100", "00100", "00100", "00100", "00100", "11111",
],
'J' => [
"00111", "00010", "00010", "00010", "00010", "10010", "01100",
],
'K' => [
"10001", "10010", "10100", "11000", "10100", "10010", "10001",
],
'L' => [
"10000", "10000", "10000", "10000", "10000", "10000", "11111",
],
'M' => [
"10001", "11011", "10101", "10101", "10001", "10001", "10001",
],
'N' => [
"10001", "11001", "10101", "10011", "10001", "10001", "10001",
],
'O' => [
"01110", "10001", "10001", "10001", "10001", "10001", "01110",
],
'P' => [
"11110", "10001", "10001", "11110", "10000", "10000", "10000",
],
'Q' => [
"01110", "10001", "10001", "10001", "10101", "10010", "01101",
],
'R' => [
"11110", "10001", "10001", "11110", "10100", "10010", "10001",
],
'S' => [
"01111", "10000", "10000", "01110", "00001", "00001", "11110",
],
'T' => [
"11111", "00100", "00100", "00100", "00100", "00100", "00100",
],
'U' => [
"10001", "10001", "10001", "10001", "10001", "10001", "01110",
],
'V' => [
"10001", "10001", "10001", "10001", "10001", "01010", "00100",
],
'W' => [
"10001", "10001", "10001", "10101", "10101", "10101", "01010",
],
'X' => [
"10001", "10001", "01010", "00100", "01010", "10001", "10001",
],
'Y' => [
"10001", "10001", "01010", "00100", "00100", "00100", "00100",
],
'Z' => [
"11111", "00001", "00010", "00100", "01000", "10000", "11111",
],
'0' => [
"01110", "10001", "10011", "10101", "11001", "10001", "01110",
],
'1' => [
"00100", "01100", "00100", "00100", "00100", "00100", "01110",
],
'2' => [
"01110", "10001", "00001", "00010", "00100", "01000", "11111",
],
'3' => [
"11110", "00001", "00001", "01110", "00001", "00001", "11110",
],
'4' => [
"00010", "00110", "01010", "10010", "11111", "00010", "00010",
],
'5' => [
"11111", "10000", "11110", "00001", "00001", "10001", "01110",
],
'6' => [
"01110", "10000", "10000", "11110", "10001", "10001", "01110",
],
'7' => [
"11111", "00001", "00010", "00100", "01000", "01000", "01000",
],
'8' => [
"01110", "10001", "10001", "01110", "10001", "10001", "01110",
],
'9' => [
"01110", "10001", "10001", "01111", "00001", "00001", "01110",
],
'.' => [
"00000", "00000", "00000", "00000", "00000", "00100", "00100",
],
'-' => [
"00000", "00000", "00000", "11111", "00000", "00000", "00000",
],
':' => [
"00000", "00100", "00100", "00000", "00100", "00100", "00000",
],
'/' => [
"00001", "00010", "00100", "01000", "10000", "00000", "00000",
],
',' => [
"00000", "00000", "00000", "00000", "00000", "00100", "01000",
],
'\'' => [
"00100", "00100", "00000", "00000", "00000", "00000", "00000",
],
'!' => [
"00100", "00100", "00100", "00100", "00100", "00000", "00100",
],
'?' => [
"01110", "10001", "00001", "00010", "00100", "00000", "00100",
],
_ => [
"00000", "00000", "00000", "00000", "00000", "00000", "00000",
],
}
}
fn render_text_5x7(text: &str, scale: u32, char_spacing: u32, line_spacing: u32) -> GrayImage {
let lines: Vec<&str> = text.lines().collect();
let glyph_w = 5 * scale;
let glyph_h = 7 * scale;
let max_line_len = lines
.iter()
.map(|l| l.chars().count() as u32)
.max()
.unwrap_or(0);
let width = if max_line_len == 0 {
1
} else {
max_line_len * glyph_w + max_line_len.saturating_sub(1) * char_spacing + scale * 2
};
let height = if lines.is_empty() {
1
} else {
(lines.len() as u32) * glyph_h
+ (lines.len() as u32).saturating_sub(1) * line_spacing
+ scale * 2
};
let mut img = GrayImage::from_pixel(width.max(10), height.max(10), Luma([255u8]));
let mut y = scale;
for line in &lines {
let mut x = scale;
for ch in line.chars() {
if ch == ' ' {
x += glyph_w + char_spacing;
continue;
}
let rows = glyph_rows(ch.to_ascii_uppercase());
for (ry, row) in rows.iter().enumerate() {
for (rx, b) in row.as_bytes().iter().enumerate() {
if *b == b'1' {
for dy in 0..scale {
for dx in 0..scale {
img.put_pixel(
x + (rx as u32) * scale + dx,
y + (ry as u32) * scale + dy,
Luma([0u8]),
);
}
}
}
}
}
x += glyph_w + char_spacing;
}
y += glyph_h + line_spacing;
}
img
}
fn render_inverted(text: &str, scale: u32, char_spacing: u32, line_spacing: u32) -> GrayImage {
let normal = render_text_5x7(text, scale, char_spacing, line_spacing);
let (w, h) = normal.dimensions();
let mut inv = GrayImage::new(w, h);
for y in 0..h {
for x in 0..w {
let v = normal.get_pixel(x, y)[0];
inv.put_pixel(x, y, Luma([255 - v]));
}
}
inv
}
fn make_ocr_image(img: GrayImage) -> OcrImage {
OcrImage::new(DynamicImage::ImageLuma8(img), 300)
}
fn make_engine() -> OcrEngine {
let mut config = OcrConfig::default();
config.image_processing.enable_preprocessing = false;
config.layout_analysis.enable_layout_analysis = false;
OcrEngine::with_config(config).unwrap()
}
fn make_engine_full() -> OcrEngine {
let mut config = OcrConfig::default();
config.image_processing.enable_preprocessing = true;
config.layout_analysis.enable_layout_analysis = true;
OcrEngine::with_config(config).unwrap()
}
async fn recognize_with(engine: &OcrEngine, img: GrayImage) -> String {
let ocr_img = make_ocr_image(img);
let result = engine.process_image(ocr_img).await.unwrap();
result.text.trim().to_string()
}
async fn recognize(img: GrayImage) -> String {
let engine = make_engine();
engine.initialize().await.unwrap();
recognize_with(&engine, img).await
}
fn character_accuracy(recognized: &str, expected: &str) -> f32 {
if expected.is_empty() {
return if recognized.is_empty() { 1.0 } else { 0.0 };
}
let r: Vec<char> = recognized.chars().collect();
let e: Vec<char> = expected.chars().collect();
let max_len = r.len().max(e.len());
if max_len == 0 {
return 1.0;
}
let matches = r.iter().zip(e.iter()).filter(|(a, b)| a == b).count();
matches as f32 / max_len as f32
}
fn count_glyph_pixels(ch: char) -> u32 {
let rows = glyph_rows(ch);
rows.iter()
.flat_map(|r| r.as_bytes().iter())
.filter(|&&b| b == b'1')
.count() as u32
}
#[test]
fn test_glyph_render_all_letters_have_pixels() {
for &ch in SUPPORTED_LETTERS {
let px = count_glyph_pixels(ch);
assert!(
px > 0,
"Glyph '{}' should have at least 1 foreground pixel, got {}",
ch,
px
);
}
}
#[test]
fn test_glyph_render_all_digits_have_pixels() {
for &ch in SUPPORTED_DIGITS {
let px = count_glyph_pixels(ch);
assert!(
px > 0,
"Glyph '{}' should have at least 1 foreground pixel, got {}",
ch,
px
);
}
}
#[test]
fn test_glyph_render_special_chars_have_pixels() {
for &ch in SUPPORTED_SPECIALS {
let px = count_glyph_pixels(ch);
assert!(px > 0, "Glyph '{}' should have foreground pixels", ch);
}
}
#[test]
fn test_glyph_render_unknown_char_has_no_pixels() {
let px = count_glyph_pixels('@');
assert_eq!(px, 0, "Unsupported char should render as blank");
}
#[test]
fn test_render_produces_correct_dimensions_single_char() {
let img = render_text_5x7("A", 6, 6, 12);
let (w, h) = img.dimensions();
assert!(w >= 10, "Width should be >= 10, got {}", w);
assert!(h >= 10, "Height should be >= 10, got {}", h);
}
#[test]
fn test_render_produces_correct_dimensions_multi_char() {
let img = render_text_5x7("ABCD", 6, 6, 12);
let (w, h) = img.dimensions();
let expected_w = 4 * 5 * 6 + 3 * 6 + 6 * 2;
let expected_h = 7 * 6 + 6 * 2;
assert_eq!(w, expected_w.max(10));
assert_eq!(h, expected_h.max(10));
}
#[test]
fn test_render_multi_line_height() {
let img1 = render_text_5x7("HELLO", 6, 6, 12);
let img2 = render_text_5x7("HELLO\nWORLD", 6, 6, 12);
let (_, h1) = img1.dimensions();
let (_, h2) = img2.dimensions();
assert!(
h2 > h1,
"Two lines should be taller than one line ({} > {})",
h2,
h1
);
}
#[test]
fn test_render_pixel_values_are_binary() {
let img = render_text_5x7("HELLO", 6, 6, 12);
let (w, h) = img.dimensions();
for y in 0..h {
for x in 0..w {
let v = img.get_pixel(x, y)[0];
assert!(
v == 0 || v == 255,
"All pixels should be 0 or 255, got {} at ({}, {})",
v,
x,
y
);
}
}
}
#[test]
fn test_render_has_black_pixels() {
let img = render_text_5x7("HELLO", 6, 6, 12);
let (w, h) = img.dimensions();
let mut has_black = false;
for y in 0..h {
for x in 0..w {
if img.get_pixel(x, y)[0] == 0 {
has_black = true;
break;
}
}
}
assert!(has_black, "Rendered text should contain black pixels");
}
#[test]
fn test_render_has_white_pixels() {
let img = render_text_5x7("HELLO", 6, 6, 12);
let (w, h) = img.dimensions();
let mut has_white = false;
for y in 0..h {
for x in 0..w {
if img.get_pixel(x, y)[0] == 255 {
has_white = true;
break;
}
}
}
assert!(
has_white,
"Rendered text should contain white pixels (background)"
);
}
#[test]
fn test_render_space_produces_no_black_pixels_for_space_char() {
let img_with_space = render_text_5x7("A B", 6, 6, 12);
let (w, h) = img_with_space.dimensions();
assert!(w > 10, "Width with spaces should be > 10");
assert!(h > 10, "Height should be > 10");
}
#[test]
fn test_render_scale_affects_dimensions() {
let img_s = render_text_5x7("AB", 2, 2, 4);
let img_l = render_text_5x7("AB", 8, 8, 16);
let (ws, hs) = img_s.dimensions();
let (wl, hl) = img_l.dimensions();
assert!(wl > ws, "Larger scale => wider image ({} > {})", wl, ws);
assert!(hl > hs, "Larger scale => taller image ({} > {})", hl, hs);
}
#[test]
fn test_render_spacing_affects_dimensions() {
let img_tight = render_text_5x7("AB", 6, 2, 4);
let img_wide = render_text_5x7("AB", 6, 12, 4);
let (wt, _) = img_tight.dimensions();
let (ww, _) = img_wide.dimensions();
assert!(ww > wt, "Wider spacing => wider image ({} > {})", ww, wt);
}
#[tokio::test]
async fn test_roundtrip_each_letter_a_through_m() {
let engine = make_engine();
engine.initialize().await.unwrap();
let mut failures = Vec::new();
for &ch in &[
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
] {
let img = render_text_5x7(&ch.to_string(), 6, 6, 12);
let text = recognize_with(&engine, img).await;
let acc = character_accuracy(&text, &ch.to_string());
if acc < 1.0 {
failures.push(format!("{}: got '{}' ({:.0}%)", ch, text, acc * 100.0));
}
}
assert!(
failures.len() <= 2,
"Too many letter failures A-M ({}): {}",
failures.len(),
failures.join(", ")
);
}
#[tokio::test]
async fn test_roundtrip_each_letter_n_through_z() {
let engine = make_engine();
engine.initialize().await.unwrap();
let mut failures = Vec::new();
for &ch in &[
'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
] {
let img = render_text_5x7(&ch.to_string(), 6, 6, 12);
let text = recognize_with(&engine, img).await;
let acc = character_accuracy(&text, &ch.to_string());
if acc < 1.0 {
failures.push(format!("{}: got '{}' ({:.0}%)", ch, text, acc * 100.0));
}
}
assert!(
failures.len() <= 2,
"Too many letter failures N-Z ({}): {}",
failures.len(),
failures.join(", ")
);
}
#[tokio::test]
async fn test_roundtrip_each_digit_0_through_9() {
let engine = make_engine();
engine.initialize().await.unwrap();
let mut failures = Vec::new();
for &d in SUPPORTED_DIGITS {
let img = render_text_5x7(&d.to_string(), 6, 6, 12);
let text = recognize_with(&engine, img).await;
let acc = character_accuracy(&text, &d.to_string());
if acc < 1.0 {
failures.push(format!("{}: got '{}' ({:.0}%)", d, text, acc * 100.0));
}
}
assert!(
failures.len() <= 2,
"Too many digit failures ({}): {}",
failures.len(),
failures.join(", ")
);
}
#[tokio::test]
async fn test_roundtrip_special_chars_no_crash() {
let engine = make_engine();
engine.initialize().await.unwrap();
for &ch in SUPPORTED_SPECIALS {
let img = render_text_5x7(&ch.to_string(), 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await;
assert!(result.is_ok(), "Should not crash on special char '{}'", ch);
}
}
#[tokio::test]
async fn test_roundtrip_hello() {
let img = render_text_5x7("HELLO", 6, 6, 12);
let text = recognize(img).await;
let acc = character_accuracy(&text, "HELLO");
assert!(acc >= 0.8, "HELLO: got '{}' ({:.0}%)", text, acc * 100.0);
}
#[tokio::test]
async fn test_roundtrip_world() {
let img = render_text_5x7("WORLD", 6, 6, 12);
let text = recognize(img).await;
let acc = character_accuracy(&text, "WORLD");
assert!(acc >= 0.8, "WORLD: got '{}' ({:.0}%)", text, acc * 100.0);
}
#[tokio::test]
async fn test_roundtrip_test() {
let img = render_text_5x7("TEST", 6, 6, 12);
let text = recognize(img).await;
let acc = character_accuracy(&text, "TEST");
assert!(acc >= 0.75, "TEST: got '{}' ({:.0}%)", text, acc * 100.0);
}
#[tokio::test]
async fn test_roundtrip_ocr() {
let img = render_text_5x7("OCR", 6, 6, 12);
let text = recognize(img).await;
let acc = character_accuracy(&text, "OCR");
assert!(acc >= 0.66, "OCR: got '{}' ({:.0}%)", text, acc * 100.0);
}
#[tokio::test]
async fn test_roundtrip_rust() {
let img = render_text_5x7("RUST", 6, 6, 12);
let text = recognize(img).await;
let acc = character_accuracy(&text, "RUST");
assert!(acc >= 0.75, "RUST: got '{}' ({:.0}%)", text, acc * 100.0);
}
#[tokio::test]
async fn test_roundtrip_foo() {
let img = render_text_5x7("FOO", 6, 6, 12);
let text = recognize(img).await;
let acc = character_accuracy(&text, "FOO");
assert!(acc >= 0.66, "FOO: got '{}' ({:.0}%)", text, acc * 100.0);
}
#[tokio::test]
async fn test_roundtrip_abc() {
let img = render_text_5x7("ABC", 6, 6, 12);
let text = recognize(img).await;
let acc = character_accuracy(&text, "ABC");
assert!(acc >= 0.66, "ABC: got '{}' ({:.0}%)", text, acc * 100.0);
}
#[tokio::test]
async fn test_roundtrip_long_word() {
let img = render_text_5x7("ABCDEFGHIJ", 6, 6, 12);
let text = recognize(img).await;
let acc = character_accuracy(&text, "ABCDEFGHIJ");
assert!(
acc >= 0.5,
"ABCDEFGHIJ: got '{}' ({:.0}%)",
text,
acc * 100.0
);
}
#[tokio::test]
async fn test_roundtrip_all_digits_sequence() {
let img = render_text_5x7("0123456789", 6, 6, 12);
let text = recognize(img).await;
let acc = character_accuracy(&text, "0123456789");
assert!(
acc >= 0.5,
"0123456789: got '{}' ({:.0}%)",
text,
acc * 100.0
);
}
#[tokio::test]
async fn test_roundtrip_two_words_with_space() {
let engine = make_engine();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO WORLD", 6, 6, 12);
let text = recognize_with(&engine, img).await;
assert!(
text.contains("HELLO") || text.contains("HELL"),
"Should recognize HELLO in '{}'",
text
);
assert!(
text.contains("WORLD") || text.contains("WORL") || text.contains("WOR"),
"Should recognize WORLD in '{}'",
text
);
}
#[tokio::test]
async fn test_roundtrip_three_words_with_spaces() {
let engine = make_engine();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO WORLD TEST", 6, 18, 12);
let text = recognize_with(&engine, img).await;
assert!(!text.is_empty(), "Should recognize text with spaces");
let parts: Vec<&str> = text.split_whitespace().collect();
assert!(
parts.len() >= 2,
"Should have at least 2 words, got {}: {:?}",
parts.len(),
parts
);
}
#[tokio::test]
async fn test_roundtrip_lowercase_input_uppercased_output() {
let engine = make_engine();
engine.initialize().await.unwrap();
let img = render_text_5x7("hello", 6, 6, 12);
let text = recognize_with(&engine, img).await;
assert_eq!(
text.to_uppercase(),
text,
"Output should be uppercase since renderer converts to uppercase glyphs"
);
}
#[tokio::test]
async fn test_roundtrip_two_lines() {
let img = render_text_5x7("HELLO\nWORLD", 6, 6, 12);
let text = recognize(img).await;
let lines: Vec<&str> = text.lines().collect();
assert_eq!(
lines.len(),
2,
"Expected 2 lines, got {}: {:?}",
lines.len(),
lines
);
assert!(lines[0].starts_with("HELL"), "Line 1: got '{}'", lines[0]);
assert!(!lines[1].is_empty(), "Line 2 should not be empty");
}
#[tokio::test]
async fn test_roundtrip_three_lines() {
let img = render_text_5x7("HELLO\nWORLD\nTEST", 6, 6, 12);
let text = recognize(img).await;
let lines: Vec<&str> = text.lines().collect();
assert_eq!(
lines.len(),
3,
"Expected 3 lines, got {}: {:?}",
lines.len(),
lines
);
for (i, line) in lines.iter().enumerate() {
assert!(!line.is_empty(), "Line {} should not be empty", i);
}
}
#[tokio::test]
async fn test_roundtrip_four_lines() {
let img = render_text_5x7("AAA\nBBB\nCCC\nDDD", 6, 6, 12);
let text = recognize(img).await;
let lines: Vec<&str> = text.lines().collect();
assert_eq!(
lines.len(),
4,
"Expected 4 lines, got {}: {:?}",
lines.len(),
lines
);
}
#[tokio::test]
async fn test_roundtrip_five_lines() {
let img = render_text_5x7("A\nB\nC\nD\nE", 6, 6, 12);
let text = recognize(img).await;
let lines: Vec<&str> = text.lines().collect();
assert_eq!(
lines.len(),
5,
"Expected 5 lines, got {}: {:?}",
lines.len(),
lines
);
}
#[tokio::test]
async fn test_roundtrip_different_line_lengths() {
let img = render_text_5x7("A\nBBB\nCCCCC", 6, 6, 12);
let text = recognize(img).await;
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3, "Expected 3 lines with varying lengths");
}
#[tokio::test]
async fn test_roundtrip_scale_2() {
let img = render_text_5x7("HELLO", 2, 2, 4);
let engine = make_engine();
engine.initialize().await.unwrap();
let result = engine.process_image(make_ocr_image(img)).await;
assert!(result.is_ok(), "Scale 2 should not crash");
let text = result.unwrap().text.trim().to_string();
assert!(!text.is_empty(), "Scale 2 should produce non-empty text");
}
#[tokio::test]
async fn test_roundtrip_scale_4() {
let img = render_text_5x7("HELLO", 4, 4, 8);
let text = recognize(img).await;
let acc = character_accuracy(&text, "HELLO");
assert!(acc >= 0.6, "Scale 4: got '{}' ({:.0}%)", text, acc * 100.0);
}
#[tokio::test]
async fn test_roundtrip_scale_6() {
let img = render_text_5x7("HELLO", 6, 6, 12);
let text = recognize(img).await;
let acc = character_accuracy(&text, "HELLO");
assert!(acc >= 0.8, "Scale 6: got '{}' ({:.0}%)", text, acc * 100.0);
}
#[tokio::test]
async fn test_roundtrip_scale_8() {
let img = render_text_5x7("HELLO", 8, 8, 16);
let text = recognize(img).await;
let acc = character_accuracy(&text, "HELLO");
assert!(acc >= 0.8, "Scale 8: got '{}' ({:.0}%)", text, acc * 100.0);
}
#[tokio::test]
async fn test_roundtrip_scale_12() {
let img = render_text_5x7("HELLO", 12, 12, 24);
let text = recognize(img).await;
let acc = character_accuracy(&text, "HELLO");
assert!(acc >= 0.8, "Scale 12: got '{}' ({:.0}%)", text, acc * 100.0);
}
#[tokio::test]
async fn test_roundtrip_tight_spacing() {
let img = render_text_5x7("AB", 6, 1, 4);
let engine = make_engine();
engine.initialize().await.unwrap();
let result = engine.process_image(make_ocr_image(img)).await;
assert!(result.is_ok(), "Tight spacing should not crash");
}
#[tokio::test]
async fn test_roundtrip_wide_spacing() {
let img = render_text_5x7("AB", 6, 20, 12);
let text = recognize(img).await;
let acc = character_accuracy(&text, "AB");
assert!(
acc >= 0.5,
"Wide spacing: got '{}' ({:.0}%)",
text,
acc * 100.0
);
}
#[tokio::test]
async fn test_roundtrip_tight_line_spacing() {
let img = render_text_5x7("HELLO\nWORLD", 6, 6, 2);
let engine = make_engine();
engine.initialize().await.unwrap();
let result = engine.process_image(make_ocr_image(img)).await;
assert!(result.is_ok(), "Tight line spacing should not crash");
}
#[tokio::test]
async fn test_roundtrip_wide_line_spacing() {
let img = render_text_5x7("HELLO\nWORLD", 6, 6, 40);
let text = recognize(img).await;
let lines: Vec<&str> = text.lines().collect();
assert_eq!(
lines.len(),
2,
"Wide line spacing should still produce 2 lines"
);
}
#[tokio::test]
async fn test_roundtrip_preprocessing_enabled() {
let mut config = OcrConfig::default();
config.image_processing.enable_preprocessing = true;
config.layout_analysis.enable_layout_analysis = false;
let engine = OcrEngine::with_config(config).unwrap();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await.unwrap();
assert!(!result.text.trim().is_empty());
}
#[tokio::test]
async fn test_roundtrip_preprocessing_disabled() {
let mut config = OcrConfig::default();
config.image_processing.enable_preprocessing = false;
config.layout_analysis.enable_layout_analysis = false;
let engine = OcrEngine::with_config(config).unwrap();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await.unwrap();
assert!(!result.text.trim().is_empty());
}
#[tokio::test]
async fn test_roundtrip_layout_analysis_enabled() {
let mut config = OcrConfig::default();
config.image_processing.enable_preprocessing = false;
config.layout_analysis.enable_layout_analysis = true;
let engine = OcrEngine::with_config(config).unwrap();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO\nWORLD", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await.unwrap();
assert!(!result.text.trim().is_empty());
}
#[tokio::test]
async fn test_roundtrip_full_pipeline() {
let engine = make_engine_full();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await.unwrap();
assert!(!result.text.trim().is_empty());
}
#[tokio::test]
async fn test_roundtrip_binarization_otsu() {
let mut config = OcrConfig::default();
config.image_processing.enable_preprocessing = true;
config.image_processing.enable_binarization = true;
config.image_processing.binarization_method = ocr::core::config::BinarizationMethod::Otsu;
config.layout_analysis.enable_layout_analysis = false;
let engine = OcrEngine::with_config(config).unwrap();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await.unwrap();
assert!(!result.text.trim().is_empty());
}
#[tokio::test]
async fn test_roundtrip_binarization_fixed() {
let mut config = OcrConfig::default();
config.image_processing.enable_preprocessing = true;
config.image_processing.enable_binarization = true;
config.image_processing.binarization_method = ocr::core::config::BinarizationMethod::Fixed;
config.layout_analysis.enable_layout_analysis = false;
let engine = OcrEngine::with_config(config).unwrap();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await.unwrap();
assert!(!result.text.trim().is_empty());
}
#[tokio::test]
async fn test_roundtrip_binarization_adaptive() {
let mut config = OcrConfig::default();
config.image_processing.enable_preprocessing = true;
config.image_processing.enable_binarization = true;
config.image_processing.binarization_method = ocr::core::config::BinarizationMethod::Adaptive;
config.layout_analysis.enable_layout_analysis = false;
let engine = OcrEngine::with_config(config).unwrap();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await.unwrap();
assert!(!result.text.trim().is_empty());
}
#[tokio::test]
async fn test_roundtrip_binarization_sauvola() {
let mut config = OcrConfig::default();
config.image_processing.enable_preprocessing = true;
config.image_processing.enable_binarization = true;
config.image_processing.binarization_method = ocr::core::config::BinarizationMethod::Sauvola;
config.layout_analysis.enable_layout_analysis = false;
let engine = OcrEngine::with_config(config).unwrap();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await.unwrap();
assert!(!result.text.trim().is_empty());
}
#[tokio::test]
async fn test_roundtrip_inverted_image_with_preprocessing() {
let mut config = OcrConfig::default();
config.image_processing.enable_preprocessing = true;
config.layout_analysis.enable_layout_analysis = false;
let engine = OcrEngine::with_config(config).unwrap();
engine.initialize().await.unwrap();
let img = render_inverted("HELLO", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await;
assert!(
result.is_ok(),
"Inverted image should not crash with preprocessing"
);
}
#[tokio::test]
async fn test_roundtrip_empty_white_image() {
let img = GrayImage::from_pixel(100, 100, Luma([255u8]));
let text = recognize(img).await;
assert!(
text.trim().is_empty(),
"Empty white image => empty text, got '{}'",
text
);
}
#[tokio::test]
async fn test_roundtrip_empty_black_image() {
let img = GrayImage::from_pixel(100, 100, Luma([0u8]));
let engine = make_engine();
engine.initialize().await.unwrap();
let result = engine.process_image(make_ocr_image(img)).await;
assert!(result.is_ok(), "All-black image should not crash");
}
#[tokio::test]
async fn test_roundtrip_all_gray_image() {
let img = GrayImage::from_pixel(100, 100, Luma([128u8]));
let engine = make_engine();
engine.initialize().await.unwrap();
let result = engine.process_image(make_ocr_image(img)).await;
assert!(result.is_ok(), "All-gray image should not crash");
}
#[tokio::test]
async fn test_roundtrip_single_pixel_image() {
let img = GrayImage::from_pixel(10, 10, Luma([255u8]));
let engine = make_engine();
engine.initialize().await.unwrap();
let result = engine.process_image(make_ocr_image(img)).await;
assert!(result.is_ok(), "10x10 image should not crash");
}
#[tokio::test]
async fn test_roundtrip_min_size_image() {
let img = render_text_5x7("A", 1, 0, 0);
let (w, h) = img.dimensions();
assert!(
w >= 10 && h >= 10,
"Image must be >= min size, got {}x{}",
w,
h
);
let engine = make_engine();
engine.initialize().await.unwrap();
let result = engine.process_image(make_ocr_image(img)).await;
assert!(result.is_ok(), "Minimum-size image should not crash");
}
#[tokio::test]
async fn test_roundtrip_wide_text() {
let img = render_text_5x7("ABCDEFGHIJKLMNOP", 6, 6, 12);
let text = recognize(img).await;
let acc = character_accuracy(&text, "ABCDEFGHIJKLMNOP");
assert!(
acc >= 0.5,
"Wide text: got '{}' ({:.0}%)",
text,
acc * 100.0
);
}
#[tokio::test]
async fn test_roundtrip_repeated_char() {
let img = render_text_5x7("AAAA", 6, 6, 12);
let text = recognize(img).await;
assert!(!text.is_empty(), "Repeated chars should produce text");
assert!(
text.chars().all(|c| c == 'A'),
"AAAA should produce only A's, got '{}'",
text
);
}
#[tokio::test]
async fn test_result_has_text() {
let engine = make_engine();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await.unwrap();
assert!(
!result.text.trim().is_empty(),
"text field should be populated"
);
}
#[tokio::test]
async fn test_result_confidence_in_range() {
let engine = make_engine();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await.unwrap();
assert!(
result.confidence >= 0.0 && result.confidence <= 1.0,
"Confidence should be in [0, 1], got {}",
result.confidence
);
}
#[tokio::test]
async fn test_result_confidence_nonzero_for_text() {
let engine = make_engine();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await.unwrap();
assert!(
result.confidence > 0.0,
"Confidence should be > 0 for recognizable text, got {}",
result.confidence
);
}
#[tokio::test]
async fn test_result_has_words() {
let engine = make_engine();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await.unwrap();
assert!(
!result.words.is_empty(),
"Should have at least one word result"
);
assert!(
!result.words[0].text.is_empty(),
"Word text should not be empty"
);
}
#[tokio::test]
async fn test_result_has_characters() {
let engine = make_engine();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await.unwrap();
assert!(
!result.characters.is_empty(),
"Should have character-level results"
);
for (i, cr) in result.characters.iter().enumerate() {
assert!(
cr.confidence >= 0.0 && cr.confidence <= 1.0,
"Character {} confidence should be in [0,1], got {}",
i,
cr.confidence
);
}
}
#[tokio::test]
async fn test_result_has_lines() {
let engine = make_engine();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO\nWORLD", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await.unwrap();
assert!(!result.lines.is_empty(), "Should have line-level results");
}
#[tokio::test]
async fn test_result_line_count_matches_input() {
let engine = make_engine();
engine.initialize().await.unwrap();
let img = render_text_5x7("AAA\nBBB\nCCC", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await.unwrap();
let text_lines: Vec<&str> = result.text.trim().lines().collect();
assert_eq!(text_lines.len(), 3, "Text should have 3 lines");
assert_eq!(result.lines.len(), 3, "lines field should have 3 entries");
}
#[tokio::test]
async fn test_result_word_count_helper() {
let engine = make_engine();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await.unwrap();
assert_eq!(result.word_count(), result.words.len());
}
#[tokio::test]
async fn test_result_character_count_helper() {
let engine = make_engine();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await.unwrap();
assert_eq!(result.character_count(), result.characters.len());
}
#[tokio::test]
async fn test_result_average_character_confidence() {
let engine = make_engine();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await.unwrap();
let avg = result.average_character_confidence();
assert!(
avg >= 0.0 && avg <= 1.0,
"Average char confidence should be in [0,1], got {}",
avg
);
}
#[tokio::test]
async fn test_result_empty_image_zero_confidence() {
let engine = make_engine();
engine.initialize().await.unwrap();
let img = GrayImage::from_pixel(100, 100, Luma([255u8]));
let result = engine.process_image(make_ocr_image(img)).await.unwrap();
assert!(
result.text.trim().is_empty(),
"Empty image should produce empty text"
);
assert_eq!(result.word_count(), 0, "Empty image should have 0 words");
assert_eq!(
result.character_count(),
0,
"Empty image should have 0 characters"
);
}
#[tokio::test]
async fn test_reproducibility_same_input_same_output() {
let engine = make_engine();
engine.initialize().await.unwrap();
let img1 = render_text_5x7("HELLO", 6, 6, 12);
let img2 = render_text_5x7("HELLO", 6, 6, 12);
let r1 = engine.process_image(make_ocr_image(img1)).await.unwrap();
let r2 = engine.process_image(make_ocr_image(img2)).await.unwrap();
assert_eq!(
r1.text.trim(),
r2.text.trim(),
"Same input should produce same text"
);
assert!(
(r1.confidence - r2.confidence).abs() < 0.001,
"Same input should produce same confidence ({} vs {})",
r1.confidence,
r2.confidence
);
}
#[tokio::test]
async fn test_sequential_recognitions_same_engine() {
let engine = make_engine();
engine.initialize().await.unwrap();
let words = vec!["HELLO", "WORLD", "TEST", "OCR"];
let mut results = Vec::new();
for word in &words {
let img = render_text_5x7(word, 6, 6, 12);
let text = recognize_with(&engine, img).await;
results.push(text);
}
assert_eq!(results.len(), 4, "Should have 4 results");
for (i, text) in results.iter().enumerate() {
assert!(!text.is_empty(), "Result {} should not be empty", i);
}
}
#[tokio::test]
async fn test_engine_reuse_after_empty_image() {
let engine = make_engine();
engine.initialize().await.unwrap();
let empty = GrayImage::from_pixel(100, 100, Luma([255u8]));
let r1 = engine.process_image(make_ocr_image(empty)).await.unwrap();
assert!(r1.text.trim().is_empty());
let hello = render_text_5x7("HELLO", 6, 6, 12);
let r2 = engine.process_image(make_ocr_image(hello)).await.unwrap();
assert!(
!r2.text.trim().is_empty(),
"Engine should still work after empty image"
);
}
#[tokio::test]
async fn test_engine_reuse_after_black_image() {
let engine = make_engine();
engine.initialize().await.unwrap();
let black = GrayImage::from_pixel(100, 100, Luma([0u8]));
let r1 = engine.process_image(make_ocr_image(black)).await;
assert!(r1.is_ok());
let hello = render_text_5x7("HELLO", 6, 6, 12);
let r2 = engine.process_image(make_ocr_image(hello)).await.unwrap();
assert!(
!r2.text.trim().is_empty(),
"Engine should still work after black image"
);
}
#[tokio::test]
async fn test_engine_processes_many_images() {
let engine = make_engine();
engine.initialize().await.unwrap();
for i in 0..20 {
let ch = char::from(b'A' + (i % 26));
let img = render_text_5x7(&ch.to_string(), 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await;
assert!(result.is_ok(), "Processing image {} should succeed", i);
}
}
#[tokio::test]
async fn test_engine_statistics_updated() {
let engine = make_engine();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO", 6, 6, 12);
let stats_before = engine.get_statistics().await.unwrap();
engine.process_image(make_ocr_image(img)).await.unwrap();
let stats_after = engine.get_statistics().await.unwrap();
assert_eq!(
stats_after.total_images_processed,
stats_before.total_images_processed + 1,
"Should have processed 1 more image"
);
assert!(
stats_after.total_processing_time_ms >= stats_before.total_processing_time_ms,
"Processing time should increase"
);
assert!(
stats_after.last_processed.is_some(),
"Last processed should be set"
);
}
#[tokio::test]
async fn test_engine_statistics_multiple_images() {
let engine = make_engine();
engine.initialize().await.unwrap();
for _ in 0..5 {
let img = render_text_5x7("HELLO", 6, 6, 12);
engine.process_image(make_ocr_image(img)).await.unwrap();
}
let stats = engine.get_statistics().await.unwrap();
assert_eq!(stats.total_images_processed, 5);
assert!(stats.average_processing_time_ms > 0.0);
}
#[tokio::test]
async fn test_engine_clear_cache() {
let engine = make_engine();
engine.initialize().await.unwrap();
let result = engine.clear_cache().await;
assert!(result.is_ok(), "clear_cache should succeed");
}
#[tokio::test]
async fn test_engine_reset_statistics() {
let engine = make_engine();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO", 6, 6, 12);
engine.process_image(make_ocr_image(img)).await.unwrap();
engine.reset_statistics().await.unwrap();
let stats = engine.get_statistics().await.unwrap();
assert_eq!(stats.total_images_processed, 0, "Stats should be reset");
}
#[tokio::test]
async fn test_engine_metadata() {
let engine = make_engine();
let metadata = engine.get_metadata();
assert_eq!(metadata.name, "OCR Engine");
assert!(!metadata.supported_languages.is_empty());
assert!(metadata.capabilities.supports_text_recognition);
}
#[tokio::test]
async fn test_engine_get_config() {
let engine = make_engine();
let config = engine.get_config();
assert!(!config.recognition.language.is_empty());
assert!(config.recognition.confidence_threshold >= 0.0);
assert!(config.recognition.confidence_threshold <= 1.0);
}
#[tokio::test]
async fn test_config_validation_rejects_bad_confidence() {
let mut config = OcrConfig::default();
config.recognition.confidence_threshold = 2.0;
let result = OcrEngine::with_config(config);
assert!(result.is_err(), "Confidence > 1.0 should be rejected");
}
#[tokio::test]
async fn test_config_validation_rejects_zero_threads() {
let mut config = OcrConfig::default();
config.performance.max_threads = 0;
let result = OcrEngine::with_config(config);
assert!(result.is_err(), "max_threads=0 should be rejected");
}
#[tokio::test]
async fn test_config_validation_rejects_zero_memory() {
let mut config = OcrConfig::default();
config.performance.memory_limit_mb = 0;
let result = OcrEngine::with_config(config);
assert!(result.is_err(), "memory_limit_mb=0 should be rejected");
}
#[tokio::test]
async fn test_config_validation_accepts_valid() {
let config = OcrConfig::default();
let result = OcrEngine::with_config(config);
assert!(result.is_ok(), "Default config should be valid");
}
#[tokio::test]
async fn test_engine_not_initialized_error() {
let engine = make_engine();
let img = render_text_5x7("HELLO", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await;
assert!(result.is_err(), "Uninitialized engine should return error");
}
#[tokio::test]
async fn test_engine_initialize_idempotent() {
let engine = make_engine();
engine.initialize().await.unwrap();
engine.initialize().await.unwrap();
let img = render_text_5x7("HELLO", 6, 6, 12);
let result = engine.process_image(make_ocr_image(img)).await;
assert!(result.is_ok(), "Double init should not break engine");
}
#[tokio::test]
async fn test_confidence_increases_with_scale() {
let engine = make_engine();
engine.initialize().await.unwrap();
let img_small = render_text_5x7("HELLO", 3, 3, 6);
let img_large = render_text_5x7("HELLO", 10, 10, 20);
let res_small = engine
.process_image(make_ocr_image(img_small))
.await
.unwrap();
let res_large = engine
.process_image(make_ocr_image(img_large))
.await
.unwrap();
assert!(
res_large.confidence >= res_small.confidence * 0.8,
"Larger scale should have >= confidence (small={:.3}, large={:.3})",
res_small.confidence,
res_large.confidence
);
}