use image::{imageops, imageops::FilterType, Rgb, RgbImage};
pub const REC_HEIGHT: u32 = 48;
pub const REC_BATCH: usize = 16;
pub struct PrepLine {
pub w: usize,
pub data: Vec<f32>,
}
pub fn prep_line(line: &RgbImage) -> Option<PrepLine> {
let (w, h) = line.dimensions();
if w == 0 || h == 0 {
return None;
}
let new_w = ((w as f32) * REC_HEIGHT as f32 / h as f32)
.round()
.clamp(8.0, 2400.0) as u32;
let resized = imageops::resize(line, new_w, REC_HEIGHT, FilterType::Triangle);
let n = (REC_HEIGHT * new_w) as usize;
let mut data = vec![0f32; 3 * n];
for (i, px) in resized.pixels().enumerate() {
data[i] = px[0] as f32 / 127.5 - 1.0;
data[n + i] = px[1] as f32 / 127.5 - 1.0;
data[2 * n + i] = px[2] as f32 / 127.5 - 1.0;
}
Some(PrepLine {
w: new_w as usize,
data,
})
}
pub fn dict_chars(dict: &str) -> Vec<String> {
let mut chars = vec![String::new()]; chars.extend(dict.lines().map(|s| s.to_string()));
chars.push(" ".to_string());
chars
}
pub fn decode_row(chars: &[String], probs: &[f32], nc: usize) -> String {
let mut out = String::new();
let mut prev = 0usize;
for row in probs.chunks_exact(nc) {
let mut best = 0usize;
let mut bestv = row[0];
for (c, &v) in row.iter().enumerate().skip(1) {
if v > bestv {
bestv = v;
best = c;
}
}
if best != prev && best != 0 {
if let Some(ch) = chars.get(best) {
out.push_str(ch);
}
}
prev = best;
}
out
}
pub(crate) fn luma(p: &Rgb<u8>) -> f32 {
0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32
}
pub fn segment_lines(crop: &RgbImage) -> Vec<(u32, u32, u32, u32)> {
let (w, h) = crop.dimensions();
if w == 0 || h == 0 {
return Vec::new();
}
let mean: f32 = crop.pixels().map(luma).sum::<f32>() / (w * h) as f32;
let thresh = mean * 0.7; let min_ink = ((w as f32) * 0.005).max(1.0) as u32;
let mut col_ink = vec![0u32; w as usize];
for y in 0..h {
for x in 0..w {
if luma(crop.get_pixel(x, y)) < thresh {
col_ink[x as usize] += 1;
}
}
}
let rule_cols = col_ink
.iter()
.filter(|&&c| c as f32 > 0.9 * h as f32)
.count();
let mask_rules = (rule_cols as f32) < 0.15 * w as f32;
let rule = |x: u32| mask_rules && col_ink[x as usize] as f32 > 0.9 * h as f32;
let mut profile = vec![0u32; h as usize];
for y in 0..h {
let mut row = 0u32;
for x in 0..w {
if !rule(x) && luma(crop.get_pixel(x, y)) < thresh {
row += 1;
}
}
profile[y as usize] = row;
}
let mut runs: Vec<(u32, u32)> = Vec::new();
let mut start: Option<u32> = None;
for y in 0..h {
let text = profile[y as usize] >= min_ink;
if text && start.is_none() {
start = Some(y);
} else if !text {
if let Some(s) = start.take() {
if y - s >= 4 {
runs.push((s, y));
}
}
}
}
if let Some(s) = start {
if h - s >= 4 {
runs.push((s, h));
}
}
runs.into_iter()
.map(|(t, b)| {
let (mut l, mut r) = (w, 0u32);
for y in t..b {
for x in 0..w {
if luma(crop.get_pixel(x, y)) < thresh {
l = l.min(x);
r = r.max(x + 1);
}
}
}
if l >= r {
(0, t, w, b)
} else {
(l, t, r, b)
}
})
.collect()
}
pub fn is_text_label(label: &str) -> bool {
matches!(
label,
"text"
| "title"
| "section_header"
| "list_item"
| "caption"
| "footnote"
| "code"
| "formula"
)
}
pub type LineBox = (f32, f32, f32, f32);
pub fn prep_region_lines(
img: &RgbImage,
regions: &[crate::layout::Region],
scale: f32,
) -> (Vec<LineBox>, Vec<PrepLine>) {
let (iw, ih) = img.dimensions();
let mut bboxes = Vec::new();
let mut lines = Vec::new();
for region in regions {
if !is_text_label(region.label) {
continue;
}
let l = (region.l * scale).max(0.0) as u32;
let t = (region.t * scale).max(0.0) as u32;
let r = ((region.r * scale).max(0.0) as u32).min(iw);
let b = ((region.b * scale).max(0.0) as u32).min(ih);
if r <= l || b <= t {
continue;
}
let crop = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
for (lx, ly, rx, ry) in segment_lines(&crop) {
let line = imageops::crop_imm(&crop, lx, ly, rx - lx, ry - ly).to_image();
let Some(pl) = prep_line(&line) else {
continue;
};
bboxes.push((
(l + lx) as f32 / scale,
(t + ly) as f32 / scale,
(l + rx) as f32 / scale,
(t + ry) as f32 / scale,
));
lines.push(pl);
}
}
(bboxes, lines)
}
pub fn segment_words(line: &RgbImage) -> Vec<(u32, u32, u32, u32)> {
let (w, h) = line.dimensions();
if w == 0 || h == 0 {
return Vec::new();
}
let mean: f32 = line.pixels().map(luma).sum::<f32>() / (w * h) as f32;
let thresh = mean * 0.7;
let mut col_ink = vec![0u32; w as usize];
for y in 0..h {
for x in 0..w {
if luma(line.get_pixel(x, y)) < thresh {
col_ink[x as usize] += 1;
}
}
}
let min_gap = ((h as f32) * 0.6).max(4.0) as u32;
let mut words = Vec::new();
let mut start: Option<u32> = None;
let mut last_ink = 0u32;
let mut gap = 0u32;
for x in 0..w {
if col_ink[x as usize] > 0 {
if start.is_none() {
start = Some(x);
}
last_ink = x;
gap = 0;
} else if let Some(s) = start {
gap += 1;
if gap >= min_gap {
words.push((s, 0, last_ink + 1, h));
start = None;
}
}
}
if let Some(s) = start {
words.push((s, 0, last_ink + 1, h));
}
words
}
pub fn prep_table_words(
img: &RgbImage,
regions: &[crate::layout::Region],
scale: f32,
) -> (Vec<LineBox>, Vec<PrepLine>) {
let (iw, ih) = img.dimensions();
let mut bboxes = Vec::new();
let mut lines = Vec::new();
for region in regions {
if !crate::assemble::is_table_like(region.label) {
continue;
}
let l = (region.l * scale).max(0.0) as u32;
let t = (region.t * scale).max(0.0) as u32;
let r = ((region.r * scale).max(0.0) as u32).min(iw);
let b = ((region.b * scale).max(0.0) as u32).min(ih);
if r <= l || b <= t {
continue;
}
let crop = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
for (lx, ly, rx, ry) in segment_lines(&crop) {
let line = imageops::crop_imm(&crop, lx, ly, rx - lx, ry - ly).to_image();
for (wx0, _, wx1, _) in segment_words(&line) {
let word = imageops::crop_imm(&line, wx0, 0, wx1 - wx0, ry - ly).to_image();
let Some(pl) = prep_line(&word) else {
continue;
};
bboxes.push((
(l + lx + wx0) as f32 / scale,
(t + ly) as f32 / scale,
(l + lx + wx1) as f32 / scale,
(t + ry) as f32 / scale,
));
lines.push(pl);
}
}
}
(bboxes, lines)
}
pub fn normalize_polarity(mut img: RgbImage) -> RgbImage {
let (w, h) = img.dimensions();
if w == 0 || h == 0 {
return img;
}
let mean: f32 = img.pixels().map(luma).sum::<f32>() / (w * h) as f32;
if mean < 128.0 {
for px in img.pixels_mut() {
px.0 = [255 - px.0[0], 255 - px.0[1], 255 - px.0[2]];
}
}
img
}
pub fn prep_page_lines(img: &RgbImage) -> Vec<PrepLine> {
segment_lines(img)
.into_iter()
.filter_map(|(l, t, r, b)| {
let line = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
prep_line(&line)
})
.collect()
}
pub fn width_batches(lines: &[PrepLine]) -> Vec<(usize, Vec<usize>)> {
let mut by_width: std::collections::BTreeMap<usize, Vec<usize>> =
std::collections::BTreeMap::new();
for (ix, pl) in lines.iter().enumerate() {
by_width.entry(pl.w).or_default().push(ix);
}
let mut out = Vec::new();
for (w, ixs) in by_width {
for chunk in ixs.chunks(REC_BATCH) {
out.push((w, chunk.to_vec()));
}
}
out
}
pub fn batch_input(w: usize, chunk: &[usize], lines: &[PrepLine]) -> Vec<f32> {
let hw = REC_HEIGHT as usize * w;
let mut data = vec![0f32; chunk.len() * 3 * hw];
for (i, &ix) in chunk.iter().enumerate() {
data[i * 3 * hw..(i + 1) * 3 * hw].copy_from_slice(&lines[ix].data);
}
data
}
#[cfg(test)]
mod tests {
use super::*;
fn page() -> RgbImage {
let mut img = RgbImage::from_pixel(200, 100, Rgb([255, 255, 255]));
for y in 20..30 {
for x in 10..190 {
img.put_pixel(x, y, Rgb([0, 0, 0]));
}
}
for y in 60..72 {
for x in 10..120 {
img.put_pixel(x, y, Rgb([0, 0, 0]));
}
}
img
}
#[test]
fn segments_and_preps_page_lines() {
let lines = prep_page_lines(&page());
assert_eq!(lines.len(), 2);
for pl in &lines {
assert_eq!(pl.data.len(), 3 * REC_HEIGHT as usize * pl.w);
}
let batches = width_batches(&lines);
assert_eq!(batches.len(), 2);
let (w0, chunk0) = &batches[0];
assert_eq!(
batch_input(*w0, chunk0, &lines).len(),
3 * REC_HEIGHT as usize * w0
);
}
#[test]
fn dark_mode_pages_normalize_to_scan_polarity() {
let mut dark = page();
for px in dark.pixels_mut() {
px.0 = [255 - px.0[0], 255 - px.0[1], 255 - px.0[2]];
}
assert_ne!(segment_lines(&dark), segment_lines(&page()));
let fixed = normalize_polarity(dark);
assert_eq!(segment_lines(&fixed), segment_lines(&page()));
assert_eq!(prep_page_lines(&fixed).len(), 2);
let light = page();
assert_eq!(normalize_polarity(light.clone()), light);
}
#[test]
fn ctc_decode_collapses_repeats_and_blanks() {
let chars = dict_chars("a\nb");
assert_eq!(chars.len(), 4); let probs = [
0.1, 0.8, 0.1, 0.0, 0.1, 0.8, 0.1, 0.0, 0.9, 0.05, 0.05, 0.0, 0.1, 0.1, 0.8, 0.0, 0.1, 0.1, 0.8, 0.0, ];
assert_eq!(decode_row(&chars, &probs, 4), "ab");
}
}
#[cfg(test)]
mod word_segmentation {
use image::{Rgb, RgbImage};
fn line_with_gap(h: u32, gap: u32) -> RgbImage {
let w = 30 + gap + 30 + 10;
let mut img = RgbImage::from_pixel(w, h, Rgb([255, 255, 255]));
for (x0, x1) in [(5u32, 35u32), (35 + gap, 65 + gap)] {
for x in x0..x1.min(w) {
for y in h / 4..(3 * h / 4) {
img.put_pixel(x, y, Rgb([0, 0, 0]));
}
}
}
img
}
#[test]
fn words_split_only_on_gaps_above_six_tenths_of_the_line_height() {
for h in [16u32, 24, 32, 40] {
let split_at = (1..=40u32)
.find(|&gap| super::segment_words(&line_with_gap(h, gap)).len() >= 2)
.expect("some gap splits");
let ratio = split_at as f32 / h as f32;
assert!(
(0.5..=0.65).contains(&ratio),
"h={h}: split at {split_at}px ({ratio:.2} x height)"
);
assert_eq!(
super::segment_words(&line_with_gap(h, split_at - 1)).len(),
1,
"h={h}: a narrower gap must not split"
);
}
}
}