use anyhow::Result;
use image::{imageops::crop_imm, GrayImage, ImageBuffer};
use std::ops::Range;
use std::path::Path;
pub const CUT_SEARCH_FRACTION: f64 = 0.12;
pub const CUT_INK_THRESHOLD: u8 = 250;
pub fn cut_column(crop: &GrayImage, x0: u32, ideal: u32, crop_w: u32) -> u32 {
if ideal >= crop_w {
return crop_w;
}
let window = (((ideal - x0) as f64 * CUT_SEARCH_FRACTION) as u32).max(1);
let lo = (x0 + 1).max(ideal.saturating_sub(window));
if lo >= ideal {
return ideal;
}
let height = crop.height();
let mut rightmost_blank: Option<u32> = None;
let mut lightest_offset = 0u32;
let mut lightest_ink = u32::MAX;
for x in lo..ideal {
let mut ink = 0u32;
for y in 0..height {
if crop.get_pixel(x, y)[0] < CUT_INK_THRESHOLD {
ink += 1;
}
}
let offset = x - lo;
if ink == 0 {
rightmost_blank = Some(offset);
}
if ink < lightest_ink {
lightest_ink = ink;
lightest_offset = offset;
}
}
lo + rightmost_blank.unwrap_or(lightest_offset)
}
pub fn tile_line(crop: &GrayImage, target_h: u32, target_w: u32) -> Vec<GrayImage> {
let (crop_w, crop_h) = crop.dimensions();
if crop_h == 0 || crop_w == 0 {
return vec![crop.clone()];
}
let scale = target_h as f64 / crop_h as f64;
if (crop_w as f64 * scale) as u32 <= target_w {
return vec![crop.clone()];
}
let tile_w_src = ((target_w as f64 / scale) as u32).max(1);
let mut tiles = Vec::new();
let mut x0 = 0u32;
while x0 < crop_w {
let ideal = x0.saturating_add(tile_w_src).min(crop_w);
let x1 = cut_column(crop, x0, ideal, crop_w).max(x0 + 1);
tiles.push(crop_imm(crop, x0, 0, x1 - x0, crop_h).to_image());
x0 = x1;
}
tiles
}
#[derive(Debug, Clone, Copy)]
pub struct BBox {
pub x: u32,
pub y: u32,
pub w: u32,
pub h: u32,
}
#[derive(Debug, Clone)]
pub struct LineSegment {
pub img: GrayImage,
pub bbox: BBox,
}
struct BinarizedPage<'a> {
gray: &'a GrayImage,
binary: &'a [u8],
}
const RULE_SPAN: f64 = 0.5;
const RULE_MIN_SPAN_PX: usize = 15;
const RULE_MAX_INK_SHARE: f64 = 0.80;
const MIN_GAP_MERGE: u32 = 10;
fn merge_runs(runs: &[(u32, u32)], hist: &[f32], max_gap: u32, min_line: u32) -> Vec<(u32, u32)> {
if runs.is_empty() {
return Vec::new();
}
let mut heights: Vec<u32> = runs
.iter()
.map(|&(a, b)| b - a)
.filter(|&h| h >= min_line)
.collect();
if heights.is_empty() {
heights = runs.iter().map(|&(a, b)| b - a).collect();
}
heights.sort_unstable();
let typical = heights[heights.len() / 2].max(1);
let ceiling = typical * 2;
let mut merged: Vec<(u32, u32)> = Vec::with_capacity(runs.len());
for &(r0, r1) in runs {
if let Some(last) = merged.last_mut() {
let gap_start = last.1;
let gap_size = r0.saturating_sub(gap_start);
let gap_has_ink =
(gap_start..r0).all(|y| hist.get(y as usize).is_some_and(|&v| v > 0.0));
let (ha, hb) = (last.1 - last.0, r1 - r0);
let fragment = 2 * ha.min(hb) <= typical && ha.max(hb) >= min_line;
if gap_size <= max_gap && (gap_has_ink || fragment) && r1 - last.0 <= ceiling {
last.1 = r1;
continue;
}
}
merged.push((r0, r1));
}
merged
}
fn suppress_page_rules(mask: &mut [u8], width: u32, height: u32) -> bool {
let (w, h) = (width as usize, height as usize);
if w == 0 || h == 0 || mask.len() < w * h {
return false;
}
let min_h = ((width as f64 * RULE_SPAN) as usize).max(RULE_MIN_SPAN_PX);
let min_v = ((height as f64 * RULE_SPAN) as usize).max(RULE_MIN_SPAN_PX);
let mut rules = vec![0u8; w * h];
for y in 0..h {
let row = y * w;
let mut start = 0usize;
for x in 0..=w {
if x < w && mask[row + x] != 0 {
continue;
}
if x - start >= min_h {
rules[row + start..row + x].fill(1);
}
start = x + 1;
}
}
for x in 0..w {
let mut start = 0usize;
for y in 0..=h {
if y < h && mask[y * w + x] != 0 {
continue;
}
if y - start >= min_v {
for i in start..y {
rules[i * w + x] = 1;
}
}
start = y + 1;
}
}
let ink = mask[..w * h].iter().filter(|&&v| v != 0).count();
let rule_ink = rules.iter().filter(|&&v| v != 0).count();
if ink == 0 || rule_ink == 0 || rule_ink as f64 > ink as f64 * RULE_MAX_INK_SHARE {
return false;
}
for (cell, &rule) in mask.iter_mut().zip(rules.iter()) {
if rule != 0 {
*cell = 0;
}
}
true
}
pub struct LineSegmenter {
min_line_height: u32,
smooth_window: u32,
density_threshold_ratio: f32,
}
pub const DEFAULT_DENSITY_THRESHOLD_RATIO: f32 = 0.05;
impl LineSegmenter {
pub fn new(min_line_height: u32, smooth_window: u32) -> Self {
Self::with_density_ratio(
min_line_height,
smooth_window,
DEFAULT_DENSITY_THRESHOLD_RATIO,
)
}
pub fn with_density_ratio(
min_line_height: u32,
smooth_window: u32,
density_threshold_ratio: f32,
) -> Self {
Self {
min_line_height,
smooth_window,
density_threshold_ratio,
}
}
pub fn segment(&self, image_path: impl AsRef<Path>) -> Result<Vec<LineSegment>> {
let img = image::open(image_path.as_ref())?;
self.segment_image(&img.to_luma8())
}
pub fn segment_image(&self, gray_img: &GrayImage) -> Result<Vec<LineSegment>> {
let (width, height) = gray_img.dimensions();
let mut binary = vec![0u8; (width * height) as usize];
for y in 0..height {
for x in 0..width {
let idx = (y * width + x) as usize;
let pixel = gray_img.get_pixel(x, y);
if pixel[0] < 128 {
binary[idx] = 1;
}
}
}
suppress_page_rules(&mut binary, width, height);
let mut hist = vec![0f32; height as usize];
for y in 0..height {
let row = (y * width) as usize;
for x in 0..width as usize {
if binary[row + x] != 0 {
hist[y as usize] += 1.0;
}
}
}
let smoothed_hist = if self.smooth_window > 1 {
self.smooth_histogram(&hist)
} else {
hist.clone()
};
let non_zero_vals: Vec<f32> = smoothed_hist
.iter()
.filter(|&&v| v > 0.0)
.copied()
.collect();
if non_zero_vals.is_empty() {
return Ok(Vec::new());
}
let mean_density: f32 = non_zero_vals.iter().sum::<f32>() / non_zero_vals.len() as f32;
let gap_threshold = mean_density * self.density_threshold_ratio;
let page = BinarizedPage {
gray: gray_img,
binary: &binary,
};
let mut results = Vec::new();
let mut runs: Vec<(u32, u32)> = Vec::new();
let mut start: Option<u32> = None;
for y in 0..height {
let is_text = hist[y as usize] > gap_threshold;
if is_text && start.is_none() {
start = Some(y);
} else if !is_text && start.is_some() {
runs.push((start.unwrap(), y));
start = None;
}
}
if let Some(s) = start {
runs.push((s, height));
}
let runs = merge_runs(&runs, &hist, MIN_GAP_MERGE, self.min_line_height);
for (r0, r1) in runs {
if r1 - r0 >= self.min_line_height {
self.extract_line(&page, r0..r1, &mut results)?;
}
}
Ok(results)
}
fn smooth_histogram(&self, hist: &[f32]) -> Vec<f32> {
let height = hist.len();
let mut smoothed = vec![0f32; height];
let half = (self.smooth_window / 2) as i32;
for (i, out) in smoothed.iter_mut().enumerate() {
let mut sum = 0f32;
let mut count = 0u32;
for j in (i as i32 - half)..=(i as i32 + half) {
if j >= 0 && j < height as i32 {
sum += hist[j as usize];
count += 1;
}
}
*out = if count > 0 { sum / count as f32 } else { 0.0 };
}
smoothed
}
fn extract_line(
&self,
page: &BinarizedPage,
rows: Range<u32>,
results: &mut Vec<LineSegment>,
) -> Result<()> {
let gray_img = page.gray;
let binary = page.binary;
let (width, height) = gray_img.dimensions();
let (r_start, r_end) = (rows.start, rows.end);
let mut x_min = width;
let mut x_max = 0u32;
let mut has_pixels = false;
for y in r_start..r_end {
for x in 0..width {
let idx = (y * width + x) as usize;
if binary[idx] == 1 {
if x < x_min {
x_min = x;
}
if x > x_max {
x_max = x;
}
has_pixels = true;
}
}
}
if !has_pixels {
return Ok(());
}
let pad = 4;
let y1 = r_start.saturating_sub(pad);
let y2 = (r_end + pad).min(height);
let x1 = x_min.saturating_sub(pad);
let x2 = (x_max + pad).min(width);
let w = x2 - x1;
let h = y2 - y1;
let mut line_img = ImageBuffer::new(w, h);
for y in 0..h {
for x in 0..w {
let src_x = x1 + x;
let src_y = y1 + y;
let pixel = gray_img.get_pixel(src_x, src_y);
line_img.put_pixel(x, y, *pixel);
}
}
results.push(LineSegment {
img: line_img,
bbox: BBox { x: x1, y: y1, w, h },
});
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use image::Luma;
use serde_json::Value;
use std::path::PathBuf;
const FIXTURE_ENV: &str = "MONOCR_TILING_FIXTURE";
fn fixture_path() -> PathBuf {
if let Some(path) = std::env::var_os(FIXTURE_ENV) {
return PathBuf::from(path);
}
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../monocr-monorepo/shared/segmentation-fixtures/tiling-cases.json")
}
fn load_fixture() -> Value {
let path = fixture_path();
let raw = std::fs::read_to_string(&path).unwrap_or_else(|e| {
panic!(
"cannot read the shared tiling fixture at {}: {e}\n\
set {FIXTURE_ENV} to point at \
monocr-monorepo/shared/segmentation-fixtures/tiling-cases.json",
path.display()
)
});
serde_json::from_str(&raw)
.unwrap_or_else(|e| panic!("{} is not valid JSON: {e}", path.display()))
}
fn u32_field(value: &Value, key: &str) -> u32 {
value
.get(key)
.and_then(Value::as_u64)
.unwrap_or_else(|| panic!("fixture entry is missing an integer '{key}': {value}"))
as u32
}
fn build_image(width: u32, height: u32, ink: &Value) -> GrayImage {
let kind = ink
.get("kind")
.and_then(Value::as_str)
.unwrap_or_else(|| panic!("ink rule has no 'kind': {ink}"));
let modulus = u32_field(ink, "modulus");
let mut img = GrayImage::from_pixel(width, height, Luma([255u8]));
for x in 0..width {
let is_ink = match kind {
"mod_eq" => x % modulus == 0,
"mod_ne" => x % modulus != 0,
"solid" => true,
"blank" => false,
other => panic!("unknown ink rule '{other}' in the fixture"),
};
if is_ink {
for y in 0..height {
img.put_pixel(x, y, Luma([0u8]));
}
}
}
img
}
struct Fixture {
target_height: u32,
target_width: u32,
root: Value,
}
fn fixture() -> Fixture {
let root = load_fixture();
let target_height = u32_field(&root, "target_height");
let target_width = u32_field(&root, "target_width");
assert_eq!(
root.get("cut_search_fraction").and_then(Value::as_f64),
Some(CUT_SEARCH_FRACTION),
"fixture and port disagree on the cut search fraction"
);
assert_eq!(
root.get("cut_ink_threshold").and_then(Value::as_u64),
Some(CUT_INK_THRESHOLD as u64),
"fixture and port disagree on the ink threshold"
);
Fixture {
target_height,
target_width,
root,
}
}
const TILING_CASES_MIN: usize = 14;
const TILING_PROBES_MIN: usize = 3;
const RULE_CASES_MIN: usize = 23;
const MERGE_CASES_MIN: usize = 18;
fn cases(root: &Value, key: &str, at_least: usize) -> Vec<Value> {
let cases = root
.get(key)
.and_then(Value::as_array)
.unwrap_or_else(|| panic!("fixture has no '{key}' array"))
.clone();
assert!(
cases.len() >= at_least,
"fixture '{key}' carries {} cases, expected at least {at_least} -- \
a fixture that shrank is a fixture that stopped testing what it claims",
cases.len()
);
cases
}
fn case_image(case: &Value) -> (GrayImage, String) {
let name = case
.get("name")
.and_then(Value::as_str)
.unwrap_or("<unnamed>")
.to_string();
let ink = case
.get("ink")
.unwrap_or_else(|| panic!("case '{name}' has no ink rule"));
let img = build_image(u32_field(case, "width"), u32_field(case, "height"), ink);
(img, name)
}
#[test]
fn tile_widths_match_the_shared_fixture() {
let f = fixture();
for case in cases(&f.root, "cases", TILING_CASES_MIN) {
let (img, name) = case_image(&case);
let expected: Vec<u32> = case
.get("expected_tile_widths")
.and_then(Value::as_array)
.unwrap_or_else(|| panic!("case '{name}' has no expected_tile_widths"))
.iter()
.map(|v| {
v.as_u64()
.unwrap_or_else(|| panic!("case '{name}' has a non-integer tile width"))
as u32
})
.collect();
let widths: Vec<u32> = tile_line(&img, f.target_height, f.target_width)
.iter()
.map(|t| t.width())
.collect();
assert_eq!(widths, expected, "case '{name}'");
}
}
#[test]
fn tiles_partition_the_line() {
let f = fixture();
for case in cases(&f.root, "cases", TILING_CASES_MIN) {
let (img, name) = case_image(&case);
let tiles = tile_line(&img, f.target_height, f.target_width);
let total: u32 = tiles.iter().map(|t| t.width()).sum();
assert_eq!(
total,
img.width(),
"case '{name}': tile widths must sum to the line width"
);
let mut rebuilt = GrayImage::new(img.width(), img.height());
let mut x_off = 0u32;
for tile in &tiles {
assert_eq!(
tile.height(),
img.height(),
"case '{name}': a tile must keep the full line height"
);
assert!(tile.width() > 0, "case '{name}': empty tile");
for x in 0..tile.width() {
for y in 0..tile.height() {
rebuilt.put_pixel(x_off + x, y, *tile.get_pixel(x, y));
}
}
x_off += tile.width();
}
assert!(
rebuilt.as_raw() == img.as_raw(),
"case '{name}': tiles do not reassemble into the source line"
);
}
}
#[test]
fn a_wide_crop_is_tiled_not_squeezed() {
let f = fixture();
let mut checked = 0;
for case in cases(&f.root, "cases", TILING_CASES_MIN) {
let expected_count = case
.get("expected_tile_widths")
.and_then(Value::as_array)
.map(|a| a.len())
.unwrap_or(0);
if expected_count < 2 {
continue;
}
let (img, name) = case_image(&case);
let tiles = tile_line(&img, f.target_height, f.target_width);
assert!(
tiles.len() > 1,
"case '{name}': a crop this wide must be tiled, got {} tile(s)",
tiles.len()
);
assert_eq!(
tiles.iter().map(|t| t.width()).sum::<u32>(),
img.width(),
"case '{name}': tiles must cover the whole crop"
);
for tile in &tiles {
assert!(
tile.width() <= img.width(),
"case '{name}': a tile cannot be wider than the crop"
);
}
checked += 1;
}
assert!(checked > 0, "the fixture has no multi-tile case to check");
}
const RULE_FIXTURE_ENV: &str = "MONOCR_RULE_FIXTURE";
fn rule_fixture_path() -> PathBuf {
if let Some(path) = std::env::var_os(RULE_FIXTURE_ENV) {
return PathBuf::from(path);
}
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../monocr-monorepo/shared/segmentation-fixtures/rule-cases.json")
}
fn load_rule_fixture() -> Value {
let path = rule_fixture_path();
let raw = std::fs::read_to_string(&path).unwrap_or_else(|e| {
panic!(
"cannot read the shared printed-rule fixture at {}: {e}\n\
set {RULE_FIXTURE_ENV} to point at \
monocr-monorepo/shared/segmentation-fixtures/rule-cases.json",
path.display()
)
});
serde_json::from_str(&raw)
.unwrap_or_else(|e| panic!("{} is not valid JSON: {e}", path.display()))
}
fn i64_field(value: &Value, key: &str) -> i64 {
value
.get(key)
.and_then(Value::as_i64)
.unwrap_or_else(|| panic!("fixture entry is missing an integer '{key}': {value}"))
}
fn rule_mask(case: &Value) -> (Vec<u8>, u32, u32) {
let width = u32_field(case, "width");
let height = u32_field(case, "height");
let (w, h) = (width as usize, height as usize);
let density = u32_field(case, "density");
let mut x: u32 = 2_463_534_242;
let mut mask = vec![0u8; w * h];
for cell in mask.iter_mut() {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
if x % 100 < density {
*cell = 1;
}
}
let run_length = i64_field(case, "run_length");
let run_start = i64_field(case, "run_start") as usize;
for row in cases_array(case, "rule_rows") {
let row = row as usize;
let (len, start) = if run_length < 0 {
(w, 0)
} else {
(run_length as usize, run_start)
};
for cell in mask[row * w + start..row * w + w.min(start + len)].iter_mut() {
*cell = 1;
}
}
let col_length = i64_field(case, "col_length");
let col_start = i64_field(case, "col_start") as usize;
for col in cases_array(case, "rule_cols") {
let col = col as usize;
let (len, start) = if col_length < 0 {
(h, 0)
} else {
(col_length as usize, col_start)
};
for y in start..h.min(start + len) {
mask[y * w + col] = 1;
}
}
(mask, width, height)
}
fn cases_array(case: &Value, key: &str) -> Vec<u64> {
case.get(key)
.and_then(Value::as_array)
.unwrap_or_else(|| panic!("fixture case has no '{key}' array: {case}"))
.iter()
.map(|v| {
v.as_u64()
.unwrap_or_else(|| panic!("non-integer entry in '{key}'"))
})
.collect()
}
fn rule_signature(mask: &[u8], modulus: u64) -> (u64, u64) {
let mut ink = 0u64;
let mut sum = 0u64;
for (i, &v) in mask.iter().enumerate() {
if v != 0 {
ink += 1;
sum += i as u64 + 1;
}
}
(ink, sum % modulus)
}
#[test]
fn page_rules_match_the_shared_fixture() {
let root = load_rule_fixture();
assert_eq!(
root.get("rule_span").and_then(Value::as_f64),
Some(RULE_SPAN),
"fixture and port disagree on the rule span"
);
assert_eq!(
root.get("rule_max_ink_share").and_then(Value::as_f64),
Some(RULE_MAX_INK_SHARE),
"fixture and port disagree on the ink-share ceiling"
);
let modulus = root
.get("checksum_modulus")
.and_then(Value::as_u64)
.expect("fixture has no checksum_modulus");
for case in cases(&root, "cases", RULE_CASES_MIN) {
let name = case
.get("name")
.and_then(Value::as_str)
.unwrap_or("<unnamed>")
.to_string();
let (mut mask, width, height) = rule_mask(&case);
let changed = suppress_page_rules(&mut mask, width, height);
assert_eq!(
changed,
case.get("expected_changed")
.and_then(Value::as_bool)
.unwrap_or_else(|| panic!("case '{name}' has no expected_changed")),
"case '{name}': wrong answer on whether anything was suppressed"
);
let (ink, checksum) = rule_signature(&mask, modulus);
assert_eq!(
ink,
case.get("expected_ink")
.and_then(Value::as_u64)
.unwrap_or_else(|| panic!("case '{name}' has no expected_ink")),
"case '{name}': wrong ink count after suppression"
);
assert_eq!(
checksum,
case.get("expected_checksum")
.and_then(Value::as_u64)
.unwrap_or_else(|| panic!("case '{name}' has no expected_checksum")),
"case '{name}': right ink count, wrong pixels — an off-by-one in \
one of the run-length scans"
);
}
}
const T_WIDTH: u32 = 800;
const T_BAND: u32 = 40;
const T_MARGIN: u32 = 30;
const T_GLYPH_W: u32 = 12;
const T_PITCH: u32 = 20;
const T_RULE_W: u32 = 4;
fn drawn_page(bands: u32, gap: u32, glyphs: u32, framed: bool) -> GrayImage {
let height = T_MARGIN * 2 + T_BAND * bands + gap * (bands - 1);
let mut img = GrayImage::from_pixel(T_WIDTH, height, Luma([255u8]));
let mut y = T_MARGIN;
for _ in 0..bands {
for yy in y..y + T_BAND {
for k in 0..glyphs {
let x0 = 100 + k * T_PITCH;
for i in 0..T_GLYPH_W {
if x0 + i < T_WIDTH {
img.put_pixel(x0 + i, yy, Luma([0u8]));
}
}
}
}
y += T_BAND + gap;
}
if framed {
for yy in 0..height {
for i in 0..T_RULE_W {
img.put_pixel(10 + i, yy, Luma([0u8]));
img.put_pixel(T_WIDTH - 10 - T_RULE_W + i, yy, Luma([0u8]));
}
}
for i in 0..T_RULE_W {
for x in 0..T_WIDTH {
img.put_pixel(x, 10 + i, Luma([0u8]));
img.put_pixel(x, height - 10 - T_RULE_W + i, Luma([0u8]));
}
}
}
img
}
#[test]
fn a_page_with_no_rules_is_untouched_to_the_pixel() {
let img = drawn_page(4, 40, 30, false);
let (w, h) = img.dimensions();
let mut mask = vec![0u8; (w * h) as usize];
for y in 0..h {
for x in 0..w {
if img.get_pixel(x, y)[0] < 128 {
mask[(y * w + x) as usize] = 1;
}
}
}
let before = mask.clone();
assert!(
!suppress_page_rules(&mut mask, w, h),
"suppression reported a change on a page with no rules"
);
assert_eq!(
mask, before,
"glyph-sized ink was classified as a rule and removed"
);
}
#[test]
fn segmenting_recovers_a_framed_page() {
let seg = LineSegmenter::new(10, 3);
let clean = seg.segment_image(&drawn_page(4, 40, 8, false)).unwrap();
let framed = seg.segment_image(&drawn_page(4, 40, 8, true)).unwrap();
assert_eq!(
clean.len(),
4,
"the unframed control must segment into 4 lines, or the comparison \
below proves nothing"
);
assert_eq!(
framed.len(),
clean.len(),
"a framed page came back as {} line(s) where the same page unframed \
gave {} — the page border is fusing the profile",
framed.len(),
clean.len()
);
}
#[test]
fn degenerate_masks_do_not_panic() {
assert!(!suppress_page_rules(&mut [], 0, 0));
assert!(!suppress_page_rules(&mut [], 10, 10));
assert!(!suppress_page_rules(&mut vec![0u8; 50 * 50], 50, 50));
assert!(!suppress_page_rules(&mut vec![1u8; 50 * 50], 50, 50));
assert!(!suppress_page_rules(&mut [1u8; 1], 1, 1));
}
#[test]
fn cut_column_matches_the_shared_fixture() {
let f = fixture();
for probe in cases(&f.root, "cut_column_probes", TILING_PROBES_MIN) {
let (img, name) = case_image(&probe);
let got = cut_column(
&img,
u32_field(&probe, "x0"),
u32_field(&probe, "ideal"),
img.width(),
);
assert_eq!(got, u32_field(&probe, "expected_cut"), "probe '{name}'");
}
}
fn page_with_a_faint_band(
bands: u32,
gap: u32,
glyphs: u32,
faint_ink: u32,
faint_h: u32,
) -> GrayImage {
let height = T_MARGIN * 2 + T_BAND * bands + gap * bands + faint_h;
let mut img = GrayImage::from_pixel(T_WIDTH, height, Luma([255u8]));
let mut y = T_MARGIN;
for _ in 0..bands {
for yy in y..y + T_BAND {
for k in 0..glyphs {
let x0 = 100 + k * T_PITCH;
for i in 0..T_GLYPH_W {
if x0 + i < T_WIDTH {
img.put_pixel(x0 + i, yy, Luma([0u8]));
}
}
}
}
y += T_BAND + gap;
}
for yy in y..y + faint_h {
for i in 0..faint_ink {
img.put_pixel(100 + i, yy, Luma([0u8]));
}
}
img
}
#[test]
fn lines_two_pixels_apart_are_not_fused() {
let seg = LineSegmenter::new(10, 3);
for gap in [1u32, 2] {
let got = seg.segment_image(&drawn_page(29, gap, 30, false)).unwrap();
assert_eq!(
got.len(),
29,
"29 bands {gap}px apart came back as {} — boundaries are being \
read off the smoothed profile again",
got.len()
);
}
let control = seg.segment_image(&drawn_page(29, 3, 30, false)).unwrap();
assert_eq!(
control.len(),
29,
"the 3px control failed, so the regression is not the profile choice"
);
}
#[test]
fn speckle_does_not_set_the_typical_line_height() {
let mut hist = vec![0f32; 700];
let mut runs: Vec<(u32, u32)> = Vec::new();
for i in 0..12u32 {
let y = i * 4;
hist[y as usize..(y + 2) as usize].fill(20.0);
runs.push((y, y + 2));
}
hist[100..124].fill(300.0);
hist[124..126].fill(5.0);
hist[126..150].fill(300.0);
runs.push((100, 124));
runs.push((126, 150));
for i in 0..3u32 {
let y = 200 + i * 60;
hist[y as usize..(y + 50) as usize].fill(300.0);
runs.push((y, y + 50));
}
let merged = merge_runs(&runs, &hist, MIN_GAP_MERGE, 10);
assert!(
merged.contains(&(100, 150)),
"the split pair did not merge, so speckle set the ceiling: got {merged:?}"
);
let speckle_band = merged
.iter()
.filter(|&&(a, b)| a < 100 && b - a >= 10)
.count();
assert_eq!(
speckle_band, 0,
"speckle fused into {speckle_band} band(s) tall enough to clear the \
height filter: got {merged:?}"
);
}
#[test]
fn no_merge_may_exceed_twice_a_typical_line() {
let mut hist = vec![0f32; 400];
hist[20..80].fill(300.0);
hist[80..82].fill(5.0); hist[82..142].fill(300.0);
hist[200..260].fill(300.0);
hist[300..360].fill(300.0);
let runs = [
(20u32, 80u32),
(82u32, 142u32),
(200u32, 260u32),
(300u32, 360u32),
];
assert_eq!(
merge_runs(&runs, &hist, MIN_GAP_MERGE, 10),
vec![(20, 80), (82, 142), (200, 260), (300, 360)],
"a merge produced a band taller than twice a typical line"
);
}
#[test]
fn merging_does_not_cascade_down_a_page() {
let mut hist = vec![0f32; 700];
let mut runs = Vec::new();
let mut y = 20u32;
for i in 0..8 {
let h = if i == 3 { 100 } else { 50 };
hist[y as usize..(y + h) as usize].fill(300.0);
hist[(y + h) as usize..(y + h + 2) as usize].fill(5.0); runs.push((y, y + h));
y += h + 2;
}
let merged = merge_runs(&runs, &hist, MIN_GAP_MERGE, 10);
let tallest = merged.iter().map(|&(a, b)| b - a).max().unwrap();
assert!(
tallest <= 100,
"a chain of 50-row runs collapsed into a band {tallest} rows tall, so \
the merge is cascading"
);
assert!(
merged.len() >= 4,
"8 runs became {} bands, so the merge is cascading",
merged.len()
);
}
#[test]
fn a_diacritic_strip_is_returned_joined_to_its_line() {
let (w, h) = (T_WIDTH, 200u32);
let mut img = GrayImage::from_pixel(w, h, Luma([255u8]));
let ink = |img: &mut GrayImage, y0: u32, y1: u32, every: u32| {
for yy in y0..y1 {
for k in 0..30u32 {
let x0 = 100 + k * T_PITCH;
for i in 0..every {
if x0 + i < w {
img.put_pixel(x0 + i, yy, Luma([0u8]));
}
}
}
}
};
ink(&mut img, 60, 80, 2);
ink(&mut img, 82, 126, T_GLYPH_W);
let got = LineSegmenter::new(10, 3).segment_image(&img).unwrap();
assert_eq!(
got.len(),
1,
"the strip and its body came back as {} bands — the merge is not \
reached from segment_image",
got.len()
);
assert!(
got[0].bbox.h >= 60,
"the returned band is {}px tall, so it holds the body without the \
marks above it",
got[0].bbox.h
);
}
#[test]
fn a_dip_between_equal_halves_merges_on_ink_alone() {
let mut hist = vec![0f32; 400];
hist[20..60].fill(300.0);
hist[60..62].fill(5.0); hist[62..102].fill(300.0);
hist[150..210].fill(300.0);
hist[260..320].fill(300.0);
let runs = [
(20u32, 60u32),
(62u32, 102u32),
(150u32, 210u32),
(260u32, 320u32),
];
assert_eq!(
merge_runs(&runs, &hist, MIN_GAP_MERGE, 10),
vec![(20, 102), (150, 210), (260, 320)],
"an ink-holding 2-row dip between two halves of a typical line did \
not merge"
);
}
#[test]
fn a_sub_threshold_dip_does_not_end_a_line() {
let mut hist = vec![0f32; 400];
hist[260..325].fill(200.0);
hist[280] = 6.0; let runs = [(260u32, 280u32), (281u32, 325u32)];
assert_eq!(
merge_runs(&runs, &hist, MIN_GAP_MERGE, 10),
vec![(260, 325)],
"a 1-row dip holding ink split one line in two"
);
}
#[test]
fn a_zero_gap_still_merges_a_fragment_into_its_line() {
let mut hist = vec![0f32; 500];
hist[341..360].fill(40.0);
hist[362..404].fill(300.0);
let runs = [(341u32, 360u32), (362u32, 404u32)];
assert_eq!(
merge_runs(&runs, &hist, MIN_GAP_MERGE, 10),
vec![(341, 404)],
"a 19-row fragment two empty rows from a 42-row line stayed separate"
);
}
#[test]
fn two_real_lines_two_rows_apart_stay_separate() {
let mut hist = vec![0f32; 400];
hist[20..60].fill(300.0);
hist[62..102].fill(300.0);
hist[180..240].fill(300.0);
hist[280..340].fill(300.0);
let runs = [
(20u32, 60u32),
(62u32, 102u32),
(180u32, 240u32),
(280u32, 340u32),
];
assert_eq!(
merge_runs(&runs, &hist, MIN_GAP_MERGE, 10),
vec![(20, 60), (62, 102), (180, 240), (280, 340)],
"two 40-row lines were fused, which is what SMEAR_Y would have done"
);
}
#[test]
fn a_wide_gap_is_a_line_boundary_however_much_ink_it_holds() {
let mut hist = vec![0f32; 400];
hist[20..60].fill(300.0);
hist[60..75].fill(5.0); hist[75..115].fill(300.0);
hist[180..240].fill(300.0);
hist[280..340].fill(300.0);
let runs = [
(20u32, 60u32),
(75u32, 115u32),
(180u32, 240u32),
(280u32, 340u32),
];
assert_eq!(
merge_runs(&runs, &hist, MIN_GAP_MERGE, 10),
vec![(20, 60), (75, 115), (180, 240), (280, 340)],
"a 15-row gap merged, so the size bound is not being applied"
);
}
#[test]
fn touching_bands_stay_one_line() {
let seg = LineSegmenter::new(10, 3);
let got = seg.segment_image(&drawn_page(29, 0, 30, false)).unwrap();
assert_eq!(got.len(), 1, "touching bands were split into {}", got.len());
}
#[test]
fn a_wide_smoother_does_not_fuse_the_page() {
let seg = LineSegmenter::new(10, 15);
for gap in [5u32, 12] {
let got = seg.segment_image(&drawn_page(29, gap, 30, false)).unwrap();
assert_eq!(
got.len(),
29,
"at smooth_window 15, 29 bands {gap}px apart came back as {}",
got.len()
);
}
}
#[test]
fn the_gap_threshold_is_calibrated_on_the_smoothed_profile() {
let seg = LineSegmenter::with_density_ratio(10, 3, 0.5);
let img = page_with_a_faint_band(8, 12, 30, 170, 20);
let got = seg.segment_image(&img).unwrap();
assert_eq!(
got.len(),
9,
"expected 8 dense bands plus the faint one, got {} — the threshold \
is being calibrated on the raw profile",
got.len()
);
}
fn banded_profile(lead: usize, gap: usize, ink: f32) -> Vec<f32> {
let mut out = vec![0f32; lead * 2 + gap];
out[..lead].fill(ink);
out[lead + gap..].fill(ink);
out
}
#[test]
fn the_box_spans_one_more_row_than_an_even_window_asks() {
for window in 2u32..=12 {
let span = (2 * (window / 2) + 1) as usize;
let at_span =
LineSegmenter::new(10, window).smooth_histogram(&banded_profile(20, span, 9.0));
let min_in_gap = at_span[20..20 + span]
.iter()
.cloned()
.fold(f32::MAX, f32::min);
assert_eq!(
min_in_gap, 0.0,
"window {window} left no zero row across a gap of {span} rows \
(min {min_in_gap}) — its span is no longer 2 * (window / 2) + 1"
);
let profile = banded_profile(20, span - 1, 9.0);
let under = LineSegmenter::new(10, window).smooth_histogram(&profile);
let min_under = under[20..20 + span - 1]
.iter()
.cloned()
.fold(f32::MAX, f32::min);
assert!(
min_under > 0.0,
"window {window} reached zero across a gap of only {} rows, so the \
box is narrower than measured",
span - 1
);
if window % 2 == 0 {
let odd = LineSegmenter::new(10, window + 1).smooth_histogram(&profile);
assert_eq!(
under,
odd,
"window {window} no longer matches window {} — the even-window \
rounding changed",
window + 1
);
}
}
}
#[test]
fn the_divisor_is_the_rows_visited_so_edge_rows_keep_their_true_mean() {
let mut dip = vec![300f32; 60];
dip[0] = 0.0;
dip[59] = 0.0;
let smoothed = LineSegmenter::new(10, 3).smooth_histogram(&dip);
assert_eq!(
smoothed[0], 150.0,
"row 0 is no longer the mean of the rows actually in range"
);
assert_eq!(smoothed[59], 150.0, "the last row lost the same property");
assert_eq!(
LineSegmenter::new(10, 5).smooth_histogram(&dip)[0],
200.0,
"window 5 row 0 should be 600 over the 3 rows in range, not 900 over 5"
);
let flat = vec![300f32; 60];
for window in [3u32, 5, 15] {
let smoothed = LineSegmenter::new(10, window).smooth_histogram(&flat);
assert_eq!(
smoothed[0], 300.0,
"window {window} attenuated row 0 to {} — the divisor became the \
window rather than the rows visited",
smoothed[0]
);
assert_eq!(
smoothed[59], 300.0,
"window {window} attenuated the last row"
);
}
}
#[test]
fn smoothing_never_lifts_the_profile_above_its_raw_peak() {
let mut profile = vec![0f32; 60];
profile[20..40].fill(300.0);
for window in 2u32..=12 {
let smoothed = LineSegmenter::new(10, window).smooth_histogram(&profile);
let peak = smoothed.iter().cloned().fold(f32::MIN, f32::max);
assert_eq!(
peak, 300.0,
"window {window} peaked at {peak}, not the raw 300 — the divisor no \
longer equals the row count"
);
assert!(
smoothed[20] < 300.0,
"window {window} left the band's first row at 300 — nothing was \
smoothed"
);
}
}
const MERGE_FIXTURE_ENV: &str = "MONOCR_MERGE_FIXTURE";
fn merge_fixture_path() -> PathBuf {
if let Some(path) = std::env::var_os(MERGE_FIXTURE_ENV) {
return PathBuf::from(path);
}
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../monocr-monorepo/shared/segmentation-fixtures/merge-cases.json")
}
fn load_merge_fixture() -> Value {
let path = merge_fixture_path();
let raw = std::fs::read_to_string(&path).unwrap_or_else(|e| {
panic!(
"cannot read the shared line-merge fixture at {}: {e}\n\
set {MERGE_FIXTURE_ENV} to point at \
monocr-monorepo/shared/segmentation-fixtures/merge-cases.json",
path.display()
)
});
serde_json::from_str(&raw)
.unwrap_or_else(|e| panic!("{} is not valid JSON: {e}", path.display()))
}
fn merge_pairs(case: &Value, key: &str) -> Vec<(u32, u32)> {
case.get(key)
.and_then(Value::as_array)
.unwrap_or_else(|| panic!("fixture case has no '{key}' array: {case}"))
.iter()
.map(|pair| {
let pair = pair
.as_array()
.unwrap_or_else(|| panic!("'{key}' entry is not a pair: {pair}"));
assert_eq!(pair.len(), 2, "'{key}' entry is not a pair: {pair:?}");
let value = |i: usize| {
pair[i]
.as_u64()
.unwrap_or_else(|| panic!("non-integer in '{key}': {pair:?}"))
as u32
};
(value(0), value(1))
})
.collect()
}
fn merge_profile(case: &Value) -> Vec<f32> {
let length = u32_field(case, "profile_length") as usize;
let mut hist = vec![0f32; length];
for fill in case
.get("profile_fills")
.and_then(Value::as_array)
.unwrap_or_else(|| panic!("fixture case has no 'profile_fills': {case}"))
{
let fill = fill
.as_array()
.unwrap_or_else(|| panic!("'profile_fills' entry is not a triple: {fill}"));
assert_eq!(
fill.len(),
3,
"'profile_fills' entry is not a triple: {fill:?}"
);
let number = |i: usize| {
fill[i]
.as_f64()
.unwrap_or_else(|| panic!("non-number in 'profile_fills': {fill:?}"))
};
let (a, b, value) = (number(0) as usize, number(1) as usize, number(2) as f32);
hist[a..b].fill(value);
}
hist
}
#[test]
fn merge_runs_matches_the_shared_fixture() {
let root = load_merge_fixture();
assert_eq!(
root.get("min_gap_merge").and_then(Value::as_u64),
Some(u64::from(MIN_GAP_MERGE)),
"fixture and port disagree on the maximum mergeable gap"
);
assert!(
root.get("mutations")
.and_then(Value::as_object)
.is_some_and(|m| !m.is_empty()),
"the fixture carries no mutation battery, so nothing proves its cases \
discriminate anything"
);
for case in cases(&root, "cases", MERGE_CASES_MIN) {
let name = case
.get("name")
.and_then(Value::as_str)
.unwrap_or("<unnamed>")
.to_string();
let note = case.get("note").and_then(Value::as_str).unwrap_or("");
let hist = merge_profile(&case);
let runs = merge_pairs(&case, "runs");
let expected = merge_pairs(&case, "expected");
let max_gap = u32_field(&case, "max_gap");
let min_line = u32_field(&case, "min_line");
assert_eq!(
merge_runs(&runs, &hist, max_gap, min_line),
expected,
"case '{name}': {note}"
);
assert!(
case.get("discriminates")
.and_then(Value::as_array)
.is_some_and(|d| !d.is_empty()),
"case '{name}' discriminates nothing, so it is padding"
);
}
}
}