khocr-gen 0.1.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 +8 -0
- khocr_gen/_pil_compat.py +25 -0
- khocr_gen/_rust_accel.py +296 -0
- khocr_gen/augmentation.py +753 -0
- khocr_gen/cli.py +208 -0
- khocr_gen/combine.py +179 -0
- khocr_gen/combine_cmd.py +98 -0
- khocr_gen/config.py +693 -0
- khocr_gen/config_loader.py +149 -0
- khocr_gen/corpus.py +232 -0
- khocr_gen/data_generator.py +613 -0
- khocr_gen/errors.py +33 -0
- khocr_gen/fonts.py +181 -0
- khocr_gen/generate.py +187 -0
- khocr_gen/lmdb_pack.py +111 -0
- khocr_gen/logging.py +25 -0
- khocr_gen/normalizer.py +217 -0
- khocr_gen/parallel.py +206 -0
- khocr_gen/rendering.py +539 -0
- khocr_gen/verify.py +288 -0
- khocr_gen/viewer.py +168 -0
- khocr_gen-0.1.0.dist-info/METADATA +213 -0
- khocr_gen-0.1.0.dist-info/RECORD +26 -0
- khocr_gen-0.1.0.dist-info/WHEEL +4 -0
- khocr_gen-0.1.0.dist-info/entry_points.txt +2 -0
- khocr_gen-0.1.0.dist-info/licenses/LICENSE +21 -0
khocr_gen/__init__.py
ADDED
khocr_gen/_pil_compat.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""PIL compatibility shims for resampling across Pillow versions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from PIL import Image
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _resolve_resample(name: str) -> Any:
|
|
11
|
+
resampling = getattr(Image, "Resampling", None)
|
|
12
|
+
if resampling is not None:
|
|
13
|
+
value = getattr(resampling, name, None)
|
|
14
|
+
if value is not None:
|
|
15
|
+
return value
|
|
16
|
+
|
|
17
|
+
legacy_value = getattr(Image, name, None)
|
|
18
|
+
if legacy_value is not None:
|
|
19
|
+
return legacy_value
|
|
20
|
+
|
|
21
|
+
raise AttributeError(f"PIL.Image does not define resample mode '{name}'")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
RESAMPLE_BILINEAR: Any = _resolve_resample("BILINEAR")
|
|
25
|
+
RESAMPLE_BICUBIC: Any = _resolve_resample("BICUBIC")
|
khocr_gen/_rust_accel.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Rust acceleration backend for khocr-gen.
|
|
2
|
+
|
|
3
|
+
This module wraps the native `_rust_accel` extension (built via maturin/PyO3).
|
|
4
|
+
If the native extension is not available, pure-Python fallbacks from the augmentation, fonts, and rendering modules are used instead.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from khocr_gen._rust_accel import (
|
|
9
|
+
apply_sauvola, apply_blur,
|
|
10
|
+
RustFontManager, FontFace, split_text_spans,
|
|
11
|
+
estimate_bg, image_is_blank, write_image,
|
|
12
|
+
HAS_RUST_ACCEL,
|
|
13
|
+
)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
|
|
26
|
+
_LOGGER = logging.getLogger("khocr_gen._rust_accel")
|
|
27
|
+
|
|
28
|
+
# ── Try to load the native Rust extension ──────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
_native: Any = None
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
import _rust_accel as _native
|
|
34
|
+
|
|
35
|
+
HAS_RUST_ACCEL = True
|
|
36
|
+
except ImportError:
|
|
37
|
+
HAS_RUST_ACCEL = False
|
|
38
|
+
_LOGGER.info("Rust extension not available, using pure-Python fallbacks.")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── Re-export flag ─────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _log_available() -> None:
|
|
45
|
+
if HAS_RUST_ACCEL:
|
|
46
|
+
try:
|
|
47
|
+
ver: str = getattr(_native, "__version__", "unknown")
|
|
48
|
+
except Exception:
|
|
49
|
+
ver = "unknown"
|
|
50
|
+
_LOGGER.info("Rust acceleration enabled (native _rust_accel v%s)", ver)
|
|
51
|
+
else:
|
|
52
|
+
_LOGGER.info("Rust acceleration NOT available.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ══════════════════════════════════════════════════════════════════════════════════
|
|
56
|
+
# Augmentation functions
|
|
57
|
+
# ══════════════════════════════════════════════════════════════════════════════════
|
|
58
|
+
|
|
59
|
+
# Each entry: (function_name, has_rgb_variant)
|
|
60
|
+
_AUG_FUNCTIONS: list[tuple[str, bool]] = [
|
|
61
|
+
("apply_sauvola", False),
|
|
62
|
+
("apply_geo_warp", False),
|
|
63
|
+
("apply_vertical_crop", False),
|
|
64
|
+
("apply_blur", False),
|
|
65
|
+
("apply_salt_pepper", False),
|
|
66
|
+
("apply_background_texture", False),
|
|
67
|
+
("apply_jpeg_compression", False),
|
|
68
|
+
("apply_rotation", False),
|
|
69
|
+
("apply_lowdpi", False),
|
|
70
|
+
("apply_oversample", False),
|
|
71
|
+
("apply_perspective", False),
|
|
72
|
+
("apply_elastic", False),
|
|
73
|
+
("apply_random_crop", False),
|
|
74
|
+
("apply_online_blur", False),
|
|
75
|
+
("apply_online_noise", False),
|
|
76
|
+
("apply_reverse", True),
|
|
77
|
+
("apply_brightness_contrast", True),
|
|
78
|
+
("apply_pixelation", False),
|
|
79
|
+
("apply_gradient_illumination", False),
|
|
80
|
+
("apply_morphological", False),
|
|
81
|
+
("apply_anisotropic_dilation", False),
|
|
82
|
+
("apply_hsv", True), # RGB-only in Rust
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _get_rust_aug_fn(name: str) -> Callable[..., Any] | None:
|
|
87
|
+
"""Return the native Rust augmentation function by name, or None."""
|
|
88
|
+
if not HAS_RUST_ACCEL:
|
|
89
|
+
return None
|
|
90
|
+
assert _native is not None
|
|
91
|
+
return getattr(_native, f"{name}_rust", None)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _make_aug_fallback(name: str) -> Callable[..., Any]:
|
|
95
|
+
"""Create a fallback that delegates to the Python augmentation module."""
|
|
96
|
+
|
|
97
|
+
def _fallback(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
98
|
+
from khocr_gen.augmentation import AUG_METHODS
|
|
99
|
+
|
|
100
|
+
fn = AUG_METHODS.get(name)
|
|
101
|
+
if fn is None:
|
|
102
|
+
return img
|
|
103
|
+
return fn(img, intensity, **kwargs)
|
|
104
|
+
|
|
105
|
+
_fallback.__name__ = name
|
|
106
|
+
_fallback.__qualname__ = name
|
|
107
|
+
return _fallback
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _export_aug_functions() -> None:
|
|
111
|
+
"""Populate the module globals with augmentation functions.
|
|
112
|
+
|
|
113
|
+
For each augmentation method, the Rust variant is preferred when available;
|
|
114
|
+
otherwise the pure-Python fallback is used.
|
|
115
|
+
|
|
116
|
+
Grayscale-only methods dispatch directly.
|
|
117
|
+
Methods with an RGB variant (`brightness_contrast`, `hsv`) are wrapped to handle both grayscale and RGB input arrays.
|
|
118
|
+
"""
|
|
119
|
+
g = globals()
|
|
120
|
+
|
|
121
|
+
for name, has_rgb in _AUG_FUNCTIONS:
|
|
122
|
+
rust_fn = _get_rust_aug_fn(name)
|
|
123
|
+
rust_rgb_fn: Callable[..., Any] | None = (
|
|
124
|
+
_get_rust_aug_fn(f"{name}_rgb") if has_rgb else None
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if has_rgb and HAS_RUST_ACCEL and (rust_fn is not None or rust_rgb_fn is not None):
|
|
128
|
+
# Wrap to auto-detect channel count and dispatch.
|
|
129
|
+
# Rust RGB functions return flat (h, w*3) 2D arrays; reshape to (h, w, 3).
|
|
130
|
+
_gray_fn = rust_fn
|
|
131
|
+
_rgb_fn = rust_rgb_fn
|
|
132
|
+
|
|
133
|
+
# Capture the fallback factory by value so we can safely delete it later
|
|
134
|
+
_fallback_factory = _make_aug_fallback
|
|
135
|
+
|
|
136
|
+
def _make_rgb_wrapper(
|
|
137
|
+
gray_fn: Callable[..., Any] | None,
|
|
138
|
+
rgb_fn: Callable[..., Any] | None,
|
|
139
|
+
fallback_name: str,
|
|
140
|
+
fallback_factory: Callable[..., Any],
|
|
141
|
+
) -> Callable[..., Any]:
|
|
142
|
+
def _wrapper(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
143
|
+
if img.ndim == 3 and img.shape[2] == 3:
|
|
144
|
+
if rgb_fn is not None:
|
|
145
|
+
h, w = img.shape[:2]
|
|
146
|
+
result = rgb_fn(img, intensity)
|
|
147
|
+
# Rust RGB fns return flat (h, w*3); reshape to (h, w, 3)
|
|
148
|
+
if result.ndim == 2 and result.shape[1] == w * 3:
|
|
149
|
+
return result.reshape(h, w, 3)
|
|
150
|
+
return result
|
|
151
|
+
if gray_fn is not None:
|
|
152
|
+
channels = [
|
|
153
|
+
gray_fn(np.ascontiguousarray(img[:, :, c]), intensity)
|
|
154
|
+
for c in range(3)
|
|
155
|
+
]
|
|
156
|
+
return np.stack(channels, axis=-1)
|
|
157
|
+
return fallback_factory(fallback_name)(img, intensity)
|
|
158
|
+
if gray_fn is not None:
|
|
159
|
+
return gray_fn(img, intensity)
|
|
160
|
+
return fallback_factory(fallback_name)(img, intensity)
|
|
161
|
+
|
|
162
|
+
return _wrapper
|
|
163
|
+
|
|
164
|
+
g[name] = _make_rgb_wrapper(_gray_fn, _rgb_fn, name, _fallback_factory)
|
|
165
|
+
elif rust_fn is not None:
|
|
166
|
+
g[name] = rust_fn
|
|
167
|
+
else:
|
|
168
|
+
g[name] = _make_aug_fallback(name)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
_export_aug_functions()
|
|
172
|
+
|
|
173
|
+
# Clean up the helpers so they don't pollute the module namespace.
|
|
174
|
+
# _make_aug_fallback and _AUG_FUNCTIONS are retained for closures referencing them.
|
|
175
|
+
del _export_aug_functions
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ══════════════════════════════════════════════════════════════════════════════════
|
|
179
|
+
# Font management
|
|
180
|
+
# ══════════════════════════════════════════════════════════════════════════════════
|
|
181
|
+
|
|
182
|
+
if HAS_RUST_ACCEL:
|
|
183
|
+
assert _native is not None
|
|
184
|
+
RustFontManager = _native.RustFontManager # type: ignore[misc]
|
|
185
|
+
else:
|
|
186
|
+
# Fallback: thin wrapper that delegates to the pure-Python FontManager
|
|
187
|
+
class RustFontManager: # type: ignore[no-redef]
|
|
188
|
+
"""Thin wrapper around the pure-Python `FontManager` for drop-in use."""
|
|
189
|
+
|
|
190
|
+
def __init__(self) -> None:
|
|
191
|
+
from khocr_gen.fonts import FontManager
|
|
192
|
+
|
|
193
|
+
self._py = FontManager()
|
|
194
|
+
|
|
195
|
+
def load(self, fonts_dir: str) -> None:
|
|
196
|
+
# The Python FontManager loads in __init__, so this is a no-op
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
def glyph_supported(self, font_path: str, ch: str) -> bool:
|
|
200
|
+
font = self._py.get_font_by_path_and_size(font_path, 28)
|
|
201
|
+
if font is None:
|
|
202
|
+
return True
|
|
203
|
+
try:
|
|
204
|
+
mask = font.getmask(ch)
|
|
205
|
+
return mask is not None and mask.getbbox() is not None
|
|
206
|
+
except Exception:
|
|
207
|
+
return True
|
|
208
|
+
|
|
209
|
+
def text_supported(self, font_path: str, text: str) -> bool:
|
|
210
|
+
for c in text:
|
|
211
|
+
if c.isspace() or ord(c) < 32:
|
|
212
|
+
continue
|
|
213
|
+
if not self.glyph_supported(font_path, c):
|
|
214
|
+
return False
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
def text_has_khmer(self, text: str) -> bool:
|
|
218
|
+
return self._py._text_has_khmer(text)
|
|
219
|
+
|
|
220
|
+
def __len__(self) -> int:
|
|
221
|
+
return len(self._py.all_fonts)
|
|
222
|
+
|
|
223
|
+
def __repr__(self) -> str:
|
|
224
|
+
return (
|
|
225
|
+
f"RustFontManager(khmer={len(self._py.khmer_fonts)}, "
|
|
226
|
+
f"english={len(self._py.english_fonts)}, "
|
|
227
|
+
f"total={len(self._py.all_fonts)})"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ══════════════════════════════════════════════════════════════════════════════════
|
|
232
|
+
# Text span splitting
|
|
233
|
+
# ══════════════════════════════════════════════════════════════════════════════════
|
|
234
|
+
|
|
235
|
+
if HAS_RUST_ACCEL:
|
|
236
|
+
assert _native is not None
|
|
237
|
+
FontFace = _native.FontFace # type: ignore[assignment]
|
|
238
|
+
else:
|
|
239
|
+
FontFace = None # type: ignore[assignment]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if HAS_RUST_ACCEL:
|
|
243
|
+
assert _native is not None
|
|
244
|
+
split_text_spans = _native.split_text_spans # type: ignore[assignment]
|
|
245
|
+
else:
|
|
246
|
+
|
|
247
|
+
def split_text_spans(text: str) -> list[tuple[str, str]]: # type: ignore[no-redef]
|
|
248
|
+
"""Split text into contiguous script spans (pure-Python fallback)."""
|
|
249
|
+
from khocr_gen.rendering import ImageRenderer
|
|
250
|
+
|
|
251
|
+
return ImageRenderer._split_text_into_spans(text)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ══════════════════════════════════════════════════════════════════════════════════
|
|
255
|
+
# Utilities
|
|
256
|
+
# ══════════════════════════════════════════════════════════════════════════════════
|
|
257
|
+
|
|
258
|
+
if HAS_RUST_ACCEL:
|
|
259
|
+
assert _native is not None
|
|
260
|
+
estimate_bg = _native.estimate_bg_rust # type: ignore[assignment]
|
|
261
|
+
image_is_blank = _native.image_is_blank_rust # type: ignore[assignment]
|
|
262
|
+
write_image = _native.write_image_rust # type: ignore[assignment]
|
|
263
|
+
else:
|
|
264
|
+
|
|
265
|
+
def estimate_bg(img: np.ndarray) -> int: # type: ignore[no-redef]
|
|
266
|
+
"""Estimate background colour from image border (pure-Python fallback)."""
|
|
267
|
+
from khocr_gen.augmentation import _estimate_bg
|
|
268
|
+
|
|
269
|
+
return int(_estimate_bg(img))
|
|
270
|
+
|
|
271
|
+
def image_is_blank( # type: ignore[no-redef]
|
|
272
|
+
img: np.ndarray, threshold: int = 150, min_ratio: float = 0.002
|
|
273
|
+
) -> bool:
|
|
274
|
+
"""Check if image has too few dark pixels (pure-Python fallback)."""
|
|
275
|
+
if img is None or img.size == 0:
|
|
276
|
+
return True
|
|
277
|
+
dark_pixels = int(np.sum(img < threshold))
|
|
278
|
+
return (dark_pixels / img.size) < min_ratio
|
|
279
|
+
|
|
280
|
+
def write_image( # type: ignore[no-redef]
|
|
281
|
+
img: np.ndarray, path: str, fmt: str = "jpg", quality: int = 90
|
|
282
|
+
) -> bool:
|
|
283
|
+
"""Write a numpy image to disk (pure-Python fallback)."""
|
|
284
|
+
from pathlib import Path
|
|
285
|
+
|
|
286
|
+
from khocr_gen.augmentation import write_output_image as _write
|
|
287
|
+
|
|
288
|
+
return _write(Path(path), img, fmt=fmt, jpeg_quality=quality)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ══════════════════════════════════════════════════════════════════════════════════
|
|
292
|
+
# Module-level init
|
|
293
|
+
# ══════════════════════════════════════════════════════════════════════════════════
|
|
294
|
+
|
|
295
|
+
_log_available()
|
|
296
|
+
del _log_available
|