Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 15 additions & 63 deletions crates/tesseract-ocr-web/src/ocr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,7 @@ use std::io::Cursor;
use std::time::Instant;

use image::{ImageReader, Limits};
use tesseract_ocr::{
build_regions, conn_comp_bb, detect_page_furniture, generate_halftone_mask,
german_invoice_fields, harden_numeric_tokens, harvest_fields, otsu_threshold_gray,
render_json_with_regions, threshold_rect_to_binary, xy_cut, DocPage, XyCutParams, MIN_HEIGHT,
MIN_WIDTH,
};
use tesseract_ocr::german_invoice_fields;

use crate::state::AppState;

Expand Down Expand Up @@ -161,74 +156,31 @@ pub fn ocr_image_bytes(state: &AppState, bytes: &[u8]) -> Result<OcrOutcome, Str
})
}

/// Decode `bytes` to grey and run the word/box recognition path
/// ([`LstmRecognizer::recognize_page_makerow_words`]), then build the
/// structured `doc.v1` JSON document: numeric hardening
/// ([`harden_numeric_tokens`]) followed by the German-invoice field harvest
/// ([`harvest_fields`] with [`german_invoice_fields`]), rendered via
/// [`render_json`]. Same off-runtime contract as [`ocr_image_bytes`] — heavy
/// synchronous CPU work, callers MUST run it via `spawn_blocking`.
/// Decode `bytes` to grey and run the canonical one-shot structured-document
/// path ([`LstmRecognizer::recognize_document`]): word/box recognition →
/// `doc.v1` DOM → numeric hardening → German-invoice field harvest → region
/// classification (page furniture + XY-cut blocks + halftone figures) →
/// `doc.v1` JSON. The composition itself lives in `tesseract-ocr` so this
/// demo and the `tesseract-ogar` executor share ONE source of truth. Same
/// off-runtime contract as [`ocr_image_bytes`] — heavy synchronous CPU work,
/// callers MUST run it via `spawn_blocking`.
pub fn ocr_image_bytes_json(state: &AppState, bytes: &[u8]) -> Result<OcrJsonOutcome, String> {
let (raw, w, h) = decode_grey(bytes)?;

let t0 = Instant::now();
let lines = state
let specs = german_invoice_fields();
let doc = state
.recognizer
.recognize_page_makerow_words(&raw, w, h, state.dict.as_ref())
.recognize_document(&raw, w, h, state.dict.as_ref(), Some(&specs))
.map_err(|e| format!("recognition failed: {e}"))?;
let mut page = DocPage::from_line_words(&lines, &state.recognizer.charset, w as u32, h as u32);
harden_numeric_tokens(&mut page);
let fields = harvest_fields(&page, &german_invoice_fields());

// Region classification over the SAME decoded grey: page furniture
// (header/footer), XY-cut layout blocks (reading order), and the
// halftone/image mask (figure regions; only defined for pages >= the
// leptonica MinWidth/MinHeight gate). All three are the parity-proven
// library layers — this is just their doc.v1 wiring.
let furniture = detect_page_furniture(&page);
let blocks: Vec<(i32, i32, i32, i32)> = xy_cut(&raw, w, h, &XyCutParams::default())
.into_iter()
.map(|r| (r.left as i32, r.top as i32, r.right as i32, r.bottom as i32))
.collect();
let figures: Vec<(i32, i32, i32, i32)> = if w >= MIN_WIDTH && h >= MIN_HEIGHT {
let otsu = otsu_threshold_gray(&raw, w, 0, 0, w, h);
let binary: Vec<u8> = if otsu.hi_value == -1 {
// Otsu "no opinion" (degenerate page): fixed mid-grey split, the
// same fallback the library's own segmenters use.
raw.iter().map(|&p| if p < 128 { 0 } else { 255 }).collect()
} else {
threshold_rect_to_binary(&raw, w, 0, 0, w, h, otsu)
};
generate_halftone_mask(&binary, w, h)
.filter(|hm| hm.found)
.map(|hm| {
conn_comp_bb(&hm.mask, hm.mask_w, hm.mask_h, 8)
.into_iter()
.map(|b| (b.x, b.y, b.x + b.w, b.y + b.h))
.collect()
})
.unwrap_or_default()
} else {
Vec::new()
};
let regions = build_regions(
&page,
&furniture.header_lines,
&furniture.footer_lines,
&blocks,
&figures,
);
let json = render_json_with_regions(&page, &regions, &fields);
let elapsed_ms = t0.elapsed().as_secs_f64() * 1000.0;

let word_count = page.lines.iter().map(|l| l.words.len()).sum();
let line_count = page.lines.len();
Ok(OcrJsonOutcome {
json,
json: doc.json,
width: w,
height: h,
word_count,
line_count,
word_count: doc.word_count,
line_count: doc.line_count,
elapsed_ms,
})
}
Expand Down
2 changes: 1 addition & 1 deletion crates/tesseract-ocr/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ pub use conncomp::{conn_comp_areas, conn_comp_bb, ConnComp, ConnCompBox};
pub use image_input::{parse_pgm, prescale_grey_to_height, PgmError};
#[cfg(feature = "seg-approx")]
pub use line_segment::{find_text_lines, LineBand};
pub use lstm_recognizer::{LstmRecognizer, RecognizerError};
pub use lstm_recognizer::{Document, LstmRecognizer, RecognizerError};
pub use morph::{
close_brick, close_safe_brick, dilate_brick, erode_brick, morph_sequence, open_brick,
};
Expand Down
161 changes: 161 additions & 0 deletions crates/tesseract-ocr/src/lstm_recognizer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,21 @@ impl From<NetError> for RecognizerError {
}
}

/// The result of [`LstmRecognizer::recognize_document`] — the rendered
/// `tesseract-rs/doc.v1` JSON, the harvested typed fields, and word/line
/// counts for callers that want stats without re-parsing the JSON.
#[derive(Clone, Debug)]
pub struct Document {
/// The rendered `doc.v1` JSON document (structure + classified regions).
pub json: String,
/// The harvested typed fields (empty when no harvest profile was given).
pub fields: Vec<crate::structured::HarvestedField>,
/// Total recognized words across all lines.
pub word_count: usize,
/// Number of non-empty recognized lines.
pub line_count: usize,
}

/// A loaded LSTM recognizer — the network plus the char-set / recoder tissue and
/// the scalar fields `LSTMRecognizer::DeSerialize` reads. This is the object
/// `RecognizeLine` (B3) drives; the training-only scalars are carried for
Expand Down Expand Up @@ -657,6 +672,101 @@ impl LstmRecognizer {
Ok(out)
}

/// **The one-shot structured-document path** — consumer-side composition
/// (NOT a Tesseract transcode; see the `structured.rs` / `xy_cut.rs` /
/// `pageseg.rs` / `page_furniture.rs` banners). Recognizes the grey page
/// to word/box output, builds the `doc.v1` DOM, hardens numeric tokens,
/// optionally harvests typed fields, classifies regions (page furniture +
/// XY-cut layout blocks + halftone-mask figures), and renders the
/// `tesseract-rs/doc.v1` JSON.
///
/// **This is the single canonical composition** the web demo's JSON arm
/// and the `tesseract-ogar` `recognize_document` executor arm both call,
/// so the two consumers cannot drift.
///
/// `harvest` selects the field harvest: `Some(specs)` runs
/// [`harvest_fields`](crate::structured::harvest_fields) (e.g. with
/// [`german_invoice_fields`](crate::structured::german_invoice_fields));
/// `None` harvests nothing (`"fields":[]`).
///
/// # Errors
///
/// [`RecognizerError`] from the underlying word recognition.
pub fn recognize_document(
&self,
grey: &[u8],
w: usize,
h: usize,
dict: Option<&DictLite>,
harvest: Option<&[crate::structured::FieldSpec]>,
) -> Result<Document, RecognizerError> {
let lines = self.recognize_page_makerow_words(grey, w, h, dict)?;
let mut page =
crate::structured::DocPage::from_line_words(&lines, &self.charset, w as u32, h as u32);
crate::structured::harden_numeric_tokens(&mut page);
let fields = match harvest {
Some(specs) => crate::structured::harvest_fields(&page, specs),
None => Vec::new(),
};

// Region classification over the SAME grey page: furniture
// (header/footer), XY-cut layout blocks (reading order), halftone
// figures. All three are the parity-proven library layers.
let furniture = crate::page_furniture::detect_page_furniture(&page);
let blocks: Vec<(i32, i32, i32, i32)> =
crate::xy_cut::xy_cut(grey, w, h, &crate::xy_cut::XyCutParams::default())
.into_iter()
.map(|r| (r.left as i32, r.top as i32, r.right as i32, r.bottom as i32))
.collect();
let figures = Self::halftone_figures(grey, w, h);
let regions = crate::structured::build_regions(
&page,
&furniture.header_lines,
&furniture.footer_lines,
&blocks,
&figures,
);
let json = crate::structured::render_json_with_regions(&page, &regions, &fields);
let word_count = page.lines.iter().map(|l| l.words.len()).sum();
let line_count = page.lines.len();
Ok(Document {
json,
fields,
word_count,
line_count,
})
}

