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.
Files changed (48) hide show
  1. wormocr/__init__.py +18 -0
  2. wormocr/__main__.py +4 -0
  3. wormocr/api.py +229 -0
  4. wormocr/benchmark/__init__.py +5 -0
  5. wormocr/benchmark/report.py +403 -0
  6. wormocr/checkpoints.py +55 -0
  7. wormocr/cli.py +308 -0
  8. wormocr/config.py +220 -0
  9. wormocr/data/__init__.py +5 -0
  10. wormocr/data/dataset.py +110 -0
  11. wormocr/data/fonts.py +173 -0
  12. wormocr/data/handwriting.py +304 -0
  13. wormocr/data/lexicon.py +105 -0
  14. wormocr/data/lexicon_medical.py +48 -0
  15. wormocr/data/mixed.py +211 -0
  16. wormocr/data/synthetic.py +445 -0
  17. wormocr/detection/__init__.py +5 -0
  18. wormocr/detection/lines.py +382 -0
  19. wormocr/evaluation/__init__.py +5 -0
  20. wormocr/evaluation/errors.py +145 -0
  21. wormocr/evaluation/metrics.py +105 -0
  22. wormocr/evaluation/robustness.py +43 -0
  23. wormocr/exceptions.py +25 -0
  24. wormocr/export/__init__.py +5 -0
  25. wormocr/export/onnx_export.py +75 -0
  26. wormocr/hub.py +157 -0
  27. wormocr/inference/__init__.py +5 -0
  28. wormocr/inference/decode.py +113 -0
  29. wormocr/inference/engine.py +122 -0
  30. wormocr/models/__init__.py +5 -0
  31. wormocr/models/crnn.py +236 -0
  32. wormocr/preprocessing.py +135 -0
  33. wormocr/py.typed +0 -0
  34. wormocr/runtime.py +56 -0
  35. wormocr/training/__init__.py +5 -0
  36. wormocr/training/loop.py +645 -0
  37. wormocr/vocabulary.py +69 -0
  38. wormocr/web/__init__.py +5 -0
  39. wormocr/web/server.py +505 -0
  40. wormocr/web/static/app.js +514 -0
  41. wormocr/web/static/index.html +141 -0
  42. wormocr/web/static/style.css +379 -0
  43. wormocr-1.0.0.dist-info/METADATA +377 -0
  44. wormocr-1.0.0.dist-info/RECORD +48 -0
  45. wormocr-1.0.0.dist-info/WHEEL +5 -0
  46. wormocr-1.0.0.dist-info/entry_points.txt +2 -0
  47. wormocr-1.0.0.dist-info/licenses/LICENSE +21 -0
  48. 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)
@@ -0,0 +1,5 @@
1
+ """Synthetic and file-backed OCR datasets."""
2
+
3
+ from wormocr.data.dataset import LabelDataset, OnlineSynthDataset, collate_lines
4
+
5
+ __all__ = ["LabelDataset", "OnlineSynthDataset", "collate_lines"]