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/render.py
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
"""RAQM shaping, bounded font reuse, and deterministic Pillow augmentation."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import math
|
|
5
|
+
import random
|
|
6
|
+
import unicodedata
|
|
7
|
+
from collections import OrderedDict
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from PIL import (
|
|
11
|
+
Image,
|
|
12
|
+
ImageColor,
|
|
13
|
+
ImageDraw,
|
|
14
|
+
ImageEnhance,
|
|
15
|
+
ImageFilter,
|
|
16
|
+
ImageFont,
|
|
17
|
+
ImageOps,
|
|
18
|
+
features,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from .config import (
|
|
22
|
+
Generation,
|
|
23
|
+
background_distribution,
|
|
24
|
+
document_layers,
|
|
25
|
+
resolve_document_settings,
|
|
26
|
+
validate_augmentation,
|
|
27
|
+
)
|
|
28
|
+
from .downloads import sha256
|
|
29
|
+
from .fonts import Font
|
|
30
|
+
|
|
31
|
+
AUGMENT_MARGIN = 4 # Room for bicubic resampling and the blur halo.
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _color(value: str, mode: str):
|
|
35
|
+
"""Match Pillow's RGB-to-luminance conversion without a full image buffer."""
|
|
36
|
+
red, green, blue = ImageColor.getrgb(value)
|
|
37
|
+
if mode == "RGB":
|
|
38
|
+
return red, green, blue
|
|
39
|
+
return (red * 299 + green * 587 + blue * 114 + 500) // 1000
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _background(dimensions: tuple[int, int], settings: Generation, rng: random.Random):
|
|
43
|
+
"""Build a background over the complete canvas using bounded noise buffers."""
|
|
44
|
+
mode = "RGB" if settings.color_mode == "rgb" else "L"
|
|
45
|
+
start = _color(settings.background_color, mode)
|
|
46
|
+
if settings.background == "plain":
|
|
47
|
+
return Image.new(mode, dimensions, start)
|
|
48
|
+
if settings.background == "gradient":
|
|
49
|
+
end = _color(settings.background_color_end, mode)
|
|
50
|
+
# A fixed-size ramp avoids a Python array proportional to image height.
|
|
51
|
+
ramp = Image.new(mode, (1, 256))
|
|
52
|
+
if mode == "RGB":
|
|
53
|
+
ramp.putdata(
|
|
54
|
+
[
|
|
55
|
+
tuple(round(a + (b - a) * index / 255) for a, b in zip(start, end, strict=True))
|
|
56
|
+
for index in range(256)
|
|
57
|
+
]
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
ramp.putdata([round(start + (end - start) * index / 255) for index in range(256)])
|
|
61
|
+
try:
|
|
62
|
+
return ramp.resize(dimensions, Image.Resampling.BILINEAR)
|
|
63
|
+
finally:
|
|
64
|
+
ramp.close()
|
|
65
|
+
if not settings.texture_strength:
|
|
66
|
+
return Image.new(mode, dimensions, start)
|
|
67
|
+
if settings.background == "noise":
|
|
68
|
+
# Fill independent tiles, rather than stretching grain into visible blocks
|
|
69
|
+
# on wide lines or allocating a full-image temporary bytes object.
|
|
70
|
+
texture = Image.new("L", dimensions)
|
|
71
|
+
for top in range(0, dimensions[1], 256):
|
|
72
|
+
for left in range(0, dimensions[0], 256):
|
|
73
|
+
tile_size = (min(256, dimensions[0] - left), min(256, dimensions[1] - top))
|
|
74
|
+
tile = Image.frombytes("L", tile_size, rng.randbytes(math.prod(tile_size)))
|
|
75
|
+
texture.paste(tile, (left, top))
|
|
76
|
+
tile.close()
|
|
77
|
+
else:
|
|
78
|
+
texture_size = tuple(max(1, min(256, size // 8)) for size in dimensions)
|
|
79
|
+
source = Image.frombytes("L", texture_size, rng.randbytes(math.prod(texture_size)))
|
|
80
|
+
try:
|
|
81
|
+
texture = source.resize(dimensions, Image.Resampling.BILINEAR)
|
|
82
|
+
finally:
|
|
83
|
+
source.close()
|
|
84
|
+
spread = 255 * settings.texture_strength / 2
|
|
85
|
+
channels = start if mode == "RGB" else (start,)
|
|
86
|
+
low = tuple(max(0, round(channel - spread)) for channel in channels)
|
|
87
|
+
high = tuple(min(255, round(channel + spread)) for channel in channels)
|
|
88
|
+
try:
|
|
89
|
+
if mode == "RGB":
|
|
90
|
+
return ImageOps.colorize(texture, low, high)
|
|
91
|
+
return texture.point(
|
|
92
|
+
[round(low[0] + (high[0] - low[0]) * value / 255) for value in range(256)]
|
|
93
|
+
)
|
|
94
|
+
finally:
|
|
95
|
+
texture.close()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _effect_rng(seed: int, effect: str) -> random.Random:
|
|
99
|
+
"""A layer consumes only its own stream, keeping comparisons matched."""
|
|
100
|
+
digest = hashlib.sha256(f"{seed}:{effect}".encode()).digest()
|
|
101
|
+
return random.Random(int.from_bytes(digest[:16], "big"))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _mask_size(dimensions: tuple[int, int]) -> tuple[int, int]:
|
|
105
|
+
return tuple(max(8, min(256, math.ceil(size / 4))) for size in dimensions)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _apply_mask(image: Image.Image, mask: Image.Image, opacity: float, color=0) -> None:
|
|
109
|
+
"""Consume a small mask, resize after scaling alpha, and release it promptly."""
|
|
110
|
+
if opacity == 1:
|
|
111
|
+
scaled = mask
|
|
112
|
+
else:
|
|
113
|
+
scaled = mask.point([round(value * opacity) for value in range(256)])
|
|
114
|
+
mask.close()
|
|
115
|
+
if scaled.size != image.size:
|
|
116
|
+
try:
|
|
117
|
+
expanded = scaled.resize(image.size, Image.Resampling.BILINEAR)
|
|
118
|
+
finally:
|
|
119
|
+
scaled.close()
|
|
120
|
+
else:
|
|
121
|
+
expanded = scaled
|
|
122
|
+
try:
|
|
123
|
+
image.paste(color, (0, 0, image.width, image.height), expanded)
|
|
124
|
+
finally:
|
|
125
|
+
expanded.close()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _document_background(image: Image.Image, layers: dict[str, float], seed: int, font_size: int):
|
|
129
|
+
"""Compose document layers before ink, with at most 256² source buffers.
|
|
130
|
+
|
|
131
|
+
Layers shade a complete canvas rather than a rectangle behind the text. Ink
|
|
132
|
+
is pasted afterwards, so stains, ruling and folds cannot erase characters.
|
|
133
|
+
"""
|
|
134
|
+
dimensions = image.size
|
|
135
|
+
small = _mask_size(dimensions)
|
|
136
|
+
if strength := layers["paper_texture"]:
|
|
137
|
+
rng = _effect_rng(seed, "paper_texture")
|
|
138
|
+
# Coarse pulp density, fine mottling and short fibers occupy different
|
|
139
|
+
# scales. One stretched noise texture makes paper look like a cloudy
|
|
140
|
+
# rectangle, and loses all fine detail on long lines.
|
|
141
|
+
for scale, opacity in ((8, 0.12), (1, 0.08)):
|
|
142
|
+
size = tuple(max(2, value // scale) for value in small)
|
|
143
|
+
source = Image.frombytes("L", size, rng.randbytes(math.prod(size)))
|
|
144
|
+
try:
|
|
145
|
+
mask = source.filter(ImageFilter.GaussianBlur(0.6))
|
|
146
|
+
finally:
|
|
147
|
+
source.close()
|
|
148
|
+
_apply_mask(image, mask, strength * opacity)
|
|
149
|
+
fibers = Image.new("L", dimensions)
|
|
150
|
+
draw = ImageDraw.Draw(fibers)
|
|
151
|
+
for _ in range(min(4000, max(20, math.prod(dimensions) // 100))):
|
|
152
|
+
x, y = rng.randrange(dimensions[0]), rng.randrange(dimensions[1])
|
|
153
|
+
length = rng.uniform(2, 8)
|
|
154
|
+
angle = rng.uniform(-0.6, 0.6)
|
|
155
|
+
draw.line(
|
|
156
|
+
(x, y, x + length * math.cos(angle), y + length * math.sin(angle)),
|
|
157
|
+
fill=rng.randrange(50, 170),
|
|
158
|
+
width=1,
|
|
159
|
+
)
|
|
160
|
+
_apply_mask(image, fibers, strength * 0.2)
|
|
161
|
+
if strength := layers["shadow"]:
|
|
162
|
+
rng = _effect_rng(seed, "shadow")
|
|
163
|
+
angle = rng.uniform(0, math.tau)
|
|
164
|
+
dx, dy = math.cos(angle), math.sin(angle)
|
|
165
|
+
scale = max(abs(dx) + abs(dy), 0.01)
|
|
166
|
+
# A broad off-center light source plus a smooth directional falloff,
|
|
167
|
+
# continuous across the full canvas rather than repeated on tiles.
|
|
168
|
+
center_x, center_y = rng.uniform(-0.4, 0.4), rng.uniform(-0.4, 0.4)
|
|
169
|
+
pixels = bytearray(math.prod(small))
|
|
170
|
+
for y in range(small[1]):
|
|
171
|
+
ny = 2 * y / max(1, small[1] - 1) - 1
|
|
172
|
+
for x in range(small[0]):
|
|
173
|
+
nx = 2 * x / max(1, small[0] - 1) - 1
|
|
174
|
+
directional = (nx * dx + ny * dy + scale) / (2 * scale)
|
|
175
|
+
directional = directional * directional * (3 - 2 * directional)
|
|
176
|
+
edge = min(1, ((nx - center_x) ** 2 + (ny - center_y) ** 2) / 2)
|
|
177
|
+
pixels[y * small[0] + x] = round(255 * (0.72 * directional + 0.28 * edge))
|
|
178
|
+
_apply_mask(image, Image.frombytes("L", small, bytes(pixels)), strength * 0.44)
|
|
179
|
+
if strength := layers["stains"]:
|
|
180
|
+
rng = _effect_rng(seed, "stains")
|
|
181
|
+
mask = Image.new("L", small)
|
|
182
|
+
draw = ImageDraw.Draw(mask)
|
|
183
|
+
for _ in range(rng.randrange(3, 7)):
|
|
184
|
+
x, y = rng.uniform(-0.2, 1.2) * small[0], rng.uniform(-0.2, 1.2) * small[1]
|
|
185
|
+
rx, ry = rng.uniform(0.08, 0.35) * small[0], rng.uniform(0.12, 0.65) * small[1]
|
|
186
|
+
radii = [rng.uniform(0.65, 1.25) for _ in range(16)]
|
|
187
|
+
# Uneven wet edges, with a faint darker tide mark surrounding a
|
|
188
|
+
# softer interior. Fixed 16-point polygons keep allocations small.
|
|
189
|
+
for inset, fill in ((1, rng.randint(100, 180)), (0.78, rng.randint(55, 105))):
|
|
190
|
+
draw.polygon(
|
|
191
|
+
[
|
|
192
|
+
(
|
|
193
|
+
x + math.cos(index * math.tau / 16) * rx * radius * inset,
|
|
194
|
+
y + math.sin(index * math.tau / 16) * ry * radius * inset,
|
|
195
|
+
)
|
|
196
|
+
for index, radius in enumerate(radii)
|
|
197
|
+
],
|
|
198
|
+
fill=fill,
|
|
199
|
+
)
|
|
200
|
+
original = mask
|
|
201
|
+
mask = original.filter(ImageFilter.GaussianBlur(max(0.6, min(small) / 30)))
|
|
202
|
+
original.close()
|
|
203
|
+
_apply_mask(image, mask, strength * 0.3, (112, 75, 36) if image.mode == "RGB" else 80)
|
|
204
|
+
if strength := layers["creases"]:
|
|
205
|
+
rng = _effect_rng(seed, "creases")
|
|
206
|
+
mask = Image.new("L", small)
|
|
207
|
+
highlight = Image.new("L", small)
|
|
208
|
+
draw, light = ImageDraw.Draw(mask), ImageDraw.Draw(highlight)
|
|
209
|
+
for _ in range(5):
|
|
210
|
+
start = (rng.uniform(-0.15, 1.15) * small[0], -0.1 * small[1])
|
|
211
|
+
end = (rng.uniform(-0.15, 1.15) * small[0], 1.1 * small[1])
|
|
212
|
+
midpoint = (
|
|
213
|
+
(start[0] + end[0]) / 2 + rng.uniform(-0.08, 0.08) * small[0],
|
|
214
|
+
rng.uniform(0.3, 0.7) * small[1],
|
|
215
|
+
)
|
|
216
|
+
points = (start, midpoint, end)
|
|
217
|
+
draw.line(points, fill=rng.randrange(110, 220), width=2, joint="curve")
|
|
218
|
+
light.line(
|
|
219
|
+
[(x + 1.5, y + 0.8) for x, y in points],
|
|
220
|
+
fill=180,
|
|
221
|
+
width=1,
|
|
222
|
+
joint="curve",
|
|
223
|
+
)
|
|
224
|
+
original, original_light = mask, highlight
|
|
225
|
+
mask = original.filter(ImageFilter.GaussianBlur(0.8))
|
|
226
|
+
highlight = original_light.filter(ImageFilter.GaussianBlur(0.6))
|
|
227
|
+
original.close()
|
|
228
|
+
original_light.close()
|
|
229
|
+
_apply_mask(image, mask, strength * 0.27)
|
|
230
|
+
_apply_mask(
|
|
231
|
+
image, highlight, strength * 0.35, (255, 255, 255) if image.mode == "RGB" else 255
|
|
232
|
+
)
|
|
233
|
+
if strength := layers["ruled_lines"]:
|
|
234
|
+
rng = _effect_rng(seed, "ruled_lines")
|
|
235
|
+
# Keep actual line spacing tied to type size, rather than to a stretched
|
|
236
|
+
# low-resolution source. This mask is only the current image, not data.
|
|
237
|
+
mask = Image.new("L", dimensions)
|
|
238
|
+
draw = ImageDraw.Draw(mask)
|
|
239
|
+
spacing = max(12, round(font_size * 0.66))
|
|
240
|
+
offset = rng.randrange(spacing)
|
|
241
|
+
for y in range(offset, dimensions[1], spacing):
|
|
242
|
+
draw.line((0, y, dimensions[0], y), fill=round(255 * strength * 0.35), width=1)
|
|
243
|
+
_apply_mask(image, mask, 1, (95, 139, 170) if image.mode == "RGB" else 128)
|
|
244
|
+
if strength := layers["binding_shadow"]:
|
|
245
|
+
rng = _effect_rng(seed, "binding_shadow")
|
|
246
|
+
right = rng.choice((False, True))
|
|
247
|
+
width = rng.uniform(0.055, 0.13)
|
|
248
|
+
pixels = bytearray(math.prod(small))
|
|
249
|
+
for y in range(small[1]):
|
|
250
|
+
# The bend varies slowly down the gutter; the opposite edge stays
|
|
251
|
+
# lit. This shades only the paper, without inventing warped labels.
|
|
252
|
+
curvature = 0.92 + 0.08 * math.cos(y / max(1, small[1] - 1) * math.pi)
|
|
253
|
+
for x in range(small[0]):
|
|
254
|
+
distance = x / max(1, small[0] - 1)
|
|
255
|
+
if right:
|
|
256
|
+
distance = 1 - distance
|
|
257
|
+
pixels[y * small[0] + x] = round(255 * math.exp(-distance / (width * curvature)))
|
|
258
|
+
_apply_mask(image, Image.frombytes("L", small, bytes(pixels)), strength * 0.52)
|
|
259
|
+
if strength := layers["scan_banding"]:
|
|
260
|
+
rng = _effect_rng(seed, "scan_banding")
|
|
261
|
+
# Paper-feed / scan-density bands run horizontally at actual pixel
|
|
262
|
+
# scale. A small smooth ramp and sparse one-pixel rows are distinct
|
|
263
|
+
# from ruled writing lines and do not allocate a full-image byte array.
|
|
264
|
+
ramp = Image.frombytes("L", (1, 16), rng.randbytes(16))
|
|
265
|
+
_apply_mask(image, ramp, strength * 0.09)
|
|
266
|
+
mask = Image.new("L", dimensions)
|
|
267
|
+
draw = ImageDraw.Draw(mask)
|
|
268
|
+
step = max(3, round(font_size / 10))
|
|
269
|
+
for y in range(rng.randrange(step), dimensions[1], step):
|
|
270
|
+
draw.line((0, y, dimensions[0], y), fill=rng.randrange(40, 160), width=1)
|
|
271
|
+
_apply_mask(image, mask, strength * 0.2)
|
|
272
|
+
if strength := layers["speckles"]:
|
|
273
|
+
rng = _effect_rng(seed, "speckles")
|
|
274
|
+
mask = Image.new("L", dimensions)
|
|
275
|
+
draw = ImageDraw.Draw(mask)
|
|
276
|
+
# Fine irregular paper/toner flecks, sparsely distributed; sample
|
|
277
|
+
# coordinates one at a time instead of retaining a particle array.
|
|
278
|
+
for _ in range(min(5000, max(8, math.prod(dimensions) // 120))):
|
|
279
|
+
x, y = rng.randrange(dimensions[0]), rng.randrange(dimensions[1])
|
|
280
|
+
draw.line(
|
|
281
|
+
(x, y, x + rng.randrange(1, 4), y + rng.choice((-1, 0, 1))),
|
|
282
|
+
fill=rng.randrange(65, 225),
|
|
283
|
+
width=1,
|
|
284
|
+
)
|
|
285
|
+
_apply_mask(image, mask, strength * 0.55, (80, 67, 47) if image.mode == "RGB" else 60)
|
|
286
|
+
if strength := layers["grain"]:
|
|
287
|
+
rng = _effect_rng(seed, "grain")
|
|
288
|
+
# Independent 256² byte chunks preserve fine grain on wide text lines
|
|
289
|
+
# without stretching noise or constructing a full-image Python array.
|
|
290
|
+
table = [round(value * strength * 0.16) for value in range(256)]
|
|
291
|
+
for top in range(0, dimensions[1], 256):
|
|
292
|
+
for left in range(0, dimensions[0], 256):
|
|
293
|
+
size = (min(256, dimensions[0] - left), min(256, dimensions[1] - top))
|
|
294
|
+
source = Image.frombytes("L", size, rng.randbytes(math.prod(size)))
|
|
295
|
+
mask = source.point(table)
|
|
296
|
+
source.close()
|
|
297
|
+
try:
|
|
298
|
+
image.paste(0, (left, top, left + size[0], top + size[1]), mask)
|
|
299
|
+
finally:
|
|
300
|
+
mask.close()
|
|
301
|
+
return image
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def require_shaping():
|
|
305
|
+
if not features.check_feature("raqm"):
|
|
306
|
+
raise ValueError(
|
|
307
|
+
"Pillow requires libraqm for reliable multilingual shaping. "
|
|
308
|
+
"Install an official Pillow wheel or build Pillow with libraqm."
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def direction_for(text: str, direction: str) -> str:
|
|
313
|
+
if direction != "auto":
|
|
314
|
+
return direction
|
|
315
|
+
for char in text:
|
|
316
|
+
bidi = unicodedata.bidirectional(char)
|
|
317
|
+
if bidi in {"R", "AL"}:
|
|
318
|
+
return "rtl"
|
|
319
|
+
if bidi == "L":
|
|
320
|
+
return "ltr"
|
|
321
|
+
return "ltr"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class Renderer:
|
|
325
|
+
def __init__(self, settings: Generation, cache_size: int = 64):
|
|
326
|
+
validate_augmentation(settings)
|
|
327
|
+
require_shaping()
|
|
328
|
+
self.settings = settings
|
|
329
|
+
self.background_distribution = background_distribution(settings)
|
|
330
|
+
self.cache_size = cache_size
|
|
331
|
+
self.cache: OrderedDict[tuple[Font, int], ImageFont.FreeTypeFont] = OrderedDict()
|
|
332
|
+
|
|
333
|
+
def font(self, face: Font, size: int):
|
|
334
|
+
key = (face, size)
|
|
335
|
+
if key in self.cache:
|
|
336
|
+
self.cache.move_to_end(key)
|
|
337
|
+
return self.cache[key]
|
|
338
|
+
value = ImageFont.truetype(
|
|
339
|
+
face.path, size, index=face.index, layout_engine=ImageFont.Layout.RAQM
|
|
340
|
+
)
|
|
341
|
+
self.cache[key] = value
|
|
342
|
+
if len(self.cache) > self.cache_size:
|
|
343
|
+
self.cache.popitem(last=False)
|
|
344
|
+
return value
|
|
345
|
+
|
|
346
|
+
def _background_preset(self, seed: int) -> str:
|
|
347
|
+
rng = _effect_rng(seed, "preset")
|
|
348
|
+
if not self.settings.background_weights:
|
|
349
|
+
# Preserve existing datasets' seeded choices when weights are omitted.
|
|
350
|
+
return rng.choice(self.settings.background_presets)
|
|
351
|
+
return rng.choices(
|
|
352
|
+
self.settings.background_presets,
|
|
353
|
+
weights=list(self.background_distribution.values()),
|
|
354
|
+
k=1,
|
|
355
|
+
)[0]
|
|
356
|
+
|
|
357
|
+
def render(self, text: str, face: Font, seed: int, language: str = ""):
|
|
358
|
+
g = self.settings
|
|
359
|
+
rng = random.Random(seed)
|
|
360
|
+
augment = rng.random() < g.augmentation
|
|
361
|
+
preset = self._background_preset(seed) if augment and g.background_presets else ""
|
|
362
|
+
appearance = resolve_document_settings(g, preset)
|
|
363
|
+
layers = document_layers(g, preset)
|
|
364
|
+
angle = rng.uniform(-g.rotation, g.rotation) if augment else 0
|
|
365
|
+
# Sample independent effects before background generation consumes random
|
|
366
|
+
# bytes, so changing the background alone keeps blur and contrast matched.
|
|
367
|
+
blur = rng.uniform(0, g.blur) if augment else 0
|
|
368
|
+
contrast = rng.uniform(1 - g.contrast, 1 + g.contrast) if augment else 1
|
|
369
|
+
padding = (
|
|
370
|
+
max(g.padding, AUGMENT_MARGIN, math.ceil(3 * g.blur) + 2) if augment else g.padding
|
|
371
|
+
)
|
|
372
|
+
if 2 * padding >= min(g.max_width, g.max_height):
|
|
373
|
+
raise ValueError(
|
|
374
|
+
"Image bounds leave no text area inside the augmentation safety margin"
|
|
375
|
+
)
|
|
376
|
+
direction = direction_for(text, g.direction)
|
|
377
|
+
layout = {"direction": direction}
|
|
378
|
+
if g.language or language:
|
|
379
|
+
layout["language"] = g.language or language
|
|
380
|
+
size = g.font_size
|
|
381
|
+
# A fixed point size is both faster and typographically consistent across lines.
|
|
382
|
+
# Reduce oversized lines before allocating an image; never crop the transcription.
|
|
383
|
+
for _ in range(8):
|
|
384
|
+
font = self.font(face, size)
|
|
385
|
+
left, top, right, bottom = font.getbbox(text, **layout)
|
|
386
|
+
width, height = right - left, bottom - top
|
|
387
|
+
if width <= 0 or height <= 0:
|
|
388
|
+
raise ValueError("Text has no visible ink")
|
|
389
|
+
ratio = min(
|
|
390
|
+
(g.max_width - 2 * padding) / width, (g.max_height - 2 * padding) / height, 1
|
|
391
|
+
)
|
|
392
|
+
outer_width, outer_height = width + 2 * padding, height + 2 * padding
|
|
393
|
+
if augment:
|
|
394
|
+
sine = abs(math.sin(math.radians(angle)))
|
|
395
|
+
cosine = abs(math.cos(math.radians(angle)))
|
|
396
|
+
rotated_width = math.ceil(outer_width * cosine + outer_height * sine) + 2
|
|
397
|
+
rotated_height = math.ceil(outer_height * cosine + outer_width * sine) + 2
|
|
398
|
+
else:
|
|
399
|
+
rotated_width, rotated_height = outer_width, outer_height
|
|
400
|
+
ratio = min(
|
|
401
|
+
ratio,
|
|
402
|
+
g.max_width / rotated_width,
|
|
403
|
+
g.max_height / rotated_height,
|
|
404
|
+
math.sqrt(g.max_pixels / (rotated_width * rotated_height)),
|
|
405
|
+
)
|
|
406
|
+
if ratio == 1:
|
|
407
|
+
break
|
|
408
|
+
next_size = max(1, int(size * ratio))
|
|
409
|
+
if next_size >= size:
|
|
410
|
+
raise ValueError("Text cannot fit the configured image bounds")
|
|
411
|
+
size = next_size
|
|
412
|
+
else:
|
|
413
|
+
raise ValueError("Text cannot fit the configured image bounds")
|
|
414
|
+
dimensions = (width + 2 * padding, height + 2 * padding)
|
|
415
|
+
if dimensions[0] * dimensions[1] > g.max_pixels:
|
|
416
|
+
raise ValueError("Rendered image exceeds max_pixels")
|
|
417
|
+
mask = Image.new("L", dimensions, 0)
|
|
418
|
+
ImageDraw.Draw(mask).text(
|
|
419
|
+
(padding - left, padding - top), text, font=font, fill=255, **layout
|
|
420
|
+
)
|
|
421
|
+
augmentation = {}
|
|
422
|
+
if augment:
|
|
423
|
+
# Skew only the ink. Generate paper over the expanded canvas so
|
|
424
|
+
# rotation never exposes a solid-white frame around the texture.
|
|
425
|
+
original = mask
|
|
426
|
+
mask = original.rotate(
|
|
427
|
+
angle, resample=Image.Resampling.BICUBIC, expand=True, fillcolor=0
|
|
428
|
+
)
|
|
429
|
+
original.close()
|
|
430
|
+
image = _background(mask.size, appearance, rng)
|
|
431
|
+
_document_background(image, layers, seed, size)
|
|
432
|
+
image.paste(_color(appearance.text_color, image.mode), mask=mask)
|
|
433
|
+
if blur:
|
|
434
|
+
original, original_mask = image, mask
|
|
435
|
+
image = original.filter(ImageFilter.GaussianBlur(blur))
|
|
436
|
+
mask = original_mask.filter(ImageFilter.GaussianBlur(blur))
|
|
437
|
+
original.close()
|
|
438
|
+
original_mask.close()
|
|
439
|
+
if contrast != 1:
|
|
440
|
+
original = image
|
|
441
|
+
image = ImageEnhance.Contrast(original).enhance(contrast)
|
|
442
|
+
original.close()
|
|
443
|
+
augmentation = {
|
|
444
|
+
"background": appearance.background,
|
|
445
|
+
"color_mode": appearance.color_mode,
|
|
446
|
+
"background_color": appearance.background_color,
|
|
447
|
+
"text_color": appearance.text_color,
|
|
448
|
+
"texture_strength": (
|
|
449
|
+
appearance.texture_strength
|
|
450
|
+
if appearance.background in {"paper", "noise"}
|
|
451
|
+
else 0
|
|
452
|
+
),
|
|
453
|
+
"background_preset": preset,
|
|
454
|
+
"layers": layers,
|
|
455
|
+
"rotation": angle,
|
|
456
|
+
"blur": blur,
|
|
457
|
+
"contrast": contrast,
|
|
458
|
+
}
|
|
459
|
+
if preset:
|
|
460
|
+
augmentation["background_probability"] = self.background_distribution[preset]
|
|
461
|
+
if appearance.background == "gradient":
|
|
462
|
+
augmentation["background_color_end"] = appearance.background_color_end
|
|
463
|
+
else:
|
|
464
|
+
image = ImageOps.invert(mask)
|
|
465
|
+
if (
|
|
466
|
+
image.width > g.max_width
|
|
467
|
+
or image.height > g.max_height
|
|
468
|
+
or image.width * image.height > g.max_pixels
|
|
469
|
+
):
|
|
470
|
+
image.close()
|
|
471
|
+
mask.close()
|
|
472
|
+
raise ValueError(
|
|
473
|
+
"Augmented image exceeds configured bounds; increase bounds or padding"
|
|
474
|
+
)
|
|
475
|
+
bbox = mask.getbbox()
|
|
476
|
+
mask.close()
|
|
477
|
+
if bbox is None:
|
|
478
|
+
image.close()
|
|
479
|
+
raise ValueError("Font rendered no visible ink")
|
|
480
|
+
if augment and (
|
|
481
|
+
bbox[0] == 0 or bbox[1] == 0 or bbox[2] == image.width or bbox[3] == image.height
|
|
482
|
+
):
|
|
483
|
+
image.close()
|
|
484
|
+
raise ValueError("Augmented ink reaches the canvas edge; increase padding or bounds")
|
|
485
|
+
return image, {
|
|
486
|
+
"width": image.width,
|
|
487
|
+
"height": image.height,
|
|
488
|
+
"bbox": list(bbox),
|
|
489
|
+
"font_size": size,
|
|
490
|
+
"direction": direction,
|
|
491
|
+
"language": layout.get("language", ""),
|
|
492
|
+
"augmentation": augmentation,
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
_renderer: Renderer | None = None
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def initialize(settings: Generation, cache_size: int):
|
|
500
|
+
global _renderer
|
|
501
|
+
_renderer = Renderer(settings, cache_size)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def release():
|
|
505
|
+
"""Release serial native font objects when a generation job ends."""
|
|
506
|
+
global _renderer
|
|
507
|
+
_renderer = None
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def render_task(task: dict) -> dict:
|
|
511
|
+
if _renderer is None:
|
|
512
|
+
raise RuntimeError("Renderer is not initialized")
|
|
513
|
+
image, info = _renderer.render(task["text"], task["face"], task["seed"], task["language"])
|
|
514
|
+
path = Path(task["destination"])
|
|
515
|
+
try:
|
|
516
|
+
options = {"compress_level": 1} if path.suffix == ".png" else {}
|
|
517
|
+
if path.suffix == ".jpg":
|
|
518
|
+
options = {"quality": 95}
|
|
519
|
+
image.save(path, **options)
|
|
520
|
+
finally:
|
|
521
|
+
image.close()
|
|
522
|
+
face = task["face"]
|
|
523
|
+
# A shaping hint must not relabel the source language in annotations/exports.
|
|
524
|
+
info["shaping_language"] = info["language"]
|
|
525
|
+
info["language"] = task["language"]
|
|
526
|
+
# Hash source labels for deterministic, disjoint text splits across font variants.
|
|
527
|
+
settings = _renderer.settings
|
|
528
|
+
split_value = (
|
|
529
|
+
int.from_bytes(hashlib.sha256(f"{settings.seed}:{task['text']}".encode()).digest()[:8])
|
|
530
|
+
/ 2**64
|
|
531
|
+
)
|
|
532
|
+
split = (
|
|
533
|
+
"train"
|
|
534
|
+
if split_value < settings.train_ratio
|
|
535
|
+
else "validation"
|
|
536
|
+
if split_value < settings.train_ratio + settings.validation_ratio
|
|
537
|
+
else "test"
|
|
538
|
+
)
|
|
539
|
+
return {
|
|
540
|
+
"id": task["id"],
|
|
541
|
+
"image": f"images/{path.name}",
|
|
542
|
+
"text": task["text"],
|
|
543
|
+
"text_id": task["text_id"],
|
|
544
|
+
"font": task["font_name"],
|
|
545
|
+
"font_id": task["font_id"],
|
|
546
|
+
"face_index": face.index,
|
|
547
|
+
"source": task["source"],
|
|
548
|
+
"source_line": task["line"],
|
|
549
|
+
"genre": task["genre"],
|
|
550
|
+
"split": split,
|
|
551
|
+
"attribution": task["attribution"],
|
|
552
|
+
"image_sha256": sha256(path),
|
|
553
|
+
"seed": task["seed"],
|
|
554
|
+
**info,
|
|
555
|
+
}
|
synthocr/spelling.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Authored Kurdish spelling examples and explicitly labelled rendering probes."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
LANGUAGES = ("ckb", "kmr", "mixed")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def spelling_cases(language: str = "mixed", words_only: bool = False):
|
|
15
|
+
if language not in LANGUAGES:
|
|
16
|
+
raise ValueError("Spelling cases support ckb, kmr or mixed")
|
|
17
|
+
resource = files("synthocr").joinpath("kurdish_spelling.json").read_bytes()
|
|
18
|
+
catalog = json.loads(resource)
|
|
19
|
+
digest = hashlib.sha256(resource).hexdigest()
|
|
20
|
+
|
|
21
|
+
def record(identifier, locale, text, kind, features):
|
|
22
|
+
return {
|
|
23
|
+
"text": text,
|
|
24
|
+
"language": locale,
|
|
25
|
+
"genre": "spelling",
|
|
26
|
+
"attribution": {
|
|
27
|
+
"source": "SynthOCR Kurdish spelling cases",
|
|
28
|
+
"version": catalog["version"],
|
|
29
|
+
"catalog_sha256": digest,
|
|
30
|
+
"source_id": identifier,
|
|
31
|
+
"features": features,
|
|
32
|
+
"kind": kind,
|
|
33
|
+
"synthetic": True,
|
|
34
|
+
"license": "CC0-1.0",
|
|
35
|
+
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
|
|
36
|
+
"modified": False,
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for group in catalog["groups"]:
|
|
41
|
+
if language != "mixed" and group["language"] != language:
|
|
42
|
+
continue
|
|
43
|
+
if words_only and group["kind"] != "words":
|
|
44
|
+
continue
|
|
45
|
+
for index, text in enumerate(group["texts"], 1):
|
|
46
|
+
yield record(
|
|
47
|
+
f"{group['id']}-{index:02}",
|
|
48
|
+
group["language"],
|
|
49
|
+
text,
|
|
50
|
+
group["kind"],
|
|
51
|
+
[group["feature"]],
|
|
52
|
+
)
|
|
53
|
+
if words_only:
|
|
54
|
+
return
|
|
55
|
+
profiles = json.loads(
|
|
56
|
+
files("synthocr").joinpath("font_profiles.json").read_text(encoding="utf-8")
|
|
57
|
+
)
|
|
58
|
+
if language in {"ckb", "mixed"}:
|
|
59
|
+
for letter in profiles["sorani"]["letters"]:
|
|
60
|
+
# Context strings are diagnostics, not invented dictionary words.
|
|
61
|
+
# Non-joining letters correctly stay unconnected where required.
|
|
62
|
+
yield record(
|
|
63
|
+
f"ckb-context-{ord(letter):04x}",
|
|
64
|
+
"ckb",
|
|
65
|
+
f"{letter} | {letter}م | س{letter}م | س{letter}",
|
|
66
|
+
"joining_probe",
|
|
67
|
+
["core_alphabet", "isolated", "initial", "medial", "final"],
|
|
68
|
+
)
|
|
69
|
+
if language in {"kmr", "mixed"}:
|
|
70
|
+
letters = profiles["kurmanji"]["letters"]
|
|
71
|
+
for letter in letters:
|
|
72
|
+
if letter.islower():
|
|
73
|
+
yield record(
|
|
74
|
+
f"kmr-case-{ord(letter):04x}",
|
|
75
|
+
"kmr",
|
|
76
|
+
f"{letter} {letter.upper()}",
|
|
77
|
+
"case_probe",
|
|
78
|
+
["core_alphabet", "lowercase", "uppercase"],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def write_spelling(output: Path, language: str = "mixed", words_only: bool = False) -> dict:
|
|
83
|
+
"""Publish one record per case atomically, without inflating counts by repetition."""
|
|
84
|
+
if output.exists():
|
|
85
|
+
raise FileExistsError(output)
|
|
86
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
temporary = None
|
|
88
|
+
languages, features, kinds = Counter(), Counter(), Counter()
|
|
89
|
+
count = 0
|
|
90
|
+
try:
|
|
91
|
+
with tempfile.NamedTemporaryFile(
|
|
92
|
+
mode="w", encoding="utf-8", dir=output.parent, delete=False
|
|
93
|
+
) as stream:
|
|
94
|
+
temporary = Path(stream.name)
|
|
95
|
+
for row in spelling_cases(language, words_only):
|
|
96
|
+
stream.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
97
|
+
count += 1
|
|
98
|
+
languages[row["language"]] += 1
|
|
99
|
+
features.update(row["attribution"]["features"])
|
|
100
|
+
kinds[row["attribution"]["kind"]] += 1
|
|
101
|
+
os.link(temporary, output)
|
|
102
|
+
finally:
|
|
103
|
+
if temporary is not None:
|
|
104
|
+
temporary.unlink(missing_ok=True)
|
|
105
|
+
return {
|
|
106
|
+
"output": str(output),
|
|
107
|
+
"records": count,
|
|
108
|
+
"languages": dict(languages),
|
|
109
|
+
"features": dict(features),
|
|
110
|
+
"kinds": dict(kinds),
|
|
111
|
+
}
|