diff --git a/src/ocrbridge/engines/easyocr/engine.py b/src/ocrbridge/engines/easyocr/engine.py index bbb83fc..048d875 100644 --- a/src/ocrbridge/engines/easyocr/engine.py +++ b/src/ocrbridge/engines/easyocr/engine.py @@ -59,6 +59,16 @@ class EasyOCREngine(OCREngine): Uses deep learning models for multilingual OCR with automatic GPU acceleration. GPU is automatically detected and used when available, with graceful fallback to CPU. Supports 80+ languages with superior accuracy for Asian scripts. + + Thread Safety: + This engine is NOT thread-safe. The internal EasyOCR Reader instance is shared + and reused across calls for performance. Concurrent calls to process() from + multiple threads may cause race conditions or undefined behavior. + + For thread-safe usage, either: + - Use a separate EasyOCREngine instance per thread + - Serialize access to process() using external locking + - Use the ocr-service which handles concurrency via async/await with to_thread() """ def __init__(self): @@ -149,6 +159,19 @@ def process(self, file_path: Path, params: OCREngineParams | None = None) -> str # Create or recreate reader if languages changed if self.reader is None or self._current_languages != easyocr_params.languages: + # Release old reader to free GPU memory before creating new one + if self.reader is not None: + del self.reader + self.reader = None + # Clear GPU cache if available + try: + import torch + + if torch.cuda.is_available(): + torch.cuda.empty_cache() + except ImportError: + pass + self.reader = self._create_reader(easyocr_params.languages) self._current_languages = easyocr_params.languages @@ -223,17 +246,13 @@ def _process_pdf(self, pdf_path: Path, params: EasyOCRParams) -> str: ) # Convert results to HOCR for this page - # Save image temporarily to get dimensions - with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp_file: - temp_path = Path(tmp_file.name) + # Save image temporarily to get dimensions using secure TemporaryDirectory + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) / "page.png" image.save(temp_path, format="PNG") - - try: page_hocr = self._to_hocr(results, temp_path) page_hocr_list.append(page_hocr) - finally: - # Clean up temp file - temp_path.unlink(missing_ok=True) + # Cleanup happens automatically when context manager exits # Merge all pages into single HOCR document if len(page_hocr_list) == 1: diff --git a/src/ocrbridge/engines/easyocr/hocr.py b/src/ocrbridge/engines/easyocr/hocr.py index b272b07..303ecd2 100644 --- a/src/ocrbridge/engines/easyocr/hocr.py +++ b/src/ocrbridge/engines/easyocr/hocr.py @@ -5,6 +5,7 @@ """ from typing import Sequence, TypedDict +from xml.sax.saxutils import escape as xml_escape Point2D = tuple[float, float] BBox = tuple[int, int, int, int] @@ -80,8 +81,13 @@ def _group_words_into_lines(easyocr_results: Sequence[EasyOCRResult]) -> list[Li heights.sort() median_height = heights[len(heights) // 2] + # Guard against zero height (edge case with certain image types) + if median_height == 0: + median_height = 1.0 # Use minimum default + # Threshold: words are on same line if y_centers within 50% of median height - line_threshold = median_height * 0.5 + # Use max to ensure a minimum threshold for very small text + line_threshold = max(1.0, median_height * 0.5) # Sort words by vertical position (top to bottom) words.sort(key=lambda w: w["y_center"]) @@ -172,14 +178,9 @@ def to_hocr(easyocr_results: Sequence[EasyOCRResult], image_width: int, image_he # Convert confidence (0.0-1.0) to percentage (0-100) conf_percent = int(word_data["confidence"] * 100) - # Escape text for XML - escaped_text = ( - word_data["text"] - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace('"', """) - .replace("'", "'") + # Escape text for XML using stdlib (more robust than manual replacement) + escaped_text = xml_escape( + word_data["text"], {'"': """, "'": "'"} ) # Create HOCR word element