ocr-foundry 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- khocr_gen/__init__.py +11 -0
- khocr_gen/_pil_compat.py +25 -0
- khocr_gen/_rust_accel.py +204 -0
- khocr_gen/augmentation.py +1193 -0
- khocr_gen/benchmark.py +888 -0
- khocr_gen/benchmark_cmd.py +103 -0
- khocr_gen/cli.py +246 -0
- khocr_gen/combine.py +214 -0
- khocr_gen/combine_cmd.py +125 -0
- khocr_gen/config.py +1474 -0
- khocr_gen/config_loader.py +157 -0
- khocr_gen/corpus.py +621 -0
- khocr_gen/data_generator.py +1160 -0
- khocr_gen/errors.py +33 -0
- khocr_gen/evaluate_cmd.py +50 -0
- khocr_gen/evaluation.py +259 -0
- khocr_gen/fonts.py +316 -0
- khocr_gen/generate.py +227 -0
- khocr_gen/layout.py +1028 -0
- khocr_gen/layout_cmd.py +371 -0
- khocr_gen/layout_degradation.py +128 -0
- khocr_gen/layout_mesh.py +200 -0
- khocr_gen/layout_shaping.py +264 -0
- khocr_gen/layout_transform.py +238 -0
- khocr_gen/layout_warp.py +473 -0
- khocr_gen/line_height.py +127 -0
- khocr_gen/lmdb_pack.py +132 -0
- khocr_gen/logging.py +25 -0
- khocr_gen/normalizer.py +221 -0
- khocr_gen/parallel.py +366 -0
- khocr_gen/provenance.py +328 -0
- khocr_gen/rendering.py +1607 -0
- khocr_gen/text_support.py +429 -0
- khocr_gen/verify.py +471 -0
- khocr_gen/viewer.py +172 -0
- ocr_foundry-0.2.0.dist-info/METADATA +174 -0
- ocr_foundry-0.2.0.dist-info/RECORD +40 -0
- ocr_foundry-0.2.0.dist-info/WHEEL +4 -0
- ocr_foundry-0.2.0.dist-info/entry_points.txt +3 -0
- ocr_foundry-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Frozen-benchmark CLI (freeze/verify/compare); independent of rendering dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
import argparse
|
|
11
|
+
|
|
12
|
+
from .benchmark import (
|
|
13
|
+
DEFAULT_LOCK,
|
|
14
|
+
SPLITS,
|
|
15
|
+
compare,
|
|
16
|
+
format_comparison_markdown,
|
|
17
|
+
freeze,
|
|
18
|
+
verify,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _expectations(parser: argparse.ArgumentParser) -> None:
|
|
23
|
+
parser.add_argument("--expect-dataset-fingerprint", help="Fail unless the dataset matches")
|
|
24
|
+
parser.add_argument("--expect-suite-fingerprint", help="Fail unless dataset+runs match")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def add_args(parser: argparse.ArgumentParser) -> None:
|
|
28
|
+
sub = parser.add_subparsers(dest="benchmark_command", metavar="ACTION", required=True)
|
|
29
|
+
p = sub.add_parser("freeze", help="Validate a manifest and write a new lock beside it")
|
|
30
|
+
p.add_argument("--manifest", required=True, help="Suite manifest JSON; its directory is root")
|
|
31
|
+
p.add_argument("--lock", default=DEFAULT_LOCK, help="New lock path relative to the root")
|
|
32
|
+
p = sub.add_parser("verify", help="Verify a lock against its files and leakage rules")
|
|
33
|
+
p.add_argument("--lock", required=True)
|
|
34
|
+
p.add_argument(
|
|
35
|
+
"--check-near-duplicates",
|
|
36
|
+
action="store_true",
|
|
37
|
+
default=False,
|
|
38
|
+
help="Opt-in cross-split near-duplicate line leakage detection via character n-gram Jaccard similarity",
|
|
39
|
+
)
|
|
40
|
+
p.add_argument(
|
|
41
|
+
"--near-duplicate-threshold",
|
|
42
|
+
type=float,
|
|
43
|
+
default=0.8,
|
|
44
|
+
help="Jaccard similarity threshold for near-duplicate detection (default: 0.8)",
|
|
45
|
+
)
|
|
46
|
+
_expectations(p)
|
|
47
|
+
p = sub.add_parser("compare", help="Verify, then score all named runs on frozen references")
|
|
48
|
+
p.add_argument("--lock", required=True)
|
|
49
|
+
p.add_argument(
|
|
50
|
+
"--split",
|
|
51
|
+
action="append",
|
|
52
|
+
choices=SPLITS,
|
|
53
|
+
help="Split to score (repeatable); default: test",
|
|
54
|
+
)
|
|
55
|
+
p.add_argument("--baseline", help="Baseline run name (default: the only baseline-role run)")
|
|
56
|
+
p.add_argument("--normalization", choices=["none", "NFC"], default="none")
|
|
57
|
+
p.add_argument("--word-tokenization", choices=["whitespace"], default=None)
|
|
58
|
+
p.add_argument(
|
|
59
|
+
"--format",
|
|
60
|
+
choices=["text", "json", "markdown"],
|
|
61
|
+
default="text",
|
|
62
|
+
help="Output format (default: text)",
|
|
63
|
+
)
|
|
64
|
+
_expectations(p)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def run(args: argparse.Namespace) -> int:
|
|
68
|
+
expected = {
|
|
69
|
+
"expect_dataset_fingerprint": getattr(args, "expect_dataset_fingerprint", None),
|
|
70
|
+
"expect_suite_fingerprint": getattr(args, "expect_suite_fingerprint", None),
|
|
71
|
+
}
|
|
72
|
+
try:
|
|
73
|
+
if args.benchmark_command == "freeze":
|
|
74
|
+
report = freeze(args.manifest, args.lock)
|
|
75
|
+
elif args.benchmark_command == "verify":
|
|
76
|
+
report = verify(
|
|
77
|
+
args.lock,
|
|
78
|
+
check_near_duplicates=getattr(args, "check_near_duplicates", False),
|
|
79
|
+
near_duplicate_threshold=float(getattr(args, "near_duplicate_threshold", 0.8)),
|
|
80
|
+
**expected,
|
|
81
|
+
)
|
|
82
|
+
else:
|
|
83
|
+
report = compare(
|
|
84
|
+
args.lock,
|
|
85
|
+
splits=args.split or ["test"],
|
|
86
|
+
baseline=args.baseline,
|
|
87
|
+
normalization=args.normalization,
|
|
88
|
+
word_tokenization=args.word_tokenization,
|
|
89
|
+
**expected,
|
|
90
|
+
)
|
|
91
|
+
fmt = getattr(args, "format", "json")
|
|
92
|
+
if fmt == "markdown":
|
|
93
|
+
output = format_comparison_markdown(report)
|
|
94
|
+
elif fmt == "text":
|
|
95
|
+
# Default comparison text output: formatted JSON (same as prior runs)
|
|
96
|
+
output = json.dumps(report, ensure_ascii=False, allow_nan=False, indent=2)
|
|
97
|
+
else: # json
|
|
98
|
+
output = json.dumps(report, ensure_ascii=False, allow_nan=False, indent=2)
|
|
99
|
+
except (ValueError, OSError) as exc:
|
|
100
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
101
|
+
return 2
|
|
102
|
+
print(output)
|
|
103
|
+
return 0
|
khocr_gen/cli.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""OCR Foundry - CLI entrypoint shared by `ocr-foundry` and `khocr-gen`.
|
|
2
|
+
|
|
3
|
+
Supports generate, combine, verify, view, and offline evaluate.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .config_loader import (
|
|
15
|
+
apply_config_defaults,
|
|
16
|
+
load_yaml_config,
|
|
17
|
+
resolve_config_path,
|
|
18
|
+
validate_config_keys,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
CommandModule = Any # duck-typed: has add_args(parser) and run(args) -> int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _SubParser(argparse.ArgumentParser):
|
|
29
|
+
"""Subcommand parser that tracks whether its args have been lazily configured."""
|
|
30
|
+
|
|
31
|
+
_lazy_loaded: bool = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_generate() -> CommandModule:
|
|
35
|
+
from . import generate
|
|
36
|
+
|
|
37
|
+
return generate
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load_combine() -> CommandModule:
|
|
41
|
+
from . import combine_cmd
|
|
42
|
+
|
|
43
|
+
return combine_cmd
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load_verify() -> CommandModule:
|
|
47
|
+
from . import verify
|
|
48
|
+
|
|
49
|
+
return verify
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _load_view() -> CommandModule:
|
|
53
|
+
from . import viewer
|
|
54
|
+
|
|
55
|
+
return viewer
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _load_evaluate() -> CommandModule:
|
|
59
|
+
from . import evaluate_cmd
|
|
60
|
+
|
|
61
|
+
return evaluate_cmd
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _load_benchmark() -> CommandModule:
|
|
65
|
+
from . import benchmark_cmd
|
|
66
|
+
|
|
67
|
+
return benchmark_cmd
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _load_layout() -> CommandModule:
|
|
71
|
+
from . import layout_cmd
|
|
72
|
+
|
|
73
|
+
return layout_cmd
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_COMMAND_LOADERS: dict[str, Callable[[], CommandModule]] = {
|
|
77
|
+
"benchmark": _load_benchmark,
|
|
78
|
+
"evaluate": _load_evaluate,
|
|
79
|
+
"generate": _load_generate,
|
|
80
|
+
"combine": _load_combine,
|
|
81
|
+
"layout": _load_layout,
|
|
82
|
+
"verify": _load_verify,
|
|
83
|
+
"view": _load_view,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_LEAF_HELP: dict[str, str] = {
|
|
87
|
+
"benchmark": "Freeze, verify and compare OCR benchmark suites from supplied predictions",
|
|
88
|
+
"evaluate": "Score supplied OCR predictions against reference JSONL (offline CER/line accuracy)",
|
|
89
|
+
"generate": "Generate synthetic training images from a text corpus",
|
|
90
|
+
"combine": "Combine multiple generated datasets into one merged LMDB dataset",
|
|
91
|
+
"layout": "Compose corpus lines into single-column page images with line annotations",
|
|
92
|
+
"verify": "Visual verification of augmentation methods at fixed MIN/MAX intensity (settable via --min/--max)",
|
|
93
|
+
"view": "Preview and extract images from an LMDB (.mdb) database",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# Commands that support -c / --config YAML loading
|
|
97
|
+
_CONFIG_COMMANDS: frozenset[str] = frozenset({"generate", "combine", "layout"})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _first_positional(tokens: list[str]) -> tuple[str | None, bool]:
|
|
101
|
+
"""Return (first non-flag token, whether a help/version flag preceded it)."""
|
|
102
|
+
help_seen = False
|
|
103
|
+
for token in tokens:
|
|
104
|
+
if token == "--":
|
|
105
|
+
return None, help_seen
|
|
106
|
+
if token in {"-h", "--help", "-v", "--version"}:
|
|
107
|
+
help_seen = True
|
|
108
|
+
continue
|
|
109
|
+
if token.startswith("-"):
|
|
110
|
+
continue
|
|
111
|
+
return token, help_seen
|
|
112
|
+
return None, help_seen
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _extract_command(argv: list[str]) -> str | None:
|
|
116
|
+
command, _ = _first_positional(argv)
|
|
117
|
+
if command is None or command not in _COMMAND_LOADERS:
|
|
118
|
+
return None
|
|
119
|
+
return command
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _configure_subcommand(
|
|
123
|
+
command: str,
|
|
124
|
+
subparser: _SubParser,
|
|
125
|
+
) -> None:
|
|
126
|
+
if subparser._lazy_loaded:
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
module = _COMMAND_LOADERS[command]()
|
|
130
|
+
module.add_args(subparser)
|
|
131
|
+
subparser.set_defaults(func=module.run)
|
|
132
|
+
subparser._lazy_loaded = True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _pre_config(argv: list[str]) -> str | None:
|
|
136
|
+
"""Extract the -c/--config value from *argv* without a full parse."""
|
|
137
|
+
i = 0
|
|
138
|
+
while i < len(argv):
|
|
139
|
+
token = argv[i]
|
|
140
|
+
if token == "--":
|
|
141
|
+
break
|
|
142
|
+
if token in ("-c", "--config") and i + 1 < len(argv):
|
|
143
|
+
return argv[i + 1]
|
|
144
|
+
if token.startswith("--config="):
|
|
145
|
+
return token[len("--config=") :]
|
|
146
|
+
if token.startswith("-c="):
|
|
147
|
+
return token[3:]
|
|
148
|
+
i += 1
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _apply_config_defaults(
|
|
153
|
+
command: str,
|
|
154
|
+
subparser: argparse.ArgumentParser,
|
|
155
|
+
argv: list[str],
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Load YAML config (if any) and push values as argparse defaults."""
|
|
158
|
+
if command not in _CONFIG_COMMANDS:
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
explicit_config = _pre_config(argv)
|
|
162
|
+
# Layout historically has no automatic recipe; explicit YAML is opt-in.
|
|
163
|
+
if command == "layout" and explicit_config is None:
|
|
164
|
+
return
|
|
165
|
+
config_path = resolve_config_path(explicit_config, command)
|
|
166
|
+
if config_path is None:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
cfg = load_yaml_config(config_path)
|
|
171
|
+
except FileNotFoundError as exc:
|
|
172
|
+
if explicit_config is not None:
|
|
173
|
+
print(f"error: {exc}")
|
|
174
|
+
sys.exit(2)
|
|
175
|
+
return
|
|
176
|
+
except Exception as exc:
|
|
177
|
+
print(f"error: failed to load config {config_path}: {exc}")
|
|
178
|
+
sys.exit(2)
|
|
179
|
+
|
|
180
|
+
cfg.pop("config", None)
|
|
181
|
+
|
|
182
|
+
if cfg:
|
|
183
|
+
validate_config_keys(subparser, cfg, command)
|
|
184
|
+
apply_config_defaults(subparser, cfg)
|
|
185
|
+
print(f"[config] loaded: {config_path}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _make_parser() -> tuple[argparse.ArgumentParser, dict[str, _SubParser]]:
|
|
189
|
+
# Preserve the invoked alias in usage/errors, including Windows console scripts.
|
|
190
|
+
executable = Path(sys.argv[0]).name.removesuffix(".exe")
|
|
191
|
+
prog = "khocr-gen" if executable == "khocr-gen" else "ocr-foundry"
|
|
192
|
+
parser = argparse.ArgumentParser(
|
|
193
|
+
prog=prog,
|
|
194
|
+
description=(
|
|
195
|
+
"OCR Foundry - synthetic OCR line generation with legacy Khmer/English "
|
|
196
|
+
"and opt-in multilingual RAQM rendering, isolated augmentation, "
|
|
197
|
+
"and offline evaluation."
|
|
198
|
+
),
|
|
199
|
+
epilog="ocr-foundry and khocr-gen run the same CLI; install the ocr-foundry distribution (with khocr-gen alias).",
|
|
200
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Existing scripts may parse the legacy command's exact version output.
|
|
204
|
+
version = (
|
|
205
|
+
f"khocr-gen {__version__}"
|
|
206
|
+
if prog == "khocr-gen"
|
|
207
|
+
else f"OCR Foundry {__version__} (ocr-foundry {__version__})"
|
|
208
|
+
)
|
|
209
|
+
parser.add_argument("-v", "--version", action="version", version=version)
|
|
210
|
+
|
|
211
|
+
subparsers = parser.add_subparsers(
|
|
212
|
+
dest="command", metavar="COMMAND", title="Commands", parser_class=_SubParser
|
|
213
|
+
)
|
|
214
|
+
subparsers.required = True
|
|
215
|
+
|
|
216
|
+
command_parsers: dict[str, _SubParser] = {}
|
|
217
|
+
for cmd_name in _COMMAND_LOADERS:
|
|
218
|
+
command_parsers[cmd_name] = subparsers.add_parser(
|
|
219
|
+
cmd_name,
|
|
220
|
+
help=_LEAF_HELP[cmd_name],
|
|
221
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return parser, command_parsers
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def main(argv: list[str] | None = None) -> None:
|
|
228
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
229
|
+
|
|
230
|
+
parser, command_parsers = _make_parser()
|
|
231
|
+
|
|
232
|
+
extracted = _extract_command(argv)
|
|
233
|
+
if extracted is not None:
|
|
234
|
+
command = extracted
|
|
235
|
+
subparser = command_parsers[command]
|
|
236
|
+
_configure_subcommand(command, subparser)
|
|
237
|
+
_apply_config_defaults(command, subparser, argv)
|
|
238
|
+
|
|
239
|
+
args = parser.parse_args(argv)
|
|
240
|
+
if not hasattr(args, "func"):
|
|
241
|
+
parser.error(f"Command '{getattr(args, 'command', None)}' was not configured.")
|
|
242
|
+
sys.exit(args.func(args))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
if __name__ == "__main__":
|
|
246
|
+
main()
|
khocr_gen/combine.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Combine multiple generated datasets into one merged dataset.
|
|
2
|
+
|
|
3
|
+
Supports n-way merge: `data_1 + data_2 + ... + data_n`.
|
|
4
|
+
|
|
5
|
+
Each input dataset is a directory containing `train/`, `val/`, and/or `test/` subdirectories,
|
|
6
|
+
where each split is either "raw" (`labels.txt` + `images/`) or "lmdb" (`lmdb/` subdirectory).
|
|
7
|
+
Inputs may use different formats independently per split; the merged output is always LMDB.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import shutil
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from tqdm import tqdm
|
|
18
|
+
|
|
19
|
+
from .lmdb_pack import pack_lmdb
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
|
|
24
|
+
SPLITS: tuple[str, ...] = ("train", "val", "test")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _write_vocab(chars: set[str], output_path: Path) -> None:
|
|
28
|
+
"""Write a vocab.json covering *chars*, with "<unk>" reserved at index 0."""
|
|
29
|
+
vocab: dict[str, int] = {"<unk>": 0}
|
|
30
|
+
for idx, ch in enumerate(sorted(chars), start=1):
|
|
31
|
+
if ch != "<unk>":
|
|
32
|
+
vocab[ch] = idx
|
|
33
|
+
|
|
34
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
with output_path.open("w", encoding="utf-8") as fh:
|
|
36
|
+
json.dump(vocab, fh, indent=2, ensure_ascii=False)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _detect_split_format(split_dir: Path) -> str | None:
|
|
40
|
+
"""Return "lmdb", "raw", or None (split absent/unrecognized)."""
|
|
41
|
+
if (split_dir / "lmdb" / "data.mdb").exists():
|
|
42
|
+
return "lmdb"
|
|
43
|
+
if (split_dir / "labels.txt").exists():
|
|
44
|
+
return "raw"
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _iter_raw_split_samples(split_dir: Path) -> Iterator[tuple[bytes, str, str]]:
|
|
49
|
+
"""Yield (image_bytes, file_extension, text) for a raw labels.txt + images/ split."""
|
|
50
|
+
labels_path = split_dir / "labels.txt"
|
|
51
|
+
images_dir = split_dir / "images"
|
|
52
|
+
|
|
53
|
+
with labels_path.open("r", encoding="utf-8") as fh:
|
|
54
|
+
for line in fh:
|
|
55
|
+
line = line.rstrip("\n")
|
|
56
|
+
if not line:
|
|
57
|
+
continue
|
|
58
|
+
parts = line.split("\t", 1)
|
|
59
|
+
if len(parts) < 2:
|
|
60
|
+
continue
|
|
61
|
+
img_name, text = parts[0].strip(), parts[1]
|
|
62
|
+
|
|
63
|
+
img_path = images_dir / img_name
|
|
64
|
+
if not img_path.exists():
|
|
65
|
+
img_path = split_dir / img_name
|
|
66
|
+
if not img_path.exists():
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
ext = img_path.suffix or ".jpg"
|
|
70
|
+
yield img_path.read_bytes(), ext, text
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _iter_lmdb_split_samples(split_dir: Path) -> Iterator[tuple[bytes, str, str]]:
|
|
74
|
+
"""Yield (image_bytes, file_extension, text) for an lmdb/ split.
|
|
75
|
+
|
|
76
|
+
Image bytes are the exact bytes stored in the LMDB (already JPEG-encoded).
|
|
77
|
+
"""
|
|
78
|
+
import lmdb # type: ignore[import-untyped]
|
|
79
|
+
|
|
80
|
+
lmdb_dir = split_dir / "lmdb"
|
|
81
|
+
env = lmdb.open(str(lmdb_dir), readonly=True, lock=False, readahead=False, meminit=False)
|
|
82
|
+
try:
|
|
83
|
+
with env.begin(write=False, buffers=True) as txn:
|
|
84
|
+
raw = txn.get(b"num-samples")
|
|
85
|
+
if raw is None:
|
|
86
|
+
return
|
|
87
|
+
num_samples = int(bytes(raw).decode())
|
|
88
|
+
for i in range(1, num_samples + 1):
|
|
89
|
+
img_bytes = txn.get(f"image-{i:09d}".encode())
|
|
90
|
+
lbl_bytes = txn.get(f"label-{i:09d}".encode())
|
|
91
|
+
if img_bytes is None or lbl_bytes is None:
|
|
92
|
+
continue
|
|
93
|
+
yield bytes(img_bytes), ".jpg", bytes(lbl_bytes).decode("utf-8")
|
|
94
|
+
finally:
|
|
95
|
+
env.close()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _iter_split_samples(split_dir: Path, fmt: str) -> Iterator[tuple[bytes, str, str]]:
|
|
99
|
+
if fmt == "lmdb":
|
|
100
|
+
yield from _iter_lmdb_split_samples(split_dir)
|
|
101
|
+
else:
|
|
102
|
+
yield from _iter_raw_split_samples(split_dir)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def combine_datasets(
|
|
106
|
+
input_dirs: list[str | Path],
|
|
107
|
+
output_dir: str | Path,
|
|
108
|
+
*,
|
|
109
|
+
keep_raw: bool = False,
|
|
110
|
+
jpeg_quality: int = 90,
|
|
111
|
+
map_size_gb: int = 256,
|
|
112
|
+
build_vocab: bool = True,
|
|
113
|
+
vocab_path: str | Path | None = None,
|
|
114
|
+
verbose: bool = False,
|
|
115
|
+
) -> dict[str, int]:
|
|
116
|
+
"""Merge multiple datasets into *output_dir*, per split, as LMDB.
|
|
117
|
+
|
|
118
|
+
Each dataset root may have any of `train/`, `val/`, and/or `test/`;
|
|
119
|
+
a split missing from an input is simply skipped.
|
|
120
|
+
A split absent from *all* inputs is skipped entirely.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
input_dirs: List of dataset root directories to merge.
|
|
124
|
+
output_dir: Where to write the merged dataset.
|
|
125
|
+
keep_raw: If True, keep the intermediate images/ directory after LMDB pack.
|
|
126
|
+
jpeg_quality: JPEG quality for stored images (1-100).
|
|
127
|
+
map_size_gb: LMDB map size in GiB.
|
|
128
|
+
build_vocab: If True, write a merged vocab.json covering every character
|
|
129
|
+
across all merged splits.
|
|
130
|
+
vocab_path: Where to write the merged vocab.json; defaults to
|
|
131
|
+
`output_dir/vocab.json`.
|
|
132
|
+
verbose: Print progress messages.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
A dict of split -> merged sample count.
|
|
136
|
+
"""
|
|
137
|
+
roots = [Path(d).expanduser().resolve() for d in input_dirs]
|
|
138
|
+
out_root = Path(output_dir).expanduser().resolve()
|
|
139
|
+
|
|
140
|
+
for root in roots:
|
|
141
|
+
if not root.exists():
|
|
142
|
+
raise FileNotFoundError(f"Dataset not found: {root}")
|
|
143
|
+
|
|
144
|
+
counts: dict[str, int] = {}
|
|
145
|
+
vocab_chars: set[str] = set()
|
|
146
|
+
|
|
147
|
+
for split in SPLITS:
|
|
148
|
+
# Collect all input splits that exist
|
|
149
|
+
split_inputs: list[tuple[Path, str]] = []
|
|
150
|
+
for root in roots:
|
|
151
|
+
split_dir = root / split
|
|
152
|
+
fmt = _detect_split_format(split_dir)
|
|
153
|
+
if fmt is not None:
|
|
154
|
+
split_inputs.append((split_dir, fmt))
|
|
155
|
+
|
|
156
|
+
if not split_inputs:
|
|
157
|
+
if verbose:
|
|
158
|
+
print(f" No '{split}' split found in any input, skipping")
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
out_split = out_root / split
|
|
162
|
+
merged_images = out_split / "images"
|
|
163
|
+
merged_labels = out_split / "labels.txt"
|
|
164
|
+
merged_images.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
|
|
166
|
+
n = 0
|
|
167
|
+
with merged_labels.open("w", encoding="utf-8") as lf:
|
|
168
|
+
for split_dir, fmt in split_inputs:
|
|
169
|
+
if verbose:
|
|
170
|
+
print(f" Merging {split_dir} ({fmt}) into {split}...")
|
|
171
|
+
samples = tqdm(
|
|
172
|
+
_iter_split_samples(split_dir, fmt),
|
|
173
|
+
desc=f" Merging {split}",
|
|
174
|
+
unit="img",
|
|
175
|
+
)
|
|
176
|
+
for img_bytes, ext, text in samples:
|
|
177
|
+
n += 1
|
|
178
|
+
img_name = f"img_{n:09d}{ext}"
|
|
179
|
+
(merged_images / img_name).write_bytes(img_bytes)
|
|
180
|
+
lf.write(f"{img_name}\t{text}\n")
|
|
181
|
+
if build_vocab:
|
|
182
|
+
vocab_chars.update(text)
|
|
183
|
+
|
|
184
|
+
if n == 0:
|
|
185
|
+
merged_labels.unlink(missing_ok=True)
|
|
186
|
+
shutil.rmtree(merged_images, ignore_errors=True)
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
counts[split] = n
|
|
190
|
+
|
|
191
|
+
# Pack to LMDB
|
|
192
|
+
lmdb_out = out_split / "lmdb"
|
|
193
|
+
lmdb_out.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
if verbose:
|
|
195
|
+
print(f" Packing LMDB for {split} ({n} samples)...")
|
|
196
|
+
pack_lmdb(
|
|
197
|
+
labels_file=str(merged_labels),
|
|
198
|
+
images_dir=str(merged_images),
|
|
199
|
+
out_dir=str(lmdb_out),
|
|
200
|
+
jpeg_quality=jpeg_quality,
|
|
201
|
+
map_size_gb=map_size_gb,
|
|
202
|
+
verbose=verbose,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if not keep_raw:
|
|
206
|
+
shutil.rmtree(merged_images)
|
|
207
|
+
|
|
208
|
+
if build_vocab and vocab_chars:
|
|
209
|
+
vp = Path(vocab_path).expanduser().resolve() if vocab_path else out_root / "vocab.json"
|
|
210
|
+
if verbose:
|
|
211
|
+
print(f" Writing merged vocab ({len(vocab_chars)} unique characters) -> {vp}")
|
|
212
|
+
_write_vocab(vocab_chars, vp)
|
|
213
|
+
|
|
214
|
+
return counts
|
khocr_gen/combine_cmd.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""`khocr-gen combine` - merge multiple generated datasets into one."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
import argparse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(args: argparse.Namespace) -> int:
|
|
14
|
+
"""Entry point called by cli.py."""
|
|
15
|
+
from .combine import combine_datasets
|
|
16
|
+
|
|
17
|
+
input_dirs: list[Path] = [Path(d).expanduser().resolve() for d in args.datasets]
|
|
18
|
+
output_dir = Path(args.output).expanduser().resolve()
|
|
19
|
+
|
|
20
|
+
for d in input_dirs:
|
|
21
|
+
if not d.exists():
|
|
22
|
+
print(f"Dataset not found: {d}", file=sys.stderr)
|
|
23
|
+
return 2
|
|
24
|
+
|
|
25
|
+
if output_dir.exists() and any(output_dir.iterdir()):
|
|
26
|
+
if not args.overwrite:
|
|
27
|
+
print(f"Output directory already exists. Re-run with --overwrite: {output_dir}")
|
|
28
|
+
return 1
|
|
29
|
+
import shutil
|
|
30
|
+
|
|
31
|
+
shutil.rmtree(output_dir)
|
|
32
|
+
|
|
33
|
+
print(f"\nCombining {len(input_dirs)} datasets:")
|
|
34
|
+
for d in input_dirs:
|
|
35
|
+
print(f" {d}")
|
|
36
|
+
print(f" -> {output_dir}")
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
counts = combine_datasets(
|
|
40
|
+
input_dirs=[str(d) for d in input_dirs],
|
|
41
|
+
output_dir=output_dir,
|
|
42
|
+
keep_raw=getattr(args, "keep_raw", False),
|
|
43
|
+
jpeg_quality=args.jpeg_quality,
|
|
44
|
+
map_size_gb=args.map_size_gb,
|
|
45
|
+
build_vocab=not getattr(args, "skip_vocab", False),
|
|
46
|
+
vocab_path=getattr(args, "vocab", None) or None,
|
|
47
|
+
verbose=args.verbose,
|
|
48
|
+
)
|
|
49
|
+
except FileNotFoundError as exc:
|
|
50
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
51
|
+
return 2
|
|
52
|
+
|
|
53
|
+
if not counts:
|
|
54
|
+
print("Nothing to combine, no dataset has a train, val, or test split.")
|
|
55
|
+
return 1
|
|
56
|
+
|
|
57
|
+
for split, n in counts.items():
|
|
58
|
+
print(f" {split}: wrote {n} samples to {output_dir / split / 'lmdb'}")
|
|
59
|
+
|
|
60
|
+
if not getattr(args, "skip_vocab", False):
|
|
61
|
+
vocab_out = (
|
|
62
|
+
Path(args.vocab).expanduser().resolve() if args.vocab else output_dir / "vocab.json"
|
|
63
|
+
)
|
|
64
|
+
if vocab_out.exists():
|
|
65
|
+
print(f" vocab: wrote merged vocab.json to {vocab_out}")
|
|
66
|
+
|
|
67
|
+
print(f"\nCombine complete. Dataset -> {output_dir}")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def add_args(parser: argparse.ArgumentParser) -> None:
|
|
72
|
+
"""Register all combine sub-command arguments onto *parser*."""
|
|
73
|
+
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"-c",
|
|
76
|
+
"--config",
|
|
77
|
+
default=None,
|
|
78
|
+
metavar="FILE",
|
|
79
|
+
help="YAML config file. Values override argparse defaults; CLI flags override YAML.",
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"datasets",
|
|
83
|
+
nargs="+",
|
|
84
|
+
metavar="DATASET",
|
|
85
|
+
help="One or more dataset directories to merge (data_1 data_2 ... data_n)",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--output",
|
|
89
|
+
"-o",
|
|
90
|
+
default="data_combined",
|
|
91
|
+
metavar="DIR",
|
|
92
|
+
help="Output directory for the merged dataset",
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--overwrite",
|
|
96
|
+
action="store_true",
|
|
97
|
+
help="Overwrite existing output directory without prompting",
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--keep-raw", action="store_true", help="Keep raw image files on disk after LMDB packing"
|
|
101
|
+
)
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
"--jpeg-quality",
|
|
104
|
+
type=int,
|
|
105
|
+
default=90,
|
|
106
|
+
metavar="N",
|
|
107
|
+
help="JPEG compression quality for LMDB images",
|
|
108
|
+
)
|
|
109
|
+
parser.add_argument(
|
|
110
|
+
"--map-size-gb", type=int, default=256, metavar="N", help="LMDB map size in GiB"
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument(
|
|
113
|
+
"--vocab",
|
|
114
|
+
default=None,
|
|
115
|
+
metavar="FILE",
|
|
116
|
+
help="Path to write merged vocab.json; defaults to OUTPUT/vocab.json",
|
|
117
|
+
)
|
|
118
|
+
parser.add_argument(
|
|
119
|
+
"--skip-vocab",
|
|
120
|
+
action="store_true",
|
|
121
|
+
help="Do not build a merged vocab.json",
|
|
122
|
+
)
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
"--verbose", action="store_true", help="Print merge and LMDB packing progress"
|
|
125
|
+
)
|