1use image::{GrayImage, ImageBuffer};
25
26#[cfg(test)]
27use image::Luma;
28use std::error::Error;
29use std::fmt;
30
31#[derive(Debug, Clone)]
33pub enum VerticalDetectError {
34 InvalidImage(String),
36 ProcessingError(String),
38}
39
40impl fmt::Display for VerticalDetectError {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 match self {
43 Self::InvalidImage(msg) => write!(f, "Invalid image: {}", msg),
44 Self::ProcessingError(msg) => write!(f, "Processing error: {}", msg),
45 }
46 }
47}
48
49impl Error for VerticalDetectError {}
50
51#[derive(Debug, Clone)]
53pub struct VerticalDetectOptions {
54 pub black_threshold: u8,
57 pub block_count: u32,
60 pub vertical_threshold: f64,
62}
63
64impl Default for VerticalDetectOptions {
65 fn default() -> Self {
66 Self {
67 black_threshold: 128,
68 block_count: 4,
69 vertical_threshold: 0.5,
70 }
71 }
72}
73
74impl VerticalDetectOptions {
75 pub fn for_binary() -> Self {
77 Self {
78 black_threshold: 10,
79 ..Default::default()
80 }
81 }
82
83 pub fn with_threshold(threshold: u8) -> Self {
85 Self {
86 black_threshold: threshold,
87 ..Default::default()
88 }
89 }
90}
91
92#[derive(Debug, Clone)]
94pub struct VerticalDetectResult {
95 pub vertical_probability: f64,
97 pub horizontal_score: f64,
99 pub vertical_score: f64,
101 pub is_vertical: bool,
103}
104
105impl VerticalDetectResult {
106 pub fn horizontal_probability(&self) -> f64 {
108 1.0 - self.vertical_probability
109 }
110}
111
112#[derive(Debug, Clone)]
114pub struct BookVerticalResult {
115 pub vertical_probability: f64,
117 pub is_vertical: bool,
119 pub page_count: usize,
121 pub page_results: Vec<VerticalDetectResult>,
123}
124
125pub fn detect_vertical_probability(
136 image: &GrayImage,
137 options: &VerticalDetectOptions,
138) -> Result<VerticalDetectResult, VerticalDetectError> {
139 let width = image.width();
140 let height = image.height();
141
142 if width == 0 || height == 0 {
143 return Err(VerticalDetectError::InvalidImage(
144 "Image has zero dimensions".to_string(),
145 ));
146 }
147
148 let horizontal_score = compute_linear_score(image, options);
150
151 let rotated = rotate_90_clockwise(image);
153 let vertical_score = compute_linear_score(&rotated, options);
154
155 let sum = horizontal_score + vertical_score + 1e-9;
157 let mut vertical_probability = vertical_score / sum;
158
159 vertical_probability = vertical_probability.clamp(0.0, 1.0);
161
162 let is_vertical = vertical_probability >= options.vertical_threshold;
163
164 Ok(VerticalDetectResult {
165 vertical_probability,
166 horizontal_score,
167 vertical_score,
168 is_vertical,
169 })
170}
171
172pub fn detect_book_vertical_writing(
177 images: &[GrayImage],
178 options: &VerticalDetectOptions,
179) -> Result<BookVerticalResult, VerticalDetectError> {
180 if images.is_empty() {
181 return Err(VerticalDetectError::InvalidImage(
182 "No images provided".to_string(),
183 ));
184 }
185
186 let mut page_results = Vec::with_capacity(images.len());
187
188 for image in images {
189 let result = detect_vertical_probability(image, options)?;
190 page_results.push(result);
191 }
192
193 let vertical_probability = if page_results.len() >= 10 {
194 page_results.iter().map(|r| r.vertical_probability).sum::<f64>() / page_results.len() as f64
196 } else {
197 page_results.iter().map(|r| r.vertical_probability).sum::<f64>() / page_results.len() as f64
199 };
200
201 let is_vertical = vertical_probability >= options.vertical_threshold;
202
203 Ok(BookVerticalResult {
204 vertical_probability,
205 is_vertical,
206 page_count: page_results.len(),
207 page_results,
208 })
209}
210
211fn compute_linear_score(image: &GrayImage, options: &VerticalDetectOptions) -> f64 {
218 let width = image.width() as usize;
219 let height = image.height() as usize;
220
221 if width == 0 || height == 0 {
222 return 0.0;
223 }
224
225 let block_count = options.block_count.max(1) as usize;
226 let block_width = width / block_count;
227
228 if block_width == 0 {
229 return 0.0;
230 }
231
232 let mut block_scores = Vec::with_capacity(block_count);
233
234 for blk in 0..block_count {
235 let start_x = blk * block_width;
236 let end_x = if blk == block_count - 1 {
237 width
238 } else {
239 start_x + block_width
240 };
241
242 let mut intersections_per_row = vec![0usize; height];
244 let mut zero_lines: usize = 0;
245 let mut mean: f64 = 0.0;
246 let mut m2: f64 = 0.0;
247 let mut count: usize = 0;
248
249 for (y, row_intersects) in intersections_per_row.iter_mut().enumerate() {
250 let mut intersects = 0;
251 let mut in_black = false;
252
253 for x in start_x..end_x {
255 let pixel = image.get_pixel(x as u32, y as u32).0[0];
256 let is_black = pixel <= options.black_threshold;
257
258 if is_black {
259 if !in_black {
260 intersects += 1;
261 in_black = true;
262 }
263 } else {
264 in_black = false;
265 }
266 }
267
268 *row_intersects = intersects;
269
270 if intersects == 0 {
271 zero_lines += 1;
272 }
273
274 count += 1;
276 let delta = intersects as f64 - mean;
277 mean += delta / count as f64;
278 let delta2 = intersects as f64 - mean;
279 m2 += delta * delta2;
280 }
281
282 if count == 0 {
283 block_scores.push(0.0);
284 continue;
285 }
286
287 let variance = m2 / count as f64;
288 let stddev = variance.sqrt();
289 let variation_coefficient = if mean > 0.0 { stddev / mean } else { 0.0 };
290 let zero_ratio = zero_lines as f64 / count as f64;
291
292 let threshold = mean.max(1.0);
294 let mut line_thicknesses = Vec::new();
295 let mut gap_heights = Vec::new();
296
297 let mut in_line = false;
298 let mut run_len = 0;
299
300 for &intersects in &intersections_per_row {
301 let is_line = intersects as f64 >= threshold;
302
303 if is_line == in_line {
304 run_len += 1;
305 } else {
306 if run_len > 0 {
308 if in_line {
309 line_thicknesses.push(run_len);
310 } else {
311 gap_heights.push(run_len);
312 }
313 }
314 in_line = is_line;
315 run_len = 1;
316 }
317 }
318
319 if run_len > 0 {
321 if in_line {
322 line_thicknesses.push(run_len);
323 } else {
324 gap_heights.push(run_len);
325 }
326 }
327
328 let separation_ratio = if !line_thicknesses.is_empty() && !gap_heights.is_empty() {
330 let median_line = median(&mut line_thicknesses);
331 let median_gap = median(&mut gap_heights);
332 median_gap as f64 / (median_line as f64 + median_gap as f64 + 1e-9)
333 } else {
334 0.0
335 };
336
337 let score =
340 (variation_coefficient * 0.4) + (zero_ratio * 0.2) + (separation_ratio * 0.4);
341
342 block_scores.push(score.clamp(0.0, 1.0));
343 }
344
345 if block_scores.is_empty() {
347 0.0
348 } else {
349 block_scores.iter().sum::<f64>() / block_scores.len() as f64
350 }
351}
352
353fn rotate_90_clockwise(image: &GrayImage) -> GrayImage {
355 let (width, height) = image.dimensions();
356 let mut rotated: GrayImage = ImageBuffer::new(height, width);
357
358 for y in 0..height {
359 for x in 0..width {
360 let pixel = image.get_pixel(x, y);
361 let new_x = y;
366 let new_y = width - 1 - x;
367 rotated.put_pixel(new_x, new_y, *pixel);
368 }
369 }
370
371 rotated
372}
373
374fn median(data: &mut [usize]) -> usize {
376 if data.is_empty() {
377 return 0;
378 }
379
380 data.sort_unstable();
381 let mid = data.len() / 2;
382
383 if data.len() % 2 == 1 {
384 data[mid]
385 } else {
386 (data[mid - 1] + data[mid]) / 2
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 #[test]
397 fn test_vd001_vertical_probability_range() {
398 let image: GrayImage = ImageBuffer::from_fn(100, 100, |_, _| Luma([255u8]));
400 let options = VerticalDetectOptions::default();
401
402 let result = detect_vertical_probability(&image, &options).unwrap();
403
404 assert!(result.vertical_probability >= 0.0);
406 assert!(result.vertical_probability <= 1.0);
407 }
408
409 #[test]
412 fn test_vd002_horizontal_probability() {
413 let image: GrayImage = ImageBuffer::from_fn(100, 100, |_, _| Luma([255u8]));
414 let options = VerticalDetectOptions::default();
415
416 let result = detect_vertical_probability(&image, &options).unwrap();
417
418 let expected = 1.0 - result.vertical_probability;
420 assert!((result.horizontal_probability() - expected).abs() < 1e-9);
421 }
422
423 #[test]
426 fn test_vd004_empty_image_error() {
427 let image: GrayImage = ImageBuffer::new(0, 0);
428 let options = VerticalDetectOptions::default();
429
430 let result = detect_vertical_probability(&image, &options);
431 assert!(result.is_err());
432 }
433
434 #[test]
435 fn test_vd004_zero_width() {
436 let image: GrayImage = ImageBuffer::new(0, 100);
437 let options = VerticalDetectOptions::default();
438
439 let result = detect_vertical_probability(&image, &options);
440 assert!(result.is_err());
441 }
442
443 #[test]
444 fn test_vd004_zero_height() {
445 let image: GrayImage = ImageBuffer::new(100, 0);
446 let options = VerticalDetectOptions::default();
447
448 let result = detect_vertical_probability(&image, &options);
449 assert!(result.is_err());
450 }
451
452 #[test]
455 fn test_vd005_block_count_options() {
456 let image: GrayImage = ImageBuffer::from_fn(400, 100, |_, _| Luma([255u8]));
457
458 for block_count in [1, 2, 4, 8] {
459 let options = VerticalDetectOptions {
460 block_count,
461 ..Default::default()
462 };
463
464 let result = detect_vertical_probability(&image, &options);
465 assert!(result.is_ok());
466 }
467 }
468
469 #[test]
472 fn test_vd006_horizontal_lines_detected() {
473 let image: GrayImage = ImageBuffer::from_fn(100, 100, |_, y| {
475 if y % 10 < 5 {
476 Luma([0u8]) } else {
478 Luma([255u8]) }
480 });
481
482 let options = VerticalDetectOptions::default();
483 let result = detect_vertical_probability(&image, &options).unwrap();
484
485 assert!(result.horizontal_score > 0.0);
487 }
488
489 #[test]
490 fn test_vd006_vertical_lines_detected() {
491 let image: GrayImage = ImageBuffer::from_fn(100, 100, |x, _| {
493 if x % 10 < 5 {
494 Luma([0u8]) } else {
496 Luma([255u8]) }
498 });
499
500 let options = VerticalDetectOptions::default();
501 let result = detect_vertical_probability(&image, &options).unwrap();
502
503 assert!(result.vertical_score > 0.0);
505 }
506
507 #[test]
510 fn test_vd009_score_clamped() {
511 let image: GrayImage = ImageBuffer::from_fn(100, 100, |x, y| {
512 if (x + y) % 2 == 0 {
513 Luma([0u8])
514 } else {
515 Luma([255u8])
516 }
517 });
518
519 let options = VerticalDetectOptions::default();
520 let result = detect_vertical_probability(&image, &options).unwrap();
521
522 assert!(result.horizontal_score >= 0.0 && result.horizontal_score <= 1.0);
524 assert!(result.vertical_score >= 0.0 && result.vertical_score <= 1.0);
525 }
526
527 #[test]
530 fn test_vd010_rotation_90_clockwise() {
531 let image: GrayImage = ImageBuffer::from_fn(10, 5, |x, y| {
533 if x == 0 && y == 0 {
534 Luma([100u8])
535 } else {
536 Luma([255u8])
537 }
538 });
539
540 let rotated = rotate_90_clockwise(&image);
541
542 assert_eq!(rotated.dimensions(), (5, 10));
544
545 assert_eq!(rotated.get_pixel(0, 9).0[0], 100);
547 }
548
549 #[test]
552 fn test_vd012_book_empty_error() {
553 let images: Vec<GrayImage> = vec![];
554 let options = VerticalDetectOptions::default();
555
556 let result = detect_book_vertical_writing(&images, &options);
557 assert!(result.is_err());
558 }
559
560 #[test]
561 fn test_vd012_book_single_page() {
562 let image: GrayImage = ImageBuffer::from_fn(100, 100, |_, _| Luma([255u8]));
563 let images = vec![image];
564 let options = VerticalDetectOptions::default();
565
566 let result = detect_book_vertical_writing(&images, &options).unwrap();
567 assert_eq!(result.page_count, 1);
568 }
569
570 #[test]
571 fn test_vd012_book_multiple_pages() {
572 let images: Vec<GrayImage> = (0..10)
573 .map(|_| ImageBuffer::from_fn(100, 100, |_, _| Luma([255u8])))
574 .collect();
575
576 let options = VerticalDetectOptions::default();
577 let result = detect_book_vertical_writing(&images, &options).unwrap();
578
579 assert_eq!(result.page_count, 10);
580 assert_eq!(result.page_results.len(), 10);
581 }
582
583 #[test]
586 fn test_options_default() {
587 let options = VerticalDetectOptions::default();
588 assert_eq!(options.black_threshold, 128);
589 assert_eq!(options.block_count, 4);
590 assert_eq!(options.vertical_threshold, 0.5);
591 }
592
593 #[test]
594 fn test_options_for_binary() {
595 let options = VerticalDetectOptions::for_binary();
596 assert_eq!(options.black_threshold, 10);
597 }
598
599 #[test]
600 fn test_options_with_threshold() {
601 let options = VerticalDetectOptions::with_threshold(50);
602 assert_eq!(options.black_threshold, 50);
603 }
604
605 #[test]
606 fn test_median_calculation() {
607 let mut data = vec![5, 2, 9, 1, 7];
608 assert_eq!(median(&mut data), 5);
609
610 let mut data_even = vec![1, 2, 3, 4];
611 assert_eq!(median(&mut data_even), 2); let mut empty: Vec<usize> = vec![];
614 assert_eq!(median(&mut empty), 0);
615 }
616
617 #[test]
618 fn test_result_debug_impl() {
619 let result = VerticalDetectResult {
620 vertical_probability: 0.7,
621 horizontal_score: 0.3,
622 vertical_score: 0.7,
623 is_vertical: true,
624 };
625 let debug_str = format!("{:?}", result);
626 assert!(debug_str.contains("VerticalDetectResult"));
627 }
628
629 #[test]
630 fn test_error_display() {
631 let err = VerticalDetectError::InvalidImage("test".to_string());
632 assert!(err.to_string().contains("Invalid image"));
633
634 let err2 = VerticalDetectError::ProcessingError("test".to_string());
635 assert!(err2.to_string().contains("Processing error"));
636 }
637
638 #[test]
639 fn test_white_image() {
640 let image: GrayImage = ImageBuffer::from_fn(100, 100, |_, _| Luma([255u8]));
642 let options = VerticalDetectOptions::default();
643
644 let result = detect_vertical_probability(&image, &options).unwrap();
645
646 assert!(result.horizontal_score >= 0.0);
648 assert!(result.vertical_score >= 0.0);
649 }
650
651 #[test]
652 fn test_black_image() {
653 let image: GrayImage = ImageBuffer::from_fn(100, 100, |_, _| Luma([0u8]));
655 let options = VerticalDetectOptions::default();
656
657 let result = detect_vertical_probability(&image, &options).unwrap();
658
659 assert!(result.vertical_probability >= 0.0);
661 assert!(result.vertical_probability <= 1.0);
662 }
663
664 #[test]
665 fn test_small_image() {
666 let image: GrayImage = ImageBuffer::from_fn(5, 5, |_, _| Luma([128u8]));
668 let options = VerticalDetectOptions::default();
669
670 let result = detect_vertical_probability(&image, &options);
671 assert!(result.is_ok());
672 }
673
674 #[test]
675 fn test_thread_safety() {
676 use std::sync::Arc;
677 use std::thread;
678
679 let image = Arc::new(ImageBuffer::from_fn(100, 100, |_, _| Luma([255u8])));
680 let options = Arc::new(VerticalDetectOptions::default());
681
682 let handles: Vec<_> = (0..4)
683 .map(|_| {
684 let img = Arc::clone(&image);
685 let opts = Arc::clone(&options);
686 thread::spawn(move || detect_vertical_probability(&img, &opts))
687 })
688 .collect();
689
690 for handle in handles {
691 let result = handle.join().unwrap();
692 assert!(result.is_ok());
693 }
694 }
695
696 #[test]
699 fn test_large_block_count() {
700 let image: GrayImage = ImageBuffer::from_fn(50, 50, |_, _| Luma([128u8]));
702 let options = VerticalDetectOptions {
703 block_count: 100, ..Default::default()
705 };
706
707 let result = detect_vertical_probability(&image, &options);
708 assert!(result.is_ok());
709 }
710
711 #[test]
712 fn test_extreme_threshold() {
713 let image: GrayImage = ImageBuffer::from_fn(100, 100, |_, _| Luma([128u8]));
714
715 let options_low = VerticalDetectOptions {
717 black_threshold: 1,
718 ..Default::default()
719 };
720 let result_low = detect_vertical_probability(&image, &options_low);
721 assert!(result_low.is_ok());
722
723 let options_high = VerticalDetectOptions {
725 black_threshold: 254,
726 ..Default::default()
727 };
728 let result_high = detect_vertical_probability(&image, &options_high);
729 assert!(result_high.is_ok());
730 }
731
732 #[test]
733 fn test_vertical_threshold_extremes() {
734 let image: GrayImage = ImageBuffer::from_fn(100, 100, |_, _| Luma([255u8]));
735
736 let options_zero = VerticalDetectOptions {
738 vertical_threshold: 0.0,
739 ..Default::default()
740 };
741 let result_zero = detect_vertical_probability(&image, &options_zero).unwrap();
742 assert!(result_zero.is_vertical);
743
744 let options_one = VerticalDetectOptions {
746 vertical_threshold: 1.0,
747 ..Default::default()
748 };
749 let result_one = detect_vertical_probability(&image, &options_one).unwrap();
750 assert!(!result_one.is_vertical);
751 }
752
753 #[test]
754 fn test_book_result_fields() {
755 let images: Vec<GrayImage> = (0..5)
756 .map(|_| ImageBuffer::from_fn(100, 100, |_, _| Luma([255u8])))
757 .collect();
758
759 let options = VerticalDetectOptions::default();
760 let result = detect_book_vertical_writing(&images, &options).unwrap();
761
762 assert_eq!(result.page_count, 5);
763 assert_eq!(result.page_results.len(), 5);
764 assert!(result.vertical_probability >= 0.0);
765 assert!(result.vertical_probability <= 1.0);
766 }
767
768 #[test]
769 fn test_book_mixed_results() {
770 let horizontal_image: GrayImage = ImageBuffer::from_fn(100, 100, |_, y| {
772 if y % 10 < 5 {
773 Luma([0u8])
774 } else {
775 Luma([255u8])
776 }
777 });
778
779 let vertical_image: GrayImage = ImageBuffer::from_fn(100, 100, |x, _| {
780 if x % 10 < 5 {
781 Luma([0u8])
782 } else {
783 Luma([255u8])
784 }
785 });
786
787 let images = vec![horizontal_image.clone(), vertical_image, horizontal_image];
788 let options = VerticalDetectOptions::default();
789
790 let result = detect_book_vertical_writing(&images, &options).unwrap();
791 assert_eq!(result.page_count, 3);
792 }
793
794 #[test]
795 fn test_rotate_90_clockwise() {
796 let image: GrayImage = ImageBuffer::from_fn(20, 10, |x, y| Luma([(x + y * 10) as u8]));
797
798 let rotated = rotate_90_clockwise(&image);
799
800 assert_eq!(rotated.width(), 10);
802 assert_eq!(rotated.height(), 20);
803 }
804
805 #[test]
806 fn test_result_horizontal_probability() {
807 let result = VerticalDetectResult {
808 vertical_probability: 0.3,
809 horizontal_score: 0.7,
810 vertical_score: 0.3,
811 is_vertical: false,
812 };
813
814 assert!((result.horizontal_probability() - 0.7).abs() < 1e-9);
815 }
816
817 #[test]
818 fn test_error_debug_impl() {
819 let err = VerticalDetectError::InvalidImage("test".to_string());
820 let debug_str = format!("{:?}", err);
821 assert!(debug_str.contains("InvalidImage"));
822 }
823}