ocr-foundry 0.2.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.
- khocr_gen/__init__.py +11 -0
- khocr_gen/_pil_compat.py +25 -0
- khocr_gen/_rust_accel.py +204 -0
- khocr_gen/augmentation.py +1193 -0
- khocr_gen/benchmark.py +888 -0
- khocr_gen/benchmark_cmd.py +103 -0
- khocr_gen/cli.py +246 -0
- khocr_gen/combine.py +214 -0
- khocr_gen/combine_cmd.py +125 -0
- khocr_gen/config.py +1474 -0
- khocr_gen/config_loader.py +157 -0
- khocr_gen/corpus.py +621 -0
- khocr_gen/data_generator.py +1160 -0
- khocr_gen/errors.py +33 -0
- khocr_gen/evaluate_cmd.py +50 -0
- khocr_gen/evaluation.py +259 -0
- khocr_gen/fonts.py +316 -0
- khocr_gen/generate.py +227 -0
- khocr_gen/layout.py +1028 -0
- khocr_gen/layout_cmd.py +371 -0
- khocr_gen/layout_degradation.py +128 -0
- khocr_gen/layout_mesh.py +200 -0
- khocr_gen/layout_shaping.py +264 -0
- khocr_gen/layout_transform.py +238 -0
- khocr_gen/layout_warp.py +473 -0
- khocr_gen/line_height.py +127 -0
- khocr_gen/lmdb_pack.py +132 -0
- khocr_gen/logging.py +25 -0
- khocr_gen/normalizer.py +221 -0
- khocr_gen/parallel.py +366 -0
- khocr_gen/provenance.py +328 -0
- khocr_gen/rendering.py +1607 -0
- khocr_gen/text_support.py +429 -0
- khocr_gen/verify.py +471 -0
- khocr_gen/viewer.py +172 -0
- ocr_foundry-0.2.0.dist-info/METADATA +174 -0
- ocr_foundry-0.2.0.dist-info/RECORD +40 -0
- ocr_foundry-0.2.0.dist-info/WHEEL +4 -0
- ocr_foundry-0.2.0.dist-info/entry_points.txt +3 -0
- ocr_foundry-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1160 @@
|
|
|
1
|
+
"""DatasetGenerator - orchestrates synthetic data generation.
|
|
2
|
+
|
|
3
|
+
Coordinates corpus loading, font selection, rendering with isolated augmentation, and parallel worker dispatch.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import random
|
|
13
|
+
import shutil
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
16
|
+
|
|
17
|
+
import cv2
|
|
18
|
+
from tqdm import tqdm
|
|
19
|
+
|
|
20
|
+
from .augmentation import write_output_image
|
|
21
|
+
from .corpus import (
|
|
22
|
+
CorpusItem,
|
|
23
|
+
char_frequencies,
|
|
24
|
+
load_corpus_with_stats,
|
|
25
|
+
rare_chars_from_frequencies,
|
|
26
|
+
)
|
|
27
|
+
from .errors import InputValidationError
|
|
28
|
+
from .fonts import FontManager
|
|
29
|
+
from .normalizer import NormalizerConfig, normalize
|
|
30
|
+
from .parallel import (
|
|
31
|
+
_init_render_worker,
|
|
32
|
+
_render_sample_batch,
|
|
33
|
+
derive_sample_seed,
|
|
34
|
+
ensure_worker_main_guard,
|
|
35
|
+
resolve_chunk_size,
|
|
36
|
+
resolve_mp_context,
|
|
37
|
+
resolve_worker_count,
|
|
38
|
+
seed_sample_generators,
|
|
39
|
+
)
|
|
40
|
+
from .provenance import build_sample_records, record_run_manifest
|
|
41
|
+
from .rendering import ImageRenderer
|
|
42
|
+
from .text_support import detect_script, line_separator_error
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from .config import GenerationConfig
|
|
46
|
+
|
|
47
|
+
_log = logging.getLogger("khocr_gen.generator")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DatasetGenerator:
|
|
51
|
+
"""Generate synthetic training datasets from a text corpus.
|
|
52
|
+
|
|
53
|
+
Each text line is rendered to a clean canvas,
|
|
54
|
+
then exactly one augmentation method is applied per output image (isolated augmentation).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, cfg: GenerationConfig) -> None:
|
|
58
|
+
self._cfg = cfg
|
|
59
|
+
self.font_manager = FontManager(
|
|
60
|
+
language=cfg.language,
|
|
61
|
+
fonts_dir=cfg.fonts_dir,
|
|
62
|
+
multilingual=cfg.multilingual,
|
|
63
|
+
font_pool=cfg.font_pool,
|
|
64
|
+
)
|
|
65
|
+
self.renderer = ImageRenderer(self.font_manager, cfg)
|
|
66
|
+
self._run_ingestion_stats: dict[str, Any] = {}
|
|
67
|
+
self._run_used_fonts: dict[str, set[str]] = {}
|
|
68
|
+
self._used_font_paths: set[str] = set()
|
|
69
|
+
|
|
70
|
+
# Helpers
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _reject_multilingual_line_separators(text_file: str | Path) -> None:
|
|
74
|
+
"""Fail closed on U+2028/U+2029 before strip() can hide them.
|
|
75
|
+
|
|
76
|
+
The check uses the raw file records. Leading or trailing separators are
|
|
77
|
+
Unicode whitespace, so rejecting only the stripped line would keep the
|
|
78
|
+
visible remainder and expand it into copies.
|
|
79
|
+
"""
|
|
80
|
+
rejected: list[str] = []
|
|
81
|
+
total = 0
|
|
82
|
+
with open(text_file, encoding="utf-8") as handle:
|
|
83
|
+
for raw in handle:
|
|
84
|
+
reason = line_separator_error(raw)
|
|
85
|
+
if reason is None and not raw.strip():
|
|
86
|
+
continue
|
|
87
|
+
total += 1
|
|
88
|
+
if reason is None:
|
|
89
|
+
continue
|
|
90
|
+
preview = raw.strip() or raw
|
|
91
|
+
rejected.append(f"{preview[:60]!r}: {reason}")
|
|
92
|
+
if rejected:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
"Multilingual corpus preflight rejected "
|
|
95
|
+
f"{len(rejected)} / {total} lines containing a line or paragraph separator; "
|
|
96
|
+
"U+2028 and U+2029 are not horizontal-line text. " + "; ".join(rejected[:5])
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _sanitize_label_text(text: str) -> str:
|
|
101
|
+
"""Normalize embedded tabs in label text (labels.txt uses tab as delimiter)."""
|
|
102
|
+
return text.replace("\t", " ")
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _read_non_empty_lines(
|
|
106
|
+
text_file: str | Path,
|
|
107
|
+
image_dir: str | None = None,
|
|
108
|
+
normalizer: NormalizerConfig | None = None,
|
|
109
|
+
stats: dict[str, int] | None = None,
|
|
110
|
+
) -> list[str | CorpusItem | tuple[str, str]]:
|
|
111
|
+
"""Read lines from *text_file*, optionally parsing (filename, text) pairs.
|
|
112
|
+
|
|
113
|
+
Auto-detects JSONL format by file extension, reconstituting
|
|
114
|
+
:class:`CorpusItem` instances with provenance metadata.
|
|
115
|
+
"""
|
|
116
|
+
from .corpus import _is_jsonl, _parse_jsonl_record
|
|
117
|
+
|
|
118
|
+
text_file = Path(text_file)
|
|
119
|
+
lines: list[str | CorpusItem | tuple[str, str]] = []
|
|
120
|
+
if stats is None:
|
|
121
|
+
stats = {}
|
|
122
|
+
stats.update(total=0, passing=0, blank=0, empty=0, malformed=0)
|
|
123
|
+
|
|
124
|
+
if _is_jsonl(text_file) and not image_dir:
|
|
125
|
+
with open(text_file, encoding="utf-8-sig") as f:
|
|
126
|
+
for line_number, raw in enumerate(f, start=1):
|
|
127
|
+
record = _parse_jsonl_record(raw, line_number, strict=True)
|
|
128
|
+
if record is None:
|
|
129
|
+
stats["blank"] += 1
|
|
130
|
+
continue
|
|
131
|
+
stats["total"] += 1
|
|
132
|
+
text = record["text"]
|
|
133
|
+
# Mirror load_corpus/_classify_raw_line: reject line
|
|
134
|
+
# separators before strip() hides leading/trailing ones,
|
|
135
|
+
# then skip blank/whitespace-only record text.
|
|
136
|
+
reason = line_separator_error(text)
|
|
137
|
+
if reason is not None:
|
|
138
|
+
raise InputValidationError(
|
|
139
|
+
f"JSONL line {line_number}: {reason}", code="jsonl_text_error"
|
|
140
|
+
)
|
|
141
|
+
text = text.strip()
|
|
142
|
+
if not text:
|
|
143
|
+
stats["empty"] += 1
|
|
144
|
+
continue
|
|
145
|
+
raw_val = record.get("raw_text", text)
|
|
146
|
+
if "normalization_policy" in record and record.get("normalization_policy") is not None:
|
|
147
|
+
norm_text = text
|
|
148
|
+
elif normalizer is not None:
|
|
149
|
+
norm_text = normalize(text, normalizer)
|
|
150
|
+
else:
|
|
151
|
+
norm_text = text
|
|
152
|
+
norm_text = DatasetGenerator._sanitize_label_text(norm_text)
|
|
153
|
+
if not norm_text:
|
|
154
|
+
stats["empty"] += 1
|
|
155
|
+
continue
|
|
156
|
+
item = CorpusItem(
|
|
157
|
+
norm_text,
|
|
158
|
+
source_id=record.get("source_id"),
|
|
159
|
+
source_group=record.get("source_group"),
|
|
160
|
+
language=record.get("language"),
|
|
161
|
+
script=record.get("script"),
|
|
162
|
+
domain=record.get("domain"),
|
|
163
|
+
source_uri=record.get("source_uri"),
|
|
164
|
+
license=record.get("license"),
|
|
165
|
+
retrieved_at=record.get("retrieved_at"),
|
|
166
|
+
source_sha256=record.get("source_sha256"),
|
|
167
|
+
normalization_policy=record.get("normalization_policy"),
|
|
168
|
+
consent_or_restrictions=record.get("consent_or_restrictions"),
|
|
169
|
+
raw_text=raw_val,
|
|
170
|
+
)
|
|
171
|
+
lines.append(item)
|
|
172
|
+
stats["passing"] += 1
|
|
173
|
+
return lines
|
|
174
|
+
|
|
175
|
+
with open(text_file, encoding="utf-8") as f:
|
|
176
|
+
for line in f:
|
|
177
|
+
line = line.strip()
|
|
178
|
+
if not line:
|
|
179
|
+
stats["blank"] += 1
|
|
180
|
+
continue
|
|
181
|
+
stats["total"] += 1
|
|
182
|
+
if image_dir:
|
|
183
|
+
parts = line.split("\t", 1)
|
|
184
|
+
if len(parts) == 2:
|
|
185
|
+
lines.append((parts[0], DatasetGenerator._sanitize_label_text(parts[1])))
|
|
186
|
+
stats["passing"] += 1
|
|
187
|
+
else:
|
|
188
|
+
stats["malformed"] += 1
|
|
189
|
+
else:
|
|
190
|
+
lines.append(DatasetGenerator._sanitize_label_text(line))
|
|
191
|
+
stats["passing"] += 1
|
|
192
|
+
return lines
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
def _split_lines_without_text_overlap(
|
|
196
|
+
lines: list, val_ratio: float = 0.1, test_ratio: float = 0.1, seed: int | None = 42
|
|
197
|
+
) -> tuple[list, list, list]:
|
|
198
|
+
"""Split *lines* into train/val/test keeping each group in exactly one split.
|
|
199
|
+
|
|
200
|
+
When items carry a ``source_group`` attribute (i.e. are
|
|
201
|
+
:class:`CorpusItem` instances loaded from a JSONL manifest), lines
|
|
202
|
+
are grouped by ``source_group`` so that all lines from the same
|
|
203
|
+
document/source stay together (**document-disjoint** splitting).
|
|
204
|
+
If no ``source_group`` is present, lines are grouped by their text
|
|
205
|
+
value (the existing **text-disjoint** behavior).
|
|
206
|
+
|
|
207
|
+
Returns ``(train_lines, val_lines, test_lines)`` where no group key
|
|
208
|
+
appears in more than one partition.
|
|
209
|
+
|
|
210
|
+
Resolution of effective ratios matches :meth:`GenerationConfig.resolve_split_ratios`:
|
|
211
|
+
pass ``test_ratio=0.0`` to disable the test split (returns empty list for test).
|
|
212
|
+
"""
|
|
213
|
+
if not lines:
|
|
214
|
+
return [], [], []
|
|
215
|
+
|
|
216
|
+
val_ratio = float(max(0.0, min(1.0, val_ratio)))
|
|
217
|
+
test_ratio = float(max(0.0, min(1.0, test_ratio)))
|
|
218
|
+
# Clamp so train can't go negative
|
|
219
|
+
if val_ratio + test_ratio >= 1.0:
|
|
220
|
+
excess = val_ratio + test_ratio
|
|
221
|
+
val_ratio = val_ratio / excess
|
|
222
|
+
test_ratio = test_ratio / excess
|
|
223
|
+
|
|
224
|
+
if val_ratio <= 0.0 and test_ratio <= 0.0:
|
|
225
|
+
return list(lines), [], []
|
|
226
|
+
|
|
227
|
+
def _group_key(item: Any) -> str:
|
|
228
|
+
"""Return the grouping key for a corpus item."""
|
|
229
|
+
# source_group takes priority (document-disjoint). Blank/whitespace-only
|
|
230
|
+
# groups fall back to text so they don't collapse into one bucket.
|
|
231
|
+
sg = getattr(item, "source_group", None)
|
|
232
|
+
if isinstance(sg, str) and sg.strip():
|
|
233
|
+
return sg
|
|
234
|
+
# Fall back to text value (text-disjoint, existing behavior)
|
|
235
|
+
return item[1] if isinstance(item, tuple) else item
|
|
236
|
+
|
|
237
|
+
buckets: dict[str, list] = {}
|
|
238
|
+
for item in lines:
|
|
239
|
+
key = _group_key(item)
|
|
240
|
+
buckets.setdefault(key, []).append(item)
|
|
241
|
+
|
|
242
|
+
unique_keys = list(buckets.keys())
|
|
243
|
+
if len(unique_keys) <= 1:
|
|
244
|
+
return list(lines), [], []
|
|
245
|
+
|
|
246
|
+
rng = random.Random(seed)
|
|
247
|
+
rng.shuffle(unique_keys)
|
|
248
|
+
|
|
249
|
+
n = len(unique_keys)
|
|
250
|
+
val_unique = round(n * val_ratio)
|
|
251
|
+
test_unique = round(n * test_ratio)
|
|
252
|
+
# Ensure at least one key stays in train
|
|
253
|
+
val_unique = max(0, min(val_unique, n - 1))
|
|
254
|
+
test_unique = max(0, min(test_unique, n - 1 - val_unique))
|
|
255
|
+
|
|
256
|
+
val_keys = set(unique_keys[:val_unique])
|
|
257
|
+
test_keys = set(unique_keys[val_unique : val_unique + test_unique])
|
|
258
|
+
|
|
259
|
+
train_lines: list = []
|
|
260
|
+
val_lines: list = []
|
|
261
|
+
test_lines: list = []
|
|
262
|
+
for key in unique_keys:
|
|
263
|
+
if key in val_keys:
|
|
264
|
+
val_lines.extend(buckets[key])
|
|
265
|
+
elif key in test_keys:
|
|
266
|
+
test_lines.extend(buckets[key])
|
|
267
|
+
else:
|
|
268
|
+
train_lines.extend(buckets[key])
|
|
269
|
+
|
|
270
|
+
return train_lines, val_lines, test_lines
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _copies_for_line(
|
|
274
|
+
text: str,
|
|
275
|
+
copies: int,
|
|
276
|
+
rare_chars: set[str] | None,
|
|
277
|
+
rare_char_multiplier: float,
|
|
278
|
+
) -> int:
|
|
279
|
+
"""Boost *copies* for lines containing a rare character.
|
|
280
|
+
|
|
281
|
+
Lines with no rare character are unaffected. Multiplier is only
|
|
282
|
+
applied (and only ever increases copies) when rendering random-font
|
|
283
|
+
training samples; val/test always pass ``rare_chars=None``.
|
|
284
|
+
"""
|
|
285
|
+
if not rare_chars or rare_char_multiplier <= 1.0:
|
|
286
|
+
return copies
|
|
287
|
+
if any(ch in rare_chars for ch in text):
|
|
288
|
+
return max(copies, round(copies * rare_char_multiplier))
|
|
289
|
+
return copies
|
|
290
|
+
|
|
291
|
+
@staticmethod
|
|
292
|
+
def _write_lines(path: Path, lines: list) -> None:
|
|
293
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
294
|
+
for line in lines:
|
|
295
|
+
if isinstance(line, CorpusItem):
|
|
296
|
+
record: dict[str, Any] = {"text": str(line)}
|
|
297
|
+
for attr in (
|
|
298
|
+
"source_id",
|
|
299
|
+
"source_group",
|
|
300
|
+
"language",
|
|
301
|
+
"script",
|
|
302
|
+
"domain",
|
|
303
|
+
"source_uri",
|
|
304
|
+
"license",
|
|
305
|
+
"retrieved_at",
|
|
306
|
+
"source_sha256",
|
|
307
|
+
"normalization_policy",
|
|
308
|
+
"consent_or_restrictions",
|
|
309
|
+
"raw_text",
|
|
310
|
+
):
|
|
311
|
+
val = getattr(line, attr, None)
|
|
312
|
+
if val is not None:
|
|
313
|
+
record[attr] = val
|
|
314
|
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
315
|
+
elif isinstance(line, tuple):
|
|
316
|
+
f.write(f"{line[0]}\t{line[1]}\n")
|
|
317
|
+
else:
|
|
318
|
+
f.write(line + "\n")
|
|
319
|
+
|
|
320
|
+
@staticmethod
|
|
321
|
+
def _build_vocab(labels_files: list[str], output_path: str) -> None:
|
|
322
|
+
"""Scan labels files and write a vocab.json covering every character."""
|
|
323
|
+
print("\nBuilding vocabulary from generated labels...")
|
|
324
|
+
unique_chars: set[str] = set()
|
|
325
|
+
|
|
326
|
+
for lf in labels_files:
|
|
327
|
+
if not os.path.exists(lf):
|
|
328
|
+
continue
|
|
329
|
+
with open(lf, encoding="utf-8") as fh:
|
|
330
|
+
for line in fh:
|
|
331
|
+
parts = line.strip().split("\t", 1)
|
|
332
|
+
if len(parts) == 2:
|
|
333
|
+
unique_chars.update(parts[1])
|
|
334
|
+
|
|
335
|
+
print(f" Found {len(unique_chars)} unique characters.")
|
|
336
|
+
|
|
337
|
+
if not unique_chars:
|
|
338
|
+
print(
|
|
339
|
+
" WARNING: no characters found in any labels file. "
|
|
340
|
+
"The written vocab.json will contain only {'<unk>': 0}."
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
vocab: dict[str, int] = {"<unk>": 0}
|
|
344
|
+
for idx, ch in enumerate(sorted(unique_chars), start=1):
|
|
345
|
+
if ch != "<unk>":
|
|
346
|
+
vocab[ch] = idx
|
|
347
|
+
|
|
348
|
+
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
|
349
|
+
with open(output_path, "w", encoding="utf-8") as fh:
|
|
350
|
+
json.dump(vocab, fh, indent=2, ensure_ascii=False)
|
|
351
|
+
|
|
352
|
+
print(f" Vocabulary saved -> {output_path}")
|
|
353
|
+
|
|
354
|
+
# Worker config
|
|
355
|
+
|
|
356
|
+
def _render_worker_config(
|
|
357
|
+
self,
|
|
358
|
+
output_dir: Path,
|
|
359
|
+
split_name: str,
|
|
360
|
+
font_mode: str,
|
|
361
|
+
retry_limit: int,
|
|
362
|
+
image_dir: str | None = None,
|
|
363
|
+
) -> dict[str, Any]:
|
|
364
|
+
"""Build the dict passed to worker initializer."""
|
|
365
|
+
worker_dict = self._cfg.to_dict()
|
|
366
|
+
worker_dict.update(
|
|
367
|
+
{
|
|
368
|
+
"split_name": split_name,
|
|
369
|
+
"output_images_dir": str(output_dir / "images"),
|
|
370
|
+
"font_mode": font_mode,
|
|
371
|
+
"retry_limit": retry_limit,
|
|
372
|
+
"image_dir": image_dir,
|
|
373
|
+
}
|
|
374
|
+
)
|
|
375
|
+
return worker_dict
|
|
376
|
+
|
|
377
|
+
# Batch iteration
|
|
378
|
+
|
|
379
|
+
@staticmethod
|
|
380
|
+
def _iter_sample_batches(samples: list[tuple[str, Any]], start_idx: int, chunk_size: int):
|
|
381
|
+
"""Yield batches of (index, text, font_ref) tuples."""
|
|
382
|
+
batch: list[tuple[int, str, Any]] = []
|
|
383
|
+
for offset, (text, font_ref) in enumerate(samples):
|
|
384
|
+
batch.append((start_idx + offset, text, font_ref))
|
|
385
|
+
if len(batch) >= chunk_size:
|
|
386
|
+
yield batch
|
|
387
|
+
batch = []
|
|
388
|
+
if batch:
|
|
389
|
+
yield batch
|
|
390
|
+
|
|
391
|
+
# Split generation
|
|
392
|
+
|
|
393
|
+
def _generate_split_serial(
|
|
394
|
+
self,
|
|
395
|
+
samples: list[tuple[str, Any]],
|
|
396
|
+
output_dir: Path,
|
|
397
|
+
split_name: str,
|
|
398
|
+
font_mode: str,
|
|
399
|
+
retry_limit: int,
|
|
400
|
+
start_idx: int,
|
|
401
|
+
file_mode: str,
|
|
402
|
+
image_dir: str | None = None,
|
|
403
|
+
) -> tuple[int, list[int]]:
|
|
404
|
+
"""Generate one split using a single process.
|
|
405
|
+
|
|
406
|
+
Returns `(success_count, heights)` where *heights* is the pixel height
|
|
407
|
+
of every successfully rendered image (for the QA height-distribution
|
|
408
|
+
summary).
|
|
409
|
+
"""
|
|
410
|
+
enabled_methods = self._cfg.enabled_aug_methods()
|
|
411
|
+
success_count = 0
|
|
412
|
+
heights: list[int] = []
|
|
413
|
+
record_metadata = bool(self._cfg.record_metadata)
|
|
414
|
+
# Provenance is only meaningful for JSONL manifests, which yield
|
|
415
|
+
# CorpusItem instances carrying source identity. Plain .txt corpora
|
|
416
|
+
# and image-dir mode produce no provenance.jsonl.
|
|
417
|
+
has_corpus_items = any(isinstance(text, CorpusItem) for text, _ in samples)
|
|
418
|
+
prov_rows: list[dict[str, Any]] = []
|
|
419
|
+
|
|
420
|
+
meta_file = (
|
|
421
|
+
open(output_dir / "metadata.jsonl", file_mode, encoding="utf-8") # noqa: SIM115
|
|
422
|
+
if record_metadata
|
|
423
|
+
else None
|
|
424
|
+
)
|
|
425
|
+
try:
|
|
426
|
+
with open(output_dir / "labels.txt", file_mode, encoding="utf-8") as labels_file:
|
|
427
|
+
for idx, (text, specific_font) in enumerate(
|
|
428
|
+
tqdm(samples, desc=" Generating", unit="img")
|
|
429
|
+
):
|
|
430
|
+
current_idx = start_idx + idx
|
|
431
|
+
sample_id = f"{split_name}_{current_idx:06d}"
|
|
432
|
+
seed_sample_generators(self._cfg.seed, current_idx, sample_id)
|
|
433
|
+
current_retry_limit = 1 if font_mode == "all" else retry_limit
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
meta: dict[str, Any] = {}
|
|
437
|
+
if image_dir:
|
|
438
|
+
img_path = Path(image_dir) / specific_font
|
|
439
|
+
img_array = cv2.imread(str(img_path))
|
|
440
|
+
if img_array is None:
|
|
441
|
+
raise RuntimeError(f"Could not read image: {img_path}")
|
|
442
|
+
if img_array.ndim == 3 and img_array.shape[2] == 3:
|
|
443
|
+
img_array = cv2.cvtColor(img_array, cv2.COLOR_BGR2RGB)
|
|
444
|
+
|
|
445
|
+
aug_results = self.renderer.augment_image(
|
|
446
|
+
img_array, enabled_methods, script=detect_script(text)
|
|
447
|
+
)
|
|
448
|
+
if not aug_results:
|
|
449
|
+
continue
|
|
450
|
+
_, img = aug_results[0]
|
|
451
|
+
else:
|
|
452
|
+
result = self.renderer.render_with_one_augmentation(
|
|
453
|
+
text,
|
|
454
|
+
enabled_methods,
|
|
455
|
+
specific_font=specific_font,
|
|
456
|
+
retry_limit=current_retry_limit,
|
|
457
|
+
)
|
|
458
|
+
if result is None:
|
|
459
|
+
continue
|
|
460
|
+
_, img, meta = result
|
|
461
|
+
|
|
462
|
+
if img is None:
|
|
463
|
+
continue
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
img_filename = f"{split_name}_{current_idx:06d}.{self._cfg.output_format}"
|
|
467
|
+
img_path = output_dir / "images" / img_filename
|
|
468
|
+
write_ok = write_output_image(
|
|
469
|
+
img_path,
|
|
470
|
+
img,
|
|
471
|
+
fmt=self._cfg.output_format,
|
|
472
|
+
jpeg_quality=self._cfg.jpeg_quality,
|
|
473
|
+
)
|
|
474
|
+
if not write_ok:
|
|
475
|
+
raise RuntimeError(f"cv2.imwrite failed for output path: {img_path}")
|
|
476
|
+
|
|
477
|
+
labels_file.write(f"{img_filename}\t{text}\n")
|
|
478
|
+
heights.append(int(img.shape[0]))
|
|
479
|
+
self._used_font_paths.update(
|
|
480
|
+
path for path in meta.get("fonts", [meta.get("font")]) if path
|
|
481
|
+
)
|
|
482
|
+
if meta_file is not None:
|
|
483
|
+
record, prov_row = build_sample_records(
|
|
484
|
+
img_filename=img_filename,
|
|
485
|
+
text=text,
|
|
486
|
+
width=img.shape[1],
|
|
487
|
+
height=img.shape[0],
|
|
488
|
+
split_name=split_name,
|
|
489
|
+
meta=meta,
|
|
490
|
+
default_language=self._cfg.language,
|
|
491
|
+
)
|
|
492
|
+
if prov_row is not None:
|
|
493
|
+
prov_rows.append(prov_row)
|
|
494
|
+
meta_file.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
495
|
+
success_count += 1
|
|
496
|
+
|
|
497
|
+
except Exception as e:
|
|
498
|
+
print(f" Failed for '{text[:30]}...': {e}")
|
|
499
|
+
continue
|
|
500
|
+
finally:
|
|
501
|
+
if meta_file is not None:
|
|
502
|
+
meta_file.close()
|
|
503
|
+
|
|
504
|
+
if record_metadata and has_corpus_items:
|
|
505
|
+
prov_path = output_dir / "provenance.jsonl"
|
|
506
|
+
with open(prov_path, file_mode, encoding="utf-8") as prov_file:
|
|
507
|
+
for row in prov_rows:
|
|
508
|
+
prov_file.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
509
|
+
|
|
510
|
+
return success_count, heights
|
|
511
|
+
|
|
512
|
+
def _generate_split_parallel(
|
|
513
|
+
self,
|
|
514
|
+
samples: list[tuple[str, Any]],
|
|
515
|
+
output_dir: Path,
|
|
516
|
+
split_name: str,
|
|
517
|
+
font_mode: str,
|
|
518
|
+
retry_limit: int,
|
|
519
|
+
start_idx: int,
|
|
520
|
+
file_mode: str,
|
|
521
|
+
workers: int,
|
|
522
|
+
image_dir: str | None = None,
|
|
523
|
+
) -> tuple[int, list[int]]:
|
|
524
|
+
"""Generate one split using multiple worker processes.
|
|
525
|
+
|
|
526
|
+
Returns `(success_count, heights)`; see `_generate_split_serial`.
|
|
527
|
+
"""
|
|
528
|
+
import json as _json
|
|
529
|
+
from concurrent.futures import FIRST_COMPLETED, ProcessPoolExecutor, TimeoutError, wait
|
|
530
|
+
|
|
531
|
+
resolved_workers = resolve_worker_count(workers, len(samples))
|
|
532
|
+
if resolved_workers <= 1:
|
|
533
|
+
return self._generate_split_serial(
|
|
534
|
+
samples=samples,
|
|
535
|
+
output_dir=output_dir,
|
|
536
|
+
split_name=split_name,
|
|
537
|
+
font_mode=font_mode,
|
|
538
|
+
retry_limit=retry_limit,
|
|
539
|
+
start_idx=start_idx,
|
|
540
|
+
file_mode=file_mode,
|
|
541
|
+
image_dir=image_dir,
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
chunk_size = resolve_chunk_size(len(samples), resolved_workers)
|
|
545
|
+
if (
|
|
546
|
+
resolved_workers > 1
|
|
547
|
+
and len(samples) > resolved_workers
|
|
548
|
+
and chunk_size > len(samples) // resolved_workers
|
|
549
|
+
):
|
|
550
|
+
chunk_size = max(1, len(samples) // resolved_workers)
|
|
551
|
+
worker_config = self._render_worker_config(
|
|
552
|
+
output_dir=output_dir,
|
|
553
|
+
split_name=split_name,
|
|
554
|
+
font_mode=font_mode,
|
|
555
|
+
retry_limit=retry_limit,
|
|
556
|
+
image_dir=image_dir,
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
print(f" Rendering with {resolved_workers} worker processes (chunk size: {chunk_size})")
|
|
560
|
+
|
|
561
|
+
worker_timeout = self._cfg.worker_timeout if self._cfg.worker_timeout > 0 else None
|
|
562
|
+
error_log_path = output_dir / "generation_errors.jsonl"
|
|
563
|
+
record_metadata = bool(self._cfg.record_metadata)
|
|
564
|
+
# Provenance is only meaningful for JSONL manifests, which yield
|
|
565
|
+
# CorpusItem instances carrying source identity.
|
|
566
|
+
has_corpus_items = any(isinstance(text, CorpusItem) for text, _ in samples)
|
|
567
|
+
|
|
568
|
+
success_count = 0
|
|
569
|
+
heights: list[int] = []
|
|
570
|
+
error_records: list[dict] = []
|
|
571
|
+
batch_iter = self._iter_sample_batches(samples, start_idx=start_idx, chunk_size=chunk_size)
|
|
572
|
+
max_in_flight = max(2, resolved_workers * 2)
|
|
573
|
+
|
|
574
|
+
mp_context, start_method = resolve_mp_context()
|
|
575
|
+
ensure_worker_main_guard(start_method)
|
|
576
|
+
|
|
577
|
+
with (
|
|
578
|
+
open(output_dir / "labels.txt", file_mode, encoding="utf-8") as labels_file,
|
|
579
|
+
contextlib.ExitStack() as stack,
|
|
580
|
+
ProcessPoolExecutor(
|
|
581
|
+
max_workers=resolved_workers,
|
|
582
|
+
mp_context=mp_context,
|
|
583
|
+
initializer=_init_render_worker,
|
|
584
|
+
initargs=(worker_config,),
|
|
585
|
+
) as executor,
|
|
586
|
+
):
|
|
587
|
+
meta_file = (
|
|
588
|
+
stack.enter_context(
|
|
589
|
+
open(output_dir / "metadata.jsonl", file_mode, encoding="utf-8")
|
|
590
|
+
)
|
|
591
|
+
if record_metadata
|
|
592
|
+
else None
|
|
593
|
+
)
|
|
594
|
+
prov_file = (
|
|
595
|
+
stack.enter_context(
|
|
596
|
+
open(output_dir / "provenance.jsonl", file_mode, encoding="utf-8")
|
|
597
|
+
)
|
|
598
|
+
if record_metadata and has_corpus_items
|
|
599
|
+
else None
|
|
600
|
+
)
|
|
601
|
+
print(f" Multiprocessing start method: {start_method}")
|
|
602
|
+
in_flight: dict[Any, int] = {}
|
|
603
|
+
pending_results: dict[int, Any] = {}
|
|
604
|
+
batch_seq = 0
|
|
605
|
+
next_write_seq = 0
|
|
606
|
+
|
|
607
|
+
for _ in range(max_in_flight):
|
|
608
|
+
try:
|
|
609
|
+
batch = next(batch_iter)
|
|
610
|
+
except StopIteration:
|
|
611
|
+
break
|
|
612
|
+
fut = executor.submit(_render_sample_batch, batch)
|
|
613
|
+
in_flight[fut] = batch_seq
|
|
614
|
+
batch_seq += 1
|
|
615
|
+
|
|
616
|
+
with tqdm(total=len(samples), desc=" Generating", unit="img") as pbar:
|
|
617
|
+
while in_flight:
|
|
618
|
+
done, _ = wait(set(in_flight.keys()), return_when=FIRST_COMPLETED)
|
|
619
|
+
for future in done:
|
|
620
|
+
b_seq = in_flight.pop(future)
|
|
621
|
+
try:
|
|
622
|
+
res = future.result(timeout=worker_timeout)
|
|
623
|
+
except TimeoutError:
|
|
624
|
+
raise RuntimeError(
|
|
625
|
+
f"A worker batch timed out after {worker_timeout}s. "
|
|
626
|
+
"Increase --worker-timeout or set it to 0 to disable."
|
|
627
|
+
) from None
|
|
628
|
+
pending_results[b_seq] = res
|
|
629
|
+
pbar.update(res[1])
|
|
630
|
+
|
|
631
|
+
try:
|
|
632
|
+
batch = next(batch_iter)
|
|
633
|
+
new_fut = executor.submit(_render_sample_batch, batch)
|
|
634
|
+
in_flight[new_fut] = batch_seq
|
|
635
|
+
batch_seq += 1
|
|
636
|
+
except StopIteration:
|
|
637
|
+
pass
|
|
638
|
+
|
|
639
|
+
while next_write_seq in pending_results:
|
|
640
|
+
(
|
|
641
|
+
labels,
|
|
642
|
+
_attempted,
|
|
643
|
+
batch_success,
|
|
644
|
+
err_msgs,
|
|
645
|
+
meta_lines,
|
|
646
|
+
prov_lines,
|
|
647
|
+
batch_heights,
|
|
648
|
+
) = pending_results.pop(next_write_seq)
|
|
649
|
+
next_write_seq += 1
|
|
650
|
+
|
|
651
|
+
if labels:
|
|
652
|
+
labels_file.writelines(labels)
|
|
653
|
+
if meta_file is not None and meta_lines:
|
|
654
|
+
meta_file.writelines(meta_lines)
|
|
655
|
+
for meta_line in meta_lines:
|
|
656
|
+
worker_meta = json.loads(meta_line)
|
|
657
|
+
self._used_font_paths.update(worker_meta.get("fonts", []))
|
|
658
|
+
if prov_file is not None and prov_lines:
|
|
659
|
+
prov_file.writelines(prov_lines)
|
|
660
|
+
success_count += batch_success
|
|
661
|
+
heights.extend(batch_heights)
|
|
662
|
+
|
|
663
|
+
for message in err_msgs:
|
|
664
|
+
print(message)
|
|
665
|
+
error_records.append({"message": message})
|
|
666
|
+
if error_records:
|
|
667
|
+
with open(error_log_path, "a", encoding="utf-8") as ef:
|
|
668
|
+
for rec in error_records:
|
|
669
|
+
ef.write(_json.dumps(rec, ensure_ascii=False) + "\n")
|
|
670
|
+
print(
|
|
671
|
+
f" {len(error_records)} error(s) written to "
|
|
672
|
+
f"{error_log_path.relative_to(output_dir.parent)}"
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
return success_count, heights
|
|
676
|
+
|
|
677
|
+
# Top-level entrypoints
|
|
678
|
+
|
|
679
|
+
def generate_split(
|
|
680
|
+
self,
|
|
681
|
+
text_file: str | Path,
|
|
682
|
+
output_dir: str | Path,
|
|
683
|
+
split_name: str,
|
|
684
|
+
font_mode: str = "random",
|
|
685
|
+
retry_limit: int = 10,
|
|
686
|
+
append: bool = False,
|
|
687
|
+
workers: int = 0,
|
|
688
|
+
image_dir: str | None = None,
|
|
689
|
+
copies: int = 3,
|
|
690
|
+
rare_chars: set[str] | None = None,
|
|
691
|
+
rare_char_multiplier: float = 1.0,
|
|
692
|
+
) -> int:
|
|
693
|
+
"""Generate one split (train or val) from *text_file*.
|
|
694
|
+
|
|
695
|
+
*rare_chars*/*rare_char_multiplier* boost the per-line copy count for
|
|
696
|
+
lines containing a rare character; pass ``rare_chars=None`` (the
|
|
697
|
+
default) for splits that should stay at a uniform ``copies`` per line
|
|
698
|
+
(e.g. val/test).
|
|
699
|
+
"""
|
|
700
|
+
output_dir = Path(output_dir)
|
|
701
|
+
(output_dir / "images").mkdir(parents=True, exist_ok=True)
|
|
702
|
+
overrides = {
|
|
703
|
+
"text_file": str(text_file), "font_mode": font_mode, "copies": copies,
|
|
704
|
+
"retry_limit": retry_limit, "workers": workers, "append": append,
|
|
705
|
+
"image_dir": image_dir, "rare_chars": sorted(rare_chars or ()),
|
|
706
|
+
"rare_char_multiplier": rare_char_multiplier,
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if self._cfg.multilingual and not image_dir:
|
|
710
|
+
self._reject_multilingual_line_separators(text_file)
|
|
711
|
+
|
|
712
|
+
ingestion_stats: dict[str, int] = {}
|
|
713
|
+
lines = self._read_non_empty_lines(
|
|
714
|
+
text_file, image_dir=image_dir, normalizer=self._cfg.normalizer, stats=ingestion_stats
|
|
715
|
+
)
|
|
716
|
+
self._run_ingestion_stats[split_name] = {
|
|
717
|
+
"path": str(text_file),
|
|
718
|
+
"counts": ingestion_stats,
|
|
719
|
+
"length_filters_applied": False,
|
|
720
|
+
}
|
|
721
|
+
self._used_font_paths = set()
|
|
722
|
+
print(f" Loaded {len(lines)} lines from {text_file}")
|
|
723
|
+
|
|
724
|
+
if self._cfg.multilingual and not image_dir:
|
|
725
|
+
rejected: list[str] = []
|
|
726
|
+
for line in cast("list[str]", lines):
|
|
727
|
+
if not self.renderer.is_line_eligible(line):
|
|
728
|
+
rejected.append(f"{line[:60]!r}: {self.renderer.last_rejection}")
|
|
729
|
+
if rejected:
|
|
730
|
+
req_desc = (
|
|
731
|
+
"provide fonts covering all grapheme clusters or select another --font-pool"
|
|
732
|
+
if self._cfg.cluster_font_fallback
|
|
733
|
+
else "provide a font covering each whole line or select another --font-pool"
|
|
734
|
+
)
|
|
735
|
+
raise ValueError(
|
|
736
|
+
f"Multilingual font preflight rejected {len(rejected)} / {len(lines)} lines; "
|
|
737
|
+
f"{req_desc}. "
|
|
738
|
+
+ "; ".join(rejected[:5])
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
# Build samples
|
|
742
|
+
samples: list[tuple[str, Any]] = []
|
|
743
|
+
|
|
744
|
+
if image_dir:
|
|
745
|
+
print(" Using EXISTING IMAGES mode (retry limit ignored)")
|
|
746
|
+
for item in lines:
|
|
747
|
+
filename, text = item
|
|
748
|
+
n = self._copies_for_line(text, copies, rare_chars, rare_char_multiplier)
|
|
749
|
+
for _ in range(n):
|
|
750
|
+
samples.append((text, filename))
|
|
751
|
+
elif font_mode == "all":
|
|
752
|
+
print(" Using ALL fonts mode")
|
|
753
|
+
fonts_list = self.font_manager.all_fonts
|
|
754
|
+
print(f" Iterating {len(fonts_list)} fonts per line...")
|
|
755
|
+
# image_dir is falsy here, so _read_non_empty_lines only produced plain str lines.
|
|
756
|
+
for line in cast("list[str]", lines):
|
|
757
|
+
if self._cfg.multilingual:
|
|
758
|
+
eligible = set(self.renderer.eligible_font_paths(line))
|
|
759
|
+
if eligible:
|
|
760
|
+
for font_tuple in fonts_list:
|
|
761
|
+
if font_tuple[0] in eligible:
|
|
762
|
+
samples.append((line, (font_tuple[0], font_tuple[1])))
|
|
763
|
+
elif self._cfg.cluster_font_fallback and self.renderer.is_line_eligible(line):
|
|
764
|
+
samples.append((line, None))
|
|
765
|
+
else:
|
|
766
|
+
for font_tuple in fonts_list:
|
|
767
|
+
samples.append((line, (font_tuple[0], font_tuple[1])))
|
|
768
|
+
else:
|
|
769
|
+
print(f" Using RANDOM fonts mode (retry limit: {retry_limit} attempts per sample)")
|
|
770
|
+
for line in cast("list[str]", lines):
|
|
771
|
+
n = self._copies_for_line(line, copies, rare_chars, rare_char_multiplier)
|
|
772
|
+
for _ in range(n):
|
|
773
|
+
samples.append((line, None))
|
|
774
|
+
|
|
775
|
+
# samples now holds every (text, font_ref) entry to render; the raw
|
|
776
|
+
# line list isn't needed anymore and would otherwise stay resident
|
|
777
|
+
# alongside samples for the rest of the split.
|
|
778
|
+
del lines
|
|
779
|
+
|
|
780
|
+
if not samples:
|
|
781
|
+
print(" Generating 0 images...")
|
|
782
|
+
self._record_split_run(output_dir, split_name, overrides, attempted=0, generated=0)
|
|
783
|
+
return 0
|
|
784
|
+
|
|
785
|
+
if self._cfg.seed is not None:
|
|
786
|
+
shuffle_seed = derive_sample_seed(self._cfg.seed, 0, f"split_shuffle_{split_name}")
|
|
787
|
+
random.Random(shuffle_seed).shuffle(samples)
|
|
788
|
+
else:
|
|
789
|
+
random.shuffle(samples)
|
|
790
|
+
file_mode = "a" if append else "w"
|
|
791
|
+
|
|
792
|
+
print(f" Generating {len(samples)} images...")
|
|
793
|
+
|
|
794
|
+
success_count, heights = self._generate_split_parallel(
|
|
795
|
+
samples=samples,
|
|
796
|
+
output_dir=output_dir,
|
|
797
|
+
split_name=split_name,
|
|
798
|
+
font_mode=font_mode,
|
|
799
|
+
retry_limit=retry_limit,
|
|
800
|
+
start_idx=0,
|
|
801
|
+
file_mode=file_mode,
|
|
802
|
+
workers=workers,
|
|
803
|
+
image_dir=image_dir,
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
self._record_split_run(
|
|
807
|
+
output_dir, split_name, overrides, attempted=len(samples), generated=success_count,
|
|
808
|
+
)
|
|
809
|
+
print(f" Generated {success_count} / {len(samples)} images")
|
|
810
|
+
if self._cfg.multilingual:
|
|
811
|
+
print(f" Failed/skipped: {len(samples) - success_count}")
|
|
812
|
+
if success_count == 0:
|
|
813
|
+
raise ValueError(
|
|
814
|
+
"Multilingual generation produced zero images; check font loading, RAQM, and augmentation settings"
|
|
815
|
+
)
|
|
816
|
+
self._print_height_summary(heights)
|
|
817
|
+
print()
|
|
818
|
+
return success_count
|
|
819
|
+
|
|
820
|
+
def _record_split_run(
|
|
821
|
+
self, output_dir: Path, split_name: str, overrides: dict[str, Any],
|
|
822
|
+
*, attempted: int, generated: int,
|
|
823
|
+
) -> None:
|
|
824
|
+
self._run_used_fonts[split_name] = set(self._used_font_paths)
|
|
825
|
+
record_run_manifest(
|
|
826
|
+
output_dir,
|
|
827
|
+
config=self._cfg,
|
|
828
|
+
split_name=split_name,
|
|
829
|
+
invocation_overrides=overrides,
|
|
830
|
+
available_fonts=self.font_manager.get_available_fonts(),
|
|
831
|
+
used_font_paths=sorted(self._used_font_paths),
|
|
832
|
+
ingestion={split_name: self._run_ingestion_stats[split_name]},
|
|
833
|
+
outcomes={"attempted": attempted, "generated": generated, "failed_or_skipped": attempted - generated},
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
def _print_height_summary(self, heights: list[int]) -> None:
|
|
837
|
+
"""Print observed line-height distribution for QA (variable-height modes)."""
|
|
838
|
+
mode = self._cfg.line_height_mode
|
|
839
|
+
print(f" Line height mode: {mode}")
|
|
840
|
+
if mode != "fixed":
|
|
841
|
+
print(
|
|
842
|
+
f" Height range: {self._cfg.min_line_height}..{self._cfg.max_line_height} "
|
|
843
|
+
f"step {self._cfg.line_height_step}"
|
|
844
|
+
)
|
|
845
|
+
if heights:
|
|
846
|
+
mean = sum(heights) / len(heights)
|
|
847
|
+
print(f" Observed heights: min={min(heights)} max={max(heights)} mean={mean:.1f}")
|
|
848
|
+
|
|
849
|
+
def generate_dataset(
|
|
850
|
+
self,
|
|
851
|
+
corpus_path: str | Path,
|
|
852
|
+
output_dir: str | Path = "data",
|
|
853
|
+
val_file: str | Path | None = None,
|
|
854
|
+
test_file: str | Path | None = None,
|
|
855
|
+
copies: int = 3,
|
|
856
|
+
font_mode: str = "random",
|
|
857
|
+
retry_limit: int = 10,
|
|
858
|
+
existing_mode: str = "error",
|
|
859
|
+
workers: int = 0,
|
|
860
|
+
split_seed: int | None = 42,
|
|
861
|
+
val_ratio: float = 0.1,
|
|
862
|
+
test_ratio: float = 0.1,
|
|
863
|
+
image_dir: str | None = None,
|
|
864
|
+
min_length: int = 5,
|
|
865
|
+
max_length: int = 120,
|
|
866
|
+
max_lines: int = 0,
|
|
867
|
+
oversample_rare_chars: bool = False,
|
|
868
|
+
rare_char_percentile: float = 5.0,
|
|
869
|
+
rare_char_multiplier: float = 3.0,
|
|
870
|
+
) -> dict[str, int]:
|
|
871
|
+
"""Generate a complete dataset with train/val/test splits.
|
|
872
|
+
|
|
873
|
+
When *oversample_rare_chars* is set, training lines containing one of
|
|
874
|
+
the least-frequent *rare_char_percentile* percent of characters in
|
|
875
|
+
the corpus get their copy count multiplied by *rare_char_multiplier*
|
|
876
|
+
(val/test stay uniform, so evaluation reflects natural frequency).
|
|
877
|
+
|
|
878
|
+
Returns a dict of split -> sample count.
|
|
879
|
+
"""
|
|
880
|
+
if split_seed is None and self._cfg.seed is not None:
|
|
881
|
+
split_seed = self._cfg.seed
|
|
882
|
+
valid_modes = {"error", "append", "overwrite"}
|
|
883
|
+
if existing_mode not in valid_modes:
|
|
884
|
+
raise ValueError(
|
|
885
|
+
f"existing_mode must be one of {sorted(valid_modes)}, got: {existing_mode!r}"
|
|
886
|
+
)
|
|
887
|
+
if not 0.0 <= float(val_ratio) < 1.0:
|
|
888
|
+
raise ValueError("val_ratio must be in [0.0, 1.0).")
|
|
889
|
+
if not 0.0 <= float(test_ratio) < 1.0:
|
|
890
|
+
raise ValueError("test_ratio must be in [0.0, 1.0).")
|
|
891
|
+
|
|
892
|
+
val_ratio = float(val_ratio)
|
|
893
|
+
test_ratio = float(test_ratio)
|
|
894
|
+
|
|
895
|
+
# An explicit test_file is the sole source of the test split; don't also
|
|
896
|
+
# carve a test set out of the disjoint ratio-based split (mirrors val_file).
|
|
897
|
+
if test_file and Path(test_file).exists():
|
|
898
|
+
test_ratio = 0.0
|
|
899
|
+
|
|
900
|
+
print("\n" + "=" * 70)
|
|
901
|
+
print(" Dataset Generation")
|
|
902
|
+
print("=" * 70)
|
|
903
|
+
|
|
904
|
+
output_dir = Path(output_dir)
|
|
905
|
+
|
|
906
|
+
if self._cfg.multilingual and not image_dir:
|
|
907
|
+
self._reject_multilingual_line_separators(corpus_path)
|
|
908
|
+
for extra in (val_file, test_file):
|
|
909
|
+
if extra and Path(extra).exists():
|
|
910
|
+
self._reject_multilingual_line_separators(extra)
|
|
911
|
+
|
|
912
|
+
# Handle existing output
|
|
913
|
+
append_mode = False
|
|
914
|
+
if output_dir.exists() and any(output_dir.iterdir()):
|
|
915
|
+
print(f"\n Dataset directory '{output_dir}' already exists.")
|
|
916
|
+
if existing_mode == "append":
|
|
917
|
+
append_mode = True
|
|
918
|
+
print(" Continuing generation (appending to existing)...")
|
|
919
|
+
elif existing_mode == "overwrite":
|
|
920
|
+
print(" Cleaning up existing directory...")
|
|
921
|
+
shutil.rmtree(output_dir)
|
|
922
|
+
else:
|
|
923
|
+
raise FileExistsError(
|
|
924
|
+
"Dataset output already exists. Re-run with "
|
|
925
|
+
"existing_mode='append' or existing_mode='overwrite'."
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
# Create output dirs
|
|
929
|
+
output_dir.mkdir(exist_ok=True)
|
|
930
|
+
(output_dir / "train" / "images").mkdir(parents=True, exist_ok=True)
|
|
931
|
+
|
|
932
|
+
will_have_val = bool((val_file and Path(val_file).exists()) or float(val_ratio) > 0.0)
|
|
933
|
+
if will_have_val:
|
|
934
|
+
(output_dir / "val" / "images").mkdir(parents=True, exist_ok=True)
|
|
935
|
+
|
|
936
|
+
will_have_test = bool((test_file and Path(test_file).exists()) or float(test_ratio) > 0.0)
|
|
937
|
+
if will_have_test:
|
|
938
|
+
(output_dir / "test" / "images").mkdir(parents=True, exist_ok=True)
|
|
939
|
+
|
|
940
|
+
counts: dict[str, int] = {}
|
|
941
|
+
self._run_ingestion_stats = {}
|
|
942
|
+
self._run_used_fonts = {}
|
|
943
|
+
|
|
944
|
+
rare_chars: set[str] | None = None
|
|
945
|
+
if oversample_rare_chars:
|
|
946
|
+
freq = char_frequencies(
|
|
947
|
+
corpus_path,
|
|
948
|
+
min_length=min_length,
|
|
949
|
+
max_length=max_length,
|
|
950
|
+
max_lines=max_lines,
|
|
951
|
+
normalizer=self._cfg.normalizer,
|
|
952
|
+
)
|
|
953
|
+
rare_chars = rare_chars_from_frequencies(freq, percentile=rare_char_percentile)
|
|
954
|
+
print(
|
|
955
|
+
f" Rare-char oversampling: {len(rare_chars)} rare char(s) "
|
|
956
|
+
f"(bottom {rare_char_percentile:g}% of {len(freq)} distinct chars), "
|
|
957
|
+
f"{rare_char_multiplier:g}x copies for lines containing one (train only)"
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
if val_file and Path(val_file).exists():
|
|
961
|
+
print("\nGenerating TRAINING set...")
|
|
962
|
+
counts["train"] = self.generate_split(
|
|
963
|
+
text_file=corpus_path,
|
|
964
|
+
output_dir=output_dir / "train",
|
|
965
|
+
split_name="train",
|
|
966
|
+
font_mode=font_mode,
|
|
967
|
+
retry_limit=retry_limit,
|
|
968
|
+
append=append_mode,
|
|
969
|
+
workers=workers,
|
|
970
|
+
image_dir=image_dir,
|
|
971
|
+
copies=copies,
|
|
972
|
+
rare_chars=rare_chars,
|
|
973
|
+
rare_char_multiplier=rare_char_multiplier,
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
print("\nGenerating VALIDATION set (from separate file)...")
|
|
977
|
+
counts["val"] = self.generate_split(
|
|
978
|
+
text_file=val_file,
|
|
979
|
+
output_dir=output_dir / "val",
|
|
980
|
+
split_name="val",
|
|
981
|
+
font_mode=font_mode,
|
|
982
|
+
retry_limit=retry_limit,
|
|
983
|
+
append=append_mode,
|
|
984
|
+
workers=workers,
|
|
985
|
+
image_dir=image_dir,
|
|
986
|
+
copies=1,
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
if test_file and Path(test_file).exists():
|
|
990
|
+
print("\nGenerating TEST set (from separate file)...")
|
|
991
|
+
counts["test"] = self.generate_split(
|
|
992
|
+
text_file=test_file,
|
|
993
|
+
output_dir=output_dir / "test",
|
|
994
|
+
split_name="test",
|
|
995
|
+
font_mode=font_mode,
|
|
996
|
+
retry_limit=retry_limit,
|
|
997
|
+
append=append_mode,
|
|
998
|
+
workers=workers,
|
|
999
|
+
image_dir=image_dir,
|
|
1000
|
+
copies=1,
|
|
1001
|
+
)
|
|
1002
|
+
else:
|
|
1003
|
+
print("\nPreparing disjoint TRAIN/VAL/TEST split from corpus...")
|
|
1004
|
+
print(
|
|
1005
|
+
f" Split config: train={(1.0 - val_ratio - test_ratio) * 100:.1f}%, "
|
|
1006
|
+
f"val={val_ratio * 100:.1f}%, "
|
|
1007
|
+
f"test={test_ratio * 100:.1f}%, seed={split_seed}"
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
# Filter the corpus straight into a single in-memory list (one
|
|
1011
|
+
# materialization) instead of round-tripping through a temp file
|
|
1012
|
+
# and re-reading it back into a second list.
|
|
1013
|
+
filtered_lines, corpus_stats = load_corpus_with_stats(
|
|
1014
|
+
corpus_path,
|
|
1015
|
+
min_length=min_length,
|
|
1016
|
+
max_length=max_length,
|
|
1017
|
+
max_lines=max_lines,
|
|
1018
|
+
normalizer=self._cfg.normalizer,
|
|
1019
|
+
strict_jsonl=True,
|
|
1020
|
+
)
|
|
1021
|
+
self._run_ingestion_stats["corpus"] = {
|
|
1022
|
+
"path": str(corpus_path),
|
|
1023
|
+
"counts": corpus_stats,
|
|
1024
|
+
"length_filters_applied": True,
|
|
1025
|
+
"min_length": min_length, "max_length": max_length, "max_lines": max_lines,
|
|
1026
|
+
"count_scope": "records inspected until the passing-record cap; unread remainder is not counted",
|
|
1027
|
+
}
|
|
1028
|
+
all_lines = list(tqdm(filtered_lines, desc=" Filtering corpus", unit="line"))
|
|
1029
|
+
train_lines, val_lines, test_lines = self._split_lines_without_text_overlap(
|
|
1030
|
+
all_lines, val_ratio=val_ratio, test_ratio=test_ratio, seed=split_seed
|
|
1031
|
+
)
|
|
1032
|
+
# all_lines is now fully partitioned across train/val/test; drop the
|
|
1033
|
+
# reference so it doesn't sit in memory alongside the per-split
|
|
1034
|
+
# re-read that generate_split performs below.
|
|
1035
|
+
del all_lines
|
|
1036
|
+
|
|
1037
|
+
train_unique = len({(t[1] if isinstance(t, tuple) else t) for t in train_lines})
|
|
1038
|
+
val_unique = len({(t[1] if isinstance(t, tuple) else t) for t in val_lines})
|
|
1039
|
+
test_unique = len({(t[1] if isinstance(t, tuple) else t) for t in test_lines})
|
|
1040
|
+
|
|
1041
|
+
def _text_of(item):
|
|
1042
|
+
return item[1] if isinstance(item, tuple) else item
|
|
1043
|
+
|
|
1044
|
+
overlap_tv = len({_text_of(x) for x in train_lines} & {_text_of(x) for x in val_lines})
|
|
1045
|
+
overlap_tt = len({_text_of(x) for x in train_lines} & {_text_of(x) for x in test_lines})
|
|
1046
|
+
overlap_vt = len({_text_of(x) for x in val_lines} & {_text_of(x) for x in test_lines})
|
|
1047
|
+
print(
|
|
1048
|
+
f" Split summary: train={len(train_lines)} lines ({train_unique} unique), "
|
|
1049
|
+
f"val={len(val_lines)} lines ({val_unique} unique), "
|
|
1050
|
+
f"test={len(test_lines)} lines ({test_unique} unique), "
|
|
1051
|
+
f"overlaps: train∩val={overlap_tv} train∩test={overlap_tt} val∩test={overlap_vt}"
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
temp_ext = ".jsonl" if train_lines and isinstance(train_lines[0], CorpusItem) else ".txt"
|
|
1055
|
+
temp_train = output_dir / f"temp_train{temp_ext}"
|
|
1056
|
+
temp_val = output_dir / f"temp_val{temp_ext}"
|
|
1057
|
+
temp_test = output_dir / f"temp_test{temp_ext}"
|
|
1058
|
+
self._write_lines(temp_train, train_lines)
|
|
1059
|
+
self._write_lines(temp_val, val_lines)
|
|
1060
|
+
self._write_lines(temp_test, test_lines)
|
|
1061
|
+
# The split lists are now on disk for generate_split to stream back
|
|
1062
|
+
# in per-split. Drop them so the full corpus isn't held twice while
|
|
1063
|
+
# rendering runs.
|
|
1064
|
+
val_lines_present = bool(val_lines)
|
|
1065
|
+
test_lines_present = bool(test_lines)
|
|
1066
|
+
del train_lines, val_lines, test_lines
|
|
1067
|
+
|
|
1068
|
+
try:
|
|
1069
|
+
print("\nGenerating TRAINING set...")
|
|
1070
|
+
counts["train"] = self.generate_split(
|
|
1071
|
+
text_file=temp_train,
|
|
1072
|
+
output_dir=output_dir / "train",
|
|
1073
|
+
split_name="train",
|
|
1074
|
+
font_mode=font_mode,
|
|
1075
|
+
retry_limit=retry_limit,
|
|
1076
|
+
append=append_mode,
|
|
1077
|
+
workers=workers,
|
|
1078
|
+
image_dir=image_dir,
|
|
1079
|
+
copies=copies,
|
|
1080
|
+
rare_chars=rare_chars,
|
|
1081
|
+
rare_char_multiplier=rare_char_multiplier,
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
if val_lines_present:
|
|
1085
|
+
print("\nGenerating VALIDATION set (disjoint text split)...")
|
|
1086
|
+
counts["val"] = self.generate_split(
|
|
1087
|
+
text_file=temp_val,
|
|
1088
|
+
output_dir=output_dir / "val",
|
|
1089
|
+
split_name="val",
|
|
1090
|
+
font_mode=font_mode,
|
|
1091
|
+
retry_limit=retry_limit,
|
|
1092
|
+
append=append_mode,
|
|
1093
|
+
workers=workers,
|
|
1094
|
+
image_dir=image_dir,
|
|
1095
|
+
copies=1,
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
if test_lines_present:
|
|
1099
|
+
print("\nGenerating TEST set (disjoint text split)...")
|
|
1100
|
+
counts["test"] = self.generate_split(
|
|
1101
|
+
text_file=temp_test,
|
|
1102
|
+
output_dir=output_dir / "test",
|
|
1103
|
+
split_name="test",
|
|
1104
|
+
font_mode=font_mode,
|
|
1105
|
+
retry_limit=retry_limit,
|
|
1106
|
+
append=append_mode,
|
|
1107
|
+
workers=workers,
|
|
1108
|
+
image_dir=image_dir,
|
|
1109
|
+
copies=1,
|
|
1110
|
+
)
|
|
1111
|
+
|
|
1112
|
+
# Handle separate test_file when val_file path was NOT the primary path
|
|
1113
|
+
if test_file and Path(test_file).exists():
|
|
1114
|
+
print("\nGenerating TEST set (from separate file)...")
|
|
1115
|
+
counts["test"] = self.generate_split(
|
|
1116
|
+
text_file=test_file,
|
|
1117
|
+
output_dir=output_dir / "test",
|
|
1118
|
+
split_name="test",
|
|
1119
|
+
font_mode=font_mode,
|
|
1120
|
+
retry_limit=retry_limit,
|
|
1121
|
+
append=append_mode,
|
|
1122
|
+
workers=workers,
|
|
1123
|
+
image_dir=image_dir,
|
|
1124
|
+
copies=1,
|
|
1125
|
+
)
|
|
1126
|
+
finally:
|
|
1127
|
+
for tmp_path in (temp_train, temp_val, temp_test):
|
|
1128
|
+
with contextlib.suppress(FileNotFoundError):
|
|
1129
|
+
tmp_path.unlink()
|
|
1130
|
+
|
|
1131
|
+
record_run_manifest(
|
|
1132
|
+
output_dir,
|
|
1133
|
+
config=self._cfg,
|
|
1134
|
+
invocation_overrides={
|
|
1135
|
+
"corpus_path": str(corpus_path),
|
|
1136
|
+
"val_file": str(val_file) if val_file else None,
|
|
1137
|
+
"test_file": str(test_file) if test_file else None,
|
|
1138
|
+
"copies": copies, "font_mode": font_mode, "retry_limit": retry_limit,
|
|
1139
|
+
"existing_mode": existing_mode, "workers": workers, "split_seed": split_seed,
|
|
1140
|
+
"val_ratio": val_ratio, "test_ratio": test_ratio, "image_dir": image_dir,
|
|
1141
|
+
"min_length": min_length, "max_length": max_length, "max_lines": max_lines,
|
|
1142
|
+
"oversample_rare_chars": oversample_rare_chars,
|
|
1143
|
+
"rare_char_percentile": rare_char_percentile,
|
|
1144
|
+
"rare_char_multiplier": rare_char_multiplier,
|
|
1145
|
+
"split_grouping": "explicit_files" if val_file else "source_group_or_exact_text",
|
|
1146
|
+
},
|
|
1147
|
+
available_fonts=self.font_manager.get_available_fonts(),
|
|
1148
|
+
used_font_paths=sorted(set().union(*self._run_used_fonts.values())),
|
|
1149
|
+
ingestion=self._run_ingestion_stats,
|
|
1150
|
+
outcomes={"generated_by_split": counts},
|
|
1151
|
+
)
|
|
1152
|
+
print("\n" + "=" * 70)
|
|
1153
|
+
print(" Dataset Generation Complete!")
|
|
1154
|
+
print("=" * 70)
|
|
1155
|
+
for split, count in counts.items():
|
|
1156
|
+
print(f" {split.capitalize()}: {count:,} samples")
|
|
1157
|
+
print(f" Output: {output_dir}")
|
|
1158
|
+
print("=" * 70 + "\n")
|
|
1159
|
+
|
|
1160
|
+
return counts
|