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
|
@@ -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")
|