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.
@@ -0,0 +1,399 @@
1
+ """Bounded, reproducible visual comparisons using the maintained renderer."""
2
+
3
+ import hashlib
4
+ import heapq
5
+ import json
6
+ import math
7
+ import platform
8
+ import sys
9
+ import time
10
+ from dataclasses import asdict, replace
11
+ from importlib.metadata import version
12
+ from pathlib import Path
13
+
14
+ from PIL import Image, ImageDraw, ImageStat, features
15
+
16
+ from . import __version__
17
+ from .config import (
18
+ DOCUMENT_LAYERS,
19
+ DOCUMENT_PRESETS,
20
+ Config,
21
+ augmentation_contrast,
22
+ resolve_document_settings,
23
+ )
24
+ from .downloads import sha256
25
+ from .fonts import Coverage, discover
26
+ from .render import Renderer
27
+ from .spelling import spelling_cases
28
+
29
+ PRESETS = (
30
+ ("clean", {"augmentation": 0}),
31
+ *((name, {"augmentation": 1, "background_presets": [name]}) for name in DOCUMENT_PRESETS),
32
+ )
33
+ MAX_CASES = 64
34
+ SHEET_CASES = 12
35
+ SHEET_COLUMNS = 4
36
+ SHEET_ROWS = 12
37
+
38
+
39
+ def _write_json(path: Path, value):
40
+ with path.open("x", encoding="utf-8") as stream:
41
+ json.dump(value, stream, ensure_ascii=False, indent=2, sort_keys=True)
42
+ stream.write("\n")
43
+
44
+
45
+ def _selected_cases(samples: int, seed: int):
46
+ # Hash-ranked reservoirs keep at most `samples` rows, independent of any
47
+ # future growth in the diagnostic catalog. Small feature reservoirs make
48
+ # the default useful for actual shaping failures, rather than mostly words.
49
+ limits = {"ckb": (samples + 1) // 2, "kmr": samples // 2}
50
+ priorities = {
51
+ "ckb": (
52
+ "combining_marks",
53
+ "join_controls",
54
+ "mixed_direction",
55
+ "digit_sets",
56
+ "word_boundaries",
57
+ "core_alphabet",
58
+ ),
59
+ "kmr": (
60
+ "canonical_accents",
61
+ "capitalization",
62
+ "mixed_direction",
63
+ "word_boundaries",
64
+ "accented_letters",
65
+ "punctuation",
66
+ ),
67
+ }
68
+ selected = {language: [] for language in limits}
69
+ features = {language: {} for language in limits}
70
+ for case in spelling_cases():
71
+ language = case["language"]
72
+ limit = limits[language]
73
+ if not limit:
74
+ continue
75
+ identifier = case["attribution"]["source_id"]
76
+ rank = int.from_bytes(hashlib.sha256(f"{seed}:{identifier}".encode()).digest(), "big")
77
+ item = (-rank, identifier, case)
78
+ for feature in case["attribution"]["features"]:
79
+ if feature in priorities[language] and (
80
+ feature not in features[language] or item[0] > features[language][feature][0]
81
+ ):
82
+ features[language][feature] = item
83
+ heap = selected[language]
84
+ if len(heap) < limit:
85
+ heapq.heappush(heap, item)
86
+ elif rank < -heap[0][0]:
87
+ heapq.heapreplace(heap, item)
88
+ ordered = {}
89
+ for language, limit in limits.items():
90
+ candidates = [
91
+ features[language][feature]
92
+ for feature in priorities[language]
93
+ if feature in features[language]
94
+ ] + sorted(selected[language], reverse=True)
95
+ chosen = {}
96
+ for _, identifier, case in candidates:
97
+ if len(chosen) < limit:
98
+ chosen.setdefault(identifier, case)
99
+ ordered[language] = list(chosen.values())
100
+ rows = []
101
+ for index in range(max(limits.values())):
102
+ for language in limits:
103
+ if index < len(ordered[language]):
104
+ rows.append(ordered[language][index])
105
+ if len(rows) != samples:
106
+ raise ValueError("The bundled diagnostic catalog has too few cases for this experiment")
107
+ return rows
108
+
109
+
110
+ def _peak_rss():
111
+ try:
112
+ import resource
113
+ except ImportError:
114
+ return None
115
+ value = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
116
+ return value if sys.platform == "darwin" else value * 1024
117
+
118
+
119
+ def _metrics(image, metadata):
120
+ width, height = image.size
121
+ left, top, right, bottom = metadata["bbox"]
122
+ if not 0 <= left < right <= width or not 0 <= top < bottom <= height:
123
+ raise ValueError("Rendered ink bounds are invalid")
124
+ with image.convert("L") as gray:
125
+ low, high = gray.getextrema()
126
+ return {
127
+ "ink_margin_px": min(left, top, width - right, height - bottom),
128
+ "luminance_min": low,
129
+ "luminance_max": high,
130
+ "luminance_mean": round(ImageStat.Stat(gray).mean[0], 3),
131
+ "corner_luminance": [
132
+ gray.getpixel(point)
133
+ for point in ((0, 0), (width - 1, 0), (0, height - 1), (width - 1, height - 1))
134
+ ],
135
+ }
136
+
137
+
138
+ def _contact_sheet(output: Path, cases: int):
139
+ # Keep one small page and one decoded source image alive. Adding recipes
140
+ # creates more pages rather than a wider canvas; only 12 cases are shown.
141
+ shown = min(cases, SHEET_CASES)
142
+ cell_width, cell_height, gap = 320, 112, 12
143
+ columns = min(len(PRESETS), SHEET_COLUMNS)
144
+ rows_per_case = math.ceil(len(PRESETS) / columns)
145
+ cases_per_page = max(1, SHEET_ROWS // rows_per_case)
146
+ case_header = 24
147
+ case_height = case_header + rows_per_case * cell_height
148
+ pages = []
149
+ for first in range(0, shown, cases_per_page):
150
+ count = min(cases_per_page, shown - first)
151
+ filename = "contact-sheet.png" if not pages else f"contact-sheet-{len(pages) + 1:03}.png"
152
+ dimensions = (columns * cell_width + gap, 54 + count * case_height)
153
+ sheet = Image.new("RGB", dimensions, "#edf0ee")
154
+ try:
155
+ draw = ImageDraw.Draw(sheet)
156
+ draw.text(
157
+ (gap, 12),
158
+ f"Document backgrounds - cases {first + 1} to {first + count}",
159
+ fill="#183d32",
160
+ )
161
+ with (output / "manifest.jsonl").open(encoding="utf-8") as stream:
162
+ for line in stream:
163
+ row = json.loads(line)
164
+ if not first <= row["case_index"] < first + count:
165
+ continue
166
+ case_y = 44 + (row["case_index"] - first) * case_height
167
+ if not row["preset_index"]:
168
+ draw.text(
169
+ (gap, case_y),
170
+ f"{row['case_id']} | {row['language']}",
171
+ fill="#40504a",
172
+ )
173
+ x = row["preset_index"] % columns * cell_width + gap
174
+ y = case_y + case_header + row["preset_index"] // columns * cell_height
175
+ draw.rectangle(
176
+ (x, y, x + cell_width - gap, y + cell_height - gap), fill="white"
177
+ )
178
+ label = (
179
+ "Original"
180
+ if row["preset"] == "clean"
181
+ else DOCUMENT_PRESETS[row["preset"]]["label"]
182
+ )
183
+ draw.text((x + 6, y + 5), label, fill="#183d32")
184
+ thumb = Image.open(output / row["image"])
185
+ try:
186
+ thumb.thumbnail((cell_width - 2 * gap, cell_height - 38))
187
+ sheet.paste(
188
+ thumb,
189
+ (x + gap, y + 22 + (cell_height - 38 - thumb.height) // 2),
190
+ )
191
+ finally:
192
+ thumb.close()
193
+ with (output / filename).open("xb") as stream:
194
+ sheet.save(stream, format="PNG")
195
+ finally:
196
+ sheet.close()
197
+ pages.append(
198
+ {
199
+ "file": filename,
200
+ "sha256": sha256(output / filename),
201
+ "first_case_index": first,
202
+ "cases": count,
203
+ "width": dimensions[0],
204
+ "height": dimensions[1],
205
+ }
206
+ )
207
+ return pages
208
+
209
+
210
+ def run_experiment(config: Config, output: Path, samples: int = 12, seed: int = 42) -> dict:
211
+ """Compare document presets without reading the training corpus.
212
+
213
+ This writes attributed diagnostic images and evidence, not a training
214
+ dataset. Structural measurements do not establish OCR or linguistic quality.
215
+ A failed render leaves an explicit incomplete report and is re-raised.
216
+ """
217
+ if type(samples) is not int or not 1 <= samples <= MAX_CASES:
218
+ raise ValueError(f"samples must be an integer between 1 and {MAX_CASES}")
219
+ if type(seed) is not int:
220
+ raise ValueError("seed must be an integer")
221
+ config.validate()
222
+ output = Path(output)
223
+ if output.is_symlink():
224
+ raise ValueError("Experiment output must not be a symlink")
225
+ output = output.resolve()
226
+ for protected in (config.path(config.data.raw), config.path(config.data.processed)):
227
+ if output == protected or protected in output.parents or output in protected.parents:
228
+ raise ValueError("Experiment output must be separate from raw and processed data")
229
+ if output.exists() and (not output.is_dir() or next(output.iterdir(), None) is not None):
230
+ raise FileExistsError(f"Experiment output is not empty: {output}")
231
+ cases = _selected_cases(samples, seed)
232
+ fonts = [
233
+ face
234
+ for face in discover([config.path(path) for path in config.fonts.directories])
235
+ if Path(face.path).with_name("OFL.txt").is_file()
236
+ ]
237
+ if not fonts:
238
+ raise ValueError(
239
+ "Experiments require fonts accompanied by OFL.txt; use synthocr fonts fetch"
240
+ )
241
+ coverage = Coverage(min(config.fonts.coverage_cache_size, 64))
242
+ base = replace(
243
+ config.generation,
244
+ seed=seed,
245
+ workers=1,
246
+ format="png",
247
+ direction="auto",
248
+ language="",
249
+ background_color="#f5f1e8",
250
+ background_color_end="#e4e9ed",
251
+ text_color="#1a1a1a",
252
+ texture_strength=0.04,
253
+ rotation=2,
254
+ blur=0.55,
255
+ contrast=0.1,
256
+ background_presets=[],
257
+ background_weights={},
258
+ preset_colors=True,
259
+ **dict.fromkeys(DOCUMENT_LAYERS),
260
+ )
261
+ variants = [(name, replace(base, **options)) for name, options in PRESETS]
262
+ renderers = [Renderer(settings, min(config.fonts.cache_size, 4)) for _, settings in variants]
263
+ output.mkdir(parents=True, exist_ok=True)
264
+ (output / "images").mkdir()
265
+ settings = {
266
+ "schema_version": 1,
267
+ "samples": samples,
268
+ "seed": seed,
269
+ "font_cache_per_preset": min(config.fonts.cache_size, 4),
270
+ "coverage_cache": min(config.fonts.coverage_cache_size, 64),
271
+ "presets": {
272
+ name: {key: value for key, value in asdict(value).items() if key != "output"}
273
+ for name, value in variants
274
+ },
275
+ }
276
+ _write_json(output / "config.json", settings)
277
+ with (output / "cases.jsonl").open("x", encoding="utf-8") as stream:
278
+ for case in cases:
279
+ stream.write(json.dumps(case, ensure_ascii=False, sort_keys=True) + "\n")
280
+ report = {
281
+ "schema_version": 1,
282
+ "status": "incomplete",
283
+ "purpose": "Structural rendering comparison; not an OCR accuracy or spelling evaluation",
284
+ "samples": samples,
285
+ "seed": seed,
286
+ "images": 0,
287
+ "config_sha256": sha256(output / "config.json"),
288
+ "cases_sha256": sha256(output / "cases.jsonl"),
289
+ "catalog_sha256": cases[0]["attribution"]["catalog_sha256"],
290
+ "environment": {
291
+ "synthocr": __version__,
292
+ "python": platform.python_version(),
293
+ "pillow": version("pillow"),
294
+ "freetype": features.version_module("freetype2"),
295
+ "raqm": features.version_feature("raqm"),
296
+ "system": platform.system(),
297
+ "machine": platform.machine(),
298
+ },
299
+ "fonts": {},
300
+ "presets": {
301
+ name: {"images": 0, "bytes": 0, "processing_seconds": 0.0} for name, _ in variants
302
+ },
303
+ "timing_scope": (
304
+ "Per-preset processing includes rendering, metrics, encoding, disk I/O, "
305
+ "decoding, hashing and annotation writing. Elapsed time also includes "
306
+ "font selection and contact sheet creation; setup/discovery is excluded."
307
+ ),
308
+ "parent_peak_rss_bytes": None,
309
+ "memory_scope": (
310
+ "Process-lifetime parent RSS high-water mark; no workers; unavailable on Windows"
311
+ ),
312
+ }
313
+ started = time.perf_counter()
314
+ try:
315
+ with (output / "manifest.jsonl").open("x", encoding="utf-8") as manifest:
316
+ for case_index, case in enumerate(cases):
317
+ identifier = case["attribution"]["source_id"]
318
+ preferred = "notonaskharabic" if case["language"] == "ckb" else "notosans"
319
+ choices = sorted(
320
+ fonts, key=lambda face: (Path(face.path).parent.name != preferred, face.name)
321
+ )
322
+ face = coverage.choose(choices, case["text"], 0)
323
+ if face is None:
324
+ raise ValueError(
325
+ f"No font accompanied by OFL.txt covers diagnostic case {identifier}"
326
+ )
327
+ font_digest = sha256(Path(face.path))
328
+ font_id = f"{font_digest}#{face.index}"
329
+ report["fonts"][font_id] = {
330
+ "filename": Path(face.path).name,
331
+ "face_index": face.index,
332
+ "sha256": font_digest,
333
+ "license_file": "OFL.txt",
334
+ "license_note": "Accompanying license file; not an independent licensing audit",
335
+ "license_sha256": sha256(Path(face.path).with_name("OFL.txt")),
336
+ }
337
+ render_seed = int.from_bytes(
338
+ hashlib.sha256(f"{seed}:{identifier}".encode()).digest()[:8], "big"
339
+ )
340
+ for preset_index, (name, variant) in enumerate(variants):
341
+ tick = time.perf_counter()
342
+ image, metadata = renderers[preset_index].render(
343
+ case["text"], face, render_seed, case["language"]
344
+ )
345
+ filename = f"images/{case_index:03}-{name}.png"
346
+ try:
347
+ metrics = _metrics(image, metadata)
348
+ with (output / filename).open("xb") as stream:
349
+ image.save(stream, format="PNG", compress_level=1)
350
+ finally:
351
+ image.close()
352
+ # Decode the published bytes as well as inspecting the render.
353
+ with Image.open(output / filename) as decoded:
354
+ decoded.load()
355
+ if decoded.size != (metadata["width"], metadata["height"]):
356
+ raise ValueError("Encoded image dimensions differ from render metadata")
357
+ row = {
358
+ **case,
359
+ "case_id": identifier,
360
+ "case_index": case_index,
361
+ "preset": name,
362
+ "preset_index": preset_index,
363
+ "font": face.name,
364
+ "font_id": font_id,
365
+ "seed": render_seed,
366
+ "image": filename,
367
+ "image_sha256": sha256(output / filename),
368
+ "metrics": metrics,
369
+ "base_color_contrast": round(
370
+ augmentation_contrast(
371
+ resolve_document_settings(
372
+ variant, metadata["augmentation"].get("background_preset", "")
373
+ )
374
+ ),
375
+ 3,
376
+ )
377
+ if variant.augmentation
378
+ else 21.0,
379
+ **metadata,
380
+ }
381
+ manifest.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
382
+ stats = report["presets"][name]
383
+ stats["images"] += 1
384
+ stats["bytes"] += (output / filename).stat().st_size
385
+ stats["processing_seconds"] += time.perf_counter() - tick
386
+ report["images"] += 1
387
+ report["contact_sheets"] = _contact_sheet(output, samples)
388
+ report["manifest_sha256"] = sha256(output / "manifest.jsonl")
389
+ report["contact_sheet_sha256"] = sha256(output / "contact-sheet.png")
390
+ report["contact_sheet_cases"] = min(samples, SHEET_CASES)
391
+ report["status"] = "complete"
392
+ except Exception as error:
393
+ report["error"] = str(error)
394
+ raise
395
+ finally:
396
+ report["elapsed_seconds"] = time.perf_counter() - started
397
+ report["parent_peak_rss_bytes"] = _peak_rss()
398
+ _write_json(output / "report.json", report)
399
+ return {"output": str(output), **report}