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/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}")
@@ -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)