wormocr 1.0.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.
- wormocr/__init__.py +18 -0
- wormocr/__main__.py +4 -0
- wormocr/api.py +229 -0
- wormocr/benchmark/__init__.py +5 -0
- wormocr/benchmark/report.py +403 -0
- wormocr/checkpoints.py +55 -0
- wormocr/cli.py +308 -0
- wormocr/config.py +220 -0
- wormocr/data/__init__.py +5 -0
- wormocr/data/dataset.py +110 -0
- wormocr/data/fonts.py +173 -0
- wormocr/data/handwriting.py +304 -0
- wormocr/data/lexicon.py +105 -0
- wormocr/data/lexicon_medical.py +48 -0
- wormocr/data/mixed.py +211 -0
- wormocr/data/synthetic.py +445 -0
- wormocr/detection/__init__.py +5 -0
- wormocr/detection/lines.py +382 -0
- wormocr/evaluation/__init__.py +5 -0
- wormocr/evaluation/errors.py +145 -0
- wormocr/evaluation/metrics.py +105 -0
- wormocr/evaluation/robustness.py +43 -0
- wormocr/exceptions.py +25 -0
- wormocr/export/__init__.py +5 -0
- wormocr/export/onnx_export.py +75 -0
- wormocr/hub.py +157 -0
- wormocr/inference/__init__.py +5 -0
- wormocr/inference/decode.py +113 -0
- wormocr/inference/engine.py +122 -0
- wormocr/models/__init__.py +5 -0
- wormocr/models/crnn.py +236 -0
- wormocr/preprocessing.py +135 -0
- wormocr/py.typed +0 -0
- wormocr/runtime.py +56 -0
- wormocr/training/__init__.py +5 -0
- wormocr/training/loop.py +645 -0
- wormocr/vocabulary.py +69 -0
- wormocr/web/__init__.py +5 -0
- wormocr/web/server.py +505 -0
- wormocr/web/static/app.js +514 -0
- wormocr/web/static/index.html +141 -0
- wormocr/web/static/style.css +379 -0
- wormocr-1.0.0.dist-info/METADATA +377 -0
- wormocr-1.0.0.dist-info/RECORD +48 -0
- wormocr-1.0.0.dist-info/WHEEL +5 -0
- wormocr-1.0.0.dist-info/entry_points.txt +2 -0
- wormocr-1.0.0.dist-info/licenses/LICENSE +21 -0
- wormocr-1.0.0.dist-info/top_level.txt +1 -0
wormocr/checkpoints.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Checkpoint format.
|
|
2
|
+
|
|
3
|
+
Checkpoints are PyTorch pickle files. Load only files you trust.
|
|
4
|
+
They store the model, optimizer, charset, config, and a maturity label.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import torch
|
|
13
|
+
|
|
14
|
+
from wormocr import __version__
|
|
15
|
+
from wormocr.exceptions import CheckpointError
|
|
16
|
+
|
|
17
|
+
FORMAT_VERSION = 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def public_config(config: dict[str, Any]) -> dict[str, Any]:
|
|
21
|
+
return {key: value for key, value in config.items() if not str(key).startswith("_")}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def save_checkpoint(path: str | Path, payload: dict[str, Any]) -> None:
|
|
25
|
+
destination = Path(path)
|
|
26
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
body = dict(payload)
|
|
28
|
+
body["wormocr_format"] = FORMAT_VERSION
|
|
29
|
+
body["wormocr_version"] = __version__
|
|
30
|
+
torch.save(body, destination)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_checkpoint(path: str | Path, map_location: str | torch.device = "cpu") -> dict[str, Any]:
|
|
34
|
+
source = Path(path)
|
|
35
|
+
if not source.exists():
|
|
36
|
+
raise CheckpointError(
|
|
37
|
+
f"checkpoint not found: {source}. Download the released model with "
|
|
38
|
+
"`wormocr download`, train one with `wormocr train --config configs/tiny.yaml`, "
|
|
39
|
+
"or pass an explicit --checkpoint."
|
|
40
|
+
)
|
|
41
|
+
if source.suffix == ".safetensors":
|
|
42
|
+
from wormocr.hub import load_safetensors
|
|
43
|
+
|
|
44
|
+
return load_safetensors(source, map_location=map_location)
|
|
45
|
+
try:
|
|
46
|
+
payload = torch.load(source, map_location=map_location, weights_only=False)
|
|
47
|
+
except TypeError:
|
|
48
|
+
payload = torch.load(source, map_location=map_location)
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
raise CheckpointError(f"cannot read checkpoint {source}: {exc}") from exc
|
|
51
|
+
if not isinstance(payload, dict) or "model_state" not in payload:
|
|
52
|
+
raise CheckpointError(f"{source} is not a wormOCR checkpoint")
|
|
53
|
+
if "charset" not in payload or "config" not in payload:
|
|
54
|
+
raise CheckpointError(f"{source} is missing its charset or config")
|
|
55
|
+
return payload
|
wormocr/cli.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
Every documented command is implemented here. Checkpoints are local files.
|
|
4
|
+
Only `wormocr download` uses the network.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from wormocr import __version__
|
|
15
|
+
from wormocr.benchmark.report import run_benchmark
|
|
16
|
+
from wormocr.config import load_config
|
|
17
|
+
from wormocr.data.fonts import discover_fonts
|
|
18
|
+
from wormocr.data.synthetic import generate_dataset
|
|
19
|
+
from wormocr.evaluation.errors import ErrorReport
|
|
20
|
+
from wormocr.evaluation.metrics import cer
|
|
21
|
+
from wormocr.evaluation.robustness import measure_robustness
|
|
22
|
+
from wormocr.exceptions import WormOCRError
|
|
23
|
+
from wormocr.export.onnx_export import export_onnx, onnx_max_abs_diff, quantize_onnx
|
|
24
|
+
from wormocr.hub import DEFAULT_MODEL, RELEASES, download
|
|
25
|
+
from wormocr.inference.engine import LineEngine
|
|
26
|
+
from wormocr.models.crnn import build_model, count_parameters
|
|
27
|
+
from wormocr.runtime import environment_report
|
|
28
|
+
from wormocr.training.loop import build_datasets, evaluate_model, make_loader, train
|
|
29
|
+
from wormocr.vocabulary import Vocabulary
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(argv: list[str] | None = None) -> int:
|
|
33
|
+
parser = build_parser()
|
|
34
|
+
args = parser.parse_args(argv)
|
|
35
|
+
try:
|
|
36
|
+
return int(args.func(args) or 0)
|
|
37
|
+
except WormOCRError as exc:
|
|
38
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
39
|
+
return 1
|
|
40
|
+
except MemoryError as exc:
|
|
41
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
42
|
+
return 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
46
|
+
parser = argparse.ArgumentParser(prog="wormocr", description="Small local OCR for printed English text.")
|
|
47
|
+
parser.add_argument("--version", action="version", version=f"wormocr {__version__}")
|
|
48
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
49
|
+
|
|
50
|
+
info = commands.add_parser("info", help="show the runtime, fonts, and model sizes")
|
|
51
|
+
info.set_defaults(func=command_info)
|
|
52
|
+
|
|
53
|
+
train_cmd = commands.add_parser("train", help="train or smoke-test a recognizer")
|
|
54
|
+
train_cmd.add_argument("--config", required=True)
|
|
55
|
+
train_cmd.add_argument("--mode", required=True, choices=["smoke", "debug", "baseline", "resume"])
|
|
56
|
+
train_cmd.add_argument("--checkpoint", help="checkpoint to resume from")
|
|
57
|
+
train_cmd.add_argument("--device", default=None)
|
|
58
|
+
train_cmd.add_argument("--set", dest="overrides", action="append", default=[], help="dotted override, such as train.max_steps=20")
|
|
59
|
+
train_cmd.set_defaults(func=command_train)
|
|
60
|
+
|
|
61
|
+
predict = commands.add_parser("predict", help="recognize an image or PDF")
|
|
62
|
+
predict.add_argument("--image", action="append", required=True)
|
|
63
|
+
predict.add_argument("--model", default=DEFAULT_MODEL, help=f"released model name (default: {DEFAULT_MODEL})")
|
|
64
|
+
predict.add_argument("--checkpoint", default=None)
|
|
65
|
+
predict.add_argument("--device", default="cpu")
|
|
66
|
+
predict.add_argument("--mode", default="auto", choices=["auto", "line", "page"])
|
|
67
|
+
predict.add_argument("--beam", type=int, default=0)
|
|
68
|
+
predict.add_argument("--json", action="store_true")
|
|
69
|
+
predict.add_argument("--no-orientation", action="store_true")
|
|
70
|
+
predict.set_defaults(func=command_predict)
|
|
71
|
+
|
|
72
|
+
evaluate = commands.add_parser("evaluate", help="measure CER and WER")
|
|
73
|
+
evaluate.add_argument("--model", required=True, help="checkpoint path")
|
|
74
|
+
evaluate.add_argument("--labels", default=None)
|
|
75
|
+
evaluate.add_argument("--image-root", default=None)
|
|
76
|
+
evaluate.add_argument("--synthetic-samples", type=int, default=100)
|
|
77
|
+
evaluate.add_argument("--seed", type=int, default=12345)
|
|
78
|
+
evaluate.add_argument("--device", default="cpu")
|
|
79
|
+
evaluate.add_argument("--beam", type=int, default=0)
|
|
80
|
+
evaluate.add_argument("--batch-size", type=int, default=8)
|
|
81
|
+
evaluate.add_argument("--errors", default=None, help="write an error-analysis markdown file")
|
|
82
|
+
evaluate.add_argument("--robustness", type=int, default=0, help="samples per corruption; 0 skips it")
|
|
83
|
+
evaluate.add_argument("--json", action="store_true")
|
|
84
|
+
evaluate.set_defaults(func=command_evaluate)
|
|
85
|
+
|
|
86
|
+
analyze = commands.add_parser("analyze", help="write an error analysis report")
|
|
87
|
+
analyze.add_argument("--model", required=True)
|
|
88
|
+
analyze.add_argument("--synthetic-samples", type=int, default=50)
|
|
89
|
+
analyze.add_argument("--seed", type=int, default=12345)
|
|
90
|
+
analyze.add_argument("--device", default="cpu")
|
|
91
|
+
analyze.add_argument("--output", required=True)
|
|
92
|
+
analyze.set_defaults(func=command_analyze)
|
|
93
|
+
|
|
94
|
+
export = commands.add_parser("export", help="export ONNX or INT8 ONNX")
|
|
95
|
+
export.add_argument("--model", required=True, help="checkpoint path")
|
|
96
|
+
export.add_argument("--format", required=True, choices=["onnx", "onnx-int8"])
|
|
97
|
+
export.add_argument("--output", required=True)
|
|
98
|
+
export.add_argument("--opset", type=int, default=17)
|
|
99
|
+
export.set_defaults(func=command_export)
|
|
100
|
+
|
|
101
|
+
benchmark = commands.add_parser("benchmark", help="measure size, accuracy, latency, and memory")
|
|
102
|
+
benchmark.add_argument("--model", required=True, help="checkpoint path")
|
|
103
|
+
benchmark.add_argument("--output", default="artifacts/benchmarks/report.md")
|
|
104
|
+
benchmark.add_argument("--samples", type=int, default=40)
|
|
105
|
+
benchmark.add_argument("--repeats", type=int, default=20)
|
|
106
|
+
benchmark.add_argument("--device", default="cpu")
|
|
107
|
+
benchmark.add_argument("--seed", type=int, default=12345)
|
|
108
|
+
benchmark.set_defaults(func=command_benchmark)
|
|
109
|
+
|
|
110
|
+
generate = commands.add_parser("generate", help="write a synthetic line dataset and manifest")
|
|
111
|
+
generate.add_argument("--config", default="configs/tiny.yaml")
|
|
112
|
+
generate.add_argument("--samples", type=int, default=32)
|
|
113
|
+
generate.add_argument("--out", required=True)
|
|
114
|
+
generate.add_argument("--seed", type=int, default=0)
|
|
115
|
+
generate.add_argument("--augment", action="store_true")
|
|
116
|
+
generate.set_defaults(func=command_generate)
|
|
117
|
+
|
|
118
|
+
serve_cmd = commands.add_parser("serve", help="open a local web UI for images and PDFs")
|
|
119
|
+
serve_cmd.add_argument("--model", default=DEFAULT_MODEL, help=f"released model name (default: {DEFAULT_MODEL})")
|
|
120
|
+
serve_cmd.add_argument("--checkpoint", default=None)
|
|
121
|
+
serve_cmd.add_argument("--device", default="cpu")
|
|
122
|
+
serve_cmd.add_argument("--host", default="127.0.0.1")
|
|
123
|
+
serve_cmd.add_argument("--port", type=int, default=8000)
|
|
124
|
+
serve_cmd.add_argument("--threads", type=int, default=None, help="CPU threads for PyTorch (default: half the logical CPUs, at most 16)")
|
|
125
|
+
serve_cmd.set_defaults(func=command_serve)
|
|
126
|
+
|
|
127
|
+
download_cmd = commands.add_parser("download", help="download released weights from GitHub and verify them")
|
|
128
|
+
download_cmd.add_argument("name", nargs="?", default=DEFAULT_MODEL, choices=sorted(RELEASES))
|
|
129
|
+
download_cmd.add_argument("--dir", default=None, help="cache folder (default: $WORMOCR_HOME or ~/.cache/wormocr)")
|
|
130
|
+
download_cmd.add_argument("--force", action="store_true", help="download again even if verified files exist")
|
|
131
|
+
download_cmd.add_argument("--no-onnx", action="store_true", help="skip the ONNX file")
|
|
132
|
+
download_cmd.set_defaults(func=command_download)
|
|
133
|
+
return parser
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def command_download(args: argparse.Namespace) -> int:
|
|
137
|
+
path = download(args.name, root=args.dir, force=args.force, include_onnx=not args.no_onnx)
|
|
138
|
+
print(f"{args.name} is ready: {path}")
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def command_serve(args: argparse.Namespace) -> int:
|
|
143
|
+
from wormocr.web import serve
|
|
144
|
+
|
|
145
|
+
serve(
|
|
146
|
+
model=args.model,
|
|
147
|
+
checkpoint=args.checkpoint,
|
|
148
|
+
device=args.device,
|
|
149
|
+
host=args.host,
|
|
150
|
+
port=args.port,
|
|
151
|
+
threads=args.threads,
|
|
152
|
+
)
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def command_info(_args: argparse.Namespace) -> int:
|
|
157
|
+
report = environment_report()
|
|
158
|
+
try:
|
|
159
|
+
fonts = discover_fonts("")
|
|
160
|
+
report["fonts"] = len(fonts)
|
|
161
|
+
except WormOCRError as exc:
|
|
162
|
+
report["fonts"] = 0
|
|
163
|
+
report["font_error"] = str(exc)
|
|
164
|
+
charset = Vocabulary()
|
|
165
|
+
for name in ("micro", "tiny", "small"):
|
|
166
|
+
config_name = f"configs/{name}.yaml"
|
|
167
|
+
try:
|
|
168
|
+
config = load_config(config_name)
|
|
169
|
+
except WormOCRError:
|
|
170
|
+
continue
|
|
171
|
+
model = build_model(config, charset.size)
|
|
172
|
+
report[f"{name}_parameters"] = count_parameters(model)
|
|
173
|
+
print(json.dumps(report, indent=2))
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def command_train(args: argparse.Namespace) -> int:
|
|
178
|
+
config = load_config(args.config, args.overrides, mode=args.mode)
|
|
179
|
+
if args.device:
|
|
180
|
+
config["device"] = args.device
|
|
181
|
+
result = train(config, mode=args.mode, resume_from=args.checkpoint)
|
|
182
|
+
print(json.dumps({
|
|
183
|
+
"last_checkpoint": result.last_checkpoint,
|
|
184
|
+
"best_checkpoint": result.best_checkpoint,
|
|
185
|
+
"steps": result.steps,
|
|
186
|
+
"best_cer": result.best_cer,
|
|
187
|
+
"maturity": result.maturity,
|
|
188
|
+
"final_loss": result.final_loss,
|
|
189
|
+
}, indent=2))
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def command_predict(args: argparse.Namespace) -> int:
|
|
194
|
+
from wormocr.api import OCR
|
|
195
|
+
|
|
196
|
+
ocr = OCR(model=args.model, checkpoint=args.checkpoint, device=args.device, beam_width=args.beam)
|
|
197
|
+
results = [
|
|
198
|
+
ocr.predict(image, mode=args.mode, correct_orientation=not args.no_orientation)
|
|
199
|
+
for image in args.image
|
|
200
|
+
]
|
|
201
|
+
if args.json or len(results) > 1:
|
|
202
|
+
print(json.dumps([result.to_dict() for result in results], indent=2, ensure_ascii=True))
|
|
203
|
+
else:
|
|
204
|
+
print(results[0].text)
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def command_evaluate(args: argparse.Namespace) -> int:
|
|
209
|
+
engine = LineEngine.load(args.model, device=args.device, beam_width=args.beam)
|
|
210
|
+
config = dict(engine.config)
|
|
211
|
+
if args.labels:
|
|
212
|
+
config["data"]["labels"] = args.labels
|
|
213
|
+
config["data"]["image_root"] = args.image_root or str(Path(args.labels).parent)
|
|
214
|
+
config["data"]["val_labels"] = None
|
|
215
|
+
else:
|
|
216
|
+
config["data"]["labels"] = None
|
|
217
|
+
config["data"]["train_samples"] = args.synthetic_samples
|
|
218
|
+
config["data"]["augment"] = False
|
|
219
|
+
config["seed"] = args.seed
|
|
220
|
+
if args.labels:
|
|
221
|
+
dataset, _val, vocabulary = build_datasets(config)
|
|
222
|
+
else:
|
|
223
|
+
from wormocr.data.dataset import OnlineSynthDataset
|
|
224
|
+
|
|
225
|
+
vocabulary = engine.vocabulary
|
|
226
|
+
dataset = OnlineSynthDataset(config, args.synthetic_samples, args.seed, augment=False)
|
|
227
|
+
loader = make_loader(dataset, args.batch_size, False, args.seed, 0)
|
|
228
|
+
totals, pairs = evaluate_model(
|
|
229
|
+
engine.model, loader, vocabulary, engine.device, collect=bool(args.errors)
|
|
230
|
+
)
|
|
231
|
+
payload = totals.to_dict()
|
|
232
|
+
payload["maturity"] = engine.maturity
|
|
233
|
+
payload["checkpoint"] = str(args.model)
|
|
234
|
+
payload["synthetic"] = not bool(args.labels)
|
|
235
|
+
if args.robustness:
|
|
236
|
+
payload["robustness"] = measure_robustness(engine, args.robustness, args.seed)
|
|
237
|
+
if args.errors:
|
|
238
|
+
report = ErrorReport()
|
|
239
|
+
for reference, hypothesis in pairs:
|
|
240
|
+
report.add(reference, hypothesis, cer(reference, hypothesis))
|
|
241
|
+
Path(args.errors).parent.mkdir(parents=True, exist_ok=True)
|
|
242
|
+
Path(args.errors).write_text(report.to_markdown(), encoding="utf-8")
|
|
243
|
+
payload["errors"] = args.errors
|
|
244
|
+
print(json.dumps(payload, indent=2))
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def command_analyze(args: argparse.Namespace) -> int:
|
|
249
|
+
args.errors = args.output
|
|
250
|
+
args.labels = None
|
|
251
|
+
args.image_root = None
|
|
252
|
+
args.beam = 0
|
|
253
|
+
args.batch_size = 8
|
|
254
|
+
args.robustness = 0
|
|
255
|
+
args.json = True
|
|
256
|
+
return command_evaluate(args)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def command_export(args: argparse.Namespace) -> int:
|
|
260
|
+
engine = LineEngine.load(args.model, device="cpu")
|
|
261
|
+
output = Path(args.output)
|
|
262
|
+
if args.format == "onnx":
|
|
263
|
+
export_onnx(engine.model, output, opset=args.opset)
|
|
264
|
+
diff = onnx_max_abs_diff(engine.model, output)
|
|
265
|
+
if diff > 1e-3:
|
|
266
|
+
raise WormOCRError(f"ONNX outputs differ from PyTorch by {diff:.4g}")
|
|
267
|
+
print(json.dumps({"output": str(output), "bytes": output.stat().st_size, "max_abs_diff": diff}, indent=2))
|
|
268
|
+
return 0
|
|
269
|
+
fp32 = output.with_suffix(".fp32.onnx")
|
|
270
|
+
export_onnx(engine.model, fp32, opset=args.opset)
|
|
271
|
+
fp32_diff = onnx_max_abs_diff(engine.model, fp32)
|
|
272
|
+
quantize_onnx(fp32, output)
|
|
273
|
+
int8_diff = onnx_max_abs_diff(engine.model, output)
|
|
274
|
+
print(json.dumps({
|
|
275
|
+
"output": str(output),
|
|
276
|
+
"fp32": str(fp32),
|
|
277
|
+
"fp32_bytes": fp32.stat().st_size,
|
|
278
|
+
"int8_bytes": output.stat().st_size,
|
|
279
|
+
"fp32_max_abs_diff": fp32_diff,
|
|
280
|
+
"int8_max_abs_diff": int8_diff,
|
|
281
|
+
}, indent=2))
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def command_benchmark(args: argparse.Namespace) -> int:
|
|
286
|
+
report = run_benchmark(
|
|
287
|
+
args.model,
|
|
288
|
+
args.output,
|
|
289
|
+
samples=args.samples,
|
|
290
|
+
seed=args.seed,
|
|
291
|
+
repeats=args.repeats,
|
|
292
|
+
device=args.device,
|
|
293
|
+
)
|
|
294
|
+
print(f"wrote {args.output}")
|
|
295
|
+
synthetic = report["synthetic_held_out"]
|
|
296
|
+
print(
|
|
297
|
+
f"parameters={report['parameters']} maturity={report['maturity']} "
|
|
298
|
+
f"CER={synthetic['cer']:.4f} median_model_ms="
|
|
299
|
+
f"{report['latency']['model_only_batch1_width256']['median_ms']:.2f}"
|
|
300
|
+
)
|
|
301
|
+
return 0
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def command_generate(args: argparse.Namespace) -> int:
|
|
305
|
+
config = load_config(args.config)
|
|
306
|
+
manifest = generate_dataset(config, args.samples, args.out, args.seed, augment=args.augment)
|
|
307
|
+
print(manifest)
|
|
308
|
+
return 0
|
wormocr/config.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""YAML configuration, defaults, and dotted command-line overrides."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from wormocr.exceptions import ConfigError
|
|
11
|
+
|
|
12
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
13
|
+
"seed": 1337,
|
|
14
|
+
"device": "auto",
|
|
15
|
+
"model": {
|
|
16
|
+
"name": "tiny",
|
|
17
|
+
"in_channels": 1,
|
|
18
|
+
"image_height": 32,
|
|
19
|
+
"width_stride": 4,
|
|
20
|
+
"channels": [32, 64, 128, 192],
|
|
21
|
+
"separable": True,
|
|
22
|
+
"rnn_hidden": 320,
|
|
23
|
+
"rnn_layers": 2,
|
|
24
|
+
"rnn_dropout": 0.1,
|
|
25
|
+
"proj_size": 256,
|
|
26
|
+
},
|
|
27
|
+
"vocab": {"charset": None},
|
|
28
|
+
"data": {
|
|
29
|
+
"train_samples": 4000,
|
|
30
|
+
"val_samples": 400,
|
|
31
|
+
"val_seed": None,
|
|
32
|
+
"font_size_min": 16,
|
|
33
|
+
"font_size_max": 32,
|
|
34
|
+
"max_chars": 40,
|
|
35
|
+
"augment": True,
|
|
36
|
+
"font_dir": None,
|
|
37
|
+
"labels": None,
|
|
38
|
+
"val_labels": None,
|
|
39
|
+
"image_root": None,
|
|
40
|
+
"val_image_root": None,
|
|
41
|
+
"max_width": 1024,
|
|
42
|
+
},
|
|
43
|
+
"train": {
|
|
44
|
+
"epochs": 10,
|
|
45
|
+
"batch_size": 32,
|
|
46
|
+
"lr": 0.001,
|
|
47
|
+
"min_lr_ratio": 0.1,
|
|
48
|
+
"weight_decay": 0.01,
|
|
49
|
+
"grad_clip": 5.0,
|
|
50
|
+
"warmup_steps": 100,
|
|
51
|
+
"max_steps": None,
|
|
52
|
+
"max_minutes": None,
|
|
53
|
+
"early_stopping_patience": 5,
|
|
54
|
+
"checkpoint_dir": "artifacts/checkpoints/tiny",
|
|
55
|
+
"log_every": 20,
|
|
56
|
+
"save_every": 5,
|
|
57
|
+
"amp": False,
|
|
58
|
+
"num_workers": 0,
|
|
59
|
+
"torch_threads": None,
|
|
60
|
+
"ema_decay": 0.0,
|
|
61
|
+
"eval_every": 0,
|
|
62
|
+
},
|
|
63
|
+
"eval": {"batch_size": 32, "beam_width": 0, "synthetic_samples": 200},
|
|
64
|
+
"export": {"opset": 17},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
MODE_PRESETS: dict[str, dict[str, Any]] = {
|
|
68
|
+
"smoke": {
|
|
69
|
+
"train": {
|
|
70
|
+
"epochs": 1,
|
|
71
|
+
"max_steps": 2,
|
|
72
|
+
"batch_size": 4,
|
|
73
|
+
"log_every": 1,
|
|
74
|
+
"early_stopping_patience": 0,
|
|
75
|
+
"warmup_steps": 0,
|
|
76
|
+
"save_every": 0,
|
|
77
|
+
"max_minutes": 5,
|
|
78
|
+
"num_workers": 0,
|
|
79
|
+
"eval_every": 0,
|
|
80
|
+
},
|
|
81
|
+
"data": {"train_samples": 8, "val_samples": 4, "augment": False},
|
|
82
|
+
},
|
|
83
|
+
"debug": {
|
|
84
|
+
"train": {
|
|
85
|
+
"epochs": 1,
|
|
86
|
+
"max_steps": 40,
|
|
87
|
+
"log_every": 5,
|
|
88
|
+
"early_stopping_patience": 0,
|
|
89
|
+
"warmup_steps": 0,
|
|
90
|
+
"max_minutes": 15,
|
|
91
|
+
"num_workers": 0,
|
|
92
|
+
"eval_every": 0,
|
|
93
|
+
},
|
|
94
|
+
"data": {"train_samples": 128, "val_samples": 32},
|
|
95
|
+
},
|
|
96
|
+
"baseline": {},
|
|
97
|
+
"resume": {},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def repo_root() -> Path:
|
|
102
|
+
here = Path(__file__).resolve()
|
|
103
|
+
for parent in here.parents:
|
|
104
|
+
if (parent / "pyproject.toml").exists() and (parent / "configs").exists():
|
|
105
|
+
return parent
|
|
106
|
+
return Path.cwd()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def config_path(name: str) -> Path:
|
|
110
|
+
candidate = Path(name)
|
|
111
|
+
if candidate.exists():
|
|
112
|
+
return candidate
|
|
113
|
+
rooted = repo_root() / name
|
|
114
|
+
if rooted.exists():
|
|
115
|
+
return rooted
|
|
116
|
+
by_stem = repo_root() / "configs" / f"{name}.yaml"
|
|
117
|
+
if by_stem.exists():
|
|
118
|
+
return by_stem
|
|
119
|
+
raise ConfigError(f"config not found: {name}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def deep_update(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
123
|
+
merged = dict(base)
|
|
124
|
+
for key, value in override.items():
|
|
125
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
126
|
+
merged[key] = deep_update(merged[key], value)
|
|
127
|
+
else:
|
|
128
|
+
merged[key] = value
|
|
129
|
+
return merged
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_override_value(raw: str) -> Any:
|
|
133
|
+
try:
|
|
134
|
+
return yaml.safe_load(raw)
|
|
135
|
+
except yaml.YAMLError:
|
|
136
|
+
return raw
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def set_dotted(config: dict[str, Any], dotted: str, value: Any) -> None:
|
|
140
|
+
if not dotted or dotted.startswith(".") or dotted.endswith("."):
|
|
141
|
+
raise ConfigError(f"invalid override key: {dotted!r}")
|
|
142
|
+
cursor = config
|
|
143
|
+
keys = dotted.split(".")
|
|
144
|
+
for key in keys[:-1]:
|
|
145
|
+
child = cursor.get(key)
|
|
146
|
+
if not isinstance(child, dict):
|
|
147
|
+
child = {}
|
|
148
|
+
cursor[key] = child
|
|
149
|
+
cursor = child
|
|
150
|
+
cursor[keys[-1]] = value
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def apply_overrides(config: dict[str, Any], overrides: list[str] | None) -> dict[str, Any]:
|
|
154
|
+
updated = config
|
|
155
|
+
for item in overrides or []:
|
|
156
|
+
if "=" not in item:
|
|
157
|
+
raise ConfigError(f"override must look like key=value, got {item!r}")
|
|
158
|
+
key, raw = item.split("=", 1)
|
|
159
|
+
set_dotted(updated, key.strip(), parse_override_value(raw))
|
|
160
|
+
return updated
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def apply_mode(config: dict[str, Any], mode: str, labels_set: bool = False) -> dict[str, Any]:
|
|
164
|
+
if mode not in MODE_PRESETS:
|
|
165
|
+
known = ", ".join(sorted(MODE_PRESETS))
|
|
166
|
+
raise ConfigError(f"unknown train mode {mode!r}. Expected one of: {known}")
|
|
167
|
+
preset = MODE_PRESETS[mode]
|
|
168
|
+
if mode == "smoke" and labels_set:
|
|
169
|
+
preset = {"train": preset.get("train", {})}
|
|
170
|
+
return deep_update(config, preset)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def validate_config(config: dict[str, Any]) -> None:
|
|
174
|
+
channels = config["model"]["channels"]
|
|
175
|
+
if not isinstance(channels, list) or len(channels) != 4:
|
|
176
|
+
raise ConfigError("model.channels must be a list of four positive integers")
|
|
177
|
+
if any(int(channel) < 4 for channel in channels):
|
|
178
|
+
raise ConfigError("each model channel count must be at least 4")
|
|
179
|
+
height = int(config["model"]["image_height"])
|
|
180
|
+
if height % 16 != 0 or height < 16:
|
|
181
|
+
raise ConfigError("model.image_height must be a multiple of 16 and at least 16")
|
|
182
|
+
if int(config["model"]["rnn_hidden"]) < 8:
|
|
183
|
+
raise ConfigError("model.rnn_hidden must be at least 8")
|
|
184
|
+
if int(config["model"]["rnn_layers"]) < 1:
|
|
185
|
+
raise ConfigError("model.rnn_layers must be at least 1")
|
|
186
|
+
if int(config["train"]["batch_size"]) < 1:
|
|
187
|
+
raise ConfigError("train.batch_size must be at least 1")
|
|
188
|
+
if int(config["data"]["train_samples"]) < 2:
|
|
189
|
+
raise ConfigError("data.train_samples must be at least 2")
|
|
190
|
+
if float(config["train"]["lr"]) <= 0:
|
|
191
|
+
raise ConfigError("train.lr must be positive")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def load_config(
|
|
195
|
+
path: str | Path,
|
|
196
|
+
overrides: list[str] | None = None,
|
|
197
|
+
mode: str | None = None,
|
|
198
|
+
) -> dict[str, Any]:
|
|
199
|
+
source = config_path(str(path))
|
|
200
|
+
try:
|
|
201
|
+
loaded = yaml.safe_load(source.read_text(encoding="utf-8")) or {}
|
|
202
|
+
except OSError as exc:
|
|
203
|
+
raise ConfigError(f"cannot read config {source}") from exc
|
|
204
|
+
except yaml.YAMLError as exc:
|
|
205
|
+
raise ConfigError(f"invalid YAML in {source}: {exc}") from exc
|
|
206
|
+
if not isinstance(loaded, dict):
|
|
207
|
+
raise ConfigError(f"config root must be a mapping: {source}")
|
|
208
|
+
config = deep_update(DEFAULT_CONFIG, loaded)
|
|
209
|
+
config = apply_overrides(config, overrides)
|
|
210
|
+
if mode:
|
|
211
|
+
config = apply_mode(config, mode, labels_set=bool(config["data"].get("labels")))
|
|
212
|
+
config = apply_overrides(config, overrides)
|
|
213
|
+
validate_config(config)
|
|
214
|
+
config["_config_path"] = str(source)
|
|
215
|
+
return config
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def dump_config(config: dict[str, Any]) -> str:
|
|
219
|
+
public = {key: value for key, value in config.items() if not str(key).startswith("_")}
|
|
220
|
+
return yaml.safe_dump(public, sort_keys=False)
|