synthocr 2.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- synthocr/__init__.py +3 -0
- synthocr/__main__.py +3 -0
- synthocr/cli.py +540 -0
- synthocr/config.py +589 -0
- synthocr/corpora.py +160 -0
- synthocr/corpus_store.py +222 -0
- synthocr/downloads.py +77 -0
- synthocr/experiments.py +399 -0
- synthocr/font_catalog.json +579 -0
- synthocr/font_profiles.json +18 -0
- synthocr/fonts.py +225 -0
- synthocr/home.py +150 -0
- synthocr/kurdish_characters.json +442 -0
- synthocr/kurdish_spelling.json +45 -0
- synthocr/lexicons.json +82 -0
- synthocr/pipeline.py +512 -0
- synthocr/preview.py +112 -0
- synthocr/render.py +555 -0
- synthocr/spelling.py +111 -0
- synthocr/static/app.css +1111 -0
- synthocr/static/app.js +1759 -0
- synthocr/static/brand.svg +5 -0
- synthocr/static/index.html +773 -0
- synthocr/static/theme.js +28 -0
- synthocr/text.py +437 -0
- synthocr/verify.py +307 -0
- synthocr/web.py +2259 -0
- synthocr-2.1.0.dist-info/METADATA +261 -0
- synthocr-2.1.0.dist-info/RECORD +34 -0
- synthocr-2.1.0.dist-info/WHEEL +4 -0
- synthocr-2.1.0.dist-info/entry_points.txt +3 -0
- synthocr-2.1.0.dist-info/licenses/LICENSE-APACHE +202 -0
- synthocr-2.1.0.dist-info/licenses/LICENSE-MIT +21 -0
- synthocr-2.1.0.dist-info/licenses/NOTICE +25 -0
synthocr/__init__.py
ADDED
synthocr/__main__.py
ADDED
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())
|