Skip to main content

superbook_pdf/margin/
mod.rs

1//! Margin Detection & Trimming module
2//!
3//! Provides functionality to detect and trim margins from scanned images.
4//!
5//! # Features
6//!
7//! - Multiple detection modes (Background, Edge, Histogram, Combined)
8//! - Unified margin calculation across multiple pages
9//! - Configurable trim percentages
10//! - Parallel processing support
11//! - Tukey fence outlier removal for group analysis
12//!
13//! # Example
14//!
15//! ```rust,no_run
16//! use superbook_pdf::{MarginOptions, ImageMarginDetector};
17//! use std::path::Path;
18//!
19//! let options = MarginOptions::builder()
20//!     .background_threshold(250)
21//!     .default_trim_percent(0.5)
22//!     .build();
23//!
24//! let detection = ImageMarginDetector::detect(
25//!     Path::new("page.png"),
26//!     &options
27//! ).unwrap();
28//!
29//! println!("Margins: top={}, bottom={}", detection.margins.top, detection.margins.bottom);
30//! ```
31
32// Submodules
33mod detect;
34mod group;
35mod types;
36
37// Re-export public API
38pub use detect::ImageMarginDetector;
39pub use group::{GroupCropAnalyzer, GroupCropRegion, PageBoundingBox, UnifiedCropRegions};
40pub use types::{
41    ContentRect, MarginDetection, MarginDetector, MarginError, Margins, Result, TrimResult,
42    UnifiedMargins,
43};
44
45// ============================================================
46// Constants
47// ============================================================
48
49/// Default background threshold for white/light backgrounds (0-255)
50const DEFAULT_BACKGROUND_THRESHOLD: u8 = 250;
51
52/// Background threshold for dark/aged documents
53const DARK_BACKGROUND_THRESHOLD: u8 = 50;
54
55/// Default minimum margin in pixels
56const DEFAULT_MIN_MARGIN: u32 = 10;
57
58/// Default trim percentage
59const DEFAULT_TRIM_PERCENT: f32 = 0.5;
60
61/// Default edge detection sensitivity
62const DEFAULT_EDGE_SENSITIVITY: f32 = 0.5;
63
64/// High precision edge sensitivity
65const PRECISE_EDGE_SENSITIVITY: f32 = 0.8;
66
67/// Minimum clamp value for percentage
68const MIN_PERCENT: f32 = 0.0;
69
70/// Maximum clamp value for percentage
71const MAX_PERCENT: f32 = 100.0;
72
73/// Minimum sensitivity value
74const MIN_SENSITIVITY: f32 = 0.0;
75
76/// Maximum sensitivity value
77const MAX_SENSITIVITY: f32 = 1.0;
78
79// ============================================================
80// Options
81// ============================================================
82
83/// Margin detection options
84#[derive(Debug, Clone)]
85pub struct MarginOptions {
86    /// Background color threshold (0-255)
87    pub background_threshold: u8,
88    /// Minimum margin in pixels
89    pub min_margin: u32,
90    /// Default trim percentage
91    pub default_trim_percent: f32,
92    /// Edge detection sensitivity
93    pub edge_sensitivity: f32,
94    /// Content detection mode
95    pub detection_mode: ContentDetectionMode,
96}
97
98impl Default for MarginOptions {
99    fn default() -> Self {
100        Self {
101            background_threshold: DEFAULT_BACKGROUND_THRESHOLD,
102            min_margin: DEFAULT_MIN_MARGIN,
103            default_trim_percent: DEFAULT_TRIM_PERCENT,
104            edge_sensitivity: DEFAULT_EDGE_SENSITIVITY,
105            detection_mode: ContentDetectionMode::BackgroundColor,
106        }
107    }
108}
109
110impl MarginOptions {
111    /// Create a new options builder
112    pub fn builder() -> MarginOptionsBuilder {
113        MarginOptionsBuilder::default()
114    }
115
116    /// Create options for dark backgrounds (e.g., scanned old books)
117    pub fn for_dark_background() -> Self {
118        Self {
119            background_threshold: DARK_BACKGROUND_THRESHOLD,
120            detection_mode: ContentDetectionMode::EdgeDetection,
121            ..Default::default()
122        }
123    }
124
125    /// Create options for precise detection
126    pub fn precise() -> Self {
127        Self {
128            detection_mode: ContentDetectionMode::Combined,
129            edge_sensitivity: PRECISE_EDGE_SENSITIVITY,
130            ..Default::default()
131        }
132    }
133}
134
135/// Builder for MarginOptions
136#[derive(Debug, Default)]
137pub struct MarginOptionsBuilder {
138    options: MarginOptions,
139}
140
141impl MarginOptionsBuilder {
142    /// Set background threshold (0-255)
143    #[must_use]
144    pub fn background_threshold(mut self, threshold: u8) -> Self {
145        self.options.background_threshold = threshold;
146        self
147    }
148
149    /// Set minimum margin in pixels
150    #[must_use]
151    pub fn min_margin(mut self, margin: u32) -> Self {
152        self.options.min_margin = margin;
153        self
154    }
155
156    /// Set default trim percentage
157    #[must_use]
158    pub fn default_trim_percent(mut self, percent: f32) -> Self {
159        self.options.default_trim_percent = percent.clamp(MIN_PERCENT, MAX_PERCENT);
160        self
161    }
162
163    /// Set edge detection sensitivity (0.0-1.0)
164    #[must_use]
165    pub fn edge_sensitivity(mut self, sensitivity: f32) -> Self {
166        self.options.edge_sensitivity = sensitivity.clamp(MIN_SENSITIVITY, MAX_SENSITIVITY);
167        self
168    }
169
170    /// Set content detection mode
171    #[must_use]
172    pub fn detection_mode(mut self, mode: ContentDetectionMode) -> Self {
173        self.options.detection_mode = mode;
174        self
175    }
176
177    /// Build the options
178    #[must_use]
179    pub fn build(self) -> MarginOptions {
180        self.options
181    }
182}
183
184/// Content detection modes
185#[derive(Debug, Clone, Copy, Default)]
186pub enum ContentDetectionMode {
187    /// Simple background color detection
188    #[default]
189    BackgroundColor,
190    /// Edge detection based
191    EdgeDetection,
192    /// Histogram analysis
193    Histogram,
194    /// Combined detection
195    Combined,
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use std::path::{Path, PathBuf};
202
203    #[test]
204    fn test_default_options() {
205        let opts = MarginOptions::default();
206
207        assert_eq!(opts.background_threshold, 250);
208        assert_eq!(opts.min_margin, 10);
209        assert_eq!(opts.default_trim_percent, 0.5);
210        assert!(matches!(
211            opts.detection_mode,
212            ContentDetectionMode::BackgroundColor
213        ));
214    }
215
216    #[test]
217    fn test_uniform_margins() {
218        let margins = Margins::uniform(20);
219
220        assert_eq!(margins.top, 20);
221        assert_eq!(margins.bottom, 20);
222        assert_eq!(margins.left, 20);
223        assert_eq!(margins.right, 20);
224        assert_eq!(margins.total_horizontal(), 40);
225        assert_eq!(margins.total_vertical(), 40);
226    }
227
228    #[test]
229    fn test_image_not_found() {
230        let result = ImageMarginDetector::detect(
231            Path::new("/nonexistent/image.png"),
232            &MarginOptions::default(),
233        );
234
235        assert!(matches!(result, Err(MarginError::ImageNotFound(_))));
236    }
237
238    // TC-MRG-001: 単一画像マージン検出
239    #[test]
240    fn test_detect_single_image_margins() {
241        let options = MarginOptions {
242            background_threshold: 200,
243            ..Default::default()
244        };
245
246        let result =
247            ImageMarginDetector::detect(Path::new("tests/fixtures/with_margins.png"), &options);
248
249        match result {
250            Ok(detection) => {
251                assert!(detection.margins.top > 0);
252            }
253            Err(MarginError::NoContentDetected) => {
254                eprintln!("No content detected - algorithm needs tuning");
255            }
256            Err(e) => panic!("Unexpected error: {:?}", e),
257        }
258    }
259
260    // TC-MRG-002: マージンなし画像
261    #[test]
262    fn test_detect_no_margins() {
263        let options = MarginOptions {
264            background_threshold: 200,
265            ..Default::default()
266        };
267
268        let result =
269            ImageMarginDetector::detect(Path::new("tests/fixtures/no_margins.png"), &options);
270
271        match result {
272            Ok(detection) => {
273                assert!(detection.margins.top < 20);
274            }
275            Err(MarginError::NoContentDetected) => {
276                eprintln!("No content detected");
277            }
278            Err(e) => panic!("Unexpected error: {:?}", e),
279        }
280    }
281
282    #[test]
283    fn test_unified_margins() {
284        let images: Vec<_> = (1..=5)
285            .map(|i| PathBuf::from(format!("tests/fixtures/page_{}.png", i)))
286            .collect();
287
288        let options = MarginOptions {
289            background_threshold: 200,
290            ..Default::default()
291        };
292
293        let result = ImageMarginDetector::detect_unified(&images, &options);
294
295        match result {
296            Ok(unified) => {
297                assert!(unified.page_detections.len() == 5);
298            }
299            Err(MarginError::NoContentDetected) => {
300                eprintln!("No content detected in unified batch");
301            }
302            Err(e) => panic!("Unexpected error: {:?}", e),
303        }
304    }
305
306    // TC-MRG-010: コンテンツなし画像エラー
307    #[test]
308    fn test_no_content_error() {
309        let result = ImageMarginDetector::detect(
310            Path::new("tests/fixtures/blank_white.png"),
311            &MarginOptions::default(),
312        );
313
314        match result {
315            Err(MarginError::NoContentDetected) => {}
316            Ok(_) => {}
317            Err(e) => panic!("Unexpected error: {:?}", e),
318        }
319    }
320
321    #[test]
322    fn test_builder_pattern() {
323        let options = MarginOptions::builder()
324            .background_threshold(200)
325            .min_margin(20)
326            .default_trim_percent(1.0)
327            .edge_sensitivity(0.7)
328            .detection_mode(ContentDetectionMode::Combined)
329            .build();
330
331        assert_eq!(options.background_threshold, 200);
332        assert_eq!(options.min_margin, 20);
333        assert_eq!(options.default_trim_percent, 1.0);
334        assert_eq!(options.edge_sensitivity, 0.7);
335        assert!(matches!(
336            options.detection_mode,
337            ContentDetectionMode::Combined
338        ));
339    }
340
341    #[test]
342    fn test_builder_clamping() {
343        let options = MarginOptions::builder().edge_sensitivity(1.5).build();
344        assert_eq!(options.edge_sensitivity, 1.0);
345
346        let options = MarginOptions::builder().edge_sensitivity(-0.5).build();
347        assert_eq!(options.edge_sensitivity, 0.0);
348    }
349
350    #[test]
351    fn test_dark_background_preset() {
352        let options = MarginOptions::for_dark_background();
353
354        assert_eq!(options.background_threshold, 50);
355        assert!(matches!(
356            options.detection_mode,
357            ContentDetectionMode::EdgeDetection
358        ));
359    }
360
361    #[test]
362    fn test_precise_preset() {
363        let options = MarginOptions::precise();
364
365        assert!(matches!(
366            options.detection_mode,
367            ContentDetectionMode::Combined
368        ));
369        assert_eq!(options.edge_sensitivity, 0.8);
370    }
371
372    // TC-MRG-005: Trim result construction
373    #[test]
374    fn test_trim_result_construction() {
375        let result = TrimResult {
376            input_path: PathBuf::from("/input/test.png"),
377            output_path: PathBuf::from("/output/test.png"),
378            original_size: (1000, 1500),
379            trimmed_size: (800, 1200),
380            margins_applied: Margins {
381                top: 100,
382                bottom: 200,
383                left: 100,
384                right: 100,
385            },
386        };
387
388        assert_eq!(result.original_size, (1000, 1500));
389        assert_eq!(result.trimmed_size, (800, 1200));
390        assert_eq!(result.margins_applied.top, 100);
391    }
392
393    // TC-MRG-003: Content rect
394    #[test]
395    fn test_content_rect_construction() {
396        let rect = ContentRect {
397            x: 50,
398            y: 100,
399            width: 800,
400            height: 1200,
401        };
402
403        assert_eq!(rect.x, 50);
404        assert_eq!(rect.y, 100);
405        assert_eq!(rect.width, 800);
406        assert_eq!(rect.height, 1200);
407    }
408
409    // TC-MRG-004: Unified margins structure
410    #[test]
411    fn test_unified_margins_construction() {
412        let detection = MarginDetection {
413            margins: Margins::uniform(50),
414            image_size: (1000, 1500),
415            content_rect: ContentRect {
416                x: 50,
417                y: 50,
418                width: 900,
419                height: 1400,
420            },
421            confidence: 0.9,
422        };
423
424        let unified = UnifiedMargins {
425            margins: Margins::uniform(30),
426            page_detections: vec![detection],
427            unified_size: (940, 1440),
428        };
429
430        assert_eq!(unified.margins.top, 30);
431        assert_eq!(unified.page_detections.len(), 1);
432        assert_eq!(unified.unified_size, (940, 1440));
433    }
434
435    // TC-MRG-008: Edge detection mode
436    #[test]
437    fn test_edge_detection_mode_option() {
438        let options = MarginOptions::builder()
439            .detection_mode(ContentDetectionMode::EdgeDetection)
440            .build();
441
442        assert!(matches!(
443            options.detection_mode,
444            ContentDetectionMode::EdgeDetection
445        ));
446    }
447
448    #[test]
449    fn test_histogram_mode_option() {
450        let options = MarginOptions::builder()
451            .detection_mode(ContentDetectionMode::Histogram)
452            .build();
453
454        assert!(matches!(
455            options.detection_mode,
456            ContentDetectionMode::Histogram
457        ));
458    }
459
460    #[test]
461    fn test_all_detection_modes() {
462        let modes = vec![
463            ContentDetectionMode::BackgroundColor,
464            ContentDetectionMode::EdgeDetection,
465            ContentDetectionMode::Histogram,
466            ContentDetectionMode::Combined,
467        ];
468
469        for mode in modes {
470            let options = MarginOptions::builder().detection_mode(mode).build();
471            match (mode, options.detection_mode) {
472                (ContentDetectionMode::BackgroundColor, ContentDetectionMode::BackgroundColor) => {}
473                (ContentDetectionMode::EdgeDetection, ContentDetectionMode::EdgeDetection) => {}
474                (ContentDetectionMode::Histogram, ContentDetectionMode::Histogram) => {}
475                (ContentDetectionMode::Combined, ContentDetectionMode::Combined) => {}
476                _ => panic!("Mode mismatch"),
477            }
478        }
479    }
480
481    #[test]
482    fn test_margin_detection_confidence() {
483        let detection = MarginDetection {
484            margins: Margins::uniform(50),
485            image_size: (1000, 1500),
486            content_rect: ContentRect {
487                x: 50,
488                y: 50,
489                width: 900,
490                height: 1400,
491            },
492            confidence: 0.85,
493        };
494
495        assert!(detection.confidence > 0.0 && detection.confidence <= 1.0);
496        assert_eq!(detection.image_size, (1000, 1500));
497    }
498
499    #[test]
500    fn test_error_types() {
501        let _err1 = MarginError::ImageNotFound(PathBuf::from("/test/path"));
502        let _err2 = MarginError::InvalidImage("Invalid format".to_string());
503        let _err3 = MarginError::NoContentDetected;
504        let _err4: MarginError = std::io::Error::other("test").into();
505    }
506
507    // TC-MRG-005: Trim margins
508    #[test]
509    fn test_trim_with_fixture() {
510        let temp_dir = tempfile::tempdir().unwrap();
511        let output = temp_dir.path().join("trimmed.png");
512
513        let margins = Margins {
514            top: 10,
515            bottom: 10,
516            left: 10,
517            right: 10,
518        };
519
520        let result = ImageMarginDetector::trim(
521            Path::new("tests/fixtures/with_margins.png"),
522            &output,
523            &margins,
524        );
525
526        match result {
527            Ok(trim_result) => {
528                assert!(output.exists());
529                assert!(trim_result.trimmed_size.0 <= trim_result.original_size.0);
530                assert!(trim_result.trimmed_size.1 <= trim_result.original_size.1);
531            }
532            Err(e) => {
533                eprintln!("Trim error: {:?}", e);
534            }
535        }
536    }
537
538    // TC-MRG-006: Pad to size
539    #[test]
540    fn test_pad_to_size_with_fixture() {
541        let temp_dir = tempfile::tempdir().unwrap();
542        let output = temp_dir.path().join("padded.png");
543
544        let result = ImageMarginDetector::pad_to_size(
545            Path::new("tests/fixtures/small_image.png"),
546            &output,
547            (500, 500),
548            [255, 255, 255],
549        );
550
551        match result {
552            Ok(_pad_result) => {
553                assert!(output.exists());
554                let img = image::open(&output).unwrap();
555                assert_eq!(img.width(), 500);
556                assert_eq!(img.height(), 500);
557            }
558            Err(e) => {
559                eprintln!("Pad error: {:?}", e);
560            }
561        }
562    }
563
564    // TC-MRG-007: Background threshold variations
565    #[test]
566    fn test_background_threshold_high() {
567        let options = MarginOptions::builder().background_threshold(254).build();
568        assert_eq!(options.background_threshold, 254);
569    }
570
571    #[test]
572    fn test_background_threshold_low() {
573        let options = MarginOptions::builder().background_threshold(100).build();
574        assert_eq!(options.background_threshold, 100);
575    }
576
577    // TC-MRG-009: Batch processing
578    #[test]
579    fn test_batch_processing() {
580        let temp_dir = tempfile::tempdir().unwrap();
581
582        let images: Vec<(PathBuf, PathBuf)> = (1..=3)
583            .map(|i| {
584                (
585                    PathBuf::from(format!("tests/fixtures/page_{}.png", i)),
586                    temp_dir.path().join(format!("output_{}.png", i)),
587                )
588            })
589            .collect();
590
591        let options = MarginOptions {
592            background_threshold: 200,
593            ..Default::default()
594        };
595
596        let result = ImageMarginDetector::process_batch(&images, &options);
597
598        match result {
599            Ok(results) => {
600                assert_eq!(results.len(), 3);
601            }
602            Err(MarginError::NoContentDetected) => {
603                eprintln!("No content detected in batch");
604            }
605            Err(e) => {
606                eprintln!("Batch error: {:?}", e);
607            }
608        }
609    }
610
611    #[test]
612    fn test_margins_arithmetic() {
613        let margins = Margins {
614            top: 10,
615            bottom: 20,
616            left: 15,
617            right: 25,
618        };
619
620        assert_eq!(margins.total_vertical(), 30);
621        assert_eq!(margins.total_horizontal(), 40);
622    }
623
624    #[test]
625    fn test_error_display_messages() {
626        let err1 = MarginError::ImageNotFound(PathBuf::from("/test/path.png"));
627        assert!(err1.to_string().contains("not found"));
628
629        let err2 = MarginError::InvalidImage("bad format".to_string());
630        assert!(err2.to_string().contains("Invalid"));
631
632        let err3 = MarginError::NoContentDetected;
633        assert!(err3.to_string().contains("content"));
634    }
635
636    #[test]
637    fn test_margins_construction() {
638        let margins = Margins {
639            top: 50,
640            bottom: 60,
641            left: 30,
642            right: 40,
643        };
644
645        assert_eq!(margins.top, 50);
646        assert_eq!(margins.bottom, 60);
647        assert_eq!(margins.left, 30);
648        assert_eq!(margins.right, 40);
649    }
650
651    #[test]
652    fn test_margins_zero() {
653        let margins = Margins {
654            top: 0,
655            bottom: 0,
656            left: 0,
657            right: 0,
658        };
659
660        assert_eq!(margins.total_vertical(), 0);
661        assert_eq!(margins.total_horizontal(), 0);
662    }
663
664    #[test]
665    fn test_margins_asymmetric() {
666        let margins = Margins {
667            top: 100,
668            bottom: 50,
669            left: 20,
670            right: 80,
671        };
672
673        assert_ne!(margins.top, margins.bottom);
674        assert_ne!(margins.left, margins.right);
675        assert_eq!(margins.total_vertical(), 150);
676        assert_eq!(margins.total_horizontal(), 100);
677    }
678
679    #[test]
680    fn test_margin_options_builder_all() {
681        let options = MarginOptions::builder()
682            .background_threshold(200)
683            .min_margin(5)
684            .edge_sensitivity(0.8)
685            .build();
686
687        assert_eq!(options.background_threshold, 200);
688        assert_eq!(options.min_margin, 5);
689        assert_eq!(options.edge_sensitivity, 0.8);
690    }
691
692    #[test]
693    fn test_margin_options_default() {
694        let options = MarginOptions::default();
695        assert!(options.background_threshold > 0);
696    }
697
698    #[test]
699    fn test_trim_result_fields_consistency() {
700        let result = TrimResult {
701            input_path: PathBuf::from("/input/original.png"),
702            output_path: PathBuf::from("/output/trimmed.png"),
703            original_size: (1000, 800),
704            trimmed_size: (900, 750),
705            margins_applied: Margins {
706                top: 20,
707                bottom: 30,
708                left: 50,
709                right: 50,
710            },
711        };
712
713        assert_eq!(result.original_size.0, 1000);
714        assert_eq!(result.trimmed_size.1, 750);
715        let expected_width =
716            result.original_size.0 - result.margins_applied.left - result.margins_applied.right;
717        assert_eq!(expected_width, result.trimmed_size.0);
718    }
719
720    #[test]
721    fn test_trim_result_unchanged() {
722        let result = TrimResult {
723            input_path: PathBuf::from("/input/same.png"),
724            output_path: PathBuf::from("/output/same.png"),
725            original_size: (500, 500),
726            trimmed_size: (500, 500),
727            margins_applied: Margins {
728                top: 0,
729                bottom: 0,
730                left: 0,
731                right: 0,
732            },
733        };
734
735        assert_eq!(result.original_size, result.trimmed_size);
736        assert_eq!(result.margins_applied.total_vertical(), 0);
737        assert_eq!(result.margins_applied.total_horizontal(), 0);
738    }
739
740    #[test]
741    fn test_edge_sensitivity_variations() {
742        let opts_zero = MarginOptions::builder().edge_sensitivity(0.0).build();
743        assert_eq!(opts_zero.edge_sensitivity, 0.0);
744
745        let opts_mid = MarginOptions::builder().edge_sensitivity(0.5).build();
746        assert_eq!(opts_mid.edge_sensitivity, 0.5);
747
748        let opts_max = MarginOptions::builder().edge_sensitivity(1.0).build();
749        assert_eq!(opts_max.edge_sensitivity, 1.0);
750    }
751
752    #[test]
753    fn test_content_detection_mode_copy() {
754        let original = ContentDetectionMode::Combined;
755        let cloned = original;
756        assert!(matches!(cloned, ContentDetectionMode::Combined));
757    }
758
759    #[test]
760    fn test_margins_copy() {
761        let original = Margins::uniform(50);
762        let cloned = original;
763        assert_eq!(cloned.top, 50);
764    }
765
766    #[test]
767    fn test_content_rect_copy() {
768        let original = ContentRect {
769            x: 10,
770            y: 20,
771            width: 100,
772            height: 200,
773        };
774        let cloned = original;
775        assert_eq!(cloned.x, 10);
776        assert_eq!(cloned.y, 20);
777    }
778
779    // Group Crop tests
780    #[test]
781    fn test_page_bounding_box_creation() {
782        let rect = ContentRect {
783            x: 100,
784            y: 50,
785            width: 800,
786            height: 1200,
787        };
788        let bbox = PageBoundingBox::new(1, rect);
789        assert_eq!(bbox.page_number, 1);
790        assert!(bbox.is_odd);
791        assert!(bbox.is_valid());
792    }
793
794    #[test]
795    fn test_group_crop_region_valid() {
796        let region = GroupCropRegion {
797            left: 100,
798            top: 50,
799            width: 800,
800            height: 1200,
801            inlier_count: 10,
802            total_count: 12,
803        };
804        assert!(region.is_valid());
805        assert_eq!(region.right(), 900);
806        assert_eq!(region.bottom(), 1250);
807    }
808
809    #[test]
810    fn test_decide_group_crop_empty() {
811        let result = GroupCropAnalyzer::decide_group_crop_region(&[]);
812        assert!(!result.is_valid());
813    }
814
815    #[test]
816    fn test_unify_odd_even_regions() {
817        let boxes = vec![
818            PageBoundingBox::new(
819                1,
820                ContentRect {
821                    x: 100,
822                    y: 50,
823                    width: 800,
824                    height: 1200,
825                },
826            ),
827            PageBoundingBox::new(
828                2,
829                ContentRect {
830                    x: 150,
831                    y: 60,
832                    width: 750,
833                    height: 1180,
834                },
835            ),
836        ];
837        let result = GroupCropAnalyzer::unify_odd_even_regions(&boxes);
838        assert!(result.odd_region.is_valid());
839        assert!(result.even_region.is_valid());
840    }
841}