octantocr 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: octantocr
3
+ Version: 0.1.0
4
+ Summary: Offline-first, edge-native OCR framework with structured output, layout analysis, and table retention.
5
+ Author-email: OctantOCR Team <devadarshanoop@gmail.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://devadarshmay-eng.github.io/octantocr/
8
+ Project-URL: Documentation, https://devadarshmay-eng.github.io/octantocr/
9
+ Project-URL: Repository, https://github.com/devadarshmay-eng/octantocr
10
+ Project-URL: Issues, https://github.com/devadarshmay-eng/octantocr/issues
11
+ Project-URL: Changelog, https://github.com/devadarshmay-eng/octantocr/releases
12
+ Keywords: ocr,offline-ocr,edge-native,onnxruntime,document-analysis,table-extraction,dbnet,hocr,layout-analysis
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: Apache Software License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
25
+ Classifier: Topic :: Text Processing :: General
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ Requires-Dist: numpy>=1.22.0
29
+ Requires-Dist: onnxruntime>=1.14.0
30
+ Requires-Dist: opencv-python-headless>=4.5.0
31
+ Requires-Dist: pyclipper>=1.3.0
32
+ Requires-Dist: shapely>=2.0.0
33
+ Requires-Dist: pypdfium2>=4.0.0
34
+ Requires-Dist: Pillow>=9.0.0
35
+
36
+ # OctantOCR (Python)
37
+
38
+ Offline-first, edge-native OCR framework with structured output, layout analysis, and table retention.
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ pip install octantocr
44
+ ```
45
+
46
+ ## Quickstart
47
+
48
+ ```python
49
+ from octantocr import OctantEngine
50
+
51
+ engine = OctantEngine()
52
+ result = engine.scan("document.png")
53
+
54
+ print(f"Recognized text:\n{result.text}")
55
+ print(f"Confidence: {result.confidence:.2f}")
56
+
57
+ # Layout blocks & lines
58
+ for block in result.blocks:
59
+ for line in block.lines:
60
+ print(f"[{line.confidence:.2f}] {line.text} (box: {line.box})")
61
+
62
+ # Extracted tables (if present)
63
+ for table in result.tables:
64
+ print(table.to_markdown())
65
+
66
+ # Export formats
67
+ hocr_str = result.to_hocr()
68
+ json_str = result.to_json()
69
+ ```
70
+
71
+ ## CLI Usage
72
+
73
+ ```bash
74
+ octantocr scan document.png
75
+ octantocr scan invoice.pdf --table --output invoice.json
76
+ ```
@@ -0,0 +1,41 @@
1
+ # OctantOCR (Python)
2
+
3
+ Offline-first, edge-native OCR framework with structured output, layout analysis, and table retention.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install octantocr
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ```python
14
+ from octantocr import OctantEngine
15
+
16
+ engine = OctantEngine()
17
+ result = engine.scan("document.png")
18
+
19
+ print(f"Recognized text:\n{result.text}")
20
+ print(f"Confidence: {result.confidence:.2f}")
21
+
22
+ # Layout blocks & lines
23
+ for block in result.blocks:
24
+ for line in block.lines:
25
+ print(f"[{line.confidence:.2f}] {line.text} (box: {line.box})")
26
+
27
+ # Extracted tables (if present)
28
+ for table in result.tables:
29
+ print(table.to_markdown())
30
+
31
+ # Export formats
32
+ hocr_str = result.to_hocr()
33
+ json_str = result.to_json()
34
+ ```
35
+
36
+ ## CLI Usage
37
+
38
+ ```bash
39
+ octantocr scan document.png
40
+ octantocr scan invoice.pdf --table --output invoice.json
41
+ ```
@@ -0,0 +1,32 @@
1
+ """
2
+ OctantOCR
3
+ Offline-first, edge-native OCR framework with structured output, layout analysis, and table retention.
4
+ """
5
+ from .models import OcrResult, OCRResult, OCRConfig, TextBlock, TextLine, Word, Table, TableCell
6
+ from .engine import OctantEngine, discover_models_dir
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ _default_engine = None
11
+
12
+ def scan(target, lang="eng", **kwargs) -> OcrResult:
13
+ """Convenience scan function using a global singleton engine."""
14
+ global _default_engine
15
+ if _default_engine is None:
16
+ _default_engine = OctantEngine()
17
+ return _default_engine.scan(target, lang=lang, **kwargs)
18
+
19
+ __all__ = [
20
+ "OctantEngine",
21
+ "OcrResult",
22
+ "OCRResult",
23
+ "OCRConfig",
24
+ "TextBlock",
25
+ "TextLine",
26
+ "Word",
27
+ "Table",
28
+ "TableCell",
29
+ "scan",
30
+ "discover_models_dir",
31
+ "__version__",
32
+ ]
@@ -0,0 +1,63 @@
1
+ """
2
+ OctantOCR Command Line Interface (CLI)
3
+ """
4
+ from __future__ import annotations
5
+ import argparse
6
+ import sys
7
+ import os
8
+ from pathlib import Path
9
+ from .engine import OctantEngine
10
+
11
+ def main():
12
+ parser = argparse.ArgumentParser(
13
+ prog="octantocr",
14
+ description="Offline-first, edge-native OCR framework with table extraction and structured output."
15
+ )
16
+ subparsers = parser.add_subparsers(dest="command", required=True)
17
+
18
+ # scan command
19
+ scan_parser = subparsers.add_parser("scan", help="Scan an image or PDF document.")
20
+ scan_parser.add_argument("input", type=str, help="Path to input image or PDF.")
21
+ scan_parser.add_argument("--lang", type=str, default="eng", help="OCR language code (default: 'eng').")
22
+ scan_parser.add_argument("--json", action="store_true", help="Output full structured JSON.")
23
+ scan_parser.add_argument("--hocr", action="store_true", help="Output W3C hOCR XHTML.")
24
+ scan_parser.add_argument("--table", action="store_true", help="Extract and print tabular structures as Markdown.")
25
+ scan_parser.add_argument("--no-deskew", action="store_true", help="Disable automatic skew correction.")
26
+ scan_parser.add_argument("--superres", action="store_true", help="Enable FSRCNN x2 neural super-resolution for blurry crops.")
27
+ scan_parser.add_argument("-o", "--output", type=str, default=None, help="Save result to file.")
28
+
29
+ args = parser.parse_args()
30
+
31
+ if args.command == "scan":
32
+ if not os.path.exists(args.input):
33
+ sys.stderr.write(f"Error: Input file not found: {args.input}\n")
34
+ sys.exit(1)
35
+
36
+ engine = OctantEngine()
37
+ result = engine.scan(
38
+ args.input,
39
+ lang=args.lang,
40
+ deskew=not args.no_deskew,
41
+ extract_tables=args.table or args.json,
42
+ superres=args.superres
43
+ )
44
+
45
+ output_content = ""
46
+ if args.json:
47
+ output_content = result.to_json()
48
+ elif args.hocr:
49
+ output_content = result.to_hocr()
50
+ elif args.table:
51
+ output_content = result.to_markdown()
52
+ else:
53
+ output_content = result.text
54
+
55
+ if args.output:
56
+ with open(args.output, "w", encoding="utf-8") as f:
57
+ f.write(output_content)
58
+ print(f"[OctantOCR] Results written to {args.output} (Latency: {result.latency_ms:.1f}ms, Conf: {result.confidence:.2f})")
59
+ else:
60
+ print(output_content)
61
+
62
+ if __name__ == "__main__":
63
+ main()
@@ -0,0 +1,125 @@
1
+ """
2
+ OctantOCR Text Detection Engine (DBNet ONNX)
3
+ """
4
+ import os
5
+ import cv2
6
+ import numpy as np
7
+ import onnxruntime as ort
8
+ import pyclipper
9
+ from shapely.geometry import Polygon
10
+
11
+ def box_score_fast(bitmap: np.ndarray, _box: np.ndarray) -> float:
12
+ """Calculate mean confidence score strictly inside the polygon contour."""
13
+ h, w = bitmap.shape[:2]
14
+ box = _box.copy()
15
+ xmin = np.clip(np.floor(box[:, 0].min()).astype(int), 0, w - 1)
16
+ xmax = np.clip(np.ceil(box[:, 0].max()).astype(int), 0, w - 1)
17
+ ymin = np.clip(np.floor(box[:, 1].min()).astype(int), 0, h - 1)
18
+ ymax = np.clip(np.ceil(box[:, 1].max()).astype(int), 0, h - 1)
19
+
20
+ if xmax <= xmin or ymax <= ymin:
21
+ return 0.0
22
+
23
+ mask = np.zeros((ymax - ymin + 1, xmax - xmin + 1), dtype=np.uint8)
24
+ box[:, 0] = box[:, 0] - xmin
25
+ box[:, 1] = box[:, 1] - ymin
26
+ cv2.fillPoly(mask, box.reshape(1, -1, 2).astype(np.int32), 1)
27
+ return float(cv2.mean(bitmap[ymin:ymax + 1, xmin:xmax + 1], mask)[0])
28
+
29
+ class OctantDetector:
30
+ def __init__(self, model_path: str, num_threads: int = 4):
31
+ sess_options = ort.SessionOptions()
32
+ sess_options.intra_op_num_threads = num_threads
33
+ sess_options.inter_op_num_threads = num_threads
34
+ sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
35
+
36
+ self.session = ort.InferenceSession(
37
+ model_path,
38
+ sess_options=sess_options,
39
+ providers=['CPUExecutionProvider']
40
+ )
41
+ self.input_name = self.session.get_inputs()[0].name
42
+
43
+ def preprocess(self, img: np.ndarray, max_side_len: int = 1600):
44
+ h, w, _ = img.shape
45
+ scale = max_side_len / float(max(h, w)) if max(h, w) > max_side_len else 1.0
46
+
47
+ target_h = int(round(h * scale / 32) * 32)
48
+ target_w = int(round(w * scale / 32) * 32)
49
+ target_h = max(32, target_h)
50
+ target_w = max(32, target_w)
51
+
52
+ resized = cv2.resize(img, (target_w, target_h))
53
+ ratio_h = target_h / float(h)
54
+ ratio_w = target_w / float(w)
55
+
56
+ norm = resized.astype(np.float32) / 255.0
57
+ norm -= np.array([0.485, 0.456, 0.406], dtype=np.float32)
58
+ norm /= np.array([0.229, 0.224, 0.225], dtype=np.float32)
59
+ norm = norm.transpose((2, 0, 1))
60
+ norm = np.expand_dims(norm, axis=0)
61
+
62
+ return norm, (ratio_h, ratio_w, target_h, target_w)
63
+
64
+ def detect(self, img: np.ndarray, thresh: float = 0.3, box_thresh: float = 0.5, unclip_ratio: float = 1.6, max_side_len: int = 1600):
65
+ h, w, _ = img.shape
66
+ tensor, (ratio_h, ratio_w, target_h, target_w) = self.preprocess(img, max_side_len=max_side_len)
67
+ preds = self.session.run(None, {self.input_name: tensor})[0]
68
+ pred_map = preds[0, 0, :, :]
69
+
70
+ segmentation = pred_map > thresh
71
+ mask = (segmentation * 255).astype(np.uint8)
72
+
73
+ contours, _ = cv2.findContours(mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
74
+ boxes = []
75
+
76
+ for c in contours:
77
+ if cv2.contourArea(c) < 12:
78
+ continue
79
+ rect = cv2.minAreaRect(c)
80
+ (cx, cy), (bw, bh), angle = rect
81
+ if min(bw, bh) < 3:
82
+ continue
83
+
84
+ pts_kernel = cv2.boxPoints(rect)
85
+ score = box_score_fast(pred_map, pts_kernel)
86
+ if score < box_thresh:
87
+ continue
88
+
89
+ poly = c.reshape(-1, 2)
90
+ area = cv2.contourArea(c)
91
+ perimeter = cv2.arcLength(c, True)
92
+ if perimeter == 0:
93
+ continue
94
+ distance = area * unclip_ratio / perimeter
95
+
96
+ offset = pyclipper.PyclipperOffset()
97
+ offset.AddPath(poly, pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON)
98
+ expanded = offset.Execute(distance)
99
+ if not expanded:
100
+ continue
101
+
102
+ expanded_poly = np.array(expanded[0])
103
+ box_rect = cv2.minAreaRect(expanded_poly)
104
+ pts_small = cv2.boxPoints(box_rect)
105
+
106
+ # Scale to original coordinates
107
+ pts = pts_small.copy()
108
+ pts[:, 0] /= ratio_w
109
+ pts[:, 1] /= ratio_h
110
+
111
+ ordered_pts = self.order_points(pts)
112
+ boxes.append((ordered_pts, score))
113
+
114
+ return boxes
115
+
116
+ @staticmethod
117
+ def order_points(pts: np.ndarray) -> np.ndarray:
118
+ rect = np.zeros((4, 2), dtype=np.float32)
119
+ s = pts.sum(axis=1)
120
+ rect[0] = pts[np.argmin(s)] # Top-left
121
+ rect[2] = pts[np.argmax(s)] # Bottom-right
122
+ diff = np.diff(pts, axis=1)
123
+ rect[1] = pts[np.argmin(diff)] # Top-right
124
+ rect[3] = pts[np.argmax(diff)] # Bottom-left
125
+ return rect
@@ -0,0 +1,356 @@
1
+ """
2
+ OctantOCR Primary Engine & Pipeline Controller
3
+ """
4
+ from __future__ import annotations
5
+ import os
6
+ import time
7
+ from pathlib import Path
8
+ from typing import List, Optional, Union, Dict, Any
9
+ import numpy as np
10
+ import cv2
11
+ from PIL import Image
12
+
13
+ from .models import OcrResult, TextBlock, TextLine, Word, Table
14
+ from .detector import OctantDetector
15
+ from .recognizer import OctantRecognizer
16
+ from .layout import get_perspective_crop, sort_reading_order, decompose_line_into_words
17
+ from .preprocess import estimate_skew_angle, deskew_image, enhance_low_quality
18
+ from .superres import SuperResolutionEngine
19
+ from .table import detect_tables
20
+
21
+ def discover_models_dir(custom_path: Optional[str] = None) -> Path:
22
+ """Locate the directory containing OctantOCR ONNX models."""
23
+ if custom_path and os.path.isdir(custom_path):
24
+ return Path(custom_path)
25
+
26
+ env_dir = os.environ.get("OCTANT_MODELS_DIR")
27
+ if env_dir and os.path.isdir(env_dir):
28
+ return Path(env_dir)
29
+
30
+ search_paths = [
31
+ Path(__file__).resolve().parent / "models_data",
32
+ Path(__file__).resolve().parent / "models",
33
+ Path.home() / ".cache" / "octantocr" / "models",
34
+ Path.cwd() / "models",
35
+ Path(__file__).resolve().parent.parent.parent.parent / "models",
36
+ Path(__file__).resolve().parent.parent.parent.parent / "octant-ocr-edge" / "packages" / "recognizer" / "models",
37
+ ]
38
+
39
+ for p in search_paths:
40
+ if p.exists() and (p / "ch_PP-OCRv4_det_infer.onnx").exists():
41
+ return p
42
+
43
+ raise FileNotFoundError(
44
+ "OctantOCR ONNX models not found. Please install the package with bundled weights, specify models_dir, or set OCTANT_MODELS_DIR."
45
+ )
46
+
47
+ class OctantEngine:
48
+ """
49
+ Offline-first, edge-native OCR engine for Python applications.
50
+
51
+ Provides high-speed document text detection, recognition, layout sorting,
52
+ and table retention without cloud dependencies.
53
+ """
54
+
55
+ _detector_instance: Optional[OctantDetector] = None
56
+ _rec_en_instance: Optional[OctantRecognizer] = None
57
+ _rec_multi_instance: Optional[OctantRecognizer] = None
58
+ _sr_instance: Optional[SuperResolutionEngine] = None
59
+
60
+ def __init__(self, models_dir: Optional[Union[str, Path]] = None, num_threads: int = 4):
61
+ self.models_dir = discover_models_dir(str(models_dir) if models_dir else None)
62
+ self.num_threads = num_threads
63
+
64
+ det_path = str(self.models_dir / "ch_PP-OCRv4_det_infer.onnx")
65
+ rec_en_path = str(self.models_dir / "en_PP-OCRv3_rec_infer.onnx")
66
+ keys_en_path = str(self.models_dir / "en_dict.txt")
67
+ rec_multi_path = str(self.models_dir / "ch_PP-OCRv4_rec_infer.onnx")
68
+ keys_multi_path = str(self.models_dir / "ppocr_keys_v1.txt")
69
+
70
+ # Initialize detector singleton
71
+ if OctantEngine._detector_instance is None:
72
+ OctantEngine._detector_instance = OctantDetector(det_path, num_threads=num_threads)
73
+ self.detector = OctantEngine._detector_instance
74
+
75
+ # English recognizer
76
+ if OctantEngine._rec_en_instance is None and os.path.exists(rec_en_path) and os.path.exists(keys_en_path):
77
+ OctantEngine._rec_en_instance = OctantRecognizer(rec_en_path, keys_en_path, num_threads=num_threads)
78
+ self.recognizer_en = OctantEngine._rec_en_instance
79
+
80
+ # Multilingual recognizer
81
+ if OctantEngine._rec_multi_instance is None and os.path.exists(rec_multi_path) and os.path.exists(keys_multi_path):
82
+ OctantEngine._rec_multi_instance = OctantRecognizer(rec_multi_path, keys_multi_path, num_threads=num_threads)
83
+ self.recognizer_multi = OctantEngine._rec_multi_instance
84
+
85
+ # Super-resolution engine
86
+ if OctantEngine._sr_instance is None:
87
+ sr_model = self.models_dir / "FSRCNN_x2.onnx"
88
+ OctantEngine._sr_instance = SuperResolutionEngine(str(sr_model) if sr_model.exists() else None)
89
+ self.sr_engine = OctantEngine._sr_instance
90
+
91
+ def _get_recognizer(self, lang: str) -> OctantRecognizer:
92
+ lang_lower = lang.lower()
93
+ if lang_lower in ("en", "eng", "english") and self.recognizer_en is not None:
94
+ return self.recognizer_en
95
+ if self.recognizer_multi is not None:
96
+ return self.recognizer_multi
97
+ if self.recognizer_en is not None:
98
+ return self.recognizer_en
99
+ raise RuntimeError("No recognition models loaded.")
100
+
101
+ def scan(
102
+ self,
103
+ target: Union[str, Path, np.ndarray, Image.Image],
104
+ lang: str = "eng",
105
+ deskew: bool = True,
106
+ enhance: bool = False,
107
+ extract_tables: bool = True,
108
+ superres: bool = False,
109
+ max_side_len: int = 1600,
110
+ ) -> OcrResult:
111
+ """
112
+ Scan a document (image file, numpy array, PIL Image, or multi-page PDF).
113
+
114
+ Args:
115
+ target: Filepath or image object
116
+ lang: Language code ('eng', 'fra', 'deu', 'multi', etc.)
117
+ deskew: Automatically correct document skew angle
118
+ enhance: Apply adaptive contrast enhancement
119
+ extract_tables: Identify and preserve tabular structures
120
+ superres: Upscale blurry or low-resolution text crops using FSRCNN
121
+ max_side_len: Maximum image dimension for DBNet detection scaling
122
+ """
123
+ t0 = time.perf_counter()
124
+
125
+ # Handle PDF input
126
+ if isinstance(target, (str, Path)):
127
+ path_str = str(target)
128
+ if path_str.lower().endswith(".pdf"):
129
+ return self._scan_pdf(
130
+ path_str,
131
+ lang=lang,
132
+ deskew=deskew,
133
+ enhance=enhance,
134
+ extract_tables=extract_tables,
135
+ superres=superres,
136
+ max_side_len=max_side_len,
137
+ start_time=t0
138
+ )
139
+
140
+ # Handle image input
141
+ img = self._load_image(target)
142
+ result = self._scan_single_image(
143
+ img,
144
+ lang=lang,
145
+ deskew=deskew,
146
+ enhance=enhance,
147
+ extract_tables=extract_tables,
148
+ superres=superres,
149
+ max_side_len=max_side_len,
150
+ )
151
+ result.latency_ms = round((time.perf_counter() - t0) * 1000, 2)
152
+ return result
153
+
154
+ def _load_image(self, target: Union[str, Path, np.ndarray, Image.Image]) -> np.ndarray:
155
+ if isinstance(target, (str, Path)):
156
+ p = str(target)
157
+ if not os.path.exists(p):
158
+ raise FileNotFoundError(f"Image not found: {p}")
159
+ img = cv2.imread(p)
160
+ if img is None:
161
+ raise ValueError(f"Failed to load image from {p}")
162
+ return img
163
+ elif isinstance(target, Image.Image):
164
+ rgb = np.array(target.convert("RGB"))
165
+ return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
166
+ elif isinstance(target, np.ndarray):
167
+ if len(target.shape) == 2:
168
+ return cv2.cvtColor(target, cv2.COLOR_GRAY2BGR)
169
+ return target
170
+ else:
171
+ raise TypeError(f"Unsupported target type: {type(target)}")
172
+
173
+ def _scan_single_image(
174
+ self,
175
+ img: np.ndarray,
176
+ lang: str,
177
+ deskew: bool,
178
+ enhance: bool,
179
+ extract_tables: bool,
180
+ superres: bool,
181
+ max_side_len: int,
182
+ ) -> OcrResult:
183
+ processed_img = img.copy()
184
+
185
+ # 1. Geometric deskew
186
+ if deskew:
187
+ angle = estimate_skew_angle(processed_img)
188
+ if abs(angle) >= 0.5:
189
+ processed_img = deskew_image(processed_img, angle)
190
+
191
+ # 2. Contrast enhancement
192
+ if enhance:
193
+ processed_img = enhance_low_quality(processed_img)
194
+
195
+ h, w = processed_img.shape[:2]
196
+
197
+ # 3. DBNet text detection
198
+ raw_boxes = self.detector.detect(processed_img, max_side_len=max_side_len)
199
+
200
+ if not raw_boxes:
201
+ return OcrResult(
202
+ text="",
203
+ confidence=1.0,
204
+ latency_ms=0.0,
205
+ hardware_tier="standard",
206
+ execution_provider="CPUExecutionProvider",
207
+ model_tier="standard",
208
+ blocks=[],
209
+ tables=[],
210
+ page_count=1
211
+ )
212
+
213
+ # 4. Perspective rectification crops
214
+ valid_crops = []
215
+ valid_box_indices = []
216
+ for idx, (box, score) in enumerate(raw_boxes):
217
+ crop = get_perspective_crop(processed_img, box)
218
+ if crop is not None:
219
+ if superres and self.sr_engine.is_blurry_or_low_res(crop):
220
+ crop = self.sr_engine.upscale(crop)
221
+ valid_crops.append(crop)
222
+ valid_box_indices.append(idx)
223
+
224
+ # 5. Batched recognition
225
+ recognizer = self._get_recognizer(lang)
226
+ rec_results = recognizer.recognize_batch(valid_crops)
227
+
228
+ # Associate recognized text with boxes
229
+ detected_items = []
230
+ for box_idx, (text, conf) in zip(valid_box_indices, rec_results):
231
+ if not text:
232
+ continue
233
+ box, det_score = raw_boxes[box_idx]
234
+ detected_items.append((box, {"text": text, "confidence": conf, "det_score": det_score}))
235
+
236
+ # 6. Natural reading order sorting & column separation
237
+ sorted_items = sort_reading_order(detected_items, page_width=w)
238
+
239
+ # 7. Word bounding box decomposition & block building
240
+ lines: List[TextLine] = []
241
+ full_text_lines = []
242
+ confidences = []
243
+
244
+ for box, data in sorted_items:
245
+ line_text = data["text"]
246
+ line_conf = data["confidence"]
247
+ full_text_lines.append(line_text)
248
+ confidences.append(line_conf)
249
+
250
+ # Decompose words
251
+ raw_words = decompose_line_into_words(box, line_text, line_conf)
252
+ words = [
253
+ Word(
254
+ text=rw["text"],
255
+ box=[[rw["bbox"][0], rw["bbox"][1]], [rw["bbox"][2], rw["bbox"][1]],
256
+ [rw["bbox"][2], rw["bbox"][3]], [rw["bbox"][0], rw["bbox"][3]]],
257
+ confidence=rw["confidence"]
258
+ )
259
+ for rw in raw_words
260
+ ]
261
+
262
+ lines.append(
263
+ TextLine(
264
+ text=line_text,
265
+ box=box.tolist() if isinstance(box, np.ndarray) else box,
266
+ confidence=line_conf,
267
+ words=words
268
+ )
269
+ )
270
+
271
+ block = TextBlock(
272
+ id=1,
273
+ box=[[0, 0], [w, 0], [w, h], [0, h]],
274
+ lines=lines
275
+ )
276
+
277
+ # 8. Table extraction (if requested)
278
+ tables: List[Table] = []
279
+ if extract_tables:
280
+ table_input = [
281
+ {"box": np.array(l.box), "text": l.text} for l in lines
282
+ ]
283
+ tables = detect_tables(processed_img, table_input)
284
+
285
+ overall_conf = float(np.mean(confidences)) if confidences else 1.0
286
+ full_text = "\n".join(full_text_lines)
287
+
288
+ return OcrResult(
289
+ text=full_text,
290
+ confidence=round(overall_conf, 4),
291
+ latency_ms=0.0,
292
+ hardware_tier="standard",
293
+ execution_provider="CPUExecutionProvider",
294
+ model_tier="standard",
295
+ blocks=[block],
296
+ tables=tables,
297
+ page_count=1
298
+ )
299
+
300
+ def _scan_pdf(
301
+ self,
302
+ pdf_path: str,
303
+ lang: str,
304
+ deskew: bool,
305
+ enhance: bool,
306
+ extract_tables: bool,
307
+ superres: bool,
308
+ max_side_len: int,
309
+ start_time: float
310
+ ) -> OcrResult:
311
+ import pypdfium2 as pdfium
312
+
313
+ pdf = pdfium.PdfDocument(pdf_path)
314
+ num_pages = len(pdf)
315
+
316
+ all_text_parts = []
317
+ all_blocks = []
318
+ all_tables = []
319
+ all_confs = []
320
+
321
+ for p_idx in range(num_pages):
322
+ page = pdf[p_idx]
323
+ # Render page to PIL image at 300 DPI
324
+ pil_img = page.render(scale=300 / 72.0).to_pil()
325
+ cv_img = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
326
+
327
+ page_res = self._scan_single_image(
328
+ cv_img,
329
+ lang=lang,
330
+ deskew=deskew,
331
+ enhance=enhance,
332
+ extract_tables=extract_tables,
333
+ superres=superres,
334
+ max_side_len=max_side_len
335
+ )
336
+
337
+ if page_res.text:
338
+ all_text_parts.append(f"--- Page {p_idx + 1} ---\n" + page_res.text)
339
+ all_confs.append(page_res.confidence)
340
+ all_blocks.extend(page_res.blocks)
341
+ all_tables.extend(page_res.tables)
342
+
343
+ total_latency = round((time.perf_counter() - start_time) * 1000, 2)
344
+ mean_conf = float(np.mean(all_confs)) if all_confs else 1.0
345
+
346
+ return OcrResult(
347
+ text="\n\n".join(all_text_parts),
348
+ confidence=round(mean_conf, 4),
349
+ latency_ms=total_latency,
350
+ hardware_tier="standard",
351
+ execution_provider="CPUExecutionProvider",
352
+ model_tier="standard",
353
+ blocks=all_blocks,
354
+ tables=all_tables,
355
+ page_count=num_pages
356
+ )