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/pipeline.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""Bounded generation and streaming exports, independent of any training framework."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import multiprocessing
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import random
|
|
11
|
+
import shutil
|
|
12
|
+
import sqlite3
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
17
|
+
from dataclasses import asdict, replace
|
|
18
|
+
from math import gcd
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from .config import Config
|
|
23
|
+
from .corpus_store import canonical_language, iter_origins
|
|
24
|
+
from .downloads import sha256
|
|
25
|
+
from .fonts import Coverage, discover, font_ids, select
|
|
26
|
+
from .render import initialize, release, render_task, require_shaping
|
|
27
|
+
from .spelling import write_spelling
|
|
28
|
+
from .text import prepare
|
|
29
|
+
from .verify import verified_records
|
|
30
|
+
|
|
31
|
+
LOG = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class GenerationCancelled(ValueError):
|
|
35
|
+
"""A cooperative stop leaves its partial dataset marked as cancelled."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _check_cancel(cancel):
|
|
39
|
+
if cancel is not None and cancel():
|
|
40
|
+
raise GenerationCancelled("Generation cancelled; partial output has been retained")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _font_name(config, face):
|
|
44
|
+
path = Path(face.path)
|
|
45
|
+
try:
|
|
46
|
+
return path.relative_to(config.root).as_posix()
|
|
47
|
+
except ValueError:
|
|
48
|
+
return path.name
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
COLUMNS = "texts.id,text,source,line,genre,language,attribution"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _selection(db, config, languages, table):
|
|
55
|
+
"""Return the matching record count and a lookup by 1-based selection position."""
|
|
56
|
+
if not languages and not config.generation.exclude_genres:
|
|
57
|
+
count = db.execute("SELECT count(*) FROM texts").fetchone()[0]
|
|
58
|
+
return count, f"SELECT {COLUMNS} FROM texts WHERE texts.id=?"
|
|
59
|
+
# Renumber the selected source IDs on disk for constant-memory permutation.
|
|
60
|
+
# Keep original text IDs in manifests, font choices and deterministic seeds.
|
|
61
|
+
db.execute("PRAGMA temp_store=FILE")
|
|
62
|
+
db.execute(f"CREATE TEMP TABLE {table} (id INTEGER PRIMARY KEY, text_id INTEGER NOT NULL)")
|
|
63
|
+
conditions, parameters = [], []
|
|
64
|
+
if languages:
|
|
65
|
+
placeholders = ",".join("?" for _ in languages)
|
|
66
|
+
conditions.append(f"lower(language) IN ({placeholders})")
|
|
67
|
+
parameters.extend(canonical_language(language) for language in languages)
|
|
68
|
+
if config.generation.exclude_genres:
|
|
69
|
+
placeholders = ",".join("?" for _ in config.generation.exclude_genres)
|
|
70
|
+
conditions.append(f"coalesce(genre,'') NOT IN ({placeholders})")
|
|
71
|
+
parameters.extend(config.generation.exclude_genres)
|
|
72
|
+
db.execute(
|
|
73
|
+
f"INSERT INTO {table}(text_id) SELECT id FROM texts "
|
|
74
|
+
f"WHERE {' AND '.join(conditions)} ORDER BY id",
|
|
75
|
+
parameters,
|
|
76
|
+
)
|
|
77
|
+
count = db.execute(f"SELECT count(*) FROM {table}").fetchone()[0]
|
|
78
|
+
return count, (
|
|
79
|
+
f"SELECT {COLUMNS} FROM texts JOIN {table} ON texts.id={table}.text_id WHERE {table}.id=?"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _candidates(config, db, fonts, stats, count, query, rng, coverage, cancel):
|
|
84
|
+
"""Yield supported (record, face, variant) pairs in a seeded constant-memory order."""
|
|
85
|
+
if not count:
|
|
86
|
+
return
|
|
87
|
+
start = rng.randrange(count)
|
|
88
|
+
stride = rng.randrange(1, count + 1)
|
|
89
|
+
while gcd(stride, count) != 1:
|
|
90
|
+
stride = stride % count + 1
|
|
91
|
+
for variant in range(len(fonts)):
|
|
92
|
+
found = False
|
|
93
|
+
for index in range(count):
|
|
94
|
+
_check_cancel(cancel)
|
|
95
|
+
position = (start + index * stride) % count + 1
|
|
96
|
+
row = db.execute(query, (position,)).fetchone()
|
|
97
|
+
text_id, text = row[0], row[1]
|
|
98
|
+
offset = (text_id + config.generation.seed) % len(fonts)
|
|
99
|
+
if config.fonts.coverage == "strict":
|
|
100
|
+
face = coverage.choose(fonts, text, offset, variant)
|
|
101
|
+
else:
|
|
102
|
+
face = fonts[(offset + variant) % len(fonts)]
|
|
103
|
+
if face is None:
|
|
104
|
+
if variant == 0:
|
|
105
|
+
stats["unsupported_texts"] += 1
|
|
106
|
+
if len(stats["unsupported_examples"]) < 5:
|
|
107
|
+
stats["unsupported_examples"].append({"text_id": text_id, "text": text})
|
|
108
|
+
continue
|
|
109
|
+
found = True
|
|
110
|
+
yield row, face, variant
|
|
111
|
+
if not found:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _quotas(names, weights, total):
|
|
116
|
+
"""Divide total by relative weight, giving remainders to the largest fractions."""
|
|
117
|
+
values = [weights.get(name, 1) for name in names]
|
|
118
|
+
exact = [total * value / sum(values) for value in values]
|
|
119
|
+
quotas = [int(value) for value in exact]
|
|
120
|
+
order = sorted(range(len(names)), key=lambda i: (quotas[i] - exact[i], i))
|
|
121
|
+
for index in order[: total - sum(quotas)]:
|
|
122
|
+
quotas[index] += 1
|
|
123
|
+
return quotas
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _interleave(streams, quotas, total):
|
|
127
|
+
"""Draw each stream up to its quota, spread evenly; an exhausted stream's
|
|
128
|
+
remainder is filled from the others so the requested total is still reached."""
|
|
129
|
+
produced = [0] * len(streams)
|
|
130
|
+
active = set(range(len(streams)))
|
|
131
|
+
drawn = 0
|
|
132
|
+
while drawn < total and active:
|
|
133
|
+
owed = [i for i in active if produced[i] < quotas[i]]
|
|
134
|
+
candidates = owed or sorted(active)
|
|
135
|
+
# Pick the stream furthest behind its proportional target at this point.
|
|
136
|
+
index = max(
|
|
137
|
+
candidates,
|
|
138
|
+
key=lambda i: (quotas[i] * (drawn + 1) / total - produced[i], -i),
|
|
139
|
+
)
|
|
140
|
+
item = next(streams[index], None)
|
|
141
|
+
if item is None:
|
|
142
|
+
active.discard(index)
|
|
143
|
+
continue
|
|
144
|
+
produced[index] += 1
|
|
145
|
+
drawn += 1
|
|
146
|
+
yield item
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _tasks(config, db, fonts, output, stats, font_ids, cancel=None):
|
|
150
|
+
coverage = Coverage(config.fonts.coverage_cache_size)
|
|
151
|
+
languages = config.generation.languages
|
|
152
|
+
weights = {
|
|
153
|
+
canonical_language(name): value
|
|
154
|
+
for name, value in config.generation.language_weights.items()
|
|
155
|
+
}
|
|
156
|
+
selected = list(dict.fromkeys(canonical_language(language) for language in languages))
|
|
157
|
+
if weights:
|
|
158
|
+
# A zero share excludes that language; it is never used to fill gaps.
|
|
159
|
+
selected = [language for language in selected if weights.get(language, 1) > 0]
|
|
160
|
+
if weights and len(selected) > 1:
|
|
161
|
+
streams, counts = [], []
|
|
162
|
+
for index, language in enumerate(selected):
|
|
163
|
+
count, query = _selection(db, config, [language], f"selected_{index}")
|
|
164
|
+
counts.append(count)
|
|
165
|
+
rng = random.Random(f"{config.generation.seed}:{language}")
|
|
166
|
+
streams.append(
|
|
167
|
+
_candidates(config, db, fonts, stats, count, query, rng, coverage, cancel)
|
|
168
|
+
)
|
|
169
|
+
if not any(counts):
|
|
170
|
+
raise ValueError(
|
|
171
|
+
"No processed records match the requested languages/genres; check source metadata"
|
|
172
|
+
)
|
|
173
|
+
candidates = _interleave(
|
|
174
|
+
streams,
|
|
175
|
+
_quotas(selected, weights, config.generation.samples),
|
|
176
|
+
config.generation.samples,
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
count, query = _selection(db, config, selected if weights else languages, "selected")
|
|
180
|
+
if not count:
|
|
181
|
+
raise ValueError(
|
|
182
|
+
"No processed records match the requested languages/genres; check source metadata"
|
|
183
|
+
)
|
|
184
|
+
rng = random.Random(config.generation.seed)
|
|
185
|
+
candidates = _candidates(config, db, fonts, stats, count, query, rng, coverage, cancel)
|
|
186
|
+
for submitted, (row, face, variant) in enumerate(candidates):
|
|
187
|
+
text_id, text, source, line, genre, language, attribution = row
|
|
188
|
+
sample_id = f"{submitted:08d}"
|
|
189
|
+
seed = int.from_bytes(
|
|
190
|
+
hashlib.sha256(f"{config.generation.seed}:{text_id}:{variant}".encode()).digest()[:8]
|
|
191
|
+
)
|
|
192
|
+
yield {
|
|
193
|
+
"id": sample_id,
|
|
194
|
+
"text_id": text_id,
|
|
195
|
+
"text": text,
|
|
196
|
+
"source": source,
|
|
197
|
+
"line": line,
|
|
198
|
+
"genre": genre,
|
|
199
|
+
"language": language,
|
|
200
|
+
"face": face,
|
|
201
|
+
"font_name": _font_name(config, face),
|
|
202
|
+
"font_id": font_ids[face],
|
|
203
|
+
"attribution": json.loads(attribution),
|
|
204
|
+
"seed": seed,
|
|
205
|
+
"destination": str(output / "images" / f"{sample_id}.{config.generation.format}"),
|
|
206
|
+
}
|
|
207
|
+
# Stop before drawing another candidate, which could count unsupported text.
|
|
208
|
+
if submitted + 1 == config.generation.samples:
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def generate(
|
|
213
|
+
config: Config,
|
|
214
|
+
*,
|
|
215
|
+
progress: Callable[[dict], None] | None = None,
|
|
216
|
+
cancel: Callable[[], bool] | None = None,
|
|
217
|
+
) -> dict:
|
|
218
|
+
config.validate()
|
|
219
|
+
if config.generation.suite == "kurdish-spelling":
|
|
220
|
+
with tempfile.TemporaryDirectory(prefix="synthocr-spelling-") as directory:
|
|
221
|
+
raw, processed = Path(directory) / "raw", Path(directory) / "processed"
|
|
222
|
+
write_spelling(raw / "spelling.jsonl")
|
|
223
|
+
return _generate(
|
|
224
|
+
replace(
|
|
225
|
+
config,
|
|
226
|
+
data=replace(
|
|
227
|
+
config.data,
|
|
228
|
+
raw=str(raw),
|
|
229
|
+
processed=str(processed),
|
|
230
|
+
source_languages={},
|
|
231
|
+
),
|
|
232
|
+
),
|
|
233
|
+
progress=progress,
|
|
234
|
+
cancel=cancel,
|
|
235
|
+
)
|
|
236
|
+
return _generate(config, progress=progress, cancel=cancel)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _generate(config: Config, *, progress=None, cancel=None) -> dict:
|
|
240
|
+
config.validate()
|
|
241
|
+
_check_cancel(cancel)
|
|
242
|
+
if progress:
|
|
243
|
+
progress({"stage": "preparing", "generated": 0, "requested": config.generation.samples})
|
|
244
|
+
require_shaping()
|
|
245
|
+
prepared = prepare(config)
|
|
246
|
+
_check_cancel(cancel)
|
|
247
|
+
fonts = discover([config.path(p) for p in config.fonts.directories])
|
|
248
|
+
face_ids = font_ids(fonts)
|
|
249
|
+
fonts = select(fonts, config.fonts.include, face_ids)
|
|
250
|
+
_check_cancel(cancel)
|
|
251
|
+
output = config.path(config.generation.output)
|
|
252
|
+
if output.exists() and any(output.iterdir()):
|
|
253
|
+
raise ValueError(f"Output directory must be empty: {output}. Choose a new --output.")
|
|
254
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
(output / "images").mkdir()
|
|
256
|
+
started = time.perf_counter()
|
|
257
|
+
stats = {
|
|
258
|
+
"requested": config.generation.samples,
|
|
259
|
+
"generated": 0,
|
|
260
|
+
"fonts": len(fonts),
|
|
261
|
+
"unsupported_texts": 0,
|
|
262
|
+
"unsupported_examples": [],
|
|
263
|
+
"status": "running",
|
|
264
|
+
}
|
|
265
|
+
run = {
|
|
266
|
+
"schema_version": 3,
|
|
267
|
+
"version": __version__,
|
|
268
|
+
"python": platform.python_version(),
|
|
269
|
+
"config": {
|
|
270
|
+
"data": asdict(config.data),
|
|
271
|
+
"fonts": asdict(config.fonts),
|
|
272
|
+
"generation": asdict(config.generation),
|
|
273
|
+
},
|
|
274
|
+
"corpus_fingerprint": prepared["fingerprint"],
|
|
275
|
+
"corpus_sources": prepared["sources"],
|
|
276
|
+
"font_files": [],
|
|
277
|
+
}
|
|
278
|
+
for face in fonts:
|
|
279
|
+
digest = face_ids[face].rsplit(":", 1)[0]
|
|
280
|
+
run["font_files"].append(
|
|
281
|
+
{
|
|
282
|
+
"font": _font_name(config, face),
|
|
283
|
+
"face_index": face.index,
|
|
284
|
+
"sha256": digest,
|
|
285
|
+
"id": face_ids[face],
|
|
286
|
+
}
|
|
287
|
+
)
|
|
288
|
+
(output / "run.json").write_text(
|
|
289
|
+
json.dumps(run, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
|
290
|
+
)
|
|
291
|
+
db_path = config.path(config.data.processed) / "texts.sqlite3"
|
|
292
|
+
db = sqlite3.connect(db_path.as_uri() + "?mode=ro", uri=True)
|
|
293
|
+
db.execute("PRAGMA cache_size=-4096")
|
|
294
|
+
db.execute("PRAGMA temp_store=FILE")
|
|
295
|
+
db.execute("CREATE TEMP TABLE emitted_origins (text_id INTEGER PRIMARY KEY)")
|
|
296
|
+
executor = None
|
|
297
|
+
try:
|
|
298
|
+
actual_fingerprint = db.execute("SELECT fingerprint FROM metadata").fetchone()[0]
|
|
299
|
+
if actual_fingerprint != prepared["fingerprint"]:
|
|
300
|
+
raise ValueError(
|
|
301
|
+
"Processed corpus changed before generation; retry with a stable corpus"
|
|
302
|
+
)
|
|
303
|
+
if progress:
|
|
304
|
+
progress({"stage": "generating", "generated": 0, "requested": stats["requested"]})
|
|
305
|
+
tasks = _tasks(config, db, fonts, output, stats, face_ids, cancel)
|
|
306
|
+
if config.generation.workers == 1:
|
|
307
|
+
initialize(config.generation, config.fonts.cache_size)
|
|
308
|
+
results = map(render_task, tasks)
|
|
309
|
+
else:
|
|
310
|
+
# Spawn avoids unsafe forking of native font engines on macOS.
|
|
311
|
+
executor = ProcessPoolExecutor(
|
|
312
|
+
max_workers=config.generation.workers,
|
|
313
|
+
mp_context=multiprocessing.get_context("spawn"),
|
|
314
|
+
initializer=initialize,
|
|
315
|
+
initargs=(config.generation, config.fonts.cache_size),
|
|
316
|
+
)
|
|
317
|
+
results = executor.map(
|
|
318
|
+
render_task, tasks, chunksize=1, buffersize=2 * config.generation.workers
|
|
319
|
+
)
|
|
320
|
+
with (
|
|
321
|
+
(output / "manifest.jsonl").open("w", encoding="utf-8") as manifest,
|
|
322
|
+
(output / "origins.jsonl").open("w", encoding="utf-8") as origins,
|
|
323
|
+
):
|
|
324
|
+
for record in results:
|
|
325
|
+
_check_cancel(cancel)
|
|
326
|
+
manifest.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
327
|
+
inserted = db.execute(
|
|
328
|
+
"INSERT OR IGNORE INTO emitted_origins VALUES(?)", (record["text_id"],)
|
|
329
|
+
).rowcount
|
|
330
|
+
if inserted:
|
|
331
|
+
for origin in iter_origins(db, record["text_id"]):
|
|
332
|
+
origin["text_sha256"] = hashlib.sha256(record["text"].encode()).hexdigest()
|
|
333
|
+
origins.write(json.dumps(origin, ensure_ascii=False) + "\n")
|
|
334
|
+
stats["generated"] += 1
|
|
335
|
+
if progress:
|
|
336
|
+
progress(
|
|
337
|
+
{
|
|
338
|
+
"stage": "generating",
|
|
339
|
+
"generated": stats["generated"],
|
|
340
|
+
"requested": stats["requested"],
|
|
341
|
+
}
|
|
342
|
+
)
|
|
343
|
+
if stats["generated"] % 1000 == 0:
|
|
344
|
+
LOG.info("Generated %s/%s images", stats["generated"], stats["requested"])
|
|
345
|
+
_check_cancel(cancel)
|
|
346
|
+
if stats["generated"] != stats["requested"]:
|
|
347
|
+
raise ValueError(
|
|
348
|
+
f"Only {stats['generated']}/{stats['requested']} unique supported "
|
|
349
|
+
"text/font pairs are available. Add text or fonts, or request fewer samples."
|
|
350
|
+
)
|
|
351
|
+
stats["status"] = "complete"
|
|
352
|
+
except BaseException as error:
|
|
353
|
+
stats["status"] = "cancelled" if isinstance(error, GenerationCancelled) else "failed"
|
|
354
|
+
stats["error"] = str(error)
|
|
355
|
+
raise
|
|
356
|
+
finally:
|
|
357
|
+
if executor:
|
|
358
|
+
executor.shutdown(wait=True, cancel_futures=True)
|
|
359
|
+
else:
|
|
360
|
+
release()
|
|
361
|
+
db.close()
|
|
362
|
+
stats["seconds"] = time.perf_counter() - started
|
|
363
|
+
stats["images_per_second"] = stats["generated"] / max(stats["seconds"], 1e-9)
|
|
364
|
+
stats["run_sha256"] = sha256(output / "run.json")
|
|
365
|
+
if (output / "manifest.jsonl").exists():
|
|
366
|
+
stats["manifest_sha256"] = sha256(output / "manifest.jsonl")
|
|
367
|
+
if (output / "origins.jsonl").exists():
|
|
368
|
+
stats["origins_sha256"] = sha256(output / "origins.jsonl")
|
|
369
|
+
(output / "stats.json").write_text(
|
|
370
|
+
json.dumps(stats, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
|
371
|
+
)
|
|
372
|
+
return stats | {"output": str(output)}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def export(dataset: Path, output: Path, format: str, *, max_bytes: int | None = None) -> dict:
|
|
376
|
+
"""Publish an attributed view atomically, optionally limiting apparent bytes.
|
|
377
|
+
|
|
378
|
+
Hardlinked images count toward the limit just like copied images. This
|
|
379
|
+
bounds temporary exports as well as their eventual downloadable content.
|
|
380
|
+
"""
|
|
381
|
+
from .verify import dataset_files
|
|
382
|
+
|
|
383
|
+
if format not in {"csv", "imagefolder", "tesseract"}:
|
|
384
|
+
raise ValueError("Export format must be csv, imagefolder or tesseract")
|
|
385
|
+
if max_bytes is not None and (type(max_bytes) is not int or max_bytes < 0):
|
|
386
|
+
raise ValueError("max_bytes must be a nonnegative integer or None")
|
|
387
|
+
dataset = dataset.resolve()
|
|
388
|
+
output = output.resolve()
|
|
389
|
+
if output.exists() and any(output.iterdir()):
|
|
390
|
+
raise ValueError(f"Export destination must be empty: {output}")
|
|
391
|
+
consumed = 0
|
|
392
|
+
|
|
393
|
+
def check(size):
|
|
394
|
+
if max_bytes is not None and consumed + size > max_bytes:
|
|
395
|
+
raise ValueError(f"Export exceeds the configured byte limit ({max_bytes} bytes)")
|
|
396
|
+
|
|
397
|
+
def charge(size):
|
|
398
|
+
nonlocal consumed
|
|
399
|
+
check(size)
|
|
400
|
+
consumed += size
|
|
401
|
+
|
|
402
|
+
def write_text(path, value):
|
|
403
|
+
if max_bytes is not None:
|
|
404
|
+
charge(len(value.encode("utf-8")))
|
|
405
|
+
path.write_text(value, encoding="utf-8", newline="")
|
|
406
|
+
|
|
407
|
+
def copy_file(source, destination):
|
|
408
|
+
if max_bytes is None:
|
|
409
|
+
with source.open("rb") as src, destination.open("xb") as dst:
|
|
410
|
+
shutil.copyfileobj(src, dst, 1024 * 1024)
|
|
411
|
+
return
|
|
412
|
+
check(source.stat().st_size)
|
|
413
|
+
with source.open("rb") as src, destination.open("xb") as dst:
|
|
414
|
+
while value := src.read(min(1024 * 1024, max_bytes - consumed + 1)):
|
|
415
|
+
charge(len(value))
|
|
416
|
+
dst.write(value)
|
|
417
|
+
|
|
418
|
+
class Labels:
|
|
419
|
+
def __init__(self, stream):
|
|
420
|
+
self.stream = stream
|
|
421
|
+
|
|
422
|
+
def write(self, value):
|
|
423
|
+
if max_bytes is not None:
|
|
424
|
+
charge(len(value.encode("utf-8")))
|
|
425
|
+
return self.stream.write(value)
|
|
426
|
+
|
|
427
|
+
if max_bytes is not None:
|
|
428
|
+
minimum = 0
|
|
429
|
+
for path in dataset_files(dataset / "images"):
|
|
430
|
+
minimum += path.stat().st_size
|
|
431
|
+
check(minimum)
|
|
432
|
+
origins = dataset / "origins.jsonl"
|
|
433
|
+
if origins.is_symlink():
|
|
434
|
+
raise ValueError("Dataset artifacts must not be symbolic links")
|
|
435
|
+
if origins.is_file():
|
|
436
|
+
check(minimum + origins.stat().st_size)
|
|
437
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
438
|
+
count = 0
|
|
439
|
+
with tempfile.TemporaryDirectory(prefix=".export-", dir=output.parent) as directory:
|
|
440
|
+
temporary = Path(directory)
|
|
441
|
+
metadata_name = {
|
|
442
|
+
"csv": "labels.csv",
|
|
443
|
+
"imagefolder": "metadata.jsonl",
|
|
444
|
+
"tesseract": "attribution.jsonl",
|
|
445
|
+
}[format]
|
|
446
|
+
with (temporary / metadata_name).open("x", encoding="utf-8", newline="") as stream:
|
|
447
|
+
labels = Labels(stream)
|
|
448
|
+
writer = csv.DictWriter(
|
|
449
|
+
labels,
|
|
450
|
+
fieldnames=[
|
|
451
|
+
"image",
|
|
452
|
+
"text",
|
|
453
|
+
"text_id",
|
|
454
|
+
"split",
|
|
455
|
+
"language",
|
|
456
|
+
"genre",
|
|
457
|
+
"attribution",
|
|
458
|
+
],
|
|
459
|
+
)
|
|
460
|
+
if format == "csv":
|
|
461
|
+
writer.writeheader()
|
|
462
|
+
for record, path in verified_records(dataset):
|
|
463
|
+
destination = temporary / f"{record['id']}{path.suffix}"
|
|
464
|
+
size = path.stat().st_size
|
|
465
|
+
check(size)
|
|
466
|
+
try:
|
|
467
|
+
os.link(path, destination)
|
|
468
|
+
except OSError:
|
|
469
|
+
copy_file(path, destination)
|
|
470
|
+
else:
|
|
471
|
+
charge(size)
|
|
472
|
+
if format == "csv":
|
|
473
|
+
writer.writerow(
|
|
474
|
+
{
|
|
475
|
+
"image": destination.name,
|
|
476
|
+
"text": record["text"],
|
|
477
|
+
"text_id": record["text_id"],
|
|
478
|
+
"split": record["split"],
|
|
479
|
+
"language": record["language"],
|
|
480
|
+
"genre": record["genre"],
|
|
481
|
+
"attribution": json.dumps(record["attribution"], ensure_ascii=False),
|
|
482
|
+
}
|
|
483
|
+
)
|
|
484
|
+
else:
|
|
485
|
+
metadata = {k: v for k, v in record.items() if k != "image"}
|
|
486
|
+
metadata["file_name"] = destination.name
|
|
487
|
+
labels.write(json.dumps(metadata, ensure_ascii=False) + "\n")
|
|
488
|
+
if format == "tesseract":
|
|
489
|
+
write_text(destination.with_suffix(".gt.txt"), record["text"])
|
|
490
|
+
count += 1
|
|
491
|
+
provenance = {}
|
|
492
|
+
if (dataset / "origins.jsonl").is_file():
|
|
493
|
+
copy_file(dataset / "origins.jsonl", temporary / "origins.jsonl")
|
|
494
|
+
provenance["origins_sha256"] = sha256(temporary / "origins.jsonl")
|
|
495
|
+
write_text(
|
|
496
|
+
temporary / "export.json",
|
|
497
|
+
json.dumps(
|
|
498
|
+
{
|
|
499
|
+
"status": "complete",
|
|
500
|
+
"records": count,
|
|
501
|
+
"format": format,
|
|
502
|
+
"source_manifest_sha256": sha256(dataset / "manifest.jsonl"),
|
|
503
|
+
**provenance,
|
|
504
|
+
},
|
|
505
|
+
indent=2,
|
|
506
|
+
)
|
|
507
|
+
+ "\n",
|
|
508
|
+
)
|
|
509
|
+
if output.exists():
|
|
510
|
+
output.rmdir() # Refuses to remove anything if another writer added content.
|
|
511
|
+
os.rename(temporary, output)
|
|
512
|
+
return {"records": count, "format": format, "output": str(output)}
|
synthocr/preview.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""A reproducible multilingual showcase rendered with the dataset engine."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from PIL import Image, ImageDraw
|
|
6
|
+
|
|
7
|
+
from .config import Config, Generation
|
|
8
|
+
from .fonts import Coverage, discover
|
|
9
|
+
from .render import Renderer
|
|
10
|
+
|
|
11
|
+
SAMPLES = (
|
|
12
|
+
("English", "en", "notosans", "Make text. Train any model."),
|
|
13
|
+
("Kurdish", "ckb", "notonaskharabic", "سڵاو جیهان، زانیاری بۆ هەمووان"),
|
|
14
|
+
("Arabic", "ar", "amiri", "بيانات واضحة، نماذج أفضل"),
|
|
15
|
+
("Persian", "fa", "notosansarabic", "دادههای روشن برای همه"),
|
|
16
|
+
("Hebrew", "he", "notosanshebrew", "מילים קטנות, אפשרויות גדולות"),
|
|
17
|
+
("Urdu", "ur", "notonastaliqurdu", "کتب خانہ سب کے لیے کھلا ہے"),
|
|
18
|
+
("Hindi", "hi", "notosansdevanagari", "हर शब्द एक नई संभावना है"),
|
|
19
|
+
("Bengali", "bn", "notosansbengali", "সবার জন্য নতুন সম্ভাবনা"),
|
|
20
|
+
("Chinese", "zh", "notosanssc", "每一种文字,都有新的可能"),
|
|
21
|
+
("Japanese", "ja", "notosansjp", "ことばから、新しい可能性へ"),
|
|
22
|
+
("Korean", "ko", "notosanskr", "모든 글자에 새로운 가능성"),
|
|
23
|
+
("Thai", "th", "notosansthai", "ทุกตัวอักษรสร้างโอกาสใหม่"),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def preview(config: Config, destination: Path) -> dict:
|
|
28
|
+
"""Use only fonts accompanied by OFL.txt; refuse to overwrite an existing file."""
|
|
29
|
+
if destination.exists():
|
|
30
|
+
raise ValueError(f"Preview already exists; choose a new output: {destination}")
|
|
31
|
+
fonts = [
|
|
32
|
+
font
|
|
33
|
+
for font in discover([config.path(p) for p in config.fonts.directories])
|
|
34
|
+
if Path(font.path).with_name("OFL.txt").is_file()
|
|
35
|
+
]
|
|
36
|
+
if not fonts:
|
|
37
|
+
raise ValueError("Preview requires fonts accompanied by OFL.txt; run synthocr fonts fetch")
|
|
38
|
+
coverage = Coverage(config.fonts.coverage_cache_size)
|
|
39
|
+
renderer = Renderer(
|
|
40
|
+
Generation(
|
|
41
|
+
font_size=44,
|
|
42
|
+
padding=8,
|
|
43
|
+
max_width=686,
|
|
44
|
+
max_height=84,
|
|
45
|
+
max_pixels=686 * 84,
|
|
46
|
+
augmentation=0.35,
|
|
47
|
+
),
|
|
48
|
+
config.fonts.cache_size,
|
|
49
|
+
)
|
|
50
|
+
preferred = sorted(fonts, key=lambda font: Path(font.path).parent.name != "notosans")
|
|
51
|
+
ui = coverage.choose(preferred, "SynthOCR / Every writing system. Any recognition model.", 0)
|
|
52
|
+
if ui is None:
|
|
53
|
+
raise ValueError("Preview requires a licensed font with Latin glyphs")
|
|
54
|
+
title = renderer.font(ui, 86)
|
|
55
|
+
subtitle = renderer.font(ui, 28)
|
|
56
|
+
label = renderer.font(ui, 17)
|
|
57
|
+
small = renderer.font(ui, 14)
|
|
58
|
+
image = Image.new("RGB", (1600, 1260), "#eef3f5")
|
|
59
|
+
try:
|
|
60
|
+
draw = ImageDraw.Draw(image)
|
|
61
|
+
draw.rectangle((0, 0, 1600, 252), fill="#10282e")
|
|
62
|
+
draw.polygon(((1270, 0), (1600, 0), (1600, 252), (1435, 252)), fill="#163b40")
|
|
63
|
+
draw.rectangle((56, 48, 62, 128), fill="#74e2c1")
|
|
64
|
+
draw.text((84, 25), "SynthOCR", font=title, fill="#f5faf9")
|
|
65
|
+
draw.text(
|
|
66
|
+
(60, 139),
|
|
67
|
+
"Every writing system. Any recognition model.",
|
|
68
|
+
font=subtitle,
|
|
69
|
+
fill="#c7ddda",
|
|
70
|
+
)
|
|
71
|
+
x = 60
|
|
72
|
+
for text in ("12 LANGUAGES", "BUILT-IN FONT CHECKS", "MODEL-NEUTRAL EXPORTS"):
|
|
73
|
+
width = int(draw.textlength(text, font=small)) + 30
|
|
74
|
+
draw.rounded_rectangle((x, 201, x + width, 232), radius=15, fill="#24464b")
|
|
75
|
+
draw.text((x + 15, 205), text, font=small, fill="#95e7cf")
|
|
76
|
+
x += width + 12
|
|
77
|
+
for index, (name, language, family, text) in enumerate(SAMPLES):
|
|
78
|
+
choices = sorted(fonts, key=lambda font: Path(font.path).parent.name != family)
|
|
79
|
+
face = coverage.choose(choices, text, 0)
|
|
80
|
+
if face is None:
|
|
81
|
+
raise ValueError(f"No licensed font covers the {name} preview; fetch catalog fonts")
|
|
82
|
+
x, y = 56 + (index % 2) * 754, 280 + (index // 2) * 152
|
|
83
|
+
draw.rounded_rectangle((x, y + 3, x + 734, y + 139), radius=16, fill="#dfe8eb")
|
|
84
|
+
draw.rounded_rectangle((x, y, x + 734, y + 136), radius=16, fill="white")
|
|
85
|
+
draw.text(
|
|
86
|
+
(x + 24, y + 13), f"{name.upper()} / {language}", font=label, fill="#29605d"
|
|
87
|
+
)
|
|
88
|
+
draw.text((x + 691, y + 15), f"{index + 1:02}", font=small, fill="#77918e")
|
|
89
|
+
sample, _ = renderer.render(text, face, 17 + index, language)
|
|
90
|
+
try:
|
|
91
|
+
image.paste(sample, (x + 24, y + 43 + (84 - sample.height) // 2))
|
|
92
|
+
finally:
|
|
93
|
+
sample.close()
|
|
94
|
+
draw.text(
|
|
95
|
+
(60, 1207),
|
|
96
|
+
"Real SynthOCR renders / RAQM shaping / OFL fonts / Authored sample text",
|
|
97
|
+
font=label,
|
|
98
|
+
fill="#456764",
|
|
99
|
+
)
|
|
100
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
# Exclusive creation also protects against a destination created during rendering.
|
|
102
|
+
with destination.open("xb") as stream:
|
|
103
|
+
image.save(stream, format="PNG", optimize=True)
|
|
104
|
+
return {
|
|
105
|
+
"output": str(destination),
|
|
106
|
+
"width": image.width,
|
|
107
|
+
"height": image.height,
|
|
108
|
+
"samples": len(SAMPLES),
|
|
109
|
+
"languages": [sample[1] for sample in SAMPLES],
|
|
110
|
+
}
|
|
111
|
+
finally:
|
|
112
|
+
image.close()
|