synthocr 2.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
synthocr/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Model-neutral synthetic text-image generation."""
2
+
3
+ __version__ = "2.1.0"
synthocr/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
synthocr/cli.py ADDED
@@ -0,0 +1,540 @@
1
+ """One maintained command line interface for every stage of the data pipeline."""
2
+
3
+ import argparse
4
+ import json
5
+ import logging
6
+ import math
7
+ import re
8
+ import sqlite3
9
+ import sys
10
+ import tempfile
11
+ from dataclasses import replace
12
+ from pathlib import Path
13
+ from uuid import uuid4
14
+
15
+ from . import __version__
16
+ from .config import AUGMENTATION_FIELDS, DOCUMENT_LAYERS, DOCUMENT_PRESETS, load_config
17
+ from .corpora import snapshot
18
+ from .fonts import (
19
+ CATALOG,
20
+ PROFILES,
21
+ check,
22
+ discover,
23
+ fetch,
24
+ font_ids,
25
+ load_characters,
26
+ load_profile,
27
+ )
28
+ from .home import NotSetUp, home, ready, resolve_config, setup
29
+ from .pipeline import export, generate
30
+ from .preview import preview
31
+ from .spelling import LANGUAGES as SPELLING_LANGUAGES
32
+ from .spelling import spelling_cases, write_spelling
33
+ from .text import GENRES, generate_text, prepare
34
+ from .verify import verify
35
+
36
+
37
+ class BackgroundWeight(argparse.Action):
38
+ """Collect a small explicit recipe-weight mapping without silent replacement."""
39
+
40
+ def __call__(self, parser, namespace, value, option_string=None):
41
+ name, separator, raw = value.partition("=")
42
+ if not separator or name not in DOCUMENT_PRESETS:
43
+ raise argparse.ArgumentError(
44
+ self, "expected a supported background preset and weight, e.g. office-scan=3"
45
+ )
46
+ try:
47
+ weight = float(raw)
48
+ except ValueError as error:
49
+ raise argparse.ArgumentError(self, "weight must be a finite number") from error
50
+ if not math.isfinite(weight) or not 0 <= weight <= 1_000_000:
51
+ raise argparse.ArgumentError(self, "weight must be between 0 and 1000000")
52
+ weights = dict(getattr(namespace, self.dest) or {})
53
+ if name in weights:
54
+ raise argparse.ArgumentError(self, f"duplicate weight for {name}")
55
+ weights[name] = weight
56
+ setattr(namespace, self.dest, weights)
57
+
58
+
59
+ class LanguageWeight(argparse.Action):
60
+ """Collect relative per-language shares, e.g. ckb=70 kmr=30."""
61
+
62
+ def __call__(self, parser, namespace, value, option_string=None):
63
+ name, separator, raw = value.partition("=")
64
+ if not separator or not re.fullmatch(r"[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*", name):
65
+ raise argparse.ArgumentError(self, "expected a language tag and weight, e.g. ckb=70")
66
+ try:
67
+ weight = float(raw)
68
+ except ValueError as error:
69
+ raise argparse.ArgumentError(self, "weight must be a finite number") from error
70
+ if not math.isfinite(weight) or not 0 <= weight <= 1_000_000:
71
+ raise argparse.ArgumentError(self, "weight must be between 0 and 1000000")
72
+ weights = dict(getattr(namespace, self.dest) or {})
73
+ if name in weights:
74
+ raise argparse.ArgumentError(self, f"duplicate weight for {name}")
75
+ weights[name] = weight
76
+ setattr(namespace, self.dest, weights)
77
+
78
+
79
+ class OutputLocation(argparse.Action):
80
+ def __call__(self, parser, namespace, value, option_string=None):
81
+ name, separator, path = value.partition("=")
82
+ if not separator or not path or not re.fullmatch(r"[a-z][a-z0-9-]{0,31}", name):
83
+ raise argparse.ArgumentError(
84
+ self, "use NAME=PATH with a lowercase location name, e.g. training=/data/training"
85
+ )
86
+ if name == "default":
87
+ raise argparse.ArgumentError(
88
+ self, "'default' is reserved; use --output-root for the default location"
89
+ )
90
+ locations = dict(getattr(namespace, self.dest) or {})
91
+ if name in locations:
92
+ raise argparse.ArgumentError(self, f"duplicate output location: {name}")
93
+ if len(locations) >= 8:
94
+ raise argparse.ArgumentError(
95
+ self, "at most eight additional output locations are supported"
96
+ )
97
+ locations[name] = path
98
+ setattr(namespace, self.dest, locations)
99
+
100
+
101
+ def parser():
102
+ root = argparse.ArgumentParser(prog="synthocr", description=__doc__)
103
+ root.add_argument("--version", action="version", version=__version__)
104
+ root.add_argument(
105
+ "--config",
106
+ type=Path,
107
+ help="Config file; defaults to ./config.toml, else your SynthOCR workspace",
108
+ )
109
+ commands = root.add_subparsers(dest="command", required=True)
110
+ setup_command = commands.add_parser(
111
+ "setup", help="Download open fonts and text into your SynthOCR workspace"
112
+ )
113
+ setup_command.add_argument(
114
+ "--all-fonts",
115
+ action="store_true",
116
+ help="Fetch every open font family, not only the Kurdish set",
117
+ )
118
+ setup_command.add_argument(
119
+ "--limit", type=int, default=20_000, help="Text entries per language (default 20000)"
120
+ )
121
+ setup_command.add_argument(
122
+ "--offline", action="store_true", help="Skip the Tatoeba download; fonts are still fetched"
123
+ )
124
+ ui = commands.add_parser("ui", help="Open the local web UI for generating datasets")
125
+ ui.add_argument("--host", choices=["127.0.0.1", "localhost", "::1"], default="127.0.0.1")
126
+ ui.add_argument("--port", type=int, default=8000)
127
+ ui.add_argument("--cache-mb", type=int, default=256, help="Maximum disposable UI cache in MiB")
128
+ ui.add_argument("--output-root", type=Path, help="Save UI datasets here (default: dataset/ui)")
129
+ ui.add_argument(
130
+ "--output-location",
131
+ dest="output_locations",
132
+ action=OutputLocation,
133
+ metavar="NAME=PATH",
134
+ help="Add a named storage location users can choose; repeat for up to eight locations",
135
+ )
136
+ ui.add_argument("--no-browser", action="store_true", help="Start without opening a browser")
137
+ prep = commands.add_parser("prepare", help="Stream raw text into indexed processed data")
138
+ prep.add_argument("--force", action="store_true")
139
+ experiment = commands.add_parser("experiment", help="Compare reproducible rendering presets")
140
+ experiment.add_argument("--output", type=Path, required=True)
141
+ experiment.add_argument("--samples", type=int, default=12)
142
+ experiment.add_argument("--seed", type=int, default=42)
143
+ for command in ("kurdish", "generate", "benchmark"):
144
+ sub = commands.add_parser(
145
+ command,
146
+ help={
147
+ "kurdish": "Generate and verify Kurdish images (Sorani by default)",
148
+ "generate": "Generate images for any language",
149
+ "benchmark": "Measure real throughput using temporary output",
150
+ }[command],
151
+ )
152
+ sub.add_argument("--samples", type=int)
153
+ sub.add_argument("--workers", type=int)
154
+ sub.add_argument("--seed", type=int)
155
+ sub.add_argument("--output", type=str)
156
+ sub.add_argument("--format", choices=["png", "jpg", "webp", "tif"])
157
+ sub.add_argument("--augmentation", type=float, help="Fraction of images to augment (0..1)")
158
+ sub.add_argument("--background", choices=["paper", "plain", "gradient", "noise"])
159
+ sub.add_argument("--color-mode", choices=["grayscale", "rgb"])
160
+ sub.add_argument("--background-color", help="Background hex color, e.g. #f5f1e8")
161
+ sub.add_argument("--background-color-end", help="Gradient end hex color")
162
+ sub.add_argument("--text-color", help="Text hex color, e.g. #1a1a1a")
163
+ sub.add_argument("--texture-strength", type=float, help="Paper/noise strength (0..0.25)")
164
+ sub.add_argument("--rotation", type=float, help="Maximum rotation in degrees (0..10)")
165
+ sub.add_argument("--blur", type=float, help="Maximum blur radius in pixels (0..2)")
166
+ sub.add_argument("--contrast", type=float, help="Contrast variation around 1 (0..0.5)")
167
+ sub.add_argument(
168
+ "--background-preset",
169
+ dest="background_presets",
170
+ action="append",
171
+ choices=DOCUMENT_PRESETS,
172
+ help="Repeat to mix document types across images",
173
+ )
174
+ sub.add_argument(
175
+ "--background-weight",
176
+ dest="background_weights",
177
+ action=BackgroundWeight,
178
+ metavar="PRESET=WEIGHT",
179
+ help="Relative share of a selected document type; repeat, omitted weights are 1",
180
+ )
181
+ sub.add_argument(
182
+ "--preset-colors",
183
+ action=argparse.BooleanOptionalAction,
184
+ default=None,
185
+ help="Use document colors; --no-preset-colors uses your custom palette",
186
+ )
187
+ for layer in DOCUMENT_LAYERS:
188
+ sub.add_argument(
189
+ "--" + layer.replace("_", "-"),
190
+ type=float,
191
+ help="Override effect strength (0..1); zero disables, omitted inherits the preset",
192
+ )
193
+ if command == "kurdish":
194
+ sub.add_argument("--language", choices=["sorani", "kurmanji", "both"], default="sorani")
195
+ sub.add_argument(
196
+ "--check", action="store_true", help="Check every bundled spelling case instead"
197
+ )
198
+ else:
199
+ sub.add_argument(
200
+ "--suite",
201
+ choices=["kurdish-spelling"],
202
+ help="Use bundled spelling cases instead of raw inputs",
203
+ )
204
+ sub.add_argument(
205
+ "--language",
206
+ dest="languages",
207
+ action="append",
208
+ help="Filter source language tags; repeat to combine, e.g. ckb and kmr",
209
+ )
210
+ sub.add_argument(
211
+ "--language-weight",
212
+ dest="language_weights",
213
+ action=LanguageWeight,
214
+ metavar="LANG=WEIGHT",
215
+ help="Relative share of a selected language; repeat, omitted weights are 1",
216
+ )
217
+ sub.add_argument(
218
+ "--exclude-genre", dest="exclude_genres", action="append", help="Repeat to combine"
219
+ )
220
+ sub.add_argument(
221
+ "--font",
222
+ dest="fonts",
223
+ action="append",
224
+ metavar="FILE",
225
+ help="Use only this font (file name, NAME#FACE or ID from `fonts list`); repeat",
226
+ )
227
+ font = commands.add_parser("fonts", help="Use the same fonts for inspection and generation")
228
+ font_commands = font.add_subparsers(dest="font_command", required=True)
229
+ font_commands.add_parser("list")
230
+ checker = font_commands.add_parser("check")
231
+ source = checker.add_mutually_exclusive_group()
232
+ source.add_argument("--chars", type=Path, help="Character JSON or UTF-8 text")
233
+ source.add_argument("--profile", choices=PROFILES, help="Bundled reference character set")
234
+ source.add_argument("--text", default="سڵاو Hello 0123456789")
235
+ audit = checker.add_mutually_exclusive_group()
236
+ audit.add_argument(
237
+ "--require-all", action="store_true", help="Exit 1 if any font lacks any required character"
238
+ )
239
+ audit.add_argument(
240
+ "--require-any", action="store_true", help="Exit 1 if no font covers the entire reference"
241
+ )
242
+ download = font_commands.add_parser("fetch")
243
+ download.add_argument("--family", action="append", help="Repeat to select catalog families")
244
+ download.add_argument("--catalog", help="Font catalog JSON; defaults to the bundled catalog")
245
+ download.add_argument("--destination", default="fonts")
246
+ text = commands.add_parser("text", help="Create seeded genre examples without external APIs")
247
+ text.add_argument("--output", type=Path, required=True)
248
+ text.add_argument("--count", type=int, default=1000)
249
+ text.add_argument("--seed", type=int, default=42)
250
+ text.add_argument(
251
+ "--genre",
252
+ default="mixed",
253
+ choices=["mixed", *GENRES],
254
+ )
255
+ text.add_argument(
256
+ "--language",
257
+ choices=["mixed", "en", "ckb", "ar", "fa", "de", "fr", "es", "tr"],
258
+ default="en",
259
+ )
260
+ spelling = commands.add_parser(
261
+ "spelling", help="Export Kurdish spelling examples and Unicode probes"
262
+ )
263
+ spelling.add_argument("--output", type=Path, required=True)
264
+ spelling.add_argument("--language", choices=SPELLING_LANGUAGES, default="mixed")
265
+ spelling.add_argument(
266
+ "--words-only",
267
+ action="store_true",
268
+ help="Only authored words and phrases; omit mechanical probes",
269
+ )
270
+ convert = commands.add_parser("export", help="Stream a model-specific dataset view")
271
+ convert.add_argument("--dataset", type=Path, default=Path("dataset/default"))
272
+ convert.add_argument("--output", type=Path, required=True)
273
+ convert.add_argument("--format", required=True, choices=["csv", "imagefolder", "tesseract"])
274
+ check_data = commands.add_parser(
275
+ "verify", help="Verify image integrity, counts, bounds and splits"
276
+ )
277
+ check_data.add_argument("--dataset", type=Path, default=Path("dataset/default"))
278
+ check_data.add_argument("--metadata-only", action="store_true")
279
+ sheet = commands.add_parser(
280
+ "preview", help="Render a multilingual showcase with licensed fonts"
281
+ )
282
+ sheet.add_argument("--output", type=Path, default=Path(".local/preview.png"))
283
+ corpora = commands.add_parser("corpora", help="Add versioned, attributed Tatoeba raw data")
284
+ corpora.add_argument(
285
+ "--language", action="append", required=True, help="Repeat ISO 639-3 codes"
286
+ )
287
+ corpora.add_argument(
288
+ "--snapshot", required=True, help="ISO date; existing snapshots stay immutable"
289
+ )
290
+ corpora.add_argument(
291
+ "--limit", type=int, default=10000, help="Maximum attributed records per language"
292
+ )
293
+ corpora.add_argument("--catalog", default="data/corpora.json")
294
+ corpora.add_argument("--cache", default=".local/corpora")
295
+ return root
296
+
297
+
298
+ def _kurdish(config, args):
299
+ """A Sorani-first entry point using the same generator and integrity checker."""
300
+ languages = {"sorani": ["ckb"], "kurmanji": ["kmr"], "both": ["ckb", "kmr"]}[args.language]
301
+ required = (
302
+ {
303
+ row["attribution"]["source_id"]
304
+ for row in spelling_cases()
305
+ if row["language"] in languages
306
+ }
307
+ if args.check
308
+ else set()
309
+ )
310
+ overrides = {
311
+ name: getattr(args, name)
312
+ for name in ("samples", "workers", "seed", "output", "format", *AUGMENTATION_FIELDS)
313
+ if getattr(args, name) is not None
314
+ }
315
+ if args.check:
316
+ overrides.setdefault("samples", len(required))
317
+ if overrides["samples"] < len(required):
318
+ raise ValueError(
319
+ f"Spelling check needs at least {len(required)} samples; omit --samples"
320
+ )
321
+ prefix = "check-" if args.check else ""
322
+ overrides.setdefault("output", f"dataset/{prefix}{args.language}-{uuid4().hex[:8]}")
323
+ # Keep diagnostic contexts whole even if ordinary corpus lines wrap sooner.
324
+ max_chars = config.data.max_chars
325
+ if args.check:
326
+ max_chars = max(
327
+ max_chars,
328
+ max(len(row["text"]) for row in spelling_cases() if row["language"] in languages),
329
+ )
330
+ config = replace(
331
+ config,
332
+ data=replace(config.data, normalization="NFC", max_chars=max_chars),
333
+ fonts=replace(config.fonts, coverage="strict"),
334
+ generation=replace(
335
+ config.generation,
336
+ **overrides,
337
+ languages=languages,
338
+ direction="auto",
339
+ language="",
340
+ suite="kurdish-spelling" if args.check else "",
341
+ exclude_genres=[] if args.check else [*config.generation.exclude_genres, "spelling"],
342
+ ),
343
+ ).validate()
344
+ result = generate(config)
345
+ result["verification"] = verify(config.path(config.generation.output))
346
+ if args.check:
347
+ missing = required.copy()
348
+ with (config.path(config.generation.output) / "manifest.jsonl").open(
349
+ encoding="utf-8"
350
+ ) as stream:
351
+ for row in map(json.loads, stream):
352
+ missing.discard(row["attribution"]["source_id"])
353
+ if missing:
354
+ raise ValueError(
355
+ f"Spelling check missed {len(missing)} cases; inspect fonts and chunking"
356
+ )
357
+ result["spelling_cases_checked"] = len(required)
358
+ return result
359
+
360
+
361
+ def main(argv: list[str] | None = None) -> int:
362
+ args = parser().parse_args(argv)
363
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s", stream=sys.stderr)
364
+ try:
365
+ if args.command == "setup":
366
+ if args.limit < 1:
367
+ raise ValueError("limit must be a positive integer")
368
+ print(
369
+ json.dumps(
370
+ setup(all_fonts=args.all_fonts, limit=args.limit, tatoeba=not args.offline),
371
+ ensure_ascii=False,
372
+ indent=2,
373
+ )
374
+ )
375
+ return 0
376
+ try:
377
+ config, personal = resolve_config(args.config, load_config)
378
+ except NotSetUp:
379
+ # The app sets itself up on first use; other commands say what to run.
380
+ if args.command != "ui" or args.config is not None:
381
+ raise
382
+ logging.getLogger(__name__).info("First run: setting up your workspace at %s", home())
383
+ setup()
384
+ config, personal = resolve_config(None, load_config)
385
+ if personal and args.command == "ui" and not ready(config):
386
+ setup()
387
+ if personal:
388
+ # Paths typed on the command line mean the current folder, not the workspace.
389
+ for name in ("output", "dataset"):
390
+ value = getattr(args, name, None)
391
+ if value is not None and not Path(value).is_absolute():
392
+ setattr(args, name, type(value)(Path.cwd() / value))
393
+ if args.command == "ui":
394
+ if not 1 <= args.port <= 65535:
395
+ raise ValueError("port must be between 1 and 65535")
396
+ if args.cache_mb <= 0:
397
+ raise ValueError("cache-mb must be a positive integer")
398
+ try:
399
+ import uvicorn
400
+
401
+ from .web import create_app
402
+ except ImportError as error:
403
+ raise ValueError(
404
+ "The web UI needs its optional dependencies. "
405
+ "Start with: uv run --extra web synthocr ui"
406
+ ) from error
407
+ app = create_app(
408
+ config,
409
+ output_root=config.path(str(args.output_root)) if args.output_root else None,
410
+ output_locations={
411
+ name: config.path(path) for name, path in args.output_locations.items()
412
+ }
413
+ if args.output_locations
414
+ else None,
415
+ cache_mb=args.cache_mb,
416
+ )
417
+ if not args.no_browser:
418
+ import threading
419
+ import webbrowser
420
+
421
+ host = f"[{args.host}]" if ":" in args.host else args.host
422
+ timer = threading.Timer(1, webbrowser.open, args=(f"http://{host}:{args.port}",))
423
+ timer.daemon = True
424
+ timer.start()
425
+ uvicorn.run(app, host=args.host, port=args.port, workers=1)
426
+ return 0
427
+ elif args.command == "prepare":
428
+ result = prepare(config, args.force)
429
+ elif args.command == "experiment":
430
+ from .experiments import run_experiment
431
+
432
+ result = run_experiment(config, config.path(str(args.output)), args.samples, args.seed)
433
+ elif args.command == "kurdish":
434
+ result = _kurdish(config, args)
435
+ elif args.command in {"generate", "benchmark"}:
436
+ overrides = {
437
+ name: getattr(args, name)
438
+ for name in (
439
+ "samples",
440
+ "workers",
441
+ "seed",
442
+ "output",
443
+ "format",
444
+ "languages",
445
+ "language_weights",
446
+ "suite",
447
+ "exclude_genres",
448
+ *AUGMENTATION_FIELDS,
449
+ )
450
+ if getattr(args, name) is not None
451
+ }
452
+ config = replace(config, generation=replace(config.generation, **overrides))
453
+ if args.fonts:
454
+ config = replace(config, fonts=replace(config.fonts, include=args.fonts))
455
+ config = config.validate()
456
+ if args.command == "benchmark":
457
+ with tempfile.TemporaryDirectory(prefix="synthocr-benchmark-") as directory:
458
+ config = replace(
459
+ config, generation=replace(config.generation, output=directory)
460
+ )
461
+ result = generate(config)
462
+ result.pop("output", None)
463
+ try:
464
+ import resource
465
+
466
+ peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
467
+ result["parent_peak_rss_bytes"] = (
468
+ peak if sys.platform == "darwin" else peak * 1024
469
+ )
470
+ except ImportError:
471
+ pass
472
+ else:
473
+ result = generate(config)
474
+ elif args.command == "fonts":
475
+ if args.font_command == "fetch":
476
+ result = fetch(
477
+ config.path(args.catalog) if args.catalog else CATALOG,
478
+ config.path(args.destination),
479
+ args.family,
480
+ )
481
+ else:
482
+ fonts = discover([config.path(p) for p in config.fonts.directories])
483
+ if args.font_command == "list":
484
+ ids = font_ids(fonts)
485
+ result = {
486
+ "fonts": [
487
+ {"path": f.path, "face_index": f.index, "id": ids[f]} for f in fonts
488
+ ]
489
+ }
490
+ else:
491
+ text = (
492
+ load_characters(config.path(str(args.chars)))
493
+ if args.chars
494
+ else load_profile(args.profile)
495
+ if args.profile
496
+ else args.text
497
+ )
498
+ result = check(fonts, text, config.fonts.coverage_cache_size)
499
+ if args.profile:
500
+ result["profile"] = args.profile
501
+ if (args.require_all and result["fully_supported"] != len(fonts)) or (
502
+ args.require_any and not result["fully_supported"]
503
+ ):
504
+ print(json.dumps(result, ensure_ascii=False, indent=2))
505
+ return 1
506
+ elif args.command == "text":
507
+ result = generate_text(
508
+ config.path(str(args.output)), args.count, args.seed, args.genre, args.language
509
+ )
510
+ elif args.command == "spelling":
511
+ result = write_spelling(config.path(str(args.output)), args.language, args.words_only)
512
+ elif args.command == "verify":
513
+ result = verify(config.path(str(args.dataset)), not args.metadata_only)
514
+ elif args.command == "preview":
515
+ result = preview(config, config.path(str(args.output)))
516
+ elif args.command == "corpora":
517
+ result = snapshot(
518
+ config.path(config.data.raw),
519
+ config.path(args.catalog),
520
+ config.path(args.cache),
521
+ args.language,
522
+ args.snapshot,
523
+ args.limit,
524
+ )
525
+ else:
526
+ result = export(
527
+ config.path(str(args.dataset)), config.path(str(args.output)), args.format
528
+ )
529
+ print(json.dumps(result, ensure_ascii=False, indent=2))
530
+ return 0
531
+ except (OSError, ValueError, KeyError, TypeError, sqlite3.Error, EOFError) as error:
532
+ logging.error("%s", error)
533
+ return 1
534
+ except KeyboardInterrupt:
535
+ logging.error("Interrupted")
536
+ return 130
537
+
538
+
539
+ if __name__ == "__main__":
540
+ raise SystemExit(main())