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/verify.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Stream integrity checks over a completed dataset using disk-backed uniqueness indexes."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from PIL import Image
|
|
11
|
+
|
|
12
|
+
from .corpus_store import canonical_language
|
|
13
|
+
from .downloads import sha256
|
|
14
|
+
from .text import required_codepoints
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def dataset_files(root: Path, *, _depth: int = 0):
|
|
18
|
+
"""Walk generated files without collecting a directory's entries in memory."""
|
|
19
|
+
if _depth > 32 or root.is_symlink():
|
|
20
|
+
raise ValueError("Dataset directories must not contain symbolic links or excessive nesting")
|
|
21
|
+
with os.scandir(root) as entries:
|
|
22
|
+
for entry in entries:
|
|
23
|
+
path = Path(entry.path)
|
|
24
|
+
if entry.is_symlink():
|
|
25
|
+
raise ValueError("Dataset files must not be symbolic links")
|
|
26
|
+
if entry.is_dir(follow_symlinks=False):
|
|
27
|
+
yield from dataset_files(path, _depth=_depth + 1)
|
|
28
|
+
elif entry.is_file(follow_symlinks=False):
|
|
29
|
+
yield path
|
|
30
|
+
else:
|
|
31
|
+
raise ValueError("Dataset entries must be ordinary files or directories")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _origin_signature(source, line, genre, attribution):
|
|
35
|
+
return hashlib.sha256(
|
|
36
|
+
json.dumps([source, line, genre, attribution], ensure_ascii=False, sort_keys=True).encode()
|
|
37
|
+
).digest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def verified_records(dataset: Path, check_images: bool = True):
|
|
41
|
+
dataset = dataset.resolve()
|
|
42
|
+
if any(
|
|
43
|
+
(dataset / name).is_symlink()
|
|
44
|
+
for name in ("manifest.jsonl", "run.json", "stats.json", "origins.jsonl")
|
|
45
|
+
):
|
|
46
|
+
raise ValueError("Dataset artifacts must not be symbolic links")
|
|
47
|
+
stats = json.loads((dataset / "stats.json").read_text(encoding="utf-8"))
|
|
48
|
+
if stats.get("status") != "complete" or stats.get("generated") != stats.get("requested"):
|
|
49
|
+
raise ValueError("Cannot verify or export an incomplete dataset")
|
|
50
|
+
for name, key in (("manifest.jsonl", "manifest_sha256"), ("run.json", "run_sha256")):
|
|
51
|
+
if not stats.get(key):
|
|
52
|
+
raise ValueError(
|
|
53
|
+
"Dataset predates integrity metadata; regenerate it before verification"
|
|
54
|
+
)
|
|
55
|
+
if sha256(dataset / name) != stats[key]:
|
|
56
|
+
raise ValueError(f"Dataset checksum mismatch: {name}")
|
|
57
|
+
run = json.loads((dataset / "run.json").read_text(encoding="utf-8"))
|
|
58
|
+
selected_languages = run["config"]["generation"].get("languages", [])
|
|
59
|
+
if not isinstance(selected_languages, list) or any(
|
|
60
|
+
not isinstance(language, str) or not language for language in selected_languages
|
|
61
|
+
):
|
|
62
|
+
raise ValueError("Invalid language selection in run metadata")
|
|
63
|
+
selected_languages = {canonical_language(language) for language in selected_languages}
|
|
64
|
+
excluded_genres = run["config"]["generation"].get("exclude_genres", [])
|
|
65
|
+
if not isinstance(excluded_genres, list) or any(
|
|
66
|
+
not isinstance(genre, str) or not genre for genre in excluded_genres
|
|
67
|
+
):
|
|
68
|
+
raise ValueError("Invalid genre exclusion in run metadata")
|
|
69
|
+
inventory = {font["id"]: font for font in run["font_files"]}
|
|
70
|
+
count = 0
|
|
71
|
+
with tempfile.TemporaryDirectory(prefix="synthocr-verify-") as directory:
|
|
72
|
+
db = sqlite3.connect(Path(directory) / "seen.sqlite3")
|
|
73
|
+
try:
|
|
74
|
+
db.execute("PRAGMA cache_size=-4096")
|
|
75
|
+
db.execute("PRAGMA temp_store=FILE")
|
|
76
|
+
db.execute("CREATE TABLE seen(id TEXT PRIMARY KEY, image TEXT UNIQUE)")
|
|
77
|
+
db.execute("CREATE TABLE labels(text TEXT PRIMARY KEY, split TEXT)")
|
|
78
|
+
db.execute(
|
|
79
|
+
"CREATE TABLE pairs(text TEXT, font TEXT, language TEXT, "
|
|
80
|
+
"PRIMARY KEY(text,font,language))"
|
|
81
|
+
)
|
|
82
|
+
provenance = run.get("schema_version", 2) >= 3
|
|
83
|
+
if provenance:
|
|
84
|
+
if not stats.get("origins_sha256") or not (dataset / "origins.jsonl").is_file():
|
|
85
|
+
raise ValueError("Missing origins provenance metadata")
|
|
86
|
+
if sha256(dataset / "origins.jsonl") != stats["origins_sha256"]:
|
|
87
|
+
raise ValueError("Dataset checksum mismatch: origins.jsonl")
|
|
88
|
+
db.execute(
|
|
89
|
+
"CREATE TABLE origins(text_id INTEGER PRIMARY KEY, language TEXT, "
|
|
90
|
+
"text_sha256 TEXT, used INTEGER DEFAULT 0)"
|
|
91
|
+
)
|
|
92
|
+
db.execute(
|
|
93
|
+
"CREATE TABLE origin_identities(text_id INTEGER, signature BLOB, "
|
|
94
|
+
"PRIMARY KEY(text_id,signature))"
|
|
95
|
+
)
|
|
96
|
+
with (dataset / "origins.jsonl").open(encoding="utf-8") as stream:
|
|
97
|
+
for line in stream:
|
|
98
|
+
origin = json.loads(line)
|
|
99
|
+
if not isinstance(origin, dict):
|
|
100
|
+
raise ValueError("Invalid corpus origin: expected an object")
|
|
101
|
+
text_id = origin.get("text_id")
|
|
102
|
+
if (
|
|
103
|
+
type(text_id) is not int
|
|
104
|
+
or text_id <= 0
|
|
105
|
+
or not isinstance(origin.get("source"), str)
|
|
106
|
+
or not origin["source"]
|
|
107
|
+
or type(origin.get("line")) is not int
|
|
108
|
+
or origin["line"] <= 0
|
|
109
|
+
or not isinstance(origin.get("genre"), str)
|
|
110
|
+
or not isinstance(origin.get("source_language"), str)
|
|
111
|
+
or not isinstance(origin.get("attribution"), dict)
|
|
112
|
+
or not isinstance(origin.get("text_sha256"), str)
|
|
113
|
+
or len(origin["text_sha256"]) != 64
|
|
114
|
+
):
|
|
115
|
+
raise ValueError("Invalid corpus origin")
|
|
116
|
+
language = canonical_language(origin["source_language"])
|
|
117
|
+
db.execute(
|
|
118
|
+
"INSERT OR IGNORE INTO origins(text_id,language,text_sha256) "
|
|
119
|
+
"VALUES(?,?,?)",
|
|
120
|
+
(text_id, language, origin["text_sha256"]),
|
|
121
|
+
)
|
|
122
|
+
if db.execute(
|
|
123
|
+
"SELECT language,text_sha256 FROM origins WHERE text_id=?", (text_id,)
|
|
124
|
+
).fetchone() != (language, origin["text_sha256"]):
|
|
125
|
+
raise ValueError("Corpus origin languages disagree")
|
|
126
|
+
db.execute(
|
|
127
|
+
"INSERT OR IGNORE INTO origin_identities VALUES(?,?)",
|
|
128
|
+
(
|
|
129
|
+
text_id,
|
|
130
|
+
_origin_signature(
|
|
131
|
+
origin["source"],
|
|
132
|
+
origin["line"],
|
|
133
|
+
origin["genre"],
|
|
134
|
+
origin["attribution"],
|
|
135
|
+
),
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
if db.total_changes % 10000 == 0:
|
|
139
|
+
db.commit()
|
|
140
|
+
with (dataset / "manifest.jsonl").open(encoding="utf-8") as stream:
|
|
141
|
+
for line in stream:
|
|
142
|
+
record = json.loads(line)
|
|
143
|
+
if not isinstance(record, dict):
|
|
144
|
+
raise ValueError("Manifest records must be objects")
|
|
145
|
+
identifier = record.get("id")
|
|
146
|
+
if (
|
|
147
|
+
not isinstance(identifier, str)
|
|
148
|
+
or not identifier.isascii()
|
|
149
|
+
or not identifier.isdigit()
|
|
150
|
+
):
|
|
151
|
+
raise ValueError("Manifest IDs must be numeric ASCII strings")
|
|
152
|
+
relative = record.get("image")
|
|
153
|
+
if not isinstance(relative, str) or Path(relative).is_absolute():
|
|
154
|
+
raise ValueError(
|
|
155
|
+
"Manifest image must be a relative path within the dataset"
|
|
156
|
+
)
|
|
157
|
+
path = (dataset / relative).resolve()
|
|
158
|
+
if not path.is_relative_to(dataset / "images") or not path.is_file():
|
|
159
|
+
raise ValueError(
|
|
160
|
+
"Manifest image must be a file within the dataset images directory"
|
|
161
|
+
)
|
|
162
|
+
text, split = record.get("text"), record.get("split")
|
|
163
|
+
if not isinstance(text, str) or not required_codepoints(text):
|
|
164
|
+
raise ValueError("Manifest contains an empty or non-renderable label")
|
|
165
|
+
if split not in {"train", "validation", "test"}:
|
|
166
|
+
raise ValueError("Unknown manifest split")
|
|
167
|
+
language = record.get("language", "")
|
|
168
|
+
if not isinstance(language, str) or (
|
|
169
|
+
selected_languages
|
|
170
|
+
and canonical_language(language) not in selected_languages
|
|
171
|
+
):
|
|
172
|
+
raise ValueError("Manifest language does not match the requested languages")
|
|
173
|
+
if record.get("genre") in excluded_genres:
|
|
174
|
+
raise ValueError("Manifest contains an excluded genre")
|
|
175
|
+
if provenance:
|
|
176
|
+
text_id = record.get("text_id")
|
|
177
|
+
if type(text_id) is not int or text_id <= 0:
|
|
178
|
+
raise ValueError("Invalid manifest text identity")
|
|
179
|
+
origin = db.execute(
|
|
180
|
+
"SELECT language,text_sha256 FROM origins WHERE text_id=?", (text_id,)
|
|
181
|
+
).fetchone()
|
|
182
|
+
if origin != (
|
|
183
|
+
canonical_language(language),
|
|
184
|
+
hashlib.sha256(text.encode()).hexdigest(),
|
|
185
|
+
):
|
|
186
|
+
raise ValueError(
|
|
187
|
+
"Missing or inconsistent corpus origin for manifest text"
|
|
188
|
+
)
|
|
189
|
+
signature = _origin_signature(
|
|
190
|
+
record.get("source"),
|
|
191
|
+
record.get("source_line"),
|
|
192
|
+
record.get("genre"),
|
|
193
|
+
record.get("attribution"),
|
|
194
|
+
)
|
|
195
|
+
if not db.execute(
|
|
196
|
+
"SELECT 1 FROM origin_identities WHERE text_id=? AND signature=?",
|
|
197
|
+
(text_id, signature),
|
|
198
|
+
).fetchone():
|
|
199
|
+
raise ValueError("Manifest attribution does not match a corpus origin")
|
|
200
|
+
db.execute("UPDATE origins SET used=1 WHERE text_id=?", (text_id,))
|
|
201
|
+
font_id = record.get("font_id")
|
|
202
|
+
font = inventory.get(font_id)
|
|
203
|
+
if not font or (font["font"], font["face_index"]) != (
|
|
204
|
+
record.get("font"),
|
|
205
|
+
record.get("face_index"),
|
|
206
|
+
):
|
|
207
|
+
raise ValueError("Manifest font does not match run inventory")
|
|
208
|
+
try:
|
|
209
|
+
db.execute("INSERT INTO seen VALUES(?,?)", (identifier, str(path)))
|
|
210
|
+
if run["config"]["data"]["deduplicate"]:
|
|
211
|
+
db.execute(
|
|
212
|
+
"INSERT INTO pairs VALUES(?,?,?)",
|
|
213
|
+
(text, font_id, canonical_language(language) if provenance else ""),
|
|
214
|
+
)
|
|
215
|
+
except sqlite3.IntegrityError as error:
|
|
216
|
+
raise ValueError("Duplicate sample, image or text/font pair") from error
|
|
217
|
+
db.execute("INSERT OR IGNORE INTO labels VALUES(?,?)", (text, split))
|
|
218
|
+
expected = db.execute(
|
|
219
|
+
"SELECT split FROM labels WHERE text=?", (text,)
|
|
220
|
+
).fetchone()[0]
|
|
221
|
+
if split != expected:
|
|
222
|
+
raise ValueError("Text leaks across dataset splits")
|
|
223
|
+
width, height, bbox = (
|
|
224
|
+
record.get("width"),
|
|
225
|
+
record.get("height"),
|
|
226
|
+
record.get("bbox"),
|
|
227
|
+
)
|
|
228
|
+
if (
|
|
229
|
+
type(width) is not int
|
|
230
|
+
or type(height) is not int
|
|
231
|
+
or min(width, height) <= 0
|
|
232
|
+
or not isinstance(bbox, list)
|
|
233
|
+
or len(bbox) != 4
|
|
234
|
+
or any(type(v) is not int for v in bbox)
|
|
235
|
+
or not 0 <= bbox[0] < bbox[2] <= width
|
|
236
|
+
or not 0 <= bbox[1] < bbox[3] <= height
|
|
237
|
+
):
|
|
238
|
+
raise ValueError("Invalid image dimensions or text bounding rectangle")
|
|
239
|
+
limits = run["config"]["generation"]
|
|
240
|
+
if (
|
|
241
|
+
width > limits["max_width"]
|
|
242
|
+
or height > limits["max_height"]
|
|
243
|
+
or width * height > limits["max_pixels"]
|
|
244
|
+
):
|
|
245
|
+
raise ValueError("Image exceeds configured size limits")
|
|
246
|
+
if check_images:
|
|
247
|
+
if not record.get("image_sha256") or sha256(path) != record["image_sha256"]:
|
|
248
|
+
raise ValueError(f"Image checksum mismatch: {relative}")
|
|
249
|
+
try:
|
|
250
|
+
with Image.open(path) as image:
|
|
251
|
+
image.verify()
|
|
252
|
+
with Image.open(path) as image:
|
|
253
|
+
if image.size != (width, height):
|
|
254
|
+
raise ValueError("Image dimensions differ from the manifest")
|
|
255
|
+
expected = {
|
|
256
|
+
"png": "PNG",
|
|
257
|
+
"jpg": "JPEG",
|
|
258
|
+
"webp": "WEBP",
|
|
259
|
+
"tif": "TIFF",
|
|
260
|
+
}[limits["format"]]
|
|
261
|
+
if (
|
|
262
|
+
image.format != expected
|
|
263
|
+
or path.suffix != f".{limits['format']}"
|
|
264
|
+
):
|
|
265
|
+
raise ValueError(
|
|
266
|
+
"Image encoding differs from configured format"
|
|
267
|
+
)
|
|
268
|
+
image.load()
|
|
269
|
+
except (
|
|
270
|
+
OSError,
|
|
271
|
+
ValueError,
|
|
272
|
+
SyntaxError,
|
|
273
|
+
Image.DecompressionBombError,
|
|
274
|
+
) as error:
|
|
275
|
+
raise ValueError(f"Cannot decode image {relative}: {error}") from error
|
|
276
|
+
count += 1
|
|
277
|
+
if count % 10000 == 0:
|
|
278
|
+
db.commit()
|
|
279
|
+
yield record, path
|
|
280
|
+
if count != stats["generated"]:
|
|
281
|
+
raise ValueError(f"Manifest count mismatch: {count} != {stats['generated']}")
|
|
282
|
+
if provenance and db.execute("SELECT 1 FROM origins WHERE used=0 LIMIT 1").fetchone():
|
|
283
|
+
raise ValueError("Corpus origins contain unreferenced text identities")
|
|
284
|
+
image_count = sum(1 for p in dataset_files(dataset / "images") if p.name != ".DS_Store")
|
|
285
|
+
if image_count != count:
|
|
286
|
+
raise ValueError("Dataset contains unlisted images or duplicate image references")
|
|
287
|
+
finally:
|
|
288
|
+
db.close()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def verify(dataset: Path, check_images: bool = True) -> dict:
|
|
292
|
+
count = 0
|
|
293
|
+
splits: dict[str, int] = {}
|
|
294
|
+
languages: dict[str, int] = {}
|
|
295
|
+
for record, _ in verified_records(dataset, check_images):
|
|
296
|
+
count += 1
|
|
297
|
+
splits[record["split"]] = splits.get(record["split"], 0) + 1
|
|
298
|
+
language = record.get("language") or "unspecified"
|
|
299
|
+
languages[language] = languages.get(language, 0) + 1
|
|
300
|
+
return {
|
|
301
|
+
"status": "verified",
|
|
302
|
+
"records": count,
|
|
303
|
+
"images_checked": check_images,
|
|
304
|
+
"splits": splits,
|
|
305
|
+
"languages": languages,
|
|
306
|
+
"dataset": str(dataset.resolve()),
|
|
307
|
+
}
|