1use image::{imageops, imageops::FilterType, Rgb, RgbImage};
13
14pub const REC_HEIGHT: u32 = 48;
16
17pub const REC_BATCH: usize = 16;
21
22pub struct PrepLine {
25 pub w: usize,
27 pub data: Vec<f32>,
29}
30
31pub fn prep_line(line: &RgbImage) -> Option<PrepLine> {
33 let (w, h) = line.dimensions();
34 if w == 0 || h == 0 {
35 return None;
36 }
37 let new_w = ((w as f32) * REC_HEIGHT as f32 / h as f32)
38 .round()
39 .clamp(8.0, 2400.0) as u32;
40 let resized = imageops::resize(line, new_w, REC_HEIGHT, FilterType::Triangle);
41 let n = (REC_HEIGHT * new_w) as usize;
42 let mut data = vec![0f32; 3 * n];
44 for (i, px) in resized.pixels().enumerate() {
45 data[i] = px[0] as f32 / 127.5 - 1.0;
46 data[n + i] = px[1] as f32 / 127.5 - 1.0;
47 data[2 * n + i] = px[2] as f32 / 127.5 - 1.0;
48 }
49 Some(PrepLine {
50 w: new_w as usize,
51 data,
52 })
53}
54
55pub fn dict_chars(dict: &str) -> Vec<String> {
58 let mut chars = vec![String::new()]; chars.extend(dict.lines().map(|s| s.to_string()));
60 chars.push(" ".to_string());
61 chars
62}
63
64pub fn decode_row(chars: &[String], probs: &[f32], nc: usize) -> String {
66 let mut out = String::new();
67 let mut prev = 0usize;
68 for row in probs.chunks_exact(nc) {
69 let mut best = 0usize;
70 let mut bestv = row[0];
71 for (c, &v) in row.iter().enumerate().skip(1) {
72 if v > bestv {
73 bestv = v;
74 best = c;
75 }
76 }
77 if best != prev && best != 0 {
78 if let Some(ch) = chars.get(best) {
79 out.push_str(ch);
80 }
81 }
82 prev = best;
83 }
84 out
85}
86
87pub(crate) fn luma(p: &Rgb<u8>) -> f32 {
88 0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32
89}
90
91pub fn segment_lines(crop: &RgbImage) -> Vec<(u32, u32, u32, u32)> {
94 let (w, h) = crop.dimensions();
95 if w == 0 || h == 0 {
96 return Vec::new();
97 }
98 let mean: f32 = crop.pixels().map(luma).sum::<f32>() / (w * h) as f32;
99 let thresh = mean * 0.7; let min_ink = ((w as f32) * 0.005).max(1.0) as u32;
101
102 let mut col_ink = vec![0u32; w as usize];
109 for y in 0..h {
110 for x in 0..w {
111 if luma(crop.get_pixel(x, y)) < thresh {
112 col_ink[x as usize] += 1;
113 }
114 }
115 }
116 let rule_cols = col_ink
121 .iter()
122 .filter(|&&c| c as f32 > 0.9 * h as f32)
123 .count();
124 let mask_rules = (rule_cols as f32) < 0.15 * w as f32;
125 let rule = |x: u32| mask_rules && col_ink[x as usize] as f32 > 0.9 * h as f32;
126
127 let mut profile = vec![0u32; h as usize];
128 for y in 0..h {
129 let mut row = 0u32;
130 for x in 0..w {
131 if !rule(x) && luma(crop.get_pixel(x, y)) < thresh {
132 row += 1;
133 }
134 }
135 profile[y as usize] = row;
136 }
137
138 let mut runs: Vec<(u32, u32)> = Vec::new();
140 let mut start: Option<u32> = None;
141 for y in 0..h {
142 let text = profile[y as usize] >= min_ink;
143 if text && start.is_none() {
144 start = Some(y);
145 } else if !text {
146 if let Some(s) = start.take() {
147 if y - s >= 4 {
148 runs.push((s, y));
149 }
150 }
151 }
152 }
153 if let Some(s) = start {
154 if h - s >= 4 {
155 runs.push((s, h));
156 }
157 }
158
159 runs.into_iter()
161 .map(|(t, b)| {
162 let (mut l, mut r) = (w, 0u32);
163 for y in t..b {
164 for x in 0..w {
165 if luma(crop.get_pixel(x, y)) < thresh {
166 l = l.min(x);
167 r = r.max(x + 1);
168 }
169 }
170 }
171 if l >= r {
172 (0, t, w, b)
173 } else {
174 (l, t, r, b)
175 }
176 })
177 .collect()
178}
179
180pub fn is_text_label(label: &str) -> bool {
182 matches!(
183 label,
184 "text"
185 | "title"
186 | "section_header"
187 | "list_item"
188 | "caption"
189 | "footnote"
190 | "code"
191 | "formula"
192 )
193}
194
195pub type LineBox = (f32, f32, f32, f32);
197
198pub fn prep_region_lines(
204 img: &RgbImage,
205 regions: &[crate::layout::Region],
206 scale: f32,
207) -> (Vec<LineBox>, Vec<PrepLine>) {
208 let (iw, ih) = img.dimensions();
209 let mut bboxes = Vec::new();
210 let mut lines = Vec::new();
211 for region in regions {
212 if !is_text_label(region.label) {
213 continue;
214 }
215 let l = (region.l * scale).max(0.0) as u32;
216 let t = (region.t * scale).max(0.0) as u32;
217 let r = ((region.r * scale).max(0.0) as u32).min(iw);
218 let b = ((region.b * scale).max(0.0) as u32).min(ih);
219 if r <= l || b <= t {
220 continue;
221 }
222 let crop = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
223 for (lx, ly, rx, ry) in segment_lines(&crop) {
224 let line = imageops::crop_imm(&crop, lx, ly, rx - lx, ry - ly).to_image();
225 let Some(pl) = prep_line(&line) else {
226 continue;
227 };
228 bboxes.push((
229 (l + lx) as f32 / scale,
230 (t + ly) as f32 / scale,
231 (l + rx) as f32 / scale,
232 (t + ry) as f32 / scale,
233 ));
234 lines.push(pl);
235 }
236 }
237 (bboxes, lines)
238}
239
240pub fn segment_words(line: &RgbImage) -> Vec<(u32, u32, u32, u32)> {
247 let (w, h) = line.dimensions();
248 if w == 0 || h == 0 {
249 return Vec::new();
250 }
251 let mean: f32 = line.pixels().map(luma).sum::<f32>() / (w * h) as f32;
252 let thresh = mean * 0.7;
253 let mut col_ink = vec![0u32; w as usize];
254 for y in 0..h {
255 for x in 0..w {
256 if luma(line.get_pixel(x, y)) < thresh {
257 col_ink[x as usize] += 1;
258 }
259 }
260 }
261 let min_gap = ((h as f32) * 0.6).max(4.0) as u32;
262 let mut words = Vec::new();
263 let mut start: Option<u32> = None;
264 let mut last_ink = 0u32;
265 let mut gap = 0u32;
266 for x in 0..w {
267 if col_ink[x as usize] > 0 {
268 if start.is_none() {
269 start = Some(x);
270 }
271 last_ink = x;
272 gap = 0;
273 } else if let Some(s) = start {
274 gap += 1;
275 if gap >= min_gap {
276 words.push((s, 0, last_ink + 1, h));
277 start = None;
278 }
279 }
280 }
281 if let Some(s) = start {
282 words.push((s, 0, last_ink + 1, h));
283 }
284 words
285}
286
287pub fn prep_table_words(
294 img: &RgbImage,
295 regions: &[crate::layout::Region],
296 scale: f32,
297) -> (Vec<LineBox>, Vec<PrepLine>) {
298 let (iw, ih) = img.dimensions();
299 let mut bboxes = Vec::new();
300 let mut lines = Vec::new();
301 for region in regions {
302 if !crate::assemble::is_table_like(region.label) {
303 continue;
304 }
305 let l = (region.l * scale).max(0.0) as u32;
306 let t = (region.t * scale).max(0.0) as u32;
307 let r = ((region.r * scale).max(0.0) as u32).min(iw);
308 let b = ((region.b * scale).max(0.0) as u32).min(ih);
309 if r <= l || b <= t {
310 continue;
311 }
312 let crop = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
313 for (lx, ly, rx, ry) in segment_lines(&crop) {
314 let line = imageops::crop_imm(&crop, lx, ly, rx - lx, ry - ly).to_image();
315 for (wx0, _, wx1, _) in segment_words(&line) {
316 let word = imageops::crop_imm(&line, wx0, 0, wx1 - wx0, ry - ly).to_image();
317 let Some(pl) = prep_line(&word) else {
318 continue;
319 };
320 bboxes.push((
321 (l + lx + wx0) as f32 / scale,
322 (t + ly) as f32 / scale,
323 (l + lx + wx1) as f32 / scale,
324 (t + ry) as f32 / scale,
325 ));
326 lines.push(pl);
327 }
328 }
329 }
330 (bboxes, lines)
331}
332
333pub fn normalize_polarity(mut img: RgbImage) -> RgbImage {
340 let (w, h) = img.dimensions();
341 if w == 0 || h == 0 {
342 return img;
343 }
344 let mean: f32 = img.pixels().map(luma).sum::<f32>() / (w * h) as f32;
345 if mean < 128.0 {
346 for px in img.pixels_mut() {
347 px.0 = [255 - px.0[0], 255 - px.0[1], 255 - px.0[2]];
348 }
349 }
350 img
351}
352
353pub fn prep_page_lines(img: &RgbImage) -> Vec<PrepLine> {
357 segment_lines(img)
358 .into_iter()
359 .filter_map(|(l, t, r, b)| {
360 let line = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
361 prep_line(&line)
362 })
363 .collect()
364}
365
366pub fn width_batches(lines: &[PrepLine]) -> Vec<(usize, Vec<usize>)> {
370 let mut by_width: std::collections::BTreeMap<usize, Vec<usize>> =
371 std::collections::BTreeMap::new();
372 for (ix, pl) in lines.iter().enumerate() {
373 by_width.entry(pl.w).or_default().push(ix);
374 }
375 let mut out = Vec::new();
376 for (w, ixs) in by_width {
377 for chunk in ixs.chunks(REC_BATCH) {
378 out.push((w, chunk.to_vec()));
379 }
380 }
381 out
382}
383
384pub fn batch_input(w: usize, chunk: &[usize], lines: &[PrepLine]) -> Vec<f32> {
386 let hw = REC_HEIGHT as usize * w;
387 let mut data = vec![0f32; chunk.len() * 3 * hw];
388 for (i, &ix) in chunk.iter().enumerate() {
389 data[i * 3 * hw..(i + 1) * 3 * hw].copy_from_slice(&lines[ix].data);
390 }
391 data
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 fn page() -> RgbImage {
400 let mut img = RgbImage::from_pixel(200, 100, Rgb([255, 255, 255]));
401 for y in 20..30 {
402 for x in 10..190 {
403 img.put_pixel(x, y, Rgb([0, 0, 0]));
404 }
405 }
406 for y in 60..72 {
407 for x in 10..120 {
408 img.put_pixel(x, y, Rgb([0, 0, 0]));
409 }
410 }
411 img
412 }
413
414 #[test]
415 fn segments_and_preps_page_lines() {
416 let lines = prep_page_lines(&page());
417 assert_eq!(lines.len(), 2);
418 for pl in &lines {
419 assert_eq!(pl.data.len(), 3 * REC_HEIGHT as usize * pl.w);
420 }
421 let batches = width_batches(&lines);
423 assert_eq!(batches.len(), 2);
424 let (w0, chunk0) = &batches[0];
425 assert_eq!(
426 batch_input(*w0, chunk0, &lines).len(),
427 3 * REC_HEIGHT as usize * w0
428 );
429 }
430
431 #[test]
432 fn dark_mode_pages_normalize_to_scan_polarity() {
433 let mut dark = page();
438 for px in dark.pixels_mut() {
439 px.0 = [255 - px.0[0], 255 - px.0[1], 255 - px.0[2]];
440 }
441 assert_ne!(segment_lines(&dark), segment_lines(&page()));
442 let fixed = normalize_polarity(dark);
443 assert_eq!(segment_lines(&fixed), segment_lines(&page()));
444 assert_eq!(prep_page_lines(&fixed).len(), 2);
445 let light = page();
447 assert_eq!(normalize_polarity(light.clone()), light);
448 }
449
450 #[test]
451 fn ctc_decode_collapses_repeats_and_blanks() {
452 let chars = dict_chars("a\nb");
454 assert_eq!(chars.len(), 4); let probs = [
456 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, ];
462 assert_eq!(decode_row(&chars, &probs, 4), "ab");
463 }
464}
465
466#[cfg(test)]
467mod word_segmentation {
468 use image::{Rgb, RgbImage};
469
470 fn line_with_gap(h: u32, gap: u32) -> RgbImage {
472 let w = 30 + gap + 30 + 10;
473 let mut img = RgbImage::from_pixel(w, h, Rgb([255, 255, 255]));
474 for (x0, x1) in [(5u32, 35u32), (35 + gap, 65 + gap)] {
475 for x in x0..x1.min(w) {
476 for y in h / 4..(3 * h / 4) {
477 img.put_pixel(x, y, Rgb([0, 0, 0]));
478 }
479 }
480 }
481 img
482 }
483
484 #[test]
491 fn words_split_only_on_gaps_above_six_tenths_of_the_line_height() {
492 for h in [16u32, 24, 32, 40] {
493 let split_at = (1..=40u32)
494 .find(|&gap| super::segment_words(&line_with_gap(h, gap)).len() >= 2)
495 .expect("some gap splits");
496 let ratio = split_at as f32 / h as f32;
497 assert!(
498 (0.5..=0.65).contains(&ratio),
499 "h={h}: split at {split_at}px ({ratio:.2} x height)"
500 );
501 assert_eq!(
503 super::segment_words(&line_with_gap(h, split_at - 1)).len(),
504 1,
505 "h={h}: a narrower gap must not split"
506 );
507 }
508 }
509}