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,110 @@
1
+ """Training datasets: on-the-fly synthetic lines, manifests, and label files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import torch
8
+ from PIL import Image
9
+ from torch.utils.data import Dataset
10
+
11
+ from wormocr.data.fonts import discover_fonts
12
+ from wormocr.data.synthetic import make_sample
13
+ from wormocr.exceptions import DataError
14
+ from wormocr.preprocessing import prepare_line
15
+ from wormocr.vocabulary import Vocabulary
16
+
17
+
18
+ class OnlineSynthDataset(Dataset):
19
+ """Deterministic synthetic lines. Item i depends only on the seed and index."""
20
+
21
+ def __init__(self, config: dict, samples: int, seed: int, augment: bool) -> None:
22
+ self.config = config
23
+ self.samples = int(samples)
24
+ self.seed = int(seed)
25
+ self.augment = bool(augment)
26
+ self.fonts = list(discover_fonts(config["data"].get("font_dir") or ""))
27
+
28
+ def __len__(self) -> int:
29
+ return self.samples
30
+
31
+ def __getitem__(self, index: int) -> dict:
32
+ return make_sample(int(index), self.seed, self.config, self.fonts, self.augment)
33
+
34
+
35
+ class LabelDataset(Dataset):
36
+ """Real or exported lines described by `relative-path<TAB>text`."""
37
+
38
+ def __init__(self, labels_path: str | Path, image_root: str | Path, config: dict, vocabulary: Vocabulary) -> None:
39
+ self.root = Path(image_root)
40
+ self.config = config
41
+ self.vocabulary = vocabulary
42
+ self.records = _read_labels(labels_path)
43
+ if not self.records:
44
+ raise DataError(f"no labels found in {labels_path}")
45
+
46
+ def __len__(self) -> int:
47
+ return len(self.records)
48
+
49
+ def __getitem__(self, index: int) -> dict:
50
+ relative, text = self.records[index]
51
+ path = self.root / relative
52
+ if not path.exists():
53
+ raise DataError(f"missing image for label {relative!r}: {path}")
54
+ try:
55
+ self.vocabulary.encode(text)
56
+ except ValueError as exc:
57
+ raise DataError(f"{relative}: {exc}") from exc
58
+ if not text:
59
+ raise DataError(f"{relative} has an empty label")
60
+ with Image.open(path) as handle:
61
+ image = handle.convert("RGB")
62
+ tensor, pad_value = prepare_line(
63
+ image,
64
+ height=int(self.config["model"]["image_height"]),
65
+ align=2,
66
+ max_width=int(self.config["data"]["max_width"]),
67
+ )
68
+ return {
69
+ "image": tensor,
70
+ "text": text,
71
+ "pad_value": pad_value,
72
+ "width": int(tensor.shape[-1]),
73
+ "path": str(path),
74
+ }
75
+
76
+
77
+ def _read_labels(path: str | Path) -> list[tuple[str, str]]:
78
+ records: list[tuple[str, str]] = []
79
+ source = Path(path)
80
+ if not source.exists():
81
+ raise DataError(f"labels file not found: {source}")
82
+ for line_number, raw in enumerate(source.read_text(encoding="utf-8").splitlines(), start=1):
83
+ stripped = raw.strip()
84
+ if not stripped or stripped.startswith("#"):
85
+ continue
86
+ if "\t" not in stripped:
87
+ raise DataError(f"{source}:{line_number} must be image<TAB>text")
88
+ relative, text = stripped.split("\t", 1)
89
+ records.append((relative.strip(), text))
90
+ return records
91
+
92
+
93
+ def collate_lines(samples: list[dict]) -> dict:
94
+ if not samples:
95
+ raise DataError("empty batch")
96
+ height = samples[0]["image"].shape[-2]
97
+ widths = [int(sample["image"].shape[-1]) for sample in samples]
98
+ max_width = max(widths)
99
+ images = samples[0]["image"].new_empty(len(samples), 1, height, max_width)
100
+ for index, sample in enumerate(samples):
101
+ image = sample["image"]
102
+ width = image.shape[-1]
103
+ images[index, :, :, :width] = image
104
+ if width < max_width:
105
+ images[index, :, :, width:] = float(sample["pad_value"])
106
+ return {
107
+ "images": images,
108
+ "texts": [sample["text"] for sample in samples],
109
+ "widths": torch.tensor(widths, dtype=torch.long),
110
+ }
wormocr/data/fonts.py ADDED
@@ -0,0 +1,173 @@
1
+ """Locate installed fonts that can draw Latin letters.
2
+
3
+ wormOCR does not ship font files. Rendering uses fonts already on the machine.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import functools
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ from PIL import Image, ImageDraw, ImageFont
13
+
14
+ from wormocr.exceptions import DataError
15
+
16
+ PREFERRED = (
17
+ "arial.ttf",
18
+ "arialbd.ttf",
19
+ "ariali.ttf",
20
+ "arialbi.ttf",
21
+ "calibri.ttf",
22
+ "calibrib.ttf",
23
+ "calibrii.ttf",
24
+ "calibriz.ttf",
25
+ "times.ttf",
26
+ "timesbd.ttf",
27
+ "timesi.ttf",
28
+ "timesbi.ttf",
29
+ "verdana.ttf",
30
+ "verdanab.ttf",
31
+ "verdanai.ttf",
32
+ "georgia.ttf",
33
+ "georgiab.ttf",
34
+ "georgiai.ttf",
35
+ "trebuc.ttf",
36
+ "trebucbd.ttf",
37
+ "segoeui.ttf",
38
+ "segoeuib.ttf",
39
+ "segoeuii.ttf",
40
+ "consola.ttf",
41
+ "consolab.ttf",
42
+ "cour.ttf",
43
+ "courbd.ttf",
44
+ "cambria.ttf",
45
+ "cambriab.ttf",
46
+ "candara.ttf",
47
+ "candarab.ttf",
48
+ "corbel.ttf",
49
+ "constan.ttf",
50
+ "constanb.ttf",
51
+ "tahoma.ttf",
52
+ "tahomabd.ttf",
53
+ "comic.ttf",
54
+ "comicbd.ttf",
55
+ "gara.ttf",
56
+ "garabd.ttf",
57
+ "pala.ttf",
58
+ "palab.ttf",
59
+ "lucon.ttf",
60
+ "bahnschrift.ttf",
61
+ "micross.ttf",
62
+ "framd.ttf",
63
+ "century.ttf",
64
+ "bookos.ttf",
65
+ "bookosb.ttf",
66
+ "DejaVuSans.ttf",
67
+ "DejaVuSans-Bold.ttf",
68
+ "LiberationSans-Regular.ttf",
69
+ "LiberationSans-Bold.ttf",
70
+ "FreeSans.ttf",
71
+ )
72
+
73
+ SKIP_NAME_PARTS = (
74
+ "wingding",
75
+ "webding",
76
+ "symbol",
77
+ "marlett",
78
+ "emoji",
79
+ "seguihis",
80
+ "segmdl2",
81
+ "holomdl",
82
+ "reference",
83
+ "mtsort",
84
+ "mtextra",
85
+ "jsmath",
86
+ "symbol",
87
+ )
88
+
89
+
90
+ def font_directories(extra: str | None = None) -> list[Path]:
91
+ home = Path.home()
92
+ candidates = [
93
+ Path(extra) if extra else None,
94
+ Path(r"C:\Windows\Fonts"),
95
+ home / "AppData/Local/Microsoft/Windows/Fonts",
96
+ Path("/usr/share/fonts"),
97
+ Path("/usr/local/share/fonts"),
98
+ home / ".fonts",
99
+ home / ".local/share/fonts",
100
+ Path("/Library/Fonts"),
101
+ Path("/System/Library/Fonts"),
102
+ home / "Library/Fonts",
103
+ ]
104
+ return [path for path in candidates if path is not None and path.exists()]
105
+
106
+
107
+ def _renders_latin(path: Path) -> bool:
108
+ try:
109
+ font = ImageFont.truetype(str(path), 28)
110
+ except OSError:
111
+ return False
112
+ image = Image.new("L", (96, 48), 255)
113
+ ImageDraw.Draw(image).text((4, 4), "Ag", font=font, fill=0)
114
+ ink = int((np.asarray(image) < 250).sum())
115
+ return ink > 15
116
+
117
+
118
+ def _iter_files(directory: Path) -> list[Path]:
119
+ found: list[Path] = []
120
+ if not directory.exists():
121
+ return found
122
+ for pattern in ("*.ttf", "*.otf", "*.ttc"):
123
+ found.extend(directory.glob(pattern))
124
+ found.extend(path for path in directory.rglob(pattern) if path.parent != directory)
125
+ unique = {path.resolve(): path for path in found if path.is_file()}
126
+ return sorted(unique.values(), key=lambda item: item.name.lower())
127
+
128
+
129
+ @functools.lru_cache(maxsize=8)
130
+ def discover_fonts(font_dir: str = "") -> tuple[str, ...]:
131
+ directories = font_directories(font_dir or None)
132
+ if not directories:
133
+ raise DataError(
134
+ "No font directory was found. Install a Latin font such as Arial or "
135
+ "DejaVu Sans, or set data.font_dir to a folder of .ttf files."
136
+ )
137
+ chosen: list[Path] = []
138
+ seen: set[Path] = set()
139
+ for name in PREFERRED:
140
+ for directory in directories:
141
+ path = directory / name
142
+ if not path.exists():
143
+ continue
144
+ resolved = path.resolve()
145
+ if resolved in seen:
146
+ continue
147
+ if _renders_latin(path):
148
+ chosen.append(path)
149
+ seen.add(resolved)
150
+ break
151
+ if len(chosen) < 3:
152
+ available = {
153
+ path.resolve(): path
154
+ for directory in directories
155
+ for path in _iter_files(directory)
156
+ }
157
+ for path in available.values():
158
+ if path.resolve() in seen:
159
+ continue
160
+ lowered = path.name.lower()
161
+ if any(part in lowered for part in SKIP_NAME_PARTS):
162
+ continue
163
+ if _renders_latin(path):
164
+ chosen.append(path)
165
+ seen.add(path.resolve())
166
+ if len(chosen) >= 12:
167
+ break
168
+ if not chosen:
169
+ raise DataError(
170
+ "No installed font could draw Latin letters. Install Arial, Calibri, "
171
+ "or DejaVu Sans, or set data.font_dir."
172
+ )
173
+ return tuple(str(path) for path in chosen)
@@ -0,0 +1,304 @@
1
+ """Synthetic handwritten lines from open-licensed handwriting fonts.
2
+
3
+ Fonts are downloaded by scripts/download_fonts.py (SIL OFL / Apache 2.0) and
4
+ are never committed. Each sample is a pure function of (seed, index), like the
5
+ printed generator. Font regularity is broken up with per-word or per-glyph
6
+ jitter, slant, a wavy baseline, elastic warping, stroke-width and pressure
7
+ changes, paper textures (ruled lines, grids, stains), and camera effects.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import functools
13
+ import random
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+ from PIL import Image, ImageDraw, ImageFilter, ImageFont
18
+
19
+ from wormocr.config import repo_root
20
+ from wormocr.data.lexicon import WORDS
21
+ from wormocr.data.lexicon_medical import DURATIONS, FORMS, FREQUENCIES, GENERICS, NOTES, UNITS
22
+ from wormocr.data.synthetic import _down_up, _jpeg, _lighting, _noise, _number
23
+ from wormocr.exceptions import DataError
24
+ from wormocr.vocabulary import default_charset
25
+
26
+ DEFAULT_FONT_DIR = "artifacts/fonts/handwriting"
27
+ LETTERS = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
28
+ CHARSET = set(default_charset())
29
+ FIRST_NAMES = "james mary john linda robert sarah michael priya arjun fatima chen maria david anna ali omar sofia luca emma noah".split()
30
+ SURNAMES = "smith garcia shukla khan nguyen patel muller rossi kim silva brown wilson ahmed sato lopez martin".split()
31
+
32
+
33
+ @functools.lru_cache(maxsize=4)
34
+ def handwriting_fonts(font_dir: str = "") -> tuple[tuple[str, frozenset], ...]:
35
+ """(path, characters it can draw) for every usable handwriting font."""
36
+ from fontTools.ttLib import TTFont
37
+
38
+ root = Path(font_dir or DEFAULT_FONT_DIR)
39
+ if not root.is_absolute():
40
+ root = repo_root() / root
41
+ found: list[tuple[str, frozenset]] = []
42
+ for path in sorted(root.rglob("*.ttf")):
43
+ try:
44
+ cmap = TTFont(str(path), lazy=True).getBestCmap() or {}
45
+ ImageFont.truetype(str(path), 24)
46
+ except Exception:
47
+ continue
48
+ coverage = frozenset(chr(code) for code in cmap if chr(code) in CHARSET)
49
+ if LETTERS <= coverage and len(coverage) >= 62:
50
+ found.append((str(path), coverage))
51
+ if not found:
52
+ raise DataError(
53
+ f"no handwriting fonts found in {root}. Run: python scripts/download_fonts.py"
54
+ )
55
+ return tuple(found)
56
+
57
+
58
+ def sample_hw_text(rng: random.Random, max_chars: int) -> str:
59
+ kind = rng.choices(
60
+ ["phrase", "sentence", "name", "number", "medical", "note", "word", "mixed"],
61
+ weights=[22, 20, 10, 10, 16, 8, 10, 4],
62
+ )[0]
63
+ if kind == "phrase":
64
+ text = " ".join(rng.choice(WORDS) for _ in range(rng.randint(2, 4)))
65
+ elif kind == "sentence":
66
+ words = [rng.choice(WORDS) for _ in range(rng.randint(4, 8))]
67
+ words[0] = words[0].capitalize()
68
+ text = " ".join(words) + rng.choice([".", ".", ",", "", "!", "?", ":"])
69
+ elif kind == "name":
70
+ first, last = rng.choice(FIRST_NAMES), rng.choice(SURNAMES)
71
+ style = rng.random()
72
+ if style < 0.35:
73
+ text = f"{first.upper()} {last.upper()}"
74
+ elif style < 0.7:
75
+ text = f"{first.capitalize()} {last.capitalize()}"
76
+ else:
77
+ text = f"{rng.choice(['Dr.', 'Mr.', 'Ms.', 'Mrs.'])} {last.capitalize()}"
78
+ elif kind == "number":
79
+ text = rng.choice(
80
+ [
81
+ _number(rng),
82
+ f"{rng.randint(1, 12)}:{rng.randint(0, 59):02d} {rng.choice(['am', 'pm', 'AM', 'PM'])}",
83
+ f"Rs. {rng.randint(10, 9999)}",
84
+ f"${rng.randint(1, 999)}.{rng.randint(0, 99):02d}",
85
+ f"+91 {rng.randint(70000, 99999)} {rng.randint(10000, 99999)}",
86
+ f"{rng.randint(1, 28)}/{rng.randint(1, 12)}/{rng.randint(20, 26)}",
87
+ ]
88
+ )
89
+ elif kind == "medical":
90
+ drug = rng.choice(GENERICS)
91
+ drug = drug.capitalize() if rng.random() < 0.6 else (drug.upper() if rng.random() < 0.5 else drug)
92
+ dose = f"{rng.choice([5, 10, 20, 25, 40, 50, 100, 250, 500, 650, 1])}{rng.choice(['', ' '])}{rng.choice(UNITS)}"
93
+ parts = [rng.choice(FORMS), drug, dose]
94
+ if rng.random() < 0.7:
95
+ parts.append(rng.choice(FREQUENCIES))
96
+ if rng.random() < 0.4:
97
+ parts.append(rng.choice(DURATIONS))
98
+ text = " ".join(parts[: rng.randint(2, len(parts))])
99
+ elif kind == "note":
100
+ text = rng.choice(NOTES)
101
+ if rng.random() < 0.5:
102
+ text += rng.choice([f" {rng.randint(90, 180)}/{rng.randint(50, 110)}", f" {rng.randint(1, 30)} days", ": " + rng.choice(WORDS)])
103
+ elif kind == "word":
104
+ text = rng.choice(WORDS + GENERICS)
105
+ roll = rng.random()
106
+ text = text.capitalize() if roll < 0.35 else (text.upper() if roll < 0.5 else text)
107
+ else:
108
+ text = f"{rng.choice(WORDS)} {rng.randint(1, 99)} {rng.choice(WORDS)}"
109
+ text = text[: max(1, int(max_chars))].strip()
110
+ return text or "a"
111
+
112
+
113
+ def _pick_font(text: str, rng: random.Random, fonts: tuple[tuple[str, frozenset], ...]) -> str | None:
114
+ needed = set(text) - {" "}
115
+ for _ in range(12):
116
+ path, coverage = rng.choice(fonts)
117
+ if needed <= coverage:
118
+ return path
119
+ usable = [path for path, coverage in fonts if needed <= coverage]
120
+ return rng.choice(usable) if usable else None
121
+
122
+
123
+ def _paste_max(canvas: np.ndarray, piece: np.ndarray, x: int, y: int) -> None:
124
+ height, width = piece.shape
125
+ x0, y0 = max(0, x), max(0, y)
126
+ x1, y1 = min(canvas.shape[1], x + width), min(canvas.shape[0], y + height)
127
+ if x1 <= x0 or y1 <= y0:
128
+ return
129
+ region = canvas[y0:y1, x0:x1]
130
+ np.maximum(region, piece[y0 - y : y1 - y, x0 - x : x1 - x], out=region)
131
+
132
+
133
+ def _render_piece(text: str, font: ImageFont.FreeTypeFont, angle: float) -> np.ndarray:
134
+ left, top, right, bottom = font.getbbox(text)
135
+ pad = 4
136
+ image = Image.new("L", (right - left + 2 * pad, bottom - top + 2 * pad), 0)
137
+ ImageDraw.Draw(image).text((pad - left, pad - top), text, font=font, fill=255)
138
+ if abs(angle) > 0.2:
139
+ image = image.rotate(angle, resample=Image.Resampling.BICUBIC, expand=True, fillcolor=0)
140
+ return np.asarray(image, dtype=np.uint8), top - pad
141
+
142
+
143
+ def _ink_mask(text: str, font_path: str, size: int, rng: random.Random) -> np.ndarray:
144
+ font = ImageFont.truetype(font_path, size)
145
+ ascent, descent = font.getmetrics()
146
+ space = max(font.getlength(" "), 0.28 * size)
147
+ glyph_mode = rng.random() < 0.35
148
+ canvas = np.zeros((int((ascent + descent) * 2.2) + 8, int(font.getlength(text) * 1.6 + size * 2 + 16)), np.uint8)
149
+ baseline = int(ascent * 1.4) + 4
150
+ x = size // 3 + 2
151
+ drift = 0.0
152
+ drift_step = rng.uniform(-0.04, 0.04) * size
153
+ for word_index, word in enumerate(text.split(" ")):
154
+ if word_index:
155
+ x += int(space * rng.uniform(0.85, 1.8))
156
+ if not word:
157
+ continue
158
+ drift += drift_step * rng.uniform(0.0, 1.0)
159
+ if glyph_mode:
160
+ for char in word:
161
+ scale = rng.uniform(0.88, 1.12)
162
+ glyph_font = ImageFont.truetype(font_path, max(8, int(size * scale)))
163
+ piece, top = _render_piece(char, glyph_font, rng.uniform(-6, 6))
164
+ offset = int(drift + rng.uniform(-0.06, 0.06) * size)
165
+ _paste_max(canvas, piece, x, baseline - glyph_font.getmetrics()[0] + top + offset)
166
+ x += int(glyph_font.getlength(char) * rng.uniform(0.82, 1.08))
167
+ else:
168
+ word_font = ImageFont.truetype(font_path, max(8, int(size * rng.uniform(0.9, 1.1))))
169
+ piece, top = _render_piece(word, word_font, rng.uniform(-3.5, 3.5))
170
+ offset = int(drift + rng.uniform(-0.07, 0.07) * size)
171
+ _paste_max(canvas, piece, x, baseline - word_font.getmetrics()[0] + top + offset)
172
+ inked = np.where(piece.max(axis=0) > 40)[0]
173
+ ink_right = int(inked[-1]) + 1 if len(inked) else 0
174
+ x += max(int(word_font.getlength(word)), ink_right)
175
+ return canvas
176
+
177
+
178
+ def _smooth_field(np_rng: np.random.Generator, shape: tuple[int, int], cells: int, amplitude: float) -> np.ndarray:
179
+ small = np_rng.normal(0.0, 1.0, size=(max(2, shape[0] // cells + 2), max(2, shape[1] // cells + 2))).astype(np.float32)
180
+ field = Image.fromarray(small, mode="F").resize((shape[1], shape[0]), Image.Resampling.BICUBIC)
181
+ return np.asarray(field, dtype=np.float32) * amplitude
182
+
183
+
184
+ def _remap(array: np.ndarray, dx: np.ndarray, dy: np.ndarray) -> np.ndarray:
185
+ height, width = array.shape
186
+ ys, xs = np.mgrid[0:height, 0:width].astype(np.float32)
187
+ sx = np.clip(xs + dx, 0, width - 1.001)
188
+ sy = np.clip(ys + dy, 0, height - 1.001)
189
+ x0, y0 = sx.astype(np.int32), sy.astype(np.int32)
190
+ fx, fy = sx - x0, sy - y0
191
+ source = array.astype(np.float32)
192
+ top = source[y0, x0] * (1 - fx) + source[y0, x0 + 1] * fx
193
+ bottom = source[y0 + 1, x0] * (1 - fx) + source[y0 + 1, x0 + 1] * fx
194
+ return top * (1 - fy) + bottom * fy
195
+
196
+
197
+ def _geometry(mask: np.ndarray, size: int, rng: random.Random, np_rng: np.random.Generator) -> np.ndarray:
198
+ height, width = mask.shape
199
+ ys, xs = np.mgrid[0:height, 0:width].astype(np.float32)
200
+ slant = rng.uniform(-0.38, 0.32)
201
+ dx = slant * (ys - height / 2)
202
+ wave = rng.uniform(0.0, 0.09) * size
203
+ period = rng.uniform(1.5, 5.0) * size
204
+ dy = wave * np.sin(xs / period * 2 * np.pi + rng.uniform(0, 6.3))
205
+ curve = rng.uniform(-0.05, 0.05) * size
206
+ dy += curve * ((xs - width / 2) / max(1.0, width / 2)) ** 2
207
+ if rng.random() < 0.7:
208
+ amplitude = rng.uniform(0.4, 1.6) * size / 32
209
+ dx += _smooth_field(np_rng, mask.shape, max(6, size // 3), amplitude)
210
+ dy += _smooth_field(np_rng, mask.shape, max(6, size // 3), amplitude)
211
+ return np.clip(_remap(mask, dx, dy), 0, 255)
212
+
213
+
214
+ def _paper(
215
+ shape: tuple[int, int], size: int, rng: random.Random, np_rng: np.random.Generator
216
+ ) -> tuple[np.ndarray, float]:
217
+ height, width = shape
218
+ base = rng.uniform(175, 252)
219
+ paper = np.full(shape, base, np.float32)
220
+ paper += _smooth_field(np_rng, shape, max(8, size), rng.uniform(1, 9))
221
+ paper += np_rng.normal(0, rng.uniform(0.5, 4), size=shape).astype(np.float32)
222
+ style = rng.random()
223
+ line_tone = base - rng.uniform(25, 80)
224
+ if style < 0.35:
225
+ spacing = rng.uniform(1.0, 1.6) * size
226
+ start = rng.uniform(0, spacing)
227
+ for y in np.arange(start, height, spacing):
228
+ row = int(y)
229
+ paper[row : row + rng.choice([1, 1, 2])] = line_tone
230
+ elif style < 0.45:
231
+ spacing = rng.uniform(0.5, 1.0) * size
232
+ for y in np.arange(rng.uniform(0, spacing), height, spacing):
233
+ paper[int(y)] = line_tone
234
+ for x in np.arange(rng.uniform(0, spacing), width, spacing):
235
+ paper[:, int(x)] = line_tone
236
+ if rng.random() < 0.08:
237
+ column = int(rng.uniform(0.02, 0.15) * width)
238
+ paper[:, column : column + 2] = base - rng.uniform(40, 90)
239
+ if rng.random() < 0.15:
240
+ cy, cx = rng.uniform(0, height), rng.uniform(0, width)
241
+ radius = rng.uniform(0.5, 2.5) * size
242
+ ys, xs = np.mgrid[0:height, 0:width]
243
+ blob = np.exp(-(((ys - cy) ** 2 + (xs - cx) ** 2) / (2 * radius**2)))
244
+ paper -= blob.astype(np.float32) * rng.uniform(15, 60)
245
+ return paper, base
246
+
247
+
248
+ def render_handwriting(
249
+ text: str,
250
+ rng: random.Random,
251
+ np_rng: np.random.Generator,
252
+ fonts: tuple[tuple[str, frozenset], ...],
253
+ size_min: int = 26,
254
+ size_max: int = 56,
255
+ augment: bool = True,
256
+ ) -> Image.Image:
257
+ font_path = _pick_font(text, rng, fonts)
258
+ if font_path is None:
259
+ raise DataError(f"no handwriting font can draw {text!r}")
260
+ size = rng.randint(size_min, size_max)
261
+ mask = _ink_mask(text, font_path, size, rng)
262
+ if augment:
263
+ mask = _geometry(mask, size, rng, np_rng)
264
+ mask_image = Image.fromarray(mask.astype(np.uint8), mode="L")
265
+ roll = rng.random()
266
+ if roll < 0.25:
267
+ mask_image = mask_image.filter(ImageFilter.MaxFilter(3))
268
+ elif roll < 0.38 and size > 34:
269
+ thinned = mask_image.filter(ImageFilter.MinFilter(3))
270
+ before = int((np.asarray(mask_image) > 128).sum())
271
+ if (np.asarray(thinned) > 128).sum() >= 0.6 * before:
272
+ mask_image = thinned
273
+ mask = np.asarray(mask_image, dtype=np.float32)
274
+ rows = np.where(mask.max(axis=1) > 40)[0]
275
+ cols = np.where(mask.max(axis=0) > 40)[0]
276
+ if len(rows) == 0 or len(cols) == 0:
277
+ raise DataError(f"rendered handwriting for {text!r} has no ink")
278
+ pad_y = int(rng.uniform(0.08, 0.45) * size)
279
+ pad_x = int(rng.uniform(0.1, 0.6) * size)
280
+ top, bottom = max(0, rows[0] - pad_y), min(mask.shape[0], rows[-1] + pad_y + 1)
281
+ left, right = max(0, cols[0] - pad_x), min(mask.shape[1], cols[-1] + pad_x + 1)
282
+ alpha = mask[top:bottom, left:right] / 255.0
283
+ if augment:
284
+ pressure = 1.0 - np.clip(_smooth_field(np_rng, alpha.shape, max(6, size // 2), rng.uniform(0.0, 0.25)), -0.15, 0.35)
285
+ alpha = np.clip(alpha * pressure * rng.uniform(0.8, 1.0), 0.0, 1.0)
286
+ paper, base = _paper(alpha.shape, size, rng, np_rng)
287
+ ink = rng.uniform(0, min(95.0, base - 90.0))
288
+ else:
289
+ paper, ink = np.full(alpha.shape, 245.0, np.float32), 20.0
290
+ page = paper * (1 - alpha) + ink * alpha
291
+ image = Image.fromarray(np.clip(page, 0, 255).astype(np.uint8), mode="L")
292
+ if augment:
293
+ for kind in rng.sample(["light", "blur", "noise", "jpeg", "lowres"], k=rng.randint(0, 3)):
294
+ if kind == "light":
295
+ image = _lighting(image, np_rng, strength=rng.uniform(0.1, 0.35))
296
+ elif kind == "blur":
297
+ image = image.filter(ImageFilter.GaussianBlur(radius=rng.uniform(0.3, 1.2)))
298
+ elif kind == "noise":
299
+ image = _noise(image, np_rng, rng.uniform(2, 12))
300
+ elif kind == "jpeg":
301
+ image = _jpeg(image, rng.randint(30, 85))
302
+ elif image.height > 40:
303
+ image = _down_up(image, rng)
304
+ return image
@@ -0,0 +1,105 @@
1
+ """A small built-in English word list for synthetic lines.
2
+
3
+ This is not a language model. It only gives the renderer realistic letter
4
+ sequences. Random strings still cover the rest of the charset.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ WORDS = """
10
+ a about above after again against all almost along already also always am
11
+ an and another any are around as at back be because been before being below
12
+ between both but by came can come could day did do does doing done down during
13
+ each early even ever every few find first for from further get give go good
14
+ great had has have he her here him his how i if in into is it its just keep
15
+ know large last later least left let like little long look made make many may
16
+ me might more most much must my near need never new next no not now of off
17
+ often old on once one only or other our out over own part people place put
18
+ right said same say see she should since small so some still such take than
19
+ that the their them then there these they this those through time to today
20
+ too under until up upon us use very want was water way we well were what when
21
+ where which while who why will with within without work would year you your
22
+ able across add age ago air allow alone along already amount answer appear
23
+ area arm ask away bad ball bank base become began begin behind believe best
24
+ better big bill black blue body book both box bring build business call car
25
+ care carry case catch cause center certain change check child city class clear
26
+ close color come common company complete consider continue control cost could
27
+ country course cover create cross current dark data day decide deep describe
28
+ design detail develop difference difficult direct door down draw drive drop
29
+ during each early earth east easy eat edge effect effort eight either else end
30
+ energy enough enter equal error especially evening ever every example expect
31
+ experience explain eye face fact fail fall family far fast father feel few
32
+ field figure fill final find fine finger finish fire firm first fish five
33
+ floor follow food foot force form forward found four free friend from front
34
+ full fund further future game garden general get girl give glass go goal
35
+ gold gone good government great green ground group grow guess half hand happen
36
+ happy hard have head health hear heart heat heavy help her high history hold
37
+ home hope horse hour house however human hundred idea important include
38
+ indeed information interest into issue item itself job join just keep key
39
+ kind king kitchen know land language large last late later laugh law lay lead
40
+ learn leave left leg less letter level lie life light like likely line list
41
+ listen little live local long look lose loss lot love low machine main major
42
+ make man many map mark market material matter may mean measure meet member
43
+ men mention middle might mile mind minute miss model modern moment money
44
+ month more morning most mother move much music must my name nation nature
45
+ near nearly need never new news next night nine no none nor north not note
46
+ nothing notice now number of off offer office often oil old on once one only
47
+ open or order other our out outside over own page paint paper parent part
48
+ party pass past pay people per perhaps period person phone pick picture
49
+ piece place plan plant play please point police policy poor position possible
50
+ power practice prepare present president pretty price private probably problem
51
+ process produce product program project provide public pull push put quality
52
+ question quick quite race radio raise range rate rather reach read ready real
53
+ really reason receive recent record red remain remember remove report require
54
+ research rest result return rich right rise road rock room rule run safe said
55
+ same save say school science sea season seat second see seem sell send sense
56
+ sentence serve service set seven several shake shall share she short should
57
+ show side sign simple since sing single sister sit six size skill small smile
58
+ so social society some son song soon sort sound south space speak special
59
+ spend stand start state stay step still stop story street strong student study
60
+ such sudden suggest summer sun support sure system table take talk tax teach
61
+ team tell ten term test than thank that the their them then theory there these
62
+ they thing think third this those though thought three through throughout throw
63
+ thus time to today together too took top total toward town trade train travel
64
+ tree trial trip trouble true try turn two type under understand unit until up
65
+ upon us use usually value various very view visit voice wait walk wall want
66
+ war was watch water way we week well west what whatever when where whether
67
+ which while white who whole why wide wife will win wind window wish with
68
+ within without woman wonder word work world would write wrong year yes yet you
69
+ young your yourself
70
+ bookkeeper committee mississippi balloon coffee letter success address moon
71
+ feed cool tall egg too see book keep good add fee all fall call hill bell
72
+ yellow apple queen zebra jazz fox box quick jumps lazy dog vex wax oxygen
73
+ """.split()
74
+
75
+ REPEAT_WORDS = [
76
+ "bookkeeper",
77
+ "committee",
78
+ "mississippi",
79
+ "balloon",
80
+ "coffee",
81
+ "letter",
82
+ "success",
83
+ "address",
84
+ "moon",
85
+ "feed",
86
+ "cool",
87
+ "tall",
88
+ "egg",
89
+ "see",
90
+ "too",
91
+ "good",
92
+ "add",
93
+ "all",
94
+ "book",
95
+ "keep",
96
+ "fee",
97
+ "hill",
98
+ "bell",
99
+ "yellow",
100
+ "apple",
101
+ "jazz",
102
+ "queen",
103
+ ]
104
+
105
+ PANGRAM = "The quick brown fox jumps over the lazy dog"