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