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/models/crnn.py ADDED
@@ -0,0 +1,236 @@
1
+ """CNN (optionally depthwise-separable) + BiGRU + CTC log-probabilities.
2
+
3
+ Width is downsampled by `width_stride` (2 or 4). Height 32 is reduced to a
4
+ short feature column and flattened. Stride 4 halves the sequence the GRU must
5
+ read and makes CTC alignment converge much sooner; older checkpoints that do
6
+ not store the field load as stride 2.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ import torch
14
+ from torch import nn
15
+
16
+ from wormocr.exceptions import ConfigError
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Spec:
21
+ kind: str
22
+ out_channels: int
23
+ kernel: tuple[int, int]
24
+ stride: tuple[int, int]
25
+ padding: tuple[int, int]
26
+
27
+
28
+ def build_specs(channels: list[int], width_stride: int = 2) -> list[Spec]:
29
+ if len(channels) != 4:
30
+ raise ConfigError("the recognizer expects four channel stages")
31
+ if int(width_stride) not in (2, 4):
32
+ raise ConfigError("model.width_stride must be 2 or 4")
33
+ first, second, third, fourth = (int(value) for value in channels)
34
+ second_pool = (2, 2) if int(width_stride) == 4 else (2, 1)
35
+ return [
36
+ Spec("conv", first, (3, 3), (1, 1), (1, 1)),
37
+ Spec("pool", 0, (2, 2), (2, 2), (0, 0)),
38
+ Spec("conv", second, (3, 3), (1, 1), (1, 1)),
39
+ Spec("pool", 0, second_pool, second_pool, (0, 0)),
40
+ Spec("conv", third, (3, 3), (1, 1), (1, 1)),
41
+ Spec("pool", 0, (2, 1), (2, 1), (0, 0)),
42
+ Spec("conv", fourth, (3, 3), (1, 1), (1, 1)),
43
+ Spec("pool", 0, (2, 1), (2, 1), (0, 0)),
44
+ ]
45
+
46
+
47
+ def _spatial(size: int, kernel: int, stride: int, padding: int) -> int:
48
+ return (int(size) + 2 * padding - kernel) // stride + 1
49
+
50
+
51
+ def output_width(width: int, specs: list[Spec]) -> int:
52
+ size = int(width)
53
+ for spec in specs:
54
+ size = _spatial(size, spec.kernel[1], spec.stride[1], spec.padding[1])
55
+ if size <= 0:
56
+ return 0
57
+ return size
58
+
59
+
60
+ class ConvBlock(nn.Module):
61
+ def __init__(
62
+ self,
63
+ in_channels: int,
64
+ out_channels: int,
65
+ kernel: tuple[int, int],
66
+ stride: tuple[int, int],
67
+ padding: tuple[int, int],
68
+ separable: bool,
69
+ ) -> None:
70
+ super().__init__()
71
+ if separable:
72
+ self.conv: nn.Module = nn.Sequential(
73
+ nn.Conv2d(
74
+ in_channels,
75
+ in_channels,
76
+ kernel,
77
+ stride=stride,
78
+ padding=padding,
79
+ groups=in_channels,
80
+ bias=False,
81
+ ),
82
+ nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False),
83
+ )
84
+ else:
85
+ self.conv = nn.Conv2d(
86
+ in_channels,
87
+ out_channels,
88
+ kernel,
89
+ stride=stride,
90
+ padding=padding,
91
+ bias=False,
92
+ )
93
+ self.bn = nn.BatchNorm2d(out_channels)
94
+ self.act = nn.ReLU(inplace=False)
95
+
96
+ def forward(self, images: torch.Tensor) -> torch.Tensor:
97
+ return self.act(self.bn(self.conv(images)))
98
+
99
+
100
+ class Encoder(nn.Module):
101
+ def __init__(self, in_channels: int, specs: list[Spec], separable: bool) -> None:
102
+ super().__init__()
103
+ self.specs = specs
104
+ blocks: list[nn.Module] = []
105
+ channels = in_channels
106
+ for spec in specs:
107
+ if spec.kind == "conv":
108
+ blocks.append(
109
+ ConvBlock(
110
+ channels,
111
+ spec.out_channels,
112
+ spec.kernel,
113
+ spec.stride,
114
+ spec.padding,
115
+ separable=separable and channels > 1,
116
+ )
117
+ )
118
+ channels = spec.out_channels
119
+ elif spec.kind == "pool":
120
+ blocks.append(
121
+ nn.MaxPool2d(
122
+ kernel_size=spec.kernel,
123
+ stride=spec.stride,
124
+ padding=spec.padding,
125
+ )
126
+ )
127
+ else:
128
+ raise ConfigError(f"unknown encoder spec {spec.kind}")
129
+ self.blocks = nn.ModuleList(blocks)
130
+
131
+ def forward(self, images: torch.Tensor) -> torch.Tensor:
132
+ features = images
133
+ for block in self.blocks:
134
+ features = block(features)
135
+ return features
136
+
137
+ def output_width(self, width: int) -> int:
138
+ return output_width(width, self.specs)
139
+
140
+
141
+ class CRNN(nn.Module):
142
+ """Variable-width line recognizer. Forward returns log-probabilities (B, T, C)."""
143
+
144
+ def __init__(
145
+ self,
146
+ num_classes: int,
147
+ in_channels: int = 1,
148
+ image_height: int = 32,
149
+ channels: list[int] | None = None,
150
+ separable: bool = True,
151
+ rnn_hidden: int = 256,
152
+ rnn_layers: int = 2,
153
+ rnn_dropout: float = 0.1,
154
+ proj_size: int = 256,
155
+ width_stride: int = 2,
156
+ ) -> None:
157
+ super().__init__()
158
+ if num_classes < 2:
159
+ raise ConfigError("num_classes must include the blank and at least one character")
160
+ stage_channels = list(channels or [32, 64, 128, 192])
161
+ self.image_height = int(image_height)
162
+ self.num_classes = int(num_classes)
163
+ self.width_stride = int(width_stride)
164
+ self.encoder = Encoder(in_channels, build_specs(stage_channels, self.width_stride), separable)
165
+ with torch.no_grad():
166
+ probe = torch.zeros(1, in_channels, self.image_height, 32)
167
+ encoded = self.encoder(probe)
168
+ if encoded.shape[-2] < 1 or encoded.shape[-1] < 1:
169
+ raise ConfigError(
170
+ f"encoder collapsed a {self.image_height}px-tall input; "
171
+ "choose a taller image_height that is a multiple of 16"
172
+ )
173
+ feature_height = int(encoded.shape[-2])
174
+ feature_channels = int(encoded.shape[1])
175
+ feature_size = feature_channels * feature_height
176
+ self.feature_size = feature_size
177
+ self.proj = nn.Sequential(
178
+ nn.Linear(feature_size, proj_size),
179
+ nn.ReLU(inplace=False),
180
+ )
181
+ dropout = float(rnn_dropout) if rnn_layers > 1 else 0.0
182
+ self.rnn = nn.GRU(
183
+ input_size=proj_size,
184
+ hidden_size=int(rnn_hidden),
185
+ num_layers=int(rnn_layers),
186
+ batch_first=True,
187
+ bidirectional=True,
188
+ dropout=dropout if rnn_layers > 1 else 0.0,
189
+ )
190
+ self.classifier = nn.Linear(int(rnn_hidden) * 2, self.num_classes)
191
+ nn.init.zeros_(self.classifier.bias)
192
+ # Class 0 is the CTC blank. A negative bias stops a fresh model from
193
+ # taking the blank at every frame before it has aligned a character.
194
+ self.classifier.bias.data[0] = -2.0
195
+
196
+ def output_lengths(self, widths: torch.Tensor) -> torch.Tensor:
197
+ lengths = [self.encoder.output_width(int(width)) for width in widths.detach().cpu().tolist()]
198
+ return torch.tensor(lengths, dtype=torch.long)
199
+
200
+ def forward(self, images: torch.Tensor) -> torch.Tensor:
201
+ if images.ndim != 4:
202
+ raise ValueError("images must have shape (batch, channels, height, width)")
203
+ if images.shape[2] != self.image_height:
204
+ raise ValueError(
205
+ f"expected image height {self.image_height}, got {images.shape[2]}"
206
+ )
207
+ features = self.encoder(images)
208
+ batch, channels, height, time = features.shape
209
+ sequence = features.permute(0, 3, 1, 2).contiguous().view(batch, time, channels * height)
210
+ sequence = self.proj(sequence)
211
+ sequence, _hidden = self.rnn(sequence)
212
+ return self.classifier(sequence).float().log_softmax(dim=-1)
213
+
214
+
215
+ def build_model(config: dict, num_classes: int) -> CRNN:
216
+ model_cfg = config["model"]
217
+ return CRNN(
218
+ num_classes=num_classes,
219
+ in_channels=int(model_cfg.get("in_channels", 1)),
220
+ image_height=int(model_cfg["image_height"]),
221
+ channels=[int(value) for value in model_cfg["channels"]],
222
+ separable=bool(model_cfg["separable"]),
223
+ rnn_hidden=int(model_cfg["rnn_hidden"]),
224
+ rnn_layers=int(model_cfg["rnn_layers"]),
225
+ rnn_dropout=float(model_cfg.get("rnn_dropout", 0.0)),
226
+ proj_size=int(model_cfg.get("proj_size", model_cfg["rnn_hidden"])),
227
+ width_stride=int(model_cfg.get("width_stride", 2)),
228
+ )
229
+
230
+
231
+ def count_parameters(module: nn.Module) -> int:
232
+ return sum(parameter.numel() for parameter in module.parameters())
233
+
234
+
235
+ def state_dict_num_bytes(module: nn.Module) -> int:
236
+ return sum(parameter.numel() * parameter.element_size() for parameter in module.parameters())
@@ -0,0 +1,135 @@
1
+ """Image loading and line normalization.
2
+
3
+ The recognizer consumes grayscale tensors of a fixed height and variable width,
4
+ normalized to [-1, 1]. Padding on the right uses the image border so a short
5
+ line in a batch is not given a false edge.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ import torch
14
+ from PIL import Image, UnidentifiedImageError
15
+
16
+ from wormocr.exceptions import RecognitionError
17
+
18
+
19
+ def to_grayscale(image: Image.Image) -> Image.Image:
20
+ if image.mode == "RGBA":
21
+ background = Image.new("RGBA", image.size, (255, 255, 255, 255))
22
+ image = Image.alpha_composite(background, image)
23
+ return image.convert("L")
24
+ if image.mode != "L":
25
+ return image.convert("L")
26
+ return image
27
+
28
+
29
+ def border_color(image: Image.Image) -> int:
30
+ array = np.asarray(image.convert("L"))
31
+ if array.size == 0:
32
+ return 255
33
+ border = np.concatenate(
34
+ [array[:, 0], array[:, -1], array[0, :], array[-1, :]]
35
+ )
36
+ return int(np.median(border))
37
+
38
+
39
+ def resize_to_height(image: Image.Image, height: int) -> Image.Image:
40
+ if height < 8:
41
+ raise ValueError("image height must be at least 8")
42
+ width, source_height = image.size
43
+ if width < 1 or source_height < 1:
44
+ raise ValueError("image is empty")
45
+ if source_height == height:
46
+ return image
47
+ new_width = max(1, int(round(width * (height / float(source_height)))))
48
+ return image.resize((new_width, height), Image.Resampling.BILINEAR)
49
+
50
+
51
+ def _normalize_pixel(value: float) -> float:
52
+ return (value / 255.0 - 0.5) / 0.5
53
+
54
+
55
+ def prepare_line(
56
+ image: Image.Image,
57
+ height: int = 32,
58
+ align: int = 2,
59
+ max_width: int = 2048,
60
+ min_width: int = 8,
61
+ ) -> tuple[torch.Tensor, float]:
62
+ """Return a float tensor of shape (1, height, width) and its pad value."""
63
+ if align < 1:
64
+ raise ValueError("align must be positive")
65
+ gray = to_grayscale(image)
66
+ if gray.width < 1 or gray.height < 1:
67
+ raise ValueError("image is empty")
68
+ resized = resize_to_height(gray, height)
69
+ if resized.width > max_width:
70
+ resized = resized.resize((max_width, height), Image.Resampling.BILINEAR)
71
+ color = border_color(resized)
72
+ if resized.width < min_width:
73
+ canvas = Image.new("L", (min_width, height), color=color)
74
+ canvas.paste(resized, (0, 0))
75
+ resized = canvas
76
+ if resized.width % align != 0:
77
+ padded_width = resized.width + (align - resized.width % align)
78
+ canvas = Image.new("L", (padded_width, height), color=color)
79
+ canvas.paste(resized, (0, 0))
80
+ resized = canvas
81
+ color = border_color(resized)
82
+ array = np.asarray(resized, dtype=np.float32)
83
+ tensor = torch.from_numpy(array).unsqueeze(0)
84
+ tensor = (tensor / 255.0 - 0.5) / 0.5
85
+ return tensor, float(_normalize_pixel(color))
86
+
87
+
88
+ def image_from_array(array: np.ndarray) -> Image.Image:
89
+ if array.ndim not in (2, 3):
90
+ raise RecognitionError("expected an HxW or HxWx3 image array")
91
+ values = array
92
+ if np.issubdtype(values.dtype, np.floating):
93
+ values = np.clip(values * 255.0, 0, 255).astype(np.uint8)
94
+ else:
95
+ values = values.astype(np.uint8)
96
+ if values.ndim == 2:
97
+ return Image.fromarray(values, mode="L")
98
+ channels = values.shape[2]
99
+ if channels == 3:
100
+ return Image.fromarray(values, mode="RGB")
101
+ if channels == 4:
102
+ return Image.fromarray(values, mode="RGBA")
103
+ raise RecognitionError(f"unsupported channel count: {channels}")
104
+
105
+
106
+ def load_image(source: str | Path) -> Image.Image:
107
+ path = Path(source)
108
+ if not path.exists():
109
+ raise RecognitionError(f"image not found: {path}")
110
+ if path.suffix.lower() == ".pdf":
111
+ raise RecognitionError("use the document loader for PDF files")
112
+ try:
113
+ with Image.open(path) as handle:
114
+ return handle.convert("RGBA") if handle.mode == "RGBA" else handle.convert("RGB")
115
+ except UnidentifiedImageError as exc:
116
+ raise RecognitionError(f"cannot read image: {path}") from exc
117
+ except OSError as exc:
118
+ raise RecognitionError(f"cannot read image: {path}") from exc
119
+
120
+
121
+ def coerce_image(source: str | Path | Image.Image | np.ndarray) -> Image.Image:
122
+ if isinstance(source, Image.Image):
123
+ return source
124
+ if isinstance(source, np.ndarray):
125
+ return image_from_array(source)
126
+ if isinstance(source, (str, Path)):
127
+ return load_image(source)
128
+ raise RecognitionError(
129
+ "image must be a file path, a PIL image, or a numpy array"
130
+ )
131
+
132
+
133
+ def looks_like_line(image: Image.Image, max_height: int = 96) -> bool:
134
+ width, height = image.size
135
+ return height <= max_height and width >= height
wormocr/py.typed ADDED
File without changes
wormocr/runtime.py ADDED
@@ -0,0 +1,56 @@
1
+ """Device selection and a small environment report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ import shutil
8
+
9
+ import torch
10
+
11
+ from wormocr.exceptions import WormOCRError
12
+
13
+
14
+ def resolve_device(name: str | None) -> torch.device:
15
+ requested = (name or "auto").strip().lower()
16
+ if requested == "auto":
17
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
18
+ if requested.startswith("cuda") and not torch.cuda.is_available():
19
+ raise WormOCRError(
20
+ "CUDA was requested, but this PyTorch installation cannot see a GPU. "
21
+ f"torch {torch.__version__} is the build in use. Run with --device cpu."
22
+ )
23
+ try:
24
+ return torch.device(requested)
25
+ except (TypeError, RuntimeError) as exc:
26
+ raise WormOCRError(f"unknown device {name!r}") from exc
27
+
28
+
29
+ def environment_report() -> dict:
30
+ report: dict = {
31
+ "python": platform.python_version(),
32
+ "platform": platform.platform(),
33
+ "cpu": platform.processor() or os.environ.get("PROCESSOR_IDENTIFIER", "unknown"),
34
+ "cpu_count": os.cpu_count(),
35
+ "torch": torch.__version__,
36
+ "cuda_available": bool(torch.cuda.is_available()),
37
+ "cuda_build": torch.version.cuda,
38
+ "torch_threads": int(torch.get_num_threads()),
39
+ "mkldnn": bool(torch.backends.mkldnn.is_available()),
40
+ }
41
+ if torch.cuda.is_available():
42
+ report["gpu"] = torch.cuda.get_device_name(0)
43
+ try:
44
+ import onnxruntime
45
+
46
+ report["onnxruntime"] = onnxruntime.__version__
47
+ except ImportError:
48
+ report["onnxruntime"] = None
49
+ try:
50
+ import onnx
51
+
52
+ report["onnx"] = onnx.__version__
53
+ except ImportError:
54
+ report["onnx"] = None
55
+ report["tesseract"] = shutil.which("tesseract")
56
+ return report
@@ -0,0 +1,5 @@
1
+ """Training entry points."""
2
+
3
+ from wormocr.training.loop import TrainResult, evaluate_model, train
4
+
5
+ __all__ = ["TrainResult", "evaluate_model", "train"]