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.
- octantocr-0.1.0/PKG-INFO +76 -0
- octantocr-0.1.0/README.md +41 -0
- octantocr-0.1.0/octantocr/__init__.py +32 -0
- octantocr-0.1.0/octantocr/cli.py +63 -0
- octantocr-0.1.0/octantocr/detector.py +125 -0
- octantocr-0.1.0/octantocr/engine.py +356 -0
- octantocr-0.1.0/octantocr/hocr.py +82 -0
- octantocr-0.1.0/octantocr/layout.py +135 -0
- octantocr-0.1.0/octantocr/models.py +108 -0
- octantocr-0.1.0/octantocr/models_data/FSRCNN_x2.onnx +0 -0
- octantocr-0.1.0/octantocr/models_data/ch_PP-OCRv4_det_infer.onnx +0 -0
- octantocr-0.1.0/octantocr/models_data/ch_PP-OCRv4_rec_infer.onnx +0 -0
- octantocr-0.1.0/octantocr/models_data/en_PP-OCRv3_rec_infer.onnx +0 -0
- octantocr-0.1.0/octantocr/models_data/en_dict.txt +95 -0
- octantocr-0.1.0/octantocr/models_data/ppocr_keys_v1.txt +6623 -0
- octantocr-0.1.0/octantocr/preprocess.py +54 -0
- octantocr-0.1.0/octantocr/recognizer.py +107 -0
- octantocr-0.1.0/octantocr/superres.py +68 -0
- octantocr-0.1.0/octantocr/table.py +108 -0
- octantocr-0.1.0/octantocr.egg-info/PKG-INFO +76 -0
- octantocr-0.1.0/octantocr.egg-info/SOURCES.txt +25 -0
- octantocr-0.1.0/octantocr.egg-info/dependency_links.txt +1 -0
- octantocr-0.1.0/octantocr.egg-info/entry_points.txt +2 -0
- octantocr-0.1.0/octantocr.egg-info/requires.txt +7 -0
- octantocr-0.1.0/octantocr.egg-info/top_level.txt +1 -0
- octantocr-0.1.0/pyproject.toml +67 -0
- octantocr-0.1.0/setup.cfg +4 -0
octantocr-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
)
|