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 decode_row_scored(chars, probs, nc).0
67}
68
69pub fn decode_row_scored(chars: &[String], probs: &[f32], nc: usize) -> (String, f32) {
74 let mut out = String::new();
75 let mut prev = 0usize;
76 let mut conf_sum = 0.0f32;
77 let mut conf_n = 0usize;
78 for row in probs.chunks_exact(nc) {
79 let mut best = 0usize;
80 let mut bestv = row[0];
81 for (c, &v) in row.iter().enumerate().skip(1) {
82 if v > bestv {
83 bestv = v;
84 best = c;
85 }
86 }
87 if best != prev && best != 0 {
88 if let Some(ch) = chars.get(best) {
89 out.push_str(ch);
90 conf_sum += bestv;
91 conf_n += 1;
92 }
93 }
94 prev = best;
95 }
96 let conf = if conf_n == 0 {
97 0.0
98 } else {
99 conf_sum / conf_n as f32
100 };
101 (out, conf)
102}
103
104pub(crate) fn luma(p: &Rgb<u8>) -> f32 {
105 0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32
106}
107
108pub fn segment_lines(crop: &RgbImage) -> Vec<(u32, u32, u32, u32)> {
111 let (w, h) = crop.dimensions();
112 if w == 0 || h == 0 {
113 return Vec::new();
114 }
115 let mean: f32 = crop.pixels().map(luma).sum::<f32>() / (w * h) as f32;
116 let thresh = mean * 0.7; let min_ink = ((w as f32) * 0.005).max(1.0) as u32;
118
119 let mut col_ink = vec![0u32; w as usize];
126 for y in 0..h {
127 for x in 0..w {
128 if luma(crop.get_pixel(x, y)) < thresh {
129 col_ink[x as usize] += 1;
130 }
131 }
132 }
133 let rule_cols = col_ink
138 .iter()
139 .filter(|&&c| c as f32 > 0.9 * h as f32)
140 .count();
141 let mask_rules = (rule_cols as f32) < 0.15 * w as f32;
142 let rule = |x: u32| mask_rules && col_ink[x as usize] as f32 > 0.9 * h as f32;
143
144 let mut profile = vec![0u32; h as usize];
145 for y in 0..h {
146 let mut row = 0u32;
147 for x in 0..w {
148 if !rule(x) && luma(crop.get_pixel(x, y)) < thresh {
149 row += 1;
150 }
151 }
152 profile[y as usize] = row;
153 }
154
155 let mut runs: Vec<(u32, u32)> = Vec::new();
157 let mut start: Option<u32> = None;
158 for y in 0..h {
159 let text = profile[y as usize] >= min_ink;
160 if text && start.is_none() {
161 start = Some(y);
162 } else if !text {
163 if let Some(s) = start.take() {
164 if y - s >= 4 {
165 runs.push((s, y));
166 }
167 }
168 }
169 }
170 if let Some(s) = start {
171 if h - s >= 4 {
172 runs.push((s, h));
173 }
174 }
175
176 runs.into_iter()
178 .map(|(t, b)| {
179 let (mut l, mut r) = (w, 0u32);
180 for y in t..b {
181 for x in 0..w {
182 if luma(crop.get_pixel(x, y)) < thresh {
183 l = l.min(x);
184 r = r.max(x + 1);
185 }
186 }
187 }
188 if l >= r {
189 (0, t, w, b)
190 } else {
191 (l, t, r, b)
192 }
193 })
194 .collect()
195}
196
197pub fn is_text_label(label: &str) -> bool {
199 matches!(
200 label,
201 "text"
202 | "title"
203 | "section_header"
204 | "list_item"
205 | "caption"
206 | "footnote"
207 | "code"
208 | "formula"
209 )
210}
211
212pub type LineBox = (f32, f32, f32, f32);
214
215pub fn prep_region_lines(
221 img: &RgbImage,
222 regions: &[crate::layout::Region],
223 scale: f32,
224) -> (Vec<LineBox>, Vec<PrepLine>) {
225 let (iw, ih) = img.dimensions();
226 let mut bboxes = Vec::new();
227 let mut lines = Vec::new();
228 for region in regions {
229 if !is_text_label(region.label) {
230 continue;
231 }
232 let l = (region.l * scale).max(0.0) as u32;
233 let t = (region.t * scale).max(0.0) as u32;
234 let r = ((region.r * scale).max(0.0) as u32).min(iw);
235 let b = ((region.b * scale).max(0.0) as u32).min(ih);
236 if r <= l || b <= t {
237 continue;
238 }
239 let crop = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
240 for (lx, ly, rx, ry) in segment_lines(&crop) {
241 let line = imageops::crop_imm(&crop, lx, ly, rx - lx, ry - ly).to_image();
242 let Some(pl) = prep_line(&line) else {
243 continue;
244 };
245 bboxes.push((
246 (l + lx) as f32 / scale,
247 (t + ly) as f32 / scale,
248 (l + rx) as f32 / scale,
249 (t + ry) as f32 / scale,
250 ));
251 lines.push(pl);
252 }
253 }
254 (bboxes, lines)
255}
256
257pub fn segment_words(line: &RgbImage) -> Vec<(u32, u32, u32, u32)> {
264 let (w, h) = line.dimensions();
265 if w == 0 || h == 0 {
266 return Vec::new();
267 }
268 let mean: f32 = line.pixels().map(luma).sum::<f32>() / (w * h) as f32;
269 let thresh = mean * 0.7;
270 let mut col_ink = vec![0u32; w as usize];
271 for y in 0..h {
272 for x in 0..w {
273 if luma(line.get_pixel(x, y)) < thresh {
274 col_ink[x as usize] += 1;
275 }
276 }
277 }
278 let min_gap = ((h as f32) * 0.6).max(4.0) as u32;
279 let mut words = Vec::new();
280 let mut start: Option<u32> = None;
281 let mut last_ink = 0u32;
282 let mut gap = 0u32;
283 for x in 0..w {
284 if col_ink[x as usize] > 0 {
285 if start.is_none() {
286 start = Some(x);
287 }
288 last_ink = x;
289 gap = 0;
290 } else if let Some(s) = start {
291 gap += 1;
292 if gap >= min_gap {
293 words.push((s, 0, last_ink + 1, h));
294 start = None;
295 }
296 }
297 }
298 if let Some(s) = start {
299 words.push((s, 0, last_ink + 1, h));
300 }
301 words
302}
303
304pub fn prep_table_words(
312 img: &RgbImage,
313 regions: &[crate::layout::Region],
314 scale: f32,
315) -> (Vec<LineBox>, Vec<PrepLine>) {
316 let (iw, ih) = img.dimensions();
317 let mut bboxes = Vec::new();
318 let mut lines = Vec::new();
319 for region in regions {
320 if !crate::assemble::is_table_like(region.label) {
321 continue;
322 }
323 let l = (region.l * scale).max(0.0) as u32;
324 let t = (region.t * scale).max(0.0) as u32;
325 let r = ((region.r * scale).max(0.0) as u32).min(iw);
326 let b = ((region.b * scale).max(0.0) as u32).min(ih);
327 if r <= l || b <= t {
328 continue;
329 }
330 let crop = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
331 for (lx, ly, rx, ry) in segment_lines(&crop) {
332 let line = imageops::crop_imm(&crop, lx, ly, rx - lx, ry - ly).to_image();
333 for (wx0, _, wx1, _) in segment_words(&line) {
334 let word = imageops::crop_imm(&line, wx0, 0, wx1 - wx0, ry - ly).to_image();
335 let Some(pl) = prep_line(&word) else {
336 continue;
337 };
338 bboxes.push((
339 (l + lx + wx0) as f32 / scale,
340 (t + ly) as f32 / scale,
341 (l + lx + wx1) as f32 / scale,
342 (t + ry) as f32 / scale,
343 ));
344 lines.push(pl);
345 }
346 }
347 }
348 (bboxes, lines)
349}
350
351pub fn normalize_polarity(mut img: RgbImage) -> RgbImage {
358 let (w, h) = img.dimensions();
359 if w == 0 || h == 0 {
360 return img;
361 }
362 let mean: f32 = img.pixels().map(luma).sum::<f32>() / (w * h) as f32;
363 if mean < 128.0 {
364 for px in img.pixels_mut() {
365 px.0 = [255 - px.0[0], 255 - px.0[1], 255 - px.0[2]];
366 }
367 }
368 img
369}
370
371pub fn prep_page_lines(img: &RgbImage) -> Vec<PrepLine> {
375 segment_lines(img)
376 .into_iter()
377 .filter_map(|(l, t, r, b)| {
378 let line = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
379 prep_line(&line)
380 })
381 .collect()
382}
383
384pub fn width_batches(lines: &[PrepLine]) -> Vec<(usize, Vec<usize>)> {
388 let mut by_width: std::collections::BTreeMap<usize, Vec<usize>> =
389 std::collections::BTreeMap::new();
390 for (ix, pl) in lines.iter().enumerate() {
391 by_width.entry(pl.w).or_default().push(ix);
392 }
393 let mut out = Vec::new();
394 for (w, ixs) in by_width {
395 for chunk in ixs.chunks(REC_BATCH) {
396 out.push((w, chunk.to_vec()));
397 }
398 }
399 out
400}
401
402pub fn batch_input(w: usize, chunk: &[usize], lines: &[PrepLine]) -> Vec<f32> {
404 let hw = REC_HEIGHT as usize * w;
405 let mut data = vec![0f32; chunk.len() * 3 * hw];
406 for (i, &ix) in chunk.iter().enumerate() {
407 data[i * 3 * hw..(i + 1) * 3 * hw].copy_from_slice(&lines[ix].data);
408 }
409 data
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 fn page() -> RgbImage {
418 let mut img = RgbImage::from_pixel(200, 100, Rgb([255, 255, 255]));
419 for y in 20..30 {
420 for x in 10..190 {
421 img.put_pixel(x, y, Rgb([0, 0, 0]));
422 }
423 }
424 for y in 60..72 {
425 for x in 10..120 {
426 img.put_pixel(x, y, Rgb([0, 0, 0]));
427 }
428 }
429 img
430 }
431
432 #[test]
433 fn segments_and_preps_page_lines() {
434 let lines = prep_page_lines(&page());
435 assert_eq!(lines.len(), 2);
436 for pl in &lines {
437 assert_eq!(pl.data.len(), 3 * REC_HEIGHT as usize * pl.w);
438 }
439 let batches = width_batches(&lines);
441 assert_eq!(batches.len(), 2);
442 let (w0, chunk0) = &batches[0];
443 assert_eq!(
444 batch_input(*w0, chunk0, &lines).len(),
445 3 * REC_HEIGHT as usize * w0
446 );
447 }
448
449 #[test]
450 fn dark_mode_pages_normalize_to_scan_polarity() {
451 let mut dark = page();
456 for px in dark.pixels_mut() {
457 px.0 = [255 - px.0[0], 255 - px.0[1], 255 - px.0[2]];
458 }
459 assert_ne!(segment_lines(&dark), segment_lines(&page()));
460 let fixed = normalize_polarity(dark);
461 assert_eq!(segment_lines(&fixed), segment_lines(&page()));
462 assert_eq!(prep_page_lines(&fixed).len(), 2);
463 let light = page();
465 assert_eq!(normalize_polarity(light.clone()), light);
466 }
467
468 #[test]
469 fn ctc_decode_collapses_repeats_and_blanks() {
470 let chars = dict_chars("a\nb");
472 assert_eq!(chars.len(), 4); let probs = [
474 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, ];
480 assert_eq!(decode_row(&chars, &probs, 4), "ab");
481 }
482}
483
484#[cfg(test)]
485mod word_segmentation {
486 use image::{Rgb, RgbImage};
487
488 fn line_with_gap(h: u32, gap: u32) -> RgbImage {
490 let w = 30 + gap + 30 + 10;
491 let mut img = RgbImage::from_pixel(w, h, Rgb([255, 255, 255]));
492 for (x0, x1) in [(5u32, 35u32), (35 + gap, 65 + gap)] {
493 for x in x0..x1.min(w) {
494 for y in h / 4..(3 * h / 4) {
495 img.put_pixel(x, y, Rgb([0, 0, 0]));
496 }
497 }
498 }
499 img
500 }
501
502 #[test]
509 fn words_split_only_on_gaps_above_six_tenths_of_the_line_height() {
510 for h in [16u32, 24, 32, 40] {
511 let split_at = (1..=40u32)
512 .find(|&gap| super::segment_words(&line_with_gap(h, gap)).len() >= 2)
513 .expect("some gap splits");
514 let ratio = split_at as f32 / h as f32;
515 assert!(
516 (0.5..=0.65).contains(&ratio),
517 "h={h}: split at {split_at}px ({ratio:.2} x height)"
518 );
519 assert_eq!(
521 super::segment_words(&line_with_gap(h, split_at - 1)).len(),
522 1,
523 "h={h}: a narrower gap must not split"
524 );
525 }
526 }
527}