Compare commits

..
Author SHA1 Message Date
Abimael Martell 2c26f58e0c fix(ocr): restore unclip_ratio 2.0 and recalibrate escalation for it
Supplying an explicit TextDetectionConfig suppresses OAROCR's
general-text-type overrides, so the staged detector was silently
running unclip 1.5 (the struct default) where the pre-split pipeline
ran 2.0 — tighter box expansion that risks clipping edge glyphs. Pin
unclip 2.0 explicitly and document that every override-set field must
be pinned when passing an explicit config.

The escalation threshold is coupled to unclip (expansion inflates
measured region heights ~15-20%), so it is recalibrated from fresh
measurements at 2.0: gain pages sit at 12.0-14.2px with 144+ regions,
the nearest non-gaining page above the region gate at 15.7px —
threshold moves 13 -> 15. A trace-level log records each page's
standard-pass median to keep future recalibration cheap. Trigger
tests updated to the measured values; quality re-verified end to end
(GT scores match pre-split main exactly on non-escalated docs;
escalation gains intact).
2026-08-17 20:06:54 -07:00
Abimael Martell c9a8da55ee Merge remote-tracking branch 'origin/main' into perf/ocr-adaptive-staged-engine 2026-08-17 20:05:31 -07:00
Abimael Martell c96c72f033 fix(ocr): return standard boxes directly after direct-escalation failure
Falling through to the adaptive branch would re-invoke the escalated
detector that just failed — repeating an OOM on a large dense page —
before settling on the standard boxes anyway.
2026-08-17 18:12:59 -07:00
Abimael Martell 009dc9d1a4 fix(ocr): address review — even-count median, escalation fallback, orientation note
- median_detection_height averages the two middle values for even-count
  region lists instead of taking the upper one, so borderline pages
  near the 13px escalation threshold are not skewed away from it.
- Direct escalated detection (renders over twice the standard limit)
  falls back to the standard pass on error, matching the adaptive
  branch, instead of failing the page outright.
- Document why OcrSpan::orientation_degrees is None: the combined
  pipeline's angle came from the text-line-orientation classifier,
  a model this engine never loads — it was structurally None before
  the staged split too; region rotation is carried by the polygon.
2026-08-17 18:08:13 -07:00
Abimael Martell bc414bd08d perf(ocr): adaptive detection escalation and parallel staged engine
Three structural limits in the OCR engine, found benchmarking against
other parsers, each addressed here:

1. Detection resolution. The backend downscales every page so its
   longest side fits 960px before text detection. A broadsheet render
   loses over 80% of its resolution, making body print ~2px tall —
   invisible to the detection model regardless of render DPI, since
   recognition crops come from the full-resolution render but detection
   never sees the text. Detection now runs at 960 and escalates to 2560
   only when the standard pass proves the page is dense fine print
   (page was downscaled, >=80 regions, median region height under 13px
   at detection scale — measured margins separate gaining pages at
   9.9-12.0px from non-gaining pages at 12.2px+). Renders more than
   twice the standard limit skip the doomed standard pass entirely.

2. Session serialization. oar-ocr guards each ONNX session behind a
   mutex (pool size is fixed at one), so concurrent predict calls
   serialize and added intra-op threads go to waste — measured, 12
   threads run 2.4x slower than 4. The engine now builds one worker
   (detector + recognizer sessions) per cores/4 capped at 3, each with
   2 intra-op threads, and fans pages across them via a sized rayon
   pool. A new OcrEngine::preferred_page_concurrency hint lets the
   pipeline size page batches at three waves per worker, replacing the
   fixed 4-page chunks that starved workers at batch barriers.

3. Escalation double-cost. The engine is restaged onto oar-ocr's own
   public components — detect, sort (sort_quad_boxes), crop
   (TextCroppingProcessor, rotation-aware), recognize — as separate
   calls instead of the combined predict, with output verified
   byte-identical across an 8-document corpus. An escalated page now
   reruns only detection; recognition runs once, on the final region
   set. Recognition stays one crop per call: batching pads every crop
   to the widest member, and even width-sorted batches measured 2-3x
   slower on CPU (ONNX Runtime re-plans kernels per input shape).

