Compare commits

..
Author SHA1 Message Date
Abimael Martell bb66d7175e refactor(markdown): drop paragraph-flush branch made unreachable by the boundary gate
The enclosing guard proves in_paragraph is false, so the nested flush
could never run; the guard and mono check collapse into one condition.
2026-08-17 16:46:59 -07:00
Abimael Martell 5bfc4c4a92 fix(markdown): font-based code blocks open only at paragraph boundaries
HTML-to-PDF producers smear an inline code literal's mono style across
whole wrapped lines, so a prose paragraph can alternate body and mono
fonts line by line. Fencing those lines cut sentences in three: prose
head, fenced middle, prose tail. A mono-set line that continues an open
prose paragraph now stays prose; font-based blocks open at paragraph
boundaries (or continue an open block), and struct-tree Code roles are
honored unconditionally.
2026-08-17 16:37:09 -07:00
Abimael Martell 71f5ee79e0 fix(markdown): emit sub-3-char mono fragments as plain text, not fences
A lone registered-trademark glyph or stray bullet set in a mono face is
not code; a fenced block containing one character reads as noise.
2026-08-17 16:28:08 -07:00
Abimael Martell 893c8fd44d fix(markdown): address review of font-name consumers
- Monotype is a foundry prefix on proportional faces (Monotype Corsiva,
  Monotype Garamond); it must not satisfy is_monospace_font's generic
  "mono" token. Regression tests pin both directions.
- Flush the pending code block before inserting a positioned table or
  image, so a block that falls between two code lines cannot be emitted
  ahead of code that precedes it in reading order; a code line after
  the block reopens a new fence naturally.
2026-08-17 16:12:48 -07:00
Abimael Martell 02e2cdf5e9 feat(extractor): stamp items with the font family name, not the resource tag
TextItem::font carried the page's font resource name ("F2", "T22") —
an arbitrary per-page tag — even though both content-stream parsers
already resolve the /BaseFont family name for bold/italic detection at
every item-creation site. Stamp that resolved family name instead
("ABCDEF+CMMI10", "Courier"), from a single item_font_name helper so
the two parsers cannot drift.

One deliberate carve-out, documented on the helper: resource names
using Distiller's CID convention (C2_0, C0_1) are kept as-is, because
text_utils::is_cid_font keys on that prefix for micro-gap joining and
the family name carries no CID marker to replace it.

Consumers that match on font names start working against real names:

- Code detection (is_monospace_font) previously never fired against
  opaque resource tags. It now does — so line classification also moves
  from any-item matching to a majority-by-characters rule
  (line_is_monospace): code lines are wholly monospace, while a lone
  URL or identifier styled in a mono face inside a prose line must not
  fence the surrounding sentence.
- Heading/body font grouping now merges resource aliases of the same
  family instead of treating them as distinct fonts.
- Positioned-item output (--items-json and the bindings) reports real
  face names.

