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
@@ -0,0 +1,445 @@
1
+ """Reproducible synthetic text-line images.
2
+
3
+ The label is chosen first and is never rewritten by augmentation. Geometric
4
+ changes stay mild so the pixels still contain the same characters.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import random
11
+ from io import BytesIO
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+ from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont
16
+
17
+ from wormocr.data.fonts import discover_fonts
18
+ from wormocr.data.lexicon import PANGRAM, REPEAT_WORDS, WORDS
19
+ from wormocr.exceptions import DataError
20
+ from wormocr.models.crnn import build_specs, output_width
21
+ from wormocr.preprocessing import prepare_line, resize_to_height
22
+ from wormocr.vocabulary import Vocabulary, ctc_time_needed, default_charset
23
+
24
+ CHARSET = default_charset()
25
+ LETTERS = [char for char in CHARSET if char.isalnum() or char in " .,!?'-:/$%"]
26
+
27
+
28
+ def sample_text(rng: random.Random, max_chars: int) -> str:
29
+ limit = max(1, int(max_chars))
30
+ kind = rng.choices(
31
+ ["word", "words", "number", "code", "repeat", "random", "punct", "pangram"],
32
+ weights=[30, 16, 14, 12, 10, 10, 6, 2],
33
+ )[0]
34
+ if kind == "word":
35
+ text = rng.choice(WORDS)
36
+ elif kind == "words":
37
+ count = 2 if limit < 16 else rng.randint(2, 3)
38
+ text = " ".join(rng.choice(WORDS) for _ in range(count))
39
+ elif kind == "number":
40
+ text = _number(rng)
41
+ elif kind == "code":
42
+ text = _code(rng)
43
+ elif kind == "repeat":
44
+ text = rng.choice(REPEAT_WORDS)
45
+ if rng.random() < 0.4 and limit >= len(text) + 4:
46
+ text = f"{text} {rng.choice(REPEAT_WORDS)}"
47
+ elif kind == "punct":
48
+ word = rng.choice(WORDS)
49
+ text = rng.choice(
50
+ [
51
+ f"{word}, {rng.choice(WORDS)}.",
52
+ f"{word}: {rng.choice(WORDS)}!",
53
+ f"({word})",
54
+ f"'{word}'",
55
+ f"{word}?",
56
+ ]
57
+ )
58
+ elif kind == "pangram":
59
+ text = PANGRAM if rng.random() < 0.5 else PANGRAM.lower()
60
+ if len(text) > limit:
61
+ text = rng.choice(WORDS)
62
+ else:
63
+ length = rng.randint(1, min(12, limit))
64
+ pool = [char for char in CHARSET if char != " "]
65
+ chars = [rng.choice(pool) for _ in range(length)]
66
+ if length > 4:
67
+ chars[length // 2] = " "
68
+ text = "".join(chars)
69
+ text = _vary_case(rng, text, kind)
70
+ text = text[:limit].strip()
71
+ if not text:
72
+ text = "a"
73
+ return text
74
+
75
+
76
+ def _vary_case(rng: random.Random, text: str, kind: str) -> str:
77
+ if kind in {"number", "code", "random"}:
78
+ return text
79
+ roll = rng.random()
80
+ if roll < 0.55:
81
+ return text.lower()
82
+ if roll < 0.75:
83
+ return text.upper()
84
+ if roll < 0.92:
85
+ return " ".join(part.capitalize() for part in text.split(" "))
86
+ return text
87
+
88
+
89
+ def _number(rng: random.Random) -> str:
90
+ choice = rng.randrange(4)
91
+ if choice == 0:
92
+ return str(rng.randint(0, 99999))
93
+ if choice == 1:
94
+ return f"{rng.randint(0, 999)}.{rng.randint(0, 99):02d}"
95
+ if choice == 2:
96
+ return f"{rng.randint(1, 28):02d}/{rng.randint(1, 12):02d}/{rng.randint(1990, 2026)}"
97
+ return f"{rng.randint(1, 100)}%"
98
+
99
+
100
+ def _code(rng: random.Random) -> str:
101
+ choice = rng.randrange(9)
102
+ if choice == 0:
103
+ return f"INV-{rng.randint(1000, 9999)}"
104
+ if choice == 1:
105
+ return f"${rng.randint(1, 999)}.{rng.randint(0, 99):02d}"
106
+ if choice == 2:
107
+ return f"ID {rng.choice('ABCDEFGHJKLMNPQRSTUVWXYZ')}{rng.randint(100, 999)}"
108
+ if choice == 3:
109
+ return f"${rng.randint(1, 99)},{rng.randint(0, 999):03d}.{rng.randint(0, 99):02d}"
110
+ if choice == 4:
111
+ return f"({rng.randint(200, 999)}) {rng.randint(200, 999)}-{rng.randint(0, 9999):04d}"
112
+ if choice == 5:
113
+ return f"#{rng.randint(1, 99999)}"
114
+ if choice == 6:
115
+ return f"{rng.choice(WORDS).capitalize()}:"
116
+ if choice == 7:
117
+ return f"{rng.randint(1, 999):,}".replace(",", rng.choice([",", ""])) + rng.choice(["", "%", ".00"])
118
+ return f"555-01{rng.randint(0, 99):02d}"
119
+
120
+
121
+ def render_text(
122
+ text: str,
123
+ rng: random.Random,
124
+ np_rng: np.random.Generator,
125
+ fonts: list[str],
126
+ font_size_min: int,
127
+ font_size_max: int,
128
+ augment: bool,
129
+ corruption: str | None = None,
130
+ ) -> Image.Image:
131
+ if not fonts:
132
+ raise DataError("no fonts are available for synthetic rendering")
133
+ last_error = "renderer produced no image"
134
+ for _attempt in range(10):
135
+ try:
136
+ image, background = _draw(text, rng, fonts, font_size_min, font_size_max)
137
+ except OSError as exc:
138
+ last_error = str(exc)
139
+ continue
140
+ if corruption:
141
+ image = apply_corruption(image, corruption, rng, np_rng, background)
142
+ elif augment:
143
+ image = apply_augmentations(image, rng, np_rng, background)
144
+ array = np.asarray(image)
145
+ if int(np.abs(array.astype(np.int16) - int(np.median(array))).sum()) < 8:
146
+ last_error = "rendered text had no visible ink"
147
+ continue
148
+ return image
149
+ raise DataError(f"failed to render {text!r}: {last_error}")
150
+
151
+
152
+ def _draw(
153
+ text: str,
154
+ rng: random.Random,
155
+ fonts: list[str],
156
+ font_size_min: int,
157
+ font_size_max: int,
158
+ ) -> tuple[Image.Image, int]:
159
+ font = ImageFont.truetype(rng.choice(fonts), rng.randint(font_size_min, font_size_max))
160
+ if rng.random() < 0.82:
161
+ background = rng.randint(214, 255)
162
+ fill = rng.randint(0, 45)
163
+ else:
164
+ background = rng.randint(0, 36)
165
+ fill = rng.randint(215, 255)
166
+ if abs(background - fill) < 80:
167
+ fill = 0 if background > 128 else 255
168
+ probe = ImageDraw.Draw(Image.new("L", (1, 1)))
169
+ box = probe.textbbox((0, 0), text, font=font)
170
+ text_width = max(1, box[2] - box[0])
171
+ text_height = max(1, box[3] - box[1])
172
+ if rng.random() < 0.4:
173
+ pad_x = rng.randint(1, 4)
174
+ pad_y = rng.randint(1, 3)
175
+ slack = rng.randint(0, 2)
176
+ else:
177
+ pad_x = rng.randint(4, 18)
178
+ pad_y = rng.randint(3, 12)
179
+ slack = rng.randint(0, 8)
180
+ width = text_width + pad_x * 2 + slack
181
+ height = text_height + pad_y * 2 + slack
182
+ image = Image.new("L", (width, height), color=background)
183
+ draw = ImageDraw.Draw(image)
184
+ origin_x = pad_x + rng.randint(0, max(1, slack)) - box[0]
185
+ origin_y = pad_y + rng.randint(0, max(1, slack)) - box[1]
186
+ stroke = 1 if rng.random() < 0.12 else 0
187
+ draw.text(
188
+ (origin_x, origin_y),
189
+ text,
190
+ font=font,
191
+ fill=fill,
192
+ stroke_width=stroke,
193
+ stroke_fill=fill,
194
+ )
195
+ return image, background
196
+
197
+
198
+ def apply_augmentations(
199
+ image: Image.Image,
200
+ rng: random.Random,
201
+ np_rng: np.random.Generator,
202
+ background: int,
203
+ ) -> Image.Image:
204
+ """Apply up to two mild corruptions, including scan-like degradations."""
205
+ if rng.random() < 0.3:
206
+ return image
207
+ count = 1 if rng.random() < 0.6 else 2
208
+ for _ in range(count):
209
+ image = _one_augmentation(image, rng, np_rng, background)
210
+ return image
211
+
212
+
213
+ def _one_augmentation(
214
+ image: Image.Image,
215
+ rng: random.Random,
216
+ np_rng: np.random.Generator,
217
+ background: int,
218
+ ) -> Image.Image:
219
+ kind = rng.choices(
220
+ ["blur", "noise", "jpeg", "tone", "light", "lowres", "rotate", "shear", "scan", "bold", "thin", "binary"],
221
+ weights=[8, 8, 8, 8, 5, 9, 5, 5, 12, 5, 4, 5],
222
+ )[0]
223
+ if kind == "scan":
224
+ return _scan(image, rng, np_rng)
225
+ if kind == "bold":
226
+ return image.filter(ImageFilter.MinFilter(3) if background > 128 else ImageFilter.MaxFilter(3))
227
+ if kind == "thin":
228
+ if image.height < 24:
229
+ return image
230
+ return image.filter(ImageFilter.MaxFilter(3) if background > 128 else ImageFilter.MinFilter(3))
231
+ if kind == "binary":
232
+ array = np.asarray(image).astype(np.int16)
233
+ threshold = int((array.min() + array.max()) / 2) + rng.randint(-20, 20)
234
+ return Image.fromarray(np.where(array > threshold, 255, 0).astype(np.uint8), mode="L")
235
+ if kind == "blur":
236
+ return image.filter(ImageFilter.GaussianBlur(radius=rng.uniform(0.3, 0.9)))
237
+ if kind == "noise":
238
+ return _noise(image, np_rng, rng.uniform(3, 10))
239
+ if kind == "jpeg":
240
+ return _jpeg(image, rng.randint(45, 85))
241
+ if kind == "tone":
242
+ image = ImageEnhance.Brightness(image).enhance(rng.uniform(0.8, 1.2))
243
+ return ImageEnhance.Contrast(image).enhance(rng.uniform(0.8, 1.25))
244
+ if kind == "light":
245
+ return _lighting(image, np_rng, strength=0.25)
246
+ if kind == "lowres":
247
+ return _down_up(image, rng)
248
+ if kind == "rotate":
249
+ return image.rotate(
250
+ rng.uniform(-2.5, 2.5),
251
+ resample=Image.Resampling.BILINEAR,
252
+ expand=True,
253
+ fillcolor=background,
254
+ )
255
+ return _shear(image, rng.uniform(-0.08, 0.08), background)
256
+
257
+
258
+ def apply_corruption(
259
+ image: Image.Image,
260
+ kind: str,
261
+ rng: random.Random,
262
+ np_rng: np.random.Generator,
263
+ background: int,
264
+ ) -> Image.Image:
265
+ if kind == "clean":
266
+ return image
267
+ if kind == "blur":
268
+ return image.filter(ImageFilter.GaussianBlur(radius=1.3))
269
+ if kind == "noise":
270
+ return _noise(image, np_rng, 18)
271
+ if kind == "jpeg":
272
+ return _jpeg(image, 28)
273
+ if kind == "lowres":
274
+ return _down_up(image, rng, factor=3)
275
+ if kind == "rotate":
276
+ return image.rotate(2.5, resample=Image.Resampling.BILINEAR, expand=True, fillcolor=background)
277
+ if kind == "shadow":
278
+ return _lighting(image, np_rng, strength=0.55)
279
+ if kind == "small":
280
+ return image
281
+ raise DataError(f"unknown corruption {kind!r}")
282
+
283
+
284
+ def _scan(image: Image.Image, rng: random.Random, np_rng: np.random.Generator) -> Image.Image:
285
+ """Approximate a low-resolution office scan: shrink, add sensor noise, re-enlarge."""
286
+ width, height = image.size
287
+ target = rng.randint(10, 18)
288
+ if height <= target:
289
+ return _noise(image, np_rng, rng.uniform(4, 12))
290
+ scale = target / float(height)
291
+ small = image.resize((max(1, int(width * scale)), target), Image.Resampling.BOX)
292
+ small = _noise(small, np_rng, rng.uniform(4, 16))
293
+ if rng.random() < 0.5:
294
+ small = _jpeg(small, rng.randint(35, 75))
295
+ return small.resize((width, height), Image.Resampling.BILINEAR)
296
+
297
+
298
+ def _noise(image: Image.Image, np_rng: np.random.Generator, sigma: float) -> Image.Image:
299
+ array = np.asarray(image).astype(np.float32)
300
+ array += np_rng.normal(0.0, sigma, size=array.shape)
301
+ return Image.fromarray(np.clip(array, 0, 255).astype(np.uint8), mode="L")
302
+
303
+
304
+ def _jpeg(image: Image.Image, quality: int) -> Image.Image:
305
+ buffer = BytesIO()
306
+ image.convert("L").save(buffer, format="JPEG", quality=int(quality))
307
+ buffer.seek(0)
308
+ with Image.open(buffer) as handle:
309
+ return handle.convert("L")
310
+
311
+
312
+ def _lighting(image: Image.Image, np_rng: np.random.Generator, strength: float = 0.35) -> Image.Image:
313
+ array = np.asarray(image).astype(np.float32)
314
+ height, width = array.shape
315
+ direction = float(np_rng.random())
316
+ ramp = np.linspace(1.0 - strength, 1.0, width if direction < 0.5 else height, dtype=np.float32)
317
+ if direction < 0.5:
318
+ array *= ramp[None, :]
319
+ else:
320
+ array *= ramp[:, None]
321
+ return Image.fromarray(np.clip(array, 0, 255).astype(np.uint8), mode="L")
322
+
323
+
324
+ def _down_up(image: Image.Image, rng: random.Random, factor: int | None = None) -> Image.Image:
325
+ scale = factor or rng.choice([2, 2, 3])
326
+ width, height = image.size
327
+ small = image.resize((max(1, width // scale), max(1, height // scale)), Image.Resampling.BILINEAR)
328
+ return small.resize((width, height), Image.Resampling.BILINEAR)
329
+
330
+
331
+ def _shear(image: Image.Image, amount: float, background: int) -> Image.Image:
332
+ return image.transform(
333
+ image.size,
334
+ Image.Transform.AFFINE,
335
+ (1, amount, 0, 0, 1, 0),
336
+ resample=Image.Resampling.BILINEAR,
337
+ fillcolor=background,
338
+ )
339
+
340
+
341
+ def fit_label_width(
342
+ image: Image.Image,
343
+ text: str,
344
+ height: int,
345
+ channels: list[int],
346
+ max_width: int,
347
+ width_stride: int = 2,
348
+ ) -> Image.Image:
349
+ """Resize to the model height and widen until CTC can align the label, including repeats."""
350
+ image = resize_to_height(image.convert("L"), height)
351
+ specs = build_specs([int(value) for value in channels], int(width_stride))
352
+ needed = max(len(text), ctc_time_needed(text))
353
+ width = image.width
354
+ guard = 0
355
+ while output_width(max(width, 8), specs) < needed and guard < 8:
356
+ width = max(width + 2, int(width * 1.25) + len(text))
357
+ guard += 1
358
+ if width > max(max_width * 2, 4096):
359
+ raise DataError(
360
+ f"label {text!r} needs more than {width} pixels. Raise data.max_width or lower data.max_chars."
361
+ )
362
+ if width != image.width:
363
+ image = image.resize((width, height), Image.Resampling.BILINEAR)
364
+ if output_width(image.width, specs) < needed:
365
+ raise DataError(f"label {text!r} does not fit in the image width")
366
+ return image
367
+
368
+
369
+ def make_sample(
370
+ index: int,
371
+ seed: int,
372
+ config: dict,
373
+ fonts: list[str],
374
+ augment: bool,
375
+ text: str | None = None,
376
+ corruption: str | None = None,
377
+ ) -> dict:
378
+ data_cfg = config["data"]
379
+ model_cfg = config["model"]
380
+ rng = random.Random(seed + index * 10007)
381
+ np_rng = np.random.default_rng(seed + index * 10007)
382
+ label = text if text is not None else sample_text(rng, int(data_cfg["max_chars"]))
383
+ charset = (config.get("vocab") or {}).get("charset") or default_charset()
384
+ try:
385
+ Vocabulary(charset).encode(label)
386
+ except ValueError as exc:
387
+ raise DataError(str(exc)) from exc
388
+ image = render_text(
389
+ label,
390
+ rng,
391
+ np_rng,
392
+ fonts,
393
+ int(data_cfg["font_size_min"]),
394
+ int(data_cfg["font_size_max"]),
395
+ augment=augment,
396
+ corruption=corruption,
397
+ )
398
+ if corruption == "small":
399
+ image = image.resize((max(1, image.width // 2), max(1, image.height // 2)), Image.Resampling.BILINEAR)
400
+ image = fit_label_width(
401
+ image,
402
+ label,
403
+ int(model_cfg["image_height"]),
404
+ list(model_cfg["channels"]),
405
+ int(data_cfg["max_width"]),
406
+ int(model_cfg.get("width_stride", 2)),
407
+ )
408
+ tensor, pad_value = prepare_line(
409
+ image,
410
+ height=int(model_cfg["image_height"]),
411
+ align=2,
412
+ max_width=max(int(data_cfg["max_width"]), image.width),
413
+ min_width=8,
414
+ )
415
+ return {
416
+ "image": tensor,
417
+ "text": label,
418
+ "pad_value": pad_value,
419
+ "width": int(tensor.shape[-1]),
420
+ }
421
+
422
+
423
+ def generate_dataset(config: dict, samples: int, output_dir: str | Path, seed: int, augment: bool = False) -> Path:
424
+ """Write PNG lines and a JSONL manifest. Used for inspection, not required to train."""
425
+ destination = Path(output_dir)
426
+ destination.mkdir(parents=True, exist_ok=True)
427
+ fonts = list(discover_fonts(config["data"].get("font_dir") or ""))
428
+ manifest = destination / "manifest.jsonl"
429
+ with manifest.open("w", encoding="utf-8") as handle:
430
+ for index in range(int(samples)):
431
+ sample = make_sample(index, seed, config, fonts, augment=augment)
432
+ image = _tensor_to_pil(sample["image"])
433
+ name = f"{index:06d}.png"
434
+ image.save(destination / name)
435
+ handle.write(
436
+ json.dumps({"image": name, "text": sample["text"], "width": sample["width"]}, ensure_ascii=True)
437
+ + "\n"
438
+ )
439
+ return manifest
440
+
441
+
442
+ def _tensor_to_pil(tensor) -> Image.Image:
443
+ array = tensor.squeeze(0).detach().cpu().numpy()
444
+ array = np.clip((array * 0.5 + 0.5) * 255.0, 0, 255).astype(np.uint8)
445
+ return Image.fromarray(array, mode="L")
@@ -0,0 +1,5 @@
1
+ """Page layout helpers."""
2
+
3
+ from wormocr.detection.lines import Box, PageLayout, segment_page
4
+
5
+ __all__ = ["Box", "PageLayout", "segment_page"]