Skip to main content

tiling_ab/
tiling_ab.rs

1//! Squeezing against tiling, measured on THIS pipeline.
2//!
3//! `mon_OCR/docs/ROADMAP.md` item 4.5.6 requires that the tiling direction be
4//! re-measured on a port before it is trusted, and is explicit about why: "the
5//! app segmenters are not the Python one." Until this example existed, no port
6//! had ever been measured — the numbers in the doc comment on `predict_page`
7//! came from an uncommitted Python harness.
8//!
9//! This reads a directory of pre-rendered line images with a `labels.txt`
10//! (produced by `mon_OCR/scripts/tiling_ab.py --dump-dir`), runs each image
11//! through this crate twice — once tiling, once squeezing — and reports the
12//! character error rate of each arm. Reading the same images the Python harness
13//! scored is the point: it isolates the pipeline as the variable rather than
14//! re-rendering and changing two things at once.
15//!
16//! Usage:
17//!     cargo run --release --example tiling_ab -- /tmp/wide-lines
18
19use std::collections::BTreeMap;
20use std::path::PathBuf;
21
22use anyhow::{Context, Result};
23use monocr_onnx::MonOcr;
24
25/// Grapheme-cluster CER would be the right metric, matching
26/// `mon_OCR/src/monocr/metrics.py`. Rust has no grapheme segmenter in this
27/// crate's dependency set, so this uses `char`-level edit distance and says so:
28/// the two differ on Mon, where a base plus its stacked marks is several chars
29/// and one grapheme. The comparison between arms stays valid because both arms
30/// are scored the same way; only the absolute rate is not comparable to the
31/// Python report.
32fn char_cer(pred: &str, reference: &str) -> f64 {
33    let a: Vec<char> = pred.chars().collect();
34    let b: Vec<char> = reference.chars().collect();
35    if b.is_empty() {
36        return if a.is_empty() { 0.0 } else { 1.0 };
37    }
38
39    let mut prev: Vec<usize> = (0..=a.len()).collect();
40    let mut cur = vec![0usize; a.len() + 1];
41    for (j, bc) in b.iter().enumerate() {
42        cur[0] = j + 1;
43        for (i, ac) in a.iter().enumerate() {
44            let cost = usize::from(ac != bc);
45            cur[i + 1] = (cur[i] + 1).min(prev[i + 1] + 1).min(prev[i] + cost);
46        }
47        std::mem::swap(&mut prev, &mut cur);
48    }
49    prev[a.len()] as f64 / b.len() as f64
50}
51
52struct Row {
53    tiles: usize,
54    cer_tiled: f64,
55    cer_squeezed: f64,
56}
57
58#[tokio::main]
59async fn main() -> Result<()> {
60    let dir: PathBuf = std::env::args()
61        .nth(1)
62        .context("usage: tiling_ab <dir with line_*.png and labels.txt>")?
63        .into();
64
65    let labels_path = dir.join("labels.txt");
66    let labels_raw = std::fs::read_to_string(&labels_path)
67        .with_context(|| format!("cannot read {}", labels_path.display()))?;
68
69    let mut labels: Vec<(PathBuf, String)> = Vec::new();
70    for line in labels_raw.lines() {
71        let Some((name, text)) = line.split_once('\t') else {
72            continue;
73        };
74        labels.push((dir.join(name), text.to_string()));
75    }
76    if labels.is_empty() {
77        anyhow::bail!(
78            "{} contained no tab-separated entries",
79            labels_path.display()
80        );
81    }
82    eprintln!("{} labelled lines", labels.len());
83
84    // Null baseline, asserted before any real number is produced. If the metric
85    // arithmetic is wrong every rate below is wrong in the same direction, and
86    // nothing else in this example would reveal it.
87    assert_eq!(
88        char_cer("", "abc"),
89        1.0,
90        "empty prediction must score exactly 1.0"
91    );
92    assert_eq!(
93        char_cer("abc", "abc"),
94        0.0,
95        "identity must score exactly 0.0"
96    );
97    eprintln!("null baseline ok");
98
99    // Two sessions rather than rebuilding one per arm: the flag is fixed at build
100    // time, and reloading the graph 240 times would dominate the runtime.
101    let mut tiled = MonOcr::builder().tile_wide_lines(true).build().await?;
102    let mut squeezed = MonOcr::builder().tile_wide_lines(false).build().await?;
103
104    let mut rows: Vec<Row> = Vec::new();
105    for (i, (path, truth)) in labels.iter().enumerate() {
106        let t = tiled.predict_single_line(path).await?;
107        let s = squeezed.predict_single_line(path).await?;
108
109        // Recovered from the geometry: a tiled read reports the union of its
110        // tiles, so width over the window width is the tile count.
111        let img = image::open(path)?.to_luma8();
112        let (w, h) = img.dimensions();
113        let scaled = (w as f64 * (160.0 / h as f64)) as u32;
114        let tiles = ((scaled as f64) / 1024.0).ceil().max(1.0) as usize;
115
116        rows.push(Row {
117            tiles,
118            cer_tiled: char_cer(&t.text, truth),
119            cer_squeezed: char_cer(&s.text, truth),
120        });
121
122        if (i + 1) % 25 == 0 {
123            eprintln!("  {}/{}", i + 1, labels.len());
124        }
125    }
126
127    let mean =
128        |f: fn(&Row) -> f64, rs: &[Row]| -> f64 { rs.iter().map(f).sum::<f64>() / rs.len() as f64 };
129    let m_sq = mean(|r| r.cer_squeezed, &rows);
130    let m_ti = mean(|r| r.cer_tiled, &rows);
131
132    println!("\nn                {}", rows.len());
133    println!("squeezed CER     {m_sq:.4}");
134    println!("tiled CER        {m_ti:.4}");
135    println!("ratio sq/tiled   {:.2}x", m_sq / m_ti);
136    println!(
137        "tiled better on  {}/{}",
138        rows.iter().filter(|r| r.cer_tiled < r.cer_squeezed).count(),
139        rows.len()
140    );
141
142    // The band table is the finding; the aggregate depends entirely on the width
143    // mix of whatever sample was handed in.
144    let mut bands: BTreeMap<usize, Vec<&Row>> = BTreeMap::new();
145    for r in &rows {
146        bands.entry(r.tiles).or_default().push(r);
147    }
148    println!("\n tiles     n   squeezed     tiled    ratio");
149    for (band, sub) in &bands {
150        if sub.len() < 3 {
151            continue;
152        }
153        let s_m = sub.iter().map(|r| r.cer_squeezed).sum::<f64>() / sub.len() as f64;
154        let t_m = sub.iter().map(|r| r.cer_tiled).sum::<f64>() / sub.len() as f64;
155        println!(
156            " {band:>5}  {:>4}   {s_m:>8.4}  {t_m:>8.4}  {:>6.1}x",
157            sub.len(),
158            s_m / t_m
159        );
160    }
161    Ok(())
162}