/// Halftone (image-region) figure bboxes for [`Self::recognize_document`]'s
/// region classification: binarize (Otsu, with the same fixed-128 fallback
/// the library segmenters use when Otsu declines), run the leptonica-parity
/// [`generate_halftone_mask`](crate::pageseg::generate_halftone_mask) (only
/// above its `MinWidth`/`MinHeight` gate), and return the found mask's
/// connected components as page-space `(l, t, r, b)` rects. Empty when the
/// page is too small or holds no halftone region.
fn halftone_figures(grey: &[u8], w: usize, h: usize) -> Vec<(i32, i32, i32, i32)> {
if w < crate::pageseg::MIN_WIDTH || h < crate::pageseg::MIN_HEIGHT {
return Vec::new();
}
let otsu = crate::threshold::otsu_threshold_gray(grey, w, 0, 0, w, h);
let binary: Vec<u8> = if otsu.hi_value == -1 {
grey.iter()
.map(|&p| if p < 128 { 0 } else { 255 })
.collect()
} else {
crate::threshold::threshold_rect_to_binary(grey, w, 0, 0, w, h, otsu)
};
crate::pageseg::generate_halftone_mask(&binary, w, h)
.filter(|hm| hm.found)
.map(|hm| {
crate::conncomp::conn_comp_bb(&hm.mask, hm.mask_w, hm.mask_h, 8)
.into_iter()
.map(|b| (b.x, b.y, b.x + b.w, b.y + b.h))
.collect()
})
.unwrap_or_default()
}

/// `LSTMRecognizer::SetRandomSeed` (`lstmrecognizer.h:287-291`): the exact
/// randomizer seeding `RecognizeLine` uses before the forward pass —
/// `seed = (i64)sample_iteration · 0x10000001`, `minstd` seed, one warm-up
Expand Down Expand Up @@ -1081,4 +1191,55 @@ mod makerow_page_tests {
"render_text(words).trim_end() must equal the string surface"
);
}

/// `recognize_document` composes the word surface into `doc.v1` JSON: the
/// canonical one-shot both the web demo and the tesseract-ogar executor
/// call. It must produce well-formed doc.v1, report the same line count as
/// the word surface, and — with the German-invoice harvest profile — carry
/// a `fields` array (possibly empty on a non-invoice fixture, but the key
/// is always present). The no-harvest arm must emit `"fields":[]`.
#[test]
fn recognize_document_composes_docv1_and_counts() {
let corpus = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus");
if !corpus.join("model/eng.lstm").exists() || !corpus.join("lines/page_roomy.pgm").exists()
{
return;
}
let lstm = std::fs::read(corpus.join("model/eng.lstm")).unwrap();
let uni = std::fs::read_to_string(corpus.join("model/eng.lstm-unicharset")).unwrap();
let rec = std::fs::read(corpus.join("model/eng.lstm-recoder")).unwrap();
let img = std::fs::read(corpus.join("lines/page_roomy.pgm")).unwrap();
let r = LstmRecognizer::from_components(&lstm, &uni, &rec).unwrap();
let (grey, w, h) = crate::image_input::parse_pgm(&img).unwrap();

let words = r.recognize_page_makerow_words(&grey, w, h, None).unwrap();

// No-harvest arm: valid doc.v1, empty fields, counts consistent.
let plain = r.recognize_document(&grey, w, h, None, None).unwrap();
assert!(plain
.json
.starts_with("{\"schema\":\"tesseract-rs/doc.v1\""));
assert!(
plain.json.contains("\"fields\":[]"),
"no-harvest → empty fields"
);
assert_eq!(
plain.line_count,
words.len(),
"line count matches word surface"
);
assert_eq!(
plain.word_count,
words.iter().map(|l| l.words.len()).sum::<usize>()
);

// Harvest arm: the fields key is present (array may be empty on a
// non-invoice fixture, but the harvest ran without panicking).
let specs = crate::structured::german_invoice_fields();
let harvested = r
.recognize_document(&grey, w, h, None, Some(&specs))
.unwrap();
assert!(harvested.json.contains("\"fields\":"));
assert_eq!(harvested.line_count, words.len());
}
}
Loading