Compare commits

..
Author SHA1 Message Date
Abimael Martell 47f3390c03 docs(layout): restore columns_have_prose rustdoc displaced by hoisted const
The hoisted LINE_FILL_THRESHOLD landed between the gate's rustdoc block
and the items that followed, absorbing the function's documentation onto
the constant. The docs return to the function, extended to cover the
sustained-run acceptance path.
2026-08-17 21:20:54 -07:00
Abimael Martell bbfcd39405 fix(layout): accept figure-diluted prose columns via a sustained full-line run
The columns_have_prose gate (added against table/TOC/checklist
false-splits in relative-valley detection) requires >=40% of a
column's lines to span >=45% of its width. A genuine prose column
hosting a figure and caption dilutes that global ratio below the bar
— measured 0.38 on a two-column research page — so the valley is
rejected, the page falls back to single-column, and same-baseline
items from both columns merge across the gutter into woven sentences.

Accept a column that contains a sustained paragraph block instead: at
least 6 consecutive full-width lines. The scattered layouts the gate
exists to reject cannot produce an unbroken run of full lines, and
the other guards (column width, minimum lines, items-per-line) still
apply. Tests pin both directions; regression corpus is unchanged.
2026-08-17 21:11:47 -07:00
Abimael Martell cf4e42b91c perf(ocr): adaptive detection escalation and parallel staged engine (#413)
* 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.

* 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.

* 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.

* 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:46:22 -07:00
+170 -47
View File
@@ -462,19 +462,95 @@ 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.
/// 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.
///
/// Returns true only when *every* column passes a minimum prose density.
/// Returns true only when *every* column passes the prose evidence.
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 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_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 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+
@@ -504,36 +580,10 @@ 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 full_lines = 0usize;
let mut total_lines = 0usize;
let mut total_items_in_lines = 0usize;
let mut stats = ProseLineStats::default();
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() {
@@ -541,35 +591,29 @@ fn columns_have_prose(columns: &[ColumnRegion], items: &[&TextItem]) -> bool {
}
line_items.push(item);
} else {
flush_line(
&line_items,
&mut full_lines,
&mut total_lines,
&mut total_items_in_lines,
);
stats.flush_line(&line_items, col, col_width);
line_items.clear();
line_y = item.y;
line_items.push(item);
}
}
flush_line(
&line_items,
&mut full_lines,
&mut total_lines,
&mut total_items_in_lines,
);
stats.flush_line(&line_items, col, col_width);
if total_lines < MIN_LINES {
if stats.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} avg_items={:.1}",
col.x_min, col.x_max, total_lines, full_lines, ratio, avg_items
"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
);
if ratio < MIN_PROSE_RATIO {
if ratio < MIN_PROSE_RATIO && best_run < MIN_PROSE_RUN {
return false;
}
// Tables and forms tend to have many small items per line (one per cell),
@@ -2567,6 +2611,85 @@ 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> {