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/experiments.py
ADDED
|
@@ -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}
|