paddleocr-hebrew 0.1.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.
- paddleocr_hebrew/__init__.py +18 -0
- paddleocr_hebrew/detect.py +107 -0
- paddleocr_hebrew/heb_postprocess.py +292 -0
- paddleocr_hebrew/pipeline.py +166 -0
- paddleocr_hebrew/plan_e_rec.py +158 -0
- paddleocr_hebrew/rec_decode.py +54 -0
- paddleocr_hebrew-0.1.0.dist-info/METADATA +356 -0
- paddleocr_hebrew-0.1.0.dist-info/RECORD +12 -0
- paddleocr_hebrew-0.1.0.dist-info/WHEEL +5 -0
- paddleocr_hebrew-0.1.0.dist-info/licenses/LICENSE +201 -0
- paddleocr_hebrew-0.1.0.dist-info/licenses/NOTICE +18 -0
- paddleocr_hebrew-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Hebrew OCR — PaddleOCR-based Hebrew recognizers + a runnable page pipeline.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
from paddleocr_hebrew import HebrewOCR
|
|
5
|
+
ocr = HebrewOCR(models_dir="hebrew-ocr-models") # a downloaded HF snapshot
|
|
6
|
+
result = ocr.read("page.png") # {lines, words, meta}
|
|
7
|
+
"""
|
|
8
|
+
from .pipeline import HebrewOCR, render_page
|
|
9
|
+
from .plan_e_rec import PlanECascade
|
|
10
|
+
from .heb_postprocess import (
|
|
11
|
+
post_process, fix_geresh_yod_text, fix_geresh_yod_token, reading_order,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
__all__ = [
|
|
16
|
+
"HebrewOCR", "render_page", "PlanECascade",
|
|
17
|
+
"post_process", "fix_geresh_yod_text", "fix_geresh_yod_token", "reading_order",
|
|
18
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""DBNet text detector (word-level or line-level) — ONNX inference.
|
|
2
|
+
|
|
3
|
+
Carved from the production pipeline. Runs a mobile PPLCNetV3 DBNet ONNX, decodes
|
|
4
|
+
the probability map into boxes via contour-finding + unclip. The SAME code drives
|
|
5
|
+
both the word detector (word-level boxes) and the line detector; passing
|
|
6
|
+
dilate_w/dilate_h > 0 bridges inter-word gaps so words on a baseline merge into a
|
|
7
|
+
line blob (line-mode). The flagship pipeline uses word-level (dilate off).
|
|
8
|
+
"""
|
|
9
|
+
import cv2
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pyclipper
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _resize_for_det(img_bgr, max_side=960):
|
|
15
|
+
h, w = img_bgr.shape[:2]
|
|
16
|
+
s = max(h, w)
|
|
17
|
+
if s > max_side:
|
|
18
|
+
scale = max_side / s
|
|
19
|
+
img_r = cv2.resize(img_bgr, (int(w * scale), int(h * scale)))
|
|
20
|
+
else:
|
|
21
|
+
scale = 1.0
|
|
22
|
+
img_r = img_bgr
|
|
23
|
+
h, w = img_r.shape[:2]
|
|
24
|
+
nh = ((h + 31) // 32) * 32
|
|
25
|
+
nw = ((w + 31) // 32) * 32
|
|
26
|
+
pad = np.zeros((nh, nw, 3), dtype=np.uint8)
|
|
27
|
+
pad[:h, :w] = img_r
|
|
28
|
+
return pad, scale, h, w
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _normalize_for_det(img_bgr):
|
|
32
|
+
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
|
|
33
|
+
mean = np.array([0.485, 0.456, 0.406])
|
|
34
|
+
std = np.array([0.229, 0.224, 0.225])
|
|
35
|
+
img_rgb = (img_rgb - mean) / std
|
|
36
|
+
return img_rgb.transpose(2, 0, 1)[None].astype(np.float32)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _polygon_area_perimeter(pts):
|
|
40
|
+
"""Shoelace area + perimeter (no shapely dependency)."""
|
|
41
|
+
pts = np.asarray(pts, dtype=np.float32).reshape(-1, 2)
|
|
42
|
+
n = len(pts)
|
|
43
|
+
if n < 3:
|
|
44
|
+
return 0.0, 0.0
|
|
45
|
+
area = perim = 0.0
|
|
46
|
+
for i in range(n):
|
|
47
|
+
j = (i + 1) % n
|
|
48
|
+
area += pts[i, 0] * pts[j, 1] - pts[j, 0] * pts[i, 1]
|
|
49
|
+
perim += float(np.linalg.norm(pts[j] - pts[i]))
|
|
50
|
+
return abs(area) / 2.0, perim
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _unclip_contour(cnt, ratio=1.3):
|
|
54
|
+
pts = cnt[:, 0]
|
|
55
|
+
area, perim = _polygon_area_perimeter(pts)
|
|
56
|
+
if area < 1:
|
|
57
|
+
return None
|
|
58
|
+
distance = area * ratio / max(perim, 1e-6)
|
|
59
|
+
pco = pyclipper.PyclipperOffset()
|
|
60
|
+
pco.AddPath(pts.tolist(), pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON)
|
|
61
|
+
expanded = pco.Execute(distance)
|
|
62
|
+
if not expanded:
|
|
63
|
+
return None
|
|
64
|
+
return np.array(expanded[0])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def detect_words(img_bgr, det_sess, det_inp, thresh=0.40, unclip_ratio=1.3,
|
|
68
|
+
min_area=3, min_wh=6, max_side=960, dilate_w=0, dilate_h=0):
|
|
69
|
+
"""Return a list of (x1, y1, x2, y2) boxes in original image coordinates.
|
|
70
|
+
|
|
71
|
+
dilate_w/dilate_h > 0 -> line-mode (merge words on a baseline into a line).
|
|
72
|
+
"""
|
|
73
|
+
pad, scale, valid_h, valid_w = _resize_for_det(img_bgr, max_side)
|
|
74
|
+
arr = _normalize_for_det(pad)
|
|
75
|
+
out = det_sess.run(None, {det_inp: arr})[0]
|
|
76
|
+
prob = out[0, 0, :valid_h, :valid_w]
|
|
77
|
+
mask = (prob > thresh).astype(np.uint8) * 255
|
|
78
|
+
|
|
79
|
+
if dilate_w > 0 or dilate_h > 0:
|
|
80
|
+
k = cv2.getStructuringElement(cv2.MORPH_RECT, (max(1, dilate_w), max(1, dilate_h)))
|
|
81
|
+
mask = cv2.dilate(mask, k, iterations=1)
|
|
82
|
+
|
|
83
|
+
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
|
|
84
|
+
bboxes = []
|
|
85
|
+
for cnt in contours:
|
|
86
|
+
if cv2.contourArea(cnt) < min_area:
|
|
87
|
+
continue
|
|
88
|
+
unclipped = _unclip_contour(cnt, unclip_ratio)
|
|
89
|
+
if unclipped is None:
|
|
90
|
+
continue
|
|
91
|
+
x, y, w, h = cv2.boundingRect(unclipped)
|
|
92
|
+
if w < min_wh or h < min_wh:
|
|
93
|
+
continue
|
|
94
|
+
bboxes.append((int(x / scale), int(y / scale),
|
|
95
|
+
int((x + w) / scale), int((y + h) / scale)))
|
|
96
|
+
return bboxes
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def crop_bbox(img_bgr, bbox, pad=0):
|
|
100
|
+
"""Crop (x1,y1,x2,y2) from a BGR image, clamped to bounds, optional pad px."""
|
|
101
|
+
H, W = img_bgr.shape[:2]
|
|
102
|
+
x1, y1, x2, y2 = bbox
|
|
103
|
+
x1 = max(0, x1 - pad); y1 = max(0, y1 - pad)
|
|
104
|
+
x2 = min(W, x2 + pad); y2 = min(H, y2 + pad)
|
|
105
|
+
if x2 <= x1 or y2 <= y1:
|
|
106
|
+
return None
|
|
107
|
+
return img_bgr[y1:y2, x1:x2]
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Hebrew OCR output post-processors.
|
|
2
|
+
|
|
3
|
+
Consumer-side normalizations to apply to recognized text before display or
|
|
4
|
+
downstream use (they do not change the model):
|
|
5
|
+
|
|
6
|
+
- normalize_quote_styles / normalize_gershayim: canonicalize apostrophe/quote
|
|
7
|
+
forms to ASCII ' and " (e.g. two adjacent apostrophes between Hebrew letters
|
|
8
|
+
-> a single gershayim ").
|
|
9
|
+
- fix_geresh_yod_*: repair the SVTRv2 residual defect where the ~1px abbreviation
|
|
10
|
+
marks geresh ' / gershayim " are read as a yod (yod resembles a thin mark),
|
|
11
|
+
e.g. עמ'->עמי, ס"ק->סיק. Conservative by design (over-correcting a real yod is
|
|
12
|
+
worse than a missed mark): a whole-token whitelist of yod-forms that are NOT
|
|
13
|
+
valid Hebrew words, plus numeric-gated page/section refs.
|
|
14
|
+
- reading_order: sort a word list into BiDi-friendly reading order (row cluster
|
|
15
|
+
+ per-row RTL/LTR direction).
|
|
16
|
+
|
|
17
|
+
NB Hebrew text must be stored/scored in LOGICAL order. Apply python-bidi
|
|
18
|
+
get_display() only at the final rendering step (GUI/PDF), never before.
|
|
19
|
+
"""
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
HEB = r"-"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normalize_gershayim(s):
|
|
26
|
+
"""Two adjacent ASCII apostrophes between Hebrew letters -> a single ASCII
|
|
27
|
+
double-quote (canonical gershayim). 'ס''מ' -> 'ס"מ'."""
|
|
28
|
+
s = re.sub(rf"([{HEB}])''([{HEB}])", r'\1"\2', s)
|
|
29
|
+
s = re.sub(rf"([{HEB}])''(?=[\s\W]|$)", r'\1"', s)
|
|
30
|
+
return s
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def normalize_quote_styles(s):
|
|
34
|
+
"""Map curly quotes / Hebrew presentation quotes to canonical ASCII."""
|
|
35
|
+
s = s.replace("״", '"') # U+05F4 Hebrew gershayim -> "
|
|
36
|
+
s = s.replace("׳", "'") # U+05F3 Hebrew geresh -> '
|
|
37
|
+
s = s.replace("“", '"').replace("”", '"')
|
|
38
|
+
s = s.replace("‘", "'").replace("’", "'")
|
|
39
|
+
return s
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def post_process(s):
|
|
43
|
+
"""Apply all canonical quote/gershayim post-processors to OCR output."""
|
|
44
|
+
s = normalize_quote_styles(s)
|
|
45
|
+
s = normalize_gershayim(s)
|
|
46
|
+
return s
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
50
|
+
# Geresh/gershayim <- yod repair (SVTRv2 residual defect)
|
|
51
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
52
|
+
_HEBSET = "-"
|
|
53
|
+
|
|
54
|
+
# yod-form -> correct mark-form. Every KEY is a non-word (verified), so an exact
|
|
55
|
+
# standalone-token match is safe. Values use canonical ASCII ' and ".
|
|
56
|
+
GERESH_YOD_TOKENS = {
|
|
57
|
+
"עויד": "עו\"ד", # lawyer (עו"ד)
|
|
58
|
+
"פרופי": "פרופ'", # professor (פרופ')
|
|
59
|
+
"וכוי": "וכו'", # etc. (וכו')
|
|
60
|
+
"וגוי": "וגו'", # and so forth (וגו')
|
|
61
|
+
"בגיץ": "בג\"ץ", # High Court of Justice (בג"ץ)
|
|
62
|
+
"דויח": "דו\"ח", # report (דו"ח)
|
|
63
|
+
"צהיל": "צה\"ל", # IDF (צה"ל)
|
|
64
|
+
"ארהיב": "ארה\"ב", # USA (ארה"ב)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_GY_TOKEN_RE = re.compile(
|
|
68
|
+
rf"(?<![{_HEBSET}])(" + "|".join(re.escape(k) for k in GERESH_YOD_TOKENS) + rf")(?![{_HEBSET}])"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# numeric-gated page/section references (yod-form IS a real word -> require a following number)
|
|
72
|
+
_GY_PAGEREF = [
|
|
73
|
+
(re.compile(rf"(?<![{_HEBSET}])עמי(?=\s+\d)"), "עמ'"), # page: עמ' 131
|
|
74
|
+
(re.compile(rf"(?<![{_HEBSET}])מסי(?=\s+\d)"), "מס'"), # number: מס' 5
|
|
75
|
+
(re.compile(rf"(?<![{_HEBSET}])סיק(?=\s+\d)"), "ס\"ק"), # sub-section: ס"ק 3
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def fix_geresh_yod_token(s):
|
|
80
|
+
"""Whole-token geresh/gershayim<-yod repair (whitelist only; safe on isolated words)."""
|
|
81
|
+
if not s:
|
|
82
|
+
return s
|
|
83
|
+
return _GY_TOKEN_RE.sub(lambda m: GERESH_YOD_TOKENS[m.group(1)], s)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def fix_geresh_yod_text(s):
|
|
87
|
+
"""Full geresh/gershayim<-yod repair for TEXT with token order: whitelist tokens
|
|
88
|
+
PLUS numeric-gated page/section refs (עמ'/מס'/ס"ק NN). Use on joined line text."""
|
|
89
|
+
if not s:
|
|
90
|
+
return s
|
|
91
|
+
s = fix_geresh_yod_token(s)
|
|
92
|
+
for rx, rep in _GY_PAGEREF:
|
|
93
|
+
s = rx.sub(rep, s)
|
|
94
|
+
return s
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
98
|
+
# Reading-order assembly (operates on lists of {bbox, text, ...} dicts)
|
|
99
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
def _row_direction(row_words):
|
|
102
|
+
"""Paragraph-level direction for a row by counting strong chars (default RTL)."""
|
|
103
|
+
rtl, ltr = 0, 0
|
|
104
|
+
for w in row_words:
|
|
105
|
+
for c in w.get("text", ""):
|
|
106
|
+
if 0x0590 <= ord(c) <= 0x05FF or 0x0600 <= ord(c) <= 0x077F:
|
|
107
|
+
rtl += 1
|
|
108
|
+
elif c.isalpha() and ord(c) < 0x0590:
|
|
109
|
+
ltr += 1
|
|
110
|
+
if rtl > ltr:
|
|
111
|
+
return "rtl"
|
|
112
|
+
if ltr > rtl:
|
|
113
|
+
return "ltr"
|
|
114
|
+
return "rtl" # default to RTL for Hebrew documents
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def cluster_rows(words, row_tolerance=0.5):
|
|
118
|
+
"""Group words into rows by y-center (row_tolerance x median word height).
|
|
119
|
+
|
|
120
|
+
Returns a list of rows, each a list of word dicts, top-to-bottom.
|
|
121
|
+
"""
|
|
122
|
+
if not words:
|
|
123
|
+
return []
|
|
124
|
+
heights = sorted([w["bbox"][3] - w["bbox"][1] for w in words])
|
|
125
|
+
median_h = heights[len(heights) // 2] if heights else 30
|
|
126
|
+
tol = max(median_h * row_tolerance, 5)
|
|
127
|
+
|
|
128
|
+
indexed = [((w["bbox"][1] + w["bbox"][3]) / 2, w) for w in words]
|
|
129
|
+
indexed.sort(key=lambda x: x[0])
|
|
130
|
+
rows = []
|
|
131
|
+
current = [indexed[0]]
|
|
132
|
+
cur_y = indexed[0][0]
|
|
133
|
+
for y, w in indexed[1:]:
|
|
134
|
+
if abs(y - cur_y) <= tol:
|
|
135
|
+
current.append((y, w))
|
|
136
|
+
cur_y = sum(t[0] for t in current) / len(current)
|
|
137
|
+
else:
|
|
138
|
+
rows.append(current)
|
|
139
|
+
current = [(y, w)]
|
|
140
|
+
cur_y = y
|
|
141
|
+
rows.append(current)
|
|
142
|
+
return [[t[1] for t in row] for row in rows]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _word_script(text):
|
|
146
|
+
"""'R' for a word with any Hebrew/Arabic letter (prefixed tokens such as ו-Twitter or ה-CEO are
|
|
147
|
+
Hebrew words), 'L' for a word with Latin letters only, 'N' for numbers and punctuation."""
|
|
148
|
+
if any(0x0590 <= ord(c) <= 0x05FF or 0x0600 <= ord(c) <= 0x077F for c in text):
|
|
149
|
+
return "R"
|
|
150
|
+
if any(c.isalpha() and ord(c) < 0x0590 for c in text):
|
|
151
|
+
return "L"
|
|
152
|
+
return "N"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _is_number(text):
|
|
156
|
+
return any(c.isdigit() for c in text) and not any(c.isalpha() for c in text)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Numbers next to an embedded run are ambiguous on the page ("Windows 11" and "50 USD" look alike).
|
|
160
|
+
EDGE_NUMBERS_JOIN_RUN = False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _restore_runs(visual_words, direction):
|
|
164
|
+
"""Put each embedded run of the other script back in its own order.
|
|
165
|
+
|
|
166
|
+
`visual_words` are in visual order for `direction` (rightmost first for "rtl"). A run starts and
|
|
167
|
+
ends on a word of the other script; numbers and punctuation between its words belong to it;
|
|
168
|
+
punctuation at its edges stays with the surrounding text, and numbers there join it only when
|
|
169
|
+
EDGE_NUMBERS_JOIN_RUN is set.
|
|
170
|
+
"""
|
|
171
|
+
base, other = ("R", "L") if direction == "rtl" else ("L", "R")
|
|
172
|
+
out, seg = [], []
|
|
173
|
+
|
|
174
|
+
def flush():
|
|
175
|
+
idx = [i for i, (_, s) in enumerate(seg) if s == other]
|
|
176
|
+
if idx:
|
|
177
|
+
a, b = idx[0], idx[-1]
|
|
178
|
+
if EDGE_NUMBERS_JOIN_RUN:
|
|
179
|
+
while a > 0 and _is_number(seg[a - 1][0].get("text", "")):
|
|
180
|
+
a -= 1
|
|
181
|
+
while b < len(seg) - 1 and _is_number(seg[b + 1][0].get("text", "")):
|
|
182
|
+
b += 1
|
|
183
|
+
out.extend(w for w, _ in seg[:a])
|
|
184
|
+
out.extend(w for w, _ in reversed(seg[a:b + 1]))
|
|
185
|
+
out.extend(w for w, _ in seg[b + 1:])
|
|
186
|
+
else:
|
|
187
|
+
out.extend(w for w, _ in seg)
|
|
188
|
+
seg.clear()
|
|
189
|
+
|
|
190
|
+
for w in visual_words:
|
|
191
|
+
s = _word_script(w.get("text", ""))
|
|
192
|
+
if s == base:
|
|
193
|
+
flush()
|
|
194
|
+
out.append(w)
|
|
195
|
+
else:
|
|
196
|
+
seg.append((w, s))
|
|
197
|
+
flush()
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def order_row(row_words, direction=None):
|
|
202
|
+
"""Order one row's words in logical (reading) order.
|
|
203
|
+
|
|
204
|
+
direction=None decides from the row: any Hebrew word makes it right to left (rightmost word first),
|
|
205
|
+
Latin without Hebrew makes it left to right, and a row of numbers only stays right to left as in a
|
|
206
|
+
Hebrew document. One guard: a row that starts and ends with Latin-only words and has at least
|
|
207
|
+
3x more Latin than Hebrew letters stays left to right (an English line where one short word was
|
|
208
|
+
misread as Hebrew, e.g. "in" read as "מו"). Inside the row, each embedded run of the other script
|
|
209
|
+
keeps its own order, so an English phrase in a Hebrew line reads "אוניברסיטת חיפה University of
|
|
210
|
+
Haifa", not reversed. Pass "ltr"/"rtl" to force the paragraph direction (see row_directions).
|
|
211
|
+
"""
|
|
212
|
+
cx = lambda w: (w["bbox"][0] + w["bbox"][2]) / 2 # noqa: E731
|
|
213
|
+
if direction is None:
|
|
214
|
+
scripts = [_word_script(w.get("text", "")) for w in sorted(row_words, key=cx)]
|
|
215
|
+
if "R" in scripts:
|
|
216
|
+
strong = [s for s in scripts if s != "N"]
|
|
217
|
+
heb = sum(1 for w in row_words for c in w.get("text", "")
|
|
218
|
+
if 0x0590 <= ord(c) <= 0x05FF or 0x0600 <= ord(c) <= 0x077F)
|
|
219
|
+
lat = sum(1 for w in row_words for c in w.get("text", "") if c.isalpha() and ord(c) < 0x0590)
|
|
220
|
+
english_line = strong[0] == "L" and strong[-1] == "L" and lat >= 3 * heb
|
|
221
|
+
direction = "ltr" if english_line else "rtl"
|
|
222
|
+
else:
|
|
223
|
+
direction = "ltr" if "L" in scripts else "rtl"
|
|
224
|
+
visual = sorted(row_words, key=(lambda w: -cx(w)) if direction == "rtl" else cx)
|
|
225
|
+
return _restore_runs(visual, direction)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def row_directions(rows, window=2, latin_ratio=4.0, min_rows=3):
|
|
229
|
+
"""Paragraph direction per row (rows as returned by cluster_rows), or None to let order_row decide.
|
|
230
|
+
|
|
231
|
+
Safeguard for English text that quotes Hebrew: a row with Hebrew in it is "ltr" when the row and its
|
|
232
|
+
neighbours (window rows above and below) hold at least `min_rows` rows with letters and
|
|
233
|
+
`latin_ratio` times more Latin than Hebrew letters. Every other row gets None.
|
|
234
|
+
"""
|
|
235
|
+
counts = []
|
|
236
|
+
for row in rows:
|
|
237
|
+
h = l = 0
|
|
238
|
+
for w in row:
|
|
239
|
+
for c in w.get("text", ""):
|
|
240
|
+
if 0x0590 <= ord(c) <= 0x05FF or 0x0600 <= ord(c) <= 0x077F:
|
|
241
|
+
h += 1
|
|
242
|
+
elif c.isalpha() and ord(c) < 0x0590:
|
|
243
|
+
l += 1
|
|
244
|
+
counts.append((h, l))
|
|
245
|
+
out = []
|
|
246
|
+
for i, (h_i, _) in enumerate(counts):
|
|
247
|
+
near = counts[max(0, i - window): i + window + 1]
|
|
248
|
+
h = sum(c[0] for c in near)
|
|
249
|
+
l = sum(c[1] for c in near)
|
|
250
|
+
lettered = sum(1 for c in near if c[0] or c[1])
|
|
251
|
+
out.append("ltr" if h_i and lettered >= min_rows and l >= latin_ratio * h else None)
|
|
252
|
+
return out
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def reading_order(words, row_tolerance=0.5):
|
|
256
|
+
"""Sort words into a flat BiDi-friendly reading order (rows top-to-bottom,
|
|
257
|
+
each row in logical order; see order_row)."""
|
|
258
|
+
rows = cluster_rows(words, row_tolerance)
|
|
259
|
+
out = []
|
|
260
|
+
for row, direction in zip(rows, row_directions(rows)):
|
|
261
|
+
out.extend(order_row(row, direction))
|
|
262
|
+
return out
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__":
|
|
266
|
+
tests = [
|
|
267
|
+
("ס''מ", "ס\"מ"), ("ס\"מ", "ס\"מ"), ("בע''מ", "בע\"מ"),
|
|
268
|
+
("פרופ'", "פרופ'"), ("הבורסה”", "הבורסה\""),
|
|
269
|
+
]
|
|
270
|
+
for inp, expected in tests:
|
|
271
|
+
got = post_process(inp)
|
|
272
|
+
ok = "OK " if got == expected else "XX "
|
|
273
|
+
print(f" {ok} {inp!r:14s} -> {got!r:14s} (want {expected!r})")
|
|
274
|
+
# word order: (word, x1, x2) as the boxes sit on the page -> expected logical text
|
|
275
|
+
order_tests = [
|
|
276
|
+
([("University", 400, 580), ("of Haifa", 600, 780), ("2024", 800, 880), ("חיפה", 900, 980),
|
|
277
|
+
("אוניברסיטת", 1000, 1100)], ["אוניברסיטת חיפה 2024 University of Haifa",
|
|
278
|
+
"אוניברסיטת חיפה University of Haifa 2024"]),
|
|
279
|
+
([("היום", 0, 120), ("הודיעה", 150, 280), ("Microsoft", 300, 440), ("Corporation", 460, 600)],
|
|
280
|
+
["Microsoft Corporation הודיעה היום"]),
|
|
281
|
+
([("ו-Twitter", 0, 140), ("Facebook", 160, 300), ("הודיעו", 320, 450)], ["הודיעו Facebook ו-Twitter"]),
|
|
282
|
+
([("החדשות", 0, 120), ("–", 130, 150), ("mako", 160, 240)], ["mako – החדשות"]),
|
|
283
|
+
([("אורתופדיה", 150, 380), ("7.1.4", 400, 480)], ["7.1.4 אורתופדיה"]),
|
|
284
|
+
([("Total", 0, 80), ("due", 90, 140)], ["Total due"]),
|
|
285
|
+
([("from", 0, 60), ("his", 70, 110), ("post", 120, 170), ("מו", 180, 200), ("accordance", 210, 330),
|
|
286
|
+
("with", 340, 390)], ["from his post מו accordance with"]),
|
|
287
|
+
]
|
|
288
|
+
for ws, expected in order_tests:
|
|
289
|
+
row = [{"bbox": [x1, 100, x2, 130], "text": t} for t, x1, x2 in ws]
|
|
290
|
+
got = " ".join(w["text"] for w in order_row(row))
|
|
291
|
+
want = expected[1] if EDGE_NUMBERS_JOIN_RUN and len(expected) > 1 else expected[0]
|
|
292
|
+
print(f" {'OK ' if got == want else 'XX '} {got} (want {want})")
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Hebrew OCR pipeline — word-DET + SVTRv2 Plan-E cascade (the flagship page path).
|
|
2
|
+
|
|
3
|
+
image/PDF -> word detector (mobile DBNet) -> crops -> Plan-E cascade recognizer
|
|
4
|
+
(SVTRv2 CTC fast path + script-gated NRTR fallback) -> reading-order assembly ->
|
|
5
|
+
Hebrew post-processing (gershayim canon + geresh<-yod repair).
|
|
6
|
+
|
|
7
|
+
This is the page pipeline the benchmark uses: word-level detection + single-pass
|
|
8
|
+
recognition (the recognizer reads each word crop once; no sliding window). On the
|
|
9
|
+
71-page GCV-anchored eval it scores 7.56% page-CER, ahead of a Hebrew-finetuned
|
|
10
|
+
line detector (10.36%) and Tesseract (14.20%).
|
|
11
|
+
|
|
12
|
+
Output is a dict of recognized LINES (logical Unicode order — apply python-bidi
|
|
13
|
+
get_display() only when rendering) plus the underlying word boxes.
|
|
14
|
+
|
|
15
|
+
from paddleocr_hebrew import HebrewOCR
|
|
16
|
+
ocr = HebrewOCR(models_dir="hebrew-ocr-models") # a downloaded HF snapshot
|
|
17
|
+
result = ocr.read("page.png")
|
|
18
|
+
for line in result["lines"]:
|
|
19
|
+
print(line["text"])
|
|
20
|
+
"""
|
|
21
|
+
import os
|
|
22
|
+
|
|
23
|
+
import cv2
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from .detect import crop_bbox, detect_words
|
|
27
|
+
from .heb_postprocess import cluster_rows, fix_geresh_yod_text, order_row, row_directions, post_process
|
|
28
|
+
from .plan_e_rec import PlanECascade
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def render_page(path, dpi=150, page=1):
|
|
32
|
+
"""Load an image, or render one PDF page, to a BGR numpy array."""
|
|
33
|
+
ext = os.path.splitext(path)[1].lower()
|
|
34
|
+
if ext == ".pdf":
|
|
35
|
+
# pypdfium2 (Apache-2.0 / BSD-3-Clause), not PyMuPDF (AGPL), so the package stays
|
|
36
|
+
# usable wherever Apache-2.0 is
|
|
37
|
+
import pypdfium2 as pdfium
|
|
38
|
+
pdf = pdfium.PdfDocument(path)
|
|
39
|
+
try:
|
|
40
|
+
if not 1 <= page <= len(pdf):
|
|
41
|
+
raise RuntimeError(f"{path} has {len(pdf)} pages; page {page} requested")
|
|
42
|
+
pil = pdf[page - 1].render(scale=dpi / 72).to_pil().convert("RGB")
|
|
43
|
+
finally:
|
|
44
|
+
pdf.close()
|
|
45
|
+
return cv2.cvtColor(np.asarray(pil), cv2.COLOR_RGB2BGR)
|
|
46
|
+
if ext in (".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp"):
|
|
47
|
+
img = cv2.imread(path)
|
|
48
|
+
if img is None:
|
|
49
|
+
raise RuntimeError(f"Cannot open image: {path}")
|
|
50
|
+
return img
|
|
51
|
+
raise RuntimeError(f"Unsupported input: {path}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class HebrewOCR:
|
|
55
|
+
"""End-to-end Hebrew page OCR: DBNet detector + Plan-E cascade recognizer.
|
|
56
|
+
|
|
57
|
+
Two pipelines, same code + same recognizer, only the detector differs:
|
|
58
|
+
|
|
59
|
+
HebrewOCR.word(models_dir) # DEFAULT, flagship. Word-level detection.
|
|
60
|
+
# Faster AND more accurate at page level
|
|
61
|
+
# (7.56% page-CER, 4.95 s/page on Jetson).
|
|
62
|
+
HebrewOCR.line(models_dir) # Line-level detection. Slower + a bit less
|
|
63
|
+
# accurate on clean pages (10.36%, 6.33 s), but
|
|
64
|
+
# WINS on degraded scans / dense hard layouts
|
|
65
|
+
# where word detection over-fragments.
|
|
66
|
+
|
|
67
|
+
Or construct directly and pass your own `det` ONNX + thresholds.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def word(cls, models_dir, **kw):
|
|
72
|
+
"""Flagship word-level pipeline (the default; recommended)."""
|
|
73
|
+
kw.setdefault("det", "word-det/det.onnx")
|
|
74
|
+
kw.setdefault("det_thresh", 0.40)
|
|
75
|
+
kw.setdefault("det_unclip", 1.3)
|
|
76
|
+
return cls(models_dir, **kw)
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def line(cls, models_dir, **kw):
|
|
80
|
+
"""Line-level pipeline — for degraded scans / hard layouts (see class doc)."""
|
|
81
|
+
kw.setdefault("det", "line-det/det.onnx")
|
|
82
|
+
kw.setdefault("det_thresh", 0.30)
|
|
83
|
+
kw.setdefault("det_unclip", 1.8)
|
|
84
|
+
return cls(models_dir, **kw)
|
|
85
|
+
|
|
86
|
+
def __init__(self, models_dir, *,
|
|
87
|
+
det="word-det/det.onnx",
|
|
88
|
+
ctc="server-svtrv2/ctc.onnx",
|
|
89
|
+
nrtr_encoder="server-svtrv2/nrtr-encoder.onnx",
|
|
90
|
+
nrtr_decstep="server-svtrv2/nrtr-decstep.onnx",
|
|
91
|
+
charset="charset_v2f.txt",
|
|
92
|
+
providers=None,
|
|
93
|
+
det_thresh=0.40, det_unclip=1.3, det_max_side=1280,
|
|
94
|
+
dilate_w=0, dilate_h=0,
|
|
95
|
+
conf_threshold=0.50,
|
|
96
|
+
gershayim=True, geresh_yod=True):
|
|
97
|
+
import onnxruntime as ort
|
|
98
|
+
prov = providers or ['CUDAExecutionProvider', 'CPUExecutionProvider']
|
|
99
|
+
|
|
100
|
+
def mp(rel):
|
|
101
|
+
return rel if os.path.isabs(rel) else os.path.join(models_dir, rel)
|
|
102
|
+
|
|
103
|
+
self.det_sess = ort.InferenceSession(mp(det), providers=prov)
|
|
104
|
+
self.det_inp = self.det_sess.get_inputs()[0].name
|
|
105
|
+
self.rec = PlanECascade(mp(ctc), mp(nrtr_encoder), mp(nrtr_decstep),
|
|
106
|
+
mp(charset), conf_threshold=conf_threshold,
|
|
107
|
+
providers=prov)
|
|
108
|
+
self.det_thresh = det_thresh
|
|
109
|
+
self.det_unclip = det_unclip
|
|
110
|
+
self.det_max_side = det_max_side
|
|
111
|
+
self.dilate_w = dilate_w
|
|
112
|
+
self.dilate_h = dilate_h
|
|
113
|
+
self.gershayim = gershayim
|
|
114
|
+
self.geresh_yod = geresh_yod
|
|
115
|
+
|
|
116
|
+
def read(self, path, *, page=1, dpi=150, rec_mode="cascade"):
|
|
117
|
+
"""Recognize one image or PDF page. Returns {lines, words, meta}."""
|
|
118
|
+
img = render_page(path, dpi=dpi, page=page)
|
|
119
|
+
return self.read_array(img, rec_mode=rec_mode)
|
|
120
|
+
|
|
121
|
+
def read_array(self, img_bgr, *, rec_mode="cascade"):
|
|
122
|
+
"""Recognize an in-memory BGR image array."""
|
|
123
|
+
bboxes = detect_words(img_bgr, self.det_sess, self.det_inp,
|
|
124
|
+
thresh=self.det_thresh, unclip_ratio=self.det_unclip,
|
|
125
|
+
max_side=self.det_max_side,
|
|
126
|
+
dilate_w=self.dilate_w, dilate_h=self.dilate_h)
|
|
127
|
+
crops, keep = [], []
|
|
128
|
+
for bb in bboxes:
|
|
129
|
+
c = crop_bbox(img_bgr, bb)
|
|
130
|
+
if c is not None:
|
|
131
|
+
crops.append(c)
|
|
132
|
+
keep.append(bb)
|
|
133
|
+
preds = self.rec.rec_crops(crops, mode=rec_mode)
|
|
134
|
+
|
|
135
|
+
words = []
|
|
136
|
+
for bb, txt in zip(keep, preds):
|
|
137
|
+
if self.gershayim:
|
|
138
|
+
txt = post_process(txt)
|
|
139
|
+
if txt.strip():
|
|
140
|
+
words.append({"bbox": list(bb), "text": txt})
|
|
141
|
+
|
|
142
|
+
# assemble lines in reading order (logical Unicode order)
|
|
143
|
+
lines = []
|
|
144
|
+
rows = cluster_rows(words)
|
|
145
|
+
for row, direction in zip(rows, row_directions(rows)):
|
|
146
|
+
ordered = order_row(row, direction)
|
|
147
|
+
text = " ".join(w["text"] for w in ordered).strip()
|
|
148
|
+
if self.geresh_yod:
|
|
149
|
+
text = fix_geresh_yod_text(text)
|
|
150
|
+
if not text:
|
|
151
|
+
continue
|
|
152
|
+
xs1 = min(w["bbox"][0] for w in ordered); ys1 = min(w["bbox"][1] for w in ordered)
|
|
153
|
+
xs2 = max(w["bbox"][2] for w in ordered); ys2 = max(w["bbox"][3] for w in ordered)
|
|
154
|
+
lines.append({"text": text, "bbox": [xs1, ys1, xs2, ys2],
|
|
155
|
+
"n_words": len(ordered)})
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
"lines": lines,
|
|
159
|
+
"words": words,
|
|
160
|
+
"meta": {
|
|
161
|
+
"n_words": len(words),
|
|
162
|
+
"n_lines": len(lines),
|
|
163
|
+
"n_nrtr_fallback": self.rec.last_n_fallback,
|
|
164
|
+
"rec_mode": rec_mode,
|
|
165
|
+
},
|
|
166
|
+
}
|