Skip to main content

superbook_pdf/
vertical_detect.rs

1//! Vertical text detection for Japanese books
2//!
3//! # Overview
4//!
5//! This module detects whether a scanned book page contains vertical (top-to-bottom)
6//! or horizontal (left-to-right) text by analyzing the line structure of binarized images.
7//!
8//! # Algorithm
9//!
10//! 1. Scan the image horizontally to compute a "horizontal score"
11//! 2. Rotate the image 90° and apply the same scan for a "vertical score"
12//! 3. Normalize the scores to get vertical writing probability
13//!
14//! # Example
15//!
16//! ```ignore
17//! use superbook_pdf::vertical_detect::{detect_vertical_probability, VerticalDetectOptions};
18//!
19//! let options = VerticalDetectOptions::default();
20//! let result = detect_vertical_probability(&gray_image, &options)?;
21//! println!("Vertical probability: {:.2}", result.vertical_probability);
22//! ```
23
24use image::{GrayImage, ImageBuffer};
25
26#[cfg(test)]
27use image::Luma;
28use std::error::Error;
29use std::fmt;
30
31/// Error type for vertical detection operations
32#[derive(Debug, Clone)]
33pub enum VerticalDetectError {
34    /// Image is empty or has invalid dimensions
35    InvalidImage(String),
36    /// Processing error
37    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/// Options for vertical text detection
52#[derive(Debug, Clone)]
53pub struct VerticalDetectOptions {
54    /// Threshold for considering a pixel as black (0-255)
55    /// Pixels with value <= this threshold are considered black
56    pub black_threshold: u8,
57    /// Number of horizontal blocks to divide the image into
58    /// Used to handle multi-column layouts
59    pub block_count: u32,
60    /// Minimum probability threshold for vertical writing (default: 0.5)
61    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    /// Create options for high-contrast binary images
76    pub fn for_binary() -> Self {
77        Self {
78            black_threshold: 10,
79            ..Default::default()
80        }
81    }
82
83    /// Create options with custom black threshold
84    pub fn with_threshold(threshold: u8) -> Self {
85        Self {
86            black_threshold: threshold,
87            ..Default::default()
88        }
89    }
90}
91
92/// Result of vertical text detection
93#[derive(Debug, Clone)]
94pub struct VerticalDetectResult {
95    /// Probability that the text is vertical (0.0-1.0)
96    pub vertical_probability: f64,
97    /// Score for horizontal text structure
98    pub horizontal_score: f64,
99    /// Score for vertical text structure
100    pub vertical_score: f64,
101    /// Whether the text is determined to be vertical
102    pub is_vertical: bool,
103}
104
105impl VerticalDetectResult {
106    /// Get the horizontal probability (1.0 - vertical_probability)
107    pub fn horizontal_probability(&self) -> f64 {
108        1.0 - self.vertical_probability
109    }
110}
111
112/// Result for an entire book's vertical writing detection
113#[derive(Debug, Clone)]
114pub struct BookVerticalResult {
115    /// Average vertical probability across all pages
116    pub vertical_probability: f64,
117    /// Whether the book is determined to be vertical writing
118    pub is_vertical: bool,
119    /// Number of pages analyzed
120    pub page_count: usize,
121    /// Individual page results
122    pub page_results: Vec<VerticalDetectResult>,
123}
124
125/// Detect vertical writing probability for a single grayscale image
126///
127/// # Arguments
128///
129/// * `image` - Grayscale image (binarized or near-binary preferred)
130/// * `options` - Detection options
131///
132/// # Returns
133///
134/// Detection result with vertical probability
135pub 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    // 1. Compute horizontal score (scanning rows)
149    let horizontal_score = compute_linear_score(image, options);
150
151    // 2. Rotate image 90° clockwise and compute vertical score
152    let rotated = rotate_90_clockwise(image);
153    let vertical_score = compute_linear_score(&rotated, options);
154
155    // 3. Normalize to get vertical probability
156    let sum = horizontal_score + vertical_score + 1e-9;
157    let mut vertical_probability = vertical_score / sum;
158
159    // Clamp to [0.0, 1.0]
160    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
172/// Detect vertical writing for an entire book (multiple pages)
173///
174/// Uses the average probability across pages with >= 10 pages,
175/// otherwise uses the simple average.
176pub 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        // Use average of all pages
195        page_results.iter().map(|r| r.vertical_probability).sum::<f64>() / page_results.len() as f64
196    } else {
197        // For small books, use simple average
198        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
211/// Compute the linear score for text structure detection
212///
213/// Scans the image row by row and evaluates:
214/// 1. Variation coefficient of intersection counts
215/// 2. Zero-line ratio (empty rows)
216/// 3. Separation ratio (gap between lines)
217fn 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        // 1. Count intersections per row using Welford's method
243        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            // Count black pixel clusters in this row
254            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            // Welford's online algorithm for mean and variance
275            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        // 2. Extract line thickness and gap heights
293        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                // Record previous run
307                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        // Final run
320        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        // Calculate separation ratio
329        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        // 3. Combine three metrics into block score
338        // Weights: variation=0.4, zeroLine=0.2, separation=0.4
339        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    // Return average of block scores
346    if block_scores.is_empty() {
347        0.0
348    } else {
349        block_scores.iter().sum::<f64>() / block_scores.len() as f64
350    }
351}
352
353/// Rotate an image 90 degrees clockwise
354fn 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            // (x, y) -> (height - 1 - y, x) after 90° clockwise rotation
362            // But we want new_width = height, new_height = width
363            // Original (x, y) maps to (y, width - 1 - x) in the new image
364            // Actually for 90° clockwise: new_x = old_y, new_y = old_width - 1 - old_x
365            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
374/// Calculate median of a slice (modifies the slice by sorting)
375fn 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    // ============ TC VD-001: Vertical probability calculation ============
395
396    #[test]
397    fn test_vd001_vertical_probability_range() {
398        // Create a simple test image
399        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        // Probability should be in [0, 1]
405        assert!(result.vertical_probability >= 0.0);
406        assert!(result.vertical_probability <= 1.0);
407    }
408
409    // ============ TC VD-002: Horizontal probability calculation ============
410
411    #[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        // Horizontal probability should be 1.0 - vertical_probability
419        let expected = 1.0 - result.vertical_probability;
420        assert!((result.horizontal_probability() - expected).abs() < 1e-9);
421    }
422
423    // ============ TC VD-004: Empty image handling ============
424
425    #[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    // ============ TC VD-005: Block division ============
453
454    #[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    // ============ TC VD-006: Intersection counting ============
470
471    #[test]
472    fn test_vd006_horizontal_lines_detected() {
473        // Create image with horizontal black lines
474        let image: GrayImage = ImageBuffer::from_fn(100, 100, |_, y| {
475            if y % 10 < 5 {
476                Luma([0u8]) // Black line
477            } else {
478                Luma([255u8]) // White gap
479            }
480        });
481
482        let options = VerticalDetectOptions::default();
483        let result = detect_vertical_probability(&image, &options).unwrap();
484
485        // Horizontal lines should give higher horizontal score
486        assert!(result.horizontal_score > 0.0);
487    }
488
489    #[test]
490    fn test_vd006_vertical_lines_detected() {
491        // Create image with vertical black lines
492        let image: GrayImage = ImageBuffer::from_fn(100, 100, |x, _| {
493            if x % 10 < 5 {
494                Luma([0u8]) // Black line
495            } else {
496                Luma([255u8]) // White gap
497            }
498        });
499
500        let options = VerticalDetectOptions::default();
501        let result = detect_vertical_probability(&image, &options).unwrap();
502
503        // Vertical lines should give higher vertical score
504        assert!(result.vertical_score > 0.0);
505    }
506
507    // ============ TC VD-009: Score synthesis ============
508
509    #[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        // All scores should be clamped to [0, 1]
523        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    // ============ TC VD-010: Image rotation ============
528
529    #[test]
530    fn test_vd010_rotation_90_clockwise() {
531        // Create asymmetric image to verify rotation
532        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        // Original: 10x5, Rotated: 5x10
543        assert_eq!(rotated.dimensions(), (5, 10));
544
545        // Original (0,0) with value 100 should be at (0, 9) after rotation
546        assert_eq!(rotated.get_pixel(0, 9).0[0], 100);
547    }
548
549    // ============ TC VD-012: Book vertical detection ============
550
551    #[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    // ============ Additional tests ============
584
585    #[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); // (2+3)/2 = 2 (integer division)
612
613        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        // Pure white image should have low scores for both directions
641        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        // Both scores should be low (or equal) for uniform image
647        assert!(result.horizontal_score >= 0.0);
648        assert!(result.vertical_score >= 0.0);
649    }
650
651    #[test]
652    fn test_black_image() {
653        // Pure black image
654        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        // Should not panic, probability should be valid
660        assert!(result.vertical_probability >= 0.0);
661        assert!(result.vertical_probability <= 1.0);
662    }
663
664    #[test]
665    fn test_small_image() {
666        // Very small image (edge case)
667        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    // ============ More edge case tests ============
697
698    #[test]
699    fn test_large_block_count() {
700        // Block count larger than image width/height
701        let image: GrayImage = ImageBuffer::from_fn(50, 50, |_, _| Luma([128u8]));
702        let options = VerticalDetectOptions {
703            block_count: 100, // More blocks than pixels
704            ..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        // Very low threshold
716        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        // Very high threshold
724        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        // Threshold 0.0 - always vertical
737        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        // Threshold 1.0 - never vertical
745        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        // Create images with different characteristics
771        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        // After 90° clockwise: new_width = old_height, new_height = old_width
801        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}