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 prep_region_lines_det(
272 img: &RgbImage,
273 regions: &[crate::layout::Region],
274 scale: f32,
275 detected: &[crate::ocr_det::DetBox],
276) -> (Vec<LineBox>, Vec<PrepLine>) {
277 let (iw, ih) = img.dimensions();
278 let mut bboxes = Vec::new();
279 let mut lines = Vec::new();
280 for region in regions {
281 if !is_text_label(region.label) {
282 continue;
283 }
284 let l = (region.l * scale).max(0.0) as u32;
285 let t = (region.t * scale).max(0.0) as u32;
286 let r = ((region.r * scale).max(0.0) as u32).min(iw);
287 let b = ((region.b * scale).max(0.0) as u32).min(ih);
288 if r <= l || b <= t {
289 continue;
290 }
291 let inside = det_boxes_inside(detected, l, t, r, b, iw, ih);
292 if inside.is_empty() {
293 let (bb, pl) = prep_region_lines(img, std::slice::from_ref(region), scale);
294 bboxes.extend(bb);
295 lines.extend(pl);
296 continue;
297 }
298 for ((dl, dt, dr, db), _) in inside {
299 let line = imageops::crop_imm(img, dl, dt, dr - dl, db - dt).to_image();
300 let Some(pl) = prep_line(&line) else {
301 continue;
302 };
303 bboxes.push((
304 dl as f32 / scale,
305 dt as f32 / scale,
306 dr as f32 / scale,
307 db as f32 / scale,
308 ));
309 lines.push(pl);
310 }
311 }
312 (bboxes, lines)
313}
314
315pub fn segment_words(line: &RgbImage) -> Vec<(u32, u32, u32, u32)> {
322 let (w, h) = line.dimensions();
323 if w == 0 || h == 0 {
324 return Vec::new();
325 }
326 let mean: f32 = line.pixels().map(luma).sum::<f32>() / (w * h) as f32;
327 let thresh = mean * 0.7;
328 let mut col_ink = vec![0u32; w as usize];
329 for y in 0..h {
330 for x in 0..w {
331 if luma(line.get_pixel(x, y)) < thresh {
332 col_ink[x as usize] += 1;
333 }
334 }
335 }
336 let min_gap = ((h as f32) * 0.6).max(4.0) as u32;
337 let mut words = Vec::new();
338 let mut start: Option<u32> = None;
339 let mut last_ink = 0u32;
340 let mut gap = 0u32;
341 for x in 0..w {
342 if col_ink[x as usize] > 0 {
343 if start.is_none() {
344 start = Some(x);
345 }
346 last_ink = x;
347 gap = 0;
348 } else if let Some(s) = start {
349 gap += 1;
350 if gap >= min_gap {
351 words.push((s, 0, last_ink + 1, h));
352 start = None;
353 }
354 }
355 }
356 if let Some(s) = start {
357 words.push((s, 0, last_ink + 1, h));
358 }
359 words
360}
361
362pub fn prep_table_words(
370 img: &RgbImage,
371 regions: &[crate::layout::Region],
372 scale: f32,
373) -> (Vec<LineBox>, Vec<PrepLine>) {
374 let (iw, ih) = img.dimensions();
375 let mut bboxes = Vec::new();
376 let mut lines = Vec::new();
377 for region in regions {
378 if !crate::assemble::is_table_like(region.label) {
379 continue;
380 }
381 let l = (region.l * scale).max(0.0) as u32;
382 let t = (region.t * scale).max(0.0) as u32;
383 let r = ((region.r * scale).max(0.0) as u32).min(iw);
384 let b = ((region.b * scale).max(0.0) as u32).min(ih);
385 if r <= l || b <= t {
386 continue;
387 }
388 let crop = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
389 for (lx, ly, rx, ry) in segment_lines(&crop) {
390 let line = imageops::crop_imm(&crop, lx, ly, rx - lx, ry - ly).to_image();
391 for (wx0, _, wx1, _) in segment_words(&line) {
392 let word = imageops::crop_imm(&line, wx0, 0, wx1 - wx0, ry - ly).to_image();
393 let Some(pl) = prep_line(&word) else {
394 continue;
395 };
396 bboxes.push((
397 (l + lx + wx0) as f32 / scale,
398 (t + ly) as f32 / scale,
399 (l + lx + wx1) as f32 / scale,
400 (t + ry) as f32 / scale,
401 ));
402 lines.push(pl);
403 }
404 }
405 }
406 (bboxes, lines)
407}
408
409pub fn prep_table_words_det(
415 img: &RgbImage,
416 regions: &[crate::layout::Region],
417 scale: f32,
418 detected: &[crate::ocr_det::DetBox],
419) -> (Vec<LineBox>, Vec<PrepLine>) {
420 let (iw, ih) = img.dimensions();
421 let mut bboxes = Vec::new();
422 let mut lines = Vec::new();
423 for region in regions {
424 if !crate::assemble::is_table_like(region.label) {
425 continue;
426 }
427 let l = (region.l * scale).max(0.0) as u32;
428 let t = (region.t * scale).max(0.0) as u32;
429 let r = ((region.r * scale).max(0.0) as u32).min(iw);
430 let b = ((region.b * scale).max(0.0) as u32).min(ih);
431 if r <= l || b <= t {
432 continue;
433 }
434 let inside: Vec<_> = det_boxes_inside(detected, l, t, r, b, iw, ih)
435 .into_iter()
436 .map(|(rect, _)| rect)
437 .collect();
438 if inside.is_empty() {
439 let (bb, pl) = prep_table_words(img, std::slice::from_ref(region), scale);
440 bboxes.extend(bb);
441 lines.extend(pl);
442 continue;
443 }
444 for (dl, dt, dr, db) in inside {
445 let word = imageops::crop_imm(img, dl, dt, dr - dl, db - dt).to_image();
446 let Some(pl) = prep_line(&word) else {
447 continue;
448 };
449 bboxes.push((
450 dl as f32 / scale,
451 dt as f32 / scale,
452 dr as f32 / scale,
453 db as f32 / scale,
454 ));
455 lines.push(pl);
456 }
457 }
458 (bboxes, lines)
459}
460
461type PxRect = (u32, u32, u32, u32);
471
472fn det_boxes_inside(
473 detected: &[crate::ocr_det::DetBox],
474 l: u32,
475 t: u32,
476 r: u32,
477 b: u32,
478 iw: u32,
479 ih: u32,
480) -> Vec<(PxRect, &crate::ocr_det::DetBox)> {
481 detected
482 .iter()
483 .filter(|d| {
484 let (cx, cy) = ((d.l + d.r) / 2.0, (d.t + d.b) / 2.0);
485 cx >= l as f32 && cx < r as f32 && cy >= t as f32 && cy < b as f32
486 })
487 .filter_map(|d| {
488 let dl = d.l.max(0.0) as u32;
489 let dt = d.t.max(0.0) as u32;
490 let dr = (d.r.max(0.0) as u32).min(iw);
491 let db = (d.b.max(0.0) as u32).min(ih);
492 (dr > dl && db > dt).then_some(((dl, dt, dr, db), d))
493 })
494 .collect()
495}
496
497pub enum LineSrc {
500 Crop(RgbImage),
501 Prepared(PrepLine),
502}
503
504pub fn region_line_crops_det(
512 img: &RgbImage,
513 regions: &[crate::layout::Region],
514 scale: f32,
515 detected: &[crate::ocr_det::DetBox],
516) -> (Vec<LineBox>, Vec<LineSrc>) {
517 let (iw, ih) = img.dimensions();
518 let mut bboxes = Vec::new();
519 let mut lines = Vec::new();
520 for region in regions {
521 if !is_text_label(region.label) {
522 continue;
523 }
524 let l = (region.l * scale).max(0.0) as u32;
525 let t = (region.t * scale).max(0.0) as u32;
526 let r = ((region.r * scale).max(0.0) as u32).min(iw);
527 let b = ((region.b * scale).max(0.0) as u32).min(ih);
528 if r <= l || b <= t {
529 continue;
530 }
531 let inside = det_boxes_inside(detected, l, t, r, b, iw, ih);
532 if inside.is_empty() {
533 let (bb, pl) = prep_region_lines(img, std::slice::from_ref(region), scale);
534 bboxes.extend(bb);
535 lines.extend(pl.into_iter().map(LineSrc::Prepared));
536 continue;
537 }
538 for ((dl, dt, dr, db), d) in inside {
539 let Some(crop) = rotate_crop(img, &d.quad) else {
540 continue;
541 };
542 bboxes.push((
543 dl as f32 / scale,
544 dt as f32 / scale,
545 dr as f32 / scale,
546 db as f32 / scale,
547 ));
548 lines.push(LineSrc::Crop(crop));
549 }
550 }
551 (bboxes, lines)
552}
553
554pub fn rotate_crop(img: &RgbImage, quad: &[(f32, f32); 4]) -> Option<RgbImage> {
563 let dist = |a: (f32, f32), b: (f32, f32)| ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt();
564 let w = dist(quad[0], quad[1]).max(dist(quad[2], quad[3])) as u32;
565 let h = dist(quad[0], quad[3]).max(dist(quad[1], quad[2])) as u32;
566 if w == 0 || h == 0 || img.width() == 0 || img.height() == 0 {
567 return None;
568 }
569 let (wf, hf) = (w as f64, h as f64);
570 let map = perspective(
571 [(0.0, 0.0), (wf, 0.0), (wf, hf), (0.0, hf)],
572 quad.map(|(x, y)| (x as f64, y as f64)),
573 )?;
574 let mut out = RgbImage::new(w, h);
575 for (x, y, px) in out.enumerate_pixels_mut() {
576 let (x, y) = (x as f64, y as f64);
577 let d = map[6] * x + map[7] * y + 1.0;
578 if d.abs() < 1e-12 {
579 continue;
580 }
581 let sx = (map[0] * x + map[1] * y + map[2]) / d;
582 let sy = (map[3] * x + map[4] * y + map[5]) / d;
583 *px = bicubic_replicate(img, sx, sy);
584 }
585 Some(if h as f32 / w as f32 >= 1.5 {
586 imageops::rotate270(&out)
587 } else {
588 out
589 })
590}
591
592fn perspective(from: [(f64, f64); 4], to: [(f64, f64); 4]) -> Option<[f64; 8]> {
597 let mut m = [[0f64; 9]; 8];
598 for k in 0..4 {
599 let ((x, y), (u, v)) = (from[k], to[k]);
600 m[2 * k] = [x, y, 1.0, 0.0, 0.0, 0.0, -x * u, -y * u, u];
601 m[2 * k + 1] = [0.0, 0.0, 0.0, x, y, 1.0, -x * v, -y * v, v];
602 }
603 for col in 0..8 {
605 let pivot = (col..8).max_by(|&a, &b| m[a][col].abs().total_cmp(&m[b][col].abs()))?;
606 if m[pivot][col].abs() < 1e-12 {
607 return None;
608 }
609 m.swap(col, pivot);
610 let pivot_row = m[col];
611 for (row, r) in m.iter_mut().enumerate() {
612 let f = r[col] / pivot_row[col];
613 if row != col && f != 0.0 {
614 for (v, p) in r[col..].iter_mut().zip(&pivot_row[col..]) {
615 *v -= f * p;
616 }
617 }
618 }
619 }
620 Some(std::array::from_fn(|i| m[i][8] / m[i][i]))
621}
622
623fn bicubic_replicate(img: &RgbImage, x: f64, y: f64) -> Rgb<u8> {
626 fn weights(t: f64) -> [f64; 4] {
627 const A: f64 = -0.75;
628 let w0 = ((A * (t + 1.0) - 5.0 * A) * (t + 1.0) + 8.0 * A) * (t + 1.0) - 4.0 * A;
629 let w1 = ((A + 2.0) * t - (A + 3.0)) * t * t + 1.0;
630 let w2 = ((A + 2.0) * (1.0 - t) - (A + 3.0)) * (1.0 - t) * (1.0 - t) + 1.0;
631 [w0, w1, w2, 1.0 - w0 - w1 - w2]
632 }
633 let (x0, y0) = (x.floor(), y.floor());
634 let (wx, wy) = (weights(x - x0), weights(y - y0));
635 let (mw, mh) = (img.width() as i64 - 1, img.height() as i64 - 1);
636 let mut acc = [0f64; 3];
637 for (j, wyj) in wy.iter().enumerate() {
638 let yy = (y0 as i64 - 1 + j as i64).clamp(0, mh) as u32;
639 for (i, wxi) in wx.iter().enumerate() {
640 let xx = (x0 as i64 - 1 + i as i64).clamp(0, mw) as u32;
641 let p = img.get_pixel(xx, yy);
642 let w = wxi * wyj;
643 for c in 0..3 {
644 acc[c] += w * p[c] as f64;
645 }
646 }
647 }
648 Rgb(acc.map(|v| v.round().clamp(0.0, 255.0) as u8))
649}
650
651pub fn normalize_polarity(mut img: RgbImage) -> RgbImage {
658 let (w, h) = img.dimensions();
659 if w == 0 || h == 0 {
660 return img;
661 }
662 let mean: f32 = img.pixels().map(luma).sum::<f32>() / (w * h) as f32;
663 if mean < 128.0 {
664 for px in img.pixels_mut() {
665 px.0 = [255 - px.0[0], 255 - px.0[1], 255 - px.0[2]];
666 }
667 }
668 img
669}
670
671pub fn prep_page_lines(img: &RgbImage) -> Vec<PrepLine> {
675 segment_lines(img)
676 .into_iter()
677 .filter_map(|(l, t, r, b)| {
678 let line = imageops::crop_imm(img, l, t, r - l, b - t).to_image();
679 prep_line(&line)
680 })
681 .collect()
682}
683
684pub fn text_score() -> f32 {
691 static SCORE: std::sync::OnceLock<f32> = std::sync::OnceLock::new();
692 *SCORE.get_or_init(|| {
693 text_score_from(docling_core::env::nonempty("DOCLING_RS_OCR_TEXT_SCORE").as_deref())
694 })
695}
696
697fn text_score_from(raw: Option<&str>) -> f32 {
700 raw.and_then(|r| r.trim().parse::<f32>().ok())
701 .filter(|s| s.is_finite() && (0.0..=1.0).contains(s))
702 .unwrap_or(0.5)
703}
704
705pub fn width_batches(lines: &[PrepLine]) -> Vec<(usize, Vec<usize>)> {
709 let mut by_width: std::collections::BTreeMap<usize, Vec<usize>> =
710 std::collections::BTreeMap::new();
711 for (ix, pl) in lines.iter().enumerate() {
712 by_width.entry(pl.w).or_default().push(ix);
713 }
714 let mut out = Vec::new();
715 for (w, ixs) in by_width {
716 for chunk in ixs.chunks(REC_BATCH) {
717 out.push((w, chunk.to_vec()));
718 }
719 }
720 out
721}
722
723pub fn batch_input(w: usize, chunk: &[usize], lines: &[PrepLine]) -> Vec<f32> {
725 let hw = REC_HEIGHT as usize * w;
726 let mut data = vec![0f32; chunk.len() * 3 * hw];
727 for (i, &ix) in chunk.iter().enumerate() {
728 data[i * 3 * hw..(i + 1) * 3 * hw].copy_from_slice(&lines[ix].data);
729 }
730 data
731}
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736
737 fn page() -> RgbImage {
739 let mut img = RgbImage::from_pixel(200, 100, Rgb([255, 255, 255]));
740 for y in 20..30 {
741 for x in 10..190 {
742 img.put_pixel(x, y, Rgb([0, 0, 0]));
743 }
744 }
745 for y in 60..72 {
746 for x in 10..120 {
747 img.put_pixel(x, y, Rgb([0, 0, 0]));
748 }
749 }
750 img
751 }
752
753 #[test]
754 fn segments_and_preps_page_lines() {
755 let lines = prep_page_lines(&page());
756 assert_eq!(lines.len(), 2);
757 for pl in &lines {
758 assert_eq!(pl.data.len(), 3 * REC_HEIGHT as usize * pl.w);
759 }
760 let batches = width_batches(&lines);
762 assert_eq!(batches.len(), 2);
763 let (w0, chunk0) = &batches[0];
764 assert_eq!(
765 batch_input(*w0, chunk0, &lines).len(),
766 3 * REC_HEIGHT as usize * w0
767 );
768 }
769
770 #[test]
777 fn detector_boxes_are_the_region_lines_with_projection_fallback() {
778 let img = page();
779 let region = crate::layout::Region {
780 label: "text",
781 score: 0.9,
782 l: 0.0,
783 t: 0.0,
784 r: 200.0,
785 b: 100.0,
786 };
787 let bx = |l: f32, t: f32, r: f32, b: f32| crate::ocr_det::DetBox::rect(l, t, r, b, 0.9);
788 let det = [bx(8.0, 18.0, 192.0, 32.0), bx(8.0, 58.0, 122.0, 74.0)];
789 let (boxes, lines) = prep_region_lines_det(&img, std::slice::from_ref(®ion), 1.0, &det);
790 assert_eq!(
791 boxes,
792 vec![(8.0, 18.0, 192.0, 32.0), (8.0, 58.0, 122.0, 74.0)]
793 );
794 assert_eq!(lines.len(), 2);
795 let narrow = crate::layout::Region {
798 r: 150.0,
799 ..region.clone()
800 };
801 let det = [bx(-10.0, 18.0, 250.0, 32.0), bx(300.0, 60.0, 400.0, 70.0)];
802 let (boxes, _) = prep_region_lines_det(&img, std::slice::from_ref(&narrow), 1.0, &det);
803 assert_eq!(boxes, vec![(0.0, 18.0, 200.0, 32.0)]);
804 let det = [
806 bx(8.0, 18.0, 192.0, 32.0),
807 bx(8.0, 58.0, 60.0, 74.0),
808 bx(70.0, 58.0, 122.0, 74.0),
809 ];
810 let (boxes, _) = prep_region_lines_det(&img, std::slice::from_ref(®ion), 1.0, &det);
811 assert_eq!(boxes.len(), 3);
812 let (fallback, _) = prep_region_lines_det(&img, std::slice::from_ref(®ion), 1.0, &[]);
814 let (projection, _) = prep_region_lines(&img, std::slice::from_ref(®ion), 1.0);
815 assert_eq!(fallback, projection);
816 assert_eq!(projection.len(), 2);
817 let table = crate::layout::Region {
819 label: "table",
820 ..region.clone()
821 };
822 let det = [bx(8.0, 18.0, 92.0, 32.0), bx(108.0, 18.0, 192.0, 32.0)];
823 let (words, _) = prep_table_words_det(&img, std::slice::from_ref(&table), 1.0, &det);
824 assert_eq!(
825 words,
826 vec![(8.0, 18.0, 92.0, 32.0), (108.0, 18.0, 192.0, 32.0)]
827 );
828 let (fallback, _) = prep_table_words_det(&img, std::slice::from_ref(&table), 1.0, &[]);
829 assert_eq!(
830 fallback,
831 prep_table_words(&img, std::slice::from_ref(&table), 1.0).0
832 );
833 }
834
835 #[test]
838 fn text_score_default_is_rapidocrs() {
839 assert_eq!(text_score_from(None), 0.5);
840 assert_eq!(text_score_from(Some("0")), 0.0);
841 assert_eq!(text_score_from(Some("0.7")), 0.7);
842 for garbage in ["1.5", "-0.1", "NaN", "high", ""] {
843 assert_eq!(text_score_from(Some(garbage)), 0.5, "{garbage:?}");
844 }
845 }
846
847 #[test]
848 fn dark_mode_pages_normalize_to_scan_polarity() {
849 let mut dark = page();
854 for px in dark.pixels_mut() {
855 px.0 = [255 - px.0[0], 255 - px.0[1], 255 - px.0[2]];
856 }
857 assert_ne!(segment_lines(&dark), segment_lines(&page()));
858 let fixed = normalize_polarity(dark);
859 assert_eq!(segment_lines(&fixed), segment_lines(&page()));
860 assert_eq!(prep_page_lines(&fixed).len(), 2);
861 let light = page();
863 assert_eq!(normalize_polarity(light.clone()), light);
864 }
865
866 #[test]
867 fn ctc_decode_collapses_repeats_and_blanks() {
868 let chars = dict_chars("a\nb");
870 assert_eq!(chars.len(), 4); let probs = [
872 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, ];
878 assert_eq!(decode_row(&chars, &probs, 4), "ab");
879 }
880}
881
882#[cfg(test)]
883mod word_segmentation {
884 use image::{Rgb, RgbImage};
885
886 fn line_with_gap(h: u32, gap: u32) -> RgbImage {
888 let w = 30 + gap + 30 + 10;
889 let mut img = RgbImage::from_pixel(w, h, Rgb([255, 255, 255]));
890 for (x0, x1) in [(5u32, 35u32), (35 + gap, 65 + gap)] {
891 for x in x0..x1.min(w) {
892 for y in h / 4..(3 * h / 4) {
893 img.put_pixel(x, y, Rgb([0, 0, 0]));
894 }
895 }
896 }
897 img
898 }
899
900 #[test]
907 fn words_split_only_on_gaps_above_six_tenths_of_the_line_height() {
908 for h in [16u32, 24, 32, 40] {
909 let split_at = (1..=40u32)
910 .find(|&gap| super::segment_words(&line_with_gap(h, gap)).len() >= 2)
911 .expect("some gap splits");
912 let ratio = split_at as f32 / h as f32;
913 assert!(
914 (0.5..=0.65).contains(&ratio),
915 "h={h}: split at {split_at}px ({ratio:.2} x height)"
916 );
917 assert_eq!(
919 super::segment_words(&line_with_gap(h, split_at - 1)).len(),
920 1,
921 "h={h}: a narrower gap must not split"
922 );
923 }
924 }
925
926 #[test]
930 fn rotate_crop_is_the_plain_crop_for_an_upright_box_and_turns_tall_ones() {
931 let img = RgbImage::from_fn(40, 30, |x, y| Rgb([(x * 6) as u8, (y * 8) as u8, 77]));
932 let quad = [(5.0, 4.0), (25.0, 4.0), (25.0, 14.0), (5.0, 14.0)];
933 let warped = super::rotate_crop(&img, &quad).unwrap();
934 let plain = image::imageops::crop_imm(&img, 5, 4, 20, 10).to_image();
935 assert_eq!(warped, plain);
936 let tall = [(10.0, 2.0), (16.0, 2.0), (16.0, 14.0), (10.0, 14.0)];
938 let turned = super::rotate_crop(&img, &tall).unwrap();
939 assert_eq!(turned.dimensions(), (12, 6));
940 assert_eq!(turned.get_pixel(0, 5), img.get_pixel(10, 2));
941 assert!(super::rotate_crop(&img, &[(1.0, 1.0); 4]).is_none());
942 }
943
944 #[test]
947 fn rotate_crop_straightens_a_rotated_rectangle() {
948 let (cx, cy, a) = (50.0f32, 40.0f32, 30f32.to_radians());
949 let (hw, hh) = (30.0f32, 6.0f32);
950 let inside = |x: f32, y: f32| {
951 let (dx, dy) = (x - cx, y - cy);
952 let u = dx * a.cos() + dy * a.sin();
953 let v = -dx * a.sin() + dy * a.cos();
954 u.abs() <= hw && v.abs() <= hh
955 };
956 let img = RgbImage::from_fn(100, 80, |x, y| {
957 if inside(x as f32 + 0.5, y as f32 + 0.5) {
958 Rgb([0, 0, 0])
959 } else {
960 Rgb([255, 255, 255])
961 }
962 });
963 let corner = |su: f32, sv: f32| {
964 (
965 cx + su * hw * a.cos() - sv * hh * a.sin(),
966 cy + su * hw * a.sin() + sv * hh * a.cos(),
967 )
968 };
969 let quad = crate::ocr_det::order_corners([
970 corner(-1.0, -1.0),
971 corner(1.0, -1.0),
972 corner(1.0, 1.0),
973 corner(-1.0, 1.0),
974 ]);
975 let bar = super::rotate_crop(&img, &quad).unwrap();
976 let (w, h) = bar.dimensions();
978 assert!(
979 (59..=60).contains(&w) && (11..=12).contains(&h),
980 "{w} × {h}"
981 );
982 for y in 2..h - 2 {
984 for x in 2..w - 2 {
985 assert!(
986 bar.get_pixel(x, y)[0] < 60,
987 "({x}, {y}) {:?}",
988 bar.get_pixel(x, y)
989 );
990 }
991 }
992 }
993}