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.
Files changed (48) hide show
  1. wormocr/__init__.py +18 -0
  2. wormocr/__main__.py +4 -0
  3. wormocr/api.py +229 -0
  4. wormocr/benchmark/__init__.py +5 -0
  5. wormocr/benchmark/report.py +403 -0
  6. wormocr/checkpoints.py +55 -0
  7. wormocr/cli.py +308 -0
  8. wormocr/config.py +220 -0
  9. wormocr/data/__init__.py +5 -0
  10. wormocr/data/dataset.py +110 -0
  11. wormocr/data/fonts.py +173 -0
  12. wormocr/data/handwriting.py +304 -0
  13. wormocr/data/lexicon.py +105 -0
  14. wormocr/data/lexicon_medical.py +48 -0
  15. wormocr/data/mixed.py +211 -0
  16. wormocr/data/synthetic.py +445 -0
  17. wormocr/detection/__init__.py +5 -0
  18. wormocr/detection/lines.py +382 -0
  19. wormocr/evaluation/__init__.py +5 -0
  20. wormocr/evaluation/errors.py +145 -0
  21. wormocr/evaluation/metrics.py +105 -0
  22. wormocr/evaluation/robustness.py +43 -0
  23. wormocr/exceptions.py +25 -0
  24. wormocr/export/__init__.py +5 -0
  25. wormocr/export/onnx_export.py +75 -0
  26. wormocr/hub.py +157 -0
  27. wormocr/inference/__init__.py +5 -0
  28. wormocr/inference/decode.py +113 -0
  29. wormocr/inference/engine.py +122 -0
  30. wormocr/models/__init__.py +5 -0
  31. wormocr/models/crnn.py +236 -0
  32. wormocr/preprocessing.py +135 -0
  33. wormocr/py.typed +0 -0
  34. wormocr/runtime.py +56 -0
  35. wormocr/training/__init__.py +5 -0
  36. wormocr/training/loop.py +645 -0
  37. wormocr/vocabulary.py +69 -0
  38. wormocr/web/__init__.py +5 -0
  39. wormocr/web/server.py +505 -0
  40. wormocr/web/static/app.js +514 -0
  41. wormocr/web/static/index.html +141 -0
  42. wormocr/web/static/style.css +379 -0
  43. wormocr-1.0.0.dist-info/METADATA +377 -0
  44. wormocr-1.0.0.dist-info/RECORD +48 -0
  45. wormocr-1.0.0.dist-info/WHEEL +5 -0
  46. wormocr-1.0.0.dist-info/entry_points.txt +2 -0
  47. wormocr-1.0.0.dist-info/licenses/LICENSE +21 -0
  48. 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
@@ -0,0 +1,4 @@
1
+ from wormocr.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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,5 @@
1
+ """Benchmark reports."""
2
+
3
+ from wormocr.benchmark.report import run_benchmark
4
+
5
+ __all__ = ["run_benchmark"]
@@ -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"