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
khocr_gen/lmdb_pack.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Pack a labels.txt + image directory into an LMDB database.
|
|
2
|
+
|
|
3
|
+
Key format:
|
|
4
|
+
b"num-samples" -> ASCII count of samples written
|
|
5
|
+
b"image-{i:09d}" -> raw JPEG bytes for sample *i* (1-indexed)
|
|
6
|
+
b"label-{i:09d}" -> UTF-8 text label for sample *i* (1-indexed)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from tqdm import tqdm
|
|
15
|
+
|
|
16
|
+
_DEFAULT_COMMIT_EVERY_BYTES = 256 * 1024 * 1024 # 256 MiB
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _count_non_empty_lines(path: Path) -> int:
|
|
20
|
+
count = 0
|
|
21
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
22
|
+
for line in fh:
|
|
23
|
+
if line.strip():
|
|
24
|
+
count += 1
|
|
25
|
+
return count
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def pack_lmdb(
|
|
29
|
+
labels_file: str | Path,
|
|
30
|
+
images_dir: str | Path,
|
|
31
|
+
out_dir: str | Path,
|
|
32
|
+
*,
|
|
33
|
+
jpeg_quality: int = 90,
|
|
34
|
+
map_size_gb: int = 256,
|
|
35
|
+
verbose: bool = False,
|
|
36
|
+
commit_every: int = 5_000,
|
|
37
|
+
commit_every_bytes: int = _DEFAULT_COMMIT_EVERY_BYTES,
|
|
38
|
+
) -> int:
|
|
39
|
+
"""Write an LMDB database from *labels_file* and *images_dir*.
|
|
40
|
+
|
|
41
|
+
Pending encoded images are flushed to LMDB when either *commit_every*
|
|
42
|
+
samples or *commit_every_bytes* of encoded image data have accumulated,
|
|
43
|
+
whichever comes first -- a count-only bound lets a run of large images
|
|
44
|
+
balloon the transient buffer to gigabytes before a single flush.
|
|
45
|
+
|
|
46
|
+
Returns the number of samples written.
|
|
47
|
+
"""
|
|
48
|
+
import cv2
|
|
49
|
+
import lmdb
|
|
50
|
+
|
|
51
|
+
labels_path = Path(labels_file).expanduser().resolve()
|
|
52
|
+
images_path = Path(images_dir).expanduser().resolve()
|
|
53
|
+
out_path = Path(out_dir).expanduser().resolve()
|
|
54
|
+
|
|
55
|
+
if not labels_path.is_file():
|
|
56
|
+
raise FileNotFoundError(f"Labels file not found: {labels_path}")
|
|
57
|
+
if not images_path.is_dir():
|
|
58
|
+
raise FileNotFoundError(f"Images directory not found: {images_path}")
|
|
59
|
+
|
|
60
|
+
out_path.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
|
|
62
|
+
map_size = map_size_gb * (1024**3)
|
|
63
|
+
env = lmdb.open(str(out_path), map_size=map_size)
|
|
64
|
+
|
|
65
|
+
samples_written = 0
|
|
66
|
+
errors = 0
|
|
67
|
+
total_lines = _count_non_empty_lines(labels_path)
|
|
68
|
+
|
|
69
|
+
# Batch writes to avoid holding the LMDB write lock for too long
|
|
70
|
+
pending_pairs: list[tuple[bytes, bytes, bytes, bytes]] = []
|
|
71
|
+
pending_bytes = 0
|
|
72
|
+
|
|
73
|
+
def _flush(txn, pairs: list) -> None:
|
|
74
|
+
for img_key, img_bytes, lbl_key, lbl_bytes in pairs:
|
|
75
|
+
txn.put(img_key, img_bytes)
|
|
76
|
+
txn.put(lbl_key, lbl_bytes)
|
|
77
|
+
pairs.clear()
|
|
78
|
+
|
|
79
|
+
with labels_path.open("r", encoding="utf-8") as fh:
|
|
80
|
+
lines = (ln.rstrip("\n") for ln in fh if ln.strip())
|
|
81
|
+
|
|
82
|
+
for line in tqdm(lines, total=total_lines, desc=" Packing LMDB", unit="img"):
|
|
83
|
+
parts = line.split("\t", 1)
|
|
84
|
+
if len(parts) < 2:
|
|
85
|
+
continue
|
|
86
|
+
img_name, label = parts[0].strip(), parts[1]
|
|
87
|
+
img_path = images_path / img_name
|
|
88
|
+
if not img_path.exists():
|
|
89
|
+
# Try next to the labels file
|
|
90
|
+
img_path = labels_path.parent / img_name
|
|
91
|
+
if not img_path.exists():
|
|
92
|
+
if verbose:
|
|
93
|
+
print(f" [SKIP] image not found: {img_name}", file=sys.stderr)
|
|
94
|
+
errors += 1
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
img = cv2.imread(str(img_path), cv2.IMREAD_UNCHANGED)
|
|
98
|
+
if img is None:
|
|
99
|
+
if verbose:
|
|
100
|
+
print(f" [SKIP] cv2.imread failed: {img_name}", file=sys.stderr)
|
|
101
|
+
errors += 1
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
ok, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, jpeg_quality])
|
|
105
|
+
if not ok:
|
|
106
|
+
errors += 1
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
samples_written += 1
|
|
110
|
+
idx = samples_written
|
|
111
|
+
img_key = f"image-{idx:09d}".encode()
|
|
112
|
+
lbl_key = f"label-{idx:09d}".encode()
|
|
113
|
+
img_bytes = bytes(buf)
|
|
114
|
+
pending_pairs.append((img_key, img_bytes, lbl_key, label.encode("utf-8")))
|
|
115
|
+
pending_bytes += len(img_bytes)
|
|
116
|
+
|
|
117
|
+
if len(pending_pairs) >= commit_every or pending_bytes >= commit_every_bytes:
|
|
118
|
+
with env.begin(write=True) as txn:
|
|
119
|
+
_flush(txn, pending_pairs)
|
|
120
|
+
pending_bytes = 0
|
|
121
|
+
|
|
122
|
+
# Flush remaining and write final count
|
|
123
|
+
with env.begin(write=True) as txn:
|
|
124
|
+
_flush(txn, pending_pairs)
|
|
125
|
+
txn.put(b"num-samples", str(samples_written).encode())
|
|
126
|
+
|
|
127
|
+
env.close()
|
|
128
|
+
|
|
129
|
+
if verbose and errors:
|
|
130
|
+
print(f" Skipped {errors} samples due to missing/corrupt images", file=sys.stderr)
|
|
131
|
+
|
|
132
|
+
return samples_written
|
khocr_gen/logging.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Logging utilities for khocr-gen."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
_DEFAULT_LOG_FORMAT = "%(asctime)s | %(levelname)s | %(name)s | %(message)s"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def configure_logging(level: int = logging.INFO) -> None:
|
|
11
|
+
"""Configure package logging once using a consistent format."""
|
|
12
|
+
root_logger = logging.getLogger("khocr_gen")
|
|
13
|
+
if root_logger.handlers:
|
|
14
|
+
root_logger.setLevel(level)
|
|
15
|
+
return
|
|
16
|
+
|
|
17
|
+
handler = logging.StreamHandler()
|
|
18
|
+
handler.setFormatter(logging.Formatter(_DEFAULT_LOG_FORMAT))
|
|
19
|
+
root_logger.addHandler(handler)
|
|
20
|
+
root_logger.setLevel(level)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_logger(name: str) -> logging.Logger:
|
|
24
|
+
"""Return a child logger rooted at khocr_gen."""
|
|
25
|
+
return logging.getLogger(f"khocr_gen.{name}")
|
khocr_gen/normalizer.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Khmer text normalization via khmernormalizer.
|
|
2
|
+
|
|
3
|
+
Wraps https://github.com/seanghay/khmernormalizer with configurable parameters exposed to the CLI and config file,
|
|
4
|
+
so users can tune normalization for their corpus.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import unicodedata
|
|
10
|
+
from dataclasses import asdict, dataclass, fields
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
_HAS_KHMER_NORMALIZER = False
|
|
14
|
+
_normalize_fn: Any = None
|
|
15
|
+
_TextFixerConfig: Any = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _ensure_backend() -> tuple[Any, Any]:
|
|
19
|
+
global _HAS_KHMER_NORMALIZER, _normalize_fn, _TextFixerConfig
|
|
20
|
+
if _HAS_KHMER_NORMALIZER:
|
|
21
|
+
return _normalize_fn, _TextFixerConfig
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from khmernormalizer import TextFixerConfig as _TFC
|
|
25
|
+
from khmernormalizer import normalize as _nf
|
|
26
|
+
|
|
27
|
+
_normalize_fn = _nf
|
|
28
|
+
_TextFixerConfig = _TFC
|
|
29
|
+
_HAS_KHMER_NORMALIZER = True
|
|
30
|
+
except ImportError as exc:
|
|
31
|
+
raise ImportError(
|
|
32
|
+
"khmernormalizer is required for text normalization. "
|
|
33
|
+
"Install with: pip install khmernormalizer"
|
|
34
|
+
) from exc
|
|
35
|
+
return _normalize_fn, _TextFixerConfig
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class NormalizerConfig:
|
|
40
|
+
"""Parameters for Khmer text normalization.
|
|
41
|
+
|
|
42
|
+
All parameters map directly to `khmernormalizer.normalize` kwargs.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
policy: str = "legacy-khmer" # legacy-khmer | nfc | none
|
|
46
|
+
unicode_norm: str = ""
|
|
47
|
+
emoji_replacement: str | None = ""
|
|
48
|
+
url_replacement: str | None = ""
|
|
49
|
+
remove_zwsp: bool = True
|
|
50
|
+
|
|
51
|
+
# ftfy TextFixerConfig fields
|
|
52
|
+
unescape_html: str = "auto"
|
|
53
|
+
remove_terminal_escapes: bool = True
|
|
54
|
+
fix_encoding: bool = True
|
|
55
|
+
restore_byte_a0: bool = True
|
|
56
|
+
replace_lossy_sequences: bool = True
|
|
57
|
+
decode_inconsistent_utf8: bool = True
|
|
58
|
+
fix_c1_controls: bool = True
|
|
59
|
+
fix_latin_ligatures: bool = True
|
|
60
|
+
fix_character_width: bool = True
|
|
61
|
+
uncurl_quotes: bool = True
|
|
62
|
+
fix_line_breaks: bool = True
|
|
63
|
+
fix_surrogates: bool = True
|
|
64
|
+
remove_control_chars: bool = True
|
|
65
|
+
normalization: str = "NFC"
|
|
66
|
+
max_decode_length: int = 1_000_000
|
|
67
|
+
explain: bool = False
|
|
68
|
+
|
|
69
|
+
# Shorthand for "apply no normalization at all"
|
|
70
|
+
passthrough: bool = False
|
|
71
|
+
|
|
72
|
+
def __post_init__(self) -> None:
|
|
73
|
+
if self.policy not in {"legacy-khmer", "nfc", "none"}:
|
|
74
|
+
raise ValueError("normalization policy must be legacy-khmer, nfc, or none")
|
|
75
|
+
|
|
76
|
+
def to_fix_text_config(self) -> Any:
|
|
77
|
+
"""Build a `TextFixerConfig` from this config."""
|
|
78
|
+
_, TFC = _ensure_backend()
|
|
79
|
+
return TFC(
|
|
80
|
+
unescape_html=self.unescape_html,
|
|
81
|
+
remove_terminal_escapes=self.remove_terminal_escapes,
|
|
82
|
+
fix_encoding=self.fix_encoding,
|
|
83
|
+
restore_byte_a0=self.restore_byte_a0,
|
|
84
|
+
replace_lossy_sequences=self.replace_lossy_sequences,
|
|
85
|
+
decode_inconsistent_utf8=self.decode_inconsistent_utf8,
|
|
86
|
+
fix_c1_controls=self.fix_c1_controls,
|
|
87
|
+
fix_latin_ligatures=self.fix_latin_ligatures,
|
|
88
|
+
fix_character_width=self.fix_character_width,
|
|
89
|
+
uncurl_quotes=self.uncurl_quotes,
|
|
90
|
+
fix_line_breaks=self.fix_line_breaks,
|
|
91
|
+
fix_surrogates=self.fix_surrogates,
|
|
92
|
+
remove_control_chars=self.remove_control_chars,
|
|
93
|
+
normalization=self.normalization,
|
|
94
|
+
max_decode_length=self.max_decode_length,
|
|
95
|
+
explain=self.explain,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def add_args(parser: Any, *, prefix: str = "--norm-") -> None:
|
|
100
|
+
"""Register normalizer flags on an argparse parser.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
parser: An `argparse.ArgumentParser` or argument group.
|
|
104
|
+
prefix: Prefix for flag names (default ``--norm-``).
|
|
105
|
+
"""
|
|
106
|
+
g = parser.add_argument_group("Text normalization")
|
|
107
|
+
g.add_argument(
|
|
108
|
+
f"{prefix}policy",
|
|
109
|
+
choices=["legacy-khmer", "nfc", "none"],
|
|
110
|
+
default=None,
|
|
111
|
+
help="Normalization policy (default: legacy-khmer, or nfc with --multilingual)",
|
|
112
|
+
)
|
|
113
|
+
g.add_argument(
|
|
114
|
+
f"{prefix}unicode-norm",
|
|
115
|
+
default="",
|
|
116
|
+
choices=["NFKC", "NFC", "NFD", "NFKD", "none"],
|
|
117
|
+
help="Unicode normalization form (default: none). khmernormalizer already normalizes internally.",
|
|
118
|
+
)
|
|
119
|
+
g.add_argument(
|
|
120
|
+
f"{prefix}emoji-replacement",
|
|
121
|
+
default="",
|
|
122
|
+
metavar="STR",
|
|
123
|
+
help="Replacement string for emoji characters (default: '' = remove)",
|
|
124
|
+
)
|
|
125
|
+
g.add_argument(
|
|
126
|
+
f"{prefix}url-replacement",
|
|
127
|
+
default="",
|
|
128
|
+
metavar="STR",
|
|
129
|
+
help="Replacement string for URLs (default: '' = remove)",
|
|
130
|
+
)
|
|
131
|
+
g.add_argument(
|
|
132
|
+
f"{prefix}no-remove-zwsp",
|
|
133
|
+
action="store_true",
|
|
134
|
+
help="Keep zero-width spaces instead of removing them",
|
|
135
|
+
)
|
|
136
|
+
g.add_argument(
|
|
137
|
+
f"{prefix}no-fix-encoding",
|
|
138
|
+
action="store_true",
|
|
139
|
+
help="Disable ftfy encoding fixes",
|
|
140
|
+
)
|
|
141
|
+
g.add_argument(
|
|
142
|
+
f"{prefix}no-uncurl-quotes",
|
|
143
|
+
action="store_true",
|
|
144
|
+
help="Disable quote uncurling",
|
|
145
|
+
)
|
|
146
|
+
g.add_argument(
|
|
147
|
+
f"{prefix}no-fix-line-breaks",
|
|
148
|
+
action="store_true",
|
|
149
|
+
help="Disable line break normalization",
|
|
150
|
+
)
|
|
151
|
+
g.add_argument(
|
|
152
|
+
f"{prefix}passthrough",
|
|
153
|
+
action="store_true",
|
|
154
|
+
help="Skip all normalization (text passes through unchanged)",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def from_args(cls, args: Any, *, prefix: str = "norm_") -> NormalizerConfig:
|
|
159
|
+
"""Build a `NormalizerConfig` from parsed CLI args."""
|
|
160
|
+
|
|
161
|
+
def _get(name: str) -> Any:
|
|
162
|
+
return getattr(args, f"{prefix}{name}", None)
|
|
163
|
+
|
|
164
|
+
unicode_norm_val = _get("unicode_norm")
|
|
165
|
+
if unicode_norm_val and str(unicode_norm_val).lower() == "none":
|
|
166
|
+
unicode_norm_val = ""
|
|
167
|
+
|
|
168
|
+
return cls(
|
|
169
|
+
policy=_get("policy")
|
|
170
|
+
or ("nfc" if getattr(args, "multilingual", False) else "legacy-khmer"),
|
|
171
|
+
unicode_norm=unicode_norm_val if unicode_norm_val else "",
|
|
172
|
+
emoji_replacement=_get("emoji_replacement"),
|
|
173
|
+
url_replacement=_get("url_replacement"),
|
|
174
|
+
remove_zwsp=not bool(_get("no_remove_zwsp")),
|
|
175
|
+
fix_encoding=not bool(_get("no_fix_encoding")),
|
|
176
|
+
uncurl_quotes=not bool(_get("no_uncurl_quotes")),
|
|
177
|
+
fix_line_breaks=not bool(_get("no_fix_line_breaks")),
|
|
178
|
+
passthrough=bool(_get("passthrough")),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def to_dict(self) -> dict[str, Any]:
|
|
182
|
+
"""Serialise to a plain dict for IPC."""
|
|
183
|
+
return asdict(self)
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def from_dict(cls, d: dict[str, Any]) -> NormalizerConfig:
|
|
187
|
+
"""Reconstruct from a dict."""
|
|
188
|
+
known = {f.name for f in fields(cls)}
|
|
189
|
+
return cls(**{key: value for key, value in d.items() if key in known})
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def normalize(text: str, cfg: NormalizerConfig | None = None) -> str:
|
|
193
|
+
"""Normalize Khmer text.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
text: Raw input text.
|
|
197
|
+
cfg: Normalizer config. If *None*, a default `NormalizerConfig` is used.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Normalized text, or the original text if `cfg.passthrough` is `True`.
|
|
201
|
+
"""
|
|
202
|
+
if cfg is None:
|
|
203
|
+
cfg = NormalizerConfig()
|
|
204
|
+
|
|
205
|
+
if cfg.passthrough or cfg.policy == "none":
|
|
206
|
+
return text
|
|
207
|
+
if cfg.policy == "nfc":
|
|
208
|
+
return unicodedata.normalize("NFC", text)
|
|
209
|
+
|
|
210
|
+
nf, _ = _ensure_backend()
|
|
211
|
+
|
|
212
|
+
kwargs: dict[str, Any] = {}
|
|
213
|
+
if cfg.unicode_norm:
|
|
214
|
+
kwargs["unicode_norm"] = cfg.unicode_norm
|
|
215
|
+
|
|
216
|
+
kwargs["emoji_replacement"] = cfg.emoji_replacement
|
|
217
|
+
kwargs["url_replacement"] = cfg.url_replacement
|
|
218
|
+
kwargs["remove_zwsp"] = cfg.remove_zwsp
|
|
219
|
+
kwargs["fix_text_config"] = cfg.to_fix_text_config()
|
|
220
|
+
|
|
221
|
+
return nf(text, **kwargs)
|