openmlkitOCR 1.0.0__py3-none-any.whl
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.
- openmlkit/__init__.py +9 -0
- openmlkit/detector.py +111 -0
- openmlkit/labelmap.py +105 -0
- openmlkit/pipeline.py +486 -0
- openmlkit/recognizer.py +108 -0
- openmlkitocr-1.0.0.dist-info/METADATA +88 -0
- openmlkitocr-1.0.0.dist-info/RECORD +10 -0
- openmlkitocr-1.0.0.dist-info/WHEEL +5 -0
- openmlkitocr-1.0.0.dist-info/licenses/LICENSE +176 -0
- openmlkitocr-1.0.0.dist-info/top_level.txt +1 -0
openmlkit/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# openmlkit/__init__.py
|
|
2
|
+
|
|
3
|
+
from .labelmap import LabelMap
|
|
4
|
+
from .detector import TextDetector
|
|
5
|
+
from .recognizer import TextRecognizer
|
|
6
|
+
from .pipeline import OpenMLKitOCR
|
|
7
|
+
|
|
8
|
+
__version__ = "1.0.0"
|
|
9
|
+
__all__ = ["OpenMLKitOCR", "TextDetector", "TextRecognizer", "LabelMap"]
|
openmlkit/detector.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# openmlkit/detector.py
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import cv2
|
|
5
|
+
import tflite_runtime.interpreter as tflite
|
|
6
|
+
|
|
7
|
+
class TextDetector:
|
|
8
|
+
def __init__(self, model_path):
|
|
9
|
+
self.interpreter = tflite.Interpreter(model_path=model_path)
|
|
10
|
+
self.interpreter.allocate_tensors()
|
|
11
|
+
self.input_details = self.interpreter.get_input_details()
|
|
12
|
+
self.output_details = self.interpreter.get_output_details()
|
|
13
|
+
|
|
14
|
+
def detect_raw(self, img_gray_256):
|
|
15
|
+
"""
|
|
16
|
+
Runs the text detector model on a 256x256 grayscale image.
|
|
17
|
+
Returns:
|
|
18
|
+
cls_probs: [16, 16] probability map
|
|
19
|
+
dequantized: [1, 16, 16, 4] bounding box regression offsets
|
|
20
|
+
"""
|
|
21
|
+
if img_gray_256.shape != (256, 256):
|
|
22
|
+
img_gray_256 = cv2.resize(img_gray_256, (256, 256), interpolation=cv2.INTER_LINEAR)
|
|
23
|
+
|
|
24
|
+
input_data = img_gray_256.reshape((1, 256, 256, 1))
|
|
25
|
+
self.interpreter.set_tensor(self.input_details[0]['index'], input_data)
|
|
26
|
+
self.interpreter.invoke()
|
|
27
|
+
|
|
28
|
+
output_data = self.interpreter.get_tensor(self.output_details[0]['index'])
|
|
29
|
+
|
|
30
|
+
# Dequantize output
|
|
31
|
+
scale, zero_point = self.output_details[0]['quantization']
|
|
32
|
+
dequantized = (output_data.astype(np.float32) - zero_point) * scale
|
|
33
|
+
|
|
34
|
+
cls_logits = dequantized[0, :, :, 0]
|
|
35
|
+
cls_probs = 1.0 / (1.0 + np.exp(-cls_logits))
|
|
36
|
+
|
|
37
|
+
return cls_probs, dequantized
|
|
38
|
+
|
|
39
|
+
def detect(self, img_gray, score_threshold=0.35):
|
|
40
|
+
"""
|
|
41
|
+
Detects text regions in a grayscale image (256x256 shape expected).
|
|
42
|
+
Returns a list of bounding boxes [x_min, y_min, x_max, y_max] in normalized (0 to 256) scale.
|
|
43
|
+
"""
|
|
44
|
+
cls_probs, dequantized = self.detect_raw(img_gray)
|
|
45
|
+
|
|
46
|
+
# Step 1: Decode local boxes for active cells
|
|
47
|
+
local_boxes = []
|
|
48
|
+
for y in range(16):
|
|
49
|
+
for x in range(16):
|
|
50
|
+
prob = cls_probs[y, x]
|
|
51
|
+
if prob > score_threshold:
|
|
52
|
+
vals = dequantized[0, y, x, :]
|
|
53
|
+
cy = y * 16 + 8
|
|
54
|
+
cx = x * 16 + 8
|
|
55
|
+
|
|
56
|
+
y_center = cy + vals[2] * 16
|
|
57
|
+
# Force a tight, fixed height of 14 pixels in 256 space for text lines
|
|
58
|
+
h = 14.0
|
|
59
|
+
y_min = y_center - h / 2
|
|
60
|
+
y_max = y_center + h / 2
|
|
61
|
+
|
|
62
|
+
x_min = cx + vals[3] * 16
|
|
63
|
+
x_max = cx + vals[1] * 16
|
|
64
|
+
|
|
65
|
+
local_boxes.append({
|
|
66
|
+
'x_min': x_min, 'x_max': x_max,
|
|
67
|
+
'y_min': y_min, 'y_max': y_max,
|
|
68
|
+
'y_center': y_center, 'h': h
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
# Step 2: Group local boxes into lines
|
|
72
|
+
line_groups = []
|
|
73
|
+
for box in local_boxes:
|
|
74
|
+
merged = False
|
|
75
|
+
for group in line_groups:
|
|
76
|
+
group_y_centers = [b['y_center'] for b in group]
|
|
77
|
+
avg_y_center = np.mean(group_y_centers)
|
|
78
|
+
|
|
79
|
+
# Check horizontal proximity
|
|
80
|
+
horiz_close = False
|
|
81
|
+
for g_box in group:
|
|
82
|
+
dist = max(0, box['x_min'] - g_box['x_max'], g_box['x_min'] - box['x_max'])
|
|
83
|
+
if dist < 24: # max horizontal gap in 256 space
|
|
84
|
+
horiz_close = True
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
if abs(box['y_center'] - avg_y_center) < 5.0 and horiz_close:
|
|
88
|
+
group.append(box)
|
|
89
|
+
merged = True
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
if not merged:
|
|
93
|
+
line_groups.append([box])
|
|
94
|
+
|
|
95
|
+
# Step 3: Compute final bounding boxes in normalized 0-256 scale
|
|
96
|
+
boxes = []
|
|
97
|
+
for group in line_groups:
|
|
98
|
+
min_x = min(b['x_min'] for b in group)
|
|
99
|
+
max_x = max(b['x_max'] for b in group)
|
|
100
|
+
mean_y_min = np.mean([b['y_min'] for b in group])
|
|
101
|
+
mean_y_max = np.mean([b['y_max'] for b in group])
|
|
102
|
+
|
|
103
|
+
# Pad horizontally slightly to prevent character clipping (pad by 8 pixels)
|
|
104
|
+
min_x = max(0, min_x - 8)
|
|
105
|
+
max_x = min(256, max_x + 8)
|
|
106
|
+
|
|
107
|
+
boxes.append([min_x, mean_y_min, max_x, mean_y_max])
|
|
108
|
+
|
|
109
|
+
# Sort boxes top-to-bottom
|
|
110
|
+
boxes.sort(key=lambda b: b[1])
|
|
111
|
+
return boxes
|
openmlkit/labelmap.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# openmlkit/labelmap.py
|
|
2
|
+
|
|
3
|
+
class LabelMap:
|
|
4
|
+
def __init__(self, pb_path):
|
|
5
|
+
self.mapping = self._parse_pb(pb_path)
|
|
6
|
+
|
|
7
|
+
def _skip_field(self, data, idx, wire_type):
|
|
8
|
+
if wire_type == 0: # Varint
|
|
9
|
+
while True:
|
|
10
|
+
b = data[idx]
|
|
11
|
+
idx += 1
|
|
12
|
+
if not (b & 0x80):
|
|
13
|
+
break
|
|
14
|
+
elif wire_type == 1: # 64-bit
|
|
15
|
+
idx += 8
|
|
16
|
+
elif wire_type == 2: # Length-delimited
|
|
17
|
+
flen = 0
|
|
18
|
+
shift = 0
|
|
19
|
+
while True:
|
|
20
|
+
b = data[idx]
|
|
21
|
+
idx += 1
|
|
22
|
+
flen |= (b & 0x7f) << shift
|
|
23
|
+
if not (b & 0x80):
|
|
24
|
+
break
|
|
25
|
+
shift += 7
|
|
26
|
+
idx += flen
|
|
27
|
+
elif wire_type == 5: # 32-bit
|
|
28
|
+
idx += 4
|
|
29
|
+
return idx
|
|
30
|
+
|
|
31
|
+
def _parse_pb(self, path):
|
|
32
|
+
with open(path, 'rb') as f:
|
|
33
|
+
data = f.read()
|
|
34
|
+
|
|
35
|
+
idx = 0
|
|
36
|
+
total_len = len(data)
|
|
37
|
+
label_map = {}
|
|
38
|
+
|
|
39
|
+
while idx < total_len:
|
|
40
|
+
tag_byte = data[idx]
|
|
41
|
+
tag = tag_byte >> 3
|
|
42
|
+
wire = tag_byte & 0x07
|
|
43
|
+
idx += 1
|
|
44
|
+
|
|
45
|
+
# Read varint value (used for length or integer value)
|
|
46
|
+
val = 0
|
|
47
|
+
shift = 0
|
|
48
|
+
while True:
|
|
49
|
+
b = data[idx]
|
|
50
|
+
idx += 1
|
|
51
|
+
val |= (b & 0x7f) << shift
|
|
52
|
+
if not (b & 0x80):
|
|
53
|
+
break
|
|
54
|
+
shift += 7
|
|
55
|
+
|
|
56
|
+
if tag == 1 and wire == 2:
|
|
57
|
+
# Length-delimited Entry message
|
|
58
|
+
end_idx = idx + val
|
|
59
|
+
char_str = ""
|
|
60
|
+
class_idx = 0
|
|
61
|
+
while idx < end_idx:
|
|
62
|
+
inner_tag_byte = data[idx]
|
|
63
|
+
inner_tag = inner_tag_byte >> 3
|
|
64
|
+
inner_wire = inner_tag_byte & 0x07
|
|
65
|
+
idx += 1
|
|
66
|
+
|
|
67
|
+
if inner_tag == 1 and inner_wire == 2:
|
|
68
|
+
char_len = 0
|
|
69
|
+
shift = 0
|
|
70
|
+
while True:
|
|
71
|
+
b = data[idx]
|
|
72
|
+
idx += 1
|
|
73
|
+
char_len |= (b & 0x7f) << shift
|
|
74
|
+
if not (b & 0x80):
|
|
75
|
+
break
|
|
76
|
+
shift += 7
|
|
77
|
+
char_str = data[idx:idx+char_len].decode('utf-8', errors='replace')
|
|
78
|
+
idx += char_len
|
|
79
|
+
elif inner_tag == 2 and inner_wire == 0:
|
|
80
|
+
class_val = 0
|
|
81
|
+
shift = 0
|
|
82
|
+
while True:
|
|
83
|
+
b = data[idx]
|
|
84
|
+
idx += 1
|
|
85
|
+
class_val |= (b & 0x7f) << shift
|
|
86
|
+
if not (b & 0x80):
|
|
87
|
+
break
|
|
88
|
+
shift += 7
|
|
89
|
+
class_idx = class_val
|
|
90
|
+
else:
|
|
91
|
+
idx = self._skip_field(data, idx, inner_wire)
|
|
92
|
+
label_map[class_idx] = char_str
|
|
93
|
+
else:
|
|
94
|
+
# Skip other top-level tags
|
|
95
|
+
if wire == 1:
|
|
96
|
+
idx += 8
|
|
97
|
+
elif wire == 2:
|
|
98
|
+
idx += val
|
|
99
|
+
elif wire == 5:
|
|
100
|
+
idx += 4
|
|
101
|
+
|
|
102
|
+
return label_map
|
|
103
|
+
|
|
104
|
+
def get(self, index, default=""):
|
|
105
|
+
return self.mapping.get(index, default)
|
openmlkit/pipeline.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
# openmlkit/pipeline.py
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import cv2
|
|
5
|
+
import numpy as np
|
|
6
|
+
from .labelmap import LabelMap
|
|
7
|
+
from .detector import TextDetector
|
|
8
|
+
from .recognizer import TextRecognizer
|
|
9
|
+
|
|
10
|
+
class OpenMLKitOCR:
|
|
11
|
+
def _ensure_model(self, models_dir, relative_path):
|
|
12
|
+
"""
|
|
13
|
+
Checks if the model file exists locally. If not, attempts to download it
|
|
14
|
+
from Hugging Face repository configured by the environment variable
|
|
15
|
+
'OPENMLKIT_MODEL_REPO' (defaults to '0cve0/OpenMLKitOCR').
|
|
16
|
+
"""
|
|
17
|
+
local_path = os.path.join(models_dir, relative_path)
|
|
18
|
+
if os.path.exists(local_path):
|
|
19
|
+
return local_path
|
|
20
|
+
|
|
21
|
+
repo_id = os.environ.get("OPENMLKIT_MODEL_REPO", "0cve0/OpenMLKitOCR")
|
|
22
|
+
print(f"Model file '{relative_path}' not found locally at {local_path}. Downloading from Hugging Face ({repo_id})...")
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from huggingface_hub import hf_hub_download
|
|
26
|
+
# hf_hub_download supports subdirectories in filename
|
|
27
|
+
cached_path = hf_hub_download(repo_id=repo_id, filename=relative_path)
|
|
28
|
+
return cached_path
|
|
29
|
+
except Exception as e:
|
|
30
|
+
# Fallback to urllib.request
|
|
31
|
+
import urllib.request
|
|
32
|
+
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
|
33
|
+
url = f"https://huggingface.co/{repo_id}/resolve/main/{relative_path}"
|
|
34
|
+
try:
|
|
35
|
+
print(f"Downloading {url} to {local_path}...")
|
|
36
|
+
urllib.request.urlretrieve(url, local_path)
|
|
37
|
+
return local_path
|
|
38
|
+
except Exception as download_error:
|
|
39
|
+
raise FileNotFoundError(
|
|
40
|
+
f"Required model file not found: {relative_path} at {local_path} and failed to download from {url}.\n"
|
|
41
|
+
f"Error: {download_error}\n"
|
|
42
|
+
f"Please verify internet connection or place the model file manually."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def __init__(self, models_dir=None, lang='en'):
|
|
46
|
+
"""
|
|
47
|
+
Initializes the OCR pipeline.
|
|
48
|
+
lang: 'en' (Latin) or 'ru' (Cyrillic).
|
|
49
|
+
If models_dir is None, it defaults to the 'models' subdirectory inside this package.
|
|
50
|
+
"""
|
|
51
|
+
if models_dir is None:
|
|
52
|
+
models_dir = os.path.join(os.path.dirname(__file__), 'models')
|
|
53
|
+
|
|
54
|
+
det_rel = 'detector/rpn_detector.tflite'
|
|
55
|
+
|
|
56
|
+
if lang == 'ru':
|
|
57
|
+
rec_rel = 'ru/recognizer_cyrl.tflite'
|
|
58
|
+
labelmap_rel = 'ru/LabelMap_cyrl.pb'
|
|
59
|
+
elif lang == 'zh':
|
|
60
|
+
rec_rel = 'zh/recognizer_hani.tflite'
|
|
61
|
+
labelmap_rel = 'zh/recognizer_hani_label_map.pb'
|
|
62
|
+
elif lang == 'ko':
|
|
63
|
+
rec_rel = 'ko/recognizer_kore.tflite'
|
|
64
|
+
labelmap_rel = 'ko/recognizer_kore_label_map.pb'
|
|
65
|
+
elif lang == 'ja':
|
|
66
|
+
rec_rel = 'ja/recognizer_jpan.tflite'
|
|
67
|
+
labelmap_rel = 'ja/recognizer_jpan_label_map.pb'
|
|
68
|
+
elif lang == 'ar':
|
|
69
|
+
rec_rel = 'ar/recognizer_arab_retrained.tflite'
|
|
70
|
+
labelmap_rel = 'ar/recognizer_arab_label_map.pb'
|
|
71
|
+
elif lang == 'he':
|
|
72
|
+
rec_rel = 'he/hebr.tflite'
|
|
73
|
+
labelmap_rel = 'he/hebr_label_map.pb'
|
|
74
|
+
elif lang == 'th':
|
|
75
|
+
rec_rel = 'th/recognizer_thai.tflite'
|
|
76
|
+
labelmap_rel = 'th/recognizer_thai_label_map.pb'
|
|
77
|
+
elif lang == 'ka':
|
|
78
|
+
rec_rel = 'ka/geor.tflite'
|
|
79
|
+
labelmap_rel = 'ka/geor_label_map.pb'
|
|
80
|
+
elif lang == 'bn':
|
|
81
|
+
rec_rel = 'bn/bede.tflite'
|
|
82
|
+
labelmap_rel = 'bn/bede_label_map.pb'
|
|
83
|
+
elif lang == 'ta':
|
|
84
|
+
rec_rel = 'ta/recognizer_taml.tflite'
|
|
85
|
+
labelmap_rel = 'ta/recognizer_taml_label_map.pb'
|
|
86
|
+
elif lang == 'te':
|
|
87
|
+
rec_rel = 'te/recognizer_telu.tflite'
|
|
88
|
+
labelmap_rel = 'te/recognizer_telu_label_map.pb'
|
|
89
|
+
elif lang == 'kn':
|
|
90
|
+
rec_rel = 'kn/recognizer_knda.tflite'
|
|
91
|
+
labelmap_rel = 'kn/recognizer_knda_label_map.pb'
|
|
92
|
+
elif lang == 'ml':
|
|
93
|
+
rec_rel = 'ml/recognizer_mlym.tflite'
|
|
94
|
+
labelmap_rel = 'ml/recognizer_mlym_label_map.pb'
|
|
95
|
+
elif lang == 'gu':
|
|
96
|
+
rec_rel = 'gu/gocr_tflite_recognizer_gujr.tflite'
|
|
97
|
+
labelmap_rel = 'gu/gocr_tflite_recognizer_gujr_label_map.pb'
|
|
98
|
+
elif lang in ('en_translate', 'latn_vi'):
|
|
99
|
+
rec_rel = 'vi/gocr_tflite_recognizer_latn_vi.tflite'
|
|
100
|
+
labelmap_rel = 'vi/gocr_tflite_recognizer_latn_vi_label_map.pb'
|
|
101
|
+
else:
|
|
102
|
+
rec_rel = 'en/line_recognizer.fb'
|
|
103
|
+
labelmap_rel = 'en/LabelMap.pb'
|
|
104
|
+
|
|
105
|
+
det_model = self._ensure_model(models_dir, det_rel)
|
|
106
|
+
rec_model = self._ensure_model(models_dir, rec_rel)
|
|
107
|
+
labelmap_path = self._ensure_model(models_dir, labelmap_rel)
|
|
108
|
+
|
|
109
|
+
self.label_map = LabelMap(labelmap_path)
|
|
110
|
+
self.detector = TextDetector(det_model)
|
|
111
|
+
self.recognizer = TextRecognizer(rec_model, self.label_map)
|
|
112
|
+
|
|
113
|
+
# Configure stitching parameters based on script characteristics
|
|
114
|
+
self.lang = lang
|
|
115
|
+
if lang in ('zh', 'ja', 'ko'):
|
|
116
|
+
self.min_match_len = 2
|
|
117
|
+
self.max_scan = 12
|
|
118
|
+
self.max_off = 3
|
|
119
|
+
else: # Alphabetic scripts (Latin, Cyrillic, Arabic …)
|
|
120
|
+
self.min_match_len = 3
|
|
121
|
+
self.max_scan = 22
|
|
122
|
+
self.max_off = 6
|
|
123
|
+
|
|
124
|
+
def _merge_overlapping_texts(self, t1, t2):
|
|
125
|
+
"""Merge two overlapping OCR chunk results using fuzzy suffix-prefix alignment.
|
|
126
|
+
|
|
127
|
+
The model often produces small errors at chunk edges (wrong case, extra
|
|
128
|
+
or missing character, boundary artefact). This method tolerates up to
|
|
129
|
+
``max(1, L // 5)`` substitutions over an overlap window of length L and
|
|
130
|
+
also allows a small positional *offset* so the overlap does not have to
|
|
131
|
+
start at the very first character of t2.
|
|
132
|
+
|
|
133
|
+
Strategy (tried in order):
|
|
134
|
+
1. Exact suffix-of-t1 / prefix-of-t2 character match.
|
|
135
|
+
2. Exact word-level suffix-of-t1 / prefix-of-t2 match.
|
|
136
|
+
3. Best fuzzy alignment (vary overlap length L, offset in t1 tail and
|
|
137
|
+
t2 head) scored by matches - penalty(errors) - penalty(offset).
|
|
138
|
+
4. Fallback: concatenate with a space.
|
|
139
|
+
"""
|
|
140
|
+
t1 = t1.strip()
|
|
141
|
+
t2 = t2.strip()
|
|
142
|
+
|
|
143
|
+
if not t1:
|
|
144
|
+
return t2
|
|
145
|
+
if not t2:
|
|
146
|
+
return t1
|
|
147
|
+
|
|
148
|
+
max_scan = self.max_scan
|
|
149
|
+
min_match = self.min_match_len
|
|
150
|
+
max_off = self.max_off
|
|
151
|
+
|
|
152
|
+
# 1. Exact character suffix-prefix match
|
|
153
|
+
for L in range(min(len(t1), len(t2)), min_match - 1, -1):
|
|
154
|
+
if t1[-L:] == t2[:L]:
|
|
155
|
+
return t1 + t2[L:]
|
|
156
|
+
|
|
157
|
+
# 2. Exact word-level suffix-prefix match
|
|
158
|
+
w1 = t1.split()
|
|
159
|
+
w2 = t2.split()
|
|
160
|
+
for i in range(min(len(w1), len(w2)), 0, -1):
|
|
161
|
+
if w1[-i:] == w2[:i]:
|
|
162
|
+
return " ".join(w1[:-i] + w2)
|
|
163
|
+
|
|
164
|
+
# 3. Fuzzy alignment: scan over overlap length L and small offsets
|
|
165
|
+
best_score = -1
|
|
166
|
+
best_cut1 = None # keep t1[:best_cut1]
|
|
167
|
+
best_start2 = None # append t2[best_start2:]
|
|
168
|
+
|
|
169
|
+
for L in range(min_match, min(len(t1), len(t2), max_scan) + 1):
|
|
170
|
+
for off1 in range(0, min(max_off + 1, len(t1) - L + 1)):
|
|
171
|
+
for off2 in range(0, min(max_off + 1, len(t2) - L + 1)):
|
|
172
|
+
s = t1[len(t1) - L - off1: len(t1) - off1].lower()
|
|
173
|
+
h = t2[off2: off2 + L].lower()
|
|
174
|
+
matches = sum(a == b for a, b in zip(s, h))
|
|
175
|
+
errors = L - matches
|
|
176
|
+
if errors > max(1, L // 5):
|
|
177
|
+
continue
|
|
178
|
+
# Score: reward long accurate matches, penalise offsets
|
|
179
|
+
score = matches * 5 - errors * 6 - (off1 + off2) * 2
|
|
180
|
+
if score > best_score:
|
|
181
|
+
best_score = score
|
|
182
|
+
best_cut1 = len(t1) - off1 # trim garbled tail of t1
|
|
183
|
+
best_start2 = off2 + L # skip overlap head of t2
|
|
184
|
+
|
|
185
|
+
if best_cut1 is not None and best_score > min_match * 2:
|
|
186
|
+
return t1[:best_cut1] + t2[best_start2:]
|
|
187
|
+
|
|
188
|
+
# 4. Fallback
|
|
189
|
+
return t1 + " " + t2
|
|
190
|
+
|
|
191
|
+
def _get_tiles(self, orig_w, orig_h, tile_size=512, overlap=128):
|
|
192
|
+
tiles = []
|
|
193
|
+
stride = tile_size - overlap
|
|
194
|
+
|
|
195
|
+
y = 0
|
|
196
|
+
while y < orig_h:
|
|
197
|
+
end_y = min(orig_h, y + tile_size)
|
|
198
|
+
start_y = max(0, end_y - tile_size)
|
|
199
|
+
|
|
200
|
+
x = 0
|
|
201
|
+
while x < orig_w:
|
|
202
|
+
end_x = min(orig_w, x + tile_size)
|
|
203
|
+
start_x = max(0, end_x - tile_size)
|
|
204
|
+
|
|
205
|
+
tiles.append((start_x, start_y, end_x, end_y))
|
|
206
|
+
if end_x == orig_w:
|
|
207
|
+
break
|
|
208
|
+
x += stride
|
|
209
|
+
|
|
210
|
+
if end_y == orig_h:
|
|
211
|
+
break
|
|
212
|
+
y += stride
|
|
213
|
+
|
|
214
|
+
return tiles
|
|
215
|
+
|
|
216
|
+
def _split_block_into_lines(self, block_gray, threshold_ratio=0.01, min_line_height=8):
|
|
217
|
+
h_block, w_block = block_gray.shape
|
|
218
|
+
|
|
219
|
+
# Adaptive background thresholding
|
|
220
|
+
if np.mean(block_gray) > 127:
|
|
221
|
+
_, binary = cv2.threshold(block_gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
|
|
222
|
+
else:
|
|
223
|
+
_, binary = cv2.threshold(block_gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
224
|
+
|
|
225
|
+
proj = np.sum(binary, axis=1)
|
|
226
|
+
max_val = w_block * 255
|
|
227
|
+
thresh_val = max(1000, max_val * threshold_ratio)
|
|
228
|
+
|
|
229
|
+
in_line = False
|
|
230
|
+
start_y = 0
|
|
231
|
+
raw_lines = []
|
|
232
|
+
|
|
233
|
+
for y in range(h_block):
|
|
234
|
+
if proj[y] > thresh_val:
|
|
235
|
+
if not in_line:
|
|
236
|
+
start_y = y
|
|
237
|
+
in_line = True
|
|
238
|
+
else:
|
|
239
|
+
if in_line:
|
|
240
|
+
end_y = y
|
|
241
|
+
if (end_y - start_y) >= min_line_height:
|
|
242
|
+
raw_lines.append((start_y, end_y))
|
|
243
|
+
in_line = False
|
|
244
|
+
if in_line:
|
|
245
|
+
if (h_block - start_y) >= min_line_height:
|
|
246
|
+
raw_lines.append((start_y, h_block))
|
|
247
|
+
|
|
248
|
+
refined_lines = []
|
|
249
|
+
for s_y, e_y in raw_lines:
|
|
250
|
+
s_y_pad = max(0, s_y - 2)
|
|
251
|
+
e_y_pad = min(h_block, e_y + 2)
|
|
252
|
+
refined_lines.append((s_y_pad, e_y_pad))
|
|
253
|
+
|
|
254
|
+
return refined_lines
|
|
255
|
+
|
|
256
|
+
def _find_text_horizontal_bounds(self, line_gray, threshold_ratio=0.02):
|
|
257
|
+
h_line, w_line = line_gray.shape
|
|
258
|
+
|
|
259
|
+
# Adaptive background thresholding
|
|
260
|
+
if np.mean(line_gray) > 127:
|
|
261
|
+
_, binary = cv2.threshold(line_gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
|
|
262
|
+
else:
|
|
263
|
+
_, binary = cv2.threshold(line_gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
264
|
+
|
|
265
|
+
proj = np.sum(binary, axis=0)
|
|
266
|
+
thresh_val = h_line * 255 * threshold_ratio
|
|
267
|
+
|
|
268
|
+
active_cols = np.where(proj > thresh_val)[0]
|
|
269
|
+
if len(active_cols) == 0:
|
|
270
|
+
return 0, w_line
|
|
271
|
+
|
|
272
|
+
x_min = active_cols[0]
|
|
273
|
+
x_max = active_cols[-1]
|
|
274
|
+
|
|
275
|
+
# Add small padding to prevent character clipping
|
|
276
|
+
x_min = max(0, x_min - 8)
|
|
277
|
+
x_max = min(w_line, x_max + 8)
|
|
278
|
+
|
|
279
|
+
return x_min, x_max
|
|
280
|
+
|
|
281
|
+
def _are_boxes_close(self, b1, b2):
|
|
282
|
+
y_dist = abs(b1['y_center'] - b2['y_center'])
|
|
283
|
+
x_dist = max(0, b1['x_min'] - b2['x_max'], b2['x_min'] - b1['x_max'])
|
|
284
|
+
# Allow generous horizontal gap so detector anchors (spaced every 16px
|
|
285
|
+
# in 256-space, up to ~3.5× in a full-width image) don't split one text
|
|
286
|
+
# line into multiple blocks.
|
|
287
|
+
if y_dist < 40 and x_dist < 120:
|
|
288
|
+
return True
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
def run(self, img, score_threshold=0.35):
|
|
292
|
+
"""
|
|
293
|
+
Runs the OCR pipeline on the input image.
|
|
294
|
+
img: Can be a file path (str) or a numpy array (RGB or Grayscale).
|
|
295
|
+
score_threshold: Float threshold for text detection.
|
|
296
|
+
|
|
297
|
+
Returns a list of dicts:
|
|
298
|
+
[
|
|
299
|
+
{
|
|
300
|
+
"box": [x_min, y_min, x_max, y_max], # Bounding box in original image pixels
|
|
301
|
+
"text": "..." # Recognized text string
|
|
302
|
+
},
|
|
303
|
+
...
|
|
304
|
+
]
|
|
305
|
+
"""
|
|
306
|
+
# Load image if file path is provided
|
|
307
|
+
if isinstance(img, str):
|
|
308
|
+
if not os.path.exists(img):
|
|
309
|
+
raise FileNotFoundError(f"Image file not found: {img}")
|
|
310
|
+
img_bgr = cv2.imread(img)
|
|
311
|
+
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
|
312
|
+
elif isinstance(img, np.ndarray):
|
|
313
|
+
if len(img.shape) == 3:
|
|
314
|
+
img_rgb = img
|
|
315
|
+
else:
|
|
316
|
+
img_rgb = cv2.cvtColor(img, cv2.COLOR_GRAY2RGB)
|
|
317
|
+
else:
|
|
318
|
+
raise TypeError("img must be a file path (str) or numpy array")
|
|
319
|
+
|
|
320
|
+
orig_h, orig_w = img_rgb.shape[:2]
|
|
321
|
+
img_gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY)
|
|
322
|
+
|
|
323
|
+
# 1. Determine tiles
|
|
324
|
+
if orig_w <= 512 and orig_h <= 512:
|
|
325
|
+
tiles = [(0, 0, orig_w, orig_h)]
|
|
326
|
+
else:
|
|
327
|
+
tiles = self._get_tiles(orig_w, orig_h, tile_size=512, overlap=128)
|
|
328
|
+
|
|
329
|
+
# 2. Run detector on each tile and map box predictions back to global space
|
|
330
|
+
global_boxes = []
|
|
331
|
+
for tx_min, ty_min, tx_max, ty_max in tiles:
|
|
332
|
+
tile_w = tx_max - tx_min
|
|
333
|
+
tile_h = ty_max - ty_min
|
|
334
|
+
tile_crop = img_gray[ty_min:ty_max, tx_min:tx_max]
|
|
335
|
+
|
|
336
|
+
# Resize tile to 256x256 and run detector
|
|
337
|
+
cls_probs, dequantized = self.detector.detect_raw(tile_crop)
|
|
338
|
+
|
|
339
|
+
scale_x = tile_w / 256.0
|
|
340
|
+
scale_y = tile_h / 256.0
|
|
341
|
+
|
|
342
|
+
for y in range(16):
|
|
343
|
+
for x in range(16):
|
|
344
|
+
prob = cls_probs[y, x]
|
|
345
|
+
if prob > score_threshold:
|
|
346
|
+
vals = dequantized[0, y, x, :]
|
|
347
|
+
cy = y * 16 + 8
|
|
348
|
+
cx = x * 16 + 8
|
|
349
|
+
|
|
350
|
+
y_center_local = cy + vals[2] * 16
|
|
351
|
+
h_local = 14.0
|
|
352
|
+
y_min_local = y_center_local - h_local / 2
|
|
353
|
+
y_max_local = y_center_local + h_local / 2
|
|
354
|
+
|
|
355
|
+
x_min_local = cx + vals[3] * 16
|
|
356
|
+
x_max_local = cx + vals[1] * 16
|
|
357
|
+
|
|
358
|
+
# Map to global coordinates
|
|
359
|
+
x_min_global = tx_min + x_min_local * scale_x
|
|
360
|
+
x_max_global = tx_min + x_max_local * scale_x
|
|
361
|
+
y_min_global = ty_min + y_min_local * scale_y
|
|
362
|
+
y_max_global = ty_min + y_max_local * scale_y
|
|
363
|
+
|
|
364
|
+
global_boxes.append({
|
|
365
|
+
'x_min': x_min_global,
|
|
366
|
+
'x_max': x_max_global,
|
|
367
|
+
'y_min': y_min_global,
|
|
368
|
+
'y_max': y_max_global,
|
|
369
|
+
'y_center': (y_min_global + y_max_global) / 2
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
if not global_boxes:
|
|
373
|
+
return []
|
|
374
|
+
|
|
375
|
+
# 3. Cluster predictions into global blocks
|
|
376
|
+
blocks = []
|
|
377
|
+
visited = set()
|
|
378
|
+
for i, box in enumerate(global_boxes):
|
|
379
|
+
if i in visited:
|
|
380
|
+
continue
|
|
381
|
+
block = []
|
|
382
|
+
queue = [i]
|
|
383
|
+
visited.add(i)
|
|
384
|
+
while queue:
|
|
385
|
+
curr_idx = queue.pop(0)
|
|
386
|
+
curr_box = global_boxes[curr_idx]
|
|
387
|
+
block.append(curr_box)
|
|
388
|
+
for nbr_idx, nbr_box in enumerate(global_boxes):
|
|
389
|
+
if nbr_idx not in visited:
|
|
390
|
+
if self._are_boxes_close(curr_box, nbr_box):
|
|
391
|
+
visited.add(nbr_idx)
|
|
392
|
+
queue.append(nbr_idx)
|
|
393
|
+
blocks.append(block)
|
|
394
|
+
|
|
395
|
+
# Sort blocks top-to-bottom
|
|
396
|
+
blocks.sort(key=lambda b: min(box['y_min'] for box in b))
|
|
397
|
+
|
|
398
|
+
results = []
|
|
399
|
+
|
|
400
|
+
# 4. Extract lines and run OCR using horizontal chunking
|
|
401
|
+
for block in blocks:
|
|
402
|
+
min_x = min(box['x_min'] for box in block)
|
|
403
|
+
max_x = max(box['x_max'] for box in block)
|
|
404
|
+
min_y = min(box['y_min'] for box in block)
|
|
405
|
+
max_y = max(box['y_max'] for box in block)
|
|
406
|
+
|
|
407
|
+
# Pad block bounds slightly
|
|
408
|
+
min_x = max(0, int(min_x) - 16)
|
|
409
|
+
max_x = min(orig_w, int(max_x) + 16)
|
|
410
|
+
min_y = max(0, int(min_y) - 8)
|
|
411
|
+
max_y = min(orig_h, int(max_y) + 8)
|
|
412
|
+
|
|
413
|
+
block_crop = img_gray[min_y:max_y, min_x:max_x]
|
|
414
|
+
if block_crop.size == 0:
|
|
415
|
+
continue
|
|
416
|
+
|
|
417
|
+
line_splits = self._split_block_into_lines(block_crop)
|
|
418
|
+
for y_min_rel, y_max_rel in line_splits:
|
|
419
|
+
y_min_line = min_y + y_min_rel
|
|
420
|
+
y_max_line = min_y + y_max_rel
|
|
421
|
+
|
|
422
|
+
line_gray = img_gray[y_min_line:y_max_line, min_x:max_x]
|
|
423
|
+
x_min_rel, x_max_rel = self._find_text_horizontal_bounds(line_gray)
|
|
424
|
+
|
|
425
|
+
x_min_line = max(0, int(min_x + x_min_rel))
|
|
426
|
+
x_max_line = min(orig_w, int(min_x + x_max_rel))
|
|
427
|
+
|
|
428
|
+
# Skip empty lines
|
|
429
|
+
if (x_max_line - x_min_line) <= 0 or (y_max_line - y_min_line) <= 0:
|
|
430
|
+
continue
|
|
431
|
+
|
|
432
|
+
width = x_max_line - x_min_line
|
|
433
|
+
line_h = y_max_line - y_min_line
|
|
434
|
+
|
|
435
|
+
# Compute the maximum chunk width that the recognizer can handle
|
|
436
|
+
# without squishing the text horizontally. The recognizer's
|
|
437
|
+
# input tensor is (target_h × target_w); keeping aspect ratio
|
|
438
|
+
# means the original-space chunk width must satisfy:
|
|
439
|
+
# chunk_w * (target_h / line_h) <= target_w
|
|
440
|
+
# => chunk_w <= line_h * target_w / target_h
|
|
441
|
+
# We retrieve target dimensions from the recognizer's input tensor.
|
|
442
|
+
rec_shape = self.recognizer.input_details[0]['shape'] # [1, H, W, 1]
|
|
443
|
+
rec_dim1, rec_dim2 = rec_shape[1], rec_shape[2]
|
|
444
|
+
rec_target_h = min(rec_dim1, rec_dim2)
|
|
445
|
+
rec_target_w = max(rec_dim1, rec_dim2)
|
|
446
|
+
|
|
447
|
+
max_chunk_w = max(20, int(line_h * rec_target_w / rec_target_h) - 4)
|
|
448
|
+
|
|
449
|
+
if self.lang in ('zh', 'ja', 'ko'):
|
|
450
|
+
overlap_ratio = 0.40
|
|
451
|
+
else:
|
|
452
|
+
overlap_ratio = 0.55
|
|
453
|
+
|
|
454
|
+
chunk_w = max_chunk_w
|
|
455
|
+
overlap = int(chunk_w * overlap_ratio)
|
|
456
|
+
|
|
457
|
+
if width <= chunk_w:
|
|
458
|
+
crop = img_rgb[y_min_line:y_max_line, x_min_line:x_max_line]
|
|
459
|
+
text = self.recognizer.recognize(crop)
|
|
460
|
+
else:
|
|
461
|
+
chunks = []
|
|
462
|
+
curr_x = x_min_line
|
|
463
|
+
while curr_x < x_max_line:
|
|
464
|
+
end_x = min(x_max_line, curr_x + chunk_w)
|
|
465
|
+
chunks.append((curr_x, end_x))
|
|
466
|
+
if end_x == x_max_line:
|
|
467
|
+
break
|
|
468
|
+
curr_x = end_x - overlap
|
|
469
|
+
|
|
470
|
+
text = ""
|
|
471
|
+
for cx_min, cx_max in chunks:
|
|
472
|
+
crop = img_rgb[y_min_line:y_max_line, cx_min:cx_max]
|
|
473
|
+
chunk_text = self.recognizer.recognize(crop)
|
|
474
|
+
text = self._merge_overlapping_texts(text, chunk_text)
|
|
475
|
+
|
|
476
|
+
if self.lang in ('ar', 'he'):
|
|
477
|
+
text = text[::-1]
|
|
478
|
+
|
|
479
|
+
results.append({
|
|
480
|
+
"box": [x_min_line, y_min_line, x_max_line, y_max_line],
|
|
481
|
+
"text": text
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
# Sort results top-to-bottom based on y_center of box
|
|
485
|
+
results.sort(key=lambda r: (r['box'][1] + r['box'][3]) / 2)
|
|
486
|
+
return results
|
openmlkit/recognizer.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# openmlkit/recognizer.py
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import cv2
|
|
5
|
+
import tflite_runtime.interpreter as tflite
|
|
6
|
+
|
|
7
|
+
class TextRecognizer:
|
|
8
|
+
def __init__(self, model_path, label_map):
|
|
9
|
+
self.interpreter = tflite.Interpreter(model_path=model_path)
|
|
10
|
+
self.interpreter.allocate_tensors()
|
|
11
|
+
self.input_details = self.interpreter.get_input_details()
|
|
12
|
+
self.output_details = self.interpreter.get_output_details()
|
|
13
|
+
self.label_map = label_map
|
|
14
|
+
|
|
15
|
+
def recognize(self, crop_img):
|
|
16
|
+
"""
|
|
17
|
+
Recognizes the text in a cropped image (can be color or grayscale).
|
|
18
|
+
Returns the decoded text string.
|
|
19
|
+
"""
|
|
20
|
+
if len(crop_img.shape) == 3:
|
|
21
|
+
crop_gray = cv2.cvtColor(crop_img, cv2.COLOR_RGB2GRAY)
|
|
22
|
+
else:
|
|
23
|
+
crop_gray = crop_img.copy()
|
|
24
|
+
|
|
25
|
+
# Determine model layout and target dimensions
|
|
26
|
+
shape = self.input_details[0]['shape']
|
|
27
|
+
dim1, dim2 = shape[1], shape[2]
|
|
28
|
+
|
|
29
|
+
if dim1 > dim2:
|
|
30
|
+
# Model expects [batch, width, height, channels] layout
|
|
31
|
+
target_w = dim1
|
|
32
|
+
target_h = dim2
|
|
33
|
+
transpose = True
|
|
34
|
+
else:
|
|
35
|
+
# Model expects [batch, height, width, channels] layout
|
|
36
|
+
target_h = dim1
|
|
37
|
+
target_w = dim2
|
|
38
|
+
transpose = False
|
|
39
|
+
|
|
40
|
+
# Preserve aspect ratio: scale by height, pad width with background.
|
|
41
|
+
# Directly squishing wide text into target_w causes garbled output.
|
|
42
|
+
h_src, w_src = crop_gray.shape
|
|
43
|
+
scale = target_h / h_src
|
|
44
|
+
new_w = int(round(w_src * scale))
|
|
45
|
+
|
|
46
|
+
if new_w <= target_w:
|
|
47
|
+
# Scale to target height, then right-pad with background colour
|
|
48
|
+
interp_flag = cv2.INTER_AREA if scale < 1.0 else cv2.INTER_CUBIC
|
|
49
|
+
resized = cv2.resize(crop_gray, (new_w, target_h), interpolation=interp_flag)
|
|
50
|
+
# Detect background colour (most frequent of corners)
|
|
51
|
+
corners = [crop_gray[0, 0], crop_gray[0, -1],
|
|
52
|
+
crop_gray[-1, 0], crop_gray[-1, -1]]
|
|
53
|
+
bg = int(np.median(corners))
|
|
54
|
+
canvas = np.full((target_h, target_w), bg, dtype=np.uint8)
|
|
55
|
+
canvas[:, :new_w] = resized
|
|
56
|
+
crop_resized = canvas
|
|
57
|
+
else:
|
|
58
|
+
# Chunk is still wider than the model window — squish as last resort
|
|
59
|
+
crop_resized = cv2.resize(crop_gray, (target_w, target_h),
|
|
60
|
+
interpolation=cv2.INTER_AREA)
|
|
61
|
+
|
|
62
|
+
# Reshape to standard height-width layout
|
|
63
|
+
input_data = crop_resized.reshape((1, target_h, target_w, 1))
|
|
64
|
+
|
|
65
|
+
if transpose:
|
|
66
|
+
input_data = np.transpose(input_data, (0, 2, 1, 3))
|
|
67
|
+
|
|
68
|
+
self.interpreter.set_tensor(self.input_details[0]['index'], input_data)
|
|
69
|
+
self.interpreter.invoke()
|
|
70
|
+
|
|
71
|
+
# Dynamically locate the 3D output tensor (CTC logits)
|
|
72
|
+
output_detail = None
|
|
73
|
+
for out in self.output_details:
|
|
74
|
+
if len(out['shape']) == 3:
|
|
75
|
+
output_detail = out
|
|
76
|
+
break
|
|
77
|
+
if output_detail is None:
|
|
78
|
+
output_detail = self.output_details[0]
|
|
79
|
+
|
|
80
|
+
output_data = self.interpreter.get_tensor(output_detail['index'])
|
|
81
|
+
|
|
82
|
+
# Dequantize output
|
|
83
|
+
scale, zero_point = output_detail['quantization']
|
|
84
|
+
dequantized = (output_data.astype(np.float32) - zero_point) * scale
|
|
85
|
+
|
|
86
|
+
# Decode text
|
|
87
|
+
return self._ctc_decode(dequantized)
|
|
88
|
+
|
|
89
|
+
def _ctc_decode(self, output_tensor):
|
|
90
|
+
# output_tensor shape: [1, 42, V]
|
|
91
|
+
logits = output_tensor[0]
|
|
92
|
+
best_paths = np.argmax(logits, axis=-1)
|
|
93
|
+
|
|
94
|
+
decoded_indices = []
|
|
95
|
+
prev_idx = -1
|
|
96
|
+
|
|
97
|
+
# CTC blank token: conventionally the last class index (V - 1)
|
|
98
|
+
V = output_tensor.shape[-1]
|
|
99
|
+
blank_token = V - 1
|
|
100
|
+
|
|
101
|
+
for idx in best_paths:
|
|
102
|
+
if idx != blank_token:
|
|
103
|
+
if idx != prev_idx:
|
|
104
|
+
decoded_indices.append(idx)
|
|
105
|
+
prev_idx = idx
|
|
106
|
+
|
|
107
|
+
chars = [self.label_map.get(idx, "") for idx in decoded_indices]
|
|
108
|
+
return "".join(chars)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openmlkitOCR
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A lightweight offline OCR library using Google ML Kit TFLite models
|
|
5
|
+
Author: 0cve0
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
10
|
+
Requires-Python: >=3.7
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: numpy<2.0.0
|
|
14
|
+
Requires-Dist: opencv-python
|
|
15
|
+
Requires-Dist: tflite-runtime
|
|
16
|
+
Requires-Dist: huggingface_hub
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# OpenMLkit OCR
|
|
20
|
+
|
|
21
|
+
A lightweight, offline Python OCR (Optical Character Recognition) library utilizing highly optimized, mobile-ready Google ML Kit TFLite models. It performs text detection using a Region Proposal Network (RPN) and line recognition using a CRNN-CTC architecture.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
- **Fully Offline:** Runs entirely local, no API keys or internet connection required after downloading models.
|
|
27
|
+
- **Multilingual Support:** Supports 15+ languages and scripts (Cyrillic/Russian, Latin/English, Chinese, Japanese, Korean, Arabic, Hebrew, and various Indian scripts).
|
|
28
|
+
- **Auto-downloading:** Automatically downloads and caches required models from Hugging Face if they are not present locally.
|
|
29
|
+
- **High Quality Stitching:** Handles wide text lines without squishing by dividing them into overlapping chunks and merging them using fuzzy suffix-prefix alignment.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
Install the package directly using pip:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install openmlkitOCR
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or install from source:
|
|
42
|
+
```bash
|
|
43
|
+
git clone https://github.com/0cve0/OpenMLkitOCR.git
|
|
44
|
+
cd OpenMLkitOCR
|
|
45
|
+
pip install -e .
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Quick Start
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import os
|
|
54
|
+
import cv2
|
|
55
|
+
from openmlkit import OpenMLKitOCR
|
|
56
|
+
|
|
57
|
+
# Configure Hugging Face model source (or use defaults)
|
|
58
|
+
os.environ["OPENMLKIT_MODEL_REPO"] = "0cve0/OpenMLKitOCR"
|
|
59
|
+
|
|
60
|
+
# Initialize OCR pipeline for Cyrillic (Russian) text
|
|
61
|
+
ocr = OpenMLKitOCR(lang='ru')
|
|
62
|
+
|
|
63
|
+
# Load image
|
|
64
|
+
img = cv2.imread("scratch/russian_test.png")
|
|
65
|
+
|
|
66
|
+
# Run OCR (detect and recognize text)
|
|
67
|
+
results = ocr.run(img, score_threshold=0.35)
|
|
68
|
+
|
|
69
|
+
# Output results
|
|
70
|
+
for r in results:
|
|
71
|
+
print(f"Box: {r['box']} -> Text: {r['text']}")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Project Structure
|
|
77
|
+
|
|
78
|
+
- `openmlkit/` - Core Python package directory.
|
|
79
|
+
- `detector.py` - RPN text detection logic.
|
|
80
|
+
- `recognizer.py` - CRNN text recognition logic.
|
|
81
|
+
- `labelmap.py` - Parse binary protobuf label maps.
|
|
82
|
+
- `pipeline.py` - OCR pipeline joining detection, tiling, and recognition.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
This project is licensed under the Apache 2.0 License. The model weights are subject to Google's terms and licenses for ML Kit.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
openmlkit/__init__.py,sha256=j_siZuU0kBd1JOEDII5_O3WuR0vXsU_YPynL5b8BeW4,261
|
|
2
|
+
openmlkit/detector.py,sha256=OomIXweHBQjgUkCevdc1ihljWD2iC84ysfIC7CHtgYg,4454
|
|
3
|
+
openmlkit/labelmap.py,sha256=sz8zq3_iFqQNOUtdLLnYMCZ-nkH_G1BLRcpCePDpM_Y,3489
|
|
4
|
+
openmlkit/pipeline.py,sha256=fw6b7sEpv1f4kHd56u5cuYl9Occ0qzUmbZX77Gqpk84,20045
|
|
5
|
+
openmlkit/recognizer.py,sha256=Rn_6nCppkSxTFQgDiKeUQCrCbXAvm5CyBsKm67CracI,4122
|
|
6
|
+
openmlkitocr-1.0.0.dist-info/licenses/LICENSE,sha256=mPHeCzLulNZK-e1kg1dPV6GH6hoE5hgOu5ETNc3KM5U,10172
|
|
7
|
+
openmlkitocr-1.0.0.dist-info/METADATA,sha256=4PtDkN9SaeR1i_EzSLhdb8f76HZOvGwc2gjOCDs-b_c,2660
|
|
8
|
+
openmlkitocr-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
openmlkitocr-1.0.0.dist-info/top_level.txt,sha256=Q-Q8FQ77Bhc6BoZ8NX3Tjoe8rMsUmxv815_uqhmSwJc,10
|
|
10
|
+
openmlkitocr-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that you distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
openmlkit
|