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/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
+ }