oar_ocr/pipeline/oarocr/
mod.rs

1//! The main OCR pipeline implementation.
2//!
3//! This module provides the complete OCR pipeline that combines multiple
4//! components to perform document orientation classification, text detection,
5//! text recognition, and text line classification.
6//!
7//! # Orchestration Architecture
8//!
9//! The pipeline has been refactored to use a unified orchestration system that
10//! eliminates code duplication and provides better maintainability. The key
11//! improvements include:
12//!
13//! ## Unified Processing Logic
14//!
15//! Previously, methods like `process_single_image`, `process_single_image_from_memory`,
16//! `process_images_individually`, and `process_images_from_memory` contained
17//! duplicated orchestration logic for:
18//!
19//! - Parallel vs sequential processing decisions
20//! - Index management and result sorting
21//! - Pipeline stage execution patterns
22//! - Error handling and logging
23//!
24//! Now, all these methods use the [`orchestration`] module's abstractions:
25//!
26//! - [`ImageProcessingOrchestrator`] for batch processing coordination
27//! - [`PipelineExecutor`] for unified stage execution
28//! - [`ProcessingStrategy`] for configurable parallelism
29//! - [`ImageInputSource`] for input abstraction
30//!
31//! ## Benefits
32//!
33//! - **Reduced Duplication**: ~200 lines of duplicated code eliminated
34//! - **Improved Maintainability**: Changes to orchestration logic in one place
35//! - **Better Testability**: Each orchestration component can be tested independently
36//! - **Type Safety**: Compile-time prevention of invalid configurations
37//! - **Consistent Behavior**: All processing paths use the same underlying logic
38//!
39//! ## Backward Compatibility
40//!
41//! The public API remains unchanged. All existing code using [`OAROCR::predict`]
42//! and related methods will continue to work without modification.
43//!
44//! [`orchestration`]: orchestration
45//! [`ImageProcessingOrchestrator`]: ImageProcessingOrchestrator
46//! [`PipelineExecutor`]: PipelineExecutor
47//! [`ProcessingStrategy`]: ProcessingStrategy
48//! [`ImageInputSource`]: ImageInputSource
49
50mod builder;
51mod components;
52mod config;
53mod extensible_integration;
54mod image_processing;
55mod orchestration;
56mod result;
57
58pub use builder::OAROCRBuilder;
59pub use config::{OAROCRConfig, OnnxThreadingConfig, ParallelPolicy};
60pub use extensible_integration::{ExtensibleOAROCR, ExtensibleOAROCRBuilder};
61pub use image_processing::ImageProcessor;
62pub use orchestration::{
63    ImageInputSource, ImageProcessingOrchestrator, PipelineExecutor, PipelineStage,
64    PipelineStageConfig, ProcessingStrategy,
65};
66pub use result::{ErrorMetrics, OAROCRResult, TextRegion};
67
68use crate::core::{OCRError, parse_text_line_orientation, traits::StandardPredictor};
69use crate::pipeline::PipelineStats;
70use crate::pipeline::stages::{
71    CroppingConfig, CroppingStageProcessor, RecognitionConfig, RecognitionStageProcessor,
72};
73use crate::predictor::{
74    DocOrientationClassifier, DoctrRectifierPredictor, TextDetPredictor, TextLineClasPredictor,
75    TextRecPredictor,
76};
77use components::ComponentBuilder;
78use image::RgbImage;
79
80use crate::pipeline::StatsManager;
81use std::path::Path;
82use std::sync::{Arc, Once};
83use tracing::{debug, info, warn};
84
85/// Global synchronization for rayon thread pool configuration.
86/// This ensures the global thread pool is only configured once.
87static THREAD_POOL_INIT: Once = Once::new();
88
89// Parameter struct to reduce function argument count
90#[derive(Debug, Clone)]
91pub struct SingleImageProcessingParams<'a> {
92    index: usize,
93    input_img_arc: Arc<RgbImage>,
94    current_img: RgbImage,
95    text_boxes: Vec<crate::processors::BoundingBox>,
96    orientation_angle: Option<f32>,
97    rectified_img: Option<Arc<RgbImage>>,
98    image_path: &'a Path,
99}
100
101/// Configures the global rayon thread pool if not already configured.
102///
103/// This function uses `std::sync::Once` to ensure the global thread pool
104/// is only configured once, preventing race conditions and silent failures
105/// that can occur when `build_global()` is called multiple times.
106///
107/// # Arguments
108///
109/// * `max_threads` - Maximum number of threads to use for the thread pool
110///
111/// # Returns
112///
113/// A Result indicating success or an OCRError if configuration fails
114pub fn configure_thread_pool_once(max_threads: usize) -> crate::core::OcrResult<()> {
115    let mut result = Ok(());
116
117    THREAD_POOL_INIT.call_once(|| {
118        debug!(
119            "Configuring global rayon thread pool with {} threads",
120            max_threads
121        );
122        if let Err(e) = rayon::ThreadPoolBuilder::new()
123            .num_threads(max_threads)
124            .build_global()
125        {
126            result = Err(OCRError::config_error(format!(
127                "Failed to configure global thread pool: {e}"
128            )));
129        }
130    });
131
132    result
133}
134
135/// The main OCR pipeline that combines multiple components to perform
136/// document processing and text recognition.
137///
138/// This struct manages the complete OCR pipeline, including document
139/// orientation classification, text detection, text recognition, and
140/// text line classification. It initializes and coordinates all the
141/// required components based on the provided configuration.
142pub struct OAROCR {
143    /// Configuration for the OCR pipeline.
144    config: OAROCRConfig,
145    /// Document orientation classifier (optional).
146    doc_orientation_classifier: Option<DocOrientationClassifier>,
147    /// Document rectifier for unwarping (optional).
148    doc_rectifier: Option<DoctrRectifierPredictor>,
149    /// Text detector for finding text regions (required).
150    text_detector: TextDetPredictor,
151    /// Text line classifier for orientation (optional).
152    text_line_classifier: Option<TextLineClasPredictor>,
153    /// Text recognizer for reading text content (required).
154    text_recognizer: TextRecPredictor,
155    /// Statistics manager for the pipeline execution (thread-safe).
156    stats: StatsManager,
157}
158
159impl OAROCR {
160    /// Creates a new OAROCR instance with the provided configuration.
161    ///
162    /// This method initializes all the required components based on the
163    /// configuration and builds the complete OCR pipeline.
164    ///
165    /// # Arguments
166    ///
167    /// * `config` - The configuration for the OCR pipeline
168    ///
169    /// # Returns
170    ///
171    /// A Result containing the OAROCR instance or an OCRError
172    pub fn new(config: OAROCRConfig) -> crate::core::OcrResult<Self> {
173        info!("Initializing OAROCR pipeline with config: {:?}", config);
174
175        // Configure global rayon thread pool if max_threads is specified
176        if let Some(max_threads) = config.max_threads() {
177            configure_thread_pool_once(max_threads)?;
178        }
179
180        // Initialize optional components first
181        let doc_orientation_classifier = if config.use_doc_orientation_classify {
182            info!("Initializing document orientation classifier");
183            Some(ComponentBuilder::build_doc_orientation_classifier(&config)?)
184        } else {
185            None
186        };
187
188        let doc_rectifier = if config.use_doc_unwarping {
189            info!("Initializing document rectifier");
190            Some(ComponentBuilder::build_doc_rectifier(&config)?)
191        } else {
192            None
193        };
194
195        let text_line_classifier = if config.use_textline_orientation {
196            info!("Initializing text line classifier");
197            Some(ComponentBuilder::build_text_line_classifier(&config)?)
198        } else {
199            None
200        };
201
202        // Initialize required components
203        info!("Initializing text detector");
204        let text_detector = ComponentBuilder::build_text_detector(&config)?;
205
206        info!("Initializing text recognizer");
207        let text_recognizer = ComponentBuilder::build_text_recognizer(&config)?;
208
209        let pipeline = Self {
210            config,
211            doc_orientation_classifier,
212            doc_rectifier,
213            text_detector,
214            text_line_classifier,
215            text_recognizer,
216            stats: StatsManager::new(),
217        };
218
219        info!("OAROCR pipeline initialized successfully");
220        Ok(pipeline)
221    }
222
223    /// Processes one or more images through the OCR pipeline.
224    ///
225    /// This method runs the complete OCR pipeline on either a single image or
226    /// a batch of images, including document orientation classification, text detection,
227    /// text recognition, and text line classification (if enabled).
228    ///
229    /// Multiple images are processed in parallel using rayon for optimal performance.
230    ///
231    /// # Arguments
232    ///
233    /// * `image_paths` - A slice of paths to the image files
234    ///
235    /// # Returns
236    ///
237    /// A Result containing a vector of OAROCRResult or an OCRError
238    /// Processes one or more images already loaded in memory.
239    ///
240    /// Prefer this API when you have RgbImage instances to avoid file I/O.
241    pub fn predict(&self, images: &[RgbImage]) -> crate::core::OcrResult<Vec<OAROCRResult>> {
242        let start_time = std::time::Instant::now();
243
244        info!(
245            "Starting OCR pipeline for {} in-memory image(s)",
246            images.len()
247        );
248
249        let result = self.process_images_from_memory(images);
250
251        // Update statistics based on the result
252        let processing_time = start_time.elapsed();
253        let total_time_ms = processing_time.as_millis() as f64;
254
255        match &result {
256            Ok(results) => {
257                self.update_stats(images.len(), results.len(), 0, total_time_ms);
258            }
259            Err(_) => {
260                self.update_stats(images.len(), 0, images.len(), total_time_ms);
261            }
262        }
263
264        result
265    }
266
267    /// Internal: process in-memory images individually (with parallelism thresholds)
268    fn process_images_from_memory(
269        &self,
270        images: &[RgbImage],
271    ) -> crate::core::OcrResult<Vec<OAROCRResult>> {
272        // Use the new orchestration abstraction
273        let orchestrator = ImageProcessingOrchestrator::new(self);
274
275        // Convert images to indexed inputs
276        let inputs: Vec<(usize, &RgbImage)> = images.iter().enumerate().collect();
277
278        // Use auto strategy based on image threshold
279        let image_threshold = self.config.image_threshold();
280        let strategy = ProcessingStrategy::Auto(image_threshold);
281        let stage_config = PipelineStageConfig::default(); // Full pipeline
282
283        orchestrator.process_batch(inputs, strategy, stage_config)
284    }
285
286    /// Processes a single image from the detection stage onwards.
287    ///
288    /// This method continues processing after detection has already been performed,
289    /// used in dynamic batching scenarios where detection is batched separately.
290    fn process_single_image_from_detection(
291        &self,
292        params: SingleImageProcessingParams,
293    ) -> crate::core::OcrResult<OAROCRResult> {
294        // Destructure parameters
295        let SingleImageProcessingParams {
296            index,
297            input_img_arc,
298            current_img,
299            text_boxes,
300            orientation_angle,
301            rectified_img,
302            image_path,
303        } = params;
304
305        // Stage 4: Text box cropping (can be parallelized)
306        let cropping_config = CroppingConfig::default();
307
308        let cropping_stage_result = CroppingStageProcessor::process_single(
309            &current_img,
310            &text_boxes,
311            Some(&cropping_config),
312        )?;
313
314        let cropped_images = cropping_stage_result.data.cropped_images;
315        let failed_crops = cropping_stage_result.data.failed_crops;
316
317        // Continue with text line orientation and recognition as in the original method
318        // (This is the same logic as in process_single_image from stage 5 onwards)
319
320        // Stage 5: Text line orientation classification
321        let mut text_line_orientations: Vec<Option<f32>> = Vec::new();
322        let mut failed_orientations = 0;
323        if self.config.use_textline_orientation && !text_boxes.is_empty() {
324            if let Some(ref classifier) = self.text_line_classifier {
325                let valid_images: Vec<RgbImage> = cropped_images
326                    .iter()
327                    .filter_map(|o| o.as_ref().cloned())
328                    .collect();
329                let valid_images_count = valid_images.len();
330                if !valid_images.is_empty() {
331                    match classifier.predict(valid_images, None) {
332                        Ok(result) => {
333                            let mut result_idx = 0usize;
334                            for cropped_img_opt in &cropped_images {
335                                if cropped_img_opt.is_some() {
336                                    if let (Some(labels), Some(score_list)) = (
337                                        result.label_names.get(result_idx),
338                                        result.scores.get(result_idx),
339                                    ) {
340                                        if let (Some(label), Some(&score)) =
341                                            (labels.first(), score_list.first())
342                                        {
343                                            let confidence_threshold = self
344                                                .config
345                                                .text_line_orientation_stage
346                                                .as_ref()
347                                                .and_then(|config| config.confidence_threshold);
348
349                                            let orientation_result = parse_text_line_orientation(
350                                                label.as_ref(),
351                                                score,
352                                                confidence_threshold,
353                                            );
354
355                                            if orientation_result.is_confident {
356                                                text_line_orientations
357                                                    .push(Some(orientation_result.angle));
358                                            } else {
359                                                text_line_orientations.push(None);
360                                            }
361                                        } else {
362                                            text_line_orientations.push(None);
363                                        }
364                                    } else {
365                                        text_line_orientations.push(None);
366                                    }
367                                    result_idx += 1;
368                                } else {
369                                    text_line_orientations.push(None);
370                                }
371                            }
372                        }
373                        Err(e) => {
374                            failed_orientations = valid_images_count;
375                            warn!(
376                                "Text line orientation classification failed for {} images: {}",
377                                valid_images_count, e
378                            );
379                            text_line_orientations.resize(text_boxes.len(), None);
380                        }
381                    }
382                } else {
383                    text_line_orientations.resize(text_boxes.len(), None);
384                }
385            } else {
386                text_line_orientations.resize(text_boxes.len(), None);
387            }
388        } else {
389            text_line_orientations.resize(text_boxes.len(), None);
390        }
391
392        // Stage 6: Text recognition (using existing logic)
393        let recognition_config = RecognitionConfig::from_legacy_config(
394            self.config.use_textline_orientation,
395            self.config.aspect_ratio_bucketing.clone(),
396        );
397
398        let recognition_stage_result = RecognitionStageProcessor::process_single(
399            cropped_images,
400            Some(&text_line_orientations),
401            Some(&self.text_recognizer),
402            Some(&recognition_config),
403        )?;
404
405        let rec_texts = recognition_stage_result.data.rec_texts;
406        let rec_scores = recognition_stage_result.data.rec_scores;
407        let failed_recognitions = recognition_stage_result.data.failed_recognitions;
408
409        // Stage 7: Final filtering and result assembly
410        let score_thresh = self.config.recognition.score_thresh.unwrap_or(0.0);
411        let mut final_texts: Vec<Option<Arc<str>>> = Vec::new();
412        let mut final_scores: Vec<Option<f32>> = Vec::new();
413        let mut final_orientations: Vec<Option<f32>> = Vec::new();
414        for ((text, score), orientation) in rec_texts
415            .into_iter()
416            .zip(rec_scores)
417            .zip(text_line_orientations.iter().cloned())
418        {
419            if score >= score_thresh {
420                final_texts.push(Some(text));
421                final_scores.push(Some(score));
422                final_orientations.push(orientation);
423            } else {
424                final_texts.push(None);
425                final_scores.push(None);
426                final_orientations.push(orientation);
427            }
428        }
429
430        // Create error metrics
431        let error_metrics = ErrorMetrics {
432            failed_crops,
433            failed_recognitions,
434            failed_orientations,
435            total_text_boxes: text_boxes.len(),
436        };
437
438        // Create text regions from parallel vectors
439        let text_regions = OAROCRResult::create_text_regions_from_vectors(
440            &text_boxes,
441            &final_texts,
442            &final_scores,
443            &final_orientations,
444        );
445
446        Ok(OAROCRResult {
447            input_path: Arc::from(image_path.to_string_lossy().as_ref()),
448            index,
449            input_img: input_img_arc,
450            text_regions,
451            orientation_angle,
452            rectified_img,
453            error_metrics,
454        })
455    }
456
457    /// Gets the pipeline statistics.
458    ///
459    /// # Returns
460    ///
461    /// A copy of the current PipelineStats
462    pub fn get_stats(&self) -> PipelineStats {
463        self.stats.get_stats()
464    }
465
466    /// Updates the pipeline statistics after processing images.
467    ///
468    /// # Arguments
469    ///
470    /// * `processed_count` - Number of images processed
471    /// * `successful_count` - Number of successful predictions
472    /// * `failed_count` - Number of failed predictions
473    /// * `inference_time_ms` - Total inference time in milliseconds
474    fn update_stats(
475        &self,
476        processed_count: usize,
477        successful_count: usize,
478        failed_count: usize,
479        inference_time_ms: f64,
480    ) {
481        self.stats.update_stats(
482            processed_count,
483            successful_count,
484            failed_count,
485            inference_time_ms,
486        );
487    }
488
489    /// Resets the pipeline statistics.
490    pub fn reset_stats(&self) {
491        self.stats.reset_stats();
492    }
493
494    /// Gets the pipeline configuration.
495    ///
496    /// # Returns
497    ///
498    /// A reference to the OAROCRConfig
499    pub fn get_config(&self) -> &OAROCRConfig {
500        &self.config
501    }
502
503    // Private helper methods for reducing complexity in main processing methods
504    //
505    // The following helper functions were extracted to address the long function problem
506    // identified in the codebase audit. They break down complex operations into focused,
507    // single-responsibility functions with clear contracts.
508    //
509    // Benefits achieved:
510    // - Reduced cognitive load: Each function has a single, clear purpose
511    // - Improved testability: Each helper can be tested in isolation
512    // - Better error handling: Focused error contexts and clear propagation
513    // - Enhanced maintainability: Changes to specific logic are localized
514    // - Easier auditing: Small functions are easier to review and verify
515    //
516    // The main method that benefited most from this refactoring:
517    // - process_images_with_cross_recognition_batching: 301 lines → 28 lines (90% reduction)
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    #[test]
525    fn test_oarocr_builder_text_rec_score_thresh() {
526        // Test that the text_rec_score_thresh method properly sets the score threshold
527        let builder = OAROCRBuilder::new(
528            "dummy_det_path".to_string(),
529            "dummy_rec_path".to_string(),
530            "dummy_dict_path".to_string(),
531        )
532        .text_rec_score_threshold(0.8);
533
534        assert_eq!(builder.get_config().recognition.score_thresh, Some(0.8));
535    }
536
537    #[test]
538    fn test_orchestration_abstraction_imports() {
539        // Test that the orchestration abstractions are properly exported
540        use crate::pipeline::oarocr::{
541            ImageInputSource, ImageProcessingOrchestrator, PipelineExecutor, PipelineStage,
542            PipelineStageConfig, ProcessingStrategy,
543        };
544        use image::RgbImage;
545        use std::path::Path;
546        use std::sync::Arc;
547
548        // Test that we can create instances of the orchestration types
549        let strategy = ProcessingStrategy::Sequential;
550        let config = PipelineStageConfig::default();
551
552        // Test enum variants
553        let _orientation_stage = PipelineStage::Orientation;
554        let _detection_stage = PipelineStage::Detection;
555
556        // Test ImageInputSource variants
557        let path = Path::new("test.jpg");
558        let _path_source = ImageInputSource::Path(path);
559
560        let img = RgbImage::new(100, 100);
561        let _memory_source = ImageInputSource::Memory(&img);
562
563        let img_arc = Arc::new(img);
564        let _loaded_source = ImageInputSource::LoadedWithPath(img_arc, path);
565
566        // Test that the types have the expected properties
567        assert!(!strategy.should_use_parallel(10));
568        assert_eq!(config.start_from, PipelineStage::Orientation);
569
570        // Test that we can reference the orchestrator and executor types
571        // (We can't actually create them without a valid OAROCR instance)
572        let _orchestrator_type = std::any::type_name::<ImageProcessingOrchestrator>();
573        let _executor_type = std::any::type_name::<PipelineExecutor>();
574    }
575
576    #[test]
577    fn test_processing_strategy_behavior() {
578        // Test ProcessingStrategy behavior
579        let sequential = ProcessingStrategy::Sequential;
580        let parallel = ProcessingStrategy::Parallel;
581        let auto_5 = ProcessingStrategy::Auto(5);
582
583        // Sequential should never use parallel
584        assert!(!sequential.should_use_parallel(1));
585        assert!(!sequential.should_use_parallel(100));
586
587        // Parallel should always use parallel
588        assert!(parallel.should_use_parallel(1));
589        assert!(parallel.should_use_parallel(100));
590
591        // Auto should use threshold
592        assert!(!auto_5.should_use_parallel(3));
593        assert!(!auto_5.should_use_parallel(5));
594        assert!(auto_5.should_use_parallel(6));
595        assert!(auto_5.should_use_parallel(10));
596    }
597
598    #[test]
599    fn test_pipeline_stage_config_customization() {
600        let mut config = PipelineStageConfig::default();
601
602        // Test default values
603        assert_eq!(config.start_from, PipelineStage::Orientation);
604        assert!(config.skip_stages.is_empty());
605        assert!(config.custom_params.is_none());
606
607        // Test customization
608        config.start_from = PipelineStage::Detection;
609        config.skip_stages.insert(PipelineStage::Recognition);
610
611        assert_eq!(config.start_from, PipelineStage::Detection);
612        assert!(config.skip_stages.contains(&PipelineStage::Recognition));
613        assert!(!config.skip_stages.contains(&PipelineStage::Orientation));
614    }
615
616    #[test]
617    fn test_oarocr_builder_doc_orientation_confidence_threshold() {
618        // Test that the doc_orientation_confidence_threshold method properly sets the threshold
619        let builder = OAROCRBuilder::new(
620            "dummy_det_path".to_string(),
621            "dummy_rec_path".to_string(),
622            "dummy_dict_path".to_string(),
623        )
624        .doc_orientation_threshold(0.8);
625
626        assert!(builder.get_config().orientation_stage.is_some());
627        assert_eq!(
628            builder
629                .get_config()
630                .orientation_stage
631                .as_ref()
632                .unwrap()
633                .confidence_threshold,
634            Some(0.8)
635        );
636    }
637
638    #[test]
639    fn test_oarocr_builder_textline_orientation_confidence_threshold() {
640        // Test that the textline_orientation_confidence_threshold method properly sets the threshold
641        let builder = OAROCRBuilder::new(
642            "dummy_det_path".to_string(),
643            "dummy_rec_path".to_string(),
644            "dummy_dict_path".to_string(),
645        )
646        .textline_orientation_threshold(0.9);
647
648        assert!(builder.get_config().text_line_orientation_stage.is_some());
649        assert_eq!(
650            builder
651                .get_config()
652                .text_line_orientation_stage
653                .as_ref()
654                .unwrap()
655                .confidence_threshold,
656            Some(0.9)
657        );
658    }
659
660    #[test]
661    fn test_oarocr_result_alignment_preservation() {
662        // Test that OAROCRResult maintains 1:1 correspondence between text_boxes and recognition results
663        use crate::processors::BoundingBox;
664        use crate::processors::Point;
665        use image::RgbImage;
666        use std::sync::Arc;
667
668        // Create mock data
669        let text_boxes = vec![
670            BoundingBox {
671                points: vec![
672                    Point { x: 0.0, y: 0.0 },
673                    Point { x: 10.0, y: 0.0 },
674                    Point { x: 10.0, y: 10.0 },
675                    Point { x: 0.0, y: 10.0 },
676                ],
677            },
678            BoundingBox {
679                points: vec![
680                    Point { x: 20.0, y: 0.0 },
681                    Point { x: 30.0, y: 0.0 },
682                    Point { x: 30.0, y: 10.0 },
683                    Point { x: 20.0, y: 10.0 },
684                ],
685            },
686            BoundingBox {
687                points: vec![
688                    Point { x: 40.0, y: 0.0 },
689                    Point { x: 50.0, y: 0.0 },
690                    Point { x: 50.0, y: 10.0 },
691                    Point { x: 40.0, y: 10.0 },
692                ],
693            },
694        ];
695
696        // Create recognition results where the middle one is filtered out (None)
697        let rec_texts = vec![
698            Some(Arc::from("Hello")),
699            None, // This was filtered out due to low confidence
700            Some(Arc::from("World")),
701        ];
702
703        let rec_scores = vec![
704            Some(0.9),
705            None, // This was filtered out due to low confidence
706            Some(0.8),
707        ];
708
709        // Create text regions from parallel vectors
710        let text_regions = OAROCRResult::create_text_regions_from_vectors(
711            &text_boxes,
712            &rec_texts,
713            &rec_scores,
714            &[None, None, None],
715        );
716
717        let result = OAROCRResult {
718            input_path: Arc::from("test.jpg"),
719            index: 0,
720            input_img: Arc::new(RgbImage::new(100, 100)),
721            text_regions,
722            orientation_angle: None,
723            rectified_img: None,
724            error_metrics: ErrorMetrics::default(),
725        };
726
727        // Verify that the text regions were created correctly
728        assert_eq!(result.text_regions.len(), 3);
729
730        // Verify that we can access text regions with their recognition results
731        for (i, region) in result.text_regions.iter().enumerate() {
732            // Each region should have a bounding box
733            assert!(region.bounding_box.points.len() >= 4);
734
735            match i {
736                0 => {
737                    // First region should have recognition result
738                    assert!(region.text.is_some());
739                    assert!(region.confidence.is_some());
740                    assert_eq!(region.text.as_ref().unwrap().as_ref(), "Hello");
741                    assert_eq!(region.confidence.unwrap(), 0.9);
742                }
743                1 => {
744                    // Second region should have no recognition result (filtered out)
745                    assert!(region.text.is_none());
746                    assert!(region.confidence.is_none());
747                }
748                2 => {
749                    // Third region should have recognition result
750                    assert!(region.text.is_some());
751                    assert!(region.confidence.is_some());
752                    assert_eq!(region.text.as_ref().unwrap().as_ref(), "World");
753                    assert_eq!(region.confidence.unwrap(), 0.8);
754                }
755                _ => panic!("Unexpected index"),
756            }
757        }
758    }
759
760    #[test]
761    fn test_thread_pool_configuration_once() {
762        // Test that thread pool configuration can be called multiple times without error
763        // This tests the fix for the global rayon thread pool race condition
764
765        // First call should succeed
766        let result1 = configure_thread_pool_once(2);
767        assert!(
768            result1.is_ok(),
769            "First thread pool configuration should succeed"
770        );
771
772        // Second call should also succeed (should be ignored due to Once)
773        let result2 = configure_thread_pool_once(4);
774        assert!(
775            result2.is_ok(),
776            "Second thread pool configuration should succeed (ignored)"
777        );
778
779        // Third call with different thread count should also succeed
780        let result3 = configure_thread_pool_once(1);
781        assert!(
782            result3.is_ok(),
783            "Third thread pool configuration should succeed (ignored)"
784        );
785    }
786}