How the Otsu Threshold Function Powers Tiled‑Scan Detection in pdf‑inspector
The Otsu threshold function in pdf‑inspector automatically separates "intra‑word" from "inter‑word" gap ratios to detect tiled‑scan PDFs by maximizing between‑class variance on the geometric distribution of text gaps.
pdf‑inspector is a Rust‑based open‑source tool that classifies PDF documents as Scanned, TextBased, or Mixed by analyzing the geometry of extracted text items. When processing suspected scanned PDFs that contain tiled‑scan images—many small raster tiles that collectively form a page—the detector relies on Otsu's method to distinguish normal word spacing from the uniformly wide gaps characteristic of tiled layouts.
What Is Tiled‑Scan Detection?
Tiled‑scan PDFs are created when a page is split into numerous small image tiles, often seen in exports from design tools like Canva. These documents pose a challenge because:
- The visual page consists entirely of raster image fragments
- Any text layer comes from OCR, producing geometrically irregular spacing
- Standard heuristics fail to distinguish meaningful word boundaries from tile boundaries
The detector in src/detector.rs first applies a tiled‑scan heuristic that counts image tiles and estimates total pixel area. When this heuristic flags a page as a possible tiled scan, Otsu's method provides the statistical foundation for final classification.
Collecting Gap‑to‑Font‑Size Ratios
Before threshold computation, pdf‑inspector gathers geometric data from neighboring text items. The collect_gap_ratios function in src/text_utils.rs (line 381) builds a vector of positive gap / font_size values:
- Processes every neighboring pair of text items
- Excludes CJK characters (which have different spacing expectations)
- Filters for sensible geometry (non‑negative gaps, reasonable font sizes)
- Returns a distribution of ratios representing relative spacing
These ratios form the histogram that Otsu's method will bisect into two clusters.
The Otsu Threshold Implementation
The core algorithm lives in compute_single_char_join_threshold at lines 771‑882 of src/text_utils.rs. Despite its #[cfg(test)] attribute, this function is invoked by the production detection pipeline for pages with unusually wide gaps.
Algorithm Steps
-
Sort the ratios — Creates an ordered distribution for systematic threshold search
-
Iterate split points — For every possible division between two ratio values:
- Maintain running weight (
w0) and sum (sum0) of the lower class - Derive upper class statistics from total population
- Compute between‑class variance:
w0 * w1 * (mean0 - mean1)²
- Maintain running weight (
-
Maximize variance — Select the split point that produces the greatest separation between the two clusters
-
Compute midpoint threshold — Return the average of the two ratio values bounding the optimal split
-
Clamp to valid range — Restrict result to
[0.05, 2.0]to prevent degenerate thresholds
// Conceptual implementation based on src/text_utils.rs lines 771-882
fn otsu_threshold(sorted_ratios: &[f32]) -> f32 {
let total_weight = sorted_ratios.len() as f32;
let total_sum: f32 = sorted_ratios.iter().sum();
let mut max_variance = 0.0;
let mut optimal_split = 0.0;
let mut w0 = 0.0; // weight of lower class
let mut sum0 = 0.0; // sum of lower class
for window in sorted_ratios.windows(2) {
let ratio = window[0];
w0 += 1.0;
sum0 += ratio;
let w1 = total_weight - w0;
if w1 == 0.0 { continue; }
let sum1 = total_sum - sum0;
let mean0 = sum0 / w0;
let mean1 = sum1 / w1;
let variance = w0 * w1 * (mean0 - mean1).powi(2);
if variance > max_variance {
max_variance = variance;
optimal_split = (window[0] + window[1]) / 2.0;
}
}
optimal_split.clamp(0.05, 2.0)
}
Applying the Threshold to Text Joining
The computed threshold becomes the adaptive join threshold (single_char_threshold) for the page. In should_join_items (line 885 of src/text_utils.rs), this value determines word boundaries:
// From src/text_utils.rs - determines if adjacent items form one word
fn should_join_items(
left: &TextItem,
right: &TextItem,
single_char_threshold: f32,
) -> bool {
let gap = (right.x - (left.x + left.width)).abs();
let allowed_gap = left.font_size * single_char_threshold;
gap < allowed_gap // true = same word, false = insert space
}
- Small gaps (<
font_size * threshold) → items belong to same word - Large gaps (≥
font_size * threshold) → insert space between items
This adaptive approach handles both normal documents and tiled scans without hardcoded constants.
Classifying Tiled‑Scan Documents
The detector combines Otsu results with geometric heuristics for final classification:
| Otsu Threshold | Interpretation | Classification Action |
|---|---|---|
| > 0.4 | Uniformly wide gaps, poor intra‑word clustering | Confirms tiled‑scan: page is image‑only tiles with visual‑order OCR |
| ≤ 0.4 | Clear separation between word‑internal and word‑external spacing | Normal text‑based or properly ordered scanned document |
A high Otsu threshold indicates that the gap distribution lacks the bimodal structure typical of real text—gaps are consistently wide rather than clustering around near‑zero (letter spacing) and larger values (word spacing). This geometric signature confirms tiled‑scan structure.
Practical Code Example
use pdf_inspector::text_utils::{
collect_gap_ratios,
compute_single_char_join_threshold,
should_join_items
};
/// Analyze a page to detect tiled‑scan structure
fn analyze_page_for_tiled_scan(items: &[TextItem]) -> PageClassification {
// Skip CJK and malformed geometries, collect valid ratios
let ratios = collect_gap_ratios(items);
// Compute Otsu threshold for this specific page
let otsu_threshold = compute_single_char_join_threshold(items);
// High threshold indicates uniformly wide gaps → tiled scan
if otsu_threshold > 0.4 && ratios.len() > 10 {
// Verify with tile count heuristic from detector.rs
return PageClassification::TiledScan(otsu_threshold);
}
// Normal processing: use threshold for precise word joining
let mut words: Vec<String> = Vec::new();
let mut current_word = String::new();
for i in 0..items.len() {
if i > 0 && should_join_items(&items[i-1], &items[i], otsu_threshold) {
current_word.push_str(&items[i].text);
} else {
if !current_word.is_empty() {
words.push(current_word.clone());
}
current_word = items[i].text.clone();
}
}
words.push(current_word);
PageClassification::NormalText { otsu_threshold, words }
}
Summary
-
Otsu's method in pdf‑inspector maximizes between‑class variance to find the optimal gap‑ratio threshold for each page.
-
collect_gap_ratios(line 381) gathers geometric data;compute_single_char_join_threshold(lines 771‑882) implements the Otsu algorithm. -
The resulting threshold feeds
should_join_items(line 885) for adaptive word‑boundary detection. -
Thresholds above 0.4 confirm tiled‑scan structure, enabling robust classification in
src/detector.rs.
Frequently Asked Questions
What makes Otsu's method suitable for PDF text analysis?
Otsu's method requires no prior training data and adapts to each page's specific gap distribution. This is essential for PDF processing because documents vary widely in typography, authoring tools, and OCR quality. The algorithm's assumption of bimodal distribution naturally maps to the "intra‑word" and "inter‑word" spacing problem.
Why is the threshold clamped to [0.05, 2.0]?
Extreme thresholds would produce nonsensical joining behavior: below 0.05 would merge almost everything into single words, above 2.0 would fragment text excessively. The clamp prevents degenerate splits when Otsu's method encounters uniform or near‑uniform distributions, which are themselves diagnostic of problematic documents.
How does pdf‑inspector handle CJK (Chinese, Japanese, Korean) text?
The collect_gap_ratios function explicitly excludes CJK character pairs from ratio collection. CJK scripts have fundamentally different spacing conventions—characters are typically uniform‑width with minimal inter‑character gaps—so including them would distort the threshold computation for Latin‑script documents and vice versa.
Can the Otsu threshold be tuned for specific document types?
The current implementation computes thresholds per‑page without user configuration. However, the 0.4 cutoff for tiled‑scan detection and the [0.05, 2.0] clamp range are compile‑time constants that could be exposed as parameters. For production use, Firecrawl's hosted service likely applies additional heuristics beyond the open‑source baseline.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →