use xberg_tesseract::{Pix, Result as PixResult};
const DARK_ROW_MIN_DARK_FRACTION: f64 = 0.40;
const DARK_ROW_GRAY_THRESHOLD: u8 = 110;
const MIN_BAND_HEIGHT_DIVISOR: i32 = 400;
const MIN_BAND_HEIGHT_FLOOR: i32 = 8;
const DARK_BAND_COLUMN_MAJORITY_FRACTION: f64 = 0.5;
const DARK_BAND_INVERT_PADDING: i32 = 2;
const SHADED_BAND_LOW_GRAY: f64 = 60.0;
const SHADED_BAND_HIGH_GRAY: f64 = 225.0;
const TABLE_WIDTH_MARGIN_DENOMINATOR: i32 = 8;
const SHADED_BAND_FILL_CHANGE_DELTA: f64 = 25.0;
const BAND_POLARITY_ROW_MARGIN: usize = 4;
const BAND_POLARITY_VOTE_DELTA: f32 = 40.0;
const BAND_MINIMUM_CONTRAST: f32 = 30.0;
const BAND_INK_PERCENTILE: f64 = 2.0;
const BAND_STRETCH_MAX: f32 = 255.0;
fn find_dark_bands(gray: &[u8], width: i32, height: i32) -> Vec<(i32, i32, i32, i32)> {
if width <= 0 || height <= 0 {
return Vec::new();
}
let min_band_height = (height / MIN_BAND_HEIGHT_DIVISOR).max(MIN_BAND_HEIGHT_FLOOR);
let row_dark_fraction = |y: i32| -> f64 {
let start = (y * width) as usize;
let end = start + width as usize;
let dark = gray[start..end]
.iter()
.filter(|&&value| value < DARK_ROW_GRAY_THRESHOLD)
.count();
dark as f64 / width as f64
};
let mut bands = Vec::new();
let mut y = 0;
while y < height {
if row_dark_fraction(y) >= DARK_ROW_MIN_DARK_FRACTION {
let y0 = y;
while y < height && row_dark_fraction(y) >= DARK_ROW_MIN_DARK_FRACTION {
y += 1;
}
if y - y0 >= min_band_height {
bands.push(dark_band_columns(gray, width, y0, y));
}
} else {
y += 1;
}
}
bands
}
fn dark_band_columns(gray: &[u8], width: i32, y0: i32, y1: i32) -> (i32, i32, i32, i32) {
let band_height = (y1 - y0) as f64;
let mut x0 = width;
let mut x1 = 0;
for x in 0..width {
let dark_rows = (y0..y1)
.filter(|&y| gray[(y * width + x) as usize] < DARK_ROW_GRAY_THRESHOLD)
.count();
if dark_rows as f64 / band_height > DARK_BAND_COLUMN_MAJORITY_FRACTION {
x0 = x0.min(x);
x1 = x1.max(x + 1);
}
}
if x0 >= x1 { (y0, y1, 0, width) } else { (y0, y1, x0, x1) }
}
fn padded_dark_band(gray: &[u8], width: i32, height: i32, band: (i32, i32, i32, i32)) -> (i32, i32, i32, i32) {
let (mut y0, mut y1, mut x0, mut x1) = band;
let row_is_fill = |y: i32, x0: i32, x1: i32| {
let start = (y * width + x0) as usize;
let end = (y * width + x1) as usize;
median(&gray[start..end]) < SHADED_BAND_HIGH_GRAY
};
let column_is_fill = |x: i32, y0: i32, y1: i32| {
let column: Vec<u8> = (y0..y1).map(|y| gray[(y * width + x) as usize]).collect();
median(&column) < SHADED_BAND_HIGH_GRAY
};
for _ in 0..DARK_BAND_INVERT_PADDING {
if y0 > 0 && row_is_fill(y0 - 1, x0, x1) {
y0 -= 1;
} else {
break;
}
}
for _ in 0..DARK_BAND_INVERT_PADDING {
if y1 < height && row_is_fill(y1, x0, x1) {
y1 += 1;
} else {
break;
}
}
for _ in 0..DARK_BAND_INVERT_PADDING {
if x0 > 0 && column_is_fill(x0 - 1, y0, y1) {
x0 -= 1;
} else {
break;
}
}
for _ in 0..DARK_BAND_INVERT_PADDING {
if x1 < width && column_is_fill(x1, y0, y1) {
x1 += 1;
} else {
break;
}
}
(y0, y1, x0, x1)
}
fn invert_dark_bands(gray: &mut [u8], width: i32, height: i32, bands: &[(i32, i32, i32, i32)]) {
let mut claimed_through = 0;
for &band in bands {
let (y0, y1, x0, x1) = padded_dark_band(gray, width, height, band);
let y0 = y0.max(claimed_through);
claimed_through = claimed_through.max(y1);
for y in y0..y1 {
for x in x0..x1 {
let index = (y * width + x) as usize;
gray[index] = 255 - gray[index];
}
}
}
}
fn median(values: &[u8]) -> f64 {
percentile(values, 50.0)
}
fn percentile(values: &[u8], pct: f64) -> f64 {
if values.is_empty() {
return 0.0;
}
let mut sorted: Vec<u8> = values.to_vec();
sorted.sort_unstable();
let rank = (pct / 100.0) * (sorted.len() - 1) as f64;
let lo = rank.floor() as usize;
let hi = rank.ceil() as usize;
if lo == hi {
sorted[lo] as f64
} else {
let fraction = rank - lo as f64;
sorted[lo] as f64 * (1.0 - fraction) + sorted[hi] as f64 * fraction
}
}
fn percentile_f32(values: &[f32], pct: f64) -> f32 {
if values.is_empty() {
return 0.0;
}
let mut sorted: Vec<f32> = values.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let rank = (pct / 100.0) * (sorted.len() - 1) as f64;
let lo = rank.floor() as usize;
let hi = rank.ceil() as usize;
if lo == hi {
sorted[lo]
} else {
let fraction = (rank - lo as f64) as f32;
sorted[lo] * (1.0 - fraction) + sorted[hi] * fraction
}
}
fn find_shaded_bands(gray: &[u8], width: i32, height: i32) -> Vec<(i32, i32)> {
if width <= 0 || height <= 0 {
return Vec::new();
}
let margin = width / TABLE_WIDTH_MARGIN_DENOMINATOR;
let column_start = margin.max(0);
let column_end = (width - margin).max(column_start);
let min_band_height = (height / MIN_BAND_HEIGHT_DIVISOR).max(MIN_BAND_HEIGHT_FLOOR) as usize;
let row_medians: Vec<f64> = (0..height)
.map(|y| {
let start = (y * width + column_start) as usize;
let end = (y * width + column_end) as usize;
median(&gray[start..end])
})
.collect();
let is_shaded = |y: usize| row_medians[y] > SHADED_BAND_LOW_GRAY && row_medians[y] < SHADED_BAND_HIGH_GRAY;
let mut bands = Vec::new();
let mut y = 0usize;
let height = height as usize;
while y < height {
if is_shaded(y) {
let y0 = y;
y += 1;
while y < height
&& is_shaded(y)
&& (row_medians[y] - row_medians[y - 1]).abs() <= SHADED_BAND_FILL_CHANGE_DELTA
{
y += 1;
}
if y - y0 >= min_band_height {
bands.push((y0 as i32, y as i32));
}
} else {
y += 1;
}
}
bands
}
fn band_fill_columns(gray: &[u8], stride: i32, y0: i32, y1: i32) -> (usize, usize) {
if stride <= 0 || y1 <= y0 {
return (0, stride.max(0) as usize);
}
let stride = stride as usize;
let band_height = (y1 - y0) as usize;
let mut row_start = BAND_POLARITY_ROW_MARGIN.min(band_height);
let mut row_end = band_height.saturating_sub(BAND_POLARITY_ROW_MARGIN);
if row_end <= row_start {
row_start = 0;
row_end = band_height;
}
let column_is_fill = |x: usize| {
let column: Vec<u8> = (row_start..row_end)
.map(|row| gray[(y0 as usize + row) * stride + x])
.collect();
median(&column) < SHADED_BAND_HIGH_GRAY
};
let mut x0 = stride;
let mut x1 = 0usize;
for x in 0..stride {
if column_is_fill(x) {
x0 = x0.min(x);
x1 = x1.max(x + 1);
}
}
if x0 >= x1 { (0, stride) } else { (x0, x1) }
}
fn normalize_band(gray: &mut [u8], stride: i32, y0: i32, y1: i32) {
let band_height = (y1 - y0) as usize;
let (fill_x0, fill_x1) = band_fill_columns(gray, stride, y0, y1);
let band_width = fill_x1.saturating_sub(fill_x0);
let stride = stride.max(0) as usize;
if band_height == 0 || band_width == 0 {
return;
}
let mut band: Vec<f32> = Vec::with_capacity(band_height * band_width);
for row in 0..band_height {
let start = (y0 as usize + row) * stride + fill_x0;
band.extend(gray[start..start + band_width].iter().map(|&value| value as f32));
}
let background = median_f32(&band);
let core_row_start = BAND_POLARITY_ROW_MARGIN.min(band_height);
let core_row_end = band_height.saturating_sub(BAND_POLARITY_ROW_MARGIN).max(core_row_start);
let column_margin = band_width / TABLE_WIDTH_MARGIN_DENOMINATOR as usize;
let core_column_start = column_margin;
let core_column_end = band_width.saturating_sub(column_margin).max(core_column_start);
let mut light_votes: u64 = 0;
let mut dark_votes: u64 = 0;
for row in core_row_start..core_row_end {
for column in core_column_start..core_column_end {
let value = band[row * band_width + column];
if value > background + BAND_POLARITY_VOTE_DELTA {
light_votes += 1;
} else if value < background - BAND_POLARITY_VOTE_DELTA {
dark_votes += 1;
}
}
}
let background = if light_votes > dark_votes {
for value in &mut band {
*value = BAND_STRETCH_MAX - *value;
}
median_f32(&band)
} else {
background
};
let ink = percentile_f32(&band, BAND_INK_PERCENTILE);
if background - ink < BAND_MINIMUM_CONTRAST {
return;
}
for row in 0..band_height {
for column in 0..band_width {
let value = band[row * band_width + column];
let stretched = (value - ink) / (background - ink) * BAND_STRETCH_MAX;
let index = (y0 as usize + row) * stride + fill_x0 + column;
gray[index] = stretched.clamp(0.0, BAND_STRETCH_MAX) as u8;
}
}
}
fn median_f32(values: &[f32]) -> f32 {
percentile_f32(values, 50.0)
}
pub(crate) fn normalize_shaded_rows_bytes(gray: &mut [u8], width: i32, height: i32) {
let dark_bands = find_dark_bands(gray, width, height);
invert_dark_bands(gray, width, height, &dark_bands);
for (y0, y1) in find_shaded_bands(gray, width, height) {
normalize_band(gray, width, y0, y1);
}
}
pub(crate) fn normalize_shaded_rows(pix: &Pix) -> PixResult<Pix> {
let width = pix.width();
let height = pix.height();
let mut bytes = pix.grayscale_bytes()?;
normalize_shaded_rows_bytes(&mut bytes, width, height);
let mut normalized = Pix::from_grayscale_bytes(&bytes, width as u32, height as u32)?;
if let Ok((xres, yres)) = pix.get_resolution() {
normalized.set_resolution(xres, yres)?;
}
Ok(normalized)
}
#[cfg(test)]
mod tests {
use super::*;
fn synthetic_page(width: i32, height: i32) -> Vec<u8> {
const TEXT_STRIPE_MODULUS: i32 = 5;
let mut gray = vec![255u8; (width * height) as usize];
let text_columns = (width / 4)..(width * 3 / 4);
let is_ink_column = |x: i32| text_columns.contains(&x) && x % TEXT_STRIPE_MODULUS == 0;
for y in 10..30 {
for x in 0..width {
let index = (y * width + x) as usize;
gray[index] = if is_ink_column(x) { 30 } else { 200 };
}
}
for y in 40..60 {
for x in 0..width {
let index = (y * width + x) as usize;
gray[index] = if is_ink_column(x) { 230 } else { 40 };
}
}
gray
}
fn first_ink_column(width: i32) -> i32 {
width / 4
}
#[test]
fn should_find_dark_band_for_white_on_dark_fill_row() {
let width = 200;
let height = 80;
let gray = synthetic_page(width, height);
let bands = find_dark_bands(&gray, width, height);
assert_eq!(
bands.len(),
1,
"exactly one band must be dark enough to trigger inversion"
);
let (y0, y1, _, _) = bands[0];
assert_eq!(
(y0, y1),
(40, 60),
"the dark-fill band's row range must be found exactly"
);
}
#[test]
fn should_invert_dark_band_to_dark_text_on_light() {
let width = 200;
let height = 80;
let mut gray = synthetic_page(width, height);
let bands = find_dark_bands(&gray, width, height);
invert_dark_bands(&mut gray, width, height, &bands);
let fill_pixel = gray[(45 * width + 5) as usize];
let text_pixel = gray[(45 * width + first_ink_column(width)) as usize];
assert!(
fill_pixel > 200,
"inverted dark fill must read as light: got {fill_pixel}"
);
assert!(
text_pixel < 55,
"inverted white text must read as dark: got {text_pixel}"
);
}
#[test]
fn should_pad_a_dark_band_onto_its_anti_aliased_edge_but_not_onto_the_paper() {
let width = 200;
let height = 80;
let mut gray = synthetic_page(width, height);
for x in 0..width {
gray[(39 * width + x) as usize] = 150;
}
let bands = find_dark_bands(&gray, width, height);
invert_dark_bands(&mut gray, width, height, &bands);
assert_eq!(
gray[(39 * width + 5) as usize],
105,
"the anti-aliased fill edge must still be inverted with the band"
);
assert_eq!(
gray[(38 * width + 5) as usize],
255,
"the paper above the band must not be inverted into a black rule"
);
}
#[test]
fn should_invert_a_split_dark_bands_overlapping_padding_only_once() {
let width = 200;
let height = 80;
let mut gray = synthetic_page(width, height);
for y in 50..52 {
for x in 0..width {
gray[(y * width + x) as usize] = 200;
}
}
let bands = find_dark_bands(&gray, width, height);
assert_eq!(
bands.len(),
2,
"control: the gap must split the dark row into two bands, or this test pins nothing: {bands:?}"
);
invert_dark_bands(&mut gray, width, height, &bands);
for y in 50..52 {
assert_eq!(
gray[(y * width + 5) as usize],
55,
"row {y} lies in both padded boxes and must be inverted once (200 -> 55), not \
twice (back to 200), which would strike a dark rule through the row's text"
);
}
}
#[test]
fn should_find_and_flatten_light_fill_band() {
let width = 200;
let height = 80;
let mut gray = synthetic_page(width, height);
let dark_bands = find_dark_bands(&gray, width, height);
invert_dark_bands(&mut gray, width, height, &dark_bands);
let shaded = find_shaded_bands(&gray, width, height);
assert!(
shaded.iter().any(|&(y0, y1)| y0 <= 10 && y1 >= 30),
"the light-fill band must be detected as shaded: {shaded:?}"
);
for (y0, y1) in shaded {
normalize_band(&mut gray, width, y0, y1);
}
let fill_pixel = gray[(15 * width + 5) as usize];
let text_pixel = gray[(15 * width + first_ink_column(width)) as usize];
assert!(
fill_pixel > 240,
"flattened light fill must read as white: got {fill_pixel}"
);
assert!(text_pixel < 15, "flattened text must read as black: got {text_pixel}");
}
#[test]
fn should_leave_plain_white_background_untouched() {
let width = 200;
let height = 80;
let mut gray = synthetic_page(width, height);
let before = gray.clone();
normalize_shaded_rows_bytes(&mut gray, width, height);
for y in 0..10 {
for x in 0..width {
let index = (y * width + x) as usize;
assert_eq!(gray[index], before[index], "plain white rows must not be touched");
}
}
}
#[test]
fn should_round_trip_through_pix() {
let width = 40u32;
let height = 20u32;
let data: Vec<u8> = (0..(width * height)).map(|i| (i % 256) as u8).collect();
let pix = Pix::from_grayscale_bytes(&data, width, height).unwrap();
let bytes = pix.grayscale_bytes().unwrap();
assert_eq!(bytes, data, "grayscale bytes must round-trip through Pix exactly");
}
#[test]
fn should_normalize_shaded_rows_through_pix_pipeline() {
let width = 200u32;
let height = 80u32;
let gray = synthetic_page(width as i32, height as i32);
let pix = Pix::from_grayscale_bytes(&gray, width, height).unwrap();
let normalized = normalize_shaded_rows(&pix).unwrap();
assert_eq!(normalized.width(), width as i32);
assert_eq!(normalized.height(), height as i32);
let bytes = normalized.grayscale_bytes().unwrap();
let fill_pixel = bytes[(15 * width as i32 + 5) as usize];
assert!(
fill_pixel > 240,
"light fill must read white after the Pix round trip: got {fill_pixel}"
);
}
fn synthetic_page_with_margins(width: i32, height: i32) -> Vec<u8> {
const TEXT_STRIPE_MODULUS: i32 = 5;
const TEXT_STRIPE_OFFSET: i32 = 1;
let mut gray = vec![255u8; (width * height) as usize];
let table_columns = (width / 4)..(width * 3 / 4);
let text_columns = (width * 3 / 8)..(width * 5 / 8);
for y in 40..60 {
for x in table_columns.clone() {
let ink = text_columns.contains(&x) && x % TEXT_STRIPE_MODULUS == TEXT_STRIPE_OFFSET;
gray[(y * width + x) as usize] = if ink { 255 } else { 148 };
}
}
gray
}
#[test]
fn should_flatten_a_mid_fill_band_without_touching_the_page_margins() {
let width = 400;
let height = 80;
let mut gray = synthetic_page_with_margins(width, height);
let fill_column = width / 4 + 10;
let ink_column = ((width * 3 / 8)..(width * 5 / 8))
.find(|x| x % 5 == 1)
.expect("the band must carry at least one ink column");
let margin_column = width / 8;
normalize_shaded_rows_bytes(&mut gray, width, height);
let fill_pixel = gray[(50 * width + fill_column) as usize];
let ink_pixel = gray[(50 * width + ink_column) as usize];
let margin_pixel = gray[(50 * width + margin_column) as usize];
assert!(
fill_pixel > 240,
"flattened mid fill must read as white: got {fill_pixel}"
);
assert!(
ink_pixel < 15,
"white text on a mid fill must flatten to black ink: got {ink_pixel}"
);
assert_eq!(
margin_pixel, 255,
"the page margin beside the band must be left untouched"
);
}
}