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 ADDED
@@ -0,0 +1,8 @@
1
+ """khocr-gen - Synthetic OCR training data generator for mixed Khmer/English text."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("khocr-gen")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0"
@@ -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")
@@ -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