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/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,5 @@
1
+ """Deployment export helpers."""
2
+
3
+ from wormocr.export.onnx_export import export_onnx, onnx_max_abs_diff, quantize_onnx
4
+
5
+ __all__ = ["export_onnx", "onnx_max_abs_diff", "quantize_onnx"]
@@ -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,5 @@
1
+ """Decoding and recognition runtime."""
2
+
3
+ from wormocr.inference.decode import ctc_beam_search, greedy_decode
4
+
5
+ __all__ = ["ctc_beam_search", "greedy_decode"]
@@ -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]
@@ -0,0 +1,5 @@
1
+ """Recognizer architectures."""
2
+
3
+ from wormocr.models.crnn import CRNN, build_model, count_parameters
4
+
5
+ __all__ = ["CRNN", "build_model", "count_parameters"]