indra-ocr 0.3.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.
- indra_ocr/__init__.py +49 -0
- indra_ocr/__main__.py +4 -0
- indra_ocr/cli.py +161 -0
- indra_ocr/det_post.py +155 -0
- indra_ocr/document.py +291 -0
- indra_ocr/layout_post.py +385 -0
- indra_ocr/line_mask.py +129 -0
- indra_ocr/math.py +145 -0
- indra_ocr/models.py +47 -0
- indra_ocr/order_post.py +307 -0
- indra_ocr/parallel.py +210 -0
- indra_ocr/pipeline.py +604 -0
- indra_ocr/rec_preproc.py +97 -0
- indra_ocr/textlayer.py +253 -0
- indra_ocr/viewer.py +638 -0
- indra_ocr/vocab.py +289 -0
- indra_ocr-0.3.0.dist-info/METADATA +305 -0
- indra_ocr-0.3.0.dist-info/RECORD +22 -0
- indra_ocr-0.3.0.dist-info/WHEEL +5 -0
- indra_ocr-0.3.0.dist-info/entry_points.txt +3 -0
- indra_ocr-0.3.0.dist-info/licenses/LICENSE +202 -0
- indra_ocr-0.3.0.dist-info/top_level.txt +1 -0
indra_ocr/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Indra OCR: fast CPU OCR (line detection, CTC recognition, reading order, markdown)."""
|
|
2
|
+
|
|
3
|
+
from .document import Converter
|
|
4
|
+
from .models import model_dir
|
|
5
|
+
from .parallel import convert_many, usable_cores
|
|
6
|
+
from .pipeline import InputError, Line, OrtOCR, Page, load_pages
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Converter",
|
|
10
|
+
"InputError",
|
|
11
|
+
"Line",
|
|
12
|
+
"OrtOCR",
|
|
13
|
+
"Page",
|
|
14
|
+
"convert_many",
|
|
15
|
+
"usable_cores",
|
|
16
|
+
"load_pages",
|
|
17
|
+
"load",
|
|
18
|
+
"model_dir",
|
|
19
|
+
]
|
|
20
|
+
__version__ = "0.3.0"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load(
|
|
24
|
+
path=None,
|
|
25
|
+
threads: int = 4,
|
|
26
|
+
dpi: float = 200.0,
|
|
27
|
+
mode: str = "auto",
|
|
28
|
+
pdf_order: bool = True,
|
|
29
|
+
image_ocr: bool = True,
|
|
30
|
+
providers: list | None = None,
|
|
31
|
+
furniture: bool = False,
|
|
32
|
+
math: bool = False,
|
|
33
|
+
) -> Converter:
|
|
34
|
+
"""A ready Converter (see ``Converter`` for the options). mode="ocr" never
|
|
35
|
+
reads the PDF text layer. furniture: keep page headers and footers in the
|
|
36
|
+
markdown. math: write math as LaTeX (slower on math pages; off by default)."""
|
|
37
|
+
return Converter(
|
|
38
|
+
OrtOCR(
|
|
39
|
+
model_dir(path, math=math),
|
|
40
|
+
threads=threads,
|
|
41
|
+
providers=providers,
|
|
42
|
+
furniture=furniture,
|
|
43
|
+
math=math,
|
|
44
|
+
),
|
|
45
|
+
dpi=dpi,
|
|
46
|
+
mode=mode,
|
|
47
|
+
pdf_order=pdf_order,
|
|
48
|
+
image_ocr=image_ocr,
|
|
49
|
+
)
|
indra_ocr/__main__.py
ADDED
indra_ocr/cli.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Command line: PDFs or images in, markdown (or text, or JSON lines) out.
|
|
2
|
+
|
|
3
|
+
indra-ocr paper.pdf # markdown to stdout
|
|
4
|
+
indra-ocr *.pdf --out results/ # one .md per input
|
|
5
|
+
indra-ocr scan.png --format json # lines with boxes, confidences, timings
|
|
6
|
+
|
|
7
|
+
Pages run in parallel on all the cores the process may use (--cores to limit).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import asdict
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import InputError
|
|
19
|
+
from .parallel import convert_many, usable_cores
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def add_source_args(ap: argparse.ArgumentParser) -> None:
|
|
23
|
+
"""Options that choose between the PDF text layer and OCR."""
|
|
24
|
+
g = ap.add_argument_group("text layer or OCR (PDFs)")
|
|
25
|
+
g.add_argument(
|
|
26
|
+
"--mode",
|
|
27
|
+
choices=["auto", "ocr", "text"],
|
|
28
|
+
default="auto",
|
|
29
|
+
help="auto: text layer where it passes the checks, OCR elsewhere (default); "
|
|
30
|
+
"ocr: never read the text layer; text: use the text layer even when the "
|
|
31
|
+
"checks fail",
|
|
32
|
+
)
|
|
33
|
+
g.add_argument(
|
|
34
|
+
"--model-order",
|
|
35
|
+
action="store_true",
|
|
36
|
+
help="order text-layer lines with the order model, not the PDF content order",
|
|
37
|
+
)
|
|
38
|
+
g.add_argument(
|
|
39
|
+
"--no-image-ocr",
|
|
40
|
+
action="store_true",
|
|
41
|
+
help="on text-layer pages, do not OCR text inside images",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def source_options(a) -> dict:
|
|
46
|
+
return {
|
|
47
|
+
"mode": a.mode,
|
|
48
|
+
"pdf_order": not a.model_order,
|
|
49
|
+
"image_ocr": not a.no_image_ocr,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main(argv=None):
|
|
54
|
+
ap = argparse.ArgumentParser(
|
|
55
|
+
prog="indra-ocr",
|
|
56
|
+
description=__doc__,
|
|
57
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
58
|
+
)
|
|
59
|
+
ap.add_argument("inputs", nargs="+", type=Path)
|
|
60
|
+
ap.add_argument(
|
|
61
|
+
"--out", type=Path, default=None, help="write one file per input here"
|
|
62
|
+
)
|
|
63
|
+
ap.add_argument("--format", choices=["md", "text", "json"], default="md")
|
|
64
|
+
add_source_args(ap)
|
|
65
|
+
ap.add_argument("--model-dir", type=Path, default=None)
|
|
66
|
+
ap.add_argument(
|
|
67
|
+
"--cores",
|
|
68
|
+
"--threads",
|
|
69
|
+
dest="cores",
|
|
70
|
+
type=int,
|
|
71
|
+
default=0,
|
|
72
|
+
help=f"cores to use (default: all this process may run on, here {usable_cores()})",
|
|
73
|
+
)
|
|
74
|
+
ap.add_argument("--dpi", type=float, default=200.0)
|
|
75
|
+
ap.add_argument("--max-pages", type=int, default=0)
|
|
76
|
+
ap.add_argument("--password", default=None, help="password of encrypted PDFs")
|
|
77
|
+
ap.add_argument(
|
|
78
|
+
"--math",
|
|
79
|
+
action="store_true",
|
|
80
|
+
help="write math as LaTeX ($...$); slower on pages with math (off by default)",
|
|
81
|
+
)
|
|
82
|
+
ap.add_argument(
|
|
83
|
+
"--keep-furniture",
|
|
84
|
+
action="store_true",
|
|
85
|
+
help="keep page headers and footers (page numbers, running titles) in the markdown",
|
|
86
|
+
)
|
|
87
|
+
ap.add_argument(
|
|
88
|
+
"--timings", action="store_true", help="print per-page timings to stderr"
|
|
89
|
+
)
|
|
90
|
+
a = ap.parse_args(argv)
|
|
91
|
+
if a.out:
|
|
92
|
+
a.out.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
ext = {"md": ".md", "text": ".txt", "json": ".jsonl"}[a.format]
|
|
94
|
+
failed, names = 0, set()
|
|
95
|
+
results = convert_many(
|
|
96
|
+
a.inputs,
|
|
97
|
+
cores=a.cores,
|
|
98
|
+
max_pages=a.max_pages,
|
|
99
|
+
password=a.password,
|
|
100
|
+
path=a.model_dir,
|
|
101
|
+
dpi=a.dpi,
|
|
102
|
+
furniture=a.keep_furniture,
|
|
103
|
+
math=a.math,
|
|
104
|
+
**source_options(a),
|
|
105
|
+
)
|
|
106
|
+
for path, result in results:
|
|
107
|
+
path = Path(path)
|
|
108
|
+
chunks = []
|
|
109
|
+
if isinstance(result, InputError):
|
|
110
|
+
print(f"indra-ocr: {result}", file=sys.stderr)
|
|
111
|
+
failed += 1
|
|
112
|
+
continue
|
|
113
|
+
for i, page in result:
|
|
114
|
+
if page.source == "error":
|
|
115
|
+
print(f"indra-ocr: {path} page {i + 1}: {page.note}", file=sys.stderr)
|
|
116
|
+
if a.format == "json":
|
|
117
|
+
chunks.append(
|
|
118
|
+
json.dumps(
|
|
119
|
+
{
|
|
120
|
+
"file": str(path),
|
|
121
|
+
"page": i,
|
|
122
|
+
"source": page.source,
|
|
123
|
+
"rotation": page.rotation,
|
|
124
|
+
"skew": page.skew,
|
|
125
|
+
"note": page.note,
|
|
126
|
+
"lines": [asdict(ln) for ln in page.lines],
|
|
127
|
+
"markdown": page.markdown,
|
|
128
|
+
"blocks": page.blocks,
|
|
129
|
+
"timings": page.timings,
|
|
130
|
+
},
|
|
131
|
+
ensure_ascii=False,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
elif a.format == "md":
|
|
135
|
+
chunks.append(page.markdown if page.markdown is not None else page.text)
|
|
136
|
+
else:
|
|
137
|
+
chunks.append(page.text)
|
|
138
|
+
if a.timings:
|
|
139
|
+
t = page.timings
|
|
140
|
+
print(
|
|
141
|
+
f"{path} page {i + 1}: {page.source}, {t.get('total_ms', 0):.0f} ms {page.note}",
|
|
142
|
+
file=sys.stderr,
|
|
143
|
+
)
|
|
144
|
+
sep = "\n" if a.format == "json" else "\n\n"
|
|
145
|
+
body = sep.join(chunks) + "\n"
|
|
146
|
+
if a.out:
|
|
147
|
+
name, k = path.stem, 1
|
|
148
|
+
while name in names: # two inputs with one name: keep both outputs
|
|
149
|
+
k += 1
|
|
150
|
+
name = f"{path.stem}_{k}"
|
|
151
|
+
names.add(name)
|
|
152
|
+
(a.out / (name + ext)).write_text(body, encoding="utf-8")
|
|
153
|
+
else:
|
|
154
|
+
sys.stdout.write(body)
|
|
155
|
+
if failed:
|
|
156
|
+
print(f"indra-ocr: {failed} of {len(a.inputs)} inputs failed", file=sys.stderr)
|
|
157
|
+
return 1 if failed else 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
sys.exit(main())
|
indra_ocr/det_post.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Torch-free pre- and post-processing for LineNet (shared by training and inference).
|
|
2
|
+
|
|
3
|
+
Inference with ONNX Runtime needs only numpy and cv2, so these functions live
|
|
4
|
+
apart from the PyTorch model in det_model.py, which re-exports them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
# Target geometry, shared by training (encode) and inference (decode).
|
|
14
|
+
CORE_FRAC = 0.4 # core band height as a fraction of the line ink height
|
|
15
|
+
CORE_X_SHRINK = 0.15 # the core is shorter than the line by this fraction of its height, at each end
|
|
16
|
+
OUT_STRIDE = 2
|
|
17
|
+
INPUT_MULTIPLE = 32 # H and W must divide by this
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Input preprocessing (shared by training and inference)
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def page_to_input(gray: np.ndarray, long_side: int) -> tuple[np.ndarray, float]:
|
|
26
|
+
"""Resize a uint8 grayscale page so its long side is ``long_side`` and pad
|
|
27
|
+
to a multiple of 32 with white. Returns (float32 [H, W] ink map, scale)."""
|
|
28
|
+
import cv2
|
|
29
|
+
|
|
30
|
+
h, w = gray.shape
|
|
31
|
+
scale = long_side / max(h, w)
|
|
32
|
+
nh, nw = max(1, round(h * scale)), max(1, round(w * scale))
|
|
33
|
+
if (nh, nw) != (h, w):
|
|
34
|
+
interp = cv2.INTER_AREA if scale < 1 else cv2.INTER_LINEAR
|
|
35
|
+
gray = cv2.resize(gray, (nw, nh), interpolation=interp)
|
|
36
|
+
ph = math.ceil(nh / INPUT_MULTIPLE) * INPUT_MULTIPLE
|
|
37
|
+
pw = math.ceil(nw / INPUT_MULTIPLE) * INPUT_MULTIPLE
|
|
38
|
+
out = np.zeros((ph, pw), np.float32) # ink map: 0 = white
|
|
39
|
+
out[:nh, :nw] = 1.0 - gray.astype(np.float32) / 255.0
|
|
40
|
+
return out, scale
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Targets
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def encode_targets(
|
|
49
|
+
boxes: np.ndarray,
|
|
50
|
+
in_hw: tuple[int, int],
|
|
51
|
+
valid_hw: tuple[int, int],
|
|
52
|
+
ignore: np.ndarray | None = None,
|
|
53
|
+
):
|
|
54
|
+
"""Build training targets at 1/2 resolution.
|
|
55
|
+
|
|
56
|
+
boxes: [N, 4] line ink boxes in input pixels (x0, y0, x1, y1)
|
|
57
|
+
in_hw: padded input size; valid_hw: size of the real page inside it
|
|
58
|
+
ignore: [M, 4] regions with no loss, in input pixels
|
|
59
|
+
|
|
60
|
+
Returns core [h, w] float32, dist [2, h, w] float32, weight [h, w] float32
|
|
61
|
+
(0 on padding and ignore regions), and reg_mask [h, w] bool (core pixels).
|
|
62
|
+
"""
|
|
63
|
+
s = OUT_STRIDE
|
|
64
|
+
h, w = in_hw[0] // s, in_hw[1] // s
|
|
65
|
+
core = np.zeros((h, w), np.float32)
|
|
66
|
+
dist = np.zeros((2, h, w), np.float32)
|
|
67
|
+
weight = np.zeros((h, w), np.float32)
|
|
68
|
+
weight[: math.ceil(valid_hw[0] / s), : math.ceil(valid_hw[1] / s)] = 1.0
|
|
69
|
+
if ignore is not None:
|
|
70
|
+
for x0, y0, x1, y1 in ignore:
|
|
71
|
+
weight[
|
|
72
|
+
max(0, int(y0 // s)) : max(0, math.ceil(y1 / s)),
|
|
73
|
+
max(0, int(x0 // s)) : max(0, math.ceil(x1 / s)),
|
|
74
|
+
] = 0.0
|
|
75
|
+
# Larger lines first, so a small line drawn later keeps its own band.
|
|
76
|
+
order = np.argsort(-(boxes[:, 3] - boxes[:, 1])) if len(boxes) else []
|
|
77
|
+
ys = (np.arange(h, dtype=np.float32) + 0.5) * s # pixel centres in input px
|
|
78
|
+
for i in order:
|
|
79
|
+
x0, y0, x1, y1 = boxes[i]
|
|
80
|
+
lh = y1 - y0
|
|
81
|
+
if lh < 2 or x1 - x0 < 2:
|
|
82
|
+
continue
|
|
83
|
+
cy = 0.5 * (y0 + y1)
|
|
84
|
+
half = 0.5 * CORE_FRAC * lh
|
|
85
|
+
r0 = max(0, int(math.floor((cy - half) / s)))
|
|
86
|
+
r1 = min(h, max(r0 + 1, int(math.ceil((cy + half) / s))))
|
|
87
|
+
sx = min(CORE_X_SHRINK * lh, 0.25 * (x1 - x0))
|
|
88
|
+
c0 = max(0, int(math.floor((x0 + sx) / s)))
|
|
89
|
+
c1 = min(w, max(c0 + 1, int(math.ceil((x1 - sx) / s))))
|
|
90
|
+
if r0 >= h or c0 >= w:
|
|
91
|
+
continue
|
|
92
|
+
core[r0:r1, c0:c1] = 1.0
|
|
93
|
+
dist[0, r0:r1, c0:c1] = (ys[r0:r1] - y0)[:, None]
|
|
94
|
+
dist[1, r0:r1, c0:c1] = (y1 - ys[r0:r1])[:, None]
|
|
95
|
+
# A line inside an ignore region is still a positive.
|
|
96
|
+
weight[r0:r1, c0:c1] = np.maximum(weight[r0:r1, c0:c1], 1.0)
|
|
97
|
+
return core, dist, weight, core > 0.5
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Decoding
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def decode(
|
|
106
|
+
out: np.ndarray,
|
|
107
|
+
thresh: float = 0.5,
|
|
108
|
+
min_area: int = 2,
|
|
109
|
+
grow_y: float = 0.0,
|
|
110
|
+
grow_x: float = 0.0,
|
|
111
|
+
) -> np.ndarray:
|
|
112
|
+
"""Decode one page. out: [3, h, w] (core logit, top dist, bottom dist).
|
|
113
|
+
|
|
114
|
+
``grow_y`` / ``grow_x`` widen every box by that fraction of its height
|
|
115
|
+
(each side), to absorb the +-1 px error of the 1/2-resolution output.
|
|
116
|
+
|
|
117
|
+
Returns [N, 5] boxes in input pixels: x0, y0, x1, y1, score."""
|
|
118
|
+
import cv2
|
|
119
|
+
|
|
120
|
+
prob = 1.0 / (1.0 + np.exp(-out[0]))
|
|
121
|
+
mask = (prob > thresh).astype(np.uint8)
|
|
122
|
+
n, lab, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=4)
|
|
123
|
+
if n <= 1:
|
|
124
|
+
return np.zeros((0, 5), np.float32)
|
|
125
|
+
s = OUT_STRIDE
|
|
126
|
+
flat = lab.ravel()
|
|
127
|
+
keep = flat > 0
|
|
128
|
+
idx = flat[keep]
|
|
129
|
+
ys = (np.indices(lab.shape)[0].ravel()[keep].astype(np.float32) + 0.5) * s
|
|
130
|
+
top = ys - out[1].ravel()[keep]
|
|
131
|
+
bot = ys + out[2].ravel()[keep]
|
|
132
|
+
sc = prob.ravel()[keep]
|
|
133
|
+
cnt = np.bincount(idx, minlength=n).astype(np.float32)
|
|
134
|
+
# Mean is a robust enough summary here and is far cheaper than a median.
|
|
135
|
+
y0 = np.bincount(idx, weights=top, minlength=n) / np.maximum(cnt, 1)
|
|
136
|
+
y1 = np.bincount(idx, weights=bot, minlength=n) / np.maximum(cnt, 1)
|
|
137
|
+
score = np.bincount(idx, weights=sc, minlength=n) / np.maximum(cnt, 1)
|
|
138
|
+
boxes = []
|
|
139
|
+
for i in range(1, n):
|
|
140
|
+
if stats[i, cv2.CC_STAT_AREA] < min_area:
|
|
141
|
+
continue
|
|
142
|
+
lh = max(1.0, y1[i] - y0[i])
|
|
143
|
+
ex = min(CORE_X_SHRINK * lh, 0.5 * lh)
|
|
144
|
+
x0 = stats[i, cv2.CC_STAT_LEFT] * s - ex
|
|
145
|
+
x1 = (stats[i, cv2.CC_STAT_LEFT] + stats[i, cv2.CC_STAT_WIDTH]) * s + ex
|
|
146
|
+
boxes.append(
|
|
147
|
+
(
|
|
148
|
+
x0 - grow_x * lh,
|
|
149
|
+
y0[i] - grow_y * lh,
|
|
150
|
+
x1 + grow_x * lh,
|
|
151
|
+
y1[i] + grow_y * lh,
|
|
152
|
+
score[i],
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
return np.asarray(boxes, np.float32).reshape(-1, 5)
|
indra_ocr/document.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Whole documents: the PDF text layer where it can be trusted, OCR elsewhere.
|
|
2
|
+
|
|
3
|
+
For each PDF page (``mode="auto"``):
|
|
4
|
+
|
|
5
|
+
1. Read the text layer with pdfium and check it (``textlayer.read_text_layer``). A page
|
|
6
|
+
without a sound layer (a scan, an OCR layer, a broken font map) goes to OCR.
|
|
7
|
+
2. The detector finds the visual lines, as in OCR. Each text-layer character
|
|
8
|
+
goes to the detected line that contains its center. Inside a line, the
|
|
9
|
+
characters of each text-layer run (pdfium's segment between line breaks) keep the PDF order
|
|
10
|
+
and spaces, and runs are ordered by x, right to left in a right-to-left line. So inline math, sub- and
|
|
11
|
+
superscripts and footnote marks land in their line, and a run across two
|
|
12
|
+
table cells splits between them. A detected line with no text (text inside
|
|
13
|
+
an image) is OCR'd when the recognizer reads the page's script.
|
|
14
|
+
3. The rest is the OCR path: lines split at wide gaps, reading order, layout
|
|
15
|
+
classes and markdown. The PDF content order is kept when it is coherent
|
|
16
|
+
(few upward jumps that are not column changes, and the order model agrees
|
|
17
|
+
with at least 85% of consecutive pairs).
|
|
18
|
+
|
|
19
|
+
``mode="ocr"`` never reads the text layer; ``mode="text"`` uses it even when
|
|
20
|
+
the checks fail (and OCR only on pages with no text at all).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import time
|
|
26
|
+
import unicodedata
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
from .pipeline import InputError, OrtOCR, Page, is_pdf, read_images
|
|
32
|
+
from .textlayer import BROKEN_FONT_MAP, read_text_layer
|
|
33
|
+
|
|
34
|
+
CHECK_DPI = 72.0 # resolution of the render for the text-layer ink checks
|
|
35
|
+
MAX_PAGE_PIXELS = 100e6 # a page larger than this at the set dpi renders at a lower dpi (large scans reach ~65 MP)
|
|
36
|
+
|
|
37
|
+
READABLE_MIN = 0.9 # share of the layer's letters the recognizer must know
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def readable(ocr: OrtOCR, runs: list) -> bool:
|
|
41
|
+
"""Whether the recognizer can read the script of these text-layer runs."""
|
|
42
|
+
letters = [c for r in runs for c in r.text if c.isalpha()]
|
|
43
|
+
if not letters:
|
|
44
|
+
return True
|
|
45
|
+
cjk = ocr.vocab_cjk.index if ocr.vocab_cjk is not None else {}
|
|
46
|
+
known = sum(c in ocr.vocab.index or c in cjk for c in letters)
|
|
47
|
+
return known >= READABLE_MIN * len(letters)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def right_to_left(tokens: list) -> bool:
|
|
51
|
+
"""Unicode bidi base direction: most strong characters are right-to-left."""
|
|
52
|
+
rtl = ltr = 0
|
|
53
|
+
for t in tokens:
|
|
54
|
+
b = unicodedata.bidirectional(t[0]) if len(t[0]) == 1 else ""
|
|
55
|
+
rtl += b in ("R", "AL")
|
|
56
|
+
ltr += b == "L"
|
|
57
|
+
return rtl > ltr
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _render(page, scale: float) -> np.ndarray:
|
|
61
|
+
arr = page.render(scale=scale, grayscale=True).to_numpy()
|
|
62
|
+
return np.ascontiguousarray(arr[..., 0] if arr.ndim == 3 else arr)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def assign_runs(runs: list, boxes: np.ndarray, k: float) -> tuple[list[list], int]:
|
|
66
|
+
"""Tokens (char, x, content index) per detected line.
|
|
67
|
+
|
|
68
|
+
A run is one text-layer line piece (``textlayer.TextLine``), in content
|
|
69
|
+
order. Each character goes to the smallest detected box that contains its
|
|
70
|
+
center (a space follows the character before it), so a run that spans two
|
|
71
|
+
table cells splits between them. Inside a detected line, each run's part
|
|
72
|
+
keeps the PDF character order, and the parts are ordered by x. ``boxes``
|
|
73
|
+
are pixels, ``k`` pixels per point. Returns (tokens per detected line,
|
|
74
|
+
characters outside every box)."""
|
|
75
|
+
parts: list[dict] = [{} for _ in range(len(boxes))] # line -> run idx -> tokens
|
|
76
|
+
lost = 0
|
|
77
|
+
if not len(boxes):
|
|
78
|
+
return [], sum(not c.isspace() for r in runs for c, _ in r.chars)
|
|
79
|
+
area = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1])
|
|
80
|
+
for idx, run in enumerate(runs):
|
|
81
|
+
j = None
|
|
82
|
+
for c, b in run.chars:
|
|
83
|
+
if not c.isspace():
|
|
84
|
+
cx, cy = (b[0] + b[2]) / 2 * k, (b[1] + b[3]) / 2 * k
|
|
85
|
+
inside = (
|
|
86
|
+
(boxes[:, 0] <= cx)
|
|
87
|
+
& (cx <= boxes[:, 2])
|
|
88
|
+
& (boxes[:, 1] <= cy)
|
|
89
|
+
& (cy <= boxes[:, 3])
|
|
90
|
+
)
|
|
91
|
+
if not inside.any():
|
|
92
|
+
lost += 1
|
|
93
|
+
j = None
|
|
94
|
+
continue
|
|
95
|
+
j = int(np.flatnonzero(inside)[np.argmin(area[inside])])
|
|
96
|
+
parts[j].setdefault(idx, []).append((c, cx, idx))
|
|
97
|
+
elif j is not None:
|
|
98
|
+
prev = parts[j][idx][-1]
|
|
99
|
+
parts[j][idx].append((c, prev[1], idx))
|
|
100
|
+
toks: list[list] = []
|
|
101
|
+
for line in parts:
|
|
102
|
+
tk: list = []
|
|
103
|
+
rtl = right_to_left([t for part in line.values() for t in part])
|
|
104
|
+
order = (
|
|
105
|
+
sorted(line, key=lambda i: -max(t[1] for t in line[i]))
|
|
106
|
+
if rtl
|
|
107
|
+
else sorted(line, key=lambda i: min(t[1] for t in line[i]))
|
|
108
|
+
)
|
|
109
|
+
for idx in order:
|
|
110
|
+
if tk:
|
|
111
|
+
tk.append((" ", tk[-1][1], idx)) # parts of different runs
|
|
112
|
+
tk += line[idx]
|
|
113
|
+
toks.append(tk)
|
|
114
|
+
return toks, lost
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Converter:
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
ocr: OrtOCR,
|
|
121
|
+
dpi: float = 200.0,
|
|
122
|
+
mode: str = "auto",
|
|
123
|
+
pdf_order: bool = True,
|
|
124
|
+
image_ocr: bool = True,
|
|
125
|
+
):
|
|
126
|
+
"""mode: auto (text layer where sound, OCR elsewhere), ocr (always OCR,
|
|
127
|
+
never read the text layer), text (text layer even when the checks fail).
|
|
128
|
+
pdf_order: keep the PDF content order when it is coherent (False: the
|
|
129
|
+
order model always orders the lines). image_ocr: OCR detected lines that
|
|
130
|
+
hold no text-layer characters (False: drop them)."""
|
|
131
|
+
if mode not in ("auto", "ocr", "text"):
|
|
132
|
+
raise ValueError(f"mode must be auto, ocr or text, not {mode!r}")
|
|
133
|
+
self.ocr, self.dpi, self.mode = ocr, dpi, mode
|
|
134
|
+
self.pdf_order, self.image_ocr = pdf_order, image_ocr
|
|
135
|
+
|
|
136
|
+
def page_scale(self, page) -> float:
|
|
137
|
+
"""Pixels per point for this page: the set dpi, lowered for pages so
|
|
138
|
+
large that the render would pass MAX_PAGE_PIXELS (posters, plans)."""
|
|
139
|
+
w, h = page.get_size()
|
|
140
|
+
k = self.dpi / 72.0
|
|
141
|
+
return min(k, (MAX_PAGE_PIXELS / max(w * h, 1.0)) ** 0.5)
|
|
142
|
+
|
|
143
|
+
def pdf_page(self, pdf_path: str, doc, index: int) -> Page:
|
|
144
|
+
page = doc[index]
|
|
145
|
+
t: dict = {}
|
|
146
|
+
note = "mode=ocr"
|
|
147
|
+
if self.mode != "ocr":
|
|
148
|
+
t0 = time.perf_counter()
|
|
149
|
+
s = CHECK_DPI / 72.0
|
|
150
|
+
tl = read_text_layer(page, _render(page, s), s)
|
|
151
|
+
t["text_ms"] = 1000 * (time.perf_counter() - t0)
|
|
152
|
+
# A layer the recognizer cannot read beats OCR, unless its codes are junk.
|
|
153
|
+
unreadable = tl.reason != BROKEN_FONT_MAP and not readable(
|
|
154
|
+
self.ocr, tl.lines
|
|
155
|
+
)
|
|
156
|
+
if tl.lines and (tl.trusted or self.mode == "text" or unreadable):
|
|
157
|
+
return self._text_page(page, tl, t)
|
|
158
|
+
note = tl.reason
|
|
159
|
+
# A layer with a broken font map still places its glyphs: when most
|
|
160
|
+
# run upright, the page is upright (orient.onnx is not asked).
|
|
161
|
+
st = tl.stats
|
|
162
|
+
turn = not (
|
|
163
|
+
tl.reason == BROKEN_FONT_MAP and st["rotated"] < 0.5 * st["chars"]
|
|
164
|
+
)
|
|
165
|
+
else:
|
|
166
|
+
turn = True
|
|
167
|
+
t0 = time.perf_counter()
|
|
168
|
+
gray = _render(page, self.page_scale(page))
|
|
169
|
+
t["render_ms"] = 1000 * (time.perf_counter() - t0)
|
|
170
|
+
out = self.ocr(gray, turn)
|
|
171
|
+
out.timings = {**t, **out.timings}
|
|
172
|
+
out.timings["total_ms"] = sum(
|
|
173
|
+
v for k, v in out.timings.items() if k != "total_ms"
|
|
174
|
+
)
|
|
175
|
+
out.note = f"{note}; {out.note}"
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
def _text_page(self, page, tl, t: dict) -> Page:
|
|
179
|
+
k = self.page_scale(page)
|
|
180
|
+
t0 = time.perf_counter()
|
|
181
|
+
gray = _render(page, k)
|
|
182
|
+
t["render_ms"] = 1000 * (time.perf_counter() - t0)
|
|
183
|
+
t0 = time.perf_counter()
|
|
184
|
+
boxes = self.ocr.detect(gray)
|
|
185
|
+
t["det_ms"] = 1000 * (time.perf_counter() - t0)
|
|
186
|
+
toks, lost = assign_runs(tl.lines, boxes[:, :4], k)
|
|
187
|
+
rec: list = [None] * len(boxes)
|
|
188
|
+
empty = []
|
|
189
|
+
for i, tk in enumerate(toks):
|
|
190
|
+
text = self.ocr._decode(tk) if tk else ""
|
|
191
|
+
if text:
|
|
192
|
+
rec[i] = (text, 1.0, tk)
|
|
193
|
+
else:
|
|
194
|
+
empty.append(i)
|
|
195
|
+
source = "text"
|
|
196
|
+
if empty and self.image_ocr and readable(self.ocr, tl.lines):
|
|
197
|
+
t0 = time.perf_counter()
|
|
198
|
+
for i, r in zip(empty, self.ocr.recognize(gray, boxes[empty])):
|
|
199
|
+
if r[0].strip():
|
|
200
|
+
rec[i] = r
|
|
201
|
+
source = "hybrid"
|
|
202
|
+
t["rec_ms"] = 1000 * (time.perf_counter() - t0)
|
|
203
|
+
if getattr(self.ocr, "math", None) is not None:
|
|
204
|
+
t0 = time.perf_counter()
|
|
205
|
+
self._read_math(gray, boxes, rec)
|
|
206
|
+
t["math_ms"] = 1000 * (time.perf_counter() - t0)
|
|
207
|
+
keep = [i for i, r in enumerate(rec) if r is not None]
|
|
208
|
+
out = self.ocr.assemble(
|
|
209
|
+
(gray.shape[1], gray.shape[0]),
|
|
210
|
+
boxes[keep].reshape(-1, boxes.shape[1]),
|
|
211
|
+
[rec[i] for i in keep],
|
|
212
|
+
t,
|
|
213
|
+
content_order=self.pdf_order,
|
|
214
|
+
)
|
|
215
|
+
out.source = source
|
|
216
|
+
out.note += f"; {lost} chars outside detected lines" if lost else ""
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
def _read_math(self, gray, boxes, rec: list) -> None:
|
|
220
|
+
"""Text-layer lines that hold math (the classifier, on their crops) are read
|
|
221
|
+
by MathRec instead: the layer has the symbols but not the LaTeX. The line
|
|
222
|
+
keeps its place in the PDF content order (its first character's index)."""
|
|
223
|
+
from .line_mask import crop_lines
|
|
224
|
+
|
|
225
|
+
idx = [
|
|
226
|
+
i
|
|
227
|
+
for i, r in enumerate(rec)
|
|
228
|
+
if r is not None
|
|
229
|
+
and r[2]
|
|
230
|
+
and isinstance(r[2][0][0], str)
|
|
231
|
+
and len(r[2][0]) > 2
|
|
232
|
+
]
|
|
233
|
+
if not idx:
|
|
234
|
+
return
|
|
235
|
+
crops = crop_lines(gray, boxes[idx, :4], self.ocr.height, None)
|
|
236
|
+
math = [i for i, f in zip(idx, self.ocr.math.flags(crops)) if f]
|
|
237
|
+
if not math:
|
|
238
|
+
return
|
|
239
|
+
from .math import MATH_MIN_CONF
|
|
240
|
+
|
|
241
|
+
for i, (text, conf) in zip(math, self.ocr.math.read(gray, boxes[math])):
|
|
242
|
+
# A reading without math, or not confident, leaves the layer's text.
|
|
243
|
+
if "$" in text and conf >= MATH_MIN_CONF:
|
|
244
|
+
first = min(tk[2] for tk in rec[i][2])
|
|
245
|
+
xc = float(boxes[i, 0] + boxes[i, 2]) / 2
|
|
246
|
+
rec[i] = (text, 1.0, [(text, xc, first)])
|
|
247
|
+
|
|
248
|
+
def convert(
|
|
249
|
+
self, path: str | Path, max_pages: int = 0, password: str | None = None
|
|
250
|
+
):
|
|
251
|
+
"""Yield (page index, Page) for a PDF or an image file (the file's bytes
|
|
252
|
+
decide which). A page that fails yields an empty Page whose ``source``
|
|
253
|
+
is "error" and whose ``note`` says why; a file that cannot be opened
|
|
254
|
+
raises InputError."""
|
|
255
|
+
path = Path(path)
|
|
256
|
+
if not path.is_file():
|
|
257
|
+
raise InputError(f"{path}: no such file")
|
|
258
|
+
if not is_pdf(path):
|
|
259
|
+
for i, gray in enumerate(read_images(path)[: max_pages or None]):
|
|
260
|
+
yield i, self._safe(lambda: self.ocr(gray))
|
|
261
|
+
return
|
|
262
|
+
import pypdfium2 as pdfium
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
doc = pdfium.PdfDocument(str(path), password=password)
|
|
266
|
+
except pdfium.PdfiumError as e:
|
|
267
|
+
locked = "password" in str(e).lower()
|
|
268
|
+
raise InputError(
|
|
269
|
+
f"{path}: password-protected PDF (pass password=, or --password)"
|
|
270
|
+
if locked
|
|
271
|
+
else f"{path}: cannot open the PDF ({e})"
|
|
272
|
+
) from e
|
|
273
|
+
try:
|
|
274
|
+
n = len(doc) if not max_pages else min(len(doc), max_pages)
|
|
275
|
+
for i in range(n):
|
|
276
|
+
yield i, self._safe(lambda: self.pdf_page(str(path), doc, i))
|
|
277
|
+
finally:
|
|
278
|
+
doc.close()
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _safe(read) -> Page:
|
|
282
|
+
try:
|
|
283
|
+
return read()
|
|
284
|
+
except Exception as e: # noqa: BLE001 (one bad page must not end the document)
|
|
285
|
+
return Page(
|
|
286
|
+
[],
|
|
287
|
+
{"total_ms": 0.0},
|
|
288
|
+
markdown="",
|
|
289
|
+
source="error",
|
|
290
|
+
note=f"{type(e).__name__}: {e}",
|
|
291
|
+
)
|