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.
- wormocr/__init__.py +18 -0
- wormocr/__main__.py +4 -0
- wormocr/api.py +229 -0
- wormocr/benchmark/__init__.py +5 -0
- wormocr/benchmark/report.py +403 -0
- wormocr/checkpoints.py +55 -0
- wormocr/cli.py +308 -0
- wormocr/config.py +220 -0
- wormocr/data/__init__.py +5 -0
- wormocr/data/dataset.py +110 -0
- wormocr/data/fonts.py +173 -0
- wormocr/data/handwriting.py +304 -0
- wormocr/data/lexicon.py +105 -0
- wormocr/data/lexicon_medical.py +48 -0
- wormocr/data/mixed.py +211 -0
- wormocr/data/synthetic.py +445 -0
- wormocr/detection/__init__.py +5 -0
- wormocr/detection/lines.py +382 -0
- wormocr/evaluation/__init__.py +5 -0
- wormocr/evaluation/errors.py +145 -0
- wormocr/evaluation/metrics.py +105 -0
- wormocr/evaluation/robustness.py +43 -0
- wormocr/exceptions.py +25 -0
- wormocr/export/__init__.py +5 -0
- wormocr/export/onnx_export.py +75 -0
- wormocr/hub.py +157 -0
- wormocr/inference/__init__.py +5 -0
- wormocr/inference/decode.py +113 -0
- wormocr/inference/engine.py +122 -0
- wormocr/models/__init__.py +5 -0
- wormocr/models/crnn.py +236 -0
- wormocr/preprocessing.py +135 -0
- wormocr/py.typed +0 -0
- wormocr/runtime.py +56 -0
- wormocr/training/__init__.py +5 -0
- wormocr/training/loop.py +645 -0
- wormocr/vocabulary.py +69 -0
- wormocr/web/__init__.py +5 -0
- wormocr/web/server.py +505 -0
- wormocr/web/static/app.js +514 -0
- wormocr/web/static/index.html +141 -0
- wormocr/web/static/style.css +379 -0
- wormocr-1.0.0.dist-info/METADATA +377 -0
- wormocr-1.0.0.dist-info/RECORD +48 -0
- wormocr-1.0.0.dist-info/WHEEL +5 -0
- wormocr-1.0.0.dist-info/entry_points.txt +2 -0
- wormocr-1.0.0.dist-info/licenses/LICENSE +21 -0
- wormocr-1.0.0.dist-info/top_level.txt +1 -0
wormocr/data/dataset.py
ADDED
|
@@ -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
|
wormocr/data/lexicon.py
ADDED
|
@@ -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"
|