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/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""wormOCR: local OCR for printed and handwritten English text."""
|
|
2
|
+
|
|
3
|
+
__version__ = "1.0.0"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def __getattr__(name: str):
|
|
7
|
+
if name == "OCR":
|
|
8
|
+
from wormocr.api import OCR
|
|
9
|
+
|
|
10
|
+
return OCR
|
|
11
|
+
if name == "WormOCRError":
|
|
12
|
+
from wormocr.exceptions import WormOCRError
|
|
13
|
+
|
|
14
|
+
return WormOCRError
|
|
15
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = ["OCR", "WormOCRError", "__version__"]
|
wormocr/__main__.py
ADDED
wormocr/api.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Python API for line and simple-page OCR."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from PIL import Image
|
|
9
|
+
|
|
10
|
+
from wormocr.config import repo_root
|
|
11
|
+
from wormocr.detection.lines import segment_page
|
|
12
|
+
from wormocr.exceptions import CheckpointError, RecognitionError
|
|
13
|
+
from wormocr.hub import DEFAULT_MODEL, RELEASES, local_weights
|
|
14
|
+
from wormocr.inference.engine import LineEngine
|
|
15
|
+
from wormocr.preprocessing import coerce_image, looks_like_line
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class LineResult:
|
|
20
|
+
text: str
|
|
21
|
+
confidence: float
|
|
22
|
+
bbox: tuple[int, int, int, int]
|
|
23
|
+
order: int
|
|
24
|
+
page: int = 0
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict:
|
|
27
|
+
return {
|
|
28
|
+
"text": self.text,
|
|
29
|
+
"confidence": self.confidence,
|
|
30
|
+
"bbox": {"x": self.bbox[0], "y": self.bbox[1], "width": self.bbox[2], "height": self.bbox[3]},
|
|
31
|
+
"order": self.order,
|
|
32
|
+
"page": self.page,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class OCRResult:
|
|
38
|
+
text: str
|
|
39
|
+
lines: list[LineResult]
|
|
40
|
+
image_width: int
|
|
41
|
+
image_height: int
|
|
42
|
+
warnings: list[str] = field(default_factory=list)
|
|
43
|
+
deskew_degrees: float = 0.0
|
|
44
|
+
layout: str = "line"
|
|
45
|
+
maturity: str = "untrained"
|
|
46
|
+
pages: list["OCRResult"] | None = None
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict:
|
|
49
|
+
payload = {
|
|
50
|
+
"text": self.text,
|
|
51
|
+
"lines": [line.to_dict() for line in self.lines],
|
|
52
|
+
"image_width": self.image_width,
|
|
53
|
+
"image_height": self.image_height,
|
|
54
|
+
"warnings": list(self.warnings),
|
|
55
|
+
"deskew_degrees": self.deskew_degrees,
|
|
56
|
+
"layout": self.layout,
|
|
57
|
+
"maturity": self.maturity,
|
|
58
|
+
}
|
|
59
|
+
if self.pages is not None:
|
|
60
|
+
payload["pages"] = [page.to_dict() for page in self.pages]
|
|
61
|
+
return payload
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resolve_checkpoint(model: str, checkpoint: str | Path | None) -> Path:
|
|
65
|
+
if checkpoint:
|
|
66
|
+
path = Path(checkpoint)
|
|
67
|
+
if not path.exists():
|
|
68
|
+
raise CheckpointError(
|
|
69
|
+
f"checkpoint not found: {path}. wormOCR does not download weights."
|
|
70
|
+
)
|
|
71
|
+
return path
|
|
72
|
+
if model in RELEASES:
|
|
73
|
+
weights = local_weights(model)
|
|
74
|
+
if weights is None:
|
|
75
|
+
raise CheckpointError(
|
|
76
|
+
f"{model} is not downloaded yet. wormOCR never downloads weights on its own; "
|
|
77
|
+
f"run: wormocr download {model}"
|
|
78
|
+
)
|
|
79
|
+
return weights
|
|
80
|
+
relative = Path("artifacts") / "checkpoints" / model / "best.pt"
|
|
81
|
+
candidates = [
|
|
82
|
+
Path.cwd() / relative,
|
|
83
|
+
repo_root() / relative,
|
|
84
|
+
Path.cwd() / "artifacts" / "checkpoints" / model / "best_weights.pt",
|
|
85
|
+
repo_root() / "artifacts" / "checkpoints" / model / "best_weights.pt",
|
|
86
|
+
]
|
|
87
|
+
for candidate in candidates:
|
|
88
|
+
if candidate.exists():
|
|
89
|
+
return candidate
|
|
90
|
+
raise CheckpointError(
|
|
91
|
+
f"No local checkpoint for {model!r}. wormOCR does not download weights. "
|
|
92
|
+
f"Train one with: wormocr train --config configs/{model}.yaml --mode baseline"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class OCR:
|
|
97
|
+
"""Local recognizer. Constructing it never downloads weights."""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
model: str = DEFAULT_MODEL,
|
|
102
|
+
checkpoint: str | Path | None = None,
|
|
103
|
+
device: str | None = None,
|
|
104
|
+
beam_width: int = 0,
|
|
105
|
+
) -> None:
|
|
106
|
+
self.model_name = model
|
|
107
|
+
self.checkpoint_path = resolve_checkpoint(model, checkpoint)
|
|
108
|
+
self.engine = LineEngine.load(self.checkpoint_path, device=device, beam_width=beam_width)
|
|
109
|
+
|
|
110
|
+
def predict(
|
|
111
|
+
self,
|
|
112
|
+
image: str | Path | Image.Image,
|
|
113
|
+
mode: str = "auto",
|
|
114
|
+
correct_orientation: bool = True,
|
|
115
|
+
) -> OCRResult:
|
|
116
|
+
if mode not in {"auto", "line", "page"}:
|
|
117
|
+
raise RecognitionError("mode must be auto, line, or page")
|
|
118
|
+
if isinstance(image, (str, Path)) and Path(image).suffix.lower() == ".pdf":
|
|
119
|
+
return self._predict_pdf(Path(image), mode, correct_orientation)
|
|
120
|
+
pil = coerce_image(image)
|
|
121
|
+
if mode == "line" or (mode == "auto" and looks_like_line(pil)):
|
|
122
|
+
return self._recognize_line(pil)
|
|
123
|
+
return self._recognize_page(pil, correct_orientation)
|
|
124
|
+
|
|
125
|
+
def predict_batch(self, images: list, mode: str = "auto", correct_orientation: bool = True) -> list[OCRResult]:
|
|
126
|
+
return [self.predict(image, mode=mode, correct_orientation=correct_orientation) for image in images]
|
|
127
|
+
|
|
128
|
+
def _recognize_line(self, image: Image.Image) -> OCRResult:
|
|
129
|
+
recognized = self.engine.recognize([image])[0]
|
|
130
|
+
width, height = image.size
|
|
131
|
+
line = LineResult(
|
|
132
|
+
text=str(recognized["text"]),
|
|
133
|
+
confidence=float(recognized["confidence"]),
|
|
134
|
+
bbox=(0, 0, width, height),
|
|
135
|
+
order=0,
|
|
136
|
+
)
|
|
137
|
+
return OCRResult(
|
|
138
|
+
text=line.text,
|
|
139
|
+
lines=[line],
|
|
140
|
+
image_width=width,
|
|
141
|
+
image_height=height,
|
|
142
|
+
layout="line",
|
|
143
|
+
maturity=self.engine.maturity,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def _recognize_page(self, image: Image.Image, correct_orientation: bool) -> OCRResult:
|
|
147
|
+
warnings: list[str] = []
|
|
148
|
+
layout = segment_page(image, correct_orientation=correct_orientation)
|
|
149
|
+
if not layout.crops:
|
|
150
|
+
warnings.append("no text lines detected; recognized the whole image as one line")
|
|
151
|
+
result = self._recognize_line(image)
|
|
152
|
+
result.warnings = warnings
|
|
153
|
+
result.layout = "fallback-line"
|
|
154
|
+
return result
|
|
155
|
+
if len(layout.crops) > 400:
|
|
156
|
+
warnings.append("more than 400 regions detected; kept the first 400 in reading order")
|
|
157
|
+
layout.boxes = layout.boxes[:400]
|
|
158
|
+
layout.crops = layout.crops[:400]
|
|
159
|
+
recognized = self.engine.recognize(layout.crops)
|
|
160
|
+
lines: list[LineResult] = []
|
|
161
|
+
for order, (box, item) in enumerate(zip(layout.boxes, recognized)):
|
|
162
|
+
lines.append(
|
|
163
|
+
LineResult(
|
|
164
|
+
text=str(item["text"]),
|
|
165
|
+
confidence=float(item["confidence"]),
|
|
166
|
+
bbox=box.as_xywh(),
|
|
167
|
+
order=order,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
return OCRResult(
|
|
171
|
+
text="\n".join(line.text for line in lines),
|
|
172
|
+
lines=lines,
|
|
173
|
+
image_width=image.width,
|
|
174
|
+
image_height=image.height,
|
|
175
|
+
warnings=warnings,
|
|
176
|
+
deskew_degrees=layout.angle,
|
|
177
|
+
layout=layout.layout,
|
|
178
|
+
maturity=self.engine.maturity,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def _predict_pdf(self, path: Path, mode: str, correct_orientation: bool) -> OCRResult:
|
|
182
|
+
pages = [_page_result(self, page, mode, correct_orientation) for page in load_pdf(path)]
|
|
183
|
+
lines: list[LineResult] = []
|
|
184
|
+
warnings: list[str] = []
|
|
185
|
+
for index, page in enumerate(pages):
|
|
186
|
+
warnings.extend(page.warnings)
|
|
187
|
+
for line in page.lines:
|
|
188
|
+
line.page = index
|
|
189
|
+
lines.append(line)
|
|
190
|
+
return OCRResult(
|
|
191
|
+
text="\n\n".join(page.text for page in pages),
|
|
192
|
+
lines=lines,
|
|
193
|
+
image_width=pages[0].image_width if pages else 0,
|
|
194
|
+
image_height=pages[0].image_height if pages else 0,
|
|
195
|
+
warnings=warnings,
|
|
196
|
+
layout="pdf",
|
|
197
|
+
maturity=self.engine.maturity,
|
|
198
|
+
pages=pages,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _page_result(ocr: OCR, image: Image.Image, mode: str, correct_orientation: bool) -> OCRResult:
|
|
203
|
+
if mode == "line":
|
|
204
|
+
return ocr._recognize_line(image)
|
|
205
|
+
if mode == "page" or not looks_like_line(image):
|
|
206
|
+
return ocr._recognize_page(image, correct_orientation)
|
|
207
|
+
return ocr._recognize_line(image)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def load_pdf(path: Path, scale: float = 2.0) -> list[Image.Image]:
|
|
211
|
+
try:
|
|
212
|
+
import pypdfium2 as pdfium
|
|
213
|
+
except ImportError as exc:
|
|
214
|
+
raise RecognitionError(
|
|
215
|
+
"Reading PDFs requires the optional package pypdfium2. "
|
|
216
|
+
"Install it with: pip install pypdfium2"
|
|
217
|
+
) from exc
|
|
218
|
+
document = pdfium.PdfDocument(str(path))
|
|
219
|
+
images: list[Image.Image] = []
|
|
220
|
+
try:
|
|
221
|
+
for index in range(len(document)):
|
|
222
|
+
page = document[index]
|
|
223
|
+
bitmap = page.render(scale=scale)
|
|
224
|
+
images.append(bitmap.to_pil().convert("RGB"))
|
|
225
|
+
finally:
|
|
226
|
+
document.close()
|
|
227
|
+
if not images:
|
|
228
|
+
raise RecognitionError(f"PDF has no pages: {path}")
|
|
229
|
+
return images
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Measure size, accuracy, latency, and memory for a checkpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import torch
|
|
17
|
+
|
|
18
|
+
from wormocr.data.dataset import OnlineSynthDataset
|
|
19
|
+
from wormocr.evaluation.metrics import MetricTotals
|
|
20
|
+
from wormocr.export.onnx_export import export_onnx, onnx_max_abs_diff, quantize_onnx
|
|
21
|
+
from wormocr.inference.engine import LineEngine
|
|
22
|
+
from wormocr.models.crnn import build_model, count_parameters, state_dict_num_bytes
|
|
23
|
+
from wormocr.runtime import environment_report
|
|
24
|
+
from wormocr.training.loop import evaluate_model, make_loader
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def working_set_bytes() -> int | None:
|
|
28
|
+
if os.name == "nt":
|
|
29
|
+
import ctypes
|
|
30
|
+
|
|
31
|
+
class Counters(ctypes.Structure):
|
|
32
|
+
_fields_ = [
|
|
33
|
+
("cb", ctypes.c_ulong),
|
|
34
|
+
("PageFaultCount", ctypes.c_ulong),
|
|
35
|
+
("PeakWorkingSetSize", ctypes.c_size_t),
|
|
36
|
+
("WorkingSetSize", ctypes.c_size_t),
|
|
37
|
+
("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
|
|
38
|
+
("QuotaPagedPoolUsage", ctypes.c_size_t),
|
|
39
|
+
("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
|
|
40
|
+
("QuotaNonPagedPoolUsage", ctypes.c_size_t),
|
|
41
|
+
("PagefileUsage", ctypes.c_size_t),
|
|
42
|
+
("PeakPagefileUsage", ctypes.c_size_t),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
46
|
+
psapi = ctypes.WinDLL("psapi", use_last_error=True)
|
|
47
|
+
kernel32.GetCurrentProcess.restype = ctypes.c_void_p
|
|
48
|
+
psapi.GetProcessMemoryInfo.argtypes = [ctypes.c_void_p, ctypes.POINTER(Counters), ctypes.c_ulong]
|
|
49
|
+
psapi.GetProcessMemoryInfo.restype = ctypes.c_int
|
|
50
|
+
counters = Counters()
|
|
51
|
+
counters.cb = ctypes.sizeof(Counters)
|
|
52
|
+
handle = kernel32.GetCurrentProcess()
|
|
53
|
+
if not psapi.GetProcessMemoryInfo(handle, ctypes.byref(counters), counters.cb):
|
|
54
|
+
return None
|
|
55
|
+
return int(counters.WorkingSetSize)
|
|
56
|
+
try:
|
|
57
|
+
import resource
|
|
58
|
+
|
|
59
|
+
usage = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
60
|
+
if platform.system() == "Darwin":
|
|
61
|
+
return int(usage)
|
|
62
|
+
return int(usage) * 1024
|
|
63
|
+
except Exception:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _percentile(values: list[float], fraction: float) -> float:
|
|
68
|
+
ordered = sorted(values)
|
|
69
|
+
if not ordered:
|
|
70
|
+
return 0.0
|
|
71
|
+
position = (len(ordered) - 1) * fraction
|
|
72
|
+
low = math.floor(position)
|
|
73
|
+
high = math.ceil(position)
|
|
74
|
+
if low == high:
|
|
75
|
+
return ordered[low]
|
|
76
|
+
return ordered[low] * (high - position) + ordered[high] * (position - low)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def time_calls(function, warmup: int, repeats: int) -> dict:
|
|
80
|
+
for _ in range(warmup):
|
|
81
|
+
function()
|
|
82
|
+
before = working_set_bytes()
|
|
83
|
+
peak = before or 0
|
|
84
|
+
samples: list[float] = []
|
|
85
|
+
for _ in range(repeats):
|
|
86
|
+
start = time.perf_counter()
|
|
87
|
+
function()
|
|
88
|
+
samples.append(time.perf_counter() - start)
|
|
89
|
+
current = working_set_bytes()
|
|
90
|
+
if current is not None:
|
|
91
|
+
peak = max(peak, current)
|
|
92
|
+
median = _percentile(samples, 0.5)
|
|
93
|
+
return {
|
|
94
|
+
"median_ms": median * 1000.0,
|
|
95
|
+
"p95_ms": _percentile(samples, 0.95) * 1000.0,
|
|
96
|
+
"repeats": repeats,
|
|
97
|
+
"warmup": warmup,
|
|
98
|
+
"working_set_before_bytes": before,
|
|
99
|
+
"working_set_peak_bytes": peak if before is not None else None,
|
|
100
|
+
"working_set_delta_bytes": None if before is None else max(0, peak - before),
|
|
101
|
+
"throughput_per_sec": (1.0 / median) if median > 0 else None,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def run_benchmark(
|
|
106
|
+
checkpoint: str | Path,
|
|
107
|
+
output: str | Path,
|
|
108
|
+
samples: int = 40,
|
|
109
|
+
seed: int = 12345,
|
|
110
|
+
repeats: int = 20,
|
|
111
|
+
warmup: int = 5,
|
|
112
|
+
device: str | None = "cpu",
|
|
113
|
+
compare_untrained: bool = True,
|
|
114
|
+
) -> dict:
|
|
115
|
+
started = time.perf_counter()
|
|
116
|
+
path = Path(checkpoint)
|
|
117
|
+
engine = LineEngine.load(path, device=device, beam_width=0)
|
|
118
|
+
model = engine.model
|
|
119
|
+
parameters = count_parameters(model)
|
|
120
|
+
report: dict = {
|
|
121
|
+
"checkpoint": str(path),
|
|
122
|
+
"maturity": engine.maturity,
|
|
123
|
+
"parameters": parameters,
|
|
124
|
+
"fp32_weight_bytes": state_dict_num_bytes(model),
|
|
125
|
+
"checkpoint_bytes": path.stat().st_size,
|
|
126
|
+
"environment": environment_report(),
|
|
127
|
+
"device": str(engine.device),
|
|
128
|
+
"input_height": engine.image_height,
|
|
129
|
+
"notes": [
|
|
130
|
+
"Latency is measured after warmup.",
|
|
131
|
+
"Working set is the process working set, including Python and PyTorch, not model weights alone.",
|
|
132
|
+
"Synthetic evaluation is not a real-document benchmark.",
|
|
133
|
+
],
|
|
134
|
+
}
|
|
135
|
+
weights_path = path.with_name("best_weights.pt")
|
|
136
|
+
if weights_path.exists():
|
|
137
|
+
report["weights_only_bytes"] = weights_path.stat().st_size
|
|
138
|
+
|
|
139
|
+
startup = time.perf_counter()
|
|
140
|
+
LineEngine.load(path, device=device)
|
|
141
|
+
report["startup_ms"] = (time.perf_counter() - startup) * 1000.0
|
|
142
|
+
|
|
143
|
+
height = engine.image_height
|
|
144
|
+
tensor = torch.randn(1, 1, height, 256)
|
|
145
|
+
batch_tensor = torch.randn(8, 1, height, 256)
|
|
146
|
+
|
|
147
|
+
def model_one() -> None:
|
|
148
|
+
with torch.no_grad():
|
|
149
|
+
model(tensor.to(engine.device))
|
|
150
|
+
|
|
151
|
+
def model_batch() -> None:
|
|
152
|
+
with torch.no_grad():
|
|
153
|
+
model(batch_tensor.to(engine.device))
|
|
154
|
+
|
|
155
|
+
report["latency"] = {
|
|
156
|
+
"model_only_batch1_width256": time_calls(model_one, warmup, repeats),
|
|
157
|
+
"model_only_batch8_width256": time_calls(model_batch, warmup, repeats),
|
|
158
|
+
}
|
|
159
|
+
report["latency"]["model_only_batch8_width256"]["throughput_per_sec"] = (
|
|
160
|
+
8.0 / (report["latency"]["model_only_batch8_width256"]["median_ms"] / 1000.0)
|
|
161
|
+
if report["latency"]["model_only_batch8_width256"]["median_ms"]
|
|
162
|
+
else None
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
dataset = OnlineSynthDataset(engine.config, max(1, samples), seed, augment=False)
|
|
166
|
+
sample_image = _first_image(dataset)
|
|
167
|
+
batch_images = [_sample_image(dataset, index) for index in range(min(8, len(dataset)))]
|
|
168
|
+
|
|
169
|
+
def e2e_one() -> None:
|
|
170
|
+
engine.recognize([sample_image])
|
|
171
|
+
|
|
172
|
+
def e2e_batch() -> None:
|
|
173
|
+
engine.recognize(batch_images)
|
|
174
|
+
|
|
175
|
+
report["latency"]["end_to_end_line"] = time_calls(e2e_one, min(3, warmup), repeats)
|
|
176
|
+
report["latency"]["end_to_end_batch8"] = time_calls(e2e_batch, min(2, warmup), max(5, repeats // 2))
|
|
177
|
+
if report["latency"]["end_to_end_batch8"]["median_ms"]:
|
|
178
|
+
report["latency"]["end_to_end_batch8"]["throughput_per_sec"] = len(batch_images) / (
|
|
179
|
+
report["latency"]["end_to_end_batch8"]["median_ms"] / 1000.0
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
loader = make_loader(dataset, batch_size=8, shuffle=False, seed=seed, num_workers=0)
|
|
183
|
+
totals, _previews = evaluate_model(model, loader, engine.vocabulary, engine.device, collect=False)
|
|
184
|
+
report["synthetic_held_out"] = totals.to_dict()
|
|
185
|
+
report["synthetic_held_out"]["seed"] = seed
|
|
186
|
+
report["synthetic_held_out"]["augment"] = False
|
|
187
|
+
report["synthetic_held_out"]["includes_text_detection"] = False
|
|
188
|
+
|
|
189
|
+
if compare_untrained:
|
|
190
|
+
fresh = build_model(engine.config, engine.vocabulary.size).to(engine.device)
|
|
191
|
+
fresh.eval()
|
|
192
|
+
untrained, _unused = evaluate_model(fresh, loader, engine.vocabulary, engine.device)
|
|
193
|
+
report["untrained_same_architecture"] = untrained.to_dict()
|
|
194
|
+
|
|
195
|
+
report["external"] = {
|
|
196
|
+
"tesseract": _tesseract_status(dataset, min(samples, 20)),
|
|
197
|
+
"paddleocr": _paddle_status(),
|
|
198
|
+
}
|
|
199
|
+
report["export"] = _try_export(model, Path(output).parent)
|
|
200
|
+
report["elapsed_sec"] = time.perf_counter() - started
|
|
201
|
+
destination = Path(output)
|
|
202
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
destination.with_suffix(".json").write_text(json.dumps(report, indent=2), encoding="utf-8")
|
|
204
|
+
destination.write_text(render_markdown(report), encoding="utf-8")
|
|
205
|
+
return report
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _first_image(dataset: OnlineSynthDataset):
|
|
209
|
+
return _sample_image(dataset, 0)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _sample_image(dataset: OnlineSynthDataset, index: int):
|
|
213
|
+
from wormocr.data.synthetic import _tensor_to_pil
|
|
214
|
+
|
|
215
|
+
return _tensor_to_pil(dataset[index]["image"])
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _tesseract_status(dataset: OnlineSynthDataset, limit: int) -> dict:
|
|
219
|
+
binary = shutil.which("tesseract")
|
|
220
|
+
if not binary:
|
|
221
|
+
return {
|
|
222
|
+
"available": False,
|
|
223
|
+
"reason": "tesseract is not on PATH. It was not installed for this run.",
|
|
224
|
+
}
|
|
225
|
+
totals = MetricTotals()
|
|
226
|
+
durations: list[float] = []
|
|
227
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
228
|
+
folder = Path(tmp)
|
|
229
|
+
for index in range(min(limit, len(dataset))):
|
|
230
|
+
item = dataset[index]
|
|
231
|
+
image = _sample_image(dataset, index)
|
|
232
|
+
image_path = folder / f"{index}.png"
|
|
233
|
+
image.save(image_path)
|
|
234
|
+
start = time.perf_counter()
|
|
235
|
+
completed = subprocess.run(
|
|
236
|
+
[binary, str(image_path), "stdout", "--psm", "7", "-l", "eng"],
|
|
237
|
+
capture_output=True,
|
|
238
|
+
text=True,
|
|
239
|
+
timeout=60,
|
|
240
|
+
check=False,
|
|
241
|
+
)
|
|
242
|
+
durations.append(time.perf_counter() - start)
|
|
243
|
+
if completed.returncode != 0:
|
|
244
|
+
return {
|
|
245
|
+
"available": False,
|
|
246
|
+
"reason": completed.stderr.strip() or "tesseract failed",
|
|
247
|
+
}
|
|
248
|
+
hypothesis = completed.stdout.replace("\n", "").strip()
|
|
249
|
+
totals.update(item["text"], hypothesis)
|
|
250
|
+
return {
|
|
251
|
+
"available": True,
|
|
252
|
+
"mode": "single text line, psm 7, English. Detection is not included.",
|
|
253
|
+
"metrics": totals.to_dict(),
|
|
254
|
+
"median_ms": _percentile(durations, 0.5) * 1000.0,
|
|
255
|
+
"p95_ms": _percentile(durations, 0.95) * 1000.0,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _paddle_status() -> dict:
|
|
260
|
+
if importlib.util.find_spec("paddleocr") is None:
|
|
261
|
+
return {
|
|
262
|
+
"available": False,
|
|
263
|
+
"reason": (
|
|
264
|
+
"PaddleOCR is not installed and its weights were not downloaded. "
|
|
265
|
+
"Published PP-OCRv4 mobile recognizer sizes are listed in docs/BASELINE_COMPARISON.md."
|
|
266
|
+
),
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
"available": False,
|
|
270
|
+
"reason": "PaddleOCR is installed, but wormOCR does not auto-download or run its weights in this benchmark.",
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _try_export(model, directory: Path) -> dict:
|
|
275
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
fp32 = directory / "benchmark.fp32.onnx"
|
|
277
|
+
try:
|
|
278
|
+
export_onnx(model, fp32, opset=int(model_opset(model)))
|
|
279
|
+
diff = onnx_max_abs_diff(model, fp32)
|
|
280
|
+
result = {
|
|
281
|
+
"onnx_fp32_bytes": fp32.stat().st_size,
|
|
282
|
+
"onnx_fp32_max_abs_diff": diff,
|
|
283
|
+
}
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
return {"available": False, "reason": str(exc)}
|
|
286
|
+
int8 = directory / "benchmark.int8.onnx"
|
|
287
|
+
try:
|
|
288
|
+
quantize_onnx(fp32, int8)
|
|
289
|
+
result["onnx_int8_bytes"] = int8.stat().st_size
|
|
290
|
+
result["onnx_int8_max_abs_diff"] = onnx_max_abs_diff(model, int8)
|
|
291
|
+
except Exception as exc:
|
|
292
|
+
result["onnx_int8_reason"] = str(exc)
|
|
293
|
+
result["available"] = True
|
|
294
|
+
return result
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def model_opset(_model) -> int:
|
|
298
|
+
return 17
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def render_markdown(report: dict) -> str:
|
|
302
|
+
env = report["environment"]
|
|
303
|
+
latency = report["latency"]
|
|
304
|
+
synthetic = report["synthetic_held_out"]
|
|
305
|
+
lines = [
|
|
306
|
+
"# wormOCR benchmark",
|
|
307
|
+
"",
|
|
308
|
+
"Generated from one local run. Do not treat synthetic scores as document-OCR quality.",
|
|
309
|
+
"",
|
|
310
|
+
"## Environment",
|
|
311
|
+
"",
|
|
312
|
+
f"- platform: {env.get('platform')}",
|
|
313
|
+
f"- cpu: {env.get('cpu')}",
|
|
314
|
+
f"- python: {env.get('python')}",
|
|
315
|
+
f"- torch: {env.get('torch')} (threads {env.get('torch_threads')})",
|
|
316
|
+
f"- cuda available: {env.get('cuda_available')}",
|
|
317
|
+
f"- device used: {report.get('device')}",
|
|
318
|
+
f"- checkpoint maturity: {report.get('maturity')}",
|
|
319
|
+
"",
|
|
320
|
+
"## Size",
|
|
321
|
+
"",
|
|
322
|
+
f"- parameters: {report['parameters']:,}",
|
|
323
|
+
f"- FP32 weights: {_fmt_mb(report['fp32_weight_bytes'])}",
|
|
324
|
+
f"- checkpoint file: {_fmt_mb(report['checkpoint_bytes'])}",
|
|
325
|
+
f"- startup: {report.get('startup_ms', 0):.1f} ms",
|
|
326
|
+
"",
|
|
327
|
+
"## Synthetic held-out lines",
|
|
328
|
+
"",
|
|
329
|
+
"Text detection is not included. Images are generated text lines.",
|
|
330
|
+
"",
|
|
331
|
+
f"- samples: {synthetic['samples']}",
|
|
332
|
+
f"- CER: {synthetic['cer']:.4f}",
|
|
333
|
+
f"- WER: {synthetic['wer']:.4f}",
|
|
334
|
+
f"- exact match: {synthetic['exact_match']:.4f}",
|
|
335
|
+
"",
|
|
336
|
+
]
|
|
337
|
+
untrained = report.get("untrained_same_architecture")
|
|
338
|
+
if untrained:
|
|
339
|
+
lines.extend(
|
|
340
|
+
[
|
|
341
|
+
"Random initialization of the same architecture, same images:",
|
|
342
|
+
"",
|
|
343
|
+
f"- CER: {untrained['cer']:.4f}",
|
|
344
|
+
f"- WER: {untrained['wer']:.4f}",
|
|
345
|
+
f"- exact match: {untrained['exact_match']:.4f}",
|
|
346
|
+
"",
|
|
347
|
+
]
|
|
348
|
+
)
|
|
349
|
+
lines.extend(["## Latency", "", "| path | batch | median ms | p95 ms | throughput / s |", "| --- | ---: | ---: | ---: | ---: |"])
|
|
350
|
+
labels = {
|
|
351
|
+
"model_only_batch1_width256": (1, "model only, width 256"),
|
|
352
|
+
"model_only_batch8_width256": (8, "model only, width 256"),
|
|
353
|
+
"end_to_end_line": (1, "preprocess + model + greedy decode"),
|
|
354
|
+
"end_to_end_batch8": (8, "preprocess + model + greedy decode"),
|
|
355
|
+
}
|
|
356
|
+
for key, (batch, label) in labels.items():
|
|
357
|
+
item = latency.get(key)
|
|
358
|
+
if not item:
|
|
359
|
+
continue
|
|
360
|
+
throughput = item.get("throughput_per_sec")
|
|
361
|
+
rate = f"{throughput:.2f}" if throughput else ""
|
|
362
|
+
lines.append(
|
|
363
|
+
f"| {label} | {batch} | {item['median_ms']:.2f} | {item['p95_ms']:.2f} | {rate} |"
|
|
364
|
+
)
|
|
365
|
+
peak = latency["model_only_batch1_width256"].get("working_set_peak_bytes")
|
|
366
|
+
lines.extend(
|
|
367
|
+
[
|
|
368
|
+
"",
|
|
369
|
+
f"Process working set while timing the single-line model call peaked at {_fmt_mb(peak)}.",
|
|
370
|
+
"",
|
|
371
|
+
"## External baselines",
|
|
372
|
+
"",
|
|
373
|
+
]
|
|
374
|
+
)
|
|
375
|
+
for name, status in report.get("external", {}).items():
|
|
376
|
+
if not status.get("available"):
|
|
377
|
+
lines.append(f"- {name}: unavailable. {status.get('reason')}")
|
|
378
|
+
continue
|
|
379
|
+
metrics = status.get("metrics") or {}
|
|
380
|
+
lines.append(
|
|
381
|
+
f"- {name}: CER {metrics.get('cer', float('nan')):.4f}, "
|
|
382
|
+
f"median {status.get('median_ms', float('nan')):.1f} ms. {status.get('mode', '')}"
|
|
383
|
+
)
|
|
384
|
+
export = report.get("export") or {}
|
|
385
|
+
lines.extend(["", "## Export", ""])
|
|
386
|
+
if not export.get("available"):
|
|
387
|
+
lines.append(f"ONNX export was not measured. {export.get('reason', '')}")
|
|
388
|
+
else:
|
|
389
|
+
lines.append(f"- FP32 ONNX: {_fmt_mb(export.get('onnx_fp32_bytes'))}, max abs diff {export.get('onnx_fp32_max_abs_diff')}")
|
|
390
|
+
if export.get("onnx_int8_bytes"):
|
|
391
|
+
lines.append(
|
|
392
|
+
f"- INT8 ONNX: {_fmt_mb(export.get('onnx_int8_bytes'))}, max abs diff {export.get('onnx_int8_max_abs_diff')}"
|
|
393
|
+
)
|
|
394
|
+
if export.get("onnx_int8_reason"):
|
|
395
|
+
lines.append(f"- INT8: {export['onnx_int8_reason']}")
|
|
396
|
+
lines.extend(["", f"Benchmark wall time: {report.get('elapsed_sec', 0):.1f} s", ""])
|
|
397
|
+
return "\n".join(lines)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _fmt_mb(num_bytes) -> str:
|
|
401
|
+
if num_bytes is None:
|
|
402
|
+
return "n/a"
|
|
403
|
+
return f"{num_bytes / (1024 * 1024):.2f} MB"
|