wormocr 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.
- wormocr/__init__.py +18 -0
- wormocr/__main__.py +4 -0
- wormocr/api.py +229 -0
- wormocr/benchmark/__init__.py +5 -0
- wormocr/benchmark/report.py +403 -0
- wormocr/checkpoints.py +55 -0
- wormocr/cli.py +308 -0
- wormocr/config.py +220 -0
- wormocr/data/__init__.py +5 -0
- wormocr/data/dataset.py +110 -0
- wormocr/data/fonts.py +173 -0
- wormocr/data/handwriting.py +304 -0
- wormocr/data/lexicon.py +105 -0
- wormocr/data/lexicon_medical.py +48 -0
- wormocr/data/mixed.py +211 -0
- wormocr/data/synthetic.py +445 -0
- wormocr/detection/__init__.py +5 -0
- wormocr/detection/lines.py +382 -0
- wormocr/evaluation/__init__.py +5 -0
- wormocr/evaluation/errors.py +145 -0
- wormocr/evaluation/metrics.py +105 -0
- wormocr/evaluation/robustness.py +43 -0
- wormocr/exceptions.py +25 -0
- wormocr/export/__init__.py +5 -0
- wormocr/export/onnx_export.py +75 -0
- wormocr/hub.py +157 -0
- wormocr/inference/__init__.py +5 -0
- wormocr/inference/decode.py +113 -0
- wormocr/inference/engine.py +122 -0
- wormocr/models/__init__.py +5 -0
- wormocr/models/crnn.py +236 -0
- wormocr/preprocessing.py +135 -0
- wormocr/py.typed +0 -0
- wormocr/runtime.py +56 -0
- wormocr/training/__init__.py +5 -0
- wormocr/training/loop.py +645 -0
- wormocr/vocabulary.py +69 -0
- wormocr/web/__init__.py +5 -0
- wormocr/web/server.py +505 -0
- wormocr/web/static/app.js +514 -0
- wormocr/web/static/index.html +141 -0
- wormocr/web/static/style.css +379 -0
- wormocr-1.0.0.dist-info/METADATA +377 -0
- wormocr-1.0.0.dist-info/RECORD +48 -0
- wormocr-1.0.0.dist-info/WHEEL +5 -0
- wormocr-1.0.0.dist-info/entry_points.txt +2 -0
- wormocr-1.0.0.dist-info/licenses/LICENSE +21 -0
- wormocr-1.0.0.dist-info/top_level.txt +1 -0
wormocr/exceptions.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Errors raised by wormOCR. They are intentional and safe to show to users."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class WormOCRError(Exception):
|
|
5
|
+
"""Base class for expected wormOCR failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigError(WormOCRError):
|
|
9
|
+
"""A configuration file or override is invalid."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CheckpointError(WormOCRError):
|
|
13
|
+
"""A checkpoint is missing, incompatible, or unusable."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DataError(WormOCRError):
|
|
17
|
+
"""A training or evaluation sample cannot be used."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RecognitionError(WormOCRError):
|
|
21
|
+
"""An image cannot be recognized."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ExportError(WormOCRError):
|
|
25
|
+
"""A deployment artifact could not be created or verified."""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""ONNX export and dynamic INT8 quantization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import torch
|
|
9
|
+
|
|
10
|
+
from wormocr.exceptions import ExportError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def export_onnx(model: torch.nn.Module, path: str | Path, opset: int = 17) -> Path:
|
|
14
|
+
destination = Path(path)
|
|
15
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
model.eval()
|
|
17
|
+
height = int(model.image_height)
|
|
18
|
+
dummy = torch.zeros(1, 1, height, 128, dtype=torch.float32)
|
|
19
|
+
try:
|
|
20
|
+
torch.onnx.export(
|
|
21
|
+
model,
|
|
22
|
+
dummy,
|
|
23
|
+
str(destination),
|
|
24
|
+
input_names=["image"],
|
|
25
|
+
output_names=["log_probs"],
|
|
26
|
+
dynamic_axes={"image": {0: "batch", 3: "width"}, "log_probs": {0: "batch", 1: "time"}},
|
|
27
|
+
opset_version=int(opset),
|
|
28
|
+
dynamo=False,
|
|
29
|
+
)
|
|
30
|
+
except TypeError:
|
|
31
|
+
torch.onnx.export(
|
|
32
|
+
model,
|
|
33
|
+
dummy,
|
|
34
|
+
str(destination),
|
|
35
|
+
input_names=["image"],
|
|
36
|
+
output_names=["log_probs"],
|
|
37
|
+
dynamic_axes={"image": {0: "batch", 3: "width"}, "log_probs": {0: "batch", 1: "time"}},
|
|
38
|
+
opset_version=int(opset),
|
|
39
|
+
)
|
|
40
|
+
if not destination.exists() or destination.stat().st_size == 0:
|
|
41
|
+
raise ExportError(f"ONNX export did not write {destination}")
|
|
42
|
+
return destination
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def quantize_onnx(source: str | Path, destination: str | Path) -> Path:
|
|
46
|
+
try:
|
|
47
|
+
from onnxruntime.quantization import QuantType, quantize_dynamic
|
|
48
|
+
except ImportError as exc:
|
|
49
|
+
raise ExportError(
|
|
50
|
+
"INT8 export needs the optional packages onnx and onnxruntime. "
|
|
51
|
+
"Install them with: pip install \"wormocr[export]\""
|
|
52
|
+
) from exc
|
|
53
|
+
output = Path(destination)
|
|
54
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
try:
|
|
56
|
+
quantize_dynamic(str(source), str(output), weight_type=QuantType.QInt8)
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
raise ExportError(f"INT8 quantization failed: {exc}") from exc
|
|
59
|
+
return output
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def onnx_max_abs_diff(model: torch.nn.Module, path: str | Path, width: int = 160, batch: int = 2) -> float:
|
|
63
|
+
try:
|
|
64
|
+
import onnxruntime as ort
|
|
65
|
+
except ImportError as exc:
|
|
66
|
+
raise ExportError("onnxruntime is not installed") from exc
|
|
67
|
+
model.eval()
|
|
68
|
+
sample = torch.randn(batch, 1, int(model.image_height), width)
|
|
69
|
+
with torch.no_grad():
|
|
70
|
+
reference = model(sample).detach().cpu().numpy()
|
|
71
|
+
session = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"])
|
|
72
|
+
output = session.run(None, {"image": sample.numpy().astype(np.float32)})[0]
|
|
73
|
+
if output.shape != reference.shape:
|
|
74
|
+
raise ExportError(f"ONNX shape {output.shape} != PyTorch shape {reference.shape}")
|
|
75
|
+
return float(np.max(np.abs(reference - output)))
|
wormocr/hub.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Released wormOCR weights and the explicit download command.
|
|
2
|
+
|
|
3
|
+
Weights are published as GitHub release assets, never stored in git, and
|
|
4
|
+
never fetched implicitly: only `wormocr download` / `download()` touches the
|
|
5
|
+
network. Every file is checked against a SHA-256 recorded here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import tempfile
|
|
15
|
+
import urllib.request
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from wormocr.exceptions import CheckpointError
|
|
21
|
+
|
|
22
|
+
REPOSITORY = "arjun988/wormOCR"
|
|
23
|
+
DEFAULT_MODEL = "wormocr-2M"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Release:
|
|
28
|
+
name: str
|
|
29
|
+
tag: str
|
|
30
|
+
files: dict[str, str]
|
|
31
|
+
description: str
|
|
32
|
+
|
|
33
|
+
def url(self, filename: str) -> str:
|
|
34
|
+
return f"https://github.com/{REPOSITORY}/releases/download/{self.tag}/{filename}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
RELEASES: dict[str, Release] = {
|
|
38
|
+
"wormocr-2M": Release(
|
|
39
|
+
name="wormocr-2M",
|
|
40
|
+
tag="wormocr-2M",
|
|
41
|
+
files={
|
|
42
|
+
"wormocr-2M.safetensors": "a6bee28adfc15f219fc6ff9d0dccdce45dc92c73559bc5474dda3d7e887376f2",
|
|
43
|
+
"wormocr-2M.json": "62b7c98be82c34684e29da2d1c97e2a3efac4ce3eae4b4cd0153845f608e24c2",
|
|
44
|
+
"wormocr-2M.onnx": "18498d163622022c8d48cf3fca7fa3e2c6bbc140db720e293f4d9a2f7b4cb25e",
|
|
45
|
+
},
|
|
46
|
+
description="2,174,880 parameters. Printed and handwritten English lines. Replaces the printed-only weights previously published on this tag.",
|
|
47
|
+
),
|
|
48
|
+
"wormocr-9M": Release(
|
|
49
|
+
name="wormocr-9M",
|
|
50
|
+
tag="wormocr-9M",
|
|
51
|
+
files={
|
|
52
|
+
"wormocr-9M.safetensors": "e10fbc8d96fb230056e9c10a4083ab971306179ada66b7cfc644fbf3e0402183",
|
|
53
|
+
"wormocr-9M.json": "cc6ca2695be4dde105d61591c5175653c02502bdc33711d59ec633bb470c05a9",
|
|
54
|
+
"wormocr-9M.onnx": "9eb068021344ce07f6846bbe7915d3d4919c1f5e9cd2de441b6fe71f656e51de",
|
|
55
|
+
},
|
|
56
|
+
description="8,776,416 parameters. Same training mix as wormocr-2M, larger CRNN (height 48). Stronger on handwritten notes, IAM, and Bentham.",
|
|
57
|
+
),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cache_dir() -> Path:
|
|
62
|
+
override = os.environ.get("WORMOCR_HOME")
|
|
63
|
+
if override:
|
|
64
|
+
return Path(override).expanduser()
|
|
65
|
+
base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
|
|
66
|
+
return Path(base) / "wormocr"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_release(name: str) -> Release:
|
|
70
|
+
try:
|
|
71
|
+
return RELEASES[name]
|
|
72
|
+
except KeyError:
|
|
73
|
+
available = ", ".join(sorted(RELEASES))
|
|
74
|
+
raise CheckpointError(f"unknown wormOCR model {name!r}. Released models: {available}") from None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def sha256(path: str | Path) -> str:
|
|
78
|
+
digest = hashlib.sha256()
|
|
79
|
+
with open(path, "rb") as handle:
|
|
80
|
+
for block in iter(lambda: handle.read(1 << 20), b""):
|
|
81
|
+
digest.update(block)
|
|
82
|
+
return digest.hexdigest()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def model_dir(name: str, root: str | Path | None = None) -> Path:
|
|
86
|
+
return (Path(root) if root else cache_dir()) / name
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def local_weights(name: str, root: str | Path | None = None) -> Path | None:
|
|
90
|
+
"""Path to the downloaded safetensors file, or None if it is not there."""
|
|
91
|
+
release = get_release(name)
|
|
92
|
+
folder = model_dir(name, root)
|
|
93
|
+
weights = folder / f"{release.name}.safetensors"
|
|
94
|
+
config = folder / f"{release.name}.json"
|
|
95
|
+
return weights if weights.exists() and config.exists() else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def download(
|
|
99
|
+
name: str = DEFAULT_MODEL,
|
|
100
|
+
root: str | Path | None = None,
|
|
101
|
+
force: bool = False,
|
|
102
|
+
include_onnx: bool = True,
|
|
103
|
+
log=print,
|
|
104
|
+
) -> Path:
|
|
105
|
+
"""Download a released model into the cache and verify every file."""
|
|
106
|
+
release = get_release(name)
|
|
107
|
+
folder = model_dir(name, root)
|
|
108
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
for filename, expected in release.files.items():
|
|
110
|
+
if filename.endswith(".onnx") and not include_onnx:
|
|
111
|
+
continue
|
|
112
|
+
target = folder / filename
|
|
113
|
+
if target.exists() and not force and sha256(target) == expected:
|
|
114
|
+
log(f"{filename}: already downloaded and verified")
|
|
115
|
+
continue
|
|
116
|
+
url = release.url(filename)
|
|
117
|
+
log(f"{filename}: downloading {url}")
|
|
118
|
+
handle, temporary = tempfile.mkstemp(dir=folder, suffix=".part")
|
|
119
|
+
os.close(handle)
|
|
120
|
+
try:
|
|
121
|
+
with urllib.request.urlopen(url, timeout=60) as response, open(temporary, "wb") as out:
|
|
122
|
+
shutil.copyfileobj(response, out)
|
|
123
|
+
actual = sha256(temporary)
|
|
124
|
+
if actual != expected:
|
|
125
|
+
raise CheckpointError(f"{filename}: SHA-256 mismatch (expected {expected}, got {actual})")
|
|
126
|
+
os.replace(temporary, target)
|
|
127
|
+
except OSError as exc:
|
|
128
|
+
raise CheckpointError(f"{filename}: download failed: {exc}") from exc
|
|
129
|
+
finally:
|
|
130
|
+
if os.path.exists(temporary):
|
|
131
|
+
os.remove(temporary)
|
|
132
|
+
log(f"{filename}: {target.stat().st_size / 1e6:.1f} MB, SHA-256 verified")
|
|
133
|
+
return folder / f"{release.name}.safetensors"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load_safetensors(path: str | Path, map_location: Any = "cpu") -> dict[str, Any]:
|
|
137
|
+
"""Read released weights (`<name>.safetensors` + `<name>.json`) as a checkpoint payload."""
|
|
138
|
+
try:
|
|
139
|
+
from safetensors.torch import load_file
|
|
140
|
+
except ImportError as exc:
|
|
141
|
+
raise CheckpointError("loading .safetensors weights needs: pip install safetensors") from exc
|
|
142
|
+
weights = Path(path)
|
|
143
|
+
meta_path = weights.with_suffix(".json")
|
|
144
|
+
if not meta_path.exists():
|
|
145
|
+
raise CheckpointError(f"{weights} needs its metadata file {meta_path.name} next to it")
|
|
146
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
147
|
+
for key in ("config", "charset"):
|
|
148
|
+
if key not in meta:
|
|
149
|
+
raise CheckpointError(f"{meta_path} is missing {key!r}")
|
|
150
|
+
device = str(map_location) if map_location is not None else "cpu"
|
|
151
|
+
return {
|
|
152
|
+
"model_state": load_file(str(weights), device=device),
|
|
153
|
+
"config": meta["config"],
|
|
154
|
+
"charset": meta["charset"],
|
|
155
|
+
"maturity": meta.get("maturity", "untrained"),
|
|
156
|
+
"name": meta.get("name"),
|
|
157
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""CTC decoders.
|
|
2
|
+
|
|
3
|
+
Greedy decoding is the default. Beam search is optional and only worth its cost
|
|
4
|
+
when a measurement shows fewer errors.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
import torch
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _logadd(left: float, right: float) -> float:
|
|
15
|
+
if left == float("-inf"):
|
|
16
|
+
return right
|
|
17
|
+
if right == float("-inf"):
|
|
18
|
+
return left
|
|
19
|
+
if left > right:
|
|
20
|
+
return left + math.log1p(math.exp(right - left))
|
|
21
|
+
return right + math.log1p(math.exp(left - right))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def greedy_decode(
|
|
25
|
+
log_probs: torch.Tensor,
|
|
26
|
+
lengths: torch.Tensor | None = None,
|
|
27
|
+
blank: int = 0,
|
|
28
|
+
) -> list[list[int]]:
|
|
29
|
+
"""Collapse CTC greedy paths. Repeated labels are kept when a blank separates them."""
|
|
30
|
+
if log_probs.ndim != 3:
|
|
31
|
+
raise ValueError("log_probs must have shape (batch, time, classes)")
|
|
32
|
+
chosen = log_probs.argmax(dim=-1).detach().cpu()
|
|
33
|
+
batch, time, _classes = log_probs.shape
|
|
34
|
+
decoded: list[list[int]] = []
|
|
35
|
+
for index in range(batch):
|
|
36
|
+
limit = time if lengths is None else max(0, min(time, int(lengths[index])))
|
|
37
|
+
previous = blank
|
|
38
|
+
sequence: list[int] = []
|
|
39
|
+
row = chosen[index]
|
|
40
|
+
for step in range(limit):
|
|
41
|
+
label = int(row[step])
|
|
42
|
+
if label != blank and label != previous:
|
|
43
|
+
sequence.append(label)
|
|
44
|
+
previous = label
|
|
45
|
+
decoded.append(sequence)
|
|
46
|
+
return decoded
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def greedy_confidence(
|
|
50
|
+
log_probs: torch.Tensor,
|
|
51
|
+
length: int,
|
|
52
|
+
blank: int = 0,
|
|
53
|
+
) -> float:
|
|
54
|
+
"""Mean probability of the labels that greedy decoding actually emitted."""
|
|
55
|
+
limit = max(0, min(int(length), int(log_probs.shape[0])))
|
|
56
|
+
if limit == 0:
|
|
57
|
+
return 0.0
|
|
58
|
+
probabilities = log_probs[:limit].detach().float().exp()
|
|
59
|
+
labels = probabilities.argmax(dim=-1)
|
|
60
|
+
chosen: list[float] = []
|
|
61
|
+
previous = blank
|
|
62
|
+
for step, label in enumerate(labels.tolist()):
|
|
63
|
+
label = int(label)
|
|
64
|
+
if label != blank and label != previous:
|
|
65
|
+
chosen.append(float(probabilities[step, label]))
|
|
66
|
+
previous = label
|
|
67
|
+
if not chosen:
|
|
68
|
+
return float(probabilities[:, blank].mean())
|
|
69
|
+
return float(sum(chosen) / len(chosen))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def ctc_beam_search(log_probs: torch.Tensor, beam_width: int = 8, blank: int = 0) -> list[int]:
|
|
73
|
+
"""Prefix beam search for one sequence of shape (time, classes)."""
|
|
74
|
+
if beam_width < 1:
|
|
75
|
+
raise ValueError("beam_width must be at least 1")
|
|
76
|
+
if log_probs.ndim != 2:
|
|
77
|
+
raise ValueError("beam search expects log_probs of shape (time, classes)")
|
|
78
|
+
frame_log = log_probs.detach().float().cpu()
|
|
79
|
+
time, classes = frame_log.shape
|
|
80
|
+
neg = float("-inf")
|
|
81
|
+
beam: dict[tuple[int, ...], list[float]] = {(): [0.0, neg]}
|
|
82
|
+
|
|
83
|
+
def add(store: dict[tuple[int, ...], list[float]], prefix: tuple[int, ...], blank_p: float, non_p: float) -> None:
|
|
84
|
+
slot = store.get(prefix)
|
|
85
|
+
if slot is None:
|
|
86
|
+
store[prefix] = [blank_p, non_p]
|
|
87
|
+
return
|
|
88
|
+
slot[0] = _logadd(slot[0], blank_p)
|
|
89
|
+
slot[1] = _logadd(slot[1], non_p)
|
|
90
|
+
|
|
91
|
+
for step in range(time):
|
|
92
|
+
frame = frame_log[step]
|
|
93
|
+
ranked = sorted(beam.items(), key=lambda item: _logadd(item[1][0], item[1][1]), reverse=True)[
|
|
94
|
+
:beam_width
|
|
95
|
+
]
|
|
96
|
+
nxt: dict[tuple[int, ...], list[float]] = {}
|
|
97
|
+
for prefix, (prob_blank, prob_label) in ranked:
|
|
98
|
+
total = _logadd(prob_blank, prob_label)
|
|
99
|
+
add(nxt, prefix, total + float(frame[blank]), neg)
|
|
100
|
+
for label in range(classes):
|
|
101
|
+
if label == blank:
|
|
102
|
+
continue
|
|
103
|
+
emit = float(frame[label])
|
|
104
|
+
if prefix and label == prefix[-1]:
|
|
105
|
+
add(nxt, prefix, neg, prob_label + emit)
|
|
106
|
+
add(nxt, prefix + (label,), neg, prob_blank + emit)
|
|
107
|
+
else:
|
|
108
|
+
add(nxt, prefix + (label,), neg, total + emit)
|
|
109
|
+
beam = dict(
|
|
110
|
+
sorted(nxt.items(), key=lambda item: _logadd(item[1][0], item[1][1]), reverse=True)[:beam_width]
|
|
111
|
+
)
|
|
112
|
+
best = max(beam.items(), key=lambda item: _logadd(item[1][0], item[1][1]))
|
|
113
|
+
return list(best[0])
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Load a checkpoint and recognize text lines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import torch
|
|
9
|
+
from PIL import Image
|
|
10
|
+
|
|
11
|
+
from wormocr.checkpoints import load_checkpoint
|
|
12
|
+
from wormocr.inference.decode import ctc_beam_search, greedy_confidence, greedy_decode
|
|
13
|
+
from wormocr.models.crnn import build_model
|
|
14
|
+
from wormocr.preprocessing import prepare_line
|
|
15
|
+
from wormocr.runtime import resolve_device
|
|
16
|
+
from wormocr.vocabulary import Vocabulary
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LineEngine:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
model,
|
|
23
|
+
vocabulary: Vocabulary,
|
|
24
|
+
device: torch.device,
|
|
25
|
+
config: dict,
|
|
26
|
+
maturity: str,
|
|
27
|
+
beam_width: int = 0,
|
|
28
|
+
max_width: int = 4096,
|
|
29
|
+
batch_size: int = 8,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.model = model
|
|
32
|
+
self.vocabulary = vocabulary
|
|
33
|
+
self.device = device
|
|
34
|
+
self.config = config
|
|
35
|
+
self.maturity = maturity
|
|
36
|
+
self.beam_width = int(beam_width)
|
|
37
|
+
self.max_width = int(max_width)
|
|
38
|
+
self.batch_size = max(1, int(batch_size))
|
|
39
|
+
self.image_height = int(config["model"]["image_height"])
|
|
40
|
+
self.model.to(device)
|
|
41
|
+
self.model.eval()
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def load(
|
|
45
|
+
cls,
|
|
46
|
+
path: str | Path,
|
|
47
|
+
device: str | None = None,
|
|
48
|
+
beam_width: int = 0,
|
|
49
|
+
batch_size: int = 8,
|
|
50
|
+
) -> "LineEngine":
|
|
51
|
+
torch_device = resolve_device(device)
|
|
52
|
+
payload = load_checkpoint(path, map_location=torch_device)
|
|
53
|
+
vocabulary = Vocabulary(payload["charset"])
|
|
54
|
+
model = build_model(payload["config"], vocabulary.size)
|
|
55
|
+
model.load_state_dict(payload["model_state"])
|
|
56
|
+
maturity = str(payload.get("maturity") or "untrained")
|
|
57
|
+
if maturity in {"untrained", "smoke-tested"}:
|
|
58
|
+
warnings.warn(
|
|
59
|
+
f"This checkpoint is {maturity} and is not a quality OCR model.",
|
|
60
|
+
UserWarning,
|
|
61
|
+
stacklevel=2,
|
|
62
|
+
)
|
|
63
|
+
return cls(
|
|
64
|
+
model=model,
|
|
65
|
+
vocabulary=vocabulary,
|
|
66
|
+
device=torch_device,
|
|
67
|
+
config=payload["config"],
|
|
68
|
+
maturity=maturity,
|
|
69
|
+
beam_width=beam_width,
|
|
70
|
+
max_width=int(payload["config"].get("data", {}).get("max_width") or 1024) * 2,
|
|
71
|
+
batch_size=batch_size,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def recognize(self, images: list[Image.Image]) -> list[dict[str, float | str]]:
|
|
75
|
+
if not images:
|
|
76
|
+
return []
|
|
77
|
+
prepared: list[tuple[torch.Tensor, float, int]] = []
|
|
78
|
+
for image in images:
|
|
79
|
+
tensor, pad_value = prepare_line(
|
|
80
|
+
image,
|
|
81
|
+
height=self.image_height,
|
|
82
|
+
align=2,
|
|
83
|
+
max_width=self.max_width,
|
|
84
|
+
)
|
|
85
|
+
prepared.append((tensor, pad_value, int(tensor.shape[-1])))
|
|
86
|
+
order = sorted(range(len(prepared)), key=lambda index: prepared[index][2])
|
|
87
|
+
outputs: list[dict[str, float | str] | None] = [None] * len(prepared)
|
|
88
|
+
for start in range(0, len(order), self.batch_size):
|
|
89
|
+
chunk = order[start : start + self.batch_size]
|
|
90
|
+
widths = [prepared[index][2] for index in chunk]
|
|
91
|
+
max_width = max(widths)
|
|
92
|
+
batch = prepared[chunk[0]][0].new_empty(len(chunk), 1, self.image_height, max_width)
|
|
93
|
+
for row, index in enumerate(chunk):
|
|
94
|
+
tensor, pad_value, width = prepared[index]
|
|
95
|
+
batch[row, :, :, :width] = tensor
|
|
96
|
+
if width < max_width:
|
|
97
|
+
batch[row, :, :, width:] = pad_value
|
|
98
|
+
with torch.inference_mode():
|
|
99
|
+
log_probs = self.model(batch.to(self.device))
|
|
100
|
+
length_tensor = self.model.output_lengths(torch.tensor(widths, dtype=torch.long))
|
|
101
|
+
length_tensor = torch.minimum(length_tensor, torch.tensor(log_probs.shape[1]))
|
|
102
|
+
if self.beam_width > 0:
|
|
103
|
+
sequences = [
|
|
104
|
+
ctc_beam_search(
|
|
105
|
+
log_probs[row, : int(length_tensor[row])].cpu(),
|
|
106
|
+
self.beam_width,
|
|
107
|
+
self.vocabulary.blank,
|
|
108
|
+
)
|
|
109
|
+
for row in range(len(chunk))
|
|
110
|
+
]
|
|
111
|
+
else:
|
|
112
|
+
sequences = greedy_decode(log_probs, length_tensor, self.vocabulary.blank)
|
|
113
|
+
for row, index in enumerate(chunk):
|
|
114
|
+
outputs[index] = {
|
|
115
|
+
"text": self.vocabulary.decode(sequences[row]),
|
|
116
|
+
"confidence": greedy_confidence(
|
|
117
|
+
log_probs[row].cpu(),
|
|
118
|
+
int(length_tensor[row]),
|
|
119
|
+
self.vocabulary.blank,
|
|
120
|
+
),
|
|
121
|
+
}
|
|
122
|
+
return [item for item in outputs if item is not None]
|