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.
@@ -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