From 5c6a8ab1b6cb56a602ea16bfb43fb9d4172846a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 12:17:28 +0000 Subject: [PATCH] tesseract-rs: recognize_document one-shot + tesseract-ogar OCR v2 executor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tesseract-rs half of the OGAR OCR action-table v2 (companion to the ogar-vocab 8->14 growth). Lands in lockstep — the const-assert fuse (tesseract-ogar/src/lib.rs) makes the interim a hard workspace compile failure, so merge the OGAR PR first. - tesseract-ocr: LstmRecognizer::recognize_document — the canonical one-shot composition (word recognition -> doc.v1 DOM -> numeric hardening -> optional typed harvest -> region classification via page furniture + XY-cut blocks + halftone figures -> doc.v1 JSON), returning a Document { json, fields, word_count, line_count }. The halftone_figures helper factors out the Otsu->generate_halftone_mask->conn_comp_bb chain. This is now the SINGLE source of truth: the web demo's JSON arm is refactored onto it (dropping ~50 lines of duplicated composition), and the tesseract-ogar recognize_document arm calls it too, so the two consumers cannot drift. - tesseract-ogar: six new OcrRequest/OcrResponse variants + execute arms + capability_of + param mappings, all thin dispatch onto the proven tesseract-ocr API: recognize_page_words, recognize_document, harvest_fields, segment_page, detect_halftone_regions, detect_page_furniture. COVERED_CAPABILITIES 8->14 (the const-assert + the name-level fuse test now cover 14); the hotplug test asserts the new page_layout subject. harvest_profile is fail-closed (UnknownHarvestProfile) so a typo can never silently drop invoice-field validation — only "german_invoice" is understood today. 137/137 tesseract-ocr lib tests (+recognize_document), 9/9 tesseract-ogar (all fuse tests green), 8/8 web; clippy -D warnings clean on all four crates. Railway: the web crate ships this in the next master deploy (the JSON mode now runs the shared one-shot). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016b33swuXE23hKtqxsHu9p1 --- crates/tesseract-ocr-web/src/ocr.rs | 78 +---- crates/tesseract-ocr/src/lib.rs | 2 +- crates/tesseract-ocr/src/lstm_recognizer.rs | 161 +++++++++++ crates/tesseract-ogar/src/lib.rs | 304 +++++++++++++++++++- 4 files changed, 476 insertions(+), 69 deletions(-) diff --git a/crates/tesseract-ocr-web/src/ocr.rs b/crates/tesseract-ocr-web/src/ocr.rs index b38ef3e..2aba78f 100644 --- a/crates/tesseract-ocr-web/src/ocr.rs +++ b/crates/tesseract-ocr-web/src/ocr.rs @@ -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; @@ -161,74 +156,31 @@ pub fn ocr_image_bytes(state: &AppState, bytes: &[u8]) -> Result Result { 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 = 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, ®ions, &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, }) } diff --git a/crates/tesseract-ocr/src/lib.rs b/crates/tesseract-ocr/src/lib.rs index 9402f67..fd8f0aa 100644 --- a/crates/tesseract-ocr/src/lib.rs +++ b/crates/tesseract-ocr/src/lib.rs @@ -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, }; diff --git a/crates/tesseract-ocr/src/lstm_recognizer.rs b/crates/tesseract-ocr/src/lstm_recognizer.rs index a336d56..8a9d2ce 100644 --- a/crates/tesseract-ocr/src/lstm_recognizer.rs +++ b/crates/tesseract-ocr/src/lstm_recognizer.rs @@ -83,6 +83,21 @@ impl From 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, + /// 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 @@ -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 { + 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, ®ions, &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 = 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 @@ -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::() + ); + + // 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()); + } } diff --git a/crates/tesseract-ogar/src/lib.rs b/crates/tesseract-ogar/src/lib.rs index 19fe8b9..20599e6 100644 --- a/crates/tesseract-ogar/src/lib.rs +++ b/crates/tesseract-ogar/src/lib.rs @@ -1,9 +1,12 @@ //! # tesseract-ogar — the in-binary executor for the OGAR OCR action table //! //! [`ogar_vocab::ocr_actions`] is the **authoritative** declaration of the -//! eight OCR capabilities `tesseract-rs` exposes (`recognize_line` / -//! `recognize_page` / `extract_text_layer` / `extract_page_image` / -//! `render_text` / `render_tsv` / `render_hocr` / `render_searchable_pdf`). +//! OCR capabilities `tesseract-rs` exposes — the original eight +//! (`recognize_line` / `recognize_page` / `extract_text_layer` / +//! `extract_page_image` / `render_text` / `render_tsv` / `render_hocr` / +//! `render_searchable_pdf`) plus the v2 structured-document surface +//! (`recognize_page_words` / `recognize_document` / `harvest_fields` / +//! `segment_page` / `detect_halftone_regions` / `detect_page_furniture`). //! This crate is that table's executor: OGAR declares, this crate runs. //! //! ## Typed, not serialized @@ -43,7 +46,11 @@ use std::path::Path; use tesseract_core::dawg::DawgError; use tesseract_core::DictLite; -use tesseract_ocr::{LineWords, LstmRecognizer}; +use tesseract_ocr::{ + conn_comp_bb, detect_page_furniture, generate_halftone_mask, german_invoice_fields, + harden_numeric_tokens, harvest_fields, xy_cut, DocPage, Document, HarvestedField, LineWords, + LstmRecognizer, PageFurniture, PageRect, XyCutParams, +}; use tesseract_ocr_pdf::{GreyImage, PageOcr, PdfError, RenderReport, SearchablePdfError}; /// The V3-substrate <-> Python-SDK parity probe: walks a loaded `Network` @@ -67,6 +74,13 @@ pub const COVERED_CAPABILITIES: &[&str] = &[ "render_tsv", "render_hocr", "render_searchable_pdf", + // v2 (2026-07-10) — the structured-document + layout-classifier surface. + "recognize_page_words", + "recognize_document", + "harvest_fields", + "segment_page", + "detect_halftone_regions", + "detect_page_furniture", ]; /// This crate's hot-plug declaration — the GENERIC pattern every consumer @@ -179,6 +193,81 @@ pub enum OcrRequest<'a> { /// The embedded image resolution, in DPI. dpi: u32, }, + /// `recognize_page_words` — a full grey page recognized to WORD/box + /// output (the word-level counterpart of `recognize_page`). + RecognizePageWords { + /// Row-major 8-bit grey page. + grey: &'a [u8], + /// Width in pixels. + width: usize, + /// Height in pixels. + height: usize, + /// Use the loaded dictionary beam, if any. + with_dict: bool, + }, + /// `recognize_document` — the ONE-SHOT: grey page → `doc.v1` JSON + /// (classified regions) + typed field harvest. `harvest_profile` + /// selects the field set (`Some("german_invoice")`; `None` / empty = no + /// harvest; any other value FAILS with + /// [`OcrExecError::UnknownHarvestProfile`]). + RecognizeDocument { + /// Row-major 8-bit grey page. + grey: &'a [u8], + /// Width in pixels. + width: usize, + /// Height in pixels. + height: usize, + /// Use the loaded dictionary beam, if any. + with_dict: bool, + /// The field-harvest profile (`None` = no harvest). + harvest_profile: Option<&'a str>, + }, + /// `harvest_fields` — the typed field harvest over an already-recognized + /// page's word output. `harvest_profile` is required (unknown / empty + /// FAILS with [`OcrExecError::UnknownHarvestProfile`]). + HarvestFields { + /// Recognized lines, in reading order. + line_words: &'a [LineWords], + /// Page width in pixels. + page_w: u32, + /// Page height in pixels. + page_h: u32, + /// The field-harvest profile (e.g. `"german_invoice"`). + harvest_profile: &'a str, + }, + /// `segment_page` — recursive XY-cut layout segmentation (columns / + /// deimposition) → reading-ordered region rects. + SegmentPage { + /// Row-major 8-bit grey page. + grey: &'a [u8], + /// Width in pixels. + width: usize, + /// Height in pixels. + height: usize, + /// XY-cut tuning (the `min_gap_frac` / `min_region_px` / `max_depth` + /// optional params; [`XyCutParams::default`] for the defaults). + params: XyCutParams, + }, + /// `detect_halftone_regions` — the leptonica-parity halftone (image) + /// region detector over a BINARIZED page (`0` = ink convention). + DetectHalftoneRegions { + /// Row-major binarized page (`0` = foreground/ink, `255` = bg). + binary: &'a [u8], + /// Width in pixels. + width: usize, + /// Height in pixels. + height: usize, + }, + /// `detect_page_furniture` — header / footer / page-number detection over + /// an already-recognized page's word output. + DetectPageFurniture { + /// Recognized lines, in reading order. + line_words: &'a [LineWords], + /// Page width in pixels. + page_w: u32, + /// Page height in pixels. + page_h: u32, + }, } /// One typed response per declared OGAR OCR capability — see @@ -228,6 +317,34 @@ pub enum OcrResponse { /// Per-page WinAnsi lossy-substitution counts. report: RenderReport, }, + /// `recognize_page_words`'s `line_words` output. + LineWordsOut(Vec), + /// `recognize_document`'s `doc_json, fields` outputs. + DocumentOut { + /// The rendered `tesseract-rs/doc.v1` JSON. + doc_json: String, + /// The harvested typed fields (empty when no harvest ran). + fields: Vec, + }, + /// `harvest_fields`'s `fields` output. + Fields(Vec), + /// `segment_page`'s `regions_rects` output (reading-ordered leaf rects). + Regions(Vec), + /// `detect_halftone_regions`'s `figure_rects, mask_w, mask_h, found` + /// outputs. Figure rects are page-space `(left, top, right, bottom)`. + HalftoneRegions { + /// Figure component bboxes `(left, top, right, bottom)`. + figure_rects: Vec<(i32, i32, i32, i32)>, + /// Halftone mask width (may be smaller than the page). + mask_w: usize, + /// Halftone mask height. + mask_h: usize, + /// Whether any halftone region was found. + found: bool, + }, + /// `detect_page_furniture`'s `header_lines, footer_lines, page_number` + /// outputs. + PageFurnitureOut(PageFurniture), } /// A failure loading [`OcrExecutor`] or executing an [`OcrRequest`]. @@ -244,6 +361,10 @@ pub enum OcrExecError { Pdf(PdfError), /// [`tesseract_ocr_pdf::render_searchable_pdf`] failed. SearchablePdf(SearchablePdfError), + /// A `harvest_profile` value the executor does not recognize — fail-closed + /// so a typo can never silently drop invoice-field validation (v2, the + /// spec's V2-3 rule). The only value understood today is `"german_invoice"`. + UnknownHarvestProfile(String), } impl std::fmt::Display for OcrExecError { @@ -254,10 +375,30 @@ impl std::fmt::Display for OcrExecError { Self::Dawg(e) => write!(f, "dictionary assembly: {e:?}"), Self::Pdf(e) => write!(f, "PDF: {e}"), Self::SearchablePdf(e) => write!(f, "searchable PDF render: {e}"), + Self::UnknownHarvestProfile(p) => { + write!( + f, + "unknown harvest_profile {p:?} (known: \"german_invoice\")" + ) + } } } } +/// Map a `harvest_profile` string to its [`tesseract_ocr::FieldSpec`] set. +/// `None`/empty → no harvest (`Ok(None)`); `"german_invoice"` → the German +/// invoice field set; anything else fails closed +/// ([`OcrExecError::UnknownHarvestProfile`]). +fn harvest_specs( + profile: Option<&str>, +) -> Result>, OcrExecError> { + match profile.filter(|p| !p.is_empty()) { + None => Ok(None), + Some("german_invoice") => Ok(Some(german_invoice_fields())), + Some(other) => Err(OcrExecError::UnknownHarvestProfile(other.to_owned())), + } +} + impl std::error::Error for OcrExecError {} fn read_component(path: &Path) -> Result, OcrExecError> { @@ -333,6 +474,12 @@ impl OcrExecutor { OcrRequest::RenderTsv { .. } => "render_tsv", OcrRequest::RenderHocr { .. } => "render_hocr", OcrRequest::RenderSearchablePdf { .. } => "render_searchable_pdf", + OcrRequest::RecognizePageWords { .. } => "recognize_page_words", + OcrRequest::RecognizeDocument { .. } => "recognize_document", + OcrRequest::HarvestFields { .. } => "harvest_fields", + OcrRequest::SegmentPage { .. } => "segment_page", + OcrRequest::DetectHalftoneRegions { .. } => "detect_halftone_regions", + OcrRequest::DetectPageFurniture { .. } => "detect_page_furniture", } } @@ -418,6 +565,93 @@ impl OcrExecutor { .map_err(OcrExecError::SearchablePdf)?; Ok(OcrResponse::PdfBytes { bytes, report }) } + OcrRequest::RecognizePageWords { + grey, + width, + height, + with_dict, + } => { + let dict = if with_dict { self.dict.as_ref() } else { None }; + let lines = self + .recognizer + .recognize_page_makerow_words(grey, width, height, dict) + .map_err(OcrExecError::Recognizer)?; + Ok(OcrResponse::LineWordsOut(lines)) + } + OcrRequest::RecognizeDocument { + grey, + width, + height, + with_dict, + harvest_profile, + } => { + let dict = if with_dict { self.dict.as_ref() } else { None }; + let specs = harvest_specs(harvest_profile)?; + let Document { json, fields, .. } = self + .recognizer + .recognize_document(grey, width, height, dict, specs.as_deref()) + .map_err(OcrExecError::Recognizer)?; + Ok(OcrResponse::DocumentOut { + doc_json: json, + fields, + }) + } + OcrRequest::HarvestFields { + line_words, + page_w, + page_h, + harvest_profile, + } => { + // harvest_profile is required here; empty/unknown fails closed. + let specs = harvest_specs(Some(harvest_profile))?.ok_or_else(|| { + OcrExecError::UnknownHarvestProfile(harvest_profile.to_owned()) + })?; + let mut page = + DocPage::from_line_words(line_words, &self.recognizer.charset, page_w, page_h); + harden_numeric_tokens(&mut page); + Ok(OcrResponse::Fields(harvest_fields(&page, &specs))) + } + OcrRequest::SegmentPage { + grey, + width, + height, + params, + } => Ok(OcrResponse::Regions(xy_cut(grey, width, height, ¶ms))), + OcrRequest::DetectHalftoneRegions { + binary, + width, + height, + } => { + // No halftone mask (page below MinWidth/MinHeight, or empty) → + // found=false, no rects, zero mask dims. + let (figure_rects, mask_w, mask_h, found) = + match generate_halftone_mask(binary, width, height) { + Some(hm) if hm.found => { + let rects = 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(); + (rects, hm.mask_w, hm.mask_h, true) + } + Some(hm) => (Vec::new(), hm.mask_w, hm.mask_h, false), + None => (Vec::new(), 0, 0, false), + }; + Ok(OcrResponse::HalftoneRegions { + figure_rects, + mask_w, + mask_h, + found, + }) + } + OcrRequest::DetectPageFurniture { + line_words, + page_w, + page_h, + } => { + let page = + DocPage::from_line_words(line_words, &self.recognizer.charset, page_w, page_h); + Ok(OcrResponse::PageFurnitureOut(detect_page_furniture(&page))) + } } } } @@ -441,8 +675,13 @@ mod tests { HOT_PLUG.covered, ) .expect("hot-plug drifted from the authoritative OGAR tables"); - assert_eq!(concepts.len(), 3, "3 hot-plugged concepts"); + assert_eq!( + concepts.len(), + ogar_vocab::ocr_actions::OCR_SUBJECT_CLASSIDS.len(), + "one concept per hot-plugged subject classid" + ); assert!(concepts.contains(&("textline", 0x0805))); + assert!(concepts.contains(&("page_layout", 0x0807)), "v2 subject"); assert_eq!(capabilities.len(), COVERED_CAPABILITIES.len()); } @@ -502,6 +741,41 @@ mod tests { image_name: "", }, OcrRequest::RenderSearchablePdf { pages: &[], dpi: 0 }, + OcrRequest::RecognizePageWords { + grey: &[], + width: 0, + height: 0, + with_dict: false, + }, + OcrRequest::RecognizeDocument { + grey: &[], + width: 0, + height: 0, + with_dict: false, + harvest_profile: None, + }, + OcrRequest::HarvestFields { + line_words: &[], + page_w: 0, + page_h: 0, + harvest_profile: "german_invoice", + }, + OcrRequest::SegmentPage { + grey: &[], + width: 0, + height: 0, + params: XyCutParams::default(), + }, + OcrRequest::DetectHalftoneRegions { + binary: &[], + width: 0, + height: 0, + }, + OcrRequest::DetectPageFurniture { + line_words: &[], + page_w: 0, + page_h: 0, + }, ]; assert_eq!( samples.len(), @@ -547,6 +821,26 @@ mod tests { ("render_hocr", "image_name") => Some("image_name"), ("render_searchable_pdf", "pages") => Some("pages"), ("render_searchable_pdf", "dpi") => Some("dpi"), + // v2 rows. + ("recognize_page_words", "grey_page") => Some("grey"), + ("recognize_page_words", "width") => Some("width"), + ("recognize_page_words", "height") => Some("height"), + ("recognize_document", "grey_page") => Some("grey"), + ("recognize_document", "width") => Some("width"), + ("recognize_document", "height") => Some("height"), + ("harvest_fields", "line_words") => Some("line_words"), + ("harvest_fields", "page_w") => Some("page_w"), + ("harvest_fields", "page_h") => Some("page_h"), + ("harvest_fields", "harvest_profile") => Some("harvest_profile"), + ("segment_page", "grey_page") => Some("grey"), + ("segment_page", "width") => Some("width"), + ("segment_page", "height") => Some("height"), + ("detect_halftone_regions", "binary_page") => Some("binary"), + ("detect_halftone_regions", "width") => Some("width"), + ("detect_halftone_regions", "height") => Some("height"), + ("detect_page_furniture", "line_words") => Some("line_words"), + ("detect_page_furniture", "page_w") => Some("page_w"), + ("detect_page_furniture", "page_h") => Some("page_h"), _ => None, } }