From 9a9051bcba22a03a5450d31186a859465324d80c Mon Sep 17 00:00:00 2001 From: Martin Kleine Date: Mon, 1 Dec 2025 01:17:05 +0100 Subject: [PATCH 1/3] feat!: migrate HOCR conversion to internal implementation BREAKING CHANGE: Remove dependency on ocrbridge.core.utils.easyocr_to_hocr and implement HOCR conversion internally. This requires ocrbridge-core>=3.0.0 which no longer provides the easyocr_to_hocr utility function. - Add internal hocr.py module with to_hocr() conversion function - Implement _group_words_into_lines() for line detection - Update engine.py to use internal hocr_utils instead of core utilities - Bump ocrbridge-core dependency to >=3.0.0 This change makes the EasyOCR engine self-contained for HOCR conversion, reducing coupling with ocrbridge-core and allowing independent evolution of the conversion logic. --- pyproject.toml | 2 +- src/ocrbridge/engines/easyocr/engine.py | 6 +- src/ocrbridge/engines/easyocr/hocr.py | 203 ++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 src/ocrbridge/engines/easyocr/hocr.py diff --git a/pyproject.toml b/pyproject.toml index 7f52fa0..493a2a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.10" license = {text = "MIT"} dependencies = [ - "ocrbridge-core>=2.0.0", + "ocrbridge-core>=3.0.0", "easyocr>=1.7.2", "torch>=2.1.0", "pdf2image>=1.17.0", diff --git a/src/ocrbridge/engines/easyocr/engine.py b/src/ocrbridge/engines/easyocr/engine.py index 442696f..86b4f8c 100644 --- a/src/ocrbridge/engines/easyocr/engine.py +++ b/src/ocrbridge/engines/easyocr/engine.py @@ -10,8 +10,8 @@ from ocrbridge.core import OCREngine, OCRProcessingError, UnsupportedFormatError from ocrbridge.core.models import OCREngineParams -from ocrbridge.core.utils import easyocr_to_hocr +from . import hocr as hocr_utils from .models import EasyOCRParams pdf2image = cast(Any, _pdf2image) @@ -299,7 +299,7 @@ def _to_hocr(self, easyocr_results: EasyOCRResults, image_path: Path) -> str: # Use default dimensions if image can't be opened image_width, image_height = 1000, 1000 - # Convert to HOCR using utility from core - hocr_xml = easyocr_to_hocr(easyocr_results, image_width, image_height) + # Convert to HOCR using internal utility + hocr_xml = hocr_utils.to_hocr(easyocr_results, image_width, image_height) return hocr_xml diff --git a/src/ocrbridge/engines/easyocr/hocr.py b/src/ocrbridge/engines/easyocr/hocr.py new file mode 100644 index 0000000..e1f9f7d --- /dev/null +++ b/src/ocrbridge/engines/easyocr/hocr.py @@ -0,0 +1,203 @@ +"""HOCR conversion utilities for EasyOCR engine. + +This module handles the conversion of EasyOCR's native output format +(list of bbox, text, confidence tuples) to the standard HOCR XML format. +""" + +from typing import Sequence, TypedDict + + +Point2D = tuple[float, float] +BBox = tuple[int, int, int, int] + + +class WordData(TypedDict): + """Word data with bounding box and metadata.""" + + text: str + confidence: float + bbox: BBox + y_center: float + height: float + x_min: int + + +class LineData(TypedDict): + """Line data containing grouped words.""" + + bbox: BBox + words: list[WordData] + + +EasyOCRResult = tuple[Sequence[Point2D], str, float] + + +def _group_words_into_lines(easyocr_results: Sequence[EasyOCRResult]) -> list[LineData]: + """Group EasyOCR word detections into lines based on vertical position. + + Args: + easyocr_results: List of (bbox, text, confidence) tuples from EasyOCR + + Returns: + List of line dictionaries, each containing: + - bbox: (x_min, y_min, x_max, y_max) in pixels + - words: List of word dictionaries with text, confidence, bbox + """ + if not easyocr_results: + return [] + + # Convert EasyOCR results to word dictionaries + words: list[WordData] = [] + for result in easyocr_results: + # EasyOCR bbox format: [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] + bbox, text, confidence = result + + # Extract coordinates (convert to min/max format) + x_coords = [point[0] for point in bbox] + y_coords = [point[1] for point in bbox] + x_min, x_max = int(min(x_coords)), int(max(x_coords)) + y_min, y_max = int(min(y_coords)), int(max(y_coords)) + + # Calculate vertical center for line grouping + y_center = (y_min + y_max) / 2 + height = y_max - y_min + + words.append( + { + "text": text, + "confidence": confidence, + "bbox": (x_min, y_min, x_max, y_max), + "y_center": y_center, + "height": height, + "x_min": x_min, + } + ) + + if not words: + return [] + + # Calculate median word height for threshold + heights: list[float] = [w["height"] for w in words] + heights.sort() + median_height = heights[len(heights) // 2] + + # Threshold: words are on same line if y_centers within 50% of median height + line_threshold = median_height * 0.5 + + # Sort words by vertical position (top to bottom) + words.sort(key=lambda w: w["y_center"]) + + # Group words into lines + lines: list[list[WordData]] = [] + current_line_words = [words[0]] + current_y_center = words[0]["y_center"] + + for word in words[1:]: + # Check if word belongs to current line + if abs(word["y_center"] - current_y_center) <= line_threshold: + current_line_words.append(word) + else: + # Start new line + lines.append(current_line_words) + current_line_words = [word] + current_y_center = word["y_center"] + + # Don't forget the last line + if current_line_words: + lines.append(current_line_words) + + # Process each line: sort words left-to-right and calculate bbox + result_lines: list[LineData] = [] + for line_words in lines: + # Sort words left to right + line_words.sort(key=lambda w: w["x_min"]) + + # Calculate line bounding box + line_x_min = min(w["bbox"][0] for w in line_words) + line_y_min = min(w["bbox"][1] for w in line_words) + line_x_max = max(w["bbox"][2] for w in line_words) + line_y_max = max(w["bbox"][3] for w in line_words) + + result_lines.append( + {"bbox": (line_x_min, line_y_min, line_x_max, line_y_max), "words": line_words} + ) + + return result_lines + + +def to_hocr(easyocr_results: Sequence[EasyOCRResult], image_width: int, image_height: int) -> str: + """Convert EasyOCR results to HOCR XML format with hierarchical structure. + + EasyOCR output format: [([[x1,y1], [x2,y2], [x3,y3], [x4,y4]], text, confidence), ...] + HOCR format: XML with bbox coordinates and confidence (x_wconf) + Creates proper hOCR structure: ocr_page → ocr_line → ocrx_word + + Args: + easyocr_results: List of (bbox, text, confidence) tuples from EasyOCR + image_width: Image width in pixels + image_height: Image height in pixels + + Returns: + HOCR XML string with recognized text and bounding boxes in hierarchical structure + """ + # Build HOCR XML structure + hocr_lines = [ + '', + '', + '', + "", + ' ', + ' ', + ' ', + "", + "", + f'
', + ] + + # Group words into lines + lines = _group_words_into_lines(easyocr_results) + + # Add each line with its words + word_counter = 1 + for line_idx, line_data in enumerate(lines, start=1): + line_bbox = line_data["bbox"] + + # Create line element + hocr_lines.append( + f' ' + ) + + # Add words to this line + for word_data in line_data["words"]: + # 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("'", "'") + ) + + # Create HOCR word element + word_bbox = word_data["bbox"] + hocr_lines.append( + f' ' + f"{escaped_text}" + ) + + word_counter += 1 + + # Close line element + hocr_lines.append(" ") + + # Close HOCR structure + hocr_lines.extend(["
", "", ""]) + + return "\n".join(hocr_lines) From a96388a3df92f91404c13bda64831b906ce74d8a Mon Sep 17 00:00:00 2001 From: Martin Kleine Date: Mon, 1 Dec 2025 01:23:12 +0100 Subject: [PATCH 2/3] chore: remove unnecessary blank line in hocr.py --- src/ocrbridge/engines/easyocr/hocr.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ocrbridge/engines/easyocr/hocr.py b/src/ocrbridge/engines/easyocr/hocr.py index e1f9f7d..b272b07 100644 --- a/src/ocrbridge/engines/easyocr/hocr.py +++ b/src/ocrbridge/engines/easyocr/hocr.py @@ -6,7 +6,6 @@ from typing import Sequence, TypedDict - Point2D = tuple[float, float] BBox = tuple[int, int, int, int] From d3e4bc047ee6a9534551adbc1eac3e33e0f287dd Mon Sep 17 00:00:00 2001 From: Martin Kleine Date: Mon, 1 Dec 2025 02:19:35 +0100 Subject: [PATCH 3/3] test: update mock path for HOCR conversion tests --- tests/test_engine_unit.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_engine_unit.py b/tests/test_engine_unit.py index e34ee2a..161a0ce 100644 --- a/tests/test_engine_unit.py +++ b/tests/test_engine_unit.py @@ -376,7 +376,7 @@ def test_to_hocr_with_valid_image(self, mocker: Any, tmp_path: Path) -> None: # Mock easyocr_to_hocr from core mock_converter = mocker.patch( - "ocrbridge.engines.easyocr.engine.easyocr_to_hocr", + "ocrbridge.engines.easyocr.hocr.to_hocr", return_value="converted", ) @@ -395,7 +395,7 @@ def test_to_hocr_with_invalid_image_uses_defaults(self, mocker: Any) -> None: # Mock easyocr_to_hocr from core mock_converter = mocker.patch( - "ocrbridge.engines.easyocr.engine.easyocr_to_hocr", + "ocrbridge.engines.easyocr.hocr.to_hocr", return_value="converted", )