synthocr 2.1.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.
- synthocr/__init__.py +3 -0
- synthocr/__main__.py +3 -0
- synthocr/cli.py +540 -0
- synthocr/config.py +589 -0
- synthocr/corpora.py +160 -0
- synthocr/corpus_store.py +222 -0
- synthocr/downloads.py +77 -0
- synthocr/experiments.py +399 -0
- synthocr/font_catalog.json +579 -0
- synthocr/font_profiles.json +18 -0
- synthocr/fonts.py +225 -0
- synthocr/home.py +150 -0
- synthocr/kurdish_characters.json +442 -0
- synthocr/kurdish_spelling.json +45 -0
- synthocr/lexicons.json +82 -0
- synthocr/pipeline.py +512 -0
- synthocr/preview.py +112 -0
- synthocr/render.py +555 -0
- synthocr/spelling.py +111 -0
- synthocr/static/app.css +1111 -0
- synthocr/static/app.js +1759 -0
- synthocr/static/brand.svg +5 -0
- synthocr/static/index.html +773 -0
- synthocr/static/theme.js +28 -0
- synthocr/text.py +437 -0
- synthocr/verify.py +307 -0
- synthocr/web.py +2259 -0
- synthocr-2.1.0.dist-info/METADATA +261 -0
- synthocr-2.1.0.dist-info/RECORD +34 -0
- synthocr-2.1.0.dist-info/WHEEL +4 -0
- synthocr-2.1.0.dist-info/entry_points.txt +3 -0
- synthocr-2.1.0.dist-info/licenses/LICENSE-APACHE +202 -0
- synthocr-2.1.0.dist-info/licenses/LICENSE-MIT +21 -0
- synthocr-2.1.0.dist-info/licenses/NOTICE +25 -0
synthocr/config.py
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"""Small, typed TOML configuration with paths anchored to the config file."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import dataclass, field, fields, replace
|
|
6
|
+
from math import fsum, isfinite
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
|
|
9
|
+
DOCUMENT_LAYERS = (
|
|
10
|
+
"paper_texture",
|
|
11
|
+
"grain",
|
|
12
|
+
"shadow",
|
|
13
|
+
"stains",
|
|
14
|
+
"creases",
|
|
15
|
+
"ruled_lines",
|
|
16
|
+
"scan_banding",
|
|
17
|
+
"speckles",
|
|
18
|
+
"binding_shadow",
|
|
19
|
+
)
|
|
20
|
+
LAYER_FIELDS = DOCUMENT_LAYERS
|
|
21
|
+
# Plain names for the effect layers, shared by the API and the web page.
|
|
22
|
+
DOCUMENT_LAYER_LABELS = {
|
|
23
|
+
"paper_texture": ("Paper texture", "Fibers and uneven paper"),
|
|
24
|
+
"grain": ("Grain", "Scanner or camera noise"),
|
|
25
|
+
"shadow": ("Shadows", "Uneven light across the page"),
|
|
26
|
+
"stains": ("Stains", "Soft marks on the paper"),
|
|
27
|
+
"creases": ("Folds", "Light creases in the page"),
|
|
28
|
+
"ruled_lines": ("Notebook lines", "Horizontal writing guides"),
|
|
29
|
+
"scan_banding": ("Scan bands", "Faint lines from scanning or printing"),
|
|
30
|
+
"speckles": ("Speckles", "Paper fibers and toner dots"),
|
|
31
|
+
"binding_shadow": ("Book spine shadow", "Shading near a bound edge"),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Recipes supply ready-made palettes and layer defaults. Explicit overrides
|
|
35
|
+
# remain available, and geometry settings are independent of the recipe.
|
|
36
|
+
DOCUMENT_PRESETS = {
|
|
37
|
+
"clean-paper": {
|
|
38
|
+
"label": "Clean paper",
|
|
39
|
+
"description": "Light paper fibers and a small amount of fine grain.",
|
|
40
|
+
"background": "plain",
|
|
41
|
+
"color_mode": "grayscale",
|
|
42
|
+
"background_color": "#f8f8f5",
|
|
43
|
+
"background_color_end": "#f8f8f5",
|
|
44
|
+
"text_color": "#202020",
|
|
45
|
+
"layers": dict(zip(DOCUMENT_LAYERS, (0.18, 0.03, 0, 0, 0, 0, 0, 0, 0), strict=True)),
|
|
46
|
+
},
|
|
47
|
+
"office-scan": {
|
|
48
|
+
"label": "Office scan",
|
|
49
|
+
"description": "Fine scanner grain, soft illumination and faint scan bands.",
|
|
50
|
+
"background": "plain",
|
|
51
|
+
"color_mode": "grayscale",
|
|
52
|
+
"background_color": "#f4f4f4",
|
|
53
|
+
"background_color_end": "#f4f4f4",
|
|
54
|
+
"text_color": "#202020",
|
|
55
|
+
"layers": dict(zip(DOCUMENT_LAYERS, (0.12, 0.22, 0.16, 0, 0, 0, 0.12, 0, 0), strict=True)),
|
|
56
|
+
},
|
|
57
|
+
"aged-paper": {
|
|
58
|
+
"label": "Aged paper",
|
|
59
|
+
"description": "Mottled fibers, faded stains and light fold marks.",
|
|
60
|
+
"background": "plain",
|
|
61
|
+
"color_mode": "rgb",
|
|
62
|
+
"background_color": "#ecdeb9",
|
|
63
|
+
"background_color_end": "#ecdeb9",
|
|
64
|
+
"text_color": "#342a1e",
|
|
65
|
+
"layers": dict(
|
|
66
|
+
zip(DOCUMENT_LAYERS, (0.5, 0.14, 0.15, 0.55, 0.2, 0, 0, 0.12, 0), strict=True)
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
"notebook": {
|
|
70
|
+
"label": "Notebook",
|
|
71
|
+
"description": "Ruled writing paper with a soft paper texture.",
|
|
72
|
+
"background": "plain",
|
|
73
|
+
"color_mode": "rgb",
|
|
74
|
+
"background_color": "#f7f5ea",
|
|
75
|
+
"background_color_end": "#f7f5ea",
|
|
76
|
+
"text_color": "#242c35",
|
|
77
|
+
"layers": dict(zip(DOCUMENT_LAYERS, (0.22, 0.06, 0, 0, 0, 0.6, 0, 0, 0), strict=True)),
|
|
78
|
+
},
|
|
79
|
+
"phone-photo": {
|
|
80
|
+
"label": "Phone photo",
|
|
81
|
+
"description": "Directional lighting, soft edge shadows and camera grain.",
|
|
82
|
+
"background": "gradient",
|
|
83
|
+
"color_mode": "rgb",
|
|
84
|
+
"background_color": "#f3f0e6",
|
|
85
|
+
"background_color_end": "#d9d6ca",
|
|
86
|
+
"text_color": "#252a2b",
|
|
87
|
+
"layers": dict(zip(DOCUMENT_LAYERS, (0.16, 0.2, 0.72, 0, 0.08, 0, 0, 0, 0), strict=True)),
|
|
88
|
+
},
|
|
89
|
+
"photocopy": {
|
|
90
|
+
"label": "Photocopy",
|
|
91
|
+
"description": "Stronger copy grain with subtle uneven toner density.",
|
|
92
|
+
"background": "plain",
|
|
93
|
+
"color_mode": "grayscale",
|
|
94
|
+
"background_color": "#eeeeee",
|
|
95
|
+
"background_color_end": "#eeeeee",
|
|
96
|
+
"text_color": "#232323",
|
|
97
|
+
"layers": dict(
|
|
98
|
+
zip(DOCUMENT_LAYERS, (0.3, 0.7, 0.22, 0.06, 0, 0, 0.2, 0.25, 0), strict=True)
|
|
99
|
+
),
|
|
100
|
+
},
|
|
101
|
+
"thermal-receipt": {
|
|
102
|
+
"label": "Thermal receipt",
|
|
103
|
+
"description": "Warm receipt paper with fine horizontal printer bands.",
|
|
104
|
+
"background": "plain",
|
|
105
|
+
"color_mode": "rgb",
|
|
106
|
+
"background_color": "#f4eee0",
|
|
107
|
+
"background_color_end": "#f4eee0",
|
|
108
|
+
"text_color": "#3c3933",
|
|
109
|
+
"layers": dict(
|
|
110
|
+
zip(DOCUMENT_LAYERS, (0.12, 0.12, 0.08, 0, 0.08, 0, 0.6, 0.03, 0), strict=True)
|
|
111
|
+
),
|
|
112
|
+
},
|
|
113
|
+
"newsprint": {
|
|
114
|
+
"label": "Newsprint",
|
|
115
|
+
"description": "Gray recycled newsprint with fibers and fine paper flecks.",
|
|
116
|
+
"background": "plain",
|
|
117
|
+
"color_mode": "rgb",
|
|
118
|
+
"background_color": "#d9d7cb",
|
|
119
|
+
"background_color_end": "#d9d7cb",
|
|
120
|
+
"text_color": "#2c2b27",
|
|
121
|
+
"layers": dict(zip(DOCUMENT_LAYERS, (0.6, 0.3, 0.08, 0, 0, 0, 0, 0.55, 0), strict=True)),
|
|
122
|
+
},
|
|
123
|
+
"recycled-paper": {
|
|
124
|
+
"label": "Recycled paper",
|
|
125
|
+
"description": "Natural beige paper with visible fibers and uneven flecks.",
|
|
126
|
+
"background": "plain",
|
|
127
|
+
"color_mode": "rgb",
|
|
128
|
+
"background_color": "#e6ddc8",
|
|
129
|
+
"background_color_end": "#e6ddc8",
|
|
130
|
+
"text_color": "#302d25",
|
|
131
|
+
"layers": dict(zip(DOCUMENT_LAYERS, (0.65, 0.12, 0.06, 0.1, 0, 0, 0, 0.8, 0), strict=True)),
|
|
132
|
+
},
|
|
133
|
+
"book-page": {
|
|
134
|
+
"label": "Book page",
|
|
135
|
+
"description": "Cream book paper with a soft shadow near the binding.",
|
|
136
|
+
"background": "plain",
|
|
137
|
+
"color_mode": "rgb",
|
|
138
|
+
"background_color": "#f3ead5",
|
|
139
|
+
"background_color_end": "#f3ead5",
|
|
140
|
+
"text_color": "#302a23",
|
|
141
|
+
"layers": dict(
|
|
142
|
+
zip(DOCUMENT_LAYERS, (0.25, 0.08, 0.08, 0, 0, 0, 0, 0.04, 0.7), strict=True)
|
|
143
|
+
),
|
|
144
|
+
},
|
|
145
|
+
"fax": {
|
|
146
|
+
"label": "Fax",
|
|
147
|
+
"description": "Grayscale fax paper with horizontal bands and scattered toner.",
|
|
148
|
+
"background": "plain",
|
|
149
|
+
"color_mode": "grayscale",
|
|
150
|
+
"background_color": "#f0f0ee",
|
|
151
|
+
"background_color_end": "#f0f0ee",
|
|
152
|
+
"text_color": "#292929",
|
|
153
|
+
"layers": dict(zip(DOCUMENT_LAYERS, (0.08, 0.5, 0.06, 0, 0, 0, 0.8, 0.45, 0), strict=True)),
|
|
154
|
+
},
|
|
155
|
+
"crumpled-paper": {
|
|
156
|
+
"label": "Crumpled paper",
|
|
157
|
+
"description": "Soft diagonal folds and creases on lightly textured paper.",
|
|
158
|
+
"background": "plain",
|
|
159
|
+
"color_mode": "rgb",
|
|
160
|
+
"background_color": "#eeeadd",
|
|
161
|
+
"background_color_end": "#eeeadd",
|
|
162
|
+
"text_color": "#2b2b26",
|
|
163
|
+
"layers": dict(
|
|
164
|
+
zip(DOCUMENT_LAYERS, (0.3, 0.1, 0.2, 0.04, 0.8, 0, 0, 0.05, 0), strict=True)
|
|
165
|
+
),
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
AUGMENTATION_FIELDS = (
|
|
170
|
+
"augmentation",
|
|
171
|
+
"background",
|
|
172
|
+
"color_mode",
|
|
173
|
+
"background_color",
|
|
174
|
+
"background_color_end",
|
|
175
|
+
"text_color",
|
|
176
|
+
"texture_strength",
|
|
177
|
+
"rotation",
|
|
178
|
+
"blur",
|
|
179
|
+
"contrast",
|
|
180
|
+
"background_presets",
|
|
181
|
+
"background_weights",
|
|
182
|
+
"preset_colors",
|
|
183
|
+
*DOCUMENT_LAYERS,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def background_distribution(settings) -> dict[str, float]:
|
|
188
|
+
"""Conditional recipe shares for validated settings; missing weights are one."""
|
|
189
|
+
weights = {
|
|
190
|
+
name: float(settings.background_weights.get(name, 1))
|
|
191
|
+
for name in settings.background_presets
|
|
192
|
+
}
|
|
193
|
+
total = fsum(weights.values())
|
|
194
|
+
return {name: weight / total for name, weight in weights.items()}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def document_layers(settings, preset: str = "") -> dict[str, float]:
|
|
198
|
+
"""Resolve explicit overrides; zero disables and None inherits a recipe."""
|
|
199
|
+
defaults = DOCUMENT_PRESETS[preset]["layers"] if preset else {}
|
|
200
|
+
return {
|
|
201
|
+
name: float(
|
|
202
|
+
defaults.get(name, 0) if getattr(settings, name) is None else getattr(settings, name)
|
|
203
|
+
)
|
|
204
|
+
for name in DOCUMENT_LAYERS
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def resolve_document_settings(settings, preset: str = ""):
|
|
209
|
+
"""Return a preset's ready-made palette, or retain explicit custom colors."""
|
|
210
|
+
if not preset or not settings.preset_colors:
|
|
211
|
+
return settings
|
|
212
|
+
recipe = DOCUMENT_PRESETS[preset]
|
|
213
|
+
return replace(
|
|
214
|
+
settings,
|
|
215
|
+
**{
|
|
216
|
+
name: recipe[name]
|
|
217
|
+
for name in (
|
|
218
|
+
"background",
|
|
219
|
+
"color_mode",
|
|
220
|
+
"background_color",
|
|
221
|
+
"background_color_end",
|
|
222
|
+
"text_color",
|
|
223
|
+
)
|
|
224
|
+
},
|
|
225
|
+
texture_strength=0,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _rgb(value: str) -> tuple[int, int, int]:
|
|
230
|
+
return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _luminance(rgb, grayscale):
|
|
234
|
+
if grayscale:
|
|
235
|
+
gray = int(
|
|
236
|
+
0.5
|
|
237
|
+
+ sum(
|
|
238
|
+
channel * weight for channel, weight in zip(rgb, (0.299, 0.587, 0.114), strict=True)
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
rgb = (gray, gray, gray)
|
|
242
|
+
linear = [
|
|
243
|
+
value / 255 / 12.92 if value / 255 <= 0.04045 else ((value / 255 + 0.055) / 1.055) ** 2.4
|
|
244
|
+
for value in rgb
|
|
245
|
+
]
|
|
246
|
+
return sum(
|
|
247
|
+
value * weight for value, weight in zip(linear, (0.2126, 0.7152, 0.0722), strict=True)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def augmentation_contrast(settings) -> float:
|
|
252
|
+
"""Minimum base-color contrast across the actual RGB or grayscale gradient."""
|
|
253
|
+
foreground = _luminance(_rgb(settings.text_color), settings.color_mode == "grayscale")
|
|
254
|
+
start = _rgb(settings.background_color)
|
|
255
|
+
end = _rgb(settings.background_color_end) if settings.background == "gradient" else start
|
|
256
|
+
minimum = 21.0
|
|
257
|
+
for step in range(17):
|
|
258
|
+
color = tuple(int(0.5 + a + (b - a) * step / 16) for a, b in zip(start, end, strict=True))
|
|
259
|
+
background = _luminance(color, settings.color_mode == "grayscale")
|
|
260
|
+
minimum = min(
|
|
261
|
+
minimum, (max(foreground, background) + 0.05) / (min(foreground, background) + 0.05)
|
|
262
|
+
)
|
|
263
|
+
return minimum
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def validate_augmentation(settings) -> None:
|
|
267
|
+
for name, maximum in (
|
|
268
|
+
("augmentation", 1),
|
|
269
|
+
("texture_strength", 0.25),
|
|
270
|
+
("rotation", 10),
|
|
271
|
+
("blur", 2),
|
|
272
|
+
("contrast", 0.5),
|
|
273
|
+
):
|
|
274
|
+
value = getattr(settings, name)
|
|
275
|
+
if type(value) not in {int, float} or not isfinite(value) or not 0 <= value <= maximum:
|
|
276
|
+
raise ValueError(f"{name} must be a finite number between 0 and {maximum}")
|
|
277
|
+
presets = settings.background_presets
|
|
278
|
+
if (
|
|
279
|
+
not isinstance(presets, list)
|
|
280
|
+
or len(presets) > len(DOCUMENT_PRESETS)
|
|
281
|
+
or any(not isinstance(value, str) or value not in DOCUMENT_PRESETS for value in presets)
|
|
282
|
+
or len(set(presets)) != len(presets)
|
|
283
|
+
):
|
|
284
|
+
raise ValueError("background_presets must be a list of distinct supported document presets")
|
|
285
|
+
weights = settings.background_weights
|
|
286
|
+
if not isinstance(weights, dict) or len(weights) > len(DOCUMENT_PRESETS):
|
|
287
|
+
raise ValueError("background_weights must be a mapping of selected presets to weights")
|
|
288
|
+
for name, value in weights.items():
|
|
289
|
+
if name not in presets:
|
|
290
|
+
raise ValueError("background_weights keys must be selected background_presets")
|
|
291
|
+
if type(value) not in {int, float} or not 0 <= value <= 1_000_000 or not isfinite(value):
|
|
292
|
+
raise ValueError(
|
|
293
|
+
"background_weights values must be finite numbers between 0 and 1000000"
|
|
294
|
+
)
|
|
295
|
+
if presets and not any(weights.get(name, 1) > 0 for name in presets):
|
|
296
|
+
raise ValueError(
|
|
297
|
+
"background_weights must give at least one selected preset a positive weight"
|
|
298
|
+
)
|
|
299
|
+
if type(settings.preset_colors) is not bool:
|
|
300
|
+
raise ValueError("preset_colors must be a boolean")
|
|
301
|
+
for name in DOCUMENT_LAYERS:
|
|
302
|
+
value = getattr(settings, name)
|
|
303
|
+
if value is not None and (
|
|
304
|
+
type(value) not in {int, float} or not isfinite(value) or not 0 <= value <= 1
|
|
305
|
+
):
|
|
306
|
+
raise ValueError(f"{name} must be null or a finite number between 0 and 1")
|
|
307
|
+
if not isinstance(settings.background, str) or settings.background not in {
|
|
308
|
+
"paper",
|
|
309
|
+
"plain",
|
|
310
|
+
"gradient",
|
|
311
|
+
"noise",
|
|
312
|
+
}:
|
|
313
|
+
raise ValueError("background must be paper, plain, gradient or noise")
|
|
314
|
+
if not isinstance(settings.color_mode, str) or settings.color_mode not in {"grayscale", "rgb"}:
|
|
315
|
+
raise ValueError("color_mode must be grayscale or rgb")
|
|
316
|
+
for name in ("background_color", "background_color_end", "text_color"):
|
|
317
|
+
value = getattr(settings, name)
|
|
318
|
+
if not isinstance(value, str) or not re.fullmatch(r"#[0-9A-Fa-f]{6}", value):
|
|
319
|
+
raise ValueError(f"{name} must be a six-digit hex color, such as #f5f1e8")
|
|
320
|
+
# Preset palettes are bundled reviewed values. Explicit palettes retain the
|
|
321
|
+
# same guard as legacy custom backgrounds, including intermediate gradients.
|
|
322
|
+
if (
|
|
323
|
+
settings.augmentation > 0
|
|
324
|
+
and (not presets or not settings.preset_colors)
|
|
325
|
+
and augmentation_contrast(settings) < 3
|
|
326
|
+
):
|
|
327
|
+
raise ValueError(
|
|
328
|
+
"Text and background colors need at least 3:1 base contrast across the background; "
|
|
329
|
+
"choose lighter paper or darker text"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@dataclass(frozen=True)
|
|
334
|
+
class Data:
|
|
335
|
+
raw: str = "data/raw"
|
|
336
|
+
processed: str = "data/processed"
|
|
337
|
+
max_chars: int = 100
|
|
338
|
+
normalization: str = "NFC"
|
|
339
|
+
deduplicate: bool = True
|
|
340
|
+
write_jsonl: bool = False
|
|
341
|
+
max_line_chars: int = 1_000_000
|
|
342
|
+
source_languages: dict[str, str] = field(default_factory=dict)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@dataclass(frozen=True)
|
|
346
|
+
class Fonts:
|
|
347
|
+
directories: list[str] = field(default_factory=lambda: ["fonts"])
|
|
348
|
+
coverage: str = "strict"
|
|
349
|
+
cache_size: int = 32
|
|
350
|
+
coverage_cache_size: int = 128
|
|
351
|
+
# Fonts to use, by file name, "name#face" or content ID; empty uses all.
|
|
352
|
+
include: list[str] = field(default_factory=list)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@dataclass(frozen=True)
|
|
356
|
+
class Generation:
|
|
357
|
+
output: str = "dataset/default"
|
|
358
|
+
samples: int = 1000
|
|
359
|
+
seed: int = 42
|
|
360
|
+
workers: int = 1
|
|
361
|
+
font_size: int = 48
|
|
362
|
+
padding: int = 12
|
|
363
|
+
max_width: int = 4096
|
|
364
|
+
max_height: int = 512
|
|
365
|
+
max_pixels: int = 2_097_152
|
|
366
|
+
format: str = "png"
|
|
367
|
+
direction: str = "auto"
|
|
368
|
+
language: str = ""
|
|
369
|
+
languages: list[str] = field(default_factory=list)
|
|
370
|
+
# Relative share of each selected language; empty draws from all of them as one pool.
|
|
371
|
+
language_weights: dict[str, float] = field(default_factory=dict)
|
|
372
|
+
exclude_genres: list[str] = field(default_factory=list)
|
|
373
|
+
suite: str = ""
|
|
374
|
+
augmentation: float = 0.25
|
|
375
|
+
background: str = "paper"
|
|
376
|
+
color_mode: str = "grayscale"
|
|
377
|
+
background_color: str = "#f5f1e8"
|
|
378
|
+
background_color_end: str = "#e4e9ed"
|
|
379
|
+
text_color: str = "#1a1a1a"
|
|
380
|
+
texture_strength: float = 0.04
|
|
381
|
+
rotation: float = 2
|
|
382
|
+
blur: float = 0.55
|
|
383
|
+
contrast: float = 0.1
|
|
384
|
+
background_presets: list[str] = field(default_factory=list)
|
|
385
|
+
background_weights: dict[str, float] = field(default_factory=dict)
|
|
386
|
+
preset_colors: bool = True
|
|
387
|
+
paper_texture: float | None = None
|
|
388
|
+
grain: float | None = None
|
|
389
|
+
shadow: float | None = None
|
|
390
|
+
stains: float | None = None
|
|
391
|
+
creases: float | None = None
|
|
392
|
+
ruled_lines: float | None = None
|
|
393
|
+
scan_banding: float | None = None
|
|
394
|
+
speckles: float | None = None
|
|
395
|
+
binding_shadow: float | None = None
|
|
396
|
+
train_ratio: float = 0.9
|
|
397
|
+
validation_ratio: float = 0.05
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# Convenience starting points for the UI. These are complete ordinary appearance
|
|
401
|
+
# settings, so editing a preset uses the same validation and renderer as custom
|
|
402
|
+
# settings. Keep API and CLI defaults independent for backwards compatibility.
|
|
403
|
+
IMAGE_PRESETS = {
|
|
404
|
+
identifier: {
|
|
405
|
+
"label": label,
|
|
406
|
+
"description": description,
|
|
407
|
+
"settings": {name: getattr(settings, name) for name in AUGMENTATION_FIELDS},
|
|
408
|
+
}
|
|
409
|
+
for identifier, label, description, settings in (
|
|
410
|
+
(
|
|
411
|
+
"balanced",
|
|
412
|
+
"Balanced",
|
|
413
|
+
"A mix of clean images, scanned pages, aged paper and phone photos.",
|
|
414
|
+
Generation(
|
|
415
|
+
augmentation=0.75,
|
|
416
|
+
background_presets=["office-scan", "aged-paper", "phone-photo"],
|
|
417
|
+
),
|
|
418
|
+
),
|
|
419
|
+
(
|
|
420
|
+
"clean",
|
|
421
|
+
"Clean",
|
|
422
|
+
"Black text on white, with no image effects.",
|
|
423
|
+
Generation(augmentation=0),
|
|
424
|
+
),
|
|
425
|
+
(
|
|
426
|
+
"varied",
|
|
427
|
+
"Varied",
|
|
428
|
+
"All document styles, with image effects applied to every image.",
|
|
429
|
+
Generation(augmentation=1, background_presets=list(DOCUMENT_PRESETS)),
|
|
430
|
+
),
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@dataclass(frozen=True)
|
|
436
|
+
class Config:
|
|
437
|
+
root: Path
|
|
438
|
+
data: Data = field(default_factory=Data)
|
|
439
|
+
fonts: Fonts = field(default_factory=Fonts)
|
|
440
|
+
generation: Generation = field(default_factory=Generation)
|
|
441
|
+
|
|
442
|
+
def path(self, value: str) -> Path:
|
|
443
|
+
return (self.root / value).resolve()
|
|
444
|
+
|
|
445
|
+
def validate(self) -> Config:
|
|
446
|
+
positive = {
|
|
447
|
+
"max_chars": self.data.max_chars,
|
|
448
|
+
"max_line_chars": self.data.max_line_chars,
|
|
449
|
+
"samples": self.generation.samples,
|
|
450
|
+
"font_size": self.generation.font_size,
|
|
451
|
+
"max_width": self.generation.max_width,
|
|
452
|
+
"max_height": self.generation.max_height,
|
|
453
|
+
"max_pixels": self.generation.max_pixels,
|
|
454
|
+
"cache_size": self.fonts.cache_size,
|
|
455
|
+
"coverage_cache_size": self.fonts.coverage_cache_size,
|
|
456
|
+
}
|
|
457
|
+
for name, value in positive.items():
|
|
458
|
+
if type(value) is not int or value <= 0:
|
|
459
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
460
|
+
g = self.generation
|
|
461
|
+
validate_augmentation(g)
|
|
462
|
+
if type(g.workers) is not int or not 1 <= g.workers <= 16:
|
|
463
|
+
raise ValueError("workers must be between 1 and 16")
|
|
464
|
+
if type(g.seed) is not int:
|
|
465
|
+
raise ValueError("seed must be an integer")
|
|
466
|
+
if type(g.padding) is not int or not 0 <= g.padding < min(g.max_width, g.max_height) / 2:
|
|
467
|
+
raise ValueError("padding must be nonnegative and fit inside the image")
|
|
468
|
+
if (
|
|
469
|
+
g.font_size > 1024
|
|
470
|
+
or g.max_pixels > 16_777_216
|
|
471
|
+
or self.fonts.cache_size > 256
|
|
472
|
+
or self.fonts.coverage_cache_size > 512
|
|
473
|
+
):
|
|
474
|
+
raise ValueError("font_size, max_pixels or cache_size exceeds the memory safety limit")
|
|
475
|
+
for value in (g.augmentation, g.train_ratio, g.validation_ratio):
|
|
476
|
+
if not isinstance(value, (float, int)) or not isfinite(value) or not 0 <= value <= 1:
|
|
477
|
+
raise ValueError("augmentation and split ratios must be finite values in [0, 1]")
|
|
478
|
+
if g.train_ratio + g.validation_ratio > 1:
|
|
479
|
+
raise ValueError("train_ratio + validation_ratio must be <= 1")
|
|
480
|
+
if g.format not in {"png", "jpg", "webp", "tif"}:
|
|
481
|
+
raise ValueError("format must be png, jpg, webp or tif")
|
|
482
|
+
if g.direction not in {"auto", "ltr", "rtl", "ttb"}:
|
|
483
|
+
raise ValueError("direction must be auto, ltr, rtl or ttb")
|
|
484
|
+
if self.fonts.coverage not in {"strict", "off"}:
|
|
485
|
+
raise ValueError("coverage must be strict or off")
|
|
486
|
+
if (
|
|
487
|
+
not isinstance(self.fonts.include, list)
|
|
488
|
+
or len(self.fonts.include) > 1000
|
|
489
|
+
or any(
|
|
490
|
+
not isinstance(value, str) or not 0 < len(value) <= 300
|
|
491
|
+
for value in self.fonts.include
|
|
492
|
+
)
|
|
493
|
+
):
|
|
494
|
+
raise ValueError("fonts.include must be a list of at most 1000 font names or IDs")
|
|
495
|
+
if not isinstance(g.suite, str) or g.suite not in {"", "kurdish-spelling"}:
|
|
496
|
+
raise ValueError("suite must be empty or kurdish-spelling")
|
|
497
|
+
if self.data.normalization not in {"NFC", "NFD", "NFKC", "NFKD", "none"}:
|
|
498
|
+
raise ValueError("Unknown Unicode normalization")
|
|
499
|
+
if type(self.data.deduplicate) is not bool:
|
|
500
|
+
raise ValueError("deduplicate must be a boolean")
|
|
501
|
+
if type(self.data.write_jsonl) is not bool:
|
|
502
|
+
raise ValueError("write_jsonl must be a boolean")
|
|
503
|
+
|
|
504
|
+
def valid_language(value):
|
|
505
|
+
return isinstance(value, str) and re.fullmatch(
|
|
506
|
+
r"[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*", value
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
if (
|
|
510
|
+
not isinstance(g.languages, list)
|
|
511
|
+
or len(g.languages) > 100
|
|
512
|
+
or any(not valid_language(value) for value in g.languages)
|
|
513
|
+
):
|
|
514
|
+
raise ValueError("languages must be a list of at most 100 language tags")
|
|
515
|
+
if (
|
|
516
|
+
not isinstance(g.exclude_genres, list)
|
|
517
|
+
or len(g.exclude_genres) > 100
|
|
518
|
+
or any(not isinstance(value, str) or not value for value in g.exclude_genres)
|
|
519
|
+
):
|
|
520
|
+
raise ValueError("exclude_genres must be a list of at most 100 nonempty genre names")
|
|
521
|
+
weights = g.language_weights
|
|
522
|
+
if not isinstance(weights, dict) or len(weights) > 100:
|
|
523
|
+
raise ValueError("language_weights must map selected languages to weights")
|
|
524
|
+
if weights:
|
|
525
|
+
from .corpus_store import canonical_language
|
|
526
|
+
|
|
527
|
+
selected = {canonical_language(value) for value in g.languages}
|
|
528
|
+
keys = [canonical_language(name) if valid_language(name) else "" for name in weights]
|
|
529
|
+
if not all(keys) or len(set(keys)) != len(keys) or not set(keys) <= selected:
|
|
530
|
+
raise ValueError("language_weights keys must be distinct selected languages")
|
|
531
|
+
if any(
|
|
532
|
+
type(value) not in {int, float}
|
|
533
|
+
or not isfinite(value)
|
|
534
|
+
or not 0 <= value <= 1_000_000
|
|
535
|
+
for value in weights.values()
|
|
536
|
+
):
|
|
537
|
+
raise ValueError(
|
|
538
|
+
"language_weights values must be finite numbers between 0 and 1000000"
|
|
539
|
+
)
|
|
540
|
+
# Omitted languages default to weight 1, as with background_weights.
|
|
541
|
+
canonical = dict(zip(keys, weights.values(), strict=True))
|
|
542
|
+
if not any(canonical.get(language, 1) > 0 for language in selected):
|
|
543
|
+
raise ValueError(
|
|
544
|
+
"language_weights must give at least one language a positive weight"
|
|
545
|
+
)
|
|
546
|
+
if not isinstance(self.data.source_languages, dict):
|
|
547
|
+
raise ValueError("source_languages must map relative raw source paths to language tags")
|
|
548
|
+
for source, language in self.data.source_languages.items():
|
|
549
|
+
if (
|
|
550
|
+
not isinstance(source, str)
|
|
551
|
+
or not source
|
|
552
|
+
or PurePosixPath(source).is_absolute()
|
|
553
|
+
or ".." in PurePosixPath(source).parts
|
|
554
|
+
or "\\" in source
|
|
555
|
+
or PurePosixPath(source).as_posix() != source
|
|
556
|
+
or not valid_language(language)
|
|
557
|
+
):
|
|
558
|
+
raise ValueError(
|
|
559
|
+
"source_languages must map relative raw source paths to language tags"
|
|
560
|
+
)
|
|
561
|
+
if not isinstance(self.fonts.directories, list) or not self.fonts.directories:
|
|
562
|
+
raise ValueError("fonts.directories must be a nonempty list")
|
|
563
|
+
paths = [self.data.raw, self.data.processed, g.output, *self.fonts.directories]
|
|
564
|
+
if any(not isinstance(p, str) or not p for p in paths):
|
|
565
|
+
raise ValueError("Paths must be nonempty strings")
|
|
566
|
+
raw, processed, output = map(self.path, (self.data.raw, self.data.processed, g.output))
|
|
567
|
+
for a, b in ((raw, processed), (raw, output), (processed, output)):
|
|
568
|
+
if a == b or a in b.parents or b in a.parents:
|
|
569
|
+
raise ValueError("raw, processed and output paths must be separate directories")
|
|
570
|
+
return self
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def load_config(path: str | Path = "config.toml") -> Config:
|
|
574
|
+
path = Path(path).resolve()
|
|
575
|
+
with path.open("rb") as stream:
|
|
576
|
+
values = tomllib.load(stream)
|
|
577
|
+
unknown = values.keys() - {"data", "fonts", "generation"}
|
|
578
|
+
if unknown:
|
|
579
|
+
raise ValueError(f"Unknown config sections: {sorted(unknown)}")
|
|
580
|
+
sections = {}
|
|
581
|
+
for name, cls in (("data", Data), ("fonts", Fonts), ("generation", Generation)):
|
|
582
|
+
section = values.get(name, {})
|
|
583
|
+
if not isinstance(section, dict):
|
|
584
|
+
raise ValueError(f"{name} must be a TOML table")
|
|
585
|
+
unknown = section.keys() - {f.name for f in fields(cls)}
|
|
586
|
+
if unknown:
|
|
587
|
+
raise ValueError(f"Unknown {name} settings: {sorted(unknown)}")
|
|
588
|
+
sections[name] = cls(**section)
|
|
589
|
+
return Config(root=path.parent, **sections).validate()
|