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.
@@ -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
+ }