Regression corpus: code-heavy manuals improve substantially (assembly
and C snippets previously emitted as prose now fence with line
structure preserved); remaining churn reviewed as improvements.
2026-08-17 15:33:01 -07:00
5 changed files with 96 additions and 781 deletions
+12 -150
View File
@@ -403,15 +403,8 @@ pub(crate) fn detect_from_document(
&& !(analysis.has_decodable_text_fonts && analysis.text_operator_count >= 10);
let looks_like_scan =
analysis.image_count <= 1 && analysis.text_operator_count < 50 && alphanum_low;
// A template-image page below the `pages_with_text` floor is
// a scan with incidental chrome (masthead, stamp, date line)
// even when that chrome is diverse, decodable text — keep
// this in sync with `page_ocr_signals`.
let sparse_text_over_scan = analysis.has_template_image
&& analysis.text_operator_count < config.min_text_ops_per_page.max(10);
if (analysis.has_template_image && looks_like_scan)
|| analysis.has_vector_text
|| sparse_text_over_scan
|| (analysis.text_operator_count < config.min_text_ops_per_page
&& analysis.has_images)
{
@@ -1802,24 +1795,17 @@ pub(crate) fn analyze_page_images(doc: &Document, page_id: ObjectId) -> (bool, u
/// low alphanumeric diversity in raw string operands (unless decodable
/// CID/ToUnicode fonts explain that away) — the gate used for
/// `pages_with_template_images` and Mixed-type per-page routing.
/// 2. Insufficient real text volume, using the same `effective_min_ops`
/// floor (`min_text_ops_per_page.max(10)`) that `pages_with_text`
/// applies to image-bearing pages. That floor is a per-page judgment,
/// not part of the cross-page aggregate: classification counts a
/// template-image page with fewer ops as textless and routes it to OCR,
/// so this function must agree. The lower bare threshold (3) let a
/// full-page scan carrying a small native masthead — a newspaper
/// header, stamp, or date line of ~4 diverse, decodable text ops —
/// extract as "a text page" here while whole-document classification
/// called the same page scanned, silently dropping the page body from
/// OCR routing. `alphanum_low` can't catch that case: masthead chrome
/// is real text, so its byte diversity is high.
///
/// This function always evaluates against `DetectionConfig::default()` —
/// it has no config parameter, and the per-page extraction path that calls
/// it never carries one. A caller passing a custom `min_text_ops_per_page`
/// to `detect_from_document` affects whole-document detection only; the
/// two paths agree under the default configuration.
/// 2. Insufficient real text volume, using `DetectionConfig::default()`'s
/// `min_text_ops_per_page` (3) — the same threshold Mixed-type per-page
/// routing applies via `text_operator_count < config.min_text_ops_per_page
/// && has_images` (simplified here since a template image implies
/// `has_images`). Deliberately *not* the higher `effective_min_ops`
/// floor (`min_text_ops_per_page.max(10)`) that whole-document
/// `PdfType::ImageBased`/`Scanned` classification uses for
/// `pages_with_text` — that's a cross-page aggregate decision this
/// per-page function has no way to replicate exactly, and the lower
/// per-page threshold is the one a single page's own signals can
/// actually agree with.
///
/// `has_vector_text` is true when a page has vector-outlined text (glyphs
/// drawn as paths rather than shown via text-showing operators) —
@@ -1841,7 +1827,7 @@ pub(crate) fn page_ocr_signals(doc: &Document, page_id: ObjectId) -> (bool, bool
let looks_like_scan =
analysis.image_count <= 1 && analysis.text_operator_count < 50 && alphanum_low;
let insufficient_text =
analysis.text_operator_count < DetectionConfig::default().min_text_ops_per_page.max(10);
analysis.text_operator_count < DetectionConfig::default().min_text_ops_per_page;
looks_like_scan || insufficient_text
};
@@ -3009,130 +2995,6 @@ mod tests {
);
}
// ---------- masthead-over-scan tests: template image + sparse chrome ----------
/// Builds a page whose only image is a full-page scan inside a Form
/// XObject, plus `masthead_ops` native text-show ops of diverse,
/// decodable chrome (newspaper masthead / date line style).
fn masthead_scan_page(masthead_lines: &[&str]) -> (Document, ObjectId) {
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
let image_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {
"Type" => "XObject",
"Subtype" => Object::Name(b"Image".to_vec()),
"Width" => Object::Integer(1500),
"Height" => Object::Integer(2383),
},
Vec::new(),
)));
let form_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {
"Type" => "XObject",
"Subtype" => Object::Name(b"Form".to_vec()),
"Resources" => dictionary! {
"XObject" => dictionary! {
"Im0" => Object::Reference(image_id),
},
},
},
b"1500 0 0 2383 0 0 cm /Im0 Do".to_vec(),
)));
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
});
let mut content = b"q /Fm0 Do Q BT /F1 12 Tf ".to_vec();
for line in masthead_lines {
content.extend_from_slice(format!("({line}) Tj ").as_bytes());
}
content.extend_from_slice(b"ET");
let content_id =
doc.add_object(Object::Stream(lopdf::Stream::new(dictionary! {}, content)));
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"MediaBox" => vec![0.into(), 0.into(), 1500.into(), 2383.into()],
"Resources" => dictionary! {
"Font" => dictionary! { "F1" => Object::Reference(font_id) },
"XObject" => dictionary! { "Fm0" => Object::Reference(form_id) },
},
"Contents" => Object::Reference(content_id),
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
(doc, page_id)
}
#[test]
fn test_masthead_over_form_wrapped_scan_needs_ocr() {
// A full-page scan wrapped in a Form XObject with ~4 ops of real,
// diverse masthead text. `alphanum_low` can't flag it (the chrome is
// genuine text), so the sparse-text floor must: without OCR the page
// body is silently lost while classification calls the page scanned.
let (doc, page_id) = masthead_scan_page(&[
"18",
"FINANCIAL EXPRESS",
"WWW.FINANCIALEXPRESS.COM",
"FRIDAY, DECEMBER 13, 2024",
]);
let analysis = analyze_page_content(&doc, page_id);
assert!(
analysis.has_template_image,
"sanity: full-page image inside the form must be found"
);
assert!(
analysis.unique_alphanum_chars >= 10,
"sanity: masthead text is diverse, alphanum_low cannot fire"
);
let (needs_ocr, _) = page_ocr_signals(&doc, page_id);
assert!(
needs_ocr,
"template image + text below the pages_with_text floor is a scan"
);
}
#[test]
fn test_text_page_over_background_image_stays_native() {
// Counterpart: a real text page over a full-page background image
// (letterhead/watermark) has enough text ops to clear the
// `pages_with_text` floor and must NOT be routed to OCR.
let lines: Vec<String> = (0..12)
.map(|i| format!("Paragraph line {i} with ordinary body text"))
.collect();
let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
let (doc, page_id) = masthead_scan_page(&refs);
let analysis = analyze_page_content(&doc, page_id);
assert!(
analysis.has_template_image,
"sanity: background image found"
);
assert!(
analysis.text_operator_count >= 10,
"sanity: body text clears the floor"
);
let (needs_ocr, _) = page_ocr_signals(&doc, page_id);
assert!(
!needs_ocr,
"a text page with a background image must stay native"
);
}
// ---------- P2 tests: Form XObject font traversal ----------
#[test]
+47 -170
View File
@@ -462,95 +462,19 @@ fn try_xy_cut_split(
])
}
/// A prose line must span at least this fraction of its column's width to
/// count as "full" — shared by the gate's ratio and run measurements.
const LINE_FILL_THRESHOLD: f32 = 0.45;
/// Per-column line measurements backing the prose-evidence predicates in
/// [`columns_have_prose`]: how many grouped lines exist, how many are
/// "full" (span most of the column), the longest *vertically contiguous*
/// run of full lines, and the item count per line.
#[derive(Default)]
struct ProseLineStats {
full_lines: usize,
total_lines: usize,
total_items: usize,
current_run: usize,
best_run: usize,
previous_line_y: Option<f32>,
previous_line_height: f32,
}
impl ProseLineStats {
/// A run only counts as a paragraph block while lines follow at normal
/// leading; a full line resuming after a figure-sized vertical gap
/// starts a new run rather than extending the previous one.
const MAX_LEADING_FACTOR: f32 = 2.5;
fn flush_line(&mut self, line_items: &[&TextItem], col: &ColumnRegion, col_width: f32) {
if line_items.is_empty() {
return;
}
self.total_lines += 1;
self.total_items += line_items.len();
let line_y = line_items[0].y;
let line_height = line_items
.iter()
.map(|i| i.height)
.fold(0.0_f32, f32::max)
.max(1.0);
if let Some(previous_y) = self.previous_line_y {
let leading = (previous_y - line_y).abs();
if leading > self.previous_line_height.max(line_height) * Self::MAX_LEADING_FACTOR {
self.current_run = 0;
}
}
self.previous_line_y = Some(line_y);
self.previous_line_height = line_height;
// Compute the span of text on this line within the column
let left = line_items
.iter()
.map(|i| i.x.max(col.x_min))
.fold(f32::INFINITY, f32::min);
let right = line_items
.iter()
.map(|i| (i.x + effective_width(i)).min(col.x_max))
.fold(f32::NEG_INFINITY, f32::max);
let span = (right - left).max(0.0);
if span >= col_width * LINE_FILL_THRESHOLD {
self.full_lines += 1;
self.current_run += 1;
self.best_run = self.best_run.max(self.current_run);
} else {
self.current_run = 0;
}
}
}
/// Check whether each proposed column contains paragraph-like content.
///
/// Groups items per column into rough lines by Y-proximity, then measures
/// what fraction of those lines span a significant portion of the column
/// width. Two-column prose (justified or ragged-right) produces lines that
/// fill most of the column width. Tables, forms, and checklists produce
/// short scattered items that don't. A column whose global ratio is diluted
/// by a figure still qualifies through a sustained run of consecutive
/// full-width lines at normal leading — a paragraph block scattered
/// layouts cannot produce.
/// short scattered items that don't.
///
/// Returns true only when *every* column passes the prose evidence.
/// Returns true only when *every* column passes a minimum prose density.
fn columns_have_prose(columns: &[ColumnRegion], items: &[&TextItem]) -> bool {
const Y_TOL: f32 = 3.0; // y-proximity to group items into the same line
const MIN_PROSE_RATIO: f32 = 0.40; // ≥40% of lines must be "full"...
// ...or the column contains a sustained paragraph block: this many
// CONSECUTIVE full lines. A prose column hosting a figure + caption can
// fall under the global ratio (the fragments dilute it), but the
// scattered layouts this gate exists to reject — tables, TOCs,
// checklists, forms — cannot produce an unbroken block of full-width
// lines.
const MIN_PROSE_RUN: usize = 6;
const LINE_FILL_THRESHOLD: f32 = 0.45; // line must span ≥45% of column width
const MIN_PROSE_RATIO: f32 = 0.40; // ≥40% of lines must be "full"
const MIN_LINES: usize = 8; // need enough lines to judge
const MIN_COL_WIDTH: f32 = 120.0; // columns must be ≥120pt (not narrow sidebars/fragments)
const MAX_AVG_ITEMS_PER_LINE: f32 = 3.5; // prose has 1-3 items/line; tables/forms have 4+
@@ -580,10 +504,36 @@ fn columns_have_prose(columns: &[ColumnRegion], items: &[&TextItem]) -> bool {
sorted.sort_by(|a, b| b.y.total_cmp(&a.y));
// Group into lines by Y-proximity and measure fill + item count
let mut stats = ProseLineStats::default();
let mut full_lines = 0usize;
let mut total_lines = 0usize;
let mut total_items_in_lines = 0usize;
let mut line_items: Vec<&TextItem> = Vec::new();
let mut line_y = f32::NAN;
let flush_line = |line_items: &[&TextItem],
full: &mut usize,
total: &mut usize,
total_items: &mut usize| {
if line_items.is_empty() {
return;
}
*total += 1;
*total_items += line_items.len();
// Compute the span of text on this line within the column
let left = line_items
.iter()
.map(|i| i.x.max(col.x_min))
.fold(f32::INFINITY, f32::min);
let right = line_items
.iter()
.map(|i| (i.x + effective_width(i)).min(col.x_max))
.fold(f32::NEG_INFINITY, f32::max);
let span = (right - left).max(0.0);
if span >= col_width * LINE_FILL_THRESHOLD {
*full += 1;
}
};
for item in &sorted {
if line_items.is_empty() || (line_y - item.y).abs() < Y_TOL {
if line_items.is_empty() {
@@ -591,29 +541,35 @@ fn columns_have_prose(columns: &[ColumnRegion], items: &[&TextItem]) -> bool {
}
line_items.push(item);
} else {
stats.flush_line(&line_items, col, col_width);
flush_line(
&line_items,
&mut full_lines,
&mut total_lines,
&mut total_items_in_lines,
);
line_items.clear();
line_y = item.y;
line_items.push(item);
}
}
stats.flush_line(&line_items, col, col_width);
flush_line(
&line_items,
&mut full_lines,
&mut total_lines,
&mut total_items_in_lines,
);
if stats.total_lines < MIN_LINES {
if total_lines < MIN_LINES {
return false;
}
let full_lines = stats.full_lines;
let total_lines = stats.total_lines;
let best_run = stats.best_run;
let total_items_in_lines = stats.total_items;
let ratio = full_lines as f32 / total_lines as f32;
let avg_items = total_items_in_lines as f32 / total_lines as f32;
debug!(
"columns_have_prose: col [{:.0}..{:.0}] lines={} full={} ratio={:.2} run={} avg_items={:.1}",
col.x_min, col.x_max, total_lines, full_lines, ratio, best_run, avg_items
"columns_have_prose: col [{:.0}..{:.0}] lines={} full={} ratio={:.2} avg_items={:.1}",
col.x_min, col.x_max, total_lines, full_lines, ratio, avg_items
);
if ratio < MIN_PROSE_RATIO && best_run < MIN_PROSE_RUN {
if ratio < MIN_PROSE_RATIO {
return false;
}
// Tables and forms tend to have many small items per line (one per cell),
@@ -2611,85 +2567,6 @@ mod tests {
}
}
#[test]
fn prose_gate_accepts_figure_diluted_column_via_run() {
// A prose column hosting a figure: 9 consecutive full-width lines
// (a paragraph block) followed by many short caption/figure
// fragments. The global full-line ratio falls under 40%, but the
// sustained run proves flowing prose.
let mut items: Vec<TextItem> = Vec::new();
for line in 0..9 {
// full-width prose line, ~230pt wide in a 250pt column
items.push(make_item(
1,
10.0,
700.0 - line as f32 * 14.0,
&"m".repeat(38),
));
}
for line in 0..16 {
// short figure/caption fragments
items.push(make_item(1, 60.0, 560.0 - line as f32 * 14.0, "cap"));
}
let column = ColumnRegion {
x_min: 0.0,
x_max: 250.0,
};
let refs: Vec<&TextItem> = items.iter().collect();
assert!(columns_have_prose(&[column], &refs));
}
#[test]
fn prose_gate_run_requires_vertical_continuity() {
// Full-width lines separated by figure-sized vertical gaps are not
// a paragraph block: the run must reset across large leading, so a
// column of scattered wide labels stays rejected.
let mut items: Vec<TextItem> = Vec::new();
for line in 0..6 {
// Wide labels, sequence-consecutive but 90pt apart — far beyond
// normal leading. Without the continuity rule they would count
// as a 6-line paragraph block.
items.push(make_item(
1,
10.0,
720.0 - line as f32 * 90.0,
&"m".repeat(38),
));
}
for line in 0..12 {
// Short fragments below, keeping total lines high and the
// global full-line ratio (6/18) under the 40% bar.
items.push(make_item(1, 60.0, 150.0 - line as f32 * 12.0, "box"));
}
let column = ColumnRegion {
x_min: 0.0,
x_max: 250.0,
};
let refs: Vec<&TextItem> = items.iter().collect();
assert!(!columns_have_prose(&[column], &refs));
}
#[test]
fn prose_gate_rejects_scattered_short_lines() {
// Checklist/form-like column: no sustained block of full lines and
// a low global ratio must still be rejected.
let mut items: Vec<TextItem> = Vec::new();
for line in 0..24 {
let text = if line % 4 == 0 {
"m".repeat(38)
} else {
"box".to_string()
};
items.push(make_item(1, 10.0, 700.0 - line as f32 * 14.0, &text));
}
let column = ColumnRegion {
x_min: 0.0,
x_max: 250.0,
};
let refs: Vec<&TextItem> = items.iter().collect();
assert!(!columns_have_prose(&[column], &refs));
}
/// Generate dense items in a horizontal zone across many Y positions.
/// Items are placed with overlapping coverage so no intra-zone valleys appear.
fn fill_zone(page: u32, x_start: f32, x_end: f32, y_start: f32, y_end: f32) -> Vec<TextItem> {
-7
View File
@@ -237,13 +237,6 @@ pub trait OcrEngine: Send + Sync {
pages: &[RenderedPage],
options: &OcrOptions,
) -> Result<Vec<OcrPage>, Self::Error>;
/// Number of pages this engine can process concurrently in one
/// `recognize` call. The pipeline sizes its page batches from this so a
/// parallel engine is not starved by small chunks; `1` means sequential.
fn preferred_page_concurrency(&self) -> usize {
1
}
}
#[cfg(test)]
+36 -429
View File
@@ -1,14 +1,11 @@
//! PP-OCRv6 Small implementation backed by OAR and ONNX Runtime.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use image::RgbImage;
use oar_ocr::core::config::onnx::OrtSessionConfig;
use oar_ocr::domain::tasks::TextDetectionConfig;
use oar_ocr::oarocr::{EdgeProcessor, TextCroppingProcessor};
use oar_ocr::predictors::{TextDetectionPredictor, TextRecognitionPredictor};
use oar_ocr::oarocr::{OAROCRBuilder, OAROCR};
use oar_ocr::processors::BoundingBox;
use thiserror::Error;
@@ -73,170 +70,17 @@ pub enum OarOcrError {
Backend(#[from] oar_ocr::core::OCRError),
}
/// Standard detection input cap. PP-OCR detection resizes each page so its
/// longest side fits this before inference; it is the PaddleOCR default and
/// is sufficient for ordinary body text at 150 DPI.
const DETECTION_LIMIT_STANDARD: u32 = 960;
/// Escalated detection input cap for dense fine-print pages. Beyond this the
/// measured recall plateaus while inference cost keeps growing.
const DETECTION_LIMIT_ESCALATED: u32 = 2560;
/// Hard ceiling protecting detection from out-of-memory on giant renders.
const DETECTION_MAXIMUM_SIDE: u32 = 4000;
/// Escalate only for pages dense with small text: at least this many detected
/// regions in the standard pass...
const ESCALATION_MINIMUM_REGIONS: usize = 80;
/// ...whose median height, at detection scale, is below this. Calibrated at
/// `unclip_ratio` 2.0 (the expansion inflates measured heights, so this
/// constant is coupled to [`detection_config`]): dense fine-print pages that
/// gain from escalation measure 12.014.2 px with 144+ regions; the nearest
/// non-gaining page above the region gate (an engineering drawing) measures
/// 15.7 px, and prose/typewriter pages measure 14.5 px+ with too few
/// regions to qualify at all.
const ESCALATION_MAXIMUM_MEDIAN_HEIGHT: f32 = 15.0;
/// One worker's model sessions: a standard-limit detector plus a recognizer,
/// and that worker's own lazily built escalated-limit detector.
/// Staged (detect, crop, recognize as separate calls) rather than OAROCR's
/// combined `predict` so an escalated page replaces only its detection pass —
/// recognition runs exactly once, on the final region set.
struct OcrWorker {
detector: TextDetectionPredictor,
recognizer: TextRecognitionPredictor,
/// Built on this worker's first dense fine-print page. `None` inside the
/// cell records a failed build so it is not retried per page.
escalated: std::sync::OnceLock<Option<TextDetectionPredictor>>,
}
/// CPU PP-OCRv6 Small engine using OAR's detection and recognition components.
/// CPU PP-OCRv6 Small engine using OAR's detection and recognition pipeline.
///
/// Construction accepts only [`ModelPaths`] that have already passed
/// pdf-inspector's manifest size and SHA-256 verification. OAR's independent
/// model auto-download feature is deliberately not enabled.
#[derive(Debug)]
pub struct OarOcrEngine {
workers: Vec<OcrWorker>,
detection_path: PathBuf,
intra_threads: usize,
/// Present only when more than one worker exists; sized to match.
pool: Option<rayon::ThreadPool>,
pipeline: OAROCR,
model: ModelIdentity,
}
impl std::fmt::Debug for OarOcrEngine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OarOcrEngine")
.field("workers", &self.workers.len())
.field("parallel", &self.pool.is_some())
.field("model", &self.model)
.finish()
}
}
/// Pages processed concurrently: one OAROCR pipeline (and its ONNX sessions)
/// per worker, because oar-ocr serializes each session behind a mutex.
/// Measured on CPU: workers beyond 3 stop scaling (memory-bandwidth bound)
/// and each worker is fastest with 2 intra-op threads.
fn pipeline_concurrency() -> usize {
let cores = std::thread::available_parallelism()
.map(std::num::NonZeroUsize::get)
.unwrap_or(1);
(cores / 4).clamp(1, 3)
}
fn intra_threads_per_pipeline(concurrency: usize) -> usize {
let cores = std::thread::available_parallelism()
.map(std::num::NonZeroUsize::get)
.unwrap_or(1);
if concurrency > 1 {
2
} else {
cores.min(4)
}
}
/// True when a standard-limit detection pass over a downscaled page shows
/// dense, small text: the page deserves a second pass at the escalated limit.
fn should_escalate_detection(
median_detection_height: f32,
region_count: usize,
downscale: f32,
) -> bool {
downscale < 1.0
&& region_count >= ESCALATION_MINIMUM_REGIONS
&& median_detection_height < ESCALATION_MAXIMUM_MEDIAN_HEIGHT
}
/// Median detected-region height in detection-input pixels: original-image
/// heights multiplied by the downscale detection applied.
fn median_detection_height(heights: &mut [f32], downscale: f32) -> f32 {
if heights.is_empty() {
return f32::MAX;
}
heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let middle = heights.len() / 2;
let median = if heights.len().is_multiple_of(2) {
(heights[middle - 1] + heights[middle]) / 2.0
} else {
heights[middle]
};
median * downscale
}
/// Detection preprocessing config at a given input cap.
///
/// Supplying an explicit config suppresses OAROCR's "general" text-type
/// overrides, so every field the override would have set must be pinned
/// here to match what the combined pipeline ran with before the staged
/// split: score 0.3 and box 0.6 (equal to [`TextDetectionConfig`]'s
/// defaults) and unclip 2.0 (the default is 1.5 — leaving it would
/// silently shrink detection-box expansion and risk clipping edge glyphs).
fn detection_config(detection_limit: u32) -> TextDetectionConfig {
TextDetectionConfig {
limit_side_len: Some(detection_limit),
limit_type: Some(oar_ocr::processors::LimitType::Max),
max_side_len: Some(DETECTION_MAXIMUM_SIDE),
unclip_ratio: 2.0,
..Default::default()
}
}
fn build_detector(
detection: &std::path::Path,
detection_limit: u32,
intra_threads: usize,
) -> Result<TextDetectionPredictor, OarOcrError> {
Ok(TextDetectionPredictor::builder()
.with_config(detection_config(detection_limit))
.with_ort_config(ocr_session_config(intra_threads))
.build(detection)?)
}
fn build_workers(
detection: &std::path::Path,
recognition: &std::path::Path,
dictionary: &std::path::Path,
count: usize,
intra_threads: usize,
) -> Result<Vec<OcrWorker>, OarOcrError> {
let mut workers = Vec::with_capacity(count);
for _ in 0..count {
let detector = build_detector(detection, DETECTION_LIMIT_STANDARD, intra_threads)?;
let recognizer = TextRecognitionPredictor::builder()
.dict_path(dictionary)
.with_ort_config(ocr_session_config(intra_threads))
.build(recognition)?;
workers.push(OcrWorker {
detector,
recognizer,
escalated: std::sync::OnceLock::new(),
});
}
Ok(workers)
}
impl OarOcrEngine {
/// Loads PP-OCRv6 Small from a resolved, verified model set.
pub fn from_models(models: &ModelPaths) -> Result<Self, OarOcrError> {
@@ -245,169 +89,36 @@ impl OarOcrEngine {
let recognition = required_model(models, ModelArtifactKind::TextRecognition)?;
let dictionary = required_model(models, ModelArtifactKind::CharacterDictionary)?;
let concurrency = pipeline_concurrency();
let intra_threads = intra_threads_per_pipeline(concurrency);
let workers = build_workers(
detection,
recognition,
dictionary,
concurrency,
intra_threads,
)?;
let pool = if concurrency > 1 {
rayon::ThreadPoolBuilder::new()
.num_threads(concurrency)
.build()
.ok()
} else {
None
};
let pipeline = OAROCRBuilder::new(detection, recognition, dictionary)
.ort_session(ocr_session_config())
// Document line crops often have very different widths. Keeping
// CPU recognition batches at one avoids padding every crop to the
// widest line, reducing both inference work and peak memory.
.region_batch_size(1)
.build()?;
let model = ModelIdentity::new(models.manifest_id(), models.revision());
Ok(Self {
workers,
detection_path: detection.to_path_buf(),
intra_threads,
pool,
model,
})
}
/// This worker's escalated-limit detector, built on first use.
fn escalated_detector<'w>(&self, worker: &'w OcrWorker) -> Option<&'w TextDetectionPredictor> {
worker
.escalated
.get_or_init(|| {
match build_detector(
&self.detection_path,
DETECTION_LIMIT_ESCALATED,
self.intra_threads,
) {
Ok(detector) => Some(detector),
Err(error) => {
log::warn!(
"escalated OCR detection unavailable, keeping standard pass: {error}"
);
None
}
}
})
.as_ref()
}
/// Detects text regions for one page: a standard-limit pass first, then —
/// for pages the standard limit demonstrably under-resolves — a second
/// pass at the escalated limit whose boxes replace the first. Pages whose
/// render dwarfs even the escalated limit skip the standard pass outright.
fn detect_boxes(
&self,
page: &RenderedPage,
image: &Arc<RgbImage>,
worker: &OcrWorker,
) -> Result<Vec<BoundingBox>, OarOcrError> {
let longest_side = page.width().max(page.height()) as f32;
// A page more than twice the standard limit loses over half its
// resolution before detection even runs; go straight to the escalated
// detector instead of paying a doomed standard pass.
if longest_side > (DETECTION_LIMIT_STANDARD * 2) as f32 {
if let Some(escalated) = self.escalated_detector(worker) {
log::debug!(
"page {}: direct escalated detection (render {longest_side}px)",
page.page(),
);
match detect_with(escalated, image, page.page()) {
Ok(boxes) => return Ok(boxes),
Err(error) => {
// Same degradation as the adaptive branch below: a
// failing escalated pass falls back to standard
// detection instead of failing the page outright.
// Return the standard boxes directly — the adaptive
// trigger would only re-invoke the detector that
// just failed (repeating an OOM on a dense page).
log::warn!(
"page {}: direct escalated detection failed, using standard pass: {error}",
page.page()
);
return detect_with(&worker.detector, image, page.page());
}
}
}
}
let detections = detect_with(&worker.detector, image, page.page())?;
// Dense fine-print pages (broadsheets, pricing sheets) lose most of
// their text when detection downscales them to the standard limit.
// When the standard pass shows many regions of tiny detection-scale
// height, rerun detection at the escalated limit.
let downscale = (DETECTION_LIMIT_STANDARD as f32 / longest_side).min(1.0);
let mut heights: Vec<f32> = detections.iter().map(polygon_height).collect();
let median = median_detection_height(&mut heights, downscale);
log::trace!(
"page {}: standard pass {} regions, median height {:.1}px at detection scale",
page.page(),
detections.len(),
median
);
if should_escalate_detection(median, detections.len(), downscale) {
log::debug!(
"page {}: escalating detection ({} regions, median height {:.1}px at detection scale)",
page.page(),
detections.len(),
median
);
if let Some(escalated) = self.escalated_detector(worker) {
match detect_with(escalated, image, page.page()) {
Ok(escalated_boxes) => return Ok(escalated_boxes),
Err(error) => {
log::warn!(
"page {}: escalated detection failed, keeping standard pass: {error}",
page.page()
);
}
}
}
}
Ok(detections)
Ok(Self { pipeline, model })
}
fn recognize_page(
&self,
page: &RenderedPage,
options: &OcrOptions,
worker: usize,
) -> Result<OcrPage, OarOcrError> {
let started = Instant::now();
let worker = &self.workers[worker % self.workers.len()];
let image = Arc::new(rendered_page_to_rgb(page)?);
let boxes = self.detect_boxes(page, &image, worker)?;
// Reading order, matching what the combined pipeline produced.
let boxes = oar_ocr::processors::sort_quad_boxes(&boxes);
let image = rendered_page_to_rgb(page)?;
let result = self
.pipeline
.predict(vec![image])?
.into_iter()
.next()
.ok_or(OarOcrError::MissingPageResult { page: page.page() })?;
// Same rotation-aware cropping the combined pipeline uses.
let crops =
TextCroppingProcessor::new(true).process((Arc::clone(&image), boxes.clone()))?;
drop(image);
let recognizer = &worker.recognizer;
let mut spans = Vec::with_capacity(boxes.len());
let mut spans = Vec::with_capacity(result.text_regions.len());
let mut invalid_geometry = 0usize;
let mut missing_recognition = 0usize;
for (bounding_box, crop) in boxes.iter().zip(crops) {
let Some(crop) = crop else {
invalid_geometry += 1;
continue;
};
// One crop per call: document line crops often have very
// different widths, and batching pads every crop to the widest
// line. Measured on CPU, batched recognition (even width-sorted)
// is 23× slower than per-crop calls.
let crop = Arc::try_unwrap(crop).unwrap_or_else(|shared| (*shared).clone());
let recognized = recognizer.predict(vec![crop])?;
let (Some(text), Some(confidence)) = (
recognized.texts.into_iter().next(),
recognized.scores.into_iter().next(),
) else {
for region in result.text_regions {
let (Some(text), Some(confidence)) = (region.text, region.confidence) else {
missing_recognition += 1;
continue;
};
@@ -420,21 +131,16 @@ impl OarOcrEngine {
continue;
}
let Some(polygon) = bounding_box_to_quad(bounding_box, page.width(), page.height())
else {
let polygon = region.dt_poly.as_ref().unwrap_or(&region.bounding_box);
let Some(polygon) = bounding_box_to_quad(polygon, page.width(), page.height()) else {
invalid_geometry += 1;
continue;
};
spans.push(OcrSpan {
text,
text: text.to_string(),
polygon,
confidence,
// The combined pipeline's orientation_angle came from the
// text-line-orientation classifier, a model this engine has
// never loaded — it was structurally None before the staged
// split too (the staged/combined A/B was byte-identical).
// Region rotation is still carried by the polygon itself.
orientation_degrees: None,
orientation_degrees: region.orientation_angle,
});
}
@@ -468,42 +174,12 @@ impl OarOcrEngine {
}
}
/// Runs one detector over one page image and returns its region polygons.
fn detect_with(
detector: &TextDetectionPredictor,
image: &Arc<RgbImage>,
page_number: u32,
) -> Result<Vec<BoundingBox>, OarOcrError> {
let mut result = detector.predict(vec![(**image).clone()])?;
if result.detections.is_empty() {
return Err(OarOcrError::MissingPageResult { page: page_number });
}
Ok(result
.detections
.swap_remove(0)
.into_iter()
.map(|detection| detection.bbox)
.collect())
}
/// Vertical extent of a detection polygon in original-image pixels.
fn polygon_height(polygon: &BoundingBox) -> f32 {
let mut min_y = f32::MAX;
let mut max_y = f32::MIN;
for point in &polygon.points {
min_y = min_y.min(point.y);
max_y = max_y.max(point.y);
}
if max_y > min_y {
max_y - min_y
} else {
0.0
}
}
fn ocr_session_config(intra_threads: usize) -> OrtSessionConfig {
fn ocr_session_config() -> OrtSessionConfig {
let available = std::thread::available_parallelism()
.map(std::num::NonZeroUsize::get)
.unwrap_or(1);
OrtSessionConfig::new()
.with_intra_threads(intra_threads.max(1))
.with_intra_threads(available.min(4))
.with_inter_threads(1)
.with_parallel_execution(false)
}
@@ -550,33 +226,10 @@ impl OcrEngine for OarOcrEngine {
) -> Result<Vec<OcrPage>, Self::Error> {
validate_options(options)?;
let Some(pool) = self.pool.as_ref().filter(|_| pages.len() > 1) else {
return pages
.iter()
.map(|page| self.recognize_page(page, options, 0))
.collect();
};
pool.install(|| {
use rayon::prelude::*;
pages
.par_iter()
.map(|page| {
let worker = rayon::current_thread_index().unwrap_or(0);
self.recognize_page(page, options, worker)
})
.collect()
})
}
fn preferred_page_concurrency(&self) -> usize {
// Without a pool, recognition runs sequentially regardless of worker
// count — report that honestly so the pipeline doesn't render
// oversized page batches for parallelism that isn't there.
if self.pool.is_some() {
self.workers.len()
} else {
1
}
pages
.iter()
.map(|page| self.recognize_page(page, options))
.collect()
}
}
@@ -723,13 +376,10 @@ mod tests {
#[test]
fn cpu_session_budget_is_bounded_for_small_ocr_models() {
let concurrency = pipeline_concurrency();
let config = ocr_session_config(intra_threads_per_pipeline(concurrency));
let config = ocr_session_config();
assert!((1..=4).contains(&config.intra_threads.unwrap()));
assert_eq!(config.inter_threads, Some(1));
assert_eq!(config.parallel_execution, Some(false));
// Zero requests are clamped so a session always has a thread.
assert_eq!(ocr_session_config(0).intra_threads, Some(1));
}
fn page(format: RenderPixelFormat, stride: usize, pixels: Vec<u8>) -> RenderedPage {
@@ -843,47 +493,4 @@ mod tests {
)
.is_ok());
}
#[test]
fn escalation_fires_for_dense_fine_print_pages() {
// Measured cases (at unclip 2.0) that gain from escalation: dense
// tiled ad pages (12.314.2px, 158286 regions) and a dense pricing
// sheet (12.0px, 144 regions), all downscaled by the standard limit.
assert!(should_escalate_detection(14.2, 186, 0.55));
assert!(should_escalate_detection(13.1, 286, 0.55));
assert!(should_escalate_detection(12.3, 158, 0.55));
assert!(should_escalate_detection(12.0, 144, 0.55));
}
#[test]
fn escalation_skips_ordinary_pages() {
// Academic prose: too few regions (and tall enough at unclip 2.0).
assert!(!should_escalate_detection(14.5, 47, 0.58));
// Engineering drawing: many regions but tall enough text.
assert!(!should_escalate_detection(15.7, 205, 0.58));
// Typewriter scan: tall text, few regions.
assert!(!should_escalate_detection(17.5, 77, 0.55));
// Page not downscaled at all: escalation cannot add pixels.
assert!(!should_escalate_detection(9.0, 300, 1.0));
}
#[test]
fn median_detection_height_scales_and_handles_empty() {
let mut heights = vec![30.0, 10.0, 20.0];
assert_eq!(median_detection_height(&mut heights, 0.5), 10.0);
// Even counts average the two middle values instead of picking the
// upper one, so borderline pages don't skew away from escalation.
let mut even = vec![10.0, 12.0, 14.0, 30.0];
assert_eq!(median_detection_height(&mut even, 1.0), 13.0);
let mut empty: Vec<f32> = Vec::new();
assert_eq!(median_detection_height(&mut empty, 0.5), f32::MAX);
}
#[test]
fn concurrency_derivations_stay_in_bounds() {
let concurrency = pipeline_concurrency();
assert!((1..=3).contains(&concurrency));
assert!(intra_threads_per_pipeline(2) == 2);
assert!((1..=4).contains(&intra_threads_per_pipeline(1)));
}
}
+1 -25
View File
@@ -31,17 +31,6 @@ use super::{
/// Bounds live rendered-page memory while preserving small OCR batches.
const OCR_PAGE_CHUNK_SIZE: usize = 4;
/// Pages rendered and held in memory per OCR batch. A parallel engine gets
/// three waves of work per batch so its workers are not starved at chunk
/// barriers; a sequential engine keeps the small memory-bounding default.
/// Engine-reported concurrency is a trait hook, so it is clamped before
/// sizing anything from it — this helper exists to bound rendered-page
/// memory and must not let an engine inflate it arbitrarily.
fn ocr_page_chunk_size(engine_concurrency: usize) -> usize {
const MAX_ENGINE_CONCURRENCY: usize = 8;
(engine_concurrency.clamp(1, MAX_ENGINE_CONCURRENCY) * 3).max(OCR_PAGE_CHUNK_SIZE)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct OcrEngineCacheKey {
model_root: PathBuf,
@@ -454,7 +443,7 @@ where
let mut render_time_ms = 0u64;
let mut ocr_time_ms = 0u64;
for chunk in routed_pages.chunks(ocr_page_chunk_size(engine.preferred_page_concurrency())) {
for chunk in routed_pages.chunks(OCR_PAGE_CHUNK_SIZE) {
let native_chunk = chunk
.iter()
.map(|page_number| {
@@ -938,19 +927,6 @@ pub enum OcrPipelineError {
mod tests {
use super::*;
#[test]
fn chunk_size_scales_with_engine_concurrency() {
// Sequential engines keep the memory-bounding default.
assert_eq!(ocr_page_chunk_size(1), OCR_PAGE_CHUNK_SIZE);
// Parallel engines get three waves of work per batch.
assert_eq!(ocr_page_chunk_size(3), 9);
assert_eq!(ocr_page_chunk_size(2), 6);
// Engine-reported concurrency is untrusted: clamp before sizing so a
// misbehaving engine cannot inflate rendered-page memory or overflow.
assert_eq!(ocr_page_chunk_size(0), OCR_PAGE_CHUNK_SIZE);
assert_eq!(ocr_page_chunk_size(usize::MAX), 24);
}
struct TrackingRenderer {
batches: Mutex<Vec<Vec<u32>>>,
}