Coverage for collectra/tasks/ocr.py: 75.32%
77 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 09:56 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 09:56 +0000
1__all__ = ["OCRSuraya", "LineDetectorSurya"]
3from dotenv import load_dotenv
5from collectra.tasks.base import Task
6from collectra.types.images import Image, ImageCrop
7from collectra.types.texts import Text
9load_dotenv()
12class OCRSuraya(Task):
13 def __init__(self, name: str, **kwargs) -> None:
14 """Initialize the task without loading Surya model weights."""
15 super().__init__(name, **kwargs)
16 self.detection_predictor = None
17 self.recognition_predictor = None
19 def _init_predictors(self) -> None:
20 """Load Surya predictors on first use."""
21 if self.detection_predictor is not None and self.recognition_predictor is not None:
22 return
23 from surya.detection import DetectionPredictor
24 from surya.foundation import FoundationPredictor
25 from surya.recognition import RecognitionPredictor
27 self.detection_predictor = DetectionPredictor()
28 foundation_predictor = FoundationPredictor()
29 self.recognition_predictor = RecognitionPredictor(foundation_predictor)
31 def input_type(self) -> type | tuple:
32 """Define the expected input types for this LLM task.
34 Returns:
35 type | tuple: Tuple of str and Path types for text and file inputs.
36 """
37 return Image
39 def output_type(self) -> type | tuple:
40 """Define the expected output types for this LLM task.
42 Returns:
43 type | tuple: String type for generated text outputs.
44 """
45 return Text
47 def run(self, *images: Image) -> list[Text]:
48 """Recognize text in each image and return Text artefacts."""
49 self._init_predictors()
50 results: list[Text] = []
51 for image in images:
52 if not isinstance(image, Image):
53 raise TypeError(f"image must be Image, got {type(image)}")
54 predictions = self.recognition_predictor(
55 [image.pil()], det_predictor=self.detection_predictor
56 )
57 text = "\n".join(line.text for line in predictions[0].text_lines)
58 results.append(Text(name=self.get_output_name(), data=text))
59 return results
62class LineDetectorSurya(Task):
63 def __init__(
64 self,
65 name: str,
66 merge_horizontal: bool = False,
67 min_height: int = 0,
68 **kwargs,
69 ) -> None:
70 """Initialize the task without loading Surya model weights."""
71 super().__init__(
72 name,
73 merge_horizontal=merge_horizontal,
74 min_height=min_height or 0,
75 **kwargs,
76 )
77 self.detection_predictor = None
79 def _init_predictor(self) -> None:
80 """Load the Surya detection predictor on first use."""
81 if self.detection_predictor is not None:
82 return
83 from surya.detection import DetectionPredictor
85 self.detection_predictor = DetectionPredictor()
87 def input_type(self) -> type | tuple:
88 """Define the expected input types for this LLM task.
90 Returns:
91 type | tuple: Tuple of str and Path types for text and file inputs.
92 """
93 return Image
95 def output_type(self) -> type | tuple:
96 """Define the expected output types for this LLM task.
98 Returns:
99 type | tuple: String type for generated text outputs.
100 """
101 return ImageCrop
103 def run(self, *images: Image) -> list[ImageCrop]:
104 """Detect text lines and return their image crops."""
105 self._init_predictor()
106 results: list[ImageCrop] = []
107 for image in images:
108 if not isinstance(image, Image):
109 raise TypeError(f"image must be Image, got {type(image)}")
110 pil_image = image.pil()
111 predictions = self.detection_predictor([pil_image])
113 # Sort bounding boxes vertically
114 bounding_boxes = sorted(
115 [polygon_box.bbox for polygon_box in predictions[0].bboxes],
116 key=lambda bbox: bbox[1],
117 )
119 if len(bounding_boxes) == 0 or (
120 self.min_height and image.height <= self.min_height
121 ):
122 crop = image.make_crop(0.5, 0.5, 1.0, 1.0)
123 results.append(crop)
124 elif self.merge_horizontal:
125 bbox = bounding_boxes[0]
126 top = bbox[1]
127 bottom = bbox[3]
129 for bbox in bounding_boxes:
130 if bbox[1] >= bottom:
131 crop = image.make_crop_bounding_box(
132 0, top, image.width, bottom, min_height=self.min_height
133 )
134 results.append(crop)
135 top = bbox[1]
136 bottom = bbox[3]
137 else:
138 top = min(top, bbox[1])
139 bottom = max(bottom, bbox[3])
141 crop = image.make_crop_bounding_box(0, top, image.width, bottom)
142 results.append(crop)
143 else:
144 for bbox in bounding_boxes:
145 crop = image.make_crop_bounding_box(
146 *bbox, min_height=self.min_height
147 )
148 results.append(crop)
150 return results