Escalation lifts ground-truth word recall on dense fine print from
0.678 to 0.895 with no change on ordinary scans, which keep first-pass
speed and quality; a 15-page scan drops from 9.4s to 8.0s end to end.
2026-08-17 13:45:55 -07:00
4 changed files with 7 additions and 268 deletions
-4
View File
@@ -43,10 +43,6 @@ unicode-normalization = "0.1"
# TrueType font parsing (for Identity-H CID font cmap extraction)
ttf-parser = "0.25"
# Incremental Flate inflate so detector scans can stop before a highly
# compressible stream materializes a multi-gigabyte buffer.
flate2 = "1.1"
# Native builds keep lopdf's parallel parser and CLI logging. Browser WASM is
# deliberately single-threaded so it works without cross-origin isolation.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+7 -118
View File
@@ -4,7 +4,6 @@
//! by sampling content streams for text operators (Tj/TJ) without loading
//! all objects.
use crate::stream_decode::stream_content_for_scan;
use crate::PdfError;
use lopdf::{Document, Object, ObjectId};
use std::collections::{HashMap, HashSet};
@@ -766,7 +765,10 @@ fn analyze_page_content(doc: &Document, page_id: ObjectId) -> PageAnalysis {
for content_id in content_streams {
if let Ok(Object::Stream(stream)) = doc.get_object(content_id) {
let content = stream_content_for_scan(stream);
let content = match stream.decompressed_content() {
Ok(data) => data,
Err(_) => stream.content.clone(),
};
// Scan for text operators, collecting raw font names
let mut page_font_names: HashSet<Vec<u8>> = HashSet::new();
@@ -1301,7 +1303,9 @@ fn scan_xobjects_in_resources(
.and_then(|o| o.as_name().ok());
match subtype {
Some(b"Form") => {
let content = stream_content_for_scan(stream);
let content = stream
.decompressed_content()
.unwrap_or_else(|_| stream.content.clone());
// Collect raw font names from this XObject's content stream
let mut xobj_font_names: HashSet<Vec<u8>> = HashSet::new();
let (ops, imgs, paths, fonts) = scan_content_for_text_operators(
@@ -3914,119 +3918,4 @@ mod tests {
"P3: inherited decodable font should be detected as used"
);
}
fn flate_content(plain: &[u8]) -> lopdf::Stream {
use flate2::write::ZlibEncoder;
use flate2::Compression;
use lopdf::dictionary;
use std::io::Write;
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best());
encoder.write_all(plain).unwrap();
lopdf::Stream::new(
dictionary! { "Filter" => "FlateDecode" },
encoder.finish().unwrap(),
)
}
#[test]
fn flate_page_content_still_finds_text_operators() {
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 font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
});
let content_id = doc.add_object(Object::Stream(flate_content(
b"BT /F1 12 Tf (Hello world) Tj ET",
)));
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(font_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),
}),
);
let analysis = analyze_page_content(&doc, page_id);
assert!(
analysis.text_operator_count > 0,
"bounded Flate decode must still see ordinary page text operators"
);
}
#[test]
fn flate_form_xobject_still_finds_text_operators() {
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 font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
});
let form_id = doc.add_object(Object::Stream(flate_content(
b"BT /F1 12 Tf (Form text) Tj ET",
)));
if let Object::Stream(form) = doc.objects.get_mut(&form_id).unwrap() {
form.dict.set("Type", Object::Name(b"XObject".to_vec()));
form.dict.set("Subtype", Object::Name(b"Form".to_vec()));
form.dict.set(
"Resources",
dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(font_id),
},
},
);
}
let page_content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {},
b"/Fm0 Do".to_vec(),
)));
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => dictionary! {
"XObject" => dictionary! {
"Fm0" => Object::Reference(form_id),
},
},
"Contents" => Object::Reference(page_content_id),
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
let analysis = analyze_page_content(&doc, page_id);
assert!(
analysis.text_operator_count > 0,
"bounded Flate decode must still see Form XObject text operators"
);
}
}
-1
View File
@@ -37,7 +37,6 @@ pub mod extractor;
pub mod glyph_names;
pub mod markdown;
pub mod process_mode;
mod stream_decode;
pub mod structure_tree;
pub mod tables;
mod text_quality;
-145
View File
@@ -1,145 +0,0 @@
//! Bounded stream decompression for detector scans.
//!
//! `lopdf::Stream::decompressed_content` materializes the full decoded buffer
//! before any caller can apply a limit. A few megabytes of Flate-compressed
//! zeros can therefore expand to gigabytes. These helpers stop inflate once
//! the decoded budget is reached.
use flate2::read::{DeflateDecoder, ZlibDecoder};
use lopdf::Stream;
use std::io::Read;
/// Maximum decoded bytes held for a single content stream during detection.
pub(crate) const MAX_DECOMPRESSED_STREAM_BYTES: usize = 32 * 1024 * 1024;
/// Decode `stream` for scanning, or return an empty buffer when the decoded
/// size would exceed `max_bytes`.
pub(crate) fn stream_content_for_scan(stream: &Stream) -> Vec<u8> {
match decompressed_content_bounded(stream, MAX_DECOMPRESSED_STREAM_BYTES) {
Some(data) => data,
None => Vec::new(),
}
}
/// Incremental decode with a hard output cap. `None` means the stream is
/// larger than `max_bytes` (or not safely decodable within that budget).
pub(crate) fn decompressed_content_bounded(stream: &Stream, max_bytes: usize) -> Option<Vec<u8>> {
let filters = match stream.filters() {
Ok(filters) => filters,
Err(_) => {
return take_if_within_budget(&stream.content, max_bytes);
}
};
if filters.is_empty() {
return take_if_within_budget(&stream.content, max_bytes);
}
// Plain Flate is the highly compressible case. Detector scans only need
// the inflated operator bytes; skip PNG predictors here so inflate can
// stop at the budget instead of materializing the full buffer first.
if filters.len() == 1 && filters[0] == b"FlateDecode" {
return inflate_flate_bounded(&stream.content, max_bytes);
}
if stream.content.len() > max_bytes {
return None;
}
match stream.decompressed_content() {
Ok(data) if data.len() <= max_bytes => Some(data),
Ok(_) => None,
Err(_) => take_if_within_budget(&stream.content, max_bytes),
}
}
fn take_if_within_budget(bytes: &[u8], max_bytes: usize) -> Option<Vec<u8>> {
if bytes.len() > max_bytes {
None
} else {
Some(bytes.to_vec())
}
}
fn inflate_flate_bounded(input: &[u8], max_bytes: usize) -> Option<Vec<u8>> {
if input.is_empty() {
return Some(Vec::new());
}
match read_bounded(ZlibDecoder::new(input), max_bytes) {
Some(data) => Some(data),
None if input.len() > 2 => read_bounded(DeflateDecoder::new(&input[2..]), max_bytes),
None => None,
}
}
fn read_bounded<R: Read>(mut decoder: R, max_bytes: usize) -> Option<Vec<u8>> {
let mut output = Vec::new();
let mut buf = [0u8; 16 * 1024];
loop {
match decoder.read(&mut buf) {
Ok(0) => return Some(output),
Ok(n) => {
if output.len().saturating_add(n) > max_bytes {
return None;
}
output.extend_from_slice(&buf[..n]);
}
Err(_) => return None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use flate2::write::ZlibEncoder;
use flate2::Compression;
use lopdf::dictionary;
use std::io::Write;
fn flate_stream(plain: &[u8]) -> Stream {
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best());
encoder.write_all(plain).unwrap();
let compressed = encoder.finish().unwrap();
Stream::new(dictionary! { "Filter" => "FlateDecode" }, compressed)
}
#[test]
fn small_flate_stream_round_trips() {
let plain = b"BT /F1 12 Tf (Hello world) Tj ET";
let stream = flate_stream(plain);
assert_eq!(
decompressed_content_bounded(&stream, MAX_DECOMPRESSED_STREAM_BYTES).as_deref(),
Some(plain.as_slice())
);
}
#[test]
fn highly_compressible_flate_stops_at_budget() {
let plain = vec![0u8; 256 * 1024];
let stream = flate_stream(&plain);
assert!(
stream.content.len() < 8 * 1024,
"fixture must stay compact on disk, got {} compressed bytes",
stream.content.len()
);
assert!(decompressed_content_bounded(&stream, 16 * 1024).is_none());
assert_eq!(
decompressed_content_bounded(&stream, 256 * 1024).as_deref(),
Some(plain.as_slice())
);
}
#[test]
fn uncompressed_over_budget_is_skipped() {
let stream = Stream::new(dictionary! {}, vec![b'x'; 64]);
assert!(decompressed_content_bounded(&stream, 32).is_none());
assert_eq!(decompressed_content_bounded(&stream, 64).unwrap().len(), 64);
}
#[test]
fn scan_helper_returns_empty_when_capped() {
let stream = flate_stream(&vec![0u8; 64 * 1024]);
// Production cap is far above 64 KiB, so this still decodes.
assert_eq!(stream_content_for_scan(&stream).len(), 64 * 1024);
